From 19a32b6546a60b8306e88b590018cc9ec3a798f2 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Sun, 7 Sep 2025 10:57:30 -0400 Subject: [PATCH 01/47] Account page refactoring - account header, account summary and account assets --- .env.example | 3 + package.json | 5 +- public/locales/en-US/translations.json | 87 ++- src/containers/Accounts/AccountAsset/api.ts | 45 ++ .../AccountAsset/assetTables/HeldIOUs.tsx | 247 +++++++++ .../AccountAsset/assetTables/HeldLPTokens.tsx | 250 +++++++++ .../AccountAsset/assetTables/HeldMPTs.tsx | 227 ++++++++ .../AccountAsset/assetTables/HeldNFTs.tsx | 46 ++ .../AccountAsset/assetTables/IssuedIOUs.tsx | 71 +++ .../AccountAsset/assetTables/IssuedMPTs.tsx | 151 ++++++ .../AccountAsset/assetTables/IssuedNFTs.tsx | 51 ++ .../AccountAsset/assetTables/NFTTable.tsx | 296 +++++++++++ .../Accounts/AccountAsset/index.tsx | 251 +++++++++ .../Accounts/AccountAsset/mockData.ts | 243 +++++++++ .../Accounts/AccountAsset/styles.scss | 183 +++++++ .../AccountAssetTab/AccountAssetTab.tsx | 2 - .../BalanceSelector/BalanceSelector.tsx | 41 -- .../BalanceSelector/BalanceSelectorItem.tsx | 32 -- .../BalanceSelector/balance-selector.scss | 32 -- .../Accounts/AccountHeader/SubheaderLine.tsx | 17 - .../Accounts/AccountHeader/index.tsx | 344 ++---------- .../Accounts/AccountHeader/styles.scss | 357 ++++--------- .../AccountHeader/test/AccountHeader.test.tsx | 242 --------- .../test/accountDeletedTransactions.json | 156 ------ .../AccountHeader/test/accountWithTokens.json | 219 -------- .../AccountHeader/test/rippledResponses.json | 60 --- .../AccountMPTTable/AccountMPTTable.tsx | 9 +- .../Accounts/AccountSummary/Balances.tsx | 75 +++ .../Accounts/AccountSummary/DetailsCard.tsx | 70 +++ .../Accounts/AccountSummary/FlagsCard.tsx | 153 ++++++ .../Accounts/AccountSummary/SignersCard.tsx | 41 ++ .../Accounts/AccountSummary/index.tsx | 59 +++ .../Accounts/AccountSummary/styles.scss | 501 ++++++++++++++++++ .../AccountTransactionTable.tsx | 54 +- src/containers/Accounts/index.tsx | 70 +-- src/containers/MPT/MPTHeader/MPTHeader.tsx | 4 +- src/containers/shared/CurrencyOptions.ts | 30 ++ src/containers/shared/Interfaces.tsx | 2 +- src/containers/shared/components/Account.tsx | 7 +- src/containers/shared/components/Amount.tsx | 4 +- .../shared/components/DomainLink.tsx | 11 +- .../shared/components/NFTokenLink.tsx | 5 +- .../components/PaginatedTable/index.tsx | 140 +++++ .../components/PaginatedTable/styles.scss | 58 ++ .../hooks/test/useXRPToUSDRate.test.tsx | 73 +++ .../shared/hooks/useXRPToUSDRate.ts | 46 ++ src/containers/shared/images/clock-icon.svg | 39 ++ src/containers/shared/images/info-duotone.svg | 5 + src/containers/shared/images/usd_icon.svg | 7 + .../shared/images/xrp_balance_icon.svg | 6 + .../images/xrp_reserve_balance_icon.svg | 14 + src/rippled/accountState.ts | 81 +-- src/rippled/lib/rippled.js | 162 +++++- src/rippled/lib/utils.js | 66 ++- 54 files changed, 3891 insertions(+), 1559 deletions(-) create mode 100644 src/containers/Accounts/AccountAsset/api.ts create mode 100644 src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx create mode 100644 src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx create mode 100644 src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx create mode 100644 src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx create mode 100644 src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx create mode 100644 src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx create mode 100644 src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx create mode 100644 src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx create mode 100644 src/containers/Accounts/AccountAsset/index.tsx create mode 100644 src/containers/Accounts/AccountAsset/mockData.ts create mode 100644 src/containers/Accounts/AccountAsset/styles.scss delete mode 100644 src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelector.tsx delete mode 100644 src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelectorItem.tsx delete mode 100644 src/containers/Accounts/AccountHeader/BalanceSelector/balance-selector.scss delete mode 100644 src/containers/Accounts/AccountHeader/SubheaderLine.tsx delete mode 100644 src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx delete mode 100644 src/containers/Accounts/AccountHeader/test/accountDeletedTransactions.json delete mode 100644 src/containers/Accounts/AccountHeader/test/accountWithTokens.json delete mode 100644 src/containers/Accounts/AccountHeader/test/rippledResponses.json create mode 100644 src/containers/Accounts/AccountSummary/Balances.tsx create mode 100644 src/containers/Accounts/AccountSummary/DetailsCard.tsx create mode 100644 src/containers/Accounts/AccountSummary/FlagsCard.tsx create mode 100644 src/containers/Accounts/AccountSummary/SignersCard.tsx create mode 100644 src/containers/Accounts/AccountSummary/index.tsx create mode 100644 src/containers/Accounts/AccountSummary/styles.scss create mode 100644 src/containers/shared/CurrencyOptions.ts create mode 100644 src/containers/shared/components/PaginatedTable/index.tsx create mode 100644 src/containers/shared/components/PaginatedTable/styles.scss create mode 100644 src/containers/shared/hooks/test/useXRPToUSDRate.test.tsx create mode 100644 src/containers/shared/hooks/useXRPToUSDRate.ts create mode 100644 src/containers/shared/images/clock-icon.svg create mode 100644 src/containers/shared/images/info-duotone.svg create mode 100644 src/containers/shared/images/usd_icon.svg create mode 100644 src/containers/shared/images/xrp_balance_icon.svg create mode 100644 src/containers/shared/images/xrp_reserve_balance_icon.svg diff --git a/.env.example b/.env.example index 6a1ea0373..5b5274df8 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,6 @@ VITE_DATA_URL=https://data.xrpl.org/v1/network # Whether to use ws instead of wss (boolean) # Only used locally (the deployed Explorer requires wss) VITE_INSECURE_WS=0 + +# LOS Endpoint URL +VITE_LOS_URL=https://los.dev.ripplex.io diff --git a/package.json b/package.json index 893c5e064..069e78d43 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,6 @@ "version": "1.5.0", "dependencies": { "@rollup/plugin-inject": "^5.0.5", - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/react": "^12.1.5", "@vitejs/plugin-react": "^4.2.1", "@xrplf/isomorphic": "^1.0.0-beta.1", "@xrplf/prettier-config": "^1.9.1", @@ -58,6 +56,9 @@ "@babel/eslint-parser": "^7.22.6", "@babel/preset-env": "^7.25.7", "@babel/preset-react": "^7.27.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^12.1.5", + "@testing-library/react-hooks": "^8.0.1", "@types/create-hash": "^1.2.6", "@types/enzyme": "^3.10.12", "@types/jest": "^26.0.24", diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 770a7e5b7..173c60647 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -617,5 +617,90 @@ "vault_create_table_detail": "vault for ", "withdraws": "withdraws", "deletes": "deletes", - "vault_delete_table_detail": "vault with id" + "vault_delete_table_detail": "vault with id", + "account_flag_title_lsf_global_freeze": "Global Freeze", + "account_flag_title_lsf_disable_master": "Master Key", + "account_flag_title_lsf_default_ripple": "Rippling", + "account_flag_title_lsf_allow_trustline_clawback": "Clawback", + "account_flag_title_lsf_require_destination_tag": "Require Destination Tag", + "account_flag_title_lsf_no_freeze": "No Freeze", + "account_flag_title_lsf_require_auth": "Require Authorization", + "account_flag_title_lsf_disallow_xrp": "No XRP Allowed", + "account_flag_title_lsf_disallow_incoming_trustline": "No Trustlines Allowed", + "account_flag_title_lsf_disallow_incoming_pay_chan": "No Payment Channels Allowed", + "account_flag_title_lsf_disallow_incoming_nft_token_offer": "No NFT Offers Allowed", + "account_flag_title_asf_authorized_nft_token_minter": "NFT Minter", + "account_flag_title_lsf_disallow_incoming_check": "No Checks Allowed", + "account_flag_title_lsf_deposit_auth": "Deposit Authorization", + "account_flag_title_asf_account_txn_id": "Track Account Latest Transaction", + "account_flag_description_lsf_global_freeze": "Freeze all assets issued by this account.", + "account_flag_description_lsf_disable_master": "Disallow use of the master key pair.", + "account_flag_description_lsf_default_ripple": "Enable rippling on this account's trust lines by default.", + "account_flag_description_lsf_allow_trustline_clawback": "Allow account to claw back tokens it has issued.", + "account_flag_description_lsf_require_destination_tag": "Require a destination tag to send transactions to this account.", + "account_flag_description_lsf_no_freeze": "Permanently give up the ability to freeze individual trust lines or disable Global Freeze. This flag can never be disabled after being enabled.", + "account_flag_description_lsf_require_auth": "Require authorization for users to hold balances issued by this address.", + "account_flag_description_lsf_disallow_xrp": "XRP should not be sent to this account.", + "account_flag_description_lsf_disallow_incoming_trustline": "Block incoming trust lines.", + "account_flag_description_lsf_disallow_incoming_pay_chan": "Block incoming Payment Channels.", + "account_flag_description_lsf_disallow_incoming_nft_token_offer": "Block incoming NFTokenOffers.", + "account_flag_description_asf_authorized_nft_token_minter": "Enable to allow another account to mint non-fungible tokens (NFTokens) on this account's behalf.", + "account_flag_description_lsf_disallow_incoming_check": "Block incoming Checks.", + "account_flag_description_lsf_deposit_auth": "Enable Deposit Authorization on this account.", + "account_flag_description_asf_account_txn_id": "Track the ID of this account's most recent transaction.", + "account_page_address": "Address", + "account_page_address_tag": "Tag", + "account_page_classic_address": "Classic Address", + "account_page_deleted_account_label": "Account Deleted", + "account_page_deleted_account_warning": "This account has been deleted from the XRP Ledger. Historical data is shown for reference only.", + "account_page_extended_address": "Extended Address (X-Address)", + "account_page_domain": "Domain", + "account_page_reserve_balance": "Reserve Balance", + "account_page_xrp_balance": "XRP Balance", + "account_page_xrp_balance_in_usd": "XRP Balance (USD)", + "account_page_account_properties": "Account Properties", + "account_page_flags": "Flags", + "account_page_flag_status_enabled": "Enabled", + "account_page_flag_status_disabled": "Disabled", + "account_page_signers": "Signers", + "account_page_signer_weight": "Weight", + "account_page_details": "Details", + "account_page_current_sequence": "Current Sequence", + "account_page_ticket_count": "Ticket Count", + "account_page_email_hash": "Email Hash", + "account_page_payment_channels": "Payment Channels", + "account_page_payment_channels_text": "{{currency}} available in {{number}} channel(s)", + "account_page_nft_minter": "NFT Minter", + "account_page_asset_held_title": "Assets Held", + "account_page_asset_issued_title": "Assets Issued", + "account_page_asset_tab_iou": "IOUs ({{count}})", + "account_page_asset_tab_lptoken": "LP Tokens ({{count}})", + "account_page_asset_tab_mpt": "MPTs ({{count}})", + "account_page_asset_tab_nft": "NFTs ({{count}})", + "account_page_asset_table_column_amm_instance": "AMM Instance", + "account_page_asset_table_column_amm_pair": "AMM Pair", + "account_page_asset_table_column_asset_class": "Asset Class", + "account_page_asset_table_column_balance": "Balance", + "account_page_asset_table_column_balance_usd": "Balance (USD)", + "account_page_asset_table_column_currency_code": "Currency Code", + "account_page_asset_table_column_frozen": "Frozen", + "account_page_asset_table_column_highest_bid": "Highest Bid", + "account_page_asset_table_column_holders": "Holders", + "account_page_asset_table_column_issuer": "Issuer", + "account_page_asset_table_column_locked": "Locked", + "account_page_asset_table_column_lowest_ask": "Lowest Ask", + "account_page_asset_table_column_price_usd": "Price (USD)", + "account_page_asset_table_column_share": "Share (%)", + "account_page_asset_table_column_supply": "Supply", + "account_page_asset_table_column_ticker": "Ticker", + "account_page_asset_table_column_token_id": "Token ID", + "account_page_asset_table_column_transfer_fee": "Transfer Fee", + "account_page_asset_table_column_trustlines": "Trustlines", + "account_page_asset_table_column_url": "URL", + "account_page_asset_table_mpt_locked_global": "Global", + "account_page_asset_table_mpt_locked_individual": "Individual", + "account_page_asset_table_no_iou": "No IOUs found", + "account_page_asset_table_no_lptoken": "No LP Tokens found", + "account_page_asset_table_no_mpt": "No MPTs found", + "account_page_asset_table_no_nft": "No NFTs found" } diff --git a/src/containers/Accounts/AccountAsset/api.ts b/src/containers/Accounts/AccountAsset/api.ts new file mode 100644 index 000000000..32a63f974 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/api.ts @@ -0,0 +1,45 @@ +import { + mockHeldIOUs, + mockHeldMPTs, + mockHeldLP, + mockHeldNFT, + issuedIOUs, + issuedMPs as issuedMPTs, + issuedNFTs, +} from './mockData' + +const randomDelay = () => { + const delay = Math.floor(Math.random() * 1000) + 1000 // 1000-2000ms + return new Promise((resolve) => setTimeout(resolve, delay)) +} + +// These mimic real HTTP fetchers; replace implementations later. +export const getHeldIOUs = async () => { + await randomDelay() + return mockHeldIOUs +} + +export const getHeldMPTs = async () => { + await randomDelay() + return mockHeldMPTs +} + +export const getHeldNFTs = async () => { + await randomDelay() + return mockHeldNFT +} + +export const getIssuedIOUs = async () => { + await randomDelay() + return issuedIOUs +} + +export const getIssuedMPTs = async () => { + await randomDelay() + return issuedMPTs +} + +export const getIssuedNFTs = async () => { + await randomDelay() + return issuedNFTs +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx new file mode 100644 index 000000000..946603a51 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -0,0 +1,247 @@ +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useEffect, useContext } from 'react' +import Currency from '../../../shared/components/Currency' +import { Loader } from '../../../shared/components/Loader' +import { Account } from '../../../shared/components/Account' +import { shortenAccount } from '../../../../rippled/lib/utils' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import SocketContext from '../../../shared/SocketContext' +import { + getBalances, + getAccountLines, + getAccountInfo, +} from '../../../../rippled/lib/rippled' + +interface HeldIOUsProps { + accountId: string + onCountChange?: (count: number) => void +} + +interface IOUData { + code: string + issuer: string + balance: string + balanceUsd: string | null + price: string | null + assetClass: string | null + fee: string | null + frozen: boolean + logo?: string +} + +const fetchHeldIOUs = async ( + rippledSocket: any, + accountId: string, +): Promise => { + // Step 1: Check gateway_balances to see if account holds any IOUs + const balancesResponse = await getBalances(rippledSocket, accountId) + + if ( + !balancesResponse?.assets || + Object.keys(balancesResponse.assets).length === 0 + ) { + // No IOUs held, return empty array + return [] + } + + console.log(balancesResponse.assets) + + // Step 2: Get all trust lines using account_lines with pagination + const allTrustLines: any[] = [] + let marker = '' + do { + // eslint-disable-next-line no-await-in-loop + const accountLinesResponse = await getAccountLines( + rippledSocket, + accountId, + 15, + marker, + ) + + console.log(`The number of lines is: ${accountLinesResponse.lines.length}`) + + if (accountLinesResponse?.lines) { + allTrustLines.push(...accountLinesResponse.lines) + } + + marker = accountLinesResponse?.marker || '' + console.log(`The marker of lines is: ${marker}`) + } while (marker) + + // Step 3: Filter for positive balances + const positiveBalanceLines = allTrustLines.filter( + (line: any) => parseFloat(line.balance) > 0, + ) + + if (positiveBalanceLines.length === 0) { + return [] + } + + // Step 4: Prepare token IDs for DGE API call + const tokenIds = positiveBalanceLines.map( + (line: any) => `${line.currency}.${line.account}`, + ) + + // Step 5: Batch get token info from DGE API + let tokenInfoMap: Record = {} + try { + const dgeResponse = await fetch( + 'https://los.dev.ripplex.io/tokens/batch-get', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tokenIds }), + }, + ) + + if (dgeResponse.ok) { + const dgeData = await dgeResponse.json() + tokenInfoMap = + dgeData.tokens?.reduce((acc: any, token: any) => { + acc[`${token.currency}.${token.issuer}`] = token + return acc + }, {}) || {} + } + } catch (dgeError) { + console.warn('Failed to fetch DGE token info:', dgeError) + } + + // Step 6: Get unique issuers for transfer fee info + const uniqueIssuers = [ + ...new Set(positiveBalanceLines.map((line: any) => line.account)), + ] + const issuerInfoMap: Record = {} + + for (const issuer of uniqueIssuers) { + try { + const issuerInfo = await getAccountInfo(rippledSocket, issuer) + issuerInfoMap[issuer] = issuerInfo + } catch (issuerError) { + console.warn(`Failed to get info for issuer ${issuer}:`, issuerError) + } + } + + // Step 7: Combine all data + const combinedData: IOUData[] = positiveBalanceLines.map((line: any) => { + const tokenId = `${line.currency}.${line.account}` + const tokenInfo = tokenInfoMap[tokenId] + const issuerInfo = issuerInfoMap[line.account] + + const transferFee = issuerInfo?.TransferRate + ? ((issuerInfo.TransferRate - 1000000000) / 10000000).toFixed(2) + : '0' + + return { + code: line.currency, + issuer: line.account, + balance: line.balance, + balanceUsd: + tokenInfo?.price && line.balance + ? (parseFloat(line.balance) * parseFloat(tokenInfo.price)).toFixed(2) + : null, + price: tokenInfo?.price || null, + assetClass: tokenInfo?.assetClass || null, + fee: `${transferFee}%`, + frozen: + line.freeze || + line.freeze_peer || + issuerInfo?.Flags & 0x00400000 || + false, + logo: tokenInfo?.logo, + } + }) + + return combinedData +} + +export const HeldIOUs = ({ accountId, onCountChange }: HeldIOUsProps) => { + const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + + // Use useQuery to fetch held IOUs data + const heldIOUsQuery = useQuery(['heldIOUs', accountId], () => + fetchHeldIOUs(rippledSocket, accountId), + ) + + const iouData = heldIOUsQuery.data || [] + + // Communicate count back to parent + useEffect(() => { + if (onCountChange) { + onCountChange(iouData.length) + } + }, [iouData.length, onCountChange]) + + if (heldIOUsQuery.isLoading) { + return + } + + if (heldIOUsQuery.error) { + return ( +
+ Error:{' '} + {heldIOUsQuery.error instanceof Error + ? heldIOUsQuery.error.message + : 'Failed to fetch held IOUs'} +
+ ) + } + + return ( +
+ + + + + + + + + + + + + + + {iouData.length === 0 ? ( + + {t('account_page_asset_table_no_iou')} + + ) : ( + iouData.map((token) => ( + + + + + + + + + + + )) + )} + +
{t('account_page_asset_table_column_currency_code')}{t('account_page_asset_table_column_issuer')}{t('account_page_asset_table_column_price_usd')}{t('account_page_asset_table_column_balance')}{t('account_page_asset_table_column_balance_usd')}{t('account_page_asset_table_column_asset_class')}{t('account_page_asset_table_column_transfer_fee')}{t('account_page_asset_table_column_frozen')}
+ {token.logo ? ( +
+ {token.code} + +
+ ) : ( + + )} +
+ + {token.price || '--'}{token.balance}{token.balanceUsd || '--'}{token.assetClass || '--'}{token.fee}{token.frozen ? 'Yes' : 'No'}
+
+ ) +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx new file mode 100644 index 000000000..98722a72d --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -0,0 +1,250 @@ +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useEffect, useContext } from 'react' +import { Loader } from '../../../shared/components/Loader' +import SocketContext from '../../../shared/SocketContext' +import Currency from '../../../shared/components/Currency' +import { shortenAccount } from '../../../../rippled/lib/utils' +import { Account } from '../../../shared/components/Account' +import { + USD_CURRENCY_OPTIONS, + USD_SMALL_BALANCE_CURRENCY_OPTIONS, +} from '../../../shared/CurrencyOptions' +import { useLanguage } from '../../../shared/hooks' +import { localizeNumber } from '../../../shared/utils' +import { + getBalances, + getAMMInfoByAMMAccount, +} from '../../../../rippled/lib/rippled' +import { XRP_BASE } from '../../../shared/transactionUtils' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' + +const fetchAccountHeldLPTokens = async (rippledSocket, accountId) => { + try { + const balancesResponse = await getBalances(rippledSocket, accountId) + if (!balancesResponse.assets) { + return [] // No assets held, so no LP tokens + } + + const lpTokens: any[] = [] + + // Filter assets with currency code starting with '03' and process them one by one + // Reference: https://xrpl.org/docs/concepts/tokens/decentralized-exchange/automated-market-makers#lp-token-currency-codes + for (const [issuerAccount, assets] of Object.entries( + balancesResponse.assets, + )) { + for (const asset of assets as any[]) { + if (asset.currency && asset.currency.startsWith('03')) { + // eslint-disable-next-line no-await-in-loop + const result = await processLPTokenAsset( + rippledSocket, + issuerAccount, + asset, + ) + + if (result) { + lpTokens.push(result) + } + } + } + } + + return lpTokens + } catch (error) { + console.error(error) + return [] + } +} + +const processLPTokenAsset = async (rippledSocket, issuerAccount, asset) => { + try { + const ammInfoResponse = await getAMMInfoByAMMAccount( + rippledSocket, + issuerAccount, + ) + + if (!ammInfoResponse.amm) { + return null + } + + const ammData = ammInfoResponse.amm + + // Calculate share percentage + const accountLPTokenBalance = parseFloat(asset.value) + const ammLPTokenBalance = parseFloat(ammData.lp_token?.value || 0) + const sharePercentage = + ammLPTokenBalance > 0 + ? (100 * accountLPTokenBalance) / ammLPTokenBalance + : 0 + + const isAmount1XRP = typeof ammData.amount === 'string' + const isAmount2XRP = typeof ammData.amount2 === 'string' + + // Calculate LP Token price in XRP only when one of the assets is XRP + let lpTokenPriceInXRP = 0 + if (isAmount1XRP || isAmount2XRP) { + const xrpAmount = isAmount1XRP + ? parseFloat(ammData.amount) / XRP_BASE + : parseFloat(ammData.amount2) / XRP_BASE + + if (ammLPTokenBalance > 0 && xrpAmount > 0) { + // Price per LP token = (XRP in pool * 2) / total LP tokens + // We multiply by 2 because LP token represents share of both assets + lpTokenPriceInXRP = (xrpAmount * 2) / ammLPTokenBalance + } + } + + let currency1 + let currency2 + if (isAmount1XRP && !isAmount2XRP) { + currency1 = 'XRP' + currency2 = ammData.amount2.currency + } else if (!isAmount1XRP && isAmount2XRP) { + currency1 = 'XRP' + currency2 = ammData.amount.currency + } else { + // Both are tokens (no XRP) + currency1 = ammData.amount.currency + currency2 = ammData.amount2.currency + } + + return { + ammInstance: issuerAccount, + currency1, + currency2, + lpTokenBalance: accountLPTokenBalance, + lpTokenPriceInXRP, + share: sharePercentage, + } + } catch (error) { + console.error(error) + return null + } +} + +interface HeldLPTokensProps { + accountId: string + xrpToUSDRate: number + onCountChange?: (count: number) => void +} + +export const HeldLPTokens = ({ + accountId, + xrpToUSDRate, + onCountChange, +}: HeldLPTokensProps) => { + const lang = useLanguage() + const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + + const heldLPTokensQuery = useQuery(['heldLPTokens', accountId], () => + fetchAccountHeldLPTokens(rippledSocket, accountId), + ) + const lpTokenData = heldLPTokensQuery.data ?? [] + + // Two-tier sort: 1) XRP pairs first, 2) within XRP pairs, sort by USD value descending + const rows = lpTokenData.sort((a, b) => { + const aHasXRP = a.currency1 === 'XRP' || a.currency2 === 'XRP' + const bHasXRP = b.currency1 === 'XRP' || b.currency2 === 'XRP' + + // First, prioritize XRP pairs + if (aHasXRP && !bHasXRP) { + return -1 + } + if (!aHasXRP && bHasXRP) { + return 1 + } + + // Then sort by Balance USD descending (only for XRP pairs) + if (aHasXRP && bHasXRP) { + const aBalanceUSD = a.lpTokenBalance * a.lpTokenPriceInXRP * xrpToUSDRate + const bBalanceUSD = b.lpTokenBalance * b.lpTokenPriceInXRP * xrpToUSDRate + return bBalanceUSD - aBalanceUSD + } + + // For non-XRP pairs, maintain original order + return 0 + }) + + // Communicate count back to parent + useEffect(() => { + if (onCountChange) { + onCountChange(rows.length) + } + }, [rows.length, onCountChange]) + + if (heldLPTokensQuery.isLoading) { + return + } + + const cols = [ + t('account_page_asset_table_column_amm_instance'), + t('account_page_asset_table_column_amm_pair'), + t('account_page_asset_table_column_balance'), + t('account_page_asset_table_column_balance_usd'), + t('account_page_asset_table_column_share'), + ] + return ( +
+ + + + {cols.map((c) => ( + + ))} + + + + {rows.length === 0 ? ( + + {t('account_page_asset_table_no_lptoken')} + + ) : ( + rows.map((row) => ( + + + + + + + + )) + )} + +
{c}
+ + + + / + + {localizeNumber(row.lpTokenBalance, lang)} + {row.currency1 === 'XRP' || row.currency2 === 'XRP' + ? (() => { + const balanceUSD = + row.lpTokenBalance * + row.lpTokenPriceInXRP * + xrpToUSDRate + const currencyOptions = + balanceUSD < 1 + ? USD_SMALL_BALANCE_CURRENCY_OPTIONS + : USD_CURRENCY_OPTIONS + return localizeNumber(balanceUSD, lang, currencyOptions) + })() + : '--'} + + {row.share < 1 ? row.share.toFixed(4) : row.share.toFixed(2)}% +
+
+ ) +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx new file mode 100644 index 000000000..14703e649 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx @@ -0,0 +1,227 @@ +import { useQuery } from 'react-query' +import { useEffect, useContext } from 'react' +import { useTranslation } from 'react-i18next' +import { RouteLink } from '../../../shared/routing' +import { MPT_ROUTE } from '../../../App/routes' +import { Loader } from '../../../shared/components/Loader' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import { Account } from '../../../shared/components/Account' +import { + formatMPTIssuance, + formatMPToken, + formatTransferFee, + shortenAccount, + shortenMPTID, +} from '../../../../rippled/lib/utils' +import { getAccountMPTs, getMPTIssuance } from '../../../../rippled/lib/rippled' +import SocketContext from '../../../shared/SocketContext' +import ClockIcon from '../../../shared/images/clock-icon.svg' +import { localizeNumber } from '../../../shared/utils' +import { useLanguage } from '../../../shared/hooks' + +interface HeldMPTsProps { + accountId: string + onCountChange?: (count: number) => void +} + +const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { + const mpts: any[] = [] + let marker = '' + do { + // eslint-disable-next-line no-await-in-loop + const response = await getAccountMPTs(rippledSocket, accountId, marker) + if (!response?.account_objects) { + break + } + + mpts.push(...response.account_objects) + marker = response.marker || '' + } while (marker) + + // Format and filter MPTs + const formattedMPTs = mpts + .map((mpToken: any) => formatMPToken(mpToken)) + .filter((mpToken: any) => parseInt(mpToken.mptAmount || '0', 10) > 0) + + if (formattedMPTs.length === 0) { + return [] + } + + // For each MPTokenIssuanceID, call getMPTIssuance and format the response + const issuancePromises = formattedMPTs.map(async (mpToken: any) => { + try { + const mptIssuanceResponse = await getMPTIssuance( + rippledSocket, + mpToken.mptIssuanceID, + ) + + const formattedMPTIssuance = formatMPTIssuance(mptIssuanceResponse.node) + return { + mptIssuanceId: mpToken.mptIssuanceID, + mptIssuance: formattedMPTIssuance, + } + } catch (error) { + console.error(error) + return { mptIssuanceId: mpToken.mptIssuanceID, mptIssuance: null } + } + }) + + const mptIssuanceResults = await Promise.all(issuancePromises) + const mptIssuanceIdToIssuance = new Map() + mptIssuanceResults.forEach((result) => { + if (result && result.mptIssuance) { + mptIssuanceIdToIssuance.set(result.mptIssuanceId, result.mptIssuance) + } + }) + + // Combine MPToken and MPTIssuance data + const combinedMPTs = formattedMPTs.map((mpToken: any) => { + const mptIssuance = mptIssuanceIdToIssuance.get(mpToken.mptIssuanceID) + + return { + tokenId: mpToken.mptIssuanceID, + balance: mpToken.mptAmount, + ticker: mptIssuance?.metadata?.Ticker || null, + issuer: mptIssuance?.issuer || '', + issuerName: mptIssuance?.metadata?.IssuerName || null, + assetClass: mptIssuance?.metadata?.AssetClass || null, + transferFee: formatTransferFee(mptIssuance?.transferFee), + locked: (() => { + if (mptIssuance?.flags?.includes('lsfMPTLocked')) { + return 'Global' + } + if (mpToken.flags?.includes('lsfMPTLocked')) { + return 'Individual' + } + + return '' + })(), + } + }) + + return combinedMPTs +} + +export const HeldMPTs = ({ accountId, onCountChange }: HeldMPTsProps) => { + const lang = useLanguage() + const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + + const heldMPTsQuery = useQuery(['heldMPTs', accountId], () => + fetchAccountHeldMPTs(accountId, rippledSocket), + ) + const rows = heldMPTsQuery.data ?? [] + + // Communicate count back to parent + useEffect(() => { + if (onCountChange) { + onCountChange(rows.length) + } + }, [rows.length, onCountChange]) + + if (heldMPTsQuery.isLoading) { + return + } + + return ( +
+ + + + + + + + + + + + + + + + {rows.length === 0 ? ( + + {t('account_page_asset_table_no_mpt')} + + ) : ( + rows.map((token) => ( + + + + + + + + + + + + )) + )} + +
{t('account_page_asset_table_column_token_id')}{t('account_page_asset_table_column_ticker')}{t('account_page_asset_table_column_issuer')}{t('account_page_asset_table_column_price_usd')}{t('account_page_asset_table_column_balance')}{t('account_page_asset_table_column_balance_usd')}{t('account_page_asset_table_column_asset_class')}{t('account_page_asset_table_column_transfer_fee')}{t('account_page_asset_table_column_locked')}
+ + {shortenMPTID(token.tokenId)} + + + {token.ticker ? ( + token.ticker + ) : ( + + + + )} + + + {token.issuerName && ( +
{token.issuerName}
+ )} +
+ + + + {localizeNumber(token.balance, lang)} + + + + + {token.assetClass ? ( + token.assetClass + ) : ( + + + + )} + {token.transferFee}% + {(() => { + if (token.locked === '') { + return '--' + } + if (token.locked === 'Global') { + return t('account_page_asset_table_mpt_locked_global') + } + if (token.locked === 'Individual') { + return t('account_page_asset_table_mpt_locked_individual') + } + return token.locked + })()} +
+
+ ) +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx new file mode 100644 index 000000000..3de7ddd84 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx @@ -0,0 +1,46 @@ +import { getAccountNFTs } from '../../../../rippled/lib/rippled' +import { formatTransferFee } from '../../../../rippled/lib/utils' +import { NFTTable, NFTBasic } from './NFTTable' + +interface HeldNFTsProps { + accountId: string + onCountChange?: (count: number) => void +} + +const fetchAccountHeldNFTs = async ( + accountId: string, + rippledSocket: any, +): Promise => { + try { + const allNFTs: any[] = [] + let marker = '' + do { + // eslint-disable-next-line no-await-in-loop + const response = await getAccountNFTs(rippledSocket, accountId, marker, 5) + if (response.account_nfts) { + allNFTs.push(...response.account_nfts) + } + marker = response.marker || '' + } while (marker) + + return allNFTs.map((nft) => ({ + nftId: nft.NFTokenID, + issuer: nft.Issuer, + url: nft.URI ? Buffer.from(nft.URI, 'hex').toString('utf8') : '', + fee: formatTransferFee(nft.TransferFee), + })) + } catch (error) { + console.error(error) + return [] + } +} + +export const HeldNFTs = ({ accountId, onCountChange }: HeldNFTsProps) => ( + +) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx new file mode 100644 index 000000000..7362059c6 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -0,0 +1,71 @@ +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useEffect } from 'react' +import Currency from '../../../shared/components/Currency' +import { Loader } from '../../../shared/components/Loader' +import { getIssuedIOUs } from '../api' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' + +interface IssuedIOUsProps { + accountId: string + onCountChange?: (count: number) => void +} + +export const IssuedIOUs = ({ accountId, onCountChange }: IssuedIOUsProps) => { + const { t } = useTranslation() + + const issuedIOUsQ = useQuery('issuedIOUs', getIssuedIOUs) + const rows = issuedIOUsQ.data ?? [] + + // Communicate count back to parent + useEffect(() => { + if (onCountChange) { + onCountChange(rows.length) + } + }, [rows.length, onCountChange]) + + if (issuedIOUsQ.isLoading) { + return + } + + return ( +
+ + + + + + + + + + + + + + + {rows.length === 0 ? ( + + {t('account_page_asset_table_no_iou')} + + ) : ( + rows.map((token) => ( + + + + + + + + + + + )) + )} + +
{t('account_page_asset_table_column_currency_code')}{t('account_page_asset_table_column_price_usd')}{t('account_page_asset_table_column_trustlines')}{t('account_page_asset_table_column_holders')}{t('account_page_asset_table_column_supply')}{t('account_page_asset_table_column_asset_class')}{t('account_page_asset_table_column_transfer_fee')}{t('account_page_asset_table_column_frozen')}
+ + {token.price}{token.trustlines}{token.holders}{token.supply}{token.assetClass}{token.fee}{token.frozen}
+
+ ) +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx new file mode 100644 index 000000000..8f327e356 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx @@ -0,0 +1,151 @@ +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useEffect, useContext } from 'react' +import { RouteLink } from '../../../shared/routing' +import { MPT_ROUTE } from '../../../App/routes' +import { Loader } from '../../../shared/components/Loader' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import ClockIcon from '../../../shared/images/clock-icon.svg' +import { getAccountObjects } from '../../../../rippled/lib/rippled' +import SocketContext from '../../../shared/SocketContext' +import { + formatMPTIssuance, + formatTransferFee, + shortenMPTID, +} from '../../../../rippled/lib/utils' +import { localizeNumber } from '../../../shared/utils' +import { useLanguage } from '../../../shared/hooks' + +interface IssuedMPTsProps { + accountId: string + onCountChange?: (count: number) => void +} + +const fetchAccountIssuedMPTs = async ( + accountId: string, + rippledSocket: any, +) => { + const mptIssuances: any[] = [] + let marker = '' + do { + // eslint-disable-next-line no-await-in-loop + const response = await getAccountObjects( + rippledSocket, + accountId, + 'mpt_issuance', + marker, + ) + if (!response?.account_objects) { + break + } + + mptIssuances.push(...response.account_objects) + marker = response.marker || '' + } while (marker) + + // Format the MPT issuances + const issuedMPTs = mptIssuances.map((mptIssuance: any) => { + const formattedIssuance = formatMPTIssuance(mptIssuance) + + return { + tokenId: mptIssuance.mpt_issuance_id, + ticker: formattedIssuance?.metadata?.Ticker || null, + supply: formattedIssuance?.outstandingAmt || '0', + assetClass: formattedIssuance?.metadata?.AssetClass || null, + transferFee: formatTransferFee(formattedIssuance?.transferFee), + locked: formattedIssuance?.flags?.includes('lsfMPTLocked') + ? 'Global' + : '', + } + }) + + return issuedMPTs +} + +export const IssuedMPTs = ({ accountId, onCountChange }: IssuedMPTsProps) => { + const lang = useLanguage() + const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + + const issuedMPTsQuery = useQuery(['issuedMPTs', accountId], () => + fetchAccountIssuedMPTs(accountId, rippledSocket), + ) + const rows = issuedMPTsQuery.data ?? [] + + // Communicate count back to parent + useEffect(() => { + if (onCountChange) { + onCountChange(rows.length) + } + }, [rows.length, onCountChange]) + + if (issuedMPTsQuery.isLoading) { + return + } + + return ( +
+ + + + + + + + + + + + + + {rows.length === 0 ? ( + + {t('account_page_asset_table_no_mpt')} + + ) : ( + rows.map((token) => ( + + + + + + + + + + )) + )} + +
{t('account_page_asset_table_column_token_id')}{t('account_page_asset_table_column_ticker')}{t('account_page_asset_table_column_price_usd')}{t('account_page_asset_table_column_supply')}{t('account_page_asset_table_column_asset_class')}{t('account_page_asset_table_column_transfer_fee')}{t('account_page_asset_table_column_locked')}
+ + {shortenMPTID(token.tokenId)} + + + + + + + + + + {localizeNumber(token.supply, lang)} + + + + {token.transferFee}% + {token.locked === '' + ? '--' + : t('account_page_asset_table_mpt_locked_global')} +
+
+ ) +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx new file mode 100644 index 000000000..3e343569f --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx @@ -0,0 +1,51 @@ +import { getNFTsIssuedByAccount } from '../../../../rippled/lib/rippled' +import { formatTransferFee } from '../../../../rippled/lib/utils' +import { NFTTable, NFTBasic } from './NFTTable' + +interface IssuedNFTsProps { + accountId: string + onCountChange?: (count: number) => void +} + +const fetchAccountIssuedNFTs = async ( + accountId: string, + rippledSocket: any, +): Promise => { + try { + const allNFTs: any[] = [] + let marker = '' + do { + // eslint-disable-next-line no-await-in-loop + const response = await getNFTsIssuedByAccount( + rippledSocket, + accountId, + marker, + 25, + ) + if (response.nfts) { + console.log('Fetched account NFTs: ', response.nfts.length) + allNFTs.push(...response.nfts) + } + marker = response.marker || '' + } while (marker) + + return allNFTs.map((nft) => ({ + nftId: nft.nft_id, + url: nft.uri ? Buffer.from(nft.uri, 'hex').toString('utf8') : '', + fee: formatTransferFee(nft.transfer_fee), + })) + } catch (error) { + console.error(error) + return [] + } +} + +export const IssuedNFTs = ({ onCountChange, accountId }: IssuedNFTsProps) => ( + +) diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx new file mode 100644 index 000000000..0fcefcddf --- /dev/null +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -0,0 +1,296 @@ +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useEffect, useMemo, useContext, useState, useCallback } from 'react' +import { Account } from '../../../shared/components/Account' +import { PaginatedTable } from '../../../shared/components/PaginatedTable' +import { Loader } from '../../../shared/components/Loader' +import SocketContext from '../../../shared/SocketContext' +import { + getBuyNFToffers, + getSellNFToffers, +} from '../../../../rippled/lib/rippled' +import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import { NFTokenLink } from '../../../shared/components/NFTokenLink' +import { + shortenAccount, + shortenDomain, + shortenNFTTokenID, +} from '../../../../rippled/lib/utils' +import DomainLink from '../../../shared/components/DomainLink' +import { XRP_BASE } from '../../../shared/transactionUtils' +import { localizeNumber } from '../../../shared/utils' +import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../../shared/CurrencyOptions' +import { useLanguage } from '../../../shared/hooks' + +const PAGE_SIZE = 10 + +interface NFTTableWithOffersProps { + accountId: string + onCountChange?: (count: number) => void + fetchNFTs: (rippledSocket: any, accountId: string) => Promise + queryKey: string + showIssuer?: boolean +} + +export interface NFTBasic { + nftId: string + issuer?: string + url: string + fee: string +} + +export interface NFT { + nftId: string + issuer?: string + url: string + fee: string + lowestAsk: number | null + highestBid: number | null +} + +export const NFTTable = ({ + accountId, + onCountChange, + fetchNFTs, + queryKey, + showIssuer = false, +}: NFTTableWithOffersProps) => { + const { t } = useTranslation() + const lang = useLanguage() + const rippledSocket = useContext(SocketContext) + const [nfts, setNFTs] = useState([]) + const [fetchedNFTs, setFetchedNFTs] = useState>(new Set()) + const [isLoadingOffers, setIsLoadingOffers] = useState(false) + + // Fetch basic NFT data first + const nftsQuery = useQuery([queryKey, accountId], () => + fetchNFTs(accountId, rippledSocket), + ) + + const basicNFTs = useMemo(() => nftsQuery.data ?? [], [nftsQuery.data]) + + // Helper function to process XRP offers + const processXRPOffers = useCallback( + (offers: any[], sortAscending: boolean) => { + const xrpOffers = offers.filter( + (offer: any) => typeof offer.amount === 'string', + ) + + if (xrpOffers.length === 0) return null + + const sortedOffers = xrpOffers.sort((a: any, b: any) => { + const amountA = parseInt(a.amount, 10) + const amountB = parseInt(b.amount, 10) + return sortAscending ? amountA - amountB : amountB - amountA + }) + + const bestOffer = sortedOffers[0] + return parseInt(bestOffer.amount, 10) / XRP_BASE + }, + [], + ) + + // Helper function to fetch offers + const fetchOffers = useCallback( + async (fetchFn: Function, nftId: string) => { + try { + const response = await fetchFn(rippledSocket, nftId, 10) + return response.offers && response.offers.length > 0 + ? response.offers + : [] + } catch (error) { + console.error(error) + return [] + } + }, + [rippledSocket], + ) + + const fetchOffersForNFT = useCallback( + async (nft: NFTBasic): Promise => { + try { + // Get sell offers to calculate lowest ask (XRP only) + const sellOffers = await fetchOffers(getSellNFToffers, nft.nftId) + const lowestAsk = processXRPOffers(sellOffers, true) // ascending for lowest + + // Get buy offers to calculate highest bid (XRP only) + const buyOffers = await fetchOffers(getBuyNFToffers, nft.nftId) + const highestBid = processXRPOffers(buyOffers, false) // descending for highest + + return { ...nft, lowestAsk, highestBid } + } catch (error) { + return { ...nft, lowestAsk: null, highestBid: null } + } + }, + [processXRPOffers, fetchOffers], + ) + + // Helper function to batch process NFT offers + const batchProcessNFTOffers = useCallback( + async (nftsToFetch: NFTBasic[]) => { + if (nftsToFetch.length === 0) return + + setIsLoadingOffers(true) + + try { + // Fetch all NFT offers concurrently using Promise.all + const nftsPromises = nftsToFetch.map((nft) => fetchOffersForNFT(nft)) + const nftsResults = await Promise.all(nftsPromises) + + // Update all NFTs at once + setNFTs((prev) => + prev.map((prevNft) => { + const updatedNft = nftsResults.find( + (nft) => nft.nftId === prevNft.nftId, + ) + return updatedNft || prevNft + }), + ) + + // Mark all NFTs as fetched + const nftIds = nftsToFetch.map((nft) => nft.nftId) + setFetchedNFTs((prev) => new Set([...prev, ...nftIds])) + } catch (error) { + // Silently handle batch processing errors + } + + setIsLoadingOffers(false) + }, + [fetchOffersForNFT], + ) + + // Initialize NFTs when basic data loads + useEffect(() => { + if (basicNFTs.length > 0) { + // Initialize all NFTs with null lowest ask and highest bid + const initialNFTs = basicNFTs.map((nft) => ({ + ...nft, + lowestAsk: null, + highestBid: null, + })) + setNFTs(initialNFTs) + + // Reset for new account + setFetchedNFTs(new Set()) + setIsLoadingOffers(false) + + // Fetch offers for first page NFTs immediately + const firstPageNFTs = basicNFTs.slice(0, PAGE_SIZE) + + // Fetch offers for first page NFTs immediately + batchProcessNFTOffers(firstPageNFTs) + } + }, [basicNFTs, batchProcessNFTOffers]) + + // Communicate total count back to parent + useEffect(() => { + if (onCountChange) { + onCountChange(nfts.length) + } + }, [nfts.length, onCountChange]) + + if (nftsQuery.isLoading) { + return + } + + const tableStructure = (paginatedRows: any[]) => { + // Fetch offers for visible NFTs when page changes + // Only fetch if there are NFTs that haven't been fetched yet + if (paginatedRows.length > 0) { + const unfetchedNFTs = paginatedRows.filter( + (nft) => !fetchedNFTs.has(nft.nftId), + ) + + if (unfetchedNFTs.length > 0 && !isLoadingOffers) { + // Use setTimeout to avoid calling setState during render + setTimeout(() => { + batchProcessNFTOffers(unfetchedNFTs) + }, 0) + } + } + + const colSpan = showIssuer ? 6 : 5 + + return ( +
+ + + + + {showIssuer && ( + + )} + + + + + + + + {paginatedRows.length === 0 ? ( + + {t('account_page_asset_table_no_nft')} + + ) : ( + paginatedRows.map((nft, index) => ( + + + {showIssuer && nft.issuer && ( + + )} + + + + + + )) + )} + +
{t('account_page_asset_table_column_token_id')}{t('account_page_asset_table_column_issuer')}{t('account_page_asset_table_column_url')}{t('account_page_asset_table_column_transfer_fee')}{t('account_page_asset_table_column_lowest_ask')}{t('account_page_asset_table_column_highest_bid')}
+ + + + + {nft.url ? ( + + ) : ( + '--' + )} + {nft.fee}% + {nft.lowestAsk + ? localizeNumber( + nft.lowestAsk, + lang, + XRP_SMALL_BALANCE_CURRENCY_OPTIONS, + ) + : '--'} + + {nft.highestBid + ? localizeNumber( + nft.highestBid, + lang, + XRP_SMALL_BALANCE_CURRENCY_OPTIONS, + ) + : '--'} +
+
+ ) + } + + return ( + + ) +} diff --git a/src/containers/Accounts/AccountAsset/index.tsx b/src/containers/Accounts/AccountAsset/index.tsx new file mode 100644 index 000000000..0752196fa --- /dev/null +++ b/src/containers/Accounts/AccountAsset/index.tsx @@ -0,0 +1,251 @@ +import React, { useState, useCallback } from 'react' +import './styles.scss' +import { useTranslation } from 'react-i18next' +import { HeldIOUs } from './assetTables/HeldIOUs' +import { HeldMPTs } from './assetTables/HeldMPTs' +import { HeldLPTokens } from './assetTables/HeldLPTokens' +import { HeldNFTs } from './assetTables/HeldNFTs' +import { IssuedIOUs } from './assetTables/IssuedIOUs' +import { IssuedMPTs } from './assetTables/IssuedMPTs' +import { IssuedNFTs } from './assetTables/IssuedNFTs' +import { useLanguage } from '../../shared/hooks' +import { localizeNumber } from '../../shared/utils' + +type HeldAssetTabKey = 'iou' | 'mpt' | 'lptoken' | 'nft' +type IssuedAssetTabKey = 'iou' | 'mpt' | 'nft' + +function TabButton({ + label, + active, + onClick, +}: { + label: string + active?: boolean + onClick?: () => void +}) { + return ( + + ) +} + +interface AccountAssetProps { + accountId: string + xrpToUSDRate: number +} + +export default function AccountAsset({ + accountId, + xrpToUSDRate, +}: AccountAssetProps) { + const lang = useLanguage() + const { t } = useTranslation() + + // Counts managed by individual table components + const [heldCounts, setHeldCounts] = useState({ + iou: 0, + mpt: 0, + lptoken: 0, + nft: 0, + }) + + const [issuedCounts, setIssuedCounts] = useState({ + iou: 0, + mpt: 0, + nft: 0, + }) + + // Functions to update counts (need useCallback for stable refs) + const updateHeldCount = useCallback( + (type: keyof typeof heldCounts, count: number) => { + setHeldCounts((prev) => ({ ...prev, [type]: count })) + }, + [], + ) + const updateIssuedCount = useCallback( + (type: keyof typeof issuedCounts, count: number) => { + setIssuedCounts((prev) => ({ ...prev, [type]: count })) + }, + [], + ) + + // Specific update functions for each type (need useCallback for stable refs) + const updateHeldIOUCount = useCallback( + (count: number) => updateHeldCount('iou', count), + [updateHeldCount], + ) + const updateHeldMPTokenCount = useCallback( + (count: number) => updateHeldCount('mpt', count), + [updateHeldCount], + ) + const updateHeldLPTokenCount = useCallback( + (count: number) => updateHeldCount('lptoken', count), + [updateHeldCount], + ) + const updateHeldNFTCount = useCallback( + (count: number) => updateHeldCount('nft', count), + [updateHeldCount], + ) + + const updateIssuedIOUCount = useCallback( + (count: number) => updateIssuedCount('iou', count), + [updateIssuedCount], + ) + const updateIssuedMPTokenCount = useCallback( + (count: number) => updateIssuedCount('mpt', count), + [updateIssuedCount], + ) + const updateIssuedNFTCount = useCallback( + (count: number) => updateIssuedCount('nft', count), + [updateIssuedCount], + ) + + // Tabs state + const [heldTab, setHeldTab] = useState('iou') + const [issuedTab, setIssuedTab] = useState('iou') + + return ( +
+ {/* Assets Held */} +

+ {t('account_page_asset_held_title')} +

+
+ setHeldTab('iou')} + /> + setHeldTab('mpt')} + /> + setHeldTab('lptoken')} + /> + setHeldTab('nft')} + /> +
+ + {/* Render all components to fetch data, but only show active tab */} +
+ +
+
+ +
+
+ +
+
+ +
+ + {/* Assets Issued */} +

+ {t('account_page_asset_issued_title')} +

+
+ setIssuedTab('iou')} + /> + setIssuedTab('mpt')} + /> + setIssuedTab('nft')} + /> +
+ + {/* Render all components to fetch data, but only show active tab */} +
+ +
+
+ +
+
+ +
+
+ ) +} diff --git a/src/containers/Accounts/AccountAsset/mockData.ts b/src/containers/Accounts/AccountAsset/mockData.ts new file mode 100644 index 000000000..d44235ae9 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/mockData.ts @@ -0,0 +1,243 @@ +function genHeldIOUs(count = 20) { + const codes = [ + 'RLUSD', + 'SOLO', + 'EXMP', + 'TOKN', + 'XUSD', + 'ABC', + 'DEF', + 'GHI', + 'JKL', + 'MNO', + ] + const issuers = [ + 'Ripple', + 'Sologenic', + 'Issuer A', + 'Issuer B', + 'Issuer C', + 'Issuer D', + 'Issuer E', + 'Issuer F', + 'Issuer G', + 'Issuer H', + ] + return Array.from({ length: count }, (_, i) => { + const idx = i % codes.length + return { + code: codes[idx], + issuer: issuers[idx], + price: `$${(10 + idx * 5 + (i % 7)).toFixed(2)}`, + balance: `${(i + 1) * 10}`, + balanceUsd: `$${((i + 1) * 10 * 1.23).toFixed(2)}`, + assetClass: idx % 2 === 0 ? 'RWA' : 'Defi', + fee: `${(0.05 + idx * 0.01).toFixed(2)}%`, + frozen: idx % 3 === 0 ? 'Global' : idx % 3 === 1 ? '--' : 'Trustline', + } + }) +} + +function genHeldMPTs(count = 20) { + const tickers = [ + 'RLUSD', + 'SOLO', + 'NAME', + 'TOKA', + 'TOKB', + 'TOKC', + 'TOKD', + 'TOKE', + 'TOKF', + 'TOKG', + ] + const issuers = [ + 'Ripple', + 'Sologenic', + 'Name Inc', + 'Issuer 4', + 'Issuer 5', + 'Issuer 6', + 'Issuer 7', + 'Issuer 8', + 'Issuer 9', + 'Issuer 10', + ] + return Array.from({ length: count }, (_, i) => { + const idx = i % tickers.length + const base = (i + 1000).toString(16).toUpperCase().slice(0, 8) + return { + tokenId: `r${base}C...K${base}X`, + ticker: tickers[idx], + issuer: issuers[idx], + price: `$${(1 + idx * 3 + (i % 5)).toFixed(2)}`, + balance: `${(i + 1) * 100}`, + balanceUsd: `$${((i + 1) * 100 * 1.1).toFixed(2)}`, + assetClass: idx % 2 === 0 ? 'RWA' : 'Tokenized', + fee: `${(0.05 + idx * 0.02).toFixed(2)}%`, + frozen: idx % 3 === 0 ? 'Global' : '--', + } + }) +} + +function genHeldLPTokens(count = 20) { + const pairs = [ + 'MAG/XRP', + 'ETH/BTC', + 'LTC/USDT', + 'BNB/ETH', + 'SOL/DOT', + 'AAA/BBB', + 'CCC/DDD', + 'EEE/FFF', + 'GGG/HHH', + 'III/JKL', + ] + const instances = Array.from( + { length: count }, + (_, i) => `rLp${(i + 1).toString(16).toUpperCase()}`, + ) + return Array.from({ length: count }, (_, i) => { + const idx = i % pairs.length + return { + ammInstance: instances[i], + ammPair: pairs[idx], + balance: `${(i + 1) * 50}`, + balanceUsd: `${((i + 1) * 50 * (idx + 1)).toFixed(2)} USD`, + share: `${((i % 100) / 100).toFixed(2)}%`, + } + }) +} + +/* NFT generators */ +function genHeldNFTs(count = 100) { + const issuers = [ + 'r9f2dD...qH7zX', + 'rU6BEsJ...gM9Vn', + 'rEb8TK...uK8dM', + 'rsoLo2...z3p4q', + 'rPEPPER...BEANS', + 'rGwUw...g4fk4', + 'rN7n7...pD45K', + 'rhB9c...tV9pW', + 'rJbV3D...hN6bA', + 'rUCLG...pT5sR', + ] + const urls = [ + 'xrpl-validator.net', + 'gatehub.net', + 'bithomp.com', + 'xumm.app', + 'ripple.com', + 'coinfield.com', + 'bitstamp.net', + 'alloy-network.io', + 'xrpl.cafe', + 'onxrp.com', + ] + return Array.from({ length: count }, (_, i) => { + const idx = i % issuers.length + const base = (i + 1).toString(16).toUpperCase().padStart(8, '0') + return { + tokenId: `${base}...${base}C${(i % 9) + 1}`, + issuer: issuers[idx], + url: urls[idx % urls.length], + fee: `${(idx === 3 ? 0 : (idx + 1) * 0.05).toFixed(2)}%`, + lowestAsk: `${(idx + 1) * 10} XRP`, + highestBid: `${(idx + 1) * 7 + (i % 3)} XRP`, + } + }) +} + +function genIssuedNFTs(count = 100) { + const urls = [ + 'www.website.com', + 'www.crypto-exchange.io', + 'www.staking-rewards.net', + 'www.nft-market.art', + 'www.defi-lending.org', + 'www.secure-wallet.app', + 'www.payment-gateway.com', + 'www.crypto-gaming.co', + 'www.trading-bot.ai', + 'www.cross-chain-bridge.fi', + ] + return Array.from({ length: count }, (_, i) => { + const idx = i % urls.length + const base = (i + 11111111).toString(16).toUpperCase() + return { + tokenId: `${base}...${base.slice(0, 8)}`, + url: urls[idx], + fee: `${(idx === 7 ? 5.0 : (idx + 1) * 0.15).toFixed(2)}%`, + lowestAsk: `${(idx + 1) * 123} XRP`, + highestBid: `${(idx + 1) * 10 + (i % 5)} XRP`, + } + }) +} + +/* Issued generators */ +function genIssuedIOUs(count = 20) { + const codes = [ + 'RLUSD', + 'SOLO', + 'EXMP', + 'TOKN', + 'XUSD', + 'ISS1', + 'ISS2', + 'ISS3', + 'ISS4', + 'ISS5', + ] + return Array.from({ length: count }, (_, i) => { + const idx = i % codes.length + return { + code: codes[idx], + price: `$${(5 + idx * 2 + (i % 3)).toFixed(2)}`, + trustlines: `${1000 + i * 10}`, + holders: `${500 + i * 5}`, + supply: `${100000 + i * 1000}`, + assetClass: idx % 2 === 0 ? 'RWA' : 'Other', + fee: `${(0.02 + idx * 0.01).toFixed(2)}%`, + frozen: idx % 3 === 0 ? 'Global' : '--', + } + }) +} + +function genIssuedMPTs(count = 20) { + const tickers = [ + 'RLUSD', + 'SOLO', + 'ISSU', + 'MP01', + 'MP02', + 'MP03', + 'MP04', + 'MP05', + 'MP06', + 'MP07', + ] + return Array.from({ length: count }, (_, i) => { + const idx = i % tickers.length + const base = (i + 4000).toString(16).toUpperCase().slice(0, 8) + return { + tokenId: `rIss${base}...${base}Y`, + ticker: tickers[idx], + price: `$${(2 + idx * 1.5 + (i % 4)).toFixed(2)}`, + supply: `${1000 + i * 50}`, + assetClass: idx % 2 === 0 ? 'RWA' : 'Utility', + fee: `${(0.05 + idx * 0.005).toFixed(2)}%`, + frozen: idx % 2 === 0 ? 'Global' : '--', + } + }) +} + +/* exports */ +export const mockHeldIOUs = genHeldIOUs(20) +export const mockHeldMPTs = genHeldMPTs(20) +export const mockHeldLP = genHeldLPTokens(20) +export const mockHeldNFT = genHeldNFTs(100) + +export const issuedIOUs = genIssuedIOUs(20) +export const issuedMPs = genIssuedMPTs(20) +export const issuedNFTs = genIssuedNFTs(100) diff --git a/src/containers/Accounts/AccountAsset/styles.scss b/src/containers/Accounts/AccountAsset/styles.scss new file mode 100644 index 000000000..d44ae67a7 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/styles.scss @@ -0,0 +1,183 @@ +@use '../../shared/css/variables' as *; + +.account-asset { + padding: 24px 0px; +} + +.account-asset-title { + @include bold; + + margin: 0 0 20px; + font-size: 20px; + + /* Only the second account-asset-title gets extra bottom margin */ + &:nth-of-type(2) { + margin-top: 24px; + } +} + +.account-asset-tabs { + display: flex; + height: 30px; + flex-wrap: wrap; + align-items: flex-start; + border-radius: 100px; + margin-bottom: 15px; + background: $black-80; + gap: 10px; +} + +.account-asset-tab { + padding: 4px 10px; + border: 1px solid transparent; + border-radius: 100px; + background: transparent; + color: var(--text); + cursor: pointer; + font-size: 14px; + white-space: nowrap; +} + +.account-asset-tab:hover { + border-color: rgb(255 255 255 / 3%); + background: rgb(255 255 255 / 2%); +} + +.account-asset-tab.is-active { + @include bold; + + padding: 4px 14px; + border: 1px solid $green-50; + background: $black-90; + color: $black-0; +} + +.account-asset-table { + background: transparent; + + /* Allow horizontal scrolling if the table is wider than its parent */ + -webkit-overflow-scrolling: touch; + overflow-x: auto; +} + +.account-asset-table-wrapper-fixed .account-asset-table { + overflow: auto; /* Vertical scroll when overflowing, keep horizontal if needed */ + max-height: 270px; /* Fixed viewport height for the table area */ + background: transparent; + -webkit-overflow-scrolling: touch; + + /* Custom scroll bars */ + scrollbar-color: rgb(255 255 255 / 20%) transparent; + scrollbar-width: thin; /* Firefox */ + + /* WebKit */ + &::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border: 2px solid transparent; /* create padding effect */ + border-radius: 6px; + background: rgb(255 255 255 / 20%); + background-clip: padding-box; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgb(255 255 255 / 12%); + } +} + +.account-asset-table table { + width: 100%; + min-width: 980px; + border-collapse: separate; + color: var(--text); +} + +.account-asset-table thead th { + @include semibold; + + /* Make header sticky for scrollable tables */ + position: sticky; + z-index: 2; + top: 0; + padding: 10px 12px; + backdrop-filter: blur(100px); /* As table rows scroll under the header, they appear blurred behind it */ + color: $black-50; + font-size: 12px; + text-align: left; + text-transform: uppercase; +} + +.account-asset-table tbody td { + padding: 10px 12px; + border-bottom: 1px solid $black-80; /* Table row separator */ + color: $black-0; + font-size: 14px; + text-align: left; + + &.empty-message { + padding: 16px; + color: $black-40; + font-size: 16px; + text-align: center; + } +} + +/* Add border-top to the first row to separate header from body */ +.account-asset-table tbody tr:first-child td { + border-top: 1px solid $black-80; +} + +/* Fee column in orange */ +.account-asset-table tbody td.transfer-fee, +.account-asset-table tbody td.transfer-fee * { + color: $orange-50; +} + +@media (max-width: $phone-upper-boundary) { + .account-asset-tabs { + display: grid; + height: auto; + align-items: stretch; + padding: 4px; + border-radius: 12px; + gap: 4px; + grid-template-columns: 1fr 1fr; /* Two columns */ + } + + .account-asset-tab { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 100px; + background: transparent; + font-size: 13px; + white-space: nowrap; + } + + /* Allow more height for scrollable tables on small screen */ + .account-asset-table-wrapper-fixed .account-asset-table { + max-height: 400px; + } +} + +/* Clock icon styles */ +.future-feature { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: default; + + .clock-icon { + width: px; + height: 16px; + color: $black-50; + } +} diff --git a/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx b/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx index 6445b33ba..62dd73af2 100644 --- a/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx +++ b/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx @@ -23,8 +23,6 @@ export const AccountAssetTab = ({ account }: Props) => { function switchAsset(event: ChangeEvent) { return navigate(`/accounts/${accountId}/assets/${event.target.value}`) } - // eslint-disable-next-line react/jsx-no-useless-fragment - if (account.deleted) return <> return ( <>
diff --git a/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelector.tsx b/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelector.tsx deleted file mode 100644 index 3ca3a7ea9..000000000 --- a/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelector.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { Dropdown } from '../../../shared/components/Dropdown' -import { BalanceSelectorItem } from './BalanceSelectorItem' -import './balance-selector.scss' - -export interface BalanceSelectorProps { - balances: { [currency: string]: string } - onSetCurrencySelected: (currency: string) => void - currencySelected: string -} - -export const BalanceSelector = ({ - balances, - onSetCurrencySelected, - currencySelected, -}: BalanceSelectorProps) => { - const { t } = useTranslation() - const balanceTuples = Object.entries(balances) - const title = `${balanceTuples.length - 1} ${t('accounts.other_balances')}` - - return ( - - {balanceTuples.map(([currency, value]) => { - if (currency === currencySelected) { - return undefined - } - - return ( - { - onSetCurrencySelected(currency) - }} - value={value} - /> - ) - })} - - ) -} diff --git a/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelectorItem.tsx b/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelectorItem.tsx deleted file mode 100644 index 27a7da75c..000000000 --- a/src/containers/Accounts/AccountHeader/BalanceSelector/BalanceSelectorItem.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import { useLanguage } from '../../../shared/hooks' -import { CURRENCY_OPTIONS } from '../../../shared/transactionUtils' -import { localizeNumber } from '../../../shared/utils' -import { DropdownItem } from '../../../shared/components/Dropdown' -import Currency from '../../../shared/components/Currency' - -export interface BalanceSelectorItemProps { - currency: string - handler: (currency) => void - value: string -} - -export const BalanceSelectorItem = React.memo( - ({ currency, value, handler }: BalanceSelectorItemProps) => { - const language = useLanguage() - const options = { - ...CURRENCY_OPTIONS, - currency, - minimumFractionDigits: 0, - maximumFractionDigits: 2, - } - const formattedValue = localizeNumber(value, language, options) || '0.00' - - return ( - - - {formattedValue} - - ) - }, -) diff --git a/src/containers/Accounts/AccountHeader/BalanceSelector/balance-selector.scss b/src/containers/Accounts/AccountHeader/BalanceSelector/balance-selector.scss deleted file mode 100644 index 31c4742a4..000000000 --- a/src/containers/Accounts/AccountHeader/BalanceSelector/balance-selector.scss +++ /dev/null @@ -1,32 +0,0 @@ -@use '../../../shared/css/variables.scss' as *; - -.balance-selector { - position: relative; - display: block; - width: 100%; - - @include for-size(tablet-landscape-up) { - width: 280px; - } - - .dropdown-toggle { - padding: 12px 16px; - } - - .dropdown-menu { - width: 100%; - } - - .dropdown-item { - display: flex; - } - - .currency { - font-weight: bold; - } - - .total-balance { - margin-left: auto; - color: $black-40; - } -} diff --git a/src/containers/Accounts/AccountHeader/SubheaderLine.tsx b/src/containers/Accounts/AccountHeader/SubheaderLine.tsx deleted file mode 100644 index dd2f3ad17..000000000 --- a/src/containers/Accounts/AccountHeader/SubheaderLine.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { PropsWithChildren } from 'react' -import classnames from 'classnames' - -export type SubheaderLineProps = PropsWithChildren<{ - className?: string - label: string -}> - -export const SubheaderLine = (props: SubheaderLineProps) => { - const { label, children, className } = props - return ( -
  • - {label} -
    {children}
    -
  • - ) -} diff --git a/src/containers/Accounts/AccountHeader/index.tsx b/src/containers/Accounts/AccountHeader/index.tsx index 112475809..b62b7390f 100644 --- a/src/containers/Accounts/AccountHeader/index.tsx +++ b/src/containers/Accounts/AccountHeader/index.tsx @@ -1,313 +1,71 @@ -import { Trans, useTranslation } from 'react-i18next' +import { useTranslation } from 'react-i18next' +import InfoIcon from '../../shared/images/info-duotone.svg' +import DomainLink from '../../shared/components/DomainLink' import './styles.scss' -import { BalanceSelector } from './BalanceSelector/BalanceSelector' import { Account } from '../../shared/components/Account' -import { localizeNumber } from '../../shared/utils' -import InfoIcon from '../../shared/images/info.svg' -import { useLanguage } from '../../shared/hooks' -import Currency from '../../shared/components/Currency' -import DomainLink from '../../shared/components/DomainLink' -import { AccountState } from '../../../rippled/accountState' - -const CURRENCY_OPTIONS = { - style: 'currency', - currency: 'XRP', - minimumFractionDigits: 2, - maximumFractionDigits: 6, -} interface AccountHeaderProps { - onSetCurrencySelected: (currency: string) => void - currencySelected: string - account: any + isAccountDeleted: boolean accountId: string + account?: any } -export const AccountHeader = ({ - account, +const AccountHeader = ({ + isAccountDeleted, accountId, - onSetCurrencySelected, - currencySelected, + account, }: AccountHeaderProps) => { const { t } = useTranslation() - const language = useLanguage() - - function renderBalancesSelector(data: AccountState) { - const { balances = {} } = data - return ( - Object.keys(balances).length > 1 && ( -
    - -
    - ) - ) - } + const xAddress = account?.xAddress - function renderPaymentChannels(data: AccountState) { - const { paychannels } = data - return ( - paychannels && ( -
    -
    {t('payment_channels')}
    -
      -
    • - - {localizeNumber( - paychannels.total_available, - language, - CURRENCY_OPTIONS, - )} - - {t('available_in')} - {localizeNumber(paychannels.channels.length, language)} - {t('channels')} -
    • -
    -
    - ) - ) - } - - function renderEscrows(data: AccountState) { - const { escrows } = data - return ( - escrows && ( -
    -
    {t('escrows')}
    -
      -
    • - {t('inbound_total')}: - - {localizeNumber(escrows.totalIn, language, CURRENCY_OPTIONS)} - -
    • -
    • - {t('outbound_total')}: - - {localizeNumber(escrows.totalOut, language, CURRENCY_OPTIONS)} - -
    • -
    -
    - ) - ) - } - - function renderSignerList(data: AccountState) { - const { signerList } = data - return ( - signerList && ( -
    -

    {t('signers')}

    -
      - {signerList.signers.map((d) => ( -
    • - Signer - -
      - {` ${t('weight')}:`} - {d.weight} -
      -
    • - ))} - -
    • - -
    • -
    + return ( +
    + {isAccountDeleted && ( +
    +
    +
    +
    + {t('account_page_deleted_account_warning')} +
    - ) - ) - } - // TODO: show X-address on 'classic' account pages + )} - function renderExtendedAddress(data: AccountState) { - const { xAddress } = data // undefined when page has not yet finished loading - - let messageAboutTag: JSX.Element | string = '' - if (xAddress && xAddress.tag !== false) { - messageAboutTag = ( -
  • - - - ⚠️ - {' '} - This address contains a tag, indicating it may refer to a hosted - wallet. The balance and transactions below apply to the entire - account. They are not filtered to the specified destination tag. - -
  • - ) - } - - // TODO: Translate everything e.g. {t('signers')} - return ( - xAddress && ( -
    -
    Extended Address (X-address) Details
    -

    - {xAddress.classicAddress} - {xAddress.tag ? `:${xAddress.tag}` : ''} -

    -
      -
    • - - Classic Address: - {' '} - {/* TODO: Add [copy] button */} - -
    • -
    • - Tag: - - {xAddress.tag === false ? 'false' : xAddress.tag} - -
    • -
    • - Network: - - {xAddress.test ? 'Testnet' : 'Mainnet'} - -
    • - {messageAboutTag} -
    +
    +
    + {xAddress + ? t('account_page_extended_address') + : t('account_page_address')}
    - ) - ) - } - - function renderInfo(data: AccountState) { - const { info } = data - return ( - info && ( -
    -
    {t('account_info')}
    -
      -
    • - {t('reserve')}: - - {localizeNumber( - info.reserve || 0.0, - language, - CURRENCY_OPTIONS, - )} - -
    • -
    • - {t('current_sequence')}: - {localizeNumber(info.sequence, language)} -
    • - {info.ticketCount && ( -
    • - {t('ticket_count')}: - {localizeNumber(info.ticketCount, language)} -
    • - )} - {info.domain && ( -
    • - {t('domain')}: - -
    • - )} - {info.emailHash && ( -
    • - {t('email_hash')}: - {info.emailHash} -
    • - )} - {info.nftMinter && ( -
    • - {t('nftoken_minter')}: - -
    • - )} - {info.flags.length ? ( -
    • -
        - {info.flags.map((flag) => ( -
      • {flag}
      • - ))} -
      -
    • - ) : null} -
    +
    + {accountId}
    - ) - ) - } - - function renderHeaderContent(data: AccountState) { - const { balances = {}, deleted } = data - const balance = localizeNumber( - balances[currencySelected] || 0.0, - language, - { - style: 'currency', - currency: currencySelected, - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }, - ) - return ( -
    -
    - {renderExtendedAddress(data)} -
    - {deleted ? ( -
    - {' '} - Account Deleted -
    - ) : ( - <> -
    - - - -
    -
    {balance}
    - - )} - {renderBalancesSelector(data)} + {xAddress?.classicAddress && ( +
    +
    + {t('account_page_classic_address')}: +
    +
    -
    -
    - {renderInfo(data)} - {renderEscrows(data)} - {renderPaymentChannels(data)} -
    -
    -
    {renderSignerList(data)}
    -
    -
    - ) - } - - const xAddress = account?.xAddress ?? false - const hasBridge = account?.hasBridge ?? false - - return ( -
    -
    -
    - Account ID - {hasBridge &&
    Door Account
    } -
    -

    {accountId}

    -
    -
    - {account != null && renderHeaderContent(account)} + )} + {xAddress?.tag && ( +
    +
    + {t('account_page_address_tag')}: +
    +
    {xAddress.tag}
    +
    + )} + {account?.info?.domain && ( +
    +
    {t('account_page_domain')}:
    + +
    + )}
    ) } + +export default AccountHeader diff --git a/src/containers/Accounts/AccountHeader/styles.scss b/src/containers/Accounts/AccountHeader/styles.scss index d76a9033d..013810487 100644 --- a/src/containers/Accounts/AccountHeader/styles.scss +++ b/src/containers/Accounts/AccountHeader/styles.scss @@ -1,308 +1,133 @@ @use '../../shared/css/variables' as *; .account-header { - margin-bottom: 16px; + padding: 50px 0 30px; - .title { - padding-bottom: 4px; - color: $black-40; - font-size: 14px; - text-transform: uppercase; - @include semibold; - } - - .box-header { - margin-top: 106px; - margin-bottom: 80px; - - .title { + .deleted-banner { + display: flex; + flex-direction: column; + padding: 25px; + border: 1px solid $orange-50; + border-radius: 8px; + margin-bottom: 30px; + background: $black-90; + gap: 12px; + + .deleted-label { display: flex; - flex-direction: row; + max-width: 180px; align-items: center; - } - - h1 { - overflow: hidden; - text-overflow: ellipsis; - } - - .badge { - border: 1px solid $yellow-50; - background: $yellow-50; - box-shadow: 0 1px 1px $yellow-80; - color: $black; - } - - .classic { - margin: 0; - color: $white; - font-size: 20px; - @include bold; - - @include for-size(tablet-landscape-up) { - font-size: 24px; - } - - @include for-size(desktop-up) { - font-size: 36px; - } - } - - .currency-pair { - display: inline-block; - padding: 5px; - border-width: thin; - border-style: solid; - border-color: $black-70; - margin: 0; - margin-left: 12px; - color: $white; - font-size: 16px; - text-transform: none; + padding: 3px 12px; + border: 1px solid $orange-50; + border-radius: 100px; + margin-bottom: 8px; + background: $orange-80; + color: $black-0; + font-size: 12px; @include semibold; - @include for-size(tablet-landscape-up) { - font-size: 10px; - } + gap: 10px; + text-transform: uppercase; - @include for-size(desktop-up) { - font-size: 20px; + .deleted-info-icon { + width: 16px; + height: 16px; + margin-right: 3px; } } - .amm { - display: inline-block; - padding-right: 10px; - border-bottom: none; + .deleted-message { margin: 0; - color: $white; - font-size: 18px; - @include bold; - - @include for-size(tablet-landscape-up) { - font-size: 10px; - } - - @include for-size(desktop-up) { - font-size: 23px; - } - } - } - - .x-address { - margin: 0; - color: $white; - font-size: 14px; - @include bold; - - @include for-size(tablet-landscape-up) { - font-size: 18px; - } - - @include for-size(desktop-up) { - font-size: 36px; + color: $black-0; + font-size: 16px; } } - .box-content { - min-height: 100px; - padding-bottom: 20px; - } - - .info-container { + .address-panel { display: flex; + height: auto; + box-sizing: border-box; + flex: none; flex-direction: column; - justify-content: space-between; - margin-top: 68px; - - @include for-size(desktop-up) { - flex-direction: row; - margin-top: 80px; - } - - .values { - display: flex; - flex-direction: row; - justify-content: space-between; - margin-bottom: 18px; - color: $white; - - @include for-size(desktop-up) { - flex-direction: column; - margin-bottom: 0; - } + flex-grow: 0; + align-items: flex-start; + align-self: stretch; + order: 0; + padding: 25px; + border: 1px solid $black-70; + border-radius: 8px; + background: $black-90; + gap: 10px; + + .address-label { + color: $black-40; + font-size: 14px; + @include semibold; - .value { - color: $white; - font-size: 18px; - line-height: 22.5px; - text-decoration: none; - @include bold; - } + line-height: 1; + text-transform: uppercase; } - } - .lower-header { - display: flex; - flex-direction: column; - gap: 16px; + .address-value { + display: inline-flex; + align-items: center; + color: $black-0; + font-size: 24px; + @include bold; - @include for-size(desktop-up) { - flex-direction: row; + line-height: 1; + word-break: break-all; } - } - - .column { - display: inline-block; - width: 100%; - vertical-align: top; - @include for-size(desktop-up) { - width: 50%; + .domain { + display: flex; - &.first { - padding-right: calc(50% - clamp(47.5%, 500px, 50%)); + .domain-label { + margin-right: 8px; + color: $black-40; + font-size: 14px; + line-height: 1; } - &.second { - padding-left: calc(50% - clamp(47.5%, 500px, 50%)); - text-align: right; + a { + font-size: 14px; + line-height: 1; + word-break: break-all; } } - .secondary { - padding: 0; - margin-bottom: 20px; - font-size: 12px; - @include bold; - - @include for-size(desktop-up) { - border: none; - } + .classic-address { + display: flex; - .label { + .classic-address-label { + margin-right: 8px; color: $black-40; - text-align: left; - text-transform: uppercase; - @include medium; + font-size: 14px; + line-height: 1; } - ul { - width: 100%; - padding: 0; - margin: 0; - list-style: none; - - li { - padding: 1px; - vertical-align: top; - - a { - display: inline-block; - text-transform: none; - vertical-align: middle; - @include medium; - @extend %truncate; - } - - b { - color: $white; - word-break: break-all; - @include bold; - } - - &.flags { - color: $blue-purple-30; - font-style: italic; - } - } - } - - &.x-address ul { - width: 100%; - - li { - display: flex; - justify-content: space-between; - - @media (width >= 450px) { - display: block; - } - - .tag { - font-weight: 500; - } - - .network { - font-weight: 500; - } - - &.tag-info { - padding-top: 14px; - font-size: 14px; - } - } + a { + font-size: 14px; + line-height: 1; + word-break: break-all; } + } - &.balance { - .value { - margin: -4px 0 5px -0.1em; - color: $white; - font-size: 26px; - line-height: 30px; - @include regular; - @include for-size(desktop-up) { - font-size: 30px; - line-height: 34px; - } - @extend %truncate; - } + .address-tag { + display: flex; - .balance-selector-container { - position: relative; - margin-top: 10px; - } + .address-tag-label { + margin-right: 8px; + color: $black-40; + font-size: 14px; + line-height: 1; } - &.signer-list { - display: block; - font-weight: 500; - - h2 { - text-align: left; - } - - ul { - li { - display: flex; - - .label { - padding-right: 15px; - text-align: left; - white-space: nowrap; - } - - .value { - overflow: hidden; - margin-left: auto; - font-weight: inherit; - text-overflow: ellipsis; - } - - .account { - display: block; - font-weight: inherit; - @extend %truncate; - } - - .weight { - overflow: inherit; - text-align: right; - text-overflow: inherit; - white-space: nowrap; - } - } - } + .address-tag-value { + color: $black-0; + font-size: 14px; + line-height: 1; + word-break: break-all; } } } diff --git a/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx b/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx deleted file mode 100644 index 2d730716e..000000000 --- a/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { mount } from 'enzyme' -import { QueryClientProvider } from 'react-query' -import { I18nextProvider } from 'react-i18next' -import { BrowserRouter } from 'react-router-dom' -import { testQueryClient } from '../../../test/QueryClient' -import i18n from '../../../../i18n/testConfig' -import { AccountHeader } from '..' -import { flushPromises, V7_FUTURE_ROUTER_FLAGS } from '../../../test/utils' - -const TEST_ADDRESS = 'rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv' -const TEST_X_ADDRESS = 'XV3oNHx95sqdCkTDCBCVsVeuBmvh2dz5fTZvfw8UCcMVsfe' - -describe('AccountHeader Actions', () => { - const createWrapper = (account, accountId) => - mount( - - - - - - - , - ) - - beforeEach(() => { - jest.resetModules() - }) - - it('successful account header', async () => { - const mockedGetAccountState = { - account: TEST_ADDRESS, - ledger_index: 68990183, - info: { - sequence: 2148991, - ticketCount: undefined, - ownerCount: 0, - reserve: 10, - tick: undefined, - rate: undefined, - domain: undefined, - emailHash: undefined, - flags: [], - balance: '123456000', - nftMinter: undefined, - previousTxn: - '6B6F2CA1633A22247058E988372BA9EFFFC5BF10212230B67341CA32DC9D4A82', - previousLedger: 68990183, - }, - deleted: false, - balances: { XRP: 123.456 }, - signerList: undefined, - tokens: [], - escrows: undefined, - paychannels: null, - xAddress: undefined, - hasBridge: false, - } - - const wrapper = createWrapper(mockedGetAccountState, TEST_ADDRESS) - await flushPromises() - wrapper.update() - - expect(wrapper.find('h1').text()).toBe(TEST_ADDRESS) - - wrapper.unmount() - }) - - it('account with tokens', async () => { - const mockedGetAccountState = { - account: TEST_ADDRESS, - ledger_index: 72338736, - info: { - sequence: 1227, - ticketCount: undefined, - ownerCount: 28, - reserve: 66, - tick: undefined, - rate: undefined, - domain: undefined, - emailHash: undefined, - flags: [], - balance: '1172875760329', - previousTxn: - '259A84CE4B3B09D5FBCAA133F62FC767CA2B57B3C64CF065F7546AA63D55E070', - previousLedger: 67657581, - }, - balances: { - '0158415500000000C1F76FF6ECB0BAC600000000': 3.692385398244198, - BTC: 1.075524263886059, - CHF: 0.7519685210971255, - CNY: 12.328638002, - DYM: 95.13258522535791, - EUR: 53.426387263174405, - GBP: 79.51188949705619, - JPY: 4986.30908732758, - USD: 936.8290046958887, - XAU: 3.419442510305086, - XRP: 1172875.760329, - }, - signerList: undefined, - tokens: [ - { - amount: 95.13258522535791, - currency: 'DYM', - issuer: 'rGwUWgN5BEg3QGNY3RX2HfYowjUTZdid3E', - }, - { - amount: 20, - currency: 'USD', - issuer: 'rME7HanzUymzFvETpoLgAy5rvxGcKiLrYL', - }, - { - amount: 255.7836054268899, - currency: 'USD', - issuer: 'rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q', - }, - { - amount: 1.075524263886059, - currency: 'BTC', - issuer: 'rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q', - }, - { - amount: 12.328638002, - currency: 'CNY', - issuer: 'rnuF96W4SZoCJmbHYBFoJZpR8eCaxNvekK', - }, - { - amount: 3.419442510305086, - currency: 'XAU', - issuer: 'rrh7rf1gV2pXAoqA8oYbpHd8TKv5ZQeo67', - }, - { - amount: 3.692385398244198, - currency: '0158415500000000C1F76FF6ECB0BAC600000000', - issuer: 'rrh7rf1gV2pXAoqA8oYbpHd8TKv5ZQeo67', - }, - { - amount: 0.7519685210971255, - currency: 'CHF', - issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', - }, - { - amount: 78.5098894970562, - currency: 'GBP', - issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', - }, - { - amount: 4986.30908732758, - currency: 'JPY', - issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', - }, - { - amount: 28.42638726317441, - currency: 'EUR', - issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', - }, - ], - escrows: undefined, - paychannels: null, - xAddress: undefined, - hasBridge: false, - deleted: false, - } - - const wrapper = createWrapper(mockedGetAccountState, TEST_ADDRESS) - await flushPromises() - wrapper.update() - - expect(wrapper.find('h1').text()).toBe(TEST_ADDRESS) - expect(wrapper.find('.balance-selector-container')).toExist() - expect(wrapper.find('.balance .value').text()).toBe('\uE9001,172,875.76') - - wrapper.unmount() - }) - - it('X-Address', async () => { - const mockedGetAccountState = { - account: TEST_ADDRESS, - ledger_index: 68990183, - info: { - sequence: 2148991, - ticketCount: undefined, - ownerCount: 0, - reserve: 10, - tick: undefined, - rate: undefined, - domain: undefined, - emailHash: undefined, - flags: [], - balance: '123456000', - nftMinter: undefined, - previousTxn: - '6B6F2CA1633A22247058E988372BA9EFFFC5BF10212230B67341CA32DC9D4A82', - previousLedger: 68990183, - }, - deleted: false, - balances: { XRP: 123.456 }, - signerList: undefined, - tokens: [], - escrows: undefined, - hasBridge: false, - paychannels: null, - xAddress: { - classicAddress: TEST_ADDRESS, - tag: 0, - test: false, - }, - } - - const wrapper = createWrapper(mockedGetAccountState, TEST_X_ADDRESS) - await flushPromises() - wrapper.update() - - expect(wrapper.find('h1').text()).toBe(TEST_X_ADDRESS) - - wrapper.unmount() - }) - - it('deleted account', async () => { - const mockedGetAccountState = { - account: TEST_ADDRESS, - deleted: true, - xAddress: undefined, - } - - const wrapper = createWrapper(mockedGetAccountState, TEST_ADDRESS) - await flushPromises() - wrapper.update() - - expect(wrapper.find('h1').text()).toBe(TEST_ADDRESS) - expect(wrapper.find('.warning').find('.account-deleted-text').text()).toBe( - 'Account Deleted', - ) - - wrapper.unmount() - }) -}) diff --git a/src/containers/Accounts/AccountHeader/test/accountDeletedTransactions.json b/src/containers/Accounts/AccountHeader/test/accountDeletedTransactions.json deleted file mode 100644 index 58d5c9a1a..000000000 --- a/src/containers/Accounts/AccountHeader/test/accountDeletedTransactions.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "account_info": { - "id": 2, - "status": "error", - "type": "response", - "account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "error": "actNotFound", - "error_code": 19, - "error_message": "Account not found.", - "ledger_current_index": 80602456, - "request": { - "account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "command": "account_info", - "id": 2, - "ledger_index": "current", - "queue": true, - "strict": true - }, - "validated": false, - "forwarded": true, - "warnings": [ - { - "id": 2001, - "message": "This is a clio server. clio only serves validated data. If you want to talk to rippled, include 'ledger_index':'current' in your request" - } - ] - }, - "account_tx": { - "id": 2, - "status": "success", - "type": "response", - "result": { - "account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "ledger_index_min": 32570, - "ledger_index_max": 80602468, - "transactions": [ - { - "meta": { - "AffectedNodes": [ - { - "ModifiedNode": { - "FinalFields": { - "Account": "rEoRCpve6aPs6gg2u5aaeCsBmX9ZB7TzhR", - "Balance": "1553170065225", - "Flags": 0, - "OwnerCount": 0, - "Sequence": 80463368 - }, - "LedgerEntryType": "AccountRoot", - "LedgerIndex": "856F60BEC3D95BC1035C2D9691CA4CA2FD455FA0D62783F504277374A6966589", - "PreviousFields": { - "Balance": "1553152515225" - }, - "PreviousTxnID": "005613ADA8AA0618C62B4EC53C181ED8831E694F24F1B77706DE98218522AADF", - "PreviousTxnLgrSeq": 80537220 - } - }, - { - "DeletedNode": { - "FinalFields": { - "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "Balance": "0", - "Flags": 0, - "OwnerCount": 0, - "PreviousTxnID": "B5180561D25F1D17672F2FCED48E06F3B72D43E33202660BF57647D1914D940A", - "PreviousTxnLgrSeq": 29256436, - "Sequence": 8 - }, - "LedgerEntryType": "AccountRoot", - "LedgerIndex": "EAE9EC4DC190D2C0A33E8963D86B48D3A18394B04E2C891176E5A78E1EFD7023", - "PreviousFields": { - "Balance": "19550000", - "Sequence": 7 - } - } - } - ], - "DeliveredAmount": "17550000", - "TransactionIndex": 25, - "TransactionResult": "tesSUCCESS", - "delivered_amount": "17550000" - }, - "tx": { - "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "Destination": "rEoRCpve6aPs6gg2u5aaeCsBmX9ZB7TzhR", - "DestinationTag": 2123456789, - "Fee": "2000000", - "Flags": 2147483648, - "Sequence": 7, - "SigningPubKey": "029D82602618266B98506884CE4C61CECF5504CBCEDA41CED583FE4C6972455C9E", - "TransactionType": "AccountDelete", - "TxnSignature": "3045022100A95A9508F96B5D979BD2AFE1F667733DDFC06A5BF79C5C085439CB39C4B14B04022074D84F5AB63390565A4D12D313A8D4666370A99F05B0B887505A80F431E18682", - "hash": "BB955E1E2DDC4529A79F6DCB9741013B6D218FFBE441871FC466895DD4300F37", - "ledger_index": 80537221, - "date": 740350911 - }, - "validated": true - }, - { - "meta": { - "AffectedNodes": [ - { - "ModifiedNode": { - "FinalFields": { - "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "Balance": "19550000", - "Flags": 0, - "OwnerCount": 0, - "Sequence": 7 - }, - "LedgerEntryType": "AccountRoot", - "LedgerIndex": "EAE9EC4DC190D2C0A33E8963D86B48D3A18394B04E2C891176E5A78E1EFD7023", - "PreviousFields": { - "Balance": "19700000", - "Sequence": 6 - }, - "PreviousTxnID": "56675612ACAF05BAA0499A0CD2D81E0C2B66C2133A2AC328CE311B0BD8C83401", - "PreviousTxnLgrSeq": 29256433 - } - } - ], - "TransactionIndex": 22, - "TransactionResult": "tecUNFUNDED_PAYMENT" - }, - "tx": { - "Account": "r35jYntLwkrbc3edisgavDbEdNRSKgcQE6", - "Amount": "4856600000", - "Destination": "rDCgaaSBAWYfsxUYhCk1n26Na7x8PQGmkq", - "Fee": "150000", - "Flags": 2147483648, - "Sequence": 6, - "SigningPubKey": "029D82602618266B98506884CE4C61CECF5504CBCEDA41CED583FE4C6972455C9E", - "TransactionType": "Payment", - "TxnSignature": "3045022100D84347CBC15152E08D83AD7F50B29E07548DB142E69D23D7B983AFD71E5AE67F02205EA4717A39120489DA26088BAD9F741D9F2B87A78C863CBED47FE518675B8215", - "hash": "B5180561D25F1D17672F2FCED48E06F3B72D43E33202660BF57647D1914D940A", - "ledger_index": 29256436, - "date": 546295030 - }, - "validated": true - } - ], - "validated": true, - "marker": { - "ledger": 29256436, - "seq": 22 - }, - "limit": 2 - }, - "warnings": [ - { - "id": 2001, - "message": "This is a clio server. clio only serves validated data. If you want to talk to rippled, include 'ledger_index':'current' in your request" - } - ] - } -} diff --git a/src/containers/Accounts/AccountHeader/test/accountWithTokens.json b/src/containers/Accounts/AccountHeader/test/accountWithTokens.json deleted file mode 100644 index 8d7feab70..000000000 --- a/src/containers/Accounts/AccountHeader/test/accountWithTokens.json +++ /dev/null @@ -1,219 +0,0 @@ -{ - "account_info": { - "result": { - "account_data": { - "Account": "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY", - "Balance": "1172875760329", - "Flags": 0, - "LedgerEntryType": "AccountRoot", - "OwnerCount": 28, - "PreviousTxnID": "259A84CE4B3B09D5FBCAA133F62FC767CA2B57B3C64CF065F7546AA63D55E070", - "PreviousTxnLgrSeq": 67657581, - "Sequence": 1227, - "index": "9B242A0D59328CE964FFFBFF7D3BBF8B024F9CB1A212923727B42F24ADC93930", - "signer_lists": [] - }, - "ledger_hash": "5B60A4BE898B7ED490F5126D1CB5FF5DCA64B7974B6DB7DBA7D7F0EC0F8C6D75", - "ledger_index": 72338736, - "validated": true, - "warnings": [ - { - "id": 1004, - "message": "This is a reporting server. The default behavior of a reporting server is to only return validated data. If you are looking for not yet validated data, include \"ledger_index : current\" in your request, which will cause this server to forward the request to a p2p node. If the forward is successful the response will include \"forwarded\" : \"true\"" - } - ] - }, - "status": "success", - "type": "response" - }, - "account_objects": { - "result": { - "account": "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY", - "account_objects": [], - "ledger_hash": "1BAA8681189844A207D3C6AAF6F1375663F434B2534A87E8D1821E5E8F670D36", - "ledger_index": 72338713, - "validated": true, - "warnings": [ - { - "id": 1004, - "message": "This is a reporting server. The default behavior of a reporting server is to only return validated data. If you are looking for not yet validated data, include \"ledger_index : current\" in your request, which will cause this server to forward the request to a p2p node. If the forward is successful the response will include \"forwarded\" : \"true\"" - } - ] - }, - "status": "success", - "type": "response" - }, - "gateway_balances": { - "result": { - "account": "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY", - "assets": { - "rGwUWgN5BEg3QGNY3RX2HfYowjUTZdid3E": [ - { - "currency": "DYM", - "value": "95.13258522535792" - } - ], - "rME7HanzUymzFvETpoLgAy5rvxGcKiLrYL": [ - { - "currency": "USD", - "value": "20" - } - ], - "rMwjYedjc7qqtKYVLiAccJSmCwih4LnE2q": [ - { - "currency": "USD", - "value": "255.7836054268899" - }, - { - "currency": "BTC", - "value": "1.075524263886059" - } - ], - "rnuF96W4SZoCJmbHYBFoJZpR8eCaxNvekK": [ - { - "currency": "CNY", - "value": "12.328638002" - } - ], - "rrh7rf1gV2pXAoqA8oYbpHd8TKv5ZQeo67": [ - { - "currency": "XAU", - "value": "3.419442510305086" - }, - { - "currency": "0158415500000000C1F76FF6ECB0BAC600000000", - "value": "3.692385398244198" - } - ], - "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B": [ - { - "currency": "CHF", - "value": "0.7519685210971255" - }, - { - "currency": "GBP", - "value": "78.5098894970562" - }, - { - "currency": "JPY", - "value": "4986.30908732758" - }, - { - "currency": "EUR", - "value": "28.42638726317441" - } - ] - }, - "ledger_hash": "A03F61C5E0A3E880BECFE05D2435CF370A0F962FEF574C09B024C3C9BD69C1A4", - "ledger_index": 72338588, - "obligations": { - "EUR": "25", - "GBP": "1.002", - "USD": "661.0453992689988" - }, - "validated": true - }, - "status": "success", - "type": "response" - }, - "server_info": { - "result": { - - "info": { - "build_version": "1.9.1", - "complete_ledgers": "32570-72338589", - "hostid": "HONE", - "initial_sync_duration_us": "15604", - "io_latency_ms": 1, - "last_close": { - "converge_time_s": 0, - "proposers": 0 - }, - "load_factor": 1, - "pubkey_node": "n9Ln87hHwcszpaGqr3L3zTD1psJrMgd6MaFQasXYsNBUhq9De6N8", - "published_ledger": "none", - "reporting": { - "etl_sources": [ - { - "connected": true, - "grpc_port": "50051", - "ip": "172.16.3.21", - "last_message_arrival_time": "2022-Jun-14 17:28:07.976885 UTC", - "validated_ledgers_range": "70162870-72338589", - "websocket_port": "51233" - }, - { - "connected": true, - "grpc_port": "50051", - "ip": "172.16.3.81", - "last_message_arrival_time": "2022-Jun-14 17:28:07.968027 UTC", - "validated_ledgers_range": "70151390-72338589", - "websocket_port": "51233" - }, - { - "connected": true, - "grpc_port": "50051", - "ip": "172.16.3.89", - "last_message_arrival_time": "2022-Jun-14 17:28:07.969184 UTC", - "validated_ledgers_range": "71108293-72338589", - "websocket_port": "51233" - }, - { - "connected": true, - "grpc_port": "50051", - "ip": "172.16.3.106", - "last_message_arrival_time": "2022-Jun-14 17:28:07.992218 UTC", - "validated_ledgers_range": "70161864-72338589", - "websocket_port": "51233" - } - ], - "is_writer": false, - "last_publish_time": "2022-Jun-14 17:28:04.981829 UTC" - }, - "server_state": "full", - "server_state_duration_us": "1817931687725", - "state_accounting": { - "connected": { - "duration_us": "0", - "transitions": "0" - }, - "disconnected": { - "duration_us": "15604", - "transitions": "1" - }, - "full": { - "duration_us": "1817931687725", - "transitions": "1" - }, - "syncing": { - "duration_us": "0", - "transitions": "0" - }, - "tracking": { - "duration_us": "0", - "transitions": "0" - } - }, - "time": "2022-Jun-14 17:28:07.993308 UTC", - "uptime": 1817935, - "validated_ledger": { - "age": 6, - "base_fee_xrp": 0.00001, - "hash": "35464A53AEDF0ADBBFD44D49EE58EF0B82D626B122F6338A36C99D395B594D4D", - "reserve_base_xrp": 10, - "reserve_inc_xrp": 2, - "seq": 72338589 - }, - "validation_quorum": 28 - }, - "warnings": [ - { - "id": 1004, - "message": "This is a reporting server. The default behavior of a reporting server is to only return validated data. If you are looking for not yet validated data, include \"ledger_index : current\" in your request, which will cause this server to forward the request to a p2p node. If the forward is successful the response will include \"forwarded\" : \"true\"" - } - ] - }, - "status": "success", - "type": "response" - } -} diff --git a/src/containers/Accounts/AccountHeader/test/rippledResponses.json b/src/containers/Accounts/AccountHeader/test/rippledResponses.json deleted file mode 100644 index 8b10781dd..000000000 --- a/src/containers/Accounts/AccountHeader/test/rippledResponses.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "account_info": { - "result": { - "account_data": { - "Account": "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv", - "Balance": "123456000", - "Flags": 0, - "LedgerEntryType": "AccountRoot", - "OwnerCount": 0, - "PreviousTxnID": "6B6F2CA1633A22247058E988372BA9EFFFC5BF10212230B67341CA32DC9D4A82", - "PreviousTxnLgrSeq": 68990183, - "Sequence": 2148991, - "index": "C3B625B296E95A21D7BBBB7E3D343AF423B463B87B5D56EE7F79C8E16A47A6F5", - "signer_lists": [] - }, - "ledger_hash": "43C4195B2C90423771E6C5DF4AED11BF3D77FFD1E8A153A489E5B00C96318FCA", - "ledger_index": 68990183, - "validated": true - }, - "status": "success", - "type": "response" - }, - "account_objects": { - "result": { - "account": "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv", - "account_objects": [], - "ledger_current_index": 24402380, - "validated": false - }, - "status": "success", - "type": "response" - }, - "gateway_balances": { - "result": { - "account": "rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv", - "ledger_hash": "F4023C801B8B4D05F16EFE5D8C4C3C14D02354AABBB94151F581A6BF0E04C20B", - "ledger_index": 24402706, - "validated": true - }, - "status": "success", - "type": "response" - }, - "server_info": { - "result": { - "info": { - "validated_ledger": { - "age": 1, - "base_fee_xrp": 0.00001, - "hash": "EA01E248FCA5CFD33A3393DA5EBCCD9219BA8DB6AF6DC28A3B0A968604F46A76", - "reserve_base_xrp": 10, - "reserve_inc_xrp": 2, - "seq": 24402729 - }, - "validation_quorum": 5 - } - }, - "status": "success", - "type": "response" - } -} diff --git a/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx b/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx index d1ab69f6c..c3a5f6ebf 100644 --- a/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx +++ b/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx @@ -9,10 +9,7 @@ import { getAccountMPTs, getMPTIssuance } from '../../../rippled/lib/rippled' import { Account } from '../../shared/components/Account' import { LoadMoreButton } from '../../shared/LoadMoreButton' import { MPTokenLink } from '../../shared/components/MPTokenLink' -import { - formatMPTokenInfo, - formatMPTIssuanceInfo, -} from '../../../rippled/lib/utils' +import { formatMPToken, formatMPTIssuance } from '../../../rippled/lib/utils' import { MPTIssuanceFormattedInfo } from '../../shared/Interfaces' import { convertScaledPrice } from '../../shared/utils' @@ -27,7 +24,7 @@ export const AccountMPTRow = ({ mpt }: any) => { ['getMPTIssuanceScale', mpt.mptIssuanceID], async () => { const info = await getMPTIssuance(rippledSocket, mpt.mptIssuanceID) - return formatMPTIssuanceInfo(info) + return formatMPTIssuance(info.node) }, { onError: (e: any) => { @@ -98,7 +95,7 @@ export const AccountMPTTable = ({ accountId }: AccountMPTTableProps) => { const mpts = pages?.pages .flatMap((page: any) => page.account_objects) - .map((mpt) => formatMPTokenInfo(mpt)) + .map((mpt) => formatMPToken(mpt)) return (
    diff --git a/src/containers/Accounts/AccountSummary/Balances.tsx b/src/containers/Accounts/AccountSummary/Balances.tsx new file mode 100644 index 000000000..3a3700314 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/Balances.tsx @@ -0,0 +1,75 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useLanguage } from '../../shared/hooks' +import { localizeNumber } from '../../shared/utils' +import { XRP_BASE } from '../../shared/transactionUtils' +import UsdIcon from '../../shared/images/usd_icon.svg' +import XrpIcon from '../../shared/images/xrp_balance_icon.svg' +import ReserveIcon from '../../shared/images/xrp_reserve_balance_icon.svg' +import { + XRP_CURRENCY_OPTIONS, + USD_CURRENCY_OPTIONS, +} from '../../shared/CurrencyOptions' + +interface BalancesProps { + account: any + xrpToUSDRate: number +} + +const Balances = ({ account, xrpToUSDRate }: BalancesProps) => { + const { t } = useTranslation() + const lang = useLanguage() + const { xrpBalance, xrpBalanceInUSD } = useMemo(() => { + const balance = (account.info?.balance ?? 0) / XRP_BASE + return { + xrpBalance: balance, + xrpBalanceInUSD: balance * xrpToUSDRate, + } + }, [account.info?.balance, xrpToUSDRate]) + + return ( +
    +
    +
    + {typeof XrpIcon === 'string' ? ( + XRP + ) : ( + + )} + {t('account_page_xrp_balance')} +
    +
    + {localizeNumber(xrpBalance, lang, XRP_CURRENCY_OPTIONS)} +
    +
    +
    +
    + + {t('account_page_xrp_balance_in_usd')} +
    +
    + {localizeNumber(xrpBalanceInUSD, lang, USD_CURRENCY_OPTIONS)} +
    +
    +
    +
    + {typeof ReserveIcon === 'string' ? ( + Reserve + ) : ( + + )} + {t('account_page_reserve_balance')} +
    +
    + {localizeNumber( + account.info?.reserve ?? 0, + lang, + XRP_CURRENCY_OPTIONS, + )} +
    +
    +
    + ) +} + +export default Balances diff --git a/src/containers/Accounts/AccountSummary/DetailsCard.tsx b/src/containers/Accounts/AccountSummary/DetailsCard.tsx new file mode 100644 index 000000000..9eade9075 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/DetailsCard.tsx @@ -0,0 +1,70 @@ +import { useTranslation } from 'react-i18next' +import { localizeNumber } from '../../shared/utils' +import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../shared/CurrencyOptions' +import { Account } from '../../shared/components/Account' + +interface DetailsCardProps { + account: any + lang: string +} + +const DetailsCard = ({ account, lang }: DetailsCardProps) => { + const { t } = useTranslation() + + return ( +
    +
    +
    {t('account_page_details')}
    +
    +
    +
    +
    + {t('account_page_current_sequence')} +
    +
    + {localizeNumber(account.info?.sequence, lang)} +
    +
    +
    +
    {t('account_page_ticket_count')}
    +
    + {localizeNumber(account.info?.ticketCount, lang)} +
    +
    + {account.info?.emailHash && ( +
    +
    {t('account_page_email_hash')}
    +
    {account.info.emailHash}
    +
    + )} + {account.paychannels?.total_available && ( +
    +
    + {t('account_page_payment_channels')} +
    +
    + {t('account_page_payment_channels_text', { + currency: localizeNumber( + account.paychannels.total_available, + lang, + XRP_SMALL_BALANCE_CURRENCY_OPTIONS, + ), + number: account.paychannels.channels.length, + })} +
    +
    + )} + {account.info?.nftMinter && ( +
    +
    {t('account_page_nft_minter')}
    +
    + +
    +
    + )} +
    +
    + ) +} + +export default DetailsCard diff --git a/src/containers/Accounts/AccountSummary/FlagsCard.tsx b/src/containers/Accounts/AccountSummary/FlagsCard.tsx new file mode 100644 index 000000000..6dda89765 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/FlagsCard.tsx @@ -0,0 +1,153 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +interface FlagsCardProps { + account: any +} + +const FlagsCard = ({ account }: FlagsCardProps) => { + const { t } = useTranslation() + + const flags = useMemo< + Array<{ + key: string + title: string + description: string + enabled: boolean + }> + >(() => { + const accountFlags: string[] = account.info?.flags ?? [] + + return [ + { + key: 'lsfGlobalFreeze', + title: t('account_flag_title_lsf_global_freeze'), + description: t('account_flag_description_lsf_global_freeze'), + enabled: accountFlags.includes('lsfGlobalFreeze'), + }, + { + key: 'lsfDisableMaster', + title: t('account_flag_title_lsf_disable_master'), + description: t('account_flag_description_lsf_disable_master'), + enabled: !accountFlags.includes('lsfDisableMaster'), + }, + { + key: 'lsfDefaultRipple', + title: t('account_flag_title_lsf_default_ripple'), + description: t('account_flag_description_lsf_default_ripple'), + enabled: accountFlags.includes('lsfDefaultRipple'), + }, + { + key: 'lsfAllowTrustLineClawback', + title: t('account_flag_title_lsf_allow_trustline_clawback'), + description: t('account_flag_description_lsf_allow_trustline_clawback'), + enabled: accountFlags.includes('lsfAllowTrustLineClawback'), + }, + { + key: 'lsfRequireDestTag', + title: t('account_flag_title_lsf_require_destination_tag'), + description: t('account_flag_description_lsf_require_destination_tag'), + enabled: accountFlags.includes('lsfRequireDestTag'), + }, + { + key: 'lsfNoFreeze', + title: t('account_flag_title_lsf_no_freeze'), + description: t('account_flag_description_lsf_no_freeze'), + enabled: accountFlags.includes('lsfNoFreeze'), + }, + { + key: 'lsfRequireAuth', + title: t('account_flag_title_lsf_require_auth'), + description: t('account_flag_description_lsf_require_auth'), + enabled: accountFlags.includes('lsfRequireAuth'), + }, + { + key: 'lsfDisallowXRP', + title: t('account_flag_title_lsf_disallow_xrp'), + description: t('account_flag_description_lsf_disallow_xrp'), + enabled: accountFlags.includes('lsfDisallowXRP'), + }, + { + key: 'lsfDisallowIncomingTrustline', + title: t('account_flag_title_lsf_disallow_incoming_trustline'), + description: t( + 'account_flag_description_lsf_disallow_incoming_trustline', + ), + enabled: !accountFlags.includes('lsfDisallowIncomingTrustline'), + }, + { + key: 'lsfDisallowIncomingPayChannel', + title: t('account_flag_title_lsf_disallow_incoming_pay_chan'), + description: t( + 'account_flag_description_lsf_disallow_incoming_pay_chan', + ), + enabled: !accountFlags.includes('lsfDisallowIncomingPayChannel'), + }, + { + key: 'lsfDisallowIncomingNFTokenOffer', + title: t('account_flag_title_lsf_disallow_incoming_nft_token_offer'), + description: t( + 'account_flag_description_lsf_disallow_incoming_nft_token_offer', + ), + enabled: !accountFlags.includes('lsfDisallowIncomingNFTokenOffer'), + }, + { + key: 'asfAuthorizedNFTokenMinter', + title: t('account_flag_title_asf_authorized_nft_token_minter'), + description: t( + 'account_flag_description_asf_authorized_nft_token_minter', + ), + // No ledger flag exists for the AccountSet flag `asfAuthorizedNFTokenMinter`. + // The NFTokenMinter field's presence or absence is sufficient to determine the flag's status. + enabled: !!account.info?.nftMinter, + }, + { + key: 'lsfDisallowIncomingCheck', + title: t('account_flag_title_lsf_disallow_incoming_check'), + description: t('account_flag_description_lsf_disallow_incoming_check'), + enabled: !accountFlags.includes('lsfDisallowIncomingCheck'), + }, + { + key: 'lsfDepositAuth', + title: t('account_flag_title_lsf_deposit_auth'), + description: t('account_flag_description_lsf_deposit_auth'), + enabled: accountFlags.includes('lsfDepositAuth'), + }, + { + key: 'asfAccountTxnID', + title: t('account_flag_title_asf_account_txn_id'), + description: t('account_flag_description_asf_account_txn_id'), + // No ledger flag exists for the AccountSet flag `asfAccountTxnID`. + // The AccountTxnID field's presence or absence is sufficient to determine the flag's status. + enabled: !!account.info?.accountTransactionID, + }, + ] + }, [account.info, t]) + + return ( +
    +
    +
    {t('account_page_flags')}
    +
    +
    + {flags.map((flag, i) => ( +
    +
    +
    {flag.title}
    +
    {flag.description}
    +
    +
    + {flag.enabled + ? t('account_page_flag_status_enabled') + : t('account_page_flag_status_disabled')} +
    +
    + ))} +
    +
    + ) +} + +export default FlagsCard diff --git a/src/containers/Accounts/AccountSummary/SignersCard.tsx b/src/containers/Accounts/AccountSummary/SignersCard.tsx new file mode 100644 index 000000000..b96a49600 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/SignersCard.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next' +import { Account } from '../../shared/components/Account' +import { shortenAccount } from '../../../rippled/lib/utils' + +interface Signer { + account: string + weight?: number +} + +interface SignersCardProps { + signers: Signer[] +} +const SignersCard = ({ signers }: SignersCardProps) => { + const { t } = useTranslation() + return ( +
    +
    +
    {t('account_page_signers')}
    +
    +
    + {signers.map((signer, i) => ( +
    +
    + +
    + {signer.weight !== undefined && ( +
    + {t('account_page_signer_weight')} {signer.weight} +
    + )} +
    + ))} +
    +
    + ) +} + +export default SignersCard diff --git a/src/containers/Accounts/AccountSummary/index.tsx b/src/containers/Accounts/AccountSummary/index.tsx new file mode 100644 index 000000000..b045242a3 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/index.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { useLanguage } from '../../shared/hooks' +import ArrowIcon from '../../shared/images/down_arrow.svg' +import Balances from './Balances' +import DetailsCard from './DetailsCard' +import FlagsCard from './FlagsCard' +import SignersCard from './SignersCard' + +import './styles.scss' + +interface AccountSummaryProps { + account: any + xrpToUSDRate: number +} + +export const AccountSummary = ({ + account = {}, + xrpToUSDRate, +}: AccountSummaryProps) => { + const { t } = useTranslation() + const lang = useLanguage() + const [propertiesOpen, setPropertiesOpen] = useState(false) + + return ( +
    + + +
    +
    +

    {t('account_page_account_properties')}

    + +
    + {propertiesOpen && ( +
    + + {account.signerList?.signers && ( + + )} + +
    + )} +
    +
    + ) +} + +export default AccountSummary diff --git a/src/containers/Accounts/AccountSummary/styles.scss b/src/containers/Accounts/AccountSummary/styles.scss new file mode 100644 index 000000000..61a713219 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/styles.scss @@ -0,0 +1,501 @@ +@use '../../shared/css/variables' as *; + +.account-summary { + .card { + padding: 18px; + border: 1px solid $black-70; + border-radius: 8px; + background: $black-90; + } + + .balances { + /* Frame that encompasses the 3 balance boxes */ + display: flex; + + /* Inside auto layout */ + flex: none; + flex-direction: row; + flex-grow: 0; + align-items: center; + align-self: stretch; + order: 1; + padding: 0; + gap: 28px; + + /* common base for all balance cards (markup uses .balance-card and modifier classes) */ + .balance-card { + display: flex; + min-width: 0; /* allow shrinking for long content */ + flex: 1 1 0; + flex-direction: column; + justify-content: center; + gap: 12px; + } + + .balance-title { + display: flex; + align-items: center; + color: $black-40; + font-size: 14px; + gap: 6px; + text-transform: uppercase; + @include semibold; + } + + .balance-icon { + display: inline-block; + width: 24px; + height: 24px; + flex: 0 0 auto; + } + + .balance-value { + color: $black-0; + font-size: 24px; + @include bold; + } + + /* shared visual box properties for the three balance tiles */ + .xrp, + .usd, + .reserve { + display: flex; + min-width: 0; + height: 130px; + box-sizing: border-box; + flex: 1 1 0; + flex-direction: column; + align-items: flex-start; + padding: 38px; + border-radius: 8px; + gap: 12px; + } + + /* individual visuals only set border/background/order */ + .xrp { + order: 0; + border: 1px solid #145c35; + background: rgb(20 92 53 / 40%); + } + + .usd { + order: 1; + border: 1px solid #6b0080; + background: rgb(64 0 76 / 50%); + } + + .reserve { + order: 2; + border: 1px solid #004d80; + background: rgb(0 77 128 / 40%); + } + } + + .properties { + /* layout for header and the chevron toggle */ + .properties-header { + display: flex; + align-items: center; + justify-content: flex-start; + margin: 24px 0; + gap: 2px; + + h3 { + margin: 0; + color: $black-0; + font-size: 20px; + } + + .properties-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + } + + .properties-arrow { + display: inline-block; + width: 18px; + height: 18px; + transform-origin: center; + transition: transform 180ms ease; + } + + .properties-arrow.open { + transform: rotate(180deg); + } + } + + .properties-grid { + display: grid; + align-items: start; + gap: 28px; + grid-template-columns: repeat(3, 1fr); + + /* Custom scrollbars for Flags and Signers lists */ + .flags-list, + .signers-list { + scrollbar-color: rgb(255 255 255 / 20%) transparent; + + /* Firefox */ + scrollbar-width: thin; + + /* WebKit */ + &::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border: 2px solid transparent; /* create padding effect */ + border-radius: 6px; + background: rgb(255 255 255 / 20%); + background-clip: padding-box; + } + + &::-webkit-scrollbar-thumb:hover { + background: rgb(255 255 255 / 12%); + } + } + + /* Shared visuals for both cards */ + .flags-card, + .signers-card { + display: flex; + min-width: 0; + min-height: 0; + box-sizing: border-box; + flex: none; + flex-direction: column; + flex-grow: 0; + align-items: stretch; + padding: 20px; + gap: 15px; + isolation: isolate; + } + + /* shared header styles to avoid duplicates */ + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + + .header-title { + color: $black-0; + font-size: 20px; + @include bold; + } + } + + .flags-card { + grid-column: 1 / span 2; + + .flags-list { + display: flex; + max-height: calc(5 * 70px); + flex-direction: column; + padding-right: 6px; + gap: 8px; + overflow-y: auto; + } + + .flag-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-radius: 8px; + background: $black-80; + + .flag-meta { + display: flex; + flex-direction: column; + + .flag-title { + margin-bottom: 6px; + color: $black-0; + font-size: 14px; + @include semibold; + + text-transform: uppercase; + } + + .flag-desc { + color: $black-0; + font-size: 14px; + } + } + + .flag-status { + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + @include semibold; + + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + } + + .flag-status.enabled { + background: $green-50; + color: $black-100; + } + + .flag-status.disabled { + background: $black-50; + color: $black-0; + } + } + } + + .signers-card { + grid-column: 3 / 4; + + .signers-list { + display: flex; + width: 100%; + max-height: calc(5 * 44px); + flex-direction: column; + align-self: stretch; + padding-right: 6px; /* match flags-list spacing to keep scrollbar offset consistent */ + gap: 8px; + overflow-y: auto; + } + + .signer-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + border-radius: 8px; + background: $black-80; + + .signer-address { + font-size: 14px; + } + + .signer-weight { + color: $blue-50; + font-size: 14px; + } + } + } + } + + /* Details card styles */ + @media (min-width: $tablet-landscape-upper-boundary) { + .details-card { + /* Ensure Details card matches Flags card width in properties grid (desktop only) */ + grid-column: 1 / span 2; + } + } + + .details-card { + display: flex; + flex-direction: column; + padding: 20px; + background: $black-90; + gap: 16px; + + .details-title { + margin-bottom: 8px; + color: $black-0; + font-size: 20px; + @include semibold; + } + + .details-list { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, 1fr); + + /* Only the first two direct .details-box children are side by side on desktop */ + > .details-box:nth-child(1) { + grid-column: 1; + grid-row: 1; + } + + > .details-box:nth-child(2) { + grid-column: 2; + grid-row: 1; + } + + > .details-box:nth-child(n+3) { + grid-column: 1 / span 2; + } + } + + /* Only first two details side by side, rest full width */ + .details-box { + display: flex; + min-width: 0; + flex: 1 1 180px; + flex-direction: row; + align-items: center; + padding: 10px; + border: 1px solid $black-70; + border-radius: 8px; + background: $black-80; + gap: 12px; + } + + .details-label { + color: $black-0; + font-size: 14px; + @include medium; + + text-transform: none; + } + + .details-value { + margin-left: auto; + color: $black-0; + font-size: 14px; + text-align: right; + } + } + + /* Force vertical stacking for tablet portrait and smaller */ + @media (max-width: $tablet-portrait-upper-boundary) { + .details-card .details-list { + grid-template-columns: 1fr; /* Keep grid but force 1 column */ + + > .details-box:nth-child(1), + > .details-box:nth-child(2) { + grid-column: 1; + grid-row: auto; + } + + /* Ensure all boxes span full width in grid */ + > .details-box:nth-child(n+3) { + grid-column: 1; + } + } + } + + @media (max-width: $tablet-landscape-upper-boundary) { + .details-card { + padding: 16px; + } + .details-title { font-size: 18px; } + + .details-list { + /* Keep as grid instead of flex to maintain alignment */ + display: grid; + gap: 10px; + grid-template-columns: 1fr; + } + + .details-box { + width: 100%; + flex: none; + flex-direction: column; + align-items: flex-start; + padding: 12px; + gap: 8px; + } + .details-label { font-size: 12px; } + .details-value { font-size: 14px; } + } + + /* Extra narrow screens - force better text wrapping */ + @media (max-width: $tablet-portrait-upper-boundary) { + .details-card { + padding: 12px; + + .details-list { + gap: 8px; + } + + .details-box { + overflow: hidden; /* Prevent box expansion */ + min-height: auto; + padding: 10px; + } + + .details-label { + line-height: 1.2; + } + + .details-value { + max-width: 100%; + hyphens: auto; /* Allow hyphenation if supported */ + line-height: 1.3; + overflow-wrap: anywhere; /* More aggressive wrapping */ + word-break: break-all; + } + } + } + } + + @media (max-width: $tablet-landscape-upper-boundary) { + .balances { + flex-direction: column; + margin-bottom: 24px; /* preserve spacing on mobile */ + gap: 12px; + + .balance-card { + width: 100%; + flex: none; + } + + .xrp, .usd, .reserve { + height: auto; + padding: 20px; + } + + .balance-value { font-size: 18px; } + + .balance-icon { + width: 20px; + height: 20px; + } + } + + .properties { + .properties-header { + gap: 8px; + h3 { font-size: 18px; } + .properties-toggle { padding: 4px; } + + .properties-arrow { + width: 16px; + height: 16px; + } + } + + .properties-grid { + margin-top: 12px; + gap: 12px; + grid-template-columns: 1fr; + + .flags-card, + .signers-card { + padding: 16px; + } + + .flags-card { grid-column: auto; } + .signers-card { grid-column: auto; } + + .flags-list, .signers-list { + max-height: calc(5 * 64px); + padding-inline: 4px; + } + + .flag-item, .signer-item { + padding: 10px; + } + } + } + } +} diff --git a/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx b/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx index dadfad7da..e2a7ab731 100644 --- a/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx +++ b/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx @@ -10,14 +10,12 @@ import { getAccountTransactions } from '../../../rippled' export interface AccountTransactionsTableProps { accountId: string - currencySelected?: string hasTokensColumn: boolean } export const AccountTransactionTable = ({ accountId, hasTokensColumn, - currencySelected = 'XRP', }: AccountTransactionsTableProps) => { const { t } = useTranslation() const { trackException } = useAnalytics() @@ -49,40 +47,32 @@ export const AccountTransactionTable = ({ }, ) - const filterTransactions = () => { - let processedTransactions = data?.pages?.reduce((txArray, page: any) => { - if (page.transactions) { - return txArray.concat(page.transactions) - } - return txArray - }, []) + const transactions = + data?.pages?.reduce( + (allTransactions: any[], page: any) => + page.transactions + ? allTransactions.concat(page.transactions) + : allTransactions, + [], + ) || [] - if (currencySelected !== 'XRP') { - processedTransactions = processedTransactions?.filter( - (tx) => - !currencySelected || - (currencySelected && - JSON.stringify(tx).includes( - `"currency":"${currencySelected.toUpperCase()}"`, - )), - ) - } - return processedTransactions - } - - const transactions = filterTransactions() - const tryLoading = transactions?.length === 0 && data?.pages[0]?.transactions + const tryLoading = transactions.length === 0 && data?.pages[0]?.transactions const emptyMessage = tryLoading ? 'get_account_transactions_try' : error?.message return ( - fetchNextPage()} - hasAdditionalResults={hasNextPage} - /> +
    +
    +

    {t('transactions')}

    +
    + fetchNextPage()} + hasAdditionalResults={hasNextPage} + /> +
    ) } diff --git a/src/containers/Accounts/index.tsx b/src/containers/Accounts/index.tsx index 543598d64..538ac8ec1 100644 --- a/src/containers/Accounts/index.tsx +++ b/src/containers/Accounts/index.tsx @@ -1,26 +1,24 @@ -import { useContext, useEffect, useState } from 'react' +import { useContext, useEffect } from 'react' import { Helmet } from 'react-helmet-async' import { useQuery } from 'react-query' import { isValidClassicAddress, isValidXAddress } from 'ripple-address-codec' -import { AccountHeader } from './AccountHeader' import { AccountTransactionTable } from './AccountTransactionTable' import './styles.scss' import { useAnalytics } from '../shared/analytics' -import { Tabs } from '../shared/components/Tabs' -import { AccountAssetTab } from './AccountAssetTab/AccountAssetTab' -import { buildPath, useRouteParams } from '../shared/routing' +import { useRouteParams } from '../shared/routing' import { ACCOUNT_ROUTE } from '../App/routes' import { BAD_REQUEST } from '../shared/utils' import { getAccountState } from '../../rippled' import SocketContext from '../shared/SocketContext' import { Loader } from '../shared/components/Loader' +import { AccountSummary } from './AccountSummary' +import { useXRPToUSDRate } from '../shared/hooks/useXRPToUSDRate' +import AccountAsset from './AccountAsset' +import AccountHeader from './AccountHeader' export const Accounts = () => { const { trackScreenLoaded, trackException } = useAnalytics() - const { id: accountId = '', tab = 'transactions' } = - useRouteParams(ACCOUNT_ROUTE) - const [currencySelected, setCurrencySelected] = useState('XRP') - const mainPath = buildPath(ACCOUNT_ROUTE, { id: accountId }) + const { id: accountId = '' } = useRouteParams(ACCOUNT_ROUTE) const rippledSocket = useContext(SocketContext) const { data: account, isLoading } = useQuery( @@ -30,17 +28,13 @@ export const Accounts = () => { return Promise.reject(BAD_REQUEST) } - return getAccountState(accountId, rippledSocket).catch( - (transactionRequestError) => { - const status = transactionRequestError.code - trackException( - `ledger ${accountId} --- ${JSON.stringify( - transactionRequestError, - )}`, - ) - return Promise.reject(status) - }, - ) + return getAccountState(accountId, rippledSocket).catch((requestError) => { + const status = requestError.code + trackException( + `ledger ${accountId} --- ${JSON.stringify(requestError)}`, + ) + return Promise.reject(status) + }) }, ) @@ -50,9 +44,13 @@ export const Accounts = () => { return () => { window.scrollTo(0, 0) } - }, [tab, trackScreenLoaded]) + }, [trackScreenLoaded]) - const tabs = ['transactions', 'assets'] + const xrpToUSDRate = useXRPToUSDRate() + + const isAccountDeleted = account?.deleted === true + // Show account data only after account.info is loaded and account isn't deleted + const showAccount = !!account?.info && !isAccountDeleted return (
    @@ -60,20 +58,26 @@ export const Accounts = () => { {accountId && ( <> setCurrencySelected(currency)} - currencySelected={currencySelected} + account={account} /> - - {tab === 'transactions' && ( - + {showAccount && ( + <> + + + )} - {tab === 'assets' && account && } + + {/* Show account transactions regardless of account delete status */} + )} {isLoading && } diff --git a/src/containers/MPT/MPTHeader/MPTHeader.tsx b/src/containers/MPT/MPTHeader/MPTHeader.tsx index 354268991..972f50674 100644 --- a/src/containers/MPT/MPTHeader/MPTHeader.tsx +++ b/src/containers/MPT/MPTHeader/MPTHeader.tsx @@ -9,7 +9,7 @@ import { BAD_REQUEST, HASH192_REGEX } from '../../shared/utils' import { Account } from '../../shared/components/Account' import { useAnalytics } from '../../shared/analytics' import { getMPTIssuance } from '../../../rippled/lib/rippled' -import { formatMPTIssuanceInfo } from '../../../rippled/lib/utils' +import { formatMPTIssuance } from '../../../rippled/lib/utils' import { MPTIssuanceFormattedInfo } from '../../shared/Interfaces' import { Details } from './Details' import { Settings } from './Settings' @@ -30,7 +30,7 @@ export const MPTHeader = (props: Props) => { ['getMPTIssuance', tokenId], async () => { const info = await getMPTIssuance(rippledSocket, tokenId) - return formatMPTIssuanceInfo(info) + return formatMPTIssuance(info.node) }, { onError: (e: any) => { diff --git a/src/containers/shared/CurrencyOptions.ts b/src/containers/shared/CurrencyOptions.ts new file mode 100644 index 000000000..173a368ae --- /dev/null +++ b/src/containers/shared/CurrencyOptions.ts @@ -0,0 +1,30 @@ +// Standard display for most XRP amounts (2 decimals) +export const XRP_CURRENCY_OPTIONS = { + style: 'currency', + currency: 'XRP', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +} + +// Higher precision for small (<1 XRP) balances +export const XRP_SMALL_BALANCE_CURRENCY_OPTIONS = { + style: 'currency', + currency: 'XRP', + minimumFractionDigits: 2, + maximumFractionDigits: 4, +} + +export const USD_CURRENCY_OPTIONS = { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +} + +// Higher precision for small (<1 USD) balances +export const USD_SMALL_BALANCE_CURRENCY_OPTIONS = { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 4, +} diff --git a/src/containers/shared/Interfaces.tsx b/src/containers/shared/Interfaces.tsx index 83c03903f..10ba1b388 100644 --- a/src/containers/shared/Interfaces.tsx +++ b/src/containers/shared/Interfaces.tsx @@ -37,7 +37,7 @@ export interface NFTFormattedInfo { } /** - * Values returned by 'formatMPTIssuanceInfo' from /src/rippled/lib/utils.js + * Values returned by 'formatMPTIssuance' from /src/rippled/lib/utils.js */ export interface MPTIssuanceFormattedInfo { issuer: string diff --git a/src/containers/shared/components/Account.tsx b/src/containers/shared/components/Account.tsx index c27f17ec3..08734fc62 100644 --- a/src/containers/shared/components/Account.tsx +++ b/src/containers/shared/components/Account.tsx @@ -3,12 +3,13 @@ import { ACCOUNT_ROUTE } from '../../App/routes' export interface AccountProps { account: string + shortAccount?: string link?: boolean tag?: number } export const Account = (props: AccountProps) => { - const { account, link = true, tag } = props + const { account, shortAccount, link = true, tag } = props const parts = account.split(':') const computedTag = tag || parts[1] @@ -22,11 +23,11 @@ export const Account = (props: AccountProps) => { to={ACCOUNT_ROUTE} params={{ id: parts[0] }} > - {parts[0]} + {shortAccount || parts[0]} ) : ( - {parts[0]} + {shortAccount || parts[0]} )} {computedTag && ( diff --git a/src/containers/shared/components/Amount.tsx b/src/containers/shared/components/Amount.tsx index 8c7607ca0..aaec18ce1 100644 --- a/src/containers/shared/components/Amount.tsx +++ b/src/containers/shared/components/Amount.tsx @@ -7,7 +7,7 @@ import Currency from './Currency' import { ExplorerAmount } from '../types' import { MPTIssuanceFormattedInfo } from '../Interfaces' import { getMPTIssuance } from '../../../rippled/lib/rippled' -import { formatMPTIssuanceInfo } from '../../../rippled/lib/utils' +import { formatMPTIssuance } from '../../../rippled/lib/utils' import SocketContext from '../SocketContext' import { useAnalytics } from '../analytics' @@ -57,7 +57,7 @@ export const Amount = ({ ['getMPTIssuanceScale', mptID], async () => { const info = await getMPTIssuance(rippledSocket, mptID) - return formatMPTIssuanceInfo(info) + return formatMPTIssuance(info.node) }, { onError: (e: any) => { diff --git a/src/containers/shared/components/DomainLink.tsx b/src/containers/shared/components/DomainLink.tsx index 4afe8e831..a79d25d30 100644 --- a/src/containers/shared/components/DomainLink.tsx +++ b/src/containers/shared/components/DomainLink.tsx @@ -5,6 +5,7 @@ export interface Props { className?: string decode?: boolean domain: string + displayDomain?: string keepProtocol?: boolean } @@ -13,7 +14,13 @@ const PROTOCOL_REGEX = /^([a-z][a-z0-9+\-.]*):\/\// const PROTOCOL_REMOVAL_REGEX = /^(https?:\/\/)?(.*?)(\/)?$/ const DomainLink = (props: Props) => { - const { className, decode = false, domain, keepProtocol = true } = props + const { + className, + decode = false, + domain, + displayDomain = '', + keepProtocol = true, + } = props // If decode is true, decode the domain const decodedDomain = decode ? decodeHex(domain) : domain @@ -40,7 +47,7 @@ const DomainLink = (props: Props) => { href={href} onClick={(event) => event.stopPropagation()} > - {domainText} + {displayDomain || domainText} ) } diff --git a/src/containers/shared/components/NFTokenLink.tsx b/src/containers/shared/components/NFTokenLink.tsx index e22bdcdbc..336be0601 100644 --- a/src/containers/shared/components/NFTokenLink.tsx +++ b/src/containers/shared/components/NFTokenLink.tsx @@ -3,10 +3,11 @@ import { NFT_ROUTE } from '../../App/routes' export interface NFTokenLinkProps { tokenID: string + shortTokenID?: string } -export const NFTokenLink = ({ tokenID }: NFTokenLinkProps) => ( +export const NFTokenLink = ({ tokenID, shortTokenID }: NFTokenLinkProps) => ( - {tokenID} + {shortTokenID || tokenID} ) diff --git a/src/containers/shared/components/PaginatedTable/index.tsx b/src/containers/shared/components/PaginatedTable/index.tsx new file mode 100644 index 000000000..99311af03 --- /dev/null +++ b/src/containers/shared/components/PaginatedTable/index.tsx @@ -0,0 +1,140 @@ +import './styles.scss' +import { useState, useMemo, ReactNode } from 'react' + +function getPageItems(current: number, total: number) { + const pages: (number | 'dots')[] = [] + const push = (v: number | 'dots') => pages.push(v) + const addRange = (s: number, e: number) => { + for (let i = s; i <= e; i += 1) { + push(i) + } + } + + if (total <= 7) { + addRange(1, total) + return pages + } + + if (current <= 4) { + addRange(1, 5) + push('dots') + push(total) + return pages + } + if (current >= total - 3) { + push(1) + push('dots') + addRange(total - 4, total) + return pages + } + + push(1) + push('dots') + addRange(current - 2, current + 2) + push('dots') + push(total) + return pages +} + +export interface PaginatedTableProps { + data: any[] + pageSize?: number + tableStructure: (paginatedData: any[]) => ReactNode +} + +export function PaginatedTable({ + data, + pageSize = 10, + tableStructure, +}: PaginatedTableProps) { + const [currentPage, setCurrentPage] = useState(1) + + const totalPages = Math.max(1, Math.ceil(data.length / pageSize)) + + // Calculate paginated data + const paginatedData = useMemo(() => { + const start = (currentPage - 1) * pageSize + return data.slice(start, start + pageSize) + }, [currentPage, data, pageSize]) + + const items = getPageItems(currentPage, totalPages) + const prevDisabled = currentPage === 1 + const nextDisabled = currentPage === totalPages + + const pagination = + totalPages > 1 ? ( +
    + + + + + {items.map((it, index) => + it === 'dots' ? ( + + … + + ) : ( + + ), + )} + + + + +
    + ) : null + + return ( +
    + {tableStructure(paginatedData)} + {pagination} +
    + ) +} diff --git a/src/containers/shared/components/PaginatedTable/styles.scss b/src/containers/shared/components/PaginatedTable/styles.scss new file mode 100644 index 000000000..f7f39c048 --- /dev/null +++ b/src/containers/shared/components/PaginatedTable/styles.scss @@ -0,0 +1,58 @@ +@use '../../css/variables' as *; + +.table-pagination { + position: relative; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + margin: 12px 0 0; + gap: 3px; +} + +/* Page numbers + chevrons */ +.table-pg-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: $black-50; + cursor: pointer; + font-size: 14px; +} + +.table-pg-btn:hover { + background: rgb(255 255 255 / 3%); + color: $black-0; +} + +.table-pg-btn.is-active { + background: rgb(22 163 74 / 6%); + color: $green-30; +} + +/* Larger green chevrons for first/prev/next/last */ +.table-pg-btn[aria-label="First page"], +.table-pg-btn[aria-label="Previous page"], +.table-pg-btn[aria-label="Next page"], +.table-pg-btn[aria-label="Last page"] { + padding: 0 5px; + color: $green-30; + font-size: 25px; +} + +/* Disabled state */ +.table-pg-btn:disabled { + background: transparent; + color: $black-50; + cursor: not-allowed; + opacity: 0.35; +} + +/* Ellipsis */ +.table-pg-ellipsis { + padding: 0 6px; + color: $black-50; + font-size: 15px; +} diff --git a/src/containers/shared/hooks/test/useXRPToUSDRate.test.tsx b/src/containers/shared/hooks/test/useXRPToUSDRate.test.tsx new file mode 100644 index 000000000..dd7f4719d --- /dev/null +++ b/src/containers/shared/hooks/test/useXRPToUSDRate.test.tsx @@ -0,0 +1,73 @@ +import { renderHook } from '@testing-library/react-hooks' +import { waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from 'react-query' +import { useXRPToUSDRate } from '../useXRPToUSDRate' +import SocketContext from '../../SocketContext' +import * as rippled from '../../../../rippled/lib/rippled' +import Log from '../../log' + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../log') + +const mockedGetAccountLines = rippled.getAccountLines as jest.Mock + +const createWrapper = + (socket: any = {}, queryClient = new QueryClient()) => + ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) + +describe('useXRPToUSDRate', () => { + beforeEach(() => { + jest.clearAllMocks() + process.env.VITE_ENVIRONMENT = 'mainnet' + }) + + it('returns 0.0 if no lines available', async () => { + mockedGetAccountLines.mockResolvedValue({ lines: [] }) + const { result } = renderHook(() => useXRPToUSDRate(), { + wrapper: createWrapper(), + }) + await waitFor(() => expect(result.current).toBe(0.0)) + }) + + it('returns 1.5 if not mainnet', () => { + process.env.VITE_ENVIRONMENT = 'dev' + const { result } = renderHook(() => useXRPToUSDRate(), { + wrapper: createWrapper(), + }) + expect(result.current).toBe(1.5) + }) + + it('fetches XRP/USD rate if mainnet', async () => { + mockedGetAccountLines.mockResolvedValue({ lines: [{ limit: 2.3 }] }) + const { result } = renderHook(() => useXRPToUSDRate(), { + wrapper: createWrapper({}), + }) + await waitFor(() => expect(result.current).toBe(2.3)) + }) + + it('falls back to last successful rate on error', async () => { + mockedGetAccountLines + .mockResolvedValueOnce({ lines: [{ limit: 2.1 }] }) + .mockRejectedValueOnce(new Error('network error')) + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, + }) + const { result } = renderHook(() => useXRPToUSDRate(), { + wrapper: createWrapper({}, queryClient), + }) + + // First fetch succeeds + await waitFor(() => expect(result.current).toBe(2.1)) + + // Force useQuery to refetch + queryClient.invalidateQueries('XRPToUSDRate') + + await waitFor(() => expect(result.current).toBe(2.1)) + expect(Log.error).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/containers/shared/hooks/useXRPToUSDRate.ts b/src/containers/shared/hooks/useXRPToUSDRate.ts new file mode 100644 index 000000000..0edfe3117 --- /dev/null +++ b/src/containers/shared/hooks/useXRPToUSDRate.ts @@ -0,0 +1,46 @@ +import { useContext, useState } from 'react' +import { useQuery } from 'react-query' +import SocketContext from '../SocketContext' +import { getAccountLines } from '../../../rippled/lib/rippled' +import Log from '../log' + +const FETCH_INTERVAL_MILLIS = 5 * 1000 // 1 minute +const XRP_USD_ORACLE_ACCOUNT = 'rXUMMaPpZqPutoRszR29jtC8amWq3APkx' + +const fetchXRPToUSDRate = async (rippledSocket: any) => { + const accountLines = await getAccountLines( + rippledSocket, + XRP_USD_ORACLE_ACCOUNT, + 1, + ) + + return accountLines.lines[0]?.limit ?? 0.0 +} + +/** + * Returns the current exchange rate for XRP to USD. + * Retries {@link FETCH_RETRY_COUNT} times on failure and falls back to the last successful value if fetching fails. + */ +export function useXRPToUSDRate(): number { + const isMainnet = process.env.VITE_ENVIRONMENT === 'mainnet' + + const rippledSocket = useContext(SocketContext) + const [lastRate, setLastRate] = useState(0.0) + + const { data } = useQuery( + ['XRPToUSDRate'], + () => fetchXRPToUSDRate(rippledSocket), + { + enabled: isMainnet, + refetchInterval: FETCH_INTERVAL_MILLIS, + onSuccess: (rate: number) => setLastRate(rate), // store the last successfully fetched rate + onError: (error) => Log.error(error), // do nothing, last rate stays + }, + ) + + if (!isMainnet) { + return 1.5 // This is chosen randomly for non-mainnet environments + } + + return data ?? lastRate +} diff --git a/src/containers/shared/images/clock-icon.svg b/src/containers/shared/images/clock-icon.svg new file mode 100644 index 000000000..2ca85f9a0 --- /dev/null +++ b/src/containers/shared/images/clock-icon.svg @@ -0,0 +1,39 @@ + + + + + + + + + diff --git a/src/containers/shared/images/info-duotone.svg b/src/containers/shared/images/info-duotone.svg new file mode 100644 index 000000000..bee2e99b7 --- /dev/null +++ b/src/containers/shared/images/info-duotone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/containers/shared/images/usd_icon.svg b/src/containers/shared/images/usd_icon.svg new file mode 100644 index 000000000..45aa5e424 --- /dev/null +++ b/src/containers/shared/images/usd_icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/containers/shared/images/xrp_balance_icon.svg b/src/containers/shared/images/xrp_balance_icon.svg new file mode 100644 index 000000000..8743414d5 --- /dev/null +++ b/src/containers/shared/images/xrp_balance_icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/containers/shared/images/xrp_reserve_balance_icon.svg b/src/containers/shared/images/xrp_reserve_balance_icon.svg new file mode 100644 index 000000000..435082dba --- /dev/null +++ b/src/containers/shared/images/xrp_reserve_balance_icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/rippled/accountState.ts b/src/rippled/accountState.ts index 122cb8fdc..3e0a6658a 100644 --- a/src/rippled/accountState.ts +++ b/src/rippled/accountState.ts @@ -1,9 +1,3 @@ -/** - * page path: /accounts/:id? - * - * part 1 of 2 - */ - import { isValidClassicAddress, isValidXAddress, @@ -11,16 +5,12 @@ import { } from 'ripple-address-codec' import { getAccountInfo, - getAccountEscrows, getAccountPaychannels, - getBalances, getServerInfo, getAccountTransactions, - getAccountBridges, } from './lib/rippled' import logger from './lib/logger' import { formatAccountInfo, formatSignerList } from './lib/utils' -import type { ExplorerAmount } from '../containers/shared/types' import type { ExplorerXrplClient } from '../containers/shared/SocketContext' interface XAddress { @@ -31,18 +21,11 @@ interface XAddress { export interface AccountState { account: string - balances: { - XRP: number - } paychannels?: { // eslint-disable-next-line camelcase total_available: number channels: any[] } - escrows?: { - totalIn: number - totalOut: number - } signerList?: { signers: { account: string @@ -52,6 +35,7 @@ export interface AccountState { maxSigners: number } info: { + accountTransactionID?: string reserve: number sequence?: number ticketCount: number @@ -66,53 +50,16 @@ export interface AccountState { test: boolean } deleted: boolean - hasBridge: boolean } -const log = logger({ name: 'account balances' }) - -const formatBalances = (info, data) => { - const balances = { XRP: Number(info.Balance) / 1000000 } - const { assets = {}, obligations = {} } = data - const tokens: ExplorerAmount[] = [] - - Object.keys(obligations).forEach((currency) => { - if (!balances[currency]) { - balances[currency] = 0 - } - - balances[currency] += Number(obligations[currency]) - }) - - Object.keys(assets).forEach((issuer) => { - assets[issuer].forEach((d) => { - if (!balances[d.currency]) { - balances[d.currency] = 0 - } +const log = logger({ name: 'GetAccountState' }) - balances[d.currency] += Number(d.value) - tokens.push({ - amount: Number(d.value), - currency: d.currency, - issuer, - }) - }) - }) - - return { - balances, - tokens, - } -} async function getAccountState( account: string, rippledSocket: ExplorerXrplClient, ): Promise { - // TODO: Retrieve balances for untagged X-address only? or display notice/warning - let classicAddress: string let decomposedAddress: XAddress | null = null - try { if (!isValidClassicAddress(account) && !isValidXAddress(account)) { throw new Error('Malformed address') @@ -121,10 +68,7 @@ async function getAccountState( if (isValidXAddress(account)) { decomposedAddress = xAddressToClassicAddress(account) ;({ classicAddress } = decomposedAddress) - // TODO: Display tag, if present const isTestnet = decomposedAddress.test - - // TODO: Display tag, if present if ( (isTestnet && process.env.VITE_ENVIRONMENT === 'mainnet') || (!isTestnet && @@ -140,36 +84,25 @@ async function getAccountState( log.error(error.toString()) throw error } - log.info(`get balances: ${account} -> ${classicAddress}`) + return getAccountInfo(rippledSocket, classicAddress) .then((info) => Promise.all([ - getBalances(rippledSocket, classicAddress).then((data) => - formatBalances(info, data), - ), - getAccountEscrows(rippledSocket, classicAddress, info.ledger_index), getAccountPaychannels(rippledSocket, classicAddress, info.ledger_index), getServerInfo(rippledSocket), - getAccountBridges(rippledSocket, classicAddress), ]).then((data) => ({ account: info.Account as string, - info: formatAccountInfo(info, data[3].info.validated_ledger), - balances: data[0].balances, - tokens: data[0].tokens, + info: formatAccountInfo(info, data[1].info.validated_ledger), signerList: info.signer_lists?.[0] ? formatSignerList(info.signer_lists[0]) : undefined, - escrows: data[1], - paychannels: data[2], + paychannels: data[1], xAddress: decomposedAddress || undefined, deleted: false, - hasBridge: data[4]?.length > 0, })), ) .catch((error) => { - // X-address: - // error.toString(): CustomError: account not found - // error.code: 404 + // Check if it's a deleted account if (error.code === 404) { return getAccountTransactions(rippledSocket, classicAddress, 1).then( (data) => { @@ -178,8 +111,6 @@ async function getAccountState( account: classicAddress, deleted: true, xAddress: decomposedAddress || undefined, - balances: { XRP: 0 }, - hasBridge: false, info: { reserve: 0, ticketCount: 0, flags: [] }, } } diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js index 6720ee845..b3ada0315 100644 --- a/src/rippled/lib/rippled.js +++ b/src/rippled/lib/rippled.js @@ -265,6 +265,7 @@ const getAccountPaychannels = async ( throw new Error(resp.error_message, 500) } + // This isn't working, resp.marker isn't empty, but we don't enter this if block if (!resp.account_objects.length) { return undefined } @@ -278,10 +279,12 @@ const getAccountPaychannels = async ( }) await getChannels() + const channels = list.map((c) => { remaining += c.Amount - c.Balance return formatPaychannel(c) }) + return channels.length ? { channels, @@ -347,7 +350,6 @@ const getBalances = (rippledSocket, account, ledgerIndex = 'validated') => if (resp.error_message) { throw new Error(resp.error_message, 500) } - return resp }) @@ -396,6 +398,41 @@ const getAccountNFTs = (rippledSocket, account, marker = '', limit = 20) => account, marker: marker || undefined, limit, + }).then((resp) => { + console.log(`getAccountNFTs - [${new Date().toISOString()}]`) + if (resp.error === 'actNotFound') { + throw new Error('account not found', 404) + } + + if (resp.error_message) { + throw new Error(resp.error_message, 500) + } + + return resp + }) + +const getNFTsIssuedByAccount = ( + rippledSocket, + issuer, + marker = '', + limit = 20, +) => + query(rippledSocket, { + command: 'nfts_by_issuer', + issuer, + marker: marker || undefined, + limit, + }).then((resp) => { + console.log(`getNFTsIssuedByAccount - [${new Date().toISOString()}]`) + if (resp.error === 'actNotFound') { + throw new Error('account not found', 404) + } + + if (resp.error_message) { + throw new Error(resp.error_message, 500) + } + + return resp }) const getNFTInfo = (rippledSocket, tokenId) => @@ -413,27 +450,42 @@ const getNFTInfo = (rippledSocket, tokenId) => return resp }) -const getNFToffers = ( +const getNFToffers = async ( offerCmd, rippledSocket, tokenId, limit = 50, marker = '', -) => - query(rippledSocket, { - command: offerCmd, - nft_id: tokenId, - limit, - marker: marker !== '' ? marker : undefined, - }).then((resp) => { +) => { + const allOffers = [] + let currentMarker = marker + + do { + // eslint-disable-next-line no-await-in-loop + const resp = await query(rippledSocket, { + command: offerCmd, + nft_id: tokenId, + limit, + marker: currentMarker !== '' ? currentMarker : undefined, + }) + console.log(offerCmd) + + // The NFT does not have any offers (note that object refers to the offer rather than the NFT itself). if (resp.error === 'objectNotFound') { - throw new Error('NFT not found', 404) + console.warn(`The NFT ${tokenId} does not have any offers`) + break } + if (resp.error_message) { throw new Error(resp.error_message, 500) } - return resp - }) + + allOffers.push(...(resp.offers || [])) + currentMarker = resp.marker + } while (currentMarker) + + return { offers: allOffers } +} const getBuyNFToffers = (rippledSocket, tokenId, limit = 50, marker = '') => getNFToffers('nft_buy_offers', rippledSocket, tokenId, limit, marker) @@ -551,8 +603,6 @@ const getOffers = ( return resp }) -// AMM Additions - const getAMMInfo = (rippledSocket, asset, asset2) => { const request = { command: 'amm_info', @@ -562,8 +612,36 @@ const getAMMInfo = (rippledSocket, asset, asset2) => { } return query(rippledSocket, request).then((resp) => { - if (resp.error_message || !resp.validated) { - throw new Error('Failed to get amm info', 404) + if (resp.error_message) { + throw new Error(resp.error_message) + } + + if (!resp.validated) { + throw new Error( + 'Ledger is not validated. The response data is pending and might change', + ) + } + + return resp + }) +} + +const getAMMInfoByAMMAccount = (rippledSocket, ammAccount) => { + const request = { + command: 'amm_info', + amm_account: ammAccount, + ledger_index: 'validated', + } + + return query(rippledSocket, request).then((resp) => { + if (resp.error_message) { + throw new Error(resp.error_message) + } + + if (!resp.validated) { + throw new Error( + 'Ledger is not validated. The response data is pending and might change', + ) } return resp @@ -597,12 +675,13 @@ const getMPTIssuance = (rippledSocket, tokenId) => resp.error === 'lgrNotFound' || resp.error === 'objectNotFound' ) { - throw new Error('MPT not found', 404) + throw new Error('MPT Issuance not found', 404) } if (resp.error_message) { throw new Error(resp.error_message, 500) } + return resp }) @@ -611,6 +690,7 @@ const getAccountMPTs = ( account, marker = '', ledgerIndex = 'validated', + limit = 400, ) => query(rippledSocket, { command: 'account_objects', @@ -618,13 +698,47 @@ const getAccountMPTs = ( ledger_index: ledgerIndex, type: 'mptoken', marker: marker || undefined, - limit: 400, + limit, }).then((resp) => { if (resp.error === 'actNotFound') { throw new Error('account not found', 404) } + if (resp.error === 'invalidParams') { - return undefined + // For example, "error_message": "Required field 'account' missing" + throw new Error(resp.error_message, 400) + } + + if (resp.error_message) { + throw new Error(resp.error_message, 500) + } + + return resp + }) + +const getAccountObjects = ( + rippledSocket, + account, + objectType, + marker = '', + ledgerIndex = 'validated', + limit = 400, +) => + query(rippledSocket, { + command: 'account_objects', + account, + ledger_index: ledgerIndex, + type: objectType, + marker: marker || undefined, + limit, + }).then((resp) => { + if (resp.error === 'actNotFound') { + throw new Error('account not found', 404) + } + + if (resp.error === 'invalidParams') { + // For example, "error_message": "Required field 'account' missing" + throw new Error(resp.error_message, 400) } if (resp.error_message) { @@ -634,17 +748,20 @@ const getAccountMPTs = ( return resp }) -const getAccountLines = (rippledSocket, account, limit) => +const getAccountLines = (rippledSocket, account, limit, marker = '') => query(rippledSocket, { command: 'account_lines', account, limit, + marker: marker || undefined, }).then((resp) => { + console.log(`account_lines - [${new Date().toISOString()}]`) if (resp.error === 'actNotFound') { throw new Error('account not found', 404) } if (resp.error === 'invalidParams') { - return undefined + // For example, "error_message": "Required field 'account' missing" + throw new Error(resp.error_message, 400) } if (resp.error_message) { @@ -663,6 +780,8 @@ export { getAccountPaychannels, getAccountBridges, getAccountNFTs, + getAccountObjects, + getNFTsIssuedByAccount, getBalances, getAccountTransactions, getNegativeUNL, @@ -674,6 +793,7 @@ export { getSellNFToffers, getNFTTransactions, getAMMInfo, + getAMMInfoByAMMAccount, getFeature, getMPTIssuance, getAccountMPTs, diff --git a/src/rippled/lib/utils.js b/src/rippled/lib/utils.js index e08f4862f..70c0e4afd 100644 --- a/src/rippled/lib/utils.js +++ b/src/rippled/lib/utils.js @@ -70,8 +70,9 @@ const buildFlags = (flags, flagMap) => { } const formatAccountInfo = (info, serverInfoValidated) => ({ + accountTransactionID: info.AccountTxnID, sequence: info.Sequence, - ticketCount: info.TicketCount, + ticketCount: info.TicketCount ?? 0, ownerCount: info.OwnerCount, reserve: serverInfoValidated.reserve_base_xrp ? serverInfoValidated.reserve_base_xrp + @@ -135,24 +136,24 @@ const formatNFTInfo = (info) => ({ warnings: info.warnings, }) -const formatMPTIssuanceInfo = (info) => ({ - issuer: info.node.Issuer, - assetScale: info.node.AssetScale, - maxAmt: info.node.MaximumAmount - ? BigInt(info.node.MaximumAmount).toString(10) +const formatMPTIssuance = (info) => ({ + issuer: info.Issuer, + assetScale: info.AssetScale, + maxAmt: info.MaximumAmount + ? BigInt(info.MaximumAmount).toString(10) : undefined, // default is undefined because the default maxAmt is the largest 63-bit int - outstandingAmt: info.node.OutstandingAmount - ? BigInt(info.node.OutstandingAmount).toString(10) + outstandingAmt: info.OutstandingAmount + ? BigInt(info.OutstandingAmount).toString(10) : '0', - transferFee: info.node.TransferFee, - sequence: info.node.Sequence, - metadata: info.node.MPTokenMetadata - ? decodeHex(info.node.MPTokenMetadata) - : info.node.MPTokenMetadata, - flags: buildFlags(info.node.Flags, MPT_ISSUANCE_FLAGS), + transferFee: info.TransferFee, + sequence: info.Sequence, + metadata: info.MPTokenMetadata + ? decodeHex(info.MPTokenMetadata) + : info.MPTokenMetadata, + flags: buildFlags(info.Flags, MPT_ISSUANCE_FLAGS), }) -const formatMPTokenInfo = (info) => ({ +const formatMPToken = (info) => ({ account: info.Account, flags: buildFlags(info.Flags, MPTOKEN_FLAGS), mptIssuanceID: info.MPTokenIssuanceID, @@ -162,6 +163,32 @@ const formatMPTokenInfo = (info) => ({ mptAmount: info.MPTAmount ? info.MPTAmount.toString(10) : '0', }) +const shortenAccount = (addr = '') => + addr.length > 12 ? `${addr.slice(0, 7)}...${addr.slice(-5)}` : addr + +const shortenDomain = (domain = '') => + domain.length > 26 ? `${domain.slice(0, 15)}...${domain.slice(-11)}` : domain + +const shortenNFTTokenID = (nftTokenID = '') => + nftTokenID.length > 20 + ? `${nftTokenID.slice(0, 10)}...${nftTokenID.slice(-10)}` + : nftTokenID + +const shortenMPTID = (mptTokenID = '') => + mptTokenID.length > 20 + ? `${mptTokenID.slice(0, 10)}...${mptTokenID.slice(-10)}` + : mptTokenID + +const formatTransferFee = (transferFee) => { + if (!transferFee) { + return '0' + } + + const feePercentage = transferFee / 1000 + + return feePercentage.toFixed(3) +} + export { XRP_BASE, RippledError as Error, @@ -171,6 +198,11 @@ export { formatAccountInfo, convertHexToString, formatNFTInfo, - formatMPTIssuanceInfo, - formatMPTokenInfo, + formatMPTIssuance, + formatMPToken, + formatTransferFee, + shortenAccount, + shortenDomain, + shortenNFTTokenID, + shortenMPTID, } From 864d98d2425ae22def85f4e2cb993b89359e108b Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Tue, 30 Sep 2025 00:40:40 -0400 Subject: [PATCH 02/47] no message --- .../AccountAsset/assetTables/HeldNFTs.tsx | 7 +++- .../AccountAsset/assetTables/NFTTable.tsx | 41 ++++++++++--------- src/rippled/lib/rippled.js | 1 - 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx index 3de7ddd84..47a02d875 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx @@ -16,7 +16,12 @@ const fetchAccountHeldNFTs = async ( let marker = '' do { // eslint-disable-next-line no-await-in-loop - const response = await getAccountNFTs(rippledSocket, accountId, marker, 5) + const response = await getAccountNFTs( + rippledSocket, + accountId, + marker, + 10, + ) if (response.account_nfts) { allNFTs.push(...response.account_nfts) } diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx index 0fcefcddf..95294cab4 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -69,7 +69,6 @@ export const NFTTable = ({ const basicNFTs = useMemo(() => nftsQuery.data ?? [], [nftsQuery.data]) - // Helper function to process XRP offers const processXRPOffers = useCallback( (offers: any[], sortAscending: boolean) => { const xrpOffers = offers.filter( @@ -90,11 +89,10 @@ export const NFTTable = ({ [], ) - // Helper function to fetch offers const fetchOffers = useCallback( async (fetchFn: Function, nftId: string) => { try { - const response = await fetchFn(rippledSocket, nftId, 10) + const response = await fetchFn(rippledSocket, nftId, 50) return response.offers && response.offers.length > 0 ? response.offers : [] @@ -125,7 +123,6 @@ export const NFTTable = ({ [processXRPOffers, fetchOffers], ) - // Helper function to batch process NFT offers const batchProcessNFTOffers = useCallback( async (nftsToFetch: NFTBasic[]) => { if (nftsToFetch.length === 0) return @@ -133,25 +130,29 @@ export const NFTTable = ({ setIsLoadingOffers(true) try { - // Fetch all NFT offers concurrently using Promise.all - const nftsPromises = nftsToFetch.map((nft) => fetchOffersForNFT(nft)) - const nftsResults = await Promise.all(nftsPromises) - - // Update all NFTs at once - setNFTs((prev) => - prev.map((prevNft) => { - const updatedNft = nftsResults.find( - (nft) => nft.nftId === prevNft.nftId, + // Fetch NFT offers sequentially to avoid being throttled + for (const nft of nftsToFetch) { + try { + // eslint-disable-next-line no-await-in-loop + const nftWithOffers = await fetchOffersForNFT(nft) + + // Update NFT immediately as offers are fetched + setNFTs((prev) => + prev.map((prevNft) => + prevNft.nftId === nftWithOffers.nftId ? nftWithOffers : prevNft, + ), ) - return updatedNft || prevNft - }), - ) - // Mark all NFTs as fetched - const nftIds = nftsToFetch.map((nft) => nft.nftId) - setFetchedNFTs((prev) => new Set([...prev, ...nftIds])) + // Mark as fetched + setFetchedNFTs((prev) => new Set([...prev, nft.nftId])) + } catch (error) { + // Handle individual NFT errors, continue with others + // eslint-disable-next-line no-console + console.error(`Error fetching offers for NFT ${nft.nftId}:`, error) + } + } } catch (error) { - // Silently handle batch processing errors + console.error(error) } setIsLoadingOffers(false) diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js index b3ada0315..dd1e1f17b 100644 --- a/src/rippled/lib/rippled.js +++ b/src/rippled/lib/rippled.js @@ -755,7 +755,6 @@ const getAccountLines = (rippledSocket, account, limit, marker = '') => limit, marker: marker || undefined, }).then((resp) => { - console.log(`account_lines - [${new Date().toISOString()}]`) if (resp.error === 'actNotFound') { throw new Error('account not found', 404) } From e8e7fdda8bceff982873330363a27c7e05287f69 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Tue, 30 Sep 2025 12:08:37 -0400 Subject: [PATCH 03/47] Add console logs for asset components --- .../AccountAsset/assetTables/HeldIOUs.tsx | 223 +++++++++++++----- .../AccountAsset/assetTables/HeldLPTokens.tsx | 59 +++-- .../AccountAsset/assetTables/HeldMPTs.tsx | 46 ++-- .../AccountAsset/assetTables/HeldNFTs.tsx | 11 +- .../AccountAsset/assetTables/IssuedIOUs.tsx | 7 +- .../AccountAsset/assetTables/IssuedMPTs.tsx | 35 ++- .../AccountAsset/assetTables/IssuedNFTs.tsx | 12 +- .../AccountAsset/assetTables/NFTTable.tsx | 82 ++++--- .../Accounts/AccountAsset/index.tsx | 3 + src/containers/Accounts/index.tsx | 1 + src/rippled/lib/rippled.js | 8 +- 11 files changed, 324 insertions(+), 163 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index 946603a51..9df4db2e8 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { useQuery } from 'react-query' -import { useEffect, useContext } from 'react' +import { useEffect, useContext, useState, useCallback, useMemo } from 'react' import Currency from '../../../shared/components/Currency' import { Loader } from '../../../shared/components/Loader' import { Account } from '../../../shared/components/Account' @@ -34,9 +34,8 @@ const fetchHeldIOUs = async ( rippledSocket: any, accountId: string, ): Promise => { - // Step 1: Check gateway_balances to see if account holds any IOUs + // Check gateway_balances to see if account holds any IOUs const balancesResponse = await getBalances(rippledSocket, accountId) - if ( !balancesResponse?.assets || Object.keys(balancesResponse.assets).length === 0 @@ -45,35 +44,55 @@ const fetchHeldIOUs = async ( return [] } - console.log(balancesResponse.assets) - - // Step 2: Get all trust lines using account_lines with pagination + // Get all trust lines using account_lines with pagination const allTrustLines: any[] = [] let marker = '' + let accountLinesCallCount = 0 + const startTime = performance.now() do { + const callStartTime = performance.now() // eslint-disable-next-line no-await-in-loop const accountLinesResponse = await getAccountLines( rippledSocket, accountId, - 15, + 400, marker, ) + const callEndTime = performance.now() + const callDuration = callEndTime - callStartTime + + accountLinesCallCount += 1 - console.log(`The number of lines is: ${accountLinesResponse.lines.length}`) + // Calculate response size in bytes + const responseSize = new TextEncoder().encode( + JSON.stringify(accountLinesResponse), + ).length + + // eslint-disable-next-line no-console + console.log( + `getAccountLines call #${accountLinesCallCount} took ${callDuration.toFixed(2)}ms, returned ${accountLinesResponse.lines?.length || 0} lines, response size: ${responseSize} bytes (${(responseSize / 1024).toFixed(2)} KB)`, + ) if (accountLinesResponse?.lines) { allTrustLines.push(...accountLinesResponse.lines) } marker = accountLinesResponse?.marker || '' - console.log(`The marker of lines is: ${marker}`) } while (marker) - // Step 3: Filter for positive balances + const endTime = performance.now() + const totalDuration = endTime - startTime + + // Log the total timing and call statistics + // eslint-disable-next-line no-console + console.log( + `Total getAccountLines calls: ${accountLinesCallCount}, Total duration: ${totalDuration.toFixed(2)}ms, Average per call: ${(totalDuration / accountLinesCallCount).toFixed(2)}ms, Total trust lines fetched: ${allTrustLines.length}`, + ) + + // Step 3: Filter for positive balances (using original approach) const positiveBalanceLines = allTrustLines.filter( (line: any) => parseFloat(line.balance) > 0, ) - if (positiveBalanceLines.length === 0) { return [] } @@ -83,54 +102,70 @@ const fetchHeldIOUs = async ( (line: any) => `${line.currency}.${line.account}`, ) - // Step 5: Batch get token info from DGE API + // Step 5: Batch get token info from DGE API (max 100 tokens per call) let tokenInfoMap: Record = {} - try { - const dgeResponse = await fetch( - 'https://los.dev.ripplex.io/tokens/batch-get', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tokenIds }), - }, - ) + const dgeStartTime = performance.now() + let dgeCallCount = 0 - if (dgeResponse.ok) { - const dgeData = await dgeResponse.json() - tokenInfoMap = - dgeData.tokens?.reduce((acc: any, token: any) => { - acc[`${token.currency}.${token.issuer}`] = token - return acc - }, {}) || {} - } - } catch (dgeError) { - console.warn('Failed to fetch DGE token info:', dgeError) - } - - // Step 6: Get unique issuers for transfer fee info - const uniqueIssuers = [ - ...new Set(positiveBalanceLines.map((line: any) => line.account)), - ] - const issuerInfoMap: Record = {} + // Process in batches of 100 + for (let i = 0; i < tokenIds.length; i += 100) { + const batch = tokenIds.slice(i, i + 100) + const batchStartTime = performance.now() - for (const issuer of uniqueIssuers) { try { - const issuerInfo = await getAccountInfo(rippledSocket, issuer) - issuerInfoMap[issuer] = issuerInfo - } catch (issuerError) { - console.warn(`Failed to get info for issuer ${issuer}:`, issuerError) + // eslint-disable-next-line no-await-in-loop + const dgeResponse = await fetch( + 'https://los.dev.ripplex.io/tokens/batch-get', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tokenIds: batch }), + }, + ) + const batchEndTime = performance.now() + const batchDuration = batchEndTime - batchStartTime + dgeCallCount += 1 + + // eslint-disable-next-line no-console + console.log( + `DGE batch-get call #${dgeCallCount} took ${batchDuration.toFixed(2)}ms for ${batch.length} tokens`, + ) + + if (dgeResponse.ok) { + // eslint-disable-next-line no-await-in-loop + const dgeData = await dgeResponse.json() + const batchTokens = + dgeData.tokens?.reduce((acc: any, token: any) => { + acc[`${token.currency}.${token.issuer}`] = token + return acc + }, {}) || {} + tokenInfoMap = { ...tokenInfoMap, ...batchTokens } + } + } catch (dgeError) { + const batchEndTime = performance.now() + const batchDuration = batchEndTime - batchStartTime + dgeCallCount += 1 + + // eslint-disable-next-line no-console + console.log( + `DGE batch-get call #${dgeCallCount} failed after ${batchDuration.toFixed(2)}ms for batch ${i / 100 + 1}:`, + dgeError, + ) } } - // Step 7: Combine all data + const dgeEndTime = performance.now() + const dgeTotalDuration = dgeEndTime - dgeStartTime + + // eslint-disable-next-line no-console + console.log( + `Total DGE calls: ${dgeCallCount}, Total duration: ${dgeTotalDuration.toFixed(2)}ms, Average per call: ${dgeCallCount > 0 ? (dgeTotalDuration / dgeCallCount).toFixed(2) : 0}ms`, + ) + + // Step 6: Combine all data (without transfer fees and frozen status for now) const combinedData: IOUData[] = positiveBalanceLines.map((line: any) => { const tokenId = `${line.currency}.${line.account}` const tokenInfo = tokenInfoMap[tokenId] - const issuerInfo = issuerInfoMap[line.account] - - const transferFee = issuerInfo?.TransferRate - ? ((issuerInfo.TransferRate - 1000000000) / 10000000).toFixed(2) - : '0' return { code: line.currency, @@ -142,12 +177,8 @@ const fetchHeldIOUs = async ( : null, price: tokenInfo?.price || null, assetClass: tokenInfo?.assetClass || null, - fee: `${transferFee}%`, - frozen: - line.freeze || - line.freeze_peer || - issuerInfo?.Flags & 0x00400000 || - false, + fee: null, // Will be updated progressively + frozen: line.freeze || line.freeze_peer || false, // Basic freeze info from trust line logo: tokenInfo?.logo, } }) @@ -158,13 +189,93 @@ const fetchHeldIOUs = async ( export const HeldIOUs = ({ accountId, onCountChange }: HeldIOUsProps) => { const { t } = useTranslation() const rippledSocket = useContext(SocketContext) + const [progressiveUpdates, setProgressiveUpdates] = useState< + Record + >({}) // Use useQuery to fetch held IOUs data const heldIOUsQuery = useQuery(['heldIOUs', accountId], () => fetchHeldIOUs(rippledSocket, accountId), ) - const iouData = heldIOUsQuery.data || [] + const baseIouData = useMemo( + () => heldIOUsQuery.data || [], + [heldIOUsQuery.data], + ) + + // Apply progressive updates to the base data + const iouData = baseIouData.map((token) => ({ + ...token, + fee: progressiveUpdates[token.issuer]?.fee || token.fee, + frozen: progressiveUpdates[token.issuer]?.frozen ?? token.frozen, + })) + + // Progressive fetching of issuer info for transfer fees and frozen status + const fetchIssuerInfoProgressively = useCallback(async () => { + if (baseIouData.length === 0) return + + const uniqueIssuers = [...new Set(baseIouData.map((line) => line.issuer))] + + // eslint-disable-next-line no-console + console.log( + `Starting progressive getAccountInfo calls for ${uniqueIssuers.length} issuers`, + ) + + let callCount = 0 + for (const issuer of uniqueIssuers) { + try { + const startTime = performance.now() + // eslint-disable-next-line no-await-in-loop + const issuerInfo = await getAccountInfo(rippledSocket, issuer) + const endTime = performance.now() + const duration = endTime - startTime + callCount += 1 + + const responseSize = new TextEncoder().encode( + JSON.stringify(issuerInfo), + ).length + + // eslint-disable-next-line no-console + console.log( + `getAccountInfo call #${callCount} for issuer ${issuer} took ${duration.toFixed(2)}ms, response size: ${responseSize} bytes (${(responseSize / 1024).toFixed(2)} KB)`, + ) + + const transferFee = issuerInfo?.TransferRate + ? ((issuerInfo.TransferRate - 1000000000) / 10000000).toFixed(2) + : '0' + + const globalFrozen = !!(issuerInfo?.Flags & 0x00400000) + + setProgressiveUpdates((prev) => ({ + ...prev, + [issuer]: { + fee: `${transferFee}%`, + frozen: globalFrozen, + }, + })) + } catch (error) { + callCount += 1 + // eslint-disable-next-line no-console + console.log( + `getAccountInfo call #${callCount} for issuer ${issuer} failed:`, + error, + ) + } + + // Small delay to avoid overwhelming the server + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 50) + }) + } + }, [baseIouData, rippledSocket]) + + // Start progressive updates after initial data is loaded + useEffect(() => { + if (baseIouData.length > 0) { + fetchIssuerInfoProgressively() + } + }, [baseIouData.length, fetchIssuerInfoProgressively]) // Communicate count back to parent useEffect(() => { diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx index 98722a72d..ced9db168 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -18,46 +18,43 @@ import { } from '../../../../rippled/lib/rippled' import { XRP_BASE } from '../../../shared/transactionUtils' import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import logger from '../../../../rippled/lib/logger' -const fetchAccountHeldLPTokens = async (rippledSocket, accountId) => { - try { - const balancesResponse = await getBalances(rippledSocket, accountId) - if (!balancesResponse.assets) { - return [] // No assets held, so no LP tokens - } +const log = logger({ name: 'HeldLPTokens' }) - const lpTokens: any[] = [] - - // Filter assets with currency code starting with '03' and process them one by one - // Reference: https://xrpl.org/docs/concepts/tokens/decentralized-exchange/automated-market-makers#lp-token-currency-codes - for (const [issuerAccount, assets] of Object.entries( - balancesResponse.assets, - )) { - for (const asset of assets as any[]) { - if (asset.currency && asset.currency.startsWith('03')) { - // eslint-disable-next-line no-await-in-loop - const result = await processLPTokenAsset( - rippledSocket, - issuerAccount, - asset, - ) - - if (result) { - lpTokens.push(result) - } +const fetchAccountHeldLPTokens = async (rippledSocket, accountId) => { + log.info(`Finding LP Tokens via 'gatewayBalance' for account ${accountId}`) + const balancesResponse = await getBalances(rippledSocket, accountId) + + const lpTokens: any[] = [] + for (const [issuerAccount, assets] of Object.entries( + balancesResponse?.assets || {}, + )) { + for (const asset of assets as any[]) { + // Filter assets with currency code starting with '03' and process them one by one + // Reference: https://xrpl.org/docs/concepts/tokens/decentralized-exchange/automated-market-makers#lp-token-currency-codes + if (asset.currency && asset.currency.startsWith('03')) { + // eslint-disable-next-line no-await-in-loop + const result = await processLPTokenAsset( + rippledSocket, + issuerAccount, + asset, + ) + + if (result) { + lpTokens.push(result) } } } - - return lpTokens - } catch (error) { - console.error(error) - return [] } + + log.info(`Found ${lpTokens.length} LP Tokens`) + return lpTokens } const processLPTokenAsset = async (rippledSocket, issuerAccount, asset) => { try { + log.info(`Fetching AMM pool for account ${issuerAccount}`) const ammInfoResponse = await getAMMInfoByAMMAccount( rippledSocket, issuerAccount, @@ -117,7 +114,7 @@ const processLPTokenAsset = async (rippledSocket, issuerAccount, asset) => { share: sharePercentage, } } catch (error) { - console.error(error) + log.error(`Error fetching AMM pool: ${JSON.stringify(error)}`) return null } } diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx index 14703e649..b07372161 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx @@ -18,6 +18,9 @@ import SocketContext from '../../../shared/SocketContext' import ClockIcon from '../../../shared/images/clock-icon.svg' import { localizeNumber } from '../../../shared/utils' import { useLanguage } from '../../../shared/hooks' +import logger from '../../../../rippled/lib/logger' + +const log = logger({ name: 'HeldMPTs' }) interface HeldMPTsProps { accountId: string @@ -25,31 +28,39 @@ interface HeldMPTsProps { } const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { + log.info(`Fetching MPTs for account ${accountId}`) + const mpts: any[] = [] let marker = '' do { - // eslint-disable-next-line no-await-in-loop - const response = await getAccountMPTs(rippledSocket, accountId, marker) - if (!response?.account_objects) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await getAccountMPTs(rippledSocket, accountId, marker) + if (!response?.account_objects) { + break + } + + mpts.push(...response.account_objects) + marker = response.marker || '' + } catch (error) { + log.error(`Error fetching MPTs: ${JSON.stringify(error)}`) + // Break the loop on error to avoid infinite retry break } - - mpts.push(...response.account_objects) - marker = response.marker || '' } while (marker) + log.info(`Successfully fetched ${mpts.length} MPTs`) + // Format and filter MPTs - const formattedMPTs = mpts + const positiveBalanceMPTs = mpts .map((mpToken: any) => formatMPToken(mpToken)) .filter((mpToken: any) => parseInt(mpToken.mptAmount || '0', 10) > 0) - - if (formattedMPTs.length === 0) { - return [] - } + log.info(`${positiveBalanceMPTs.length} MPTs with positive MPTAmount`) // For each MPTokenIssuanceID, call getMPTIssuance and format the response - const issuancePromises = formattedMPTs.map(async (mpToken: any) => { + const mptIssuancePromises = positiveBalanceMPTs.map(async (mpToken: any) => { try { + log.info(`Fetching MPT issuance for token ${mpToken.mptIssuanceID}`) const mptIssuanceResponse = await getMPTIssuance( rippledSocket, mpToken.mptIssuanceID, @@ -61,12 +72,17 @@ const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { mptIssuance: formattedMPTIssuance, } } catch (error) { - console.error(error) + log.error( + `Error fetching MPT issuance for token ${mpToken.mptIssuanceID}: ${JSON.stringify(error)}`, + ) return { mptIssuanceId: mpToken.mptIssuanceID, mptIssuance: null } } }) - const mptIssuanceResults = await Promise.all(issuancePromises) + const mptIssuanceResults = await Promise.all(mptIssuancePromises) + log.info( + `Successfully fetched ${mptIssuanceResults.length} MPT issuances for ${positiveBalanceMPTs.length} MPTs`, + ) const mptIssuanceIdToIssuance = new Map() mptIssuanceResults.forEach((result) => { if (result && result.mptIssuance) { @@ -75,7 +91,7 @@ const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { }) // Combine MPToken and MPTIssuance data - const combinedMPTs = formattedMPTs.map((mpToken: any) => { + const combinedMPTs = positiveBalanceMPTs.map((mpToken: any) => { const mptIssuance = mptIssuanceIdToIssuance.get(mpToken.mptIssuanceID) return { diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx index 47a02d875..f010c38a5 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx @@ -1,6 +1,9 @@ import { getAccountNFTs } from '../../../../rippled/lib/rippled' import { formatTransferFee } from '../../../../rippled/lib/utils' import { NFTTable, NFTBasic } from './NFTTable' +import logger from '../../../../rippled/lib/logger' + +const log = logger({ name: 'HeldNFTs' }) interface HeldNFTsProps { accountId: string @@ -12,6 +15,7 @@ const fetchAccountHeldNFTs = async ( rippledSocket: any, ): Promise => { try { + log.info(`Fetching held NFTs for account ${accountId}`) const allNFTs: any[] = [] let marker = '' do { @@ -20,14 +24,17 @@ const fetchAccountHeldNFTs = async ( rippledSocket, accountId, marker, - 10, + 10, // Not 10 NFTs, but 10 pages of NFTs ) if (response.account_nfts) { allNFTs.push(...response.account_nfts) + log.info(`${allNFTs.length} held NFTs fetched`) } + marker = response.marker || '' } while (marker) + log.info(`Successfully fetched ${allNFTs.length} held NFTs`) return allNFTs.map((nft) => ({ nftId: nft.NFTokenID, issuer: nft.Issuer, @@ -35,7 +42,7 @@ const fetchAccountHeldNFTs = async ( fee: formatTransferFee(nft.TransferFee), })) } catch (error) { - console.error(error) + log.error(`Error fetching held NFTs: ${JSON.stringify(error)}`) return [] } } diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index 7362059c6..7b40d0a29 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -8,10 +8,15 @@ import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' interface IssuedIOUsProps { accountId: string + account: any onCountChange?: (count: number) => void } -export const IssuedIOUs = ({ accountId, onCountChange }: IssuedIOUsProps) => { +export const IssuedIOUs = ({ + accountId, + account, + onCountChange, +}: IssuedIOUsProps) => { const { t } = useTranslation() const issuedIOUsQ = useQuery('issuedIOUs', getIssuedIOUs) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx index 8f327e356..c1e8f979a 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx @@ -15,6 +15,9 @@ import { } from '../../../../rippled/lib/utils' import { localizeNumber } from '../../../shared/utils' import { useLanguage } from '../../../shared/hooks' +import logger from '../../../../rippled/lib/logger' + +const log = logger({ name: 'IssuedMPTs' }) interface IssuedMPTsProps { accountId: string @@ -25,24 +28,34 @@ const fetchAccountIssuedMPTs = async ( accountId: string, rippledSocket: any, ) => { + log.info(`Fetching MPT Issuances for account ${accountId}`) + const mptIssuances: any[] = [] let marker = '' do { - // eslint-disable-next-line no-await-in-loop - const response = await getAccountObjects( - rippledSocket, - accountId, - 'mpt_issuance', - marker, - ) - if (!response?.account_objects) { + try { + // eslint-disable-next-line no-await-in-loop + const response = await getAccountObjects( + rippledSocket, + accountId, + 'mpt_issuance', + marker, + ) + if (!response?.account_objects) { + break + } + + mptIssuances.push(...response.account_objects) + marker = response.marker || '' + } catch (error) { + log.error(`Error fetching MPT issuances: ${JSON.stringify(error)}`) + // Break the loop on error to avoid infinite retry break } - - mptIssuances.push(...response.account_objects) - marker = response.marker || '' } while (marker) + log.info(`Successfully fetched ${mptIssuances.length} MPT Issuances`) + // Format the MPT issuances const issuedMPTs = mptIssuances.map((mptIssuance: any) => { const formattedIssuance = formatMPTIssuance(mptIssuance) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx index 3e343569f..0e2c287ea 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx @@ -1,6 +1,9 @@ import { getNFTsIssuedByAccount } from '../../../../rippled/lib/rippled' import { formatTransferFee } from '../../../../rippled/lib/utils' import { NFTTable, NFTBasic } from './NFTTable' +import logger from '../../../../rippled/lib/logger' + +const log = logger({ name: 'IssuedNFTs' }) interface IssuedNFTsProps { accountId: string @@ -12,6 +15,7 @@ const fetchAccountIssuedNFTs = async ( rippledSocket: any, ): Promise => { try { + log.info(`Fetching issued NFTs for account ${accountId}`) const allNFTs: any[] = [] let marker = '' do { @@ -23,19 +27,23 @@ const fetchAccountIssuedNFTs = async ( 25, ) if (response.nfts) { - console.log('Fetched account NFTs: ', response.nfts.length) allNFTs.push(...response.nfts) + if (allNFTs.length % 200 === 0) { + log.info(`${allNFTs.length} issued NFTs fetched`) + } } + marker = response.marker || '' } while (marker) + log.info(`Successfully fetched ${allNFTs.length} issued NFTs`) return allNFTs.map((nft) => ({ nftId: nft.nft_id, url: nft.uri ? Buffer.from(nft.uri, 'hex').toString('utf8') : '', fee: formatTransferFee(nft.transfer_fee), })) } catch (error) { - console.error(error) + log.error(`Error fetching issued NFTs: ${JSON.stringify(error)}`) return [] } } diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx index 95294cab4..0d9a7e002 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -21,6 +21,9 @@ import { XRP_BASE } from '../../../shared/transactionUtils' import { localizeNumber } from '../../../shared/utils' import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../../shared/CurrencyOptions' import { useLanguage } from '../../../shared/hooks' +import logger from '../../../../rippled/lib/logger' + +const log = logger({ name: 'NFTTable' }) const PAGE_SIZE = 10 @@ -62,7 +65,7 @@ export const NFTTable = ({ const [fetchedNFTs, setFetchedNFTs] = useState>(new Set()) const [isLoadingOffers, setIsLoadingOffers] = useState(false) - // Fetch basic NFT data first + // Fetch and show basic NFT data first const nftsQuery = useQuery([queryKey, accountId], () => fetchNFTs(accountId, rippledSocket), ) @@ -75,7 +78,9 @@ export const NFTTable = ({ (offer: any) => typeof offer.amount === 'string', ) - if (xrpOffers.length === 0) return null + if (xrpOffers.length === 0) { + return null + } const sortedOffers = xrpOffers.sort((a: any, b: any) => { const amountA = parseInt(a.amount, 10) @@ -97,7 +102,11 @@ export const NFTTable = ({ ? response.offers : [] } catch (error) { - console.error(error) + if (error.code === 404 && error.message === 'notFound') { + log.warn(`No offers returned from ${fetchFn.name} for NFT ${nftId}`) + } else { + log.error(error) + } return [] } }, @@ -106,53 +115,48 @@ export const NFTTable = ({ const fetchOffersForNFT = useCallback( async (nft: NFTBasic): Promise => { - try { - // Get sell offers to calculate lowest ask (XRP only) - const sellOffers = await fetchOffers(getSellNFToffers, nft.nftId) - const lowestAsk = processXRPOffers(sellOffers, true) // ascending for lowest + // Get sell offers to calculate lowest ask (XRP only) + const sellOffers = await fetchOffers(getSellNFToffers, nft.nftId) + const lowestAsk = processXRPOffers(sellOffers, true) // ascending for lowest - // Get buy offers to calculate highest bid (XRP only) - const buyOffers = await fetchOffers(getBuyNFToffers, nft.nftId) - const highestBid = processXRPOffers(buyOffers, false) // descending for highest + // Get buy offers to calculate highest bid (XRP only) + const buyOffers = await fetchOffers(getBuyNFToffers, nft.nftId) + const highestBid = processXRPOffers(buyOffers, false) // descending for highest - return { ...nft, lowestAsk, highestBid } - } catch (error) { - return { ...nft, lowestAsk: null, highestBid: null } - } + return { ...nft, lowestAsk, highestBid } }, [processXRPOffers, fetchOffers], ) const batchProcessNFTOffers = useCallback( async (nftsToFetch: NFTBasic[]) => { - if (nftsToFetch.length === 0) return + if (nftsToFetch.length === 0) { + return + } setIsLoadingOffers(true) - try { - // Fetch NFT offers sequentially to avoid being throttled - for (const nft of nftsToFetch) { - try { - // eslint-disable-next-line no-await-in-loop - const nftWithOffers = await fetchOffersForNFT(nft) - - // Update NFT immediately as offers are fetched - setNFTs((prev) => - prev.map((prevNft) => - prevNft.nftId === nftWithOffers.nftId ? nftWithOffers : prevNft, - ), - ) - - // Mark as fetched - setFetchedNFTs((prev) => new Set([...prev, nft.nftId])) - } catch (error) { - // Handle individual NFT errors, continue with others - // eslint-disable-next-line no-console - console.error(`Error fetching offers for NFT ${nft.nftId}:`, error) - } + // Fetch NFT offers sequentially to avoid being throttled + for (const nft of nftsToFetch) { + try { + // eslint-disable-next-line no-await-in-loop + const nftWithOffers = await fetchOffersForNFT(nft) + + // Update NFT immediately as offers are fetched + setNFTs((prev) => + prev.map((prevNft) => + prevNft.nftId === nftWithOffers.nftId ? nftWithOffers : prevNft, + ), + ) + + // Mark as fetched + setFetchedNFTs((prev) => new Set([...prev, nft.nftId])) + } catch (error) { + // Handle individual NFT errors, continue with others + log.error( + `Error fetching offers for NFT ${nft.nftId}: ${JSON.stringify(error)}`, + ) } - } catch (error) { - console.error(error) } setIsLoadingOffers(false) @@ -196,7 +200,7 @@ export const NFTTable = ({ const tableStructure = (paginatedRows: any[]) => { // Fetch offers for visible NFTs when page changes - // Only fetch if there are NFTs that haven't been fetched yet + // Only fetch if there are NFTs, for which we haven't fetched offers yet if (paginatedRows.length > 0) { const unfetchedNFTs = paginatedRows.filter( (nft) => !fetchedNFTs.has(nft.nftId), diff --git a/src/containers/Accounts/AccountAsset/index.tsx b/src/containers/Accounts/AccountAsset/index.tsx index 0752196fa..ff80ad4ea 100644 --- a/src/containers/Accounts/AccountAsset/index.tsx +++ b/src/containers/Accounts/AccountAsset/index.tsx @@ -39,11 +39,13 @@ function TabButton({ interface AccountAssetProps { accountId: string + account?: any xrpToUSDRate: number } export default function AccountAsset({ accountId, + account, xrpToUSDRate, }: AccountAssetProps) { const lang = useLanguage() @@ -225,6 +227,7 @@ export default function AccountAsset({ >
    diff --git a/src/containers/Accounts/index.tsx b/src/containers/Accounts/index.tsx index 538ac8ec1..b2c65fe78 100644 --- a/src/containers/Accounts/index.tsx +++ b/src/containers/Accounts/index.tsx @@ -68,6 +68,7 @@ export const Accounts = () => { diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js index dd1e1f17b..2a2aac00a 100644 --- a/src/rippled/lib/rippled.js +++ b/src/rippled/lib/rippled.js @@ -397,9 +397,8 @@ const getAccountNFTs = (rippledSocket, account, marker = '', limit = 20) => command: 'account_nfts', account, marker: marker || undefined, - limit, + limit, // Not `limit` of NFTs, but `limit` pages of NFTs }).then((resp) => { - console.log(`getAccountNFTs - [${new Date().toISOString()}]`) if (resp.error === 'actNotFound') { throw new Error('account not found', 404) } @@ -423,7 +422,6 @@ const getNFTsIssuedByAccount = ( marker: marker || undefined, limit, }).then((resp) => { - console.log(`getNFTsIssuedByAccount - [${new Date().toISOString()}]`) if (resp.error === 'actNotFound') { throw new Error('account not found', 404) } @@ -468,12 +466,10 @@ const getNFToffers = async ( limit, marker: currentMarker !== '' ? currentMarker : undefined, }) - console.log(offerCmd) // The NFT does not have any offers (note that object refers to the offer rather than the NFT itself). if (resp.error === 'objectNotFound') { - console.warn(`The NFT ${tokenId} does not have any offers`) - break + throw new Error(resp.error_message, 404) } if (resp.error_message) { From a6bbab49ba2412ba7cbd3799ac2de44d48d826a3 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Tue, 30 Sep 2025 22:07:44 -0400 Subject: [PATCH 04/47] Update heldIOUs --- .../AccountAsset/assetTables/HeldIOUs.tsx | 367 +++++++++--------- .../AccountAsset/assetTables/HeldLPTokens.tsx | 22 +- .../AccountAsset/assetTables/IssuedIOUs.tsx | 5 +- .../Accounts/AccountAsset/index.tsx | 6 +- .../Accounts/AccountAsset/styles.scss | 25 ++ src/containers/shared/CurrencyOptions.ts | 2 +- src/containers/shared/components/Currency.tsx | 3 +- .../shared/images/default_token_icon.svg | 3 + 8 files changed, 227 insertions(+), 206 deletions(-) create mode 100644 src/containers/shared/images/default_token_icon.svg diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index 9df4db2e8..b63441d2f 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -1,244 +1,219 @@ import { useTranslation } from 'react-i18next' import { useQuery } from 'react-query' import { useEffect, useContext, useState, useCallback, useMemo } from 'react' -import Currency from '../../../shared/components/Currency' +import Currency, { + LP_TOKEN_IDENTIFIER, +} from '../../../shared/components/Currency' import { Loader } from '../../../shared/components/Loader' import { Account } from '../../../shared/components/Account' import { shortenAccount } from '../../../../rippled/lib/utils' import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import { RouteLink } from '../../../shared/routing' +import { TOKEN_ROUTE } from '../../../App/routes' import SocketContext from '../../../shared/SocketContext' import { getBalances, getAccountLines, getAccountInfo, } from '../../../../rippled/lib/rippled' +import logger from '../../../../rippled/lib/logger' +import DefaultTokenIcon from '../../../shared/images/default_token_icon.svg' +import { CURRENCY_OPTIONS, localizeNumber } from '../../../shared/utils' +import { useLanguage } from '../../../shared/hooks' +import { + USD_CURRENCY_OPTIONS, + USD_SMALL_BALANCE_CURRENCY_OPTIONS, +} from '../../../shared/CurrencyOptions' + +const log = logger({ name: 'HeldIOUs' }) + +const LOS_TOKEN_API_BATCH_SIZE = 100 interface HeldIOUsProps { accountId: string + xrpToUSDRate: number onCountChange?: (count: number) => void } -interface IOUData { - code: string +interface IOU { + tokenCode: string + tokenIcon?: string issuer: string - balance: string - balanceUsd: string | null - price: string | null - assetClass: string | null - fee: string | null + issuerName: string + balance: number + priceInXRP: number + assetClass?: string + transferFee?: string frozen: boolean - logo?: string } -const fetchHeldIOUs = async ( +const fetchAccountHeldIOUs = async ( rippledSocket: any, accountId: string, -): Promise => { - // Check gateway_balances to see if account holds any IOUs +): Promise => { + log.info(`Finding held IOUs via 'gatewayBalance' for account ${accountId}`) const balancesResponse = await getBalances(rippledSocket, accountId) - if ( - !balancesResponse?.assets || - Object.keys(balancesResponse.assets).length === 0 - ) { - // No IOUs held, return empty array + + const nonLPTokens: any[] = [] + for (const assets of Object.values(balancesResponse?.assets || {})) { + for (const asset of assets as any[]) { + if (asset.currency && !asset.currency.startsWith(LP_TOKEN_IDENTIFIER)) { + nonLPTokens.push(asset.currency) + } + } + } + if (nonLPTokens.length === 0) { + // No non-LP token IOUs held, return empty array return [] } // Get all trust lines using account_lines with pagination const allTrustLines: any[] = [] let marker = '' - let accountLinesCallCount = 0 - const startTime = performance.now() do { - const callStartTime = performance.now() - // eslint-disable-next-line no-await-in-loop - const accountLinesResponse = await getAccountLines( - rippledSocket, - accountId, - 400, - marker, - ) - const callEndTime = performance.now() - const callDuration = callEndTime - callStartTime - - accountLinesCallCount += 1 - - // Calculate response size in bytes - const responseSize = new TextEncoder().encode( - JSON.stringify(accountLinesResponse), - ).length + try { + // eslint-disable-next-line no-await-in-loop + const accountLinesResponse = await getAccountLines( + rippledSocket, + accountId, + 400, + marker, + ) - // eslint-disable-next-line no-console - console.log( - `getAccountLines call #${accountLinesCallCount} took ${callDuration.toFixed(2)}ms, returned ${accountLinesResponse.lines?.length || 0} lines, response size: ${responseSize} bytes (${(responseSize / 1024).toFixed(2)} KB)`, - ) + if (accountLinesResponse?.lines) { + allTrustLines.push(...accountLinesResponse.lines) + } - if (accountLinesResponse?.lines) { - allTrustLines.push(...accountLinesResponse.lines) + marker = accountLinesResponse?.marker || '' + } catch (error) { + log.error( + `Error fetching account lines for account ${accountId}: ${JSON.stringify(error)}`, + ) + // Break the loop on error to avoid infinite retry + break } - - marker = accountLinesResponse?.marker || '' } while (marker) + log.info(`Found ${allTrustLines.length} trust lines`) - const endTime = performance.now() - const totalDuration = endTime - startTime - - // Log the total timing and call statistics - // eslint-disable-next-line no-console - console.log( - `Total getAccountLines calls: ${accountLinesCallCount}, Total duration: ${totalDuration.toFixed(2)}ms, Average per call: ${(totalDuration / accountLinesCallCount).toFixed(2)}ms, Total trust lines fetched: ${allTrustLines.length}`, - ) - - // Step 3: Filter for positive balances (using original approach) + // Filter for positive balances and exclude LP tokens const positiveBalanceLines = allTrustLines.filter( - (line: any) => parseFloat(line.balance) > 0, + (line: any) => + parseFloat(line.balance) > 0 && !line.currency.startsWith('03'), + ) + log.info( + `${positiveBalanceLines.length} IOU trust lines with positive balances`, ) if (positiveBalanceLines.length === 0) { return [] } - // Step 4: Prepare token IDs for DGE API call - const tokenIds = positiveBalanceLines.map( + // Batch get token data from LOS Token API + const allTokenIds = positiveBalanceLines.map( (line: any) => `${line.currency}.${line.account}`, ) - - // Step 5: Batch get token info from DGE API (max 100 tokens per call) - let tokenInfoMap: Record = {} - const dgeStartTime = performance.now() - let dgeCallCount = 0 - - // Process in batches of 100 - for (let i = 0; i < tokenIds.length; i += 100) { - const batch = tokenIds.slice(i, i + 100) - const batchStartTime = performance.now() - + let allTokens: Record = {} + for (let i = 0; i < allTokenIds.length; i += LOS_TOKEN_API_BATCH_SIZE) { + const tokenIds = allTokenIds.slice(i, i + LOS_TOKEN_API_BATCH_SIZE) try { // eslint-disable-next-line no-await-in-loop - const dgeResponse = await fetch( - 'https://los.dev.ripplex.io/tokens/batch-get', + const apiResponse = await fetch( + `${process.env.VITE_LOS_URL}/tokens/batch-get`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tokenIds: batch }), + body: JSON.stringify({ tokenIds }), }, ) - const batchEndTime = performance.now() - const batchDuration = batchEndTime - batchStartTime - dgeCallCount += 1 - - // eslint-disable-next-line no-console - console.log( - `DGE batch-get call #${dgeCallCount} took ${batchDuration.toFixed(2)}ms for ${batch.length} tokens`, - ) - if (dgeResponse.ok) { + if (apiResponse.ok) { // eslint-disable-next-line no-await-in-loop - const dgeData = await dgeResponse.json() - const batchTokens = - dgeData.tokens?.reduce((acc: any, token: any) => { - acc[`${token.currency}.${token.issuer}`] = token + const responseBody = await apiResponse.json() + const tokens = + responseBody.tokens?.reduce((acc: any, token: any) => { + acc[`${token.currency}.${token.issuer_account}`] = { + ...token, + } return acc }, {}) || {} - tokenInfoMap = { ...tokenInfoMap, ...batchTokens } + allTokens = { ...allTokens, ...tokens } } - } catch (dgeError) { - const batchEndTime = performance.now() - const batchDuration = batchEndTime - batchStartTime - dgeCallCount += 1 - - // eslint-disable-next-line no-console - console.log( - `DGE batch-get call #${dgeCallCount} failed after ${batchDuration.toFixed(2)}ms for batch ${i / 100 + 1}:`, - dgeError, + } catch (tokenError) { + log.error( + `Error fetching token batch ${i / LOS_TOKEN_API_BATCH_SIZE + 1}: ${ + tokenError instanceof Error ? tokenError.message : tokenError + }`, ) } } - const dgeEndTime = performance.now() - const dgeTotalDuration = dgeEndTime - dgeStartTime - - // eslint-disable-next-line no-console - console.log( - `Total DGE calls: ${dgeCallCount}, Total duration: ${dgeTotalDuration.toFixed(2)}ms, Average per call: ${dgeCallCount > 0 ? (dgeTotalDuration / dgeCallCount).toFixed(2) : 0}ms`, - ) + log.info(allTokens) - // Step 6: Combine all data (without transfer fees and frozen status for now) - const combinedData: IOUData[] = positiveBalanceLines.map((line: any) => { + // Combine all data (without transfer fees and Global freeze status for now) + const iouData: IOU[] = positiveBalanceLines.map((line: any) => { const tokenId = `${line.currency}.${line.account}` - const tokenInfo = tokenInfoMap[tokenId] + const token = allTokens[tokenId] return { - code: line.currency, + tokenCode: line.currency, + tokenIcon: token?.icon, issuer: line.account, - balance: line.balance, - balanceUsd: - tokenInfo?.price && line.balance - ? (parseFloat(line.balance) * parseFloat(tokenInfo.price)).toFixed(2) - : null, - price: tokenInfo?.price || null, - assetClass: tokenInfo?.assetClass || null, - fee: null, // Will be updated progressively + issuerName: token?.issuer_name, + balance: parseFloat(line.balance), + priceInXRP: token?.price ? parseFloat(token.price) : 0, + assetClass: token?.asset_class || null, + transferFee: null, frozen: line.freeze || line.freeze_peer || false, // Basic freeze info from trust line - logo: tokenInfo?.logo, } }) - return combinedData + return iouData } -export const HeldIOUs = ({ accountId, onCountChange }: HeldIOUsProps) => { +export const HeldIOUs = ({ + accountId, + xrpToUSDRate, + onCountChange, +}: HeldIOUsProps) => { + const lang = useLanguage() const { t } = useTranslation() const rippledSocket = useContext(SocketContext) const [progressiveUpdates, setProgressiveUpdates] = useState< Record >({}) - // Use useQuery to fetch held IOUs data const heldIOUsQuery = useQuery(['heldIOUs', accountId], () => - fetchHeldIOUs(rippledSocket, accountId), + fetchAccountHeldIOUs(rippledSocket, accountId), ) - const baseIouData = useMemo( - () => heldIOUsQuery.data || [], - [heldIOUsQuery.data], - ) + // Get data and sort by USD balance in one step + const sortedIOUs = useMemo(() => { + const data = heldIOUsQuery.data || [] + return [...data].sort((a, b) => { + const aBalanceUSD = a.priceInXRP * a.balance * xrpToUSDRate + const bBalanceUSD = b.priceInXRP * b.balance * xrpToUSDRate + return bBalanceUSD - aBalanceUSD + }) + }, [heldIOUsQuery.data, xrpToUSDRate]) // Apply progressive updates to the base data - const iouData = baseIouData.map((token) => ({ + const ious = sortedIOUs.map((token) => ({ ...token, - fee: progressiveUpdates[token.issuer]?.fee || token.fee, + transferFee: progressiveUpdates[token.issuer]?.fee || token.transferFee, frozen: progressiveUpdates[token.issuer]?.frozen ?? token.frozen, })) - // Progressive fetching of issuer info for transfer fees and frozen status + // Progressive fetching of issuer info for transfer fee and Global freeze status const fetchIssuerInfoProgressively = useCallback(async () => { - if (baseIouData.length === 0) return - - const uniqueIssuers = [...new Set(baseIouData.map((line) => line.issuer))] - - // eslint-disable-next-line no-console - console.log( - `Starting progressive getAccountInfo calls for ${uniqueIssuers.length} issuers`, - ) + if (sortedIOUs.length === 0) { + return + } - let callCount = 0 + const uniqueIssuers = [...new Set(sortedIOUs.map((line) => line.issuer))] for (const issuer of uniqueIssuers) { try { - const startTime = performance.now() + log.info(`Fetching account information for account ${issuer}`) // eslint-disable-next-line no-await-in-loop const issuerInfo = await getAccountInfo(rippledSocket, issuer) - const endTime = performance.now() - const duration = endTime - startTime - callCount += 1 - - const responseSize = new TextEncoder().encode( - JSON.stringify(issuerInfo), - ).length - - // eslint-disable-next-line no-console - console.log( - `getAccountInfo call #${callCount} for issuer ${issuer} took ${duration.toFixed(2)}ms, response size: ${responseSize} bytes (${(responseSize / 1024).toFixed(2)} KB)`, - ) const transferFee = issuerInfo?.TransferRate ? ((issuerInfo.TransferRate - 1000000000) / 10000000).toFixed(2) @@ -254,51 +229,31 @@ export const HeldIOUs = ({ accountId, onCountChange }: HeldIOUsProps) => { }, })) } catch (error) { - callCount += 1 - // eslint-disable-next-line no-console - console.log( - `getAccountInfo call #${callCount} for issuer ${issuer} failed:`, - error, + log.error( + `Error fetching account information: ${JSON.stringify(error)}`, ) } - - // Small delay to avoid overwhelming the server - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => { - setTimeout(resolve, 50) - }) } - }, [baseIouData, rippledSocket]) + }, [sortedIOUs, rippledSocket]) // Start progressive updates after initial data is loaded useEffect(() => { - if (baseIouData.length > 0) { + if (sortedIOUs.length > 0) { fetchIssuerInfoProgressively() } - }, [baseIouData.length, fetchIssuerInfoProgressively]) + }, [sortedIOUs.length, fetchIssuerInfoProgressively]) // Communicate count back to parent useEffect(() => { if (onCountChange) { - onCountChange(iouData.length) + onCountChange(ious.length) } - }, [iouData.length, onCountChange]) + }, [ious.length, onCountChange]) if (heldIOUsQuery.isLoading) { return } - if (heldIOUsQuery.error) { - return ( -
    - Error:{' '} - {heldIOUsQuery.error instanceof Error - ? heldIOUsQuery.error.message - : 'Failed to fetch held IOUs'} -
    - ) - } - return (
    @@ -315,38 +270,62 @@ export const HeldIOUs = ({ accountId, onCountChange }: HeldIOUsProps) => { - {iouData.length === 0 ? ( + {ious.length === 0 ? ( {t('account_page_asset_table_no_iou')} ) : ( - iouData.map((token) => ( - + ious.map((token) => ( + - - - - - + + + + + )) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx index ced9db168..9d65a7bcd 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -1,9 +1,11 @@ import { useTranslation } from 'react-i18next' import { useQuery } from 'react-query' -import { useEffect, useContext } from 'react' +import { useEffect, useContext, useMemo } from 'react' import { Loader } from '../../../shared/components/Loader' import SocketContext from '../../../shared/SocketContext' -import Currency from '../../../shared/components/Currency' +import Currency, { + LP_TOKEN_IDENTIFIER, +} from '../../../shared/components/Currency' import { shortenAccount } from '../../../../rippled/lib/utils' import { Account } from '../../../shared/components/Account' import { @@ -11,7 +13,7 @@ import { USD_SMALL_BALANCE_CURRENCY_OPTIONS, } from '../../../shared/CurrencyOptions' import { useLanguage } from '../../../shared/hooks' -import { localizeNumber } from '../../../shared/utils' +import { CURRENCY_OPTIONS, localizeNumber } from '../../../shared/utils' import { getBalances, getAMMInfoByAMMAccount, @@ -31,9 +33,7 @@ const fetchAccountHeldLPTokens = async (rippledSocket, accountId) => { balancesResponse?.assets || {}, )) { for (const asset of assets as any[]) { - // Filter assets with currency code starting with '03' and process them one by one - // Reference: https://xrpl.org/docs/concepts/tokens/decentralized-exchange/automated-market-makers#lp-token-currency-codes - if (asset.currency && asset.currency.startsWith('03')) { + if (asset.currency && asset.currency.startsWith(LP_TOKEN_IDENTIFIER)) { // eslint-disable-next-line no-await-in-loop const result = await processLPTokenAsset( rippledSocket, @@ -137,7 +137,11 @@ export const HeldLPTokens = ({ const heldLPTokensQuery = useQuery(['heldLPTokens', accountId], () => fetchAccountHeldLPTokens(rippledSocket, accountId), ) - const lpTokenData = heldLPTokensQuery.data ?? [] + + const lpTokenData = useMemo( + () => heldLPTokensQuery.data ?? [], + [heldLPTokensQuery.data], + ) // Two-tier sort: 1) XRP pairs first, 2) within XRP pairs, sort by USD value descending const rows = lpTokenData.sort((a, b) => { @@ -218,7 +222,9 @@ export const HeldLPTokens = ({ link={false} /> - + - + diff --git a/src/containers/Accounts/AccountAsset/index.tsx b/src/containers/Accounts/AccountAsset/index.tsx index ff80ad4ea..5d502550c 100644 --- a/src/containers/Accounts/AccountAsset/index.tsx +++ b/src/containers/Accounts/AccountAsset/index.tsx @@ -160,7 +160,11 @@ export default function AccountAsset({ className="account-asset-table-wrapper account-asset-table-wrapper-fixed" style={{ display: heldTab === 'iou' ? 'block' : 'none' }} > - +
    + + From 0cda16eaf2a8b7e54448b58dd4628f9d2405bd6e Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Tue, 30 Sep 2025 22:23:05 -0400 Subject: [PATCH 05/47] Simplify NFT code --- .../AccountAsset/assetTables/HeldNFTs.tsx | 4 +-- .../AccountAsset/assetTables/IssuedNFTs.tsx | 4 +-- .../AccountAsset/assetTables/NFTTable.tsx | 27 +++++++------------ 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx index f010c38a5..4c52fc248 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx @@ -1,6 +1,6 @@ import { getAccountNFTs } from '../../../../rippled/lib/rippled' import { formatTransferFee } from '../../../../rippled/lib/utils' -import { NFTTable, NFTBasic } from './NFTTable' +import { NFTTable, NFT } from './NFTTable' import logger from '../../../../rippled/lib/logger' const log = logger({ name: 'HeldNFTs' }) @@ -13,7 +13,7 @@ interface HeldNFTsProps { const fetchAccountHeldNFTs = async ( accountId: string, rippledSocket: any, -): Promise => { +): Promise => { try { log.info(`Fetching held NFTs for account ${accountId}`) const allNFTs: any[] = [] diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx index 0e2c287ea..007af4899 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx @@ -1,6 +1,6 @@ import { getNFTsIssuedByAccount } from '../../../../rippled/lib/rippled' import { formatTransferFee } from '../../../../rippled/lib/utils' -import { NFTTable, NFTBasic } from './NFTTable' +import { NFTTable, NFT } from './NFTTable' import logger from '../../../../rippled/lib/logger' const log = logger({ name: 'IssuedNFTs' }) @@ -13,7 +13,7 @@ interface IssuedNFTsProps { const fetchAccountIssuedNFTs = async ( accountId: string, rippledSocket: any, -): Promise => { +): Promise => { try { log.info(`Fetching issued NFTs for account ${accountId}`) const allNFTs: any[] = [] diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx index 0d9a7e002..9378d79f7 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -30,25 +30,18 @@ const PAGE_SIZE = 10 interface NFTTableWithOffersProps { accountId: string onCountChange?: (count: number) => void - fetchNFTs: (rippledSocket: any, accountId: string) => Promise + fetchNFTs: (rippledSocket: any, accountId: string) => Promise queryKey: string showIssuer?: boolean } -export interface NFTBasic { - nftId: string - issuer?: string - url: string - fee: string -} - export interface NFT { nftId: string issuer?: string url: string fee: string - lowestAsk: number | null - highestBid: number | null + lowestAsk?: number + highestBid?: number } export const NFTTable = ({ @@ -79,7 +72,7 @@ export const NFTTable = ({ ) if (xrpOffers.length === 0) { - return null + return undefined } const sortedOffers = xrpOffers.sort((a: any, b: any) => { @@ -101,7 +94,7 @@ export const NFTTable = ({ return response.offers && response.offers.length > 0 ? response.offers : [] - } catch (error) { + } catch (error: any) { if (error.code === 404 && error.message === 'notFound') { log.warn(`No offers returned from ${fetchFn.name} for NFT ${nftId}`) } else { @@ -114,7 +107,7 @@ export const NFTTable = ({ ) const fetchOffersForNFT = useCallback( - async (nft: NFTBasic): Promise => { + async (nft: NFT): Promise => { // Get sell offers to calculate lowest ask (XRP only) const sellOffers = await fetchOffers(getSellNFToffers, nft.nftId) const lowestAsk = processXRPOffers(sellOffers, true) // ascending for lowest @@ -129,7 +122,7 @@ export const NFTTable = ({ ) const batchProcessNFTOffers = useCallback( - async (nftsToFetch: NFTBasic[]) => { + async (nftsToFetch: NFT[]) => { if (nftsToFetch.length === 0) { return } @@ -167,11 +160,11 @@ export const NFTTable = ({ // Initialize NFTs when basic data loads useEffect(() => { if (basicNFTs.length > 0) { - // Initialize all NFTs with null lowest ask and highest bid + // Initialize all NFTs with undefined lowest ask and highest bid const initialNFTs = basicNFTs.map((nft) => ({ ...nft, - lowestAsk: null, - highestBid: null, + lowestAsk: undefined, + highestBid: undefined, })) setNFTs(initialNFTs) From 3600f53b2d9d0a621061b213cbe42285c5021198 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 1 Oct 2025 00:34:01 -0400 Subject: [PATCH 06/47] Handle transfer fee and frozen status for held IOUs --- .../AccountAsset/assetTables/HeldIOUs.tsx | 50 +++++++++++-------- .../AccountAsset/assetTables/HeldNFTs.tsx | 2 +- .../AccountAsset/assetTables/IssuedNFTs.tsx | 2 +- .../AccountAsset/assetTables/NFTTable.tsx | 4 +- src/containers/Token/TokenHeader/index.tsx | 4 +- src/rippled/lib/utils.js | 34 ++++++++----- src/rippled/token.ts | 2 +- 7 files changed, 57 insertions(+), 41 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index b63441d2f..3cc161315 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -6,7 +6,12 @@ import Currency, { } from '../../../shared/components/Currency' import { Loader } from '../../../shared/components/Loader' import { Account } from '../../../shared/components/Account' -import { shortenAccount } from '../../../../rippled/lib/utils' +import { + ACCOUNT_FLAGS, + buildFlags, + formatTransferFee, + shortenAccount, +} from '../../../../rippled/lib/utils' import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' import { RouteLink } from '../../../shared/routing' import { TOKEN_ROUTE } from '../../../App/routes' @@ -43,8 +48,8 @@ interface IOU { balance: number priceInXRP: number assetClass?: string - transferFee?: string - frozen: boolean + transferFee?: string | null + frozen?: string } const fetchAccountHeldIOUs = async ( @@ -98,7 +103,8 @@ const fetchAccountHeldIOUs = async ( // Filter for positive balances and exclude LP tokens const positiveBalanceLines = allTrustLines.filter( (line: any) => - parseFloat(line.balance) > 0 && !line.currency.startsWith('03'), + parseFloat(line.balance) > 0 && + !line.currency.startsWith(LP_TOKEN_IDENTIFIER), ) log.info( `${positiveBalanceLines.length} IOU trust lines with positive balances`, @@ -146,8 +152,6 @@ const fetchAccountHeldIOUs = async ( } } - log.info(allTokens) - // Combine all data (without transfer fees and Global freeze status for now) const iouData: IOU[] = positiveBalanceLines.map((line: any) => { const tokenId = `${line.currency}.${line.account}` @@ -162,7 +166,7 @@ const fetchAccountHeldIOUs = async ( priceInXRP: token?.price ? parseFloat(token.price) : 0, assetClass: token?.asset_class || null, transferFee: null, - frozen: line.freeze || line.freeze_peer || false, // Basic freeze info from trust line + frozen: line.freeze || line.freeze_peer ? 'Trustline' : '--', } }) @@ -178,7 +182,7 @@ export const HeldIOUs = ({ const { t } = useTranslation() const rippledSocket = useContext(SocketContext) const [progressiveUpdates, setProgressiveUpdates] = useState< - Record + Record >({}) const heldIOUsQuery = useQuery(['heldIOUs', accountId], () => @@ -196,11 +200,14 @@ export const HeldIOUs = ({ }, [heldIOUsQuery.data, xrpToUSDRate]) // Apply progressive updates to the base data - const ious = sortedIOUs.map((token) => ({ - ...token, - transferFee: progressiveUpdates[token.issuer]?.fee || token.transferFee, - frozen: progressiveUpdates[token.issuer]?.frozen ?? token.frozen, - })) + const ious = sortedIOUs.map((token) => { + const progressiveUpdate = progressiveUpdates[token.issuer] + return { + ...token, + transferFee: progressiveUpdate?.transferFee || token.transferFee, + frozen: progressiveUpdate?.accountGlobalFrozen ? 'Global' : token.frozen, + } + }) // Progressive fetching of issuer info for transfer fee and Global freeze status const fetchIssuerInfoProgressively = useCallback(async () => { @@ -213,19 +220,20 @@ export const HeldIOUs = ({ try { log.info(`Fetching account information for account ${issuer}`) // eslint-disable-next-line no-await-in-loop - const issuerInfo = await getAccountInfo(rippledSocket, issuer) + const accountInfo = await getAccountInfo(rippledSocket, issuer) - const transferFee = issuerInfo?.TransferRate - ? ((issuerInfo.TransferRate - 1000000000) / 10000000).toFixed(2) - : '0' + const transferFee = formatTransferFee(accountInfo?.TransferRate, 'IOU') - const globalFrozen = !!(issuerInfo?.Flags & 0x00400000) + const accountGlobalFrozen = buildFlags( + accountInfo?.Flags, + ACCOUNT_FLAGS, + ).includes('lsfGlobalFreeze') setProgressiveUpdates((prev) => ({ ...prev, [issuer]: { - fee: `${transferFee}%`, - frozen: globalFrozen, + transferFee: `${transferFee}%`, + accountGlobalFrozen, }, })) } catch (error) { @@ -326,7 +334,7 @@ export const HeldIOUs = ({
    - + )) )} diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx index 4c52fc248..98de4e548 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx @@ -39,7 +39,7 @@ const fetchAccountHeldNFTs = async ( nftId: nft.NFTokenID, issuer: nft.Issuer, url: nft.URI ? Buffer.from(nft.URI, 'hex').toString('utf8') : '', - fee: formatTransferFee(nft.TransferFee), + transferFee: formatTransferFee(nft.TransferFee), })) } catch (error) { log.error(`Error fetching held NFTs: ${JSON.stringify(error)}`) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx index 007af4899..aa02b24c9 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx @@ -40,7 +40,7 @@ const fetchAccountIssuedNFTs = async ( return allNFTs.map((nft) => ({ nftId: nft.nft_id, url: nft.uri ? Buffer.from(nft.uri, 'hex').toString('utf8') : '', - fee: formatTransferFee(nft.transfer_fee), + transferFee: formatTransferFee(nft.transfer_fee), })) } catch (error) { log.error(`Error fetching issued NFTs: ${JSON.stringify(error)}`) diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx index 9378d79f7..f6d901e77 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -39,7 +39,7 @@ export interface NFT { nftId: string issuer?: string url: string - fee: string + transferFee: string lowestAsk?: number highestBid?: number } @@ -256,7 +256,7 @@ export const NFTTable = ({ '--' )} - + - + diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index 24553ecb8..f53db05d7 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -1,38 +1,155 @@ import { useTranslation } from 'react-i18next' import { useQuery } from 'react-query' -import { useEffect } from 'react' -import Currency from '../../../shared/components/Currency' +import { useEffect, useContext, useMemo } from 'react' +import Currency, { + LP_TOKEN_IDENTIFIER, +} from '../../../shared/components/Currency' import { Loader } from '../../../shared/components/Loader' -import { getIssuedIOUs } from '../api' import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' -import { localizeNumber } from '../../../shared/utils' +import { RouteLink } from '../../../shared/routing' +import { TOKEN_ROUTE } from '../../../App/routes' +import SocketContext from '../../../shared/SocketContext' +import { getBalances } from '../../../../rippled/lib/rippled' +import logger from '../../../../rippled/lib/logger' +import DefaultTokenIcon from '../../../shared/images/default_token_icon.svg' +import { CURRENCY_OPTIONS, localizeNumber } from '../../../shared/utils' import { useLanguage } from '../../../shared/hooks' +import { USD_CURRENCY_OPTIONS } from '../../../shared/CurrencyOptions' + +const log = logger({ name: 'IssuedIOUs' }) + +const LOS_TOKEN_API_BATCH_SIZE = 100 interface IssuedIOUsProps { accountId: string account: any + xrpToUSDRate: number onCountChange?: (count: number) => void } +interface IOU { + tokenCode: string + tokenIcon?: string + priceInXRP: number + trustlines: number + holders: number + supply: number + assetClass: string + transferFee: string + frozen: string +} + +const fetchAccountIssuedIOUs = async ( + rippledSocket: any, + accountId: string, + account: any, +): Promise => { + log.info(`Finding issued IOUs via 'gatewayBalance' for account ${accountId}`) + const balancesResponse = await getBalances(rippledSocket, accountId) + + const iouTokens: string[] = [] + if (balancesResponse?.obligations) { + for (const currency of Object.keys(balancesResponse.obligations)) { + if (!currency.startsWith(LP_TOKEN_IDENTIFIER)) { + iouTokens.push(currency) + } + } + } + if (iouTokens.length === 0) { + // No IOUs issued by this account, return empty array + return [] + } + + // Batch get token data from LOS Token API + const allTokenIds = iouTokens.map((currency) => `${currency}.${accountId}`) + let allTokens: Record = {} + for (let i = 0; i < allTokenIds.length; i += LOS_TOKEN_API_BATCH_SIZE) { + const tokenIds = allTokenIds.slice(i, i + LOS_TOKEN_API_BATCH_SIZE) + try { + // eslint-disable-next-line no-await-in-loop + const apiResponse = await fetch( + `${process.env.VITE_LOS_URL}/tokens/batch-get`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tokenIds }), + }, + ) + + if (apiResponse.ok) { + // eslint-disable-next-line no-await-in-loop + const responseBody = await apiResponse.json() + const tokens = + responseBody.tokens?.reduce((acc: any, token: any) => { + acc[`${token.currency}.${token.issuer_account}`] = { + ...token, + } + return acc + }, {}) || {} + allTokens = { ...allTokens, ...tokens } + } + } catch (error) { + log.error( + `Error batch-get tokens[${tokenIds.join(', ')}]. Error: ${JSON.stringify(error)}`, + ) + } + } + + const transferFee = account?.info?.rate + const accountGlobalFreeze = account?.info?.flags?.includes('lsfGlobalFreeze') + + const iouData: IOU[] = iouTokens.map((currency: string) => { + const tokenId = `${currency}.${accountId}` + const token = allTokens[tokenId] + + return { + tokenCode: currency, + tokenIcon: token?.icon, + priceInXRP: token?.price ? parseFloat(token.price) : 0, + trustlines: token?.number_of_trustlines || 0, + holders: token?.number_of_holders || 0, + supply: token?.supply || 0, // This including locked balance (see rcA8X3TVMST1n3CJeAdGk1RdRCHii7N2h) + assetClass: token?.asset_class || '--', + transferFee: `${transferFee}%`, + frozen: accountGlobalFreeze ? 'Global' : '--', + } + }) + + return iouData +} + export const IssuedIOUs = ({ accountId, account, + xrpToUSDRate, onCountChange, }: IssuedIOUsProps) => { const lang = useLanguage() const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + + const issuedIOUsQuery = useQuery(['issuedIOUs', accountId], () => + fetchAccountIssuedIOUs(rippledSocket, accountId, account), + ) - const issuedIOUsQ = useQuery('issuedIOUs', getIssuedIOUs) - const rows = issuedIOUsQ.data ?? [] + // Sort by USD price + const sortedIOUs = useMemo(() => { + const data = issuedIOUsQuery.data || [] + return [...data].sort((a, b) => { + const aBalanceUSD = a.priceInXRP * xrpToUSDRate + const bBalanceUSD = b.priceInXRP * xrpToUSDRate + return bBalanceUSD - aBalanceUSD + }) + }, [issuedIOUsQuery.data, xrpToUSDRate]) // Communicate count back to parent useEffect(() => { if (onCountChange) { - onCountChange(rows.length) + onCountChange(sortedIOUs.length) } - }, [rows.length, onCountChange]) + }, [sortedIOUs.length, onCountChange]) - if (issuedIOUsQ.isLoading) { + if (issuedIOUsQuery.isLoading) { return } @@ -52,22 +169,45 @@ export const IssuedIOUs = ({ - {rows.length === 0 ? ( + {sortedIOUs.length === 0 ? ( {t('account_page_asset_table_no_iou')} ) : ( - rows.map((token) => ( - + sortedIOUs.map((token) => ( + + - - - + + - - + + )) diff --git a/src/containers/Accounts/AccountAsset/index.tsx b/src/containers/Accounts/AccountAsset/index.tsx index 5d502550c..5464651d3 100644 --- a/src/containers/Accounts/AccountAsset/index.tsx +++ b/src/containers/Accounts/AccountAsset/index.tsx @@ -232,6 +232,7 @@ export default function AccountAsset({ diff --git a/src/containers/Accounts/AccountAsset/mockData.ts b/src/containers/Accounts/AccountAsset/mockData.ts deleted file mode 100644 index d44235ae9..000000000 --- a/src/containers/Accounts/AccountAsset/mockData.ts +++ /dev/null @@ -1,243 +0,0 @@ -function genHeldIOUs(count = 20) { - const codes = [ - 'RLUSD', - 'SOLO', - 'EXMP', - 'TOKN', - 'XUSD', - 'ABC', - 'DEF', - 'GHI', - 'JKL', - 'MNO', - ] - const issuers = [ - 'Ripple', - 'Sologenic', - 'Issuer A', - 'Issuer B', - 'Issuer C', - 'Issuer D', - 'Issuer E', - 'Issuer F', - 'Issuer G', - 'Issuer H', - ] - return Array.from({ length: count }, (_, i) => { - const idx = i % codes.length - return { - code: codes[idx], - issuer: issuers[idx], - price: `$${(10 + idx * 5 + (i % 7)).toFixed(2)}`, - balance: `${(i + 1) * 10}`, - balanceUsd: `$${((i + 1) * 10 * 1.23).toFixed(2)}`, - assetClass: idx % 2 === 0 ? 'RWA' : 'Defi', - fee: `${(0.05 + idx * 0.01).toFixed(2)}%`, - frozen: idx % 3 === 0 ? 'Global' : idx % 3 === 1 ? '--' : 'Trustline', - } - }) -} - -function genHeldMPTs(count = 20) { - const tickers = [ - 'RLUSD', - 'SOLO', - 'NAME', - 'TOKA', - 'TOKB', - 'TOKC', - 'TOKD', - 'TOKE', - 'TOKF', - 'TOKG', - ] - const issuers = [ - 'Ripple', - 'Sologenic', - 'Name Inc', - 'Issuer 4', - 'Issuer 5', - 'Issuer 6', - 'Issuer 7', - 'Issuer 8', - 'Issuer 9', - 'Issuer 10', - ] - return Array.from({ length: count }, (_, i) => { - const idx = i % tickers.length - const base = (i + 1000).toString(16).toUpperCase().slice(0, 8) - return { - tokenId: `r${base}C...K${base}X`, - ticker: tickers[idx], - issuer: issuers[idx], - price: `$${(1 + idx * 3 + (i % 5)).toFixed(2)}`, - balance: `${(i + 1) * 100}`, - balanceUsd: `$${((i + 1) * 100 * 1.1).toFixed(2)}`, - assetClass: idx % 2 === 0 ? 'RWA' : 'Tokenized', - fee: `${(0.05 + idx * 0.02).toFixed(2)}%`, - frozen: idx % 3 === 0 ? 'Global' : '--', - } - }) -} - -function genHeldLPTokens(count = 20) { - const pairs = [ - 'MAG/XRP', - 'ETH/BTC', - 'LTC/USDT', - 'BNB/ETH', - 'SOL/DOT', - 'AAA/BBB', - 'CCC/DDD', - 'EEE/FFF', - 'GGG/HHH', - 'III/JKL', - ] - const instances = Array.from( - { length: count }, - (_, i) => `rLp${(i + 1).toString(16).toUpperCase()}`, - ) - return Array.from({ length: count }, (_, i) => { - const idx = i % pairs.length - return { - ammInstance: instances[i], - ammPair: pairs[idx], - balance: `${(i + 1) * 50}`, - balanceUsd: `${((i + 1) * 50 * (idx + 1)).toFixed(2)} USD`, - share: `${((i % 100) / 100).toFixed(2)}%`, - } - }) -} - -/* NFT generators */ -function genHeldNFTs(count = 100) { - const issuers = [ - 'r9f2dD...qH7zX', - 'rU6BEsJ...gM9Vn', - 'rEb8TK...uK8dM', - 'rsoLo2...z3p4q', - 'rPEPPER...BEANS', - 'rGwUw...g4fk4', - 'rN7n7...pD45K', - 'rhB9c...tV9pW', - 'rJbV3D...hN6bA', - 'rUCLG...pT5sR', - ] - const urls = [ - 'xrpl-validator.net', - 'gatehub.net', - 'bithomp.com', - 'xumm.app', - 'ripple.com', - 'coinfield.com', - 'bitstamp.net', - 'alloy-network.io', - 'xrpl.cafe', - 'onxrp.com', - ] - return Array.from({ length: count }, (_, i) => { - const idx = i % issuers.length - const base = (i + 1).toString(16).toUpperCase().padStart(8, '0') - return { - tokenId: `${base}...${base}C${(i % 9) + 1}`, - issuer: issuers[idx], - url: urls[idx % urls.length], - fee: `${(idx === 3 ? 0 : (idx + 1) * 0.05).toFixed(2)}%`, - lowestAsk: `${(idx + 1) * 10} XRP`, - highestBid: `${(idx + 1) * 7 + (i % 3)} XRP`, - } - }) -} - -function genIssuedNFTs(count = 100) { - const urls = [ - 'www.website.com', - 'www.crypto-exchange.io', - 'www.staking-rewards.net', - 'www.nft-market.art', - 'www.defi-lending.org', - 'www.secure-wallet.app', - 'www.payment-gateway.com', - 'www.crypto-gaming.co', - 'www.trading-bot.ai', - 'www.cross-chain-bridge.fi', - ] - return Array.from({ length: count }, (_, i) => { - const idx = i % urls.length - const base = (i + 11111111).toString(16).toUpperCase() - return { - tokenId: `${base}...${base.slice(0, 8)}`, - url: urls[idx], - fee: `${(idx === 7 ? 5.0 : (idx + 1) * 0.15).toFixed(2)}%`, - lowestAsk: `${(idx + 1) * 123} XRP`, - highestBid: `${(idx + 1) * 10 + (i % 5)} XRP`, - } - }) -} - -/* Issued generators */ -function genIssuedIOUs(count = 20) { - const codes = [ - 'RLUSD', - 'SOLO', - 'EXMP', - 'TOKN', - 'XUSD', - 'ISS1', - 'ISS2', - 'ISS3', - 'ISS4', - 'ISS5', - ] - return Array.from({ length: count }, (_, i) => { - const idx = i % codes.length - return { - code: codes[idx], - price: `$${(5 + idx * 2 + (i % 3)).toFixed(2)}`, - trustlines: `${1000 + i * 10}`, - holders: `${500 + i * 5}`, - supply: `${100000 + i * 1000}`, - assetClass: idx % 2 === 0 ? 'RWA' : 'Other', - fee: `${(0.02 + idx * 0.01).toFixed(2)}%`, - frozen: idx % 3 === 0 ? 'Global' : '--', - } - }) -} - -function genIssuedMPTs(count = 20) { - const tickers = [ - 'RLUSD', - 'SOLO', - 'ISSU', - 'MP01', - 'MP02', - 'MP03', - 'MP04', - 'MP05', - 'MP06', - 'MP07', - ] - return Array.from({ length: count }, (_, i) => { - const idx = i % tickers.length - const base = (i + 4000).toString(16).toUpperCase().slice(0, 8) - return { - tokenId: `rIss${base}...${base}Y`, - ticker: tickers[idx], - price: `$${(2 + idx * 1.5 + (i % 4)).toFixed(2)}`, - supply: `${1000 + i * 50}`, - assetClass: idx % 2 === 0 ? 'RWA' : 'Utility', - fee: `${(0.05 + idx * 0.005).toFixed(2)}%`, - frozen: idx % 2 === 0 ? 'Global' : '--', - } - }) -} - -/* exports */ -export const mockHeldIOUs = genHeldIOUs(20) -export const mockHeldMPTs = genHeldMPTs(20) -export const mockHeldLP = genHeldLPTokens(20) -export const mockHeldNFT = genHeldNFTs(100) - -export const issuedIOUs = genIssuedIOUs(20) -export const issuedMPs = genIssuedMPTs(20) -export const issuedNFTs = genIssuedNFTs(100) diff --git a/src/containers/Accounts/AccountAsset/styles.scss b/src/containers/Accounts/AccountAsset/styles.scss index 1e4733c42..aeef2425e 100644 --- a/src/containers/Accounts/AccountAsset/styles.scss +++ b/src/containers/Accounts/AccountAsset/styles.scss @@ -62,7 +62,7 @@ .account-asset-table-wrapper-fixed .account-asset-table { overflow: auto; /* Vertical scroll when overflowing, keep horizontal if needed */ - max-height: 270px; /* Fixed viewport height for the table area */ + max-height: 255px; /* Fixed viewport height for the table area */ background: transparent; -webkit-overflow-scrolling: touch; From 97f27ac33645a128ca253d2fa0bd7817be5ccda8 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 1 Oct 2025 10:59:54 -0400 Subject: [PATCH 08/47] New commit --- .../Accounts/AccountAsset/assetTables/HeldIOUs.tsx | 4 +++- .../Accounts/AccountAsset/assetTables/IssuedIOUs.tsx | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index 745378384..1741f28a8 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -149,7 +149,9 @@ const fetchAccountHeldIOUs = async ( ) } } - + log.info( + `Successfully fetched LOS data for ${Object.keys(allTokens).length} held IOUs`, + ) // Combine all data (without transfer fees and Global freeze status for now) const iouData: IOU[] = positiveBalanceLines.map((line: any) => { const tokenId = `${line.currency}.${line.account}` diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index f53db05d7..5b023f747 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -59,6 +59,7 @@ const fetchAccountIssuedIOUs = async ( // No IOUs issued by this account, return empty array return [] } + log.info(`Found ${iouTokens.length} issued IOUs`) // Batch get token data from LOS Token API const allTokenIds = iouTokens.map((currency) => `${currency}.${accountId}`) @@ -94,6 +95,9 @@ const fetchAccountIssuedIOUs = async ( ) } } + log.info( + `Successfully fetched LOS data for ${Object.keys(allTokens).length} issued IOUs`, + ) const transferFee = account?.info?.rate const accountGlobalFreeze = account?.info?.flags?.includes('lsfGlobalFreeze') @@ -205,7 +209,7 @@ export const IssuedIOUs = ({ - + From 997cd0e7b6ff6c751945576e3e525a8ef872e9f2 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 1 Oct 2025 11:51:02 -0400 Subject: [PATCH 09/47] Use hardcoded LOS endpoints temporarily in IOUs asset components --- src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx | 2 +- src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index 1741f28a8..c3e3abc99 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -123,7 +123,7 @@ const fetchAccountHeldIOUs = async ( try { // eslint-disable-next-line no-await-in-loop const apiResponse = await fetch( - `${process.env.VITE_LOS_URL}/tokens/batch-get`, + `https://los.dev.ripplex.io/tokens/batch-get`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index 5b023f747..57d3f0b7a 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -69,7 +69,7 @@ const fetchAccountIssuedIOUs = async ( try { // eslint-disable-next-line no-await-in-loop const apiResponse = await fetch( - `${process.env.VITE_LOS_URL}/tokens/batch-get`, + `https://los.dev.ripplex.io/tokens/batch-get`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, From b2a434e7cac224e2ab1773f36e847ead9d08a381 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 1 Oct 2025 22:54:27 -0400 Subject: [PATCH 10/47] Add spinning circle to asset tab button when data is loading in addition to the loading XRP symbol in the table area --- .../AccountAsset/assetTables/HeldIOUs.tsx | 12 +- .../AccountAsset/assetTables/HeldLPTokens.tsx | 12 +- .../AccountAsset/assetTables/HeldMPTs.tsx | 12 +- .../AccountAsset/assetTables/HeldNFTs.tsx | 6 +- .../AccountAsset/assetTables/IssuedIOUs.tsx | 15 +- .../AccountAsset/assetTables/IssuedMPTs.tsx | 12 +- .../AccountAsset/assetTables/IssuedNFTs.tsx | 6 +- .../AccountAsset/assetTables/NFTTable.tsx | 12 +- .../Accounts/AccountAsset/index.tsx | 195 +++++++++++------- .../Accounts/AccountAsset/styles.scss | 21 ++ 10 files changed, 187 insertions(+), 116 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index c3e3abc99..71fdf3d4c 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -37,7 +37,7 @@ const LOS_TOKEN_API_BATCH_SIZE = 100 interface HeldIOUsProps { accountId: string xrpToUSDRate: number - onCountChange?: (count: number) => void + onChange?: (data: { count: number; isLoading: boolean }) => void } interface IOU { @@ -176,7 +176,7 @@ const fetchAccountHeldIOUs = async ( export const HeldIOUs = ({ accountId, xrpToUSDRate, - onCountChange, + onChange, }: HeldIOUsProps) => { const lang = useLanguage() const { t } = useTranslation() @@ -251,12 +251,12 @@ export const HeldIOUs = ({ } }, [sortedIOUs.length, fetchIssuerInfoProgressively]) - // Communicate count back to parent + // Communicate count and loading state back to parent useEffect(() => { - if (onCountChange) { - onCountChange(ious.length) + if (onChange) { + onChange({ count: ious.length, isLoading: heldIOUsQuery.isLoading }) } - }, [ious.length, onCountChange]) + }, [ious.length, heldIOUsQuery.isLoading, onChange]) if (heldIOUsQuery.isLoading) { return diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx index 9d65a7bcd..5e01d8b74 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -122,13 +122,13 @@ const processLPTokenAsset = async (rippledSocket, issuerAccount, asset) => { interface HeldLPTokensProps { accountId: string xrpToUSDRate: number - onCountChange?: (count: number) => void + onChange?: (data: { count: number; isLoading: boolean }) => void } export const HeldLPTokens = ({ accountId, xrpToUSDRate, - onCountChange, + onChange, }: HeldLPTokensProps) => { const lang = useLanguage() const { t } = useTranslation() @@ -167,12 +167,12 @@ export const HeldLPTokens = ({ return 0 }) - // Communicate count back to parent + // Communicate count and loading state back to parent useEffect(() => { - if (onCountChange) { - onCountChange(rows.length) + if (onChange) { + onChange({ count: rows.length, isLoading: heldLPTokensQuery.isLoading }) } - }, [rows.length, onCountChange]) + }, [rows.length, heldLPTokensQuery.isLoading, onChange]) if (heldLPTokensQuery.isLoading) { return diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx index b07372161..c7c6d92b2 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx @@ -24,7 +24,7 @@ const log = logger({ name: 'HeldMPTs' }) interface HeldMPTsProps { accountId: string - onCountChange?: (count: number) => void + onChange?: (data: { count: number; isLoading: boolean }) => void } const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { @@ -118,7 +118,7 @@ const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { return combinedMPTs } -export const HeldMPTs = ({ accountId, onCountChange }: HeldMPTsProps) => { +export const HeldMPTs = ({ accountId, onChange }: HeldMPTsProps) => { const lang = useLanguage() const { t } = useTranslation() const rippledSocket = useContext(SocketContext) @@ -128,12 +128,12 @@ export const HeldMPTs = ({ accountId, onCountChange }: HeldMPTsProps) => { ) const rows = heldMPTsQuery.data ?? [] - // Communicate count back to parent + // Communicate count and loading state back to parent useEffect(() => { - if (onCountChange) { - onCountChange(rows.length) + if (onChange) { + onChange({ count: rows.length, isLoading: heldMPTsQuery.isLoading }) } - }, [rows.length, onCountChange]) + }, [rows.length, heldMPTsQuery.isLoading, onChange]) if (heldMPTsQuery.isLoading) { return diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx index 98de4e548..0e1d59431 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx @@ -7,7 +7,7 @@ const log = logger({ name: 'HeldNFTs' }) interface HeldNFTsProps { accountId: string - onCountChange?: (count: number) => void + onChange?: (data: { count: number; isLoading: boolean }) => void } const fetchAccountHeldNFTs = async ( @@ -47,10 +47,10 @@ const fetchAccountHeldNFTs = async ( } } -export const HeldNFTs = ({ accountId, onCountChange }: HeldNFTsProps) => ( +export const HeldNFTs = ({ accountId, onChange }: HeldNFTsProps) => ( void + onChange?: (data: { count: number; isLoading: boolean }) => void } interface IOU { @@ -126,7 +126,7 @@ export const IssuedIOUs = ({ accountId, account, xrpToUSDRate, - onCountChange, + onChange, }: IssuedIOUsProps) => { const lang = useLanguage() const { t } = useTranslation() @@ -146,12 +146,15 @@ export const IssuedIOUs = ({ }) }, [issuedIOUsQuery.data, xrpToUSDRate]) - // Communicate count back to parent + // Communicate count and loading state back to parent useEffect(() => { - if (onCountChange) { - onCountChange(sortedIOUs.length) + if (onChange) { + onChange({ + count: sortedIOUs.length, + isLoading: issuedIOUsQuery.isLoading, + }) } - }, [sortedIOUs.length, onCountChange]) + }, [sortedIOUs.length, issuedIOUsQuery.isLoading, onChange]) if (issuedIOUsQuery.isLoading) { return diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx index c1e8f979a..c7f64e027 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx @@ -21,7 +21,7 @@ const log = logger({ name: 'IssuedMPTs' }) interface IssuedMPTsProps { accountId: string - onCountChange?: (count: number) => void + onChange?: (data: { count: number; isLoading: boolean }) => void } const fetchAccountIssuedMPTs = async ( @@ -75,7 +75,7 @@ const fetchAccountIssuedMPTs = async ( return issuedMPTs } -export const IssuedMPTs = ({ accountId, onCountChange }: IssuedMPTsProps) => { +export const IssuedMPTs = ({ accountId, onChange }: IssuedMPTsProps) => { const lang = useLanguage() const { t } = useTranslation() const rippledSocket = useContext(SocketContext) @@ -85,12 +85,12 @@ export const IssuedMPTs = ({ accountId, onCountChange }: IssuedMPTsProps) => { ) const rows = issuedMPTsQuery.data ?? [] - // Communicate count back to parent + // Communicate count and loading state back to parent useEffect(() => { - if (onCountChange) { - onCountChange(rows.length) + if (onChange) { + onChange({ count: rows.length, isLoading: issuedMPTsQuery.isLoading }) } - }, [rows.length, onCountChange]) + }, [rows.length, issuedMPTsQuery.isLoading, onChange]) if (issuedMPTsQuery.isLoading) { return diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx index aa02b24c9..6d6056e74 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx @@ -7,7 +7,7 @@ const log = logger({ name: 'IssuedNFTs' }) interface IssuedNFTsProps { accountId: string - onCountChange?: (count: number) => void + onChange?: (data: { count: number; isLoading: boolean }) => void } const fetchAccountIssuedNFTs = async ( @@ -48,10 +48,10 @@ const fetchAccountIssuedNFTs = async ( } } -export const IssuedNFTs = ({ onCountChange, accountId }: IssuedNFTsProps) => ( +export const IssuedNFTs = ({ accountId, onChange }: IssuedNFTsProps) => ( void + onChange?: (data: { count: number; isLoading: boolean }) => void fetchNFTs: (rippledSocket: any, accountId: string) => Promise queryKey: string showIssuer?: boolean @@ -46,7 +46,7 @@ export interface NFT { export const NFTTable = ({ accountId, - onCountChange, + onChange, fetchNFTs, queryKey, showIssuer = false, @@ -180,12 +180,12 @@ export const NFTTable = ({ } }, [basicNFTs, batchProcessNFTOffers]) - // Communicate total count back to parent + // Communicate count and loading state back to parent useEffect(() => { - if (onCountChange) { - onCountChange(nfts.length) + if (onChange) { + onChange({ count: nfts.length, isLoading: nftsQuery.isLoading }) } - }, [nfts.length, onCountChange]) + }, [nfts.length, nftsQuery.isLoading, onChange]) if (nftsQuery.isLoading) { return diff --git a/src/containers/Accounts/AccountAsset/index.tsx b/src/containers/Accounts/AccountAsset/index.tsx index 5464651d3..0722c6ec0 100644 --- a/src/containers/Accounts/AccountAsset/index.tsx +++ b/src/containers/Accounts/AccountAsset/index.tsx @@ -1,6 +1,8 @@ -import React, { useState, useCallback } from 'react' +import { useState, useCallback } from 'react' import './styles.scss' import { useTranslation } from 'react-i18next' +import { localizeNumber } from '../../shared/utils' +import { useLanguage } from '../../shared/hooks' import { HeldIOUs } from './assetTables/HeldIOUs' import { HeldMPTs } from './assetTables/HeldMPTs' import { HeldLPTokens } from './assetTables/HeldLPTokens' @@ -8,8 +10,6 @@ import { HeldNFTs } from './assetTables/HeldNFTs' import { IssuedIOUs } from './assetTables/IssuedIOUs' import { IssuedMPTs } from './assetTables/IssuedMPTs' import { IssuedNFTs } from './assetTables/IssuedNFTs' -import { useLanguage } from '../../shared/hooks' -import { localizeNumber } from '../../shared/utils' type HeldAssetTabKey = 'iou' | 'mpt' | 'lptoken' | 'nft' type IssuedAssetTabKey = 'iou' | 'mpt' | 'nft' @@ -18,10 +18,12 @@ function TabButton({ label, active, onClick, + loading = false, }: { label: string active?: boolean onClick?: () => void + loading?: boolean }) { return ( ) } @@ -52,62 +60,82 @@ export default function AccountAsset({ const { t } = useTranslation() // Counts managed by individual table components - const [heldCounts, setHeldCounts] = useState({ - iou: 0, - mpt: 0, - lptoken: 0, - nft: 0, + const [counts, setCounts] = useState({ + heldIou: 0, + heldMpt: 0, + heldLptoken: 0, + heldNft: 0, + issuedIou: 0, + issuedMpt: 0, + issuedNft: 0, }) - const [issuedCounts, setIssuedCounts] = useState({ - iou: 0, - mpt: 0, - nft: 0, + // Loading states - start as true, set to false when we get data + const [loading, setLoading] = useState({ + heldIou: true, + heldMpt: true, + heldLptoken: true, + heldNft: true, + issuedIou: true, + issuedMpt: true, + issuedNft: true, }) - // Functions to update counts (need useCallback for stable refs) - const updateHeldCount = useCallback( - (type: keyof typeof heldCounts, count: number) => { - setHeldCounts((prev) => ({ ...prev, [type]: count })) + // Stable update functions for each asset type + const updateHeldIOUs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, heldIou: count })) + setLoading((prev) => ({ ...prev, heldIou: isLoading })) }, [], ) - const updateIssuedCount = useCallback( - (type: keyof typeof issuedCounts, count: number) => { - setIssuedCounts((prev) => ({ ...prev, [type]: count })) + + const updateHeldMPTs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, heldMpt: count })) + setLoading((prev) => ({ ...prev, heldMpt: isLoading })) }, [], ) - // Specific update functions for each type (need useCallback for stable refs) - const updateHeldIOUCount = useCallback( - (count: number) => updateHeldCount('iou', count), - [updateHeldCount], - ) - const updateHeldMPTokenCount = useCallback( - (count: number) => updateHeldCount('mpt', count), - [updateHeldCount], - ) - const updateHeldLPTokenCount = useCallback( - (count: number) => updateHeldCount('lptoken', count), - [updateHeldCount], + const updateHeldLPTokens = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, heldLptoken: count })) + setLoading((prev) => ({ ...prev, heldLptoken: isLoading })) + }, + [], ) - const updateHeldNFTCount = useCallback( - (count: number) => updateHeldCount('nft', count), - [updateHeldCount], + + const updateHeldNFTs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, heldNft: count })) + setLoading((prev) => ({ ...prev, heldNft: isLoading })) + }, + [], ) - const updateIssuedIOUCount = useCallback( - (count: number) => updateIssuedCount('iou', count), - [updateIssuedCount], + const updateIssuedIOUs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, issuedIou: count })) + setLoading((prev) => ({ ...prev, issuedIou: isLoading })) + }, + [], ) - const updateIssuedMPTokenCount = useCallback( - (count: number) => updateIssuedCount('mpt', count), - [updateIssuedCount], + + const updateIssuedMPTs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, issuedMpt: count })) + setLoading((prev) => ({ ...prev, issuedMpt: isLoading })) + }, + [], ) - const updateIssuedNFTCount = useCallback( - (count: number) => updateIssuedCount('nft', count), - [updateIssuedCount], + + const updateIssuedNFTs = useCallback( + ({ count, isLoading }: { count: number; isLoading: boolean }) => { + setCounts((prev) => ({ ...prev, issuedNft: count })) + setLoading((prev) => ({ ...prev, issuedNft: isLoading })) + }, + [], ) // Tabs state @@ -127,31 +155,47 @@ export default function AccountAsset({ > setHeldTab('iou')} + loading={loading.heldIou} /> setHeldTab('mpt')} + loading={loading.heldMpt} /> setHeldTab('lptoken')} + loading={loading.heldLptoken} /> setHeldTab('nft')} + loading={loading.heldNft} /> @@ -163,17 +207,14 @@ export default function AccountAsset({
    - +
    @@ -189,7 +230,7 @@ export default function AccountAsset({ className="account-asset-table-wrapper" style={{ display: heldTab === 'nft' ? 'block' : 'none' }} > - + {/* Assets Issued */} @@ -203,24 +244,36 @@ export default function AccountAsset({ > setIssuedTab('iou')} + loading={loading.issuedIou} /> setIssuedTab('mpt')} + loading={loading.issuedMpt} /> setIssuedTab('nft')} + loading={loading.issuedNft} /> @@ -233,26 +286,20 @@ export default function AccountAsset({ accountId={accountId} account={account} xrpToUSDRate={xrpToUSDRate} - onCountChange={updateIssuedIOUCount} + onChange={updateIssuedIOUs} />
    - +
    - +
    ) diff --git a/src/containers/Accounts/AccountAsset/styles.scss b/src/containers/Accounts/AccountAsset/styles.scss index aeef2425e..5e85a1151 100644 --- a/src/containers/Accounts/AccountAsset/styles.scss +++ b/src/containers/Accounts/AccountAsset/styles.scss @@ -52,6 +52,27 @@ color: $black-0; } +// Loading spinner for tab buttons +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.loading-spinner { + display: inline-block; + width: 10px; + height: 10px; + border: 1px solid rgb(255 255 255 / 30%); + border-radius: 50%; + border-top-color: #fff; + animation: spin 1s ease-in-out infinite; +} + .account-asset-table { background: transparent; From 51974bc63ad431c54c0d1d6692afaec6aeca8ce8 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 00:20:27 -0400 Subject: [PATCH 11/47] Rename CurrencyOption to NumberFormatOption --- .../AccountAsset/assetTables/HeldIOUs.tsx | 5 +++- .../AccountAsset/assetTables/HeldLPTokens.tsx | 2 +- .../AccountAsset/assetTables/IssuedIOUs.tsx | 2 +- .../AccountAsset/assetTables/NFTTable.tsx | 2 +- .../Accounts/AccountSummary/Balances.tsx | 2 +- .../Accounts/AccountSummary/DetailsCard.tsx | 2 +- ...yOptions.ts => NumberFormattingOptions.ts} | 24 ++++++++++++++++++- 7 files changed, 32 insertions(+), 7 deletions(-) rename src/containers/shared/{CurrencyOptions.ts => NumberFormattingOptions.ts} (57%) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index 71fdf3d4c..a265b6de9 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -28,7 +28,10 @@ import { useLanguage } from '../../../shared/hooks' import { USD_CURRENCY_OPTIONS, USD_SMALL_BALANCE_CURRENCY_OPTIONS, -} from '../../../shared/CurrencyOptions' + USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS, + NUMBER_DEFAULT_OPTIONS, + NUMBER_SMALL_OPTIONS, +} from '../../../shared/NumberFormattingOptions' const log = logger({ name: 'HeldIOUs' }) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx index 5e01d8b74..efd4dff9c 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -11,7 +11,7 @@ import { Account } from '../../../shared/components/Account' import { USD_CURRENCY_OPTIONS, USD_SMALL_BALANCE_CURRENCY_OPTIONS, -} from '../../../shared/CurrencyOptions' +} from '../../../shared/NumberFormattingOptions' import { useLanguage } from '../../../shared/hooks' import { CURRENCY_OPTIONS, localizeNumber } from '../../../shared/utils' import { diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index fa5e28c23..5bc1a38d2 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -14,7 +14,7 @@ import logger from '../../../../rippled/lib/logger' import DefaultTokenIcon from '../../../shared/images/default_token_icon.svg' import { CURRENCY_OPTIONS, localizeNumber } from '../../../shared/utils' import { useLanguage } from '../../../shared/hooks' -import { USD_CURRENCY_OPTIONS } from '../../../shared/CurrencyOptions' +import { USD_CURRENCY_OPTIONS } from '../../../shared/NumberFormattingOptions' const log = logger({ name: 'IssuedIOUs' }) diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx index b6646b0e8..8aff184b5 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -19,7 +19,7 @@ import { import DomainLink from '../../../shared/components/DomainLink' import { XRP_BASE } from '../../../shared/transactionUtils' import { localizeNumber } from '../../../shared/utils' -import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../../shared/CurrencyOptions' +import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../../shared/NumberFormattingOptions' import { useLanguage } from '../../../shared/hooks' import logger from '../../../../rippled/lib/logger' diff --git a/src/containers/Accounts/AccountSummary/Balances.tsx b/src/containers/Accounts/AccountSummary/Balances.tsx index 3a3700314..9b61ff462 100644 --- a/src/containers/Accounts/AccountSummary/Balances.tsx +++ b/src/containers/Accounts/AccountSummary/Balances.tsx @@ -9,7 +9,7 @@ import ReserveIcon from '../../shared/images/xrp_reserve_balance_icon.svg' import { XRP_CURRENCY_OPTIONS, USD_CURRENCY_OPTIONS, -} from '../../shared/CurrencyOptions' +} from '../../shared/NumberFormattingOptions' interface BalancesProps { account: any diff --git a/src/containers/Accounts/AccountSummary/DetailsCard.tsx b/src/containers/Accounts/AccountSummary/DetailsCard.tsx index 9eade9075..6a1c37e52 100644 --- a/src/containers/Accounts/AccountSummary/DetailsCard.tsx +++ b/src/containers/Accounts/AccountSummary/DetailsCard.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { localizeNumber } from '../../shared/utils' -import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../shared/CurrencyOptions' +import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../shared/NumberFormattingOptions' import { Account } from '../../shared/components/Account' interface DetailsCardProps { diff --git a/src/containers/shared/CurrencyOptions.ts b/src/containers/shared/NumberFormattingOptions.ts similarity index 57% rename from src/containers/shared/CurrencyOptions.ts rename to src/containers/shared/NumberFormattingOptions.ts index 19df212d1..ab38685d2 100644 --- a/src/containers/shared/CurrencyOptions.ts +++ b/src/containers/shared/NumberFormattingOptions.ts @@ -26,5 +26,27 @@ export const USD_SMALL_BALANCE_CURRENCY_OPTIONS = { style: 'currency', currency: 'USD', minimumFractionDigits: 2, - maximumFractionDigits: 6, + maximumFractionDigits: 4, +} + +// Higher precision for small (<0.0001 USD) balances +export const USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS = { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 8, +} + +export const NUMBER_DEFAULT_OPTIONS = { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + useGrouping: true, +} + +export const NUMBER_SMALL_OPTIONS = { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 4, + useGrouping: true, } From a33e46213b2723e9e3d281d2e4f2b1d8c57b76a2 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 00:57:00 -0400 Subject: [PATCH 12/47] Extract common USD and Balance formatting logic into utils file --- .../AccountAsset/assetTables/HeldIOUs.tsx | 114 ++++++------ .../AccountAsset/assetTables/HeldLPTokens.tsx | 99 +++++------ .../AccountAsset/assetTables/IssuedIOUs.tsx | 18 +- .../AccountAsset/assetTables/NFTTable.tsx | 2 +- .../Accounts/AccountSummary/Balances.tsx | 2 +- .../Accounts/AccountSummary/DetailsCard.tsx | 2 +- .../shared/NumberFormattingOptions.ts | 52 ------ .../shared/NumberFormattingUtils.ts | 163 ++++++++++++++++++ 8 files changed, 276 insertions(+), 176 deletions(-) delete mode 100644 src/containers/shared/NumberFormattingOptions.ts create mode 100644 src/containers/shared/NumberFormattingUtils.ts diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index a265b6de9..ca093649b 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -23,15 +23,9 @@ import { } from '../../../../rippled/lib/rippled' import logger from '../../../../rippled/lib/logger' import DefaultTokenIcon from '../../../shared/images/default_token_icon.svg' -import { CURRENCY_OPTIONS, localizeNumber } from '../../../shared/utils' + import { useLanguage } from '../../../shared/hooks' -import { - USD_CURRENCY_OPTIONS, - USD_SMALL_BALANCE_CURRENCY_OPTIONS, - USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS, - NUMBER_DEFAULT_OPTIONS, - NUMBER_SMALL_OPTIONS, -} from '../../../shared/NumberFormattingOptions' +import { calculateFormattedUsdBalance } from '../../../shared/NumberFormattingUtils' const log = logger({ name: 'HeldIOUs' }) @@ -286,60 +280,56 @@ export const HeldIOUs = ({ {t('account_page_asset_table_no_iou')} ) : ( - ious.map((token) => ( -
    - - - - - - - - - - )) + ious.map((token) => { + const { + formattedUsdPrice, + formattedBalance, + formattedBalanceUsd, + } = calculateFormattedUsdBalance( + token.balance, + token.priceInXRP, + xrpToUSDRate, + lang, + ) + + return ( + + + + + + + + + + + ) + }) )}
    - {token.logo ? ( -
    - {token.code} - + +
    + {token.tokenIcon ? ( + {token.tokenCode} + ) : ( + + )} +
    - ) : ( - - )} +
    {token.price || '--'}{token.balance}{token.balanceUsd || '--'}{token.assetClass || '--'}{token.fee} + {(() => { + const usdPrice = token.priceInXRP * xrpToUSDRate + const currencyOptions = + usdPrice < 1 ? CURRENCY_OPTIONS : USD_CURRENCY_OPTIONS + return localizeNumber(usdPrice, lang, currencyOptions) + })()} + {localizeNumber(token.balance, lang, CURRENCY_OPTIONS)} + {(() => { + const balanceUSD = + token.priceInXRP * token.balance * xrpToUSDRate + const currencyOptions = + balanceUSD < 1 + ? USD_SMALL_BALANCE_CURRENCY_OPTIONS + : USD_CURRENCY_OPTIONS + return localizeNumber(balanceUSD, lang, currencyOptions) + })()} + {token.assetClass || '--'}{token.transferFee || '--'} {token.frozen ? 'Yes' : 'No'}
    {localizeNumber(row.lpTokenBalance, lang)} + {localizeNumber(row.lpTokenBalance, lang, CURRENCY_OPTIONS)} + {row.currency1 === 'XRP' || row.currency2 === 'XRP' ? (() => { diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index 7b40d0a29..24553ecb8 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -5,6 +5,8 @@ import Currency from '../../../shared/components/Currency' import { Loader } from '../../../shared/components/Loader' import { getIssuedIOUs } from '../api' import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' +import { localizeNumber } from '../../../shared/utils' +import { useLanguage } from '../../../shared/hooks' interface IssuedIOUsProps { accountId: string @@ -17,6 +19,7 @@ export const IssuedIOUs = ({ account, onCountChange, }: IssuedIOUsProps) => { + const lang = useLanguage() const { t } = useTranslation() const issuedIOUsQ = useQuery('issuedIOUs', getIssuedIOUs) @@ -59,7 +62,7 @@ export const IssuedIOUs = ({ {token.price}{localizeNumber(token.price, lang)} {token.trustlines} {token.holders} {token.supply}{token.assetClass || '--'} {token.transferFee || '--'}{token.frozen ? 'Yes' : 'No'}{token.frozen}
    {nft.fee}%{nft.transferFee}% {nft.lowestAsk ? localizeNumber( diff --git a/src/containers/Token/TokenHeader/index.tsx b/src/containers/Token/TokenHeader/index.tsx index c22ba9e7c..725be2729 100644 --- a/src/containers/Token/TokenHeader/index.tsx +++ b/src/containers/Token/TokenHeader/index.tsx @@ -45,9 +45,7 @@ export const TokenHeader = ({ value={} /> )} - {rate && ( - - )} + {previousLedger && ( { return s } -const buildFlags = (flags, flagMap) => { +export const buildFlags = (flags, flagMap) => { const bits = zeroPad((flags || 0).toString(2), 32).split('') return bits @@ -69,6 +70,25 @@ const buildFlags = (flags, flagMap) => { .filter((d) => Boolean(d)) } +const formatTransferFee = (transferFee, tokenType = '') => { + if (!transferFee) { + return '0' + } + + // https://xrpl.org/docs/concepts/tokens/fungible-tokens/transfer-fees#technical-details + if (tokenType === 'IOU') { + const transferFeePercentage = (100 * (transferFee - BILLION)) / BILLION + return parseFloat(transferFeePercentage.toFixed(7)).toString() + } + + // For MPT and NFT + // MTP: https://xrpl.org/docs/references/protocol/data-types/nftoken#transferfee + // NFT: https://xrpl.org/docs/concepts/tokens/fungible-tokens/multi-purpose-tokens#transfer-fees + const transferFeePercentage = transferFee / THOUSAND + + return parseFloat(transferFeePercentage.toFixed(3)).toString() +} + const formatAccountInfo = (info, serverInfoValidated) => ({ accountTransactionID: info.AccountTxnID, sequence: info.Sequence, @@ -79,7 +99,7 @@ const formatAccountInfo = (info, serverInfoValidated) => ({ info.OwnerCount * serverInfoValidated.reserve_inc_xrp : undefined, tick: info.TickSize, - rate: info.TransferRate ? (info.TransferRate - BILLION) / BILLION : undefined, + rate: formatTransferFee(info.TransferRate, 'IOU'), domain: info.Domain ? hexToString(info.Domain) : undefined, emailHash: info.EmailHash, flags: buildFlags(info.Flags, ACCOUNT_FLAGS), @@ -179,16 +199,6 @@ const shortenMPTID = (mptTokenID = '') => ? `${mptTokenID.slice(0, 10)}...${mptTokenID.slice(-10)}` : mptTokenID -const formatTransferFee = (transferFee) => { - if (!transferFee) { - return '0' - } - - const feePercentage = transferFee / 1000 - - return feePercentage.toFixed(3) -} - export { XRP_BASE, RippledError as Error, diff --git a/src/rippled/token.ts b/src/rippled/token.ts index ab1adebd9..d0c04671d 100644 --- a/src/rippled/token.ts +++ b/src/rippled/token.ts @@ -9,7 +9,7 @@ export interface TokenData { balance: string reserve: number sequence: number - rate?: number + rate?: string obligations?: string domain?: string emailHash?: string From 8776f5b12c94cd72265b027bc854320d0aed836c Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 1 Oct 2025 01:30:27 -0400 Subject: [PATCH 07/47] Handle issued IOUs --- src/containers/Accounts/AccountAsset/api.ts | 45 ---- .../AccountAsset/assetTables/HeldIOUs.tsx | 24 +- .../AccountAsset/assetTables/IssuedIOUs.tsx | 176 +++++++++++-- .../Accounts/AccountAsset/index.tsx | 1 + .../Accounts/AccountAsset/mockData.ts | 243 ------------------ .../Accounts/AccountAsset/styles.scss | 2 +- 6 files changed, 171 insertions(+), 320 deletions(-) delete mode 100644 src/containers/Accounts/AccountAsset/api.ts delete mode 100644 src/containers/Accounts/AccountAsset/mockData.ts diff --git a/src/containers/Accounts/AccountAsset/api.ts b/src/containers/Accounts/AccountAsset/api.ts deleted file mode 100644 index 32a63f974..000000000 --- a/src/containers/Accounts/AccountAsset/api.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - mockHeldIOUs, - mockHeldMPTs, - mockHeldLP, - mockHeldNFT, - issuedIOUs, - issuedMPs as issuedMPTs, - issuedNFTs, -} from './mockData' - -const randomDelay = () => { - const delay = Math.floor(Math.random() * 1000) + 1000 // 1000-2000ms - return new Promise((resolve) => setTimeout(resolve, delay)) -} - -// These mimic real HTTP fetchers; replace implementations later. -export const getHeldIOUs = async () => { - await randomDelay() - return mockHeldIOUs -} - -export const getHeldMPTs = async () => { - await randomDelay() - return mockHeldMPTs -} - -export const getHeldNFTs = async () => { - await randomDelay() - return mockHeldNFT -} - -export const getIssuedIOUs = async () => { - await randomDelay() - return issuedIOUs -} - -export const getIssuedMPTs = async () => { - await randomDelay() - return issuedMPTs -} - -export const getIssuedNFTs = async () => { - await randomDelay() - return issuedNFTs -} diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index 3cc161315..745378384 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -47,8 +47,8 @@ interface IOU { issuerName: string balance: number priceInXRP: number - assetClass?: string - transferFee?: string | null + assetClass: string + transferFee?: string frozen?: string } @@ -59,16 +59,16 @@ const fetchAccountHeldIOUs = async ( log.info(`Finding held IOUs via 'gatewayBalance' for account ${accountId}`) const balancesResponse = await getBalances(rippledSocket, accountId) - const nonLPTokens: any[] = [] + const iouTokens: any[] = [] for (const assets of Object.values(balancesResponse?.assets || {})) { for (const asset of assets as any[]) { if (asset.currency && !asset.currency.startsWith(LP_TOKEN_IDENTIFIER)) { - nonLPTokens.push(asset.currency) + iouTokens.push(asset.currency) } } } - if (nonLPTokens.length === 0) { - // No non-LP token IOUs held, return empty array + if (iouTokens.length === 0) { + // No IOUs held, return empty array return [] } @@ -143,11 +143,9 @@ const fetchAccountHeldIOUs = async ( }, {}) || {} allTokens = { ...allTokens, ...tokens } } - } catch (tokenError) { + } catch (error) { log.error( - `Error fetching token batch ${i / LOS_TOKEN_API_BATCH_SIZE + 1}: ${ - tokenError instanceof Error ? tokenError.message : tokenError - }`, + `Error batch-get tokens[${tokenIds.join(', ')}]. Error: ${JSON.stringify(error)}`, ) } } @@ -164,8 +162,8 @@ const fetchAccountHeldIOUs = async ( issuerName: token?.issuer_name, balance: parseFloat(line.balance), priceInXRP: token?.price ? parseFloat(token.price) : 0, - assetClass: token?.asset_class || null, - transferFee: null, + assetClass: token?.asset_class || '--', + transferFee: '--', frozen: line.freeze || line.freeze_peer ? 'Trustline' : '--', } }) @@ -332,7 +330,7 @@ export const HeldIOUs = ({ return localizeNumber(balanceUSD, lang, currencyOptions) })()} {token.assetClass || '--'}{token.assetClass} {token.transferFee || '--'} {token.frozen}
    + +
    + {token.tokenIcon ? ( + {token.tokenCode} + ) : ( + + )} + +
    +
    +
    - + {(() => { + const usdPrice = token.priceInXRP * xrpToUSDRate + const currencyOptions = + usdPrice < 1 ? CURRENCY_OPTIONS : USD_CURRENCY_OPTIONS + return localizeNumber(usdPrice, lang, currencyOptions) + })()} {localizeNumber(token.price, lang)}{token.trustlines}{token.holders}{localizeNumber(token.trustlines, lang)}{localizeNumber(token.holders, lang)} {token.supply}{token.assetClass}{token.fee}{token.assetClass}{token.transferFee} {token.frozen}
    {localizeNumber(token.trustlines, lang)} {localizeNumber(token.holders, lang)}{token.supply}{localizeNumber(token.supply, lang, CURRENCY_OPTIONS)} {token.assetClass} {token.transferFee} {token.frozen}
    - -
    - {token.tokenIcon ? ( - {token.tokenCode} - ) : ( - - )} - -
    -
    -
    - - - {(() => { - const usdPrice = token.priceInXRP * xrpToUSDRate - const currencyOptions = - usdPrice < 1 ? CURRENCY_OPTIONS : USD_CURRENCY_OPTIONS - return localizeNumber(usdPrice, lang, currencyOptions) - })()} - {localizeNumber(token.balance, lang, CURRENCY_OPTIONS)} - {(() => { - const balanceUSD = - token.priceInXRP * token.balance * xrpToUSDRate - const currencyOptions = - balanceUSD < 1 - ? USD_SMALL_BALANCE_CURRENCY_OPTIONS - : USD_CURRENCY_OPTIONS - return localizeNumber(balanceUSD, lang, currencyOptions) - })()} - {token.assetClass}{token.transferFee || '--'}{token.frozen}
    + +
    + {token.tokenIcon ? ( + {token.tokenCode} + ) : ( + + )} + +
    +
    +
    + + {formattedUsdPrice}{formattedBalance}{formattedBalanceUsd}{token.assetClass}{token.transferFee || '--'}{token.frozen}
    diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx index efd4dff9c..df65b286a 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -9,11 +9,11 @@ import Currency, { import { shortenAccount } from '../../../../rippled/lib/utils' import { Account } from '../../../shared/components/Account' import { - USD_CURRENCY_OPTIONS, - USD_SMALL_BALANCE_CURRENCY_OPTIONS, -} from '../../../shared/NumberFormattingOptions' + formatTokenBalance, + formatUsdBalance, +} from '../../../shared/NumberFormattingUtils' import { useLanguage } from '../../../shared/hooks' -import { CURRENCY_OPTIONS, localizeNumber } from '../../../shared/utils' + import { getBalances, getAMMInfoByAMMAccount, @@ -201,50 +201,53 @@ export const HeldLPTokens = ({ {t('account_page_asset_table_no_lptoken')} ) : ( - rows.map((row) => ( - - - - - - - / - - - - {localizeNumber(row.lpTokenBalance, lang, CURRENCY_OPTIONS)} - - - {row.currency1 === 'XRP' || row.currency2 === 'XRP' - ? (() => { - const balanceUSD = - row.lpTokenBalance * - row.lpTokenPriceInXRP * - xrpToUSDRate - const currencyOptions = - balanceUSD < 1 - ? USD_SMALL_BALANCE_CURRENCY_OPTIONS - : USD_CURRENCY_OPTIONS - return localizeNumber(balanceUSD, lang, currencyOptions) - })() - : '--'} - - - {row.share < 1 ? row.share.toFixed(4) : row.share.toFixed(2)}% - - - )) + rows.map((row) => { + // Calculate display values using utility functions + const formattedBalance = formatTokenBalance( + row.lpTokenBalance, + lang, + ) + + // Format USD Balance (only for XRP pairs) + let formattedBalanceUsd = '--' + if (row.currency1 === 'XRP' || row.currency2 === 'XRP') { + const balanceUSD = + row.lpTokenBalance * row.lpTokenPriceInXRP * xrpToUSDRate + formattedBalanceUsd = formatUsdBalance(balanceUSD, lang) + } + + return ( + + + + + + + / + + + {formattedBalance} + {formattedBalanceUsd} + + {row.share < 1 + ? row.share.toFixed(4) + : row.share.toFixed(2)} + % + + + ) + }) )} diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index 5bc1a38d2..96c5a76d4 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -12,9 +12,12 @@ import SocketContext from '../../../shared/SocketContext' import { getBalances } from '../../../../rippled/lib/rippled' import logger from '../../../../rippled/lib/logger' import DefaultTokenIcon from '../../../shared/images/default_token_icon.svg' -import { CURRENCY_OPTIONS, localizeNumber } from '../../../shared/utils' +import { localizeNumber } from '../../../shared/utils' import { useLanguage } from '../../../shared/hooks' -import { USD_CURRENCY_OPTIONS } from '../../../shared/NumberFormattingOptions' +import { + formatUsdPrice, + formatTokenBalance, +} from '../../../shared/NumberFormattingUtils' const log = logger({ name: 'IssuedIOUs' }) @@ -202,17 +205,10 @@ export const IssuedIOUs = ({
    - - {(() => { - const usdPrice = token.priceInXRP * xrpToUSDRate - const currencyOptions = - usdPrice < 1 ? CURRENCY_OPTIONS : USD_CURRENCY_OPTIONS - return localizeNumber(usdPrice, lang, currencyOptions) - })()} - + {formatUsdPrice(token.priceInXRP * xrpToUSDRate, lang)} {localizeNumber(token.trustlines, lang)} {localizeNumber(token.holders, lang)} - {localizeNumber(token.supply, lang, CURRENCY_OPTIONS)} + {formatTokenBalance(token.supply, lang)} {token.assetClass} {token.transferFee} {token.frozen} diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx index 8aff184b5..d872bf950 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -19,7 +19,7 @@ import { import DomainLink from '../../../shared/components/DomainLink' import { XRP_BASE } from '../../../shared/transactionUtils' import { localizeNumber } from '../../../shared/utils' -import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../../shared/NumberFormattingOptions' +import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../../shared/NumberFormattingUtils' import { useLanguage } from '../../../shared/hooks' import logger from '../../../../rippled/lib/logger' diff --git a/src/containers/Accounts/AccountSummary/Balances.tsx b/src/containers/Accounts/AccountSummary/Balances.tsx index 9b61ff462..dbbbed167 100644 --- a/src/containers/Accounts/AccountSummary/Balances.tsx +++ b/src/containers/Accounts/AccountSummary/Balances.tsx @@ -9,7 +9,7 @@ import ReserveIcon from '../../shared/images/xrp_reserve_balance_icon.svg' import { XRP_CURRENCY_OPTIONS, USD_CURRENCY_OPTIONS, -} from '../../shared/NumberFormattingOptions' +} from '../../shared/NumberFormattingUtils' interface BalancesProps { account: any diff --git a/src/containers/Accounts/AccountSummary/DetailsCard.tsx b/src/containers/Accounts/AccountSummary/DetailsCard.tsx index 6a1c37e52..a1d7a5752 100644 --- a/src/containers/Accounts/AccountSummary/DetailsCard.tsx +++ b/src/containers/Accounts/AccountSummary/DetailsCard.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { localizeNumber } from '../../shared/utils' -import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../shared/NumberFormattingOptions' +import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../shared/NumberFormattingUtils' import { Account } from '../../shared/components/Account' interface DetailsCardProps { diff --git a/src/containers/shared/NumberFormattingOptions.ts b/src/containers/shared/NumberFormattingOptions.ts deleted file mode 100644 index ab38685d2..000000000 --- a/src/containers/shared/NumberFormattingOptions.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Standard display for most XRP amounts (2 decimals) -export const XRP_CURRENCY_OPTIONS = { - style: 'currency', - currency: 'XRP', - minimumFractionDigits: 2, - maximumFractionDigits: 2, -} - -// Higher precision for small (<1 XRP) balances -export const XRP_SMALL_BALANCE_CURRENCY_OPTIONS = { - style: 'currency', - currency: 'XRP', - minimumFractionDigits: 2, - maximumFractionDigits: 4, -} - -export const USD_CURRENCY_OPTIONS = { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, -} - -// Higher precision for small (<1 USD) balances -export const USD_SMALL_BALANCE_CURRENCY_OPTIONS = { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 4, -} - -// Higher precision for small (<0.0001 USD) balances -export const USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS = { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 8, -} - -export const NUMBER_DEFAULT_OPTIONS = { - style: 'decimal', - minimumFractionDigits: 0, - maximumFractionDigits: 2, - useGrouping: true, -} - -export const NUMBER_SMALL_OPTIONS = { - style: 'decimal', - minimumFractionDigits: 0, - maximumFractionDigits: 4, - useGrouping: true, -} diff --git a/src/containers/shared/NumberFormattingUtils.ts b/src/containers/shared/NumberFormattingUtils.ts new file mode 100644 index 000000000..b06b3b6a2 --- /dev/null +++ b/src/containers/shared/NumberFormattingUtils.ts @@ -0,0 +1,163 @@ +import { localizeNumber } from './utils' + +// Standard display for most XRP amounts (2 decimals) +export const XRP_CURRENCY_OPTIONS = { + style: 'currency', + currency: 'XRP', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +} + +// Higher precision for small (<1 XRP) balances +export const XRP_SMALL_BALANCE_CURRENCY_OPTIONS = { + style: 'currency', + currency: 'XRP', + minimumFractionDigits: 2, + maximumFractionDigits: 4, +} + +export const USD_CURRENCY_OPTIONS = { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +} + +// Higher precision for small (<1 USD) balances +export const USD_SMALL_BALANCE_CURRENCY_OPTIONS = { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 4, +} + +// Higher precision for small (<0.0001 USD) balances +export const USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS = { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 10, +} + +export const NUMBER_DEFAULT_OPTIONS = { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + useGrouping: true, +} + +export const NUMBER_SMALL_OPTIONS = { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 4, + useGrouping: true, +} + +/** + * Formats USD prices with tiered precision based on value + * @param price - The USD price to format + * @param lang - Language for localization + * @returns Formatted USD price string or '--' for zero prices + */ +export const formatUsdPrice = (price: number, lang: string): string => { + if (price === 0) { + return '--' + } + + let options + if (price >= 1) { + options = USD_CURRENCY_OPTIONS + } else if (price >= 0.0001) { + options = USD_SMALL_BALANCE_CURRENCY_OPTIONS + } else { + options = USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS + } + + return localizeNumber(price, lang, options) || '--' +} + +/** + * Formats USD balances with tiered precision based on value + * @param balance - The USD balance to format + * @param lang - Language for localization + * @returns Formatted USD balance string or '--' for zero balances + */ +export const formatUsdBalance = (balance: number, lang: string): string => { + if (balance === 0) { + return '--' + } + + let options + if (balance >= 1) { + options = USD_CURRENCY_OPTIONS + } else if (balance >= 0.0001) { + options = USD_SMALL_BALANCE_CURRENCY_OPTIONS + } else { + options = USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS + } + + return localizeNumber(balance, lang, options) || '--' +} + +/** + * Formats token balances with conditional precision based on value + * @param balance - The token balance to format + * @param lang - Language for localization + * @returns Formatted balance string + */ +export const formatTokenBalance = (balance: number, lang: string): string => { + const options = balance > 999 ? NUMBER_DEFAULT_OPTIONS : NUMBER_SMALL_OPTIONS + return localizeNumber(balance, lang, options) || '0' +} + +/** + * Three-step calculation for USD balance to ensure mathematical consistency + * Formats USD price, formats token balance, then calculates USD balance using displayed values + * @param tokenBalance - The token balance + * @param priceInXrp - Price in XRP + * @param xrpToUsdRate - XRP to USD conversion rate + * @param lang - Language for localization + * @returns Object with formatted USD price, token balance, and calculated USD balance + */ +export const calculateFormattedUsdBalance = ( + tokenBalance: number, + priceInXrp: number, + xrpToUsdRate: number, + lang: string, +): { + formattedUsdPrice: string + formattedBalance: string + formattedBalanceUsd: string +} => { + let formattedUsdPrice = '--' + let formattedBalance = '--' + let formattedBalanceUsd = '--' + + if (priceInXrp !== 0) { + // Step 1: Format USD Price + const rawUsdPrice = priceInXrp * xrpToUsdRate + formattedUsdPrice = formatUsdPrice(rawUsdPrice, lang) + const displayedUsdPrice = parseFloat( + (formattedUsdPrice || '0').replace(/[$,]/g, ''), // Removes dollar signs and commas from USD prices like "$4,321.30" → "4321.30" + ) + + // Step 2: Format Balance + formattedBalance = formatTokenBalance(tokenBalance, lang) + const displayedBalance = parseFloat( + (formattedBalance || '0').replace(/[,]/g, ''), // Removes commas from token balances like "1,234.5678" → "1234.5678" + ) + + // Step 3: Calculate USD Balance using displayed values + const calculatedBalanceUSD = displayedUsdPrice * displayedBalance + formattedBalanceUsd = formatUsdBalance(calculatedBalanceUSD, lang) + } else { + // If no price, still format the balance + formattedBalance = formatTokenBalance(tokenBalance, lang) + } + + return { + formattedUsdPrice, + formattedBalance, + formattedBalanceUsd, + } +} From 0c23b5730c70908a8bdaff57c2084774ecb56b68 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 01:11:17 -0400 Subject: [PATCH 13/47] Exclude frozen balance from Issued IOU supply --- .../AccountAsset/assetTables/IssuedIOUs.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index 96c5a76d4..f042f9e21 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -109,13 +109,26 @@ const fetchAccountIssuedIOUs = async ( const tokenId = `${currency}.${accountId}` const token = allTokens[tokenId] + // Use obligation value from gateway balance as supply since it excludes frozen balances + const obligationSupply = balancesResponse?.obligations?.[currency] + ? parseFloat(balancesResponse.obligations[currency]) + : 0 + + const apiSupply = token?.supply ? parseFloat(token.supply) : 0 + if (apiSupply > obligationSupply) { + const diff = apiSupply - obligationSupply + log.info( + `Supply for ${currency}: GatewayBalance=${obligationSupply}, API=${apiSupply}, Likely Frozen=${diff}`, + ) + } + return { tokenCode: currency, tokenIcon: token?.icon, priceInXRP: token?.price ? parseFloat(token.price) : 0, trustlines: token?.number_of_trustlines || 0, holders: token?.number_of_holders || 0, - supply: token?.supply || 0, // This including locked balance (see rcA8X3TVMST1n3CJeAdGk1RdRCHii7N2h) + supply: obligationSupply, assetClass: token?.asset_class || '--', transferFee: `${transferFee}%`, frozen: accountGlobalFreeze ? 'Global' : '--', From 05c3be6e895ccdf3c89d0a8ac7d538329f742cd7 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 08:48:35 -0400 Subject: [PATCH 14/47] Delete old account asset/token components --- .../AccountAssetTab/AccountAssetTab.tsx | 60 -------- .../test/AccountAssetTab.test.tsx | 137 ------------------ .../AccountIssuedTokenTable.tsx | 70 --------- .../Accounts/AccountIssuedTokenTable/index.ts | 1 - .../AccountMPTTable/AccountMPTTable.tsx | 122 ---------------- .../test/AccountMPTRow.test.tsx | 65 --------- .../test/AccountMPTTable.test.tsx | 114 --------------- .../AccountNFTTable/AccountNFTTable.tsx | 84 ----------- .../test/AccountNFTtable.test.tsx | 108 -------------- 9 files changed, 761 deletions(-) delete mode 100644 src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx delete mode 100644 src/containers/Accounts/AccountAssetTab/test/AccountAssetTab.test.tsx delete mode 100644 src/containers/Accounts/AccountIssuedTokenTable/AccountIssuedTokenTable.tsx delete mode 100644 src/containers/Accounts/AccountIssuedTokenTable/index.ts delete mode 100644 src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx delete mode 100644 src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx delete mode 100644 src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx delete mode 100644 src/containers/Accounts/AccountNFTTable/AccountNFTTable.tsx delete mode 100644 src/containers/Accounts/AccountNFTTable/test/AccountNFTtable.test.tsx diff --git a/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx b/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx deleted file mode 100644 index 62dd73af2..000000000 --- a/src/containers/Accounts/AccountAssetTab/AccountAssetTab.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { ChangeEvent } from 'react' -import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router' -import { useRouteParams } from '../../shared/routing' -import { AccountIssuedTokenTable } from '../AccountIssuedTokenTable' -import { AccountNFTTable } from '../AccountNFTTable/AccountNFTTable' -import { AccountMPTTable } from '../AccountMPTTable/AccountMPTTable' -import { ACCOUNT_ROUTE } from '../../App/routes' - -// TODO: Add state types or convert to react query -interface Props { - account: any -} - -const assetTypes = ['issued', 'nft', 'mpt'] - -export const AccountAssetTab = ({ account }: Props) => { - const { id: accountId = '', assetType = assetTypes[0] } = - useRouteParams(ACCOUNT_ROUTE) - - const navigate = useNavigate() - const { t } = useTranslation() - function switchAsset(event: ChangeEvent) { - return navigate(`/accounts/${accountId}/assets/${event.target.value}`) - } - return ( - <> -
    - {assetTypes.map((type) => { - const fieldId = `tokens-${type}` - return ( - - ) - })} -
    - -
    - {assetType === 'issued' && ( - - )} - {assetType === 'nft' && } - {assetType === 'mpt' && } -
    - - ) -} diff --git a/src/containers/Accounts/AccountAssetTab/test/AccountAssetTab.test.tsx b/src/containers/Accounts/AccountAssetTab/test/AccountAssetTab.test.tsx deleted file mode 100644 index 0183e7d6b..000000000 --- a/src/containers/Accounts/AccountAssetTab/test/AccountAssetTab.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { mount } from 'enzyme' -import { QueryClientProvider } from 'react-query' -import { Route } from 'react-router-dom' -import i18n from '../../../../i18n/testConfig' -import { testQueryClient } from '../../../test/QueryClient' -import { flushPromises, QuickHarness } from '../../../test/utils' -import { AccountAssetTab } from '../AccountAssetTab' -import { ACCOUNT_ROUTE } from '../../../App/routes' - -const mockAccountData = { - account: 'rH3UU2acTY5fz4rYwMLMniivDqcPLjg6mk', - info: { - sequence: 6467288, - ownerCount: 1, - reserve: 1.2, - flags: [], - balance: '99999999', - previousTxn: - '3915E32DD06F670644EDF10EB1193C7B001209113B92A580D2C09B0FDE36E3BC', - previousLedger: 6467291, - }, - balances: { - XRP: 99.999999, - }, - tokens: [], - deleted: false, - hasBridge: false, -} - -// Child tables mocked to isolate AccountAssetTab -jest.mock('../../AccountIssuedTokenTable', () => ({ - AccountIssuedTokenTable: ({ account }: any) => ( -
    {account.account}-issued
    - ), -})) -jest.mock('../../AccountNFTTable/AccountNFTTable', () => ({ - AccountNFTTable: ({ accountId }: any) => ( -
    {accountId}-nft
    - ), -})) -jest.mock('../../AccountMPTTable/AccountMPTTable', () => ({ - AccountMPTTable: ({ accountId }: any) => ( -
    {accountId}-mpt
    - ), -})) - -describe('AccountAssetTab routing', () => { - const createWrapper = (initialEntry: string, account) => - mount( - - - } - /> - - , - ) - - it('renders issued tab by default', async () => { - const wrapper = createWrapper( - `/accounts/${mockAccountData.account}/assets/issued`, - mockAccountData, - ) - await flushPromises() - wrapper.update() - - // Always show all 3 tabs - expect(wrapper.find('.radio-group').children().length).toBe(3) - - expect(wrapper.find('.issued-table')).toExist() - expect(wrapper.find('input[value="issued"]').prop('checked')).toBe(true) - - wrapper.unmount() - }) - - it('renders empty fragment when account is deleted', async () => { - const wrapper = createWrapper( - `/accounts/${mockAccountData.account}/assets/issued`, - { - deleted: true, - }, - ) - await flushPromises() - wrapper.update() - - expect(wrapper.html()).toBe('') - wrapper.unmount() - }) - - it('renders nft tab when route param is nft', async () => { - const wrapper = createWrapper( - `/accounts/${mockAccountData.account}/assets/nft`, - mockAccountData, - ) - await flushPromises() - wrapper.update() - - expect(wrapper.find('.nft-table')).toExist() - expect(wrapper.find('input[value="nft"]').prop('checked')).toBe(true) - - wrapper.unmount() - }) - - it('renders mpt tab when route param is mpt', async () => { - const wrapper = createWrapper( - `/accounts/${mockAccountData.account}/assets/mpt`, - mockAccountData, - ) - await flushPromises() - wrapper.update() - - expect(wrapper.find('.mpt-table')).toExist() - expect(wrapper.find('input[value="mpt"]').prop('checked')).toBe(true) - - wrapper.unmount() - }) - - it('navigates when radio button is changed', async () => { - const wrapper = createWrapper( - `/accounts/${mockAccountData.account}/assets/mpt`, - mockAccountData, - ) - await flushPromises() - wrapper.update() - - wrapper.find('input[value="mpt"]').simulate('change', { - target: { value: 'mpt' }, - }) - - await flushPromises() - wrapper.update() - expect(wrapper.find('.mpt-table')).toExist() - - wrapper.unmount() - }) -}) diff --git a/src/containers/Accounts/AccountIssuedTokenTable/AccountIssuedTokenTable.tsx b/src/containers/Accounts/AccountIssuedTokenTable/AccountIssuedTokenTable.tsx deleted file mode 100644 index f7c39d2b8..000000000 --- a/src/containers/Accounts/AccountIssuedTokenTable/AccountIssuedTokenTable.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { useTranslation } from 'react-i18next' -import Currency from '../../shared/components/Currency' -import { Amount } from '../../shared/components/Amount' -import { Loader } from '../../shared/components/Loader' -import { EmptyMessageTableRow } from '../../shared/EmptyMessageTableRow' -import { RouteLink } from '../../shared/routing' -import { TOKEN_ROUTE } from '../../App/routes' - -interface Props { - account: any -} - -export const AccountIssuedTokenTable = (props: Props) => { - const { account } = props - const { t } = useTranslation() - - function renderNoResults() { - return ( - - {t('assets.no_issued_message')} - - ) - } - - function renderRow(token: any) { - const tokenName = `${token.currency}.${token.issuer}` - - return ( - - - - - - - {token.issuer} - - - - - - - ) - } - - return ( -
    - - - - - - - - - - {account.tokens && - (account?.tokens?.length - ? account?.tokens.map(renderRow) - : renderNoResults())} - -
    {t('currency_code')}{t('issuer')}{t('amount')}
    - {!account.tokens && } -
    - ) -} diff --git a/src/containers/Accounts/AccountIssuedTokenTable/index.ts b/src/containers/Accounts/AccountIssuedTokenTable/index.ts deleted file mode 100644 index 2e1697f56..000000000 --- a/src/containers/Accounts/AccountIssuedTokenTable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AccountIssuedTokenTable } from './AccountIssuedTokenTable' diff --git a/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx b/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx deleted file mode 100644 index c3a5f6ebf..000000000 --- a/src/containers/Accounts/AccountMPTTable/AccountMPTTable.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useContext } from 'react' -import { useTranslation } from 'react-i18next' -import { useInfiniteQuery, useQuery } from 'react-query' -import { Loader } from '../../shared/components/Loader' -import SocketContext from '../../shared/SocketContext' -import { useAnalytics } from '../../shared/analytics' -import { EmptyMessageTableRow } from '../../shared/EmptyMessageTableRow' -import { getAccountMPTs, getMPTIssuance } from '../../../rippled/lib/rippled' -import { Account } from '../../shared/components/Account' -import { LoadMoreButton } from '../../shared/LoadMoreButton' -import { MPTokenLink } from '../../shared/components/MPTokenLink' -import { formatMPToken, formatMPTIssuance } from '../../../rippled/lib/utils' -import { MPTIssuanceFormattedInfo } from '../../shared/Interfaces' -import { convertScaledPrice } from '../../shared/utils' - -export interface AccountMPTTableProps { - accountId: string -} - -export const AccountMPTRow = ({ mpt }: any) => { - const rippledSocket = useContext(SocketContext) - const { trackException } = useAnalytics() - const { data: mptIssuanceData } = useQuery( - ['getMPTIssuanceScale', mpt.mptIssuanceID], - async () => { - const info = await getMPTIssuance(rippledSocket, mpt.mptIssuanceID) - return formatMPTIssuance(info.node) - }, - { - onError: (e: any) => { - trackException( - `mptIssuance ${mpt.mptIssuanceID} --- ${JSON.stringify(e)}`, - ) - }, - }, - ) - - if (!mptIssuanceData) return null - - const scale = mptIssuanceData?.assetScale ?? 0 - - return ( - - - - - - - - - {convertScaledPrice( - parseInt(mpt.mptAmount as string, 10).toString(16), - scale, - )} - - - ) -} - -export const AccountMPTTable = ({ accountId }: AccountMPTTableProps) => { - const rippledSocket = useContext(SocketContext) - const { trackException } = useAnalytics() - const { - data: pages, - isFetching: loading, - hasNextPage, - fetchNextPage, - } = useInfiniteQuery( - ['account_objects', accountId], - ({ pageParam = '' }) => - getAccountMPTs(rippledSocket, accountId, pageParam).catch( - (errorResponse) => { - const errorLocation = `account MPTs ${accountId} at ${pageParam}` - trackException( - `${errorLocation} --- ${JSON.stringify(errorResponse)}`, - ) - }, - ), - { - getNextPageParam: (data) => data.marker, - }, - ) - const { t } = useTranslation() - - function renderNoResults() { - return ( - - {t('assets.no_mpts_message')} - - ) - } - - const renderLoadMoreButton = () => - hasNextPage && fetchNextPage()} /> - - const mpts = pages?.pages - .flatMap((page: any) => page.account_objects) - .map((mpt) => formatMPToken(mpt)) - - return ( -
    - - - - - - - - - - {!loading && - (mpts?.length - ? mpts.map((mpt) => ( - - )) - : renderNoResults())} - -
    {t('mpt_issuance_id')} {t('issuer')}{t('amount')}
    - {loading ? : renderLoadMoreButton()} -
    - ) -} diff --git a/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx b/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx deleted file mode 100644 index 1dd71cbfd..000000000 --- a/src/containers/Accounts/AccountMPTTable/test/AccountMPTRow.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { I18nextProvider } from 'react-i18next' -import { BrowserRouter } from 'react-router-dom' -import { mount } from 'enzyme' -import { QueryClientProvider } from 'react-query' -import { AccountMPTRow } from '../AccountMPTTable' -import i18n from '../../../../i18n/testConfig' -import { testQueryClient } from '../../../test/QueryClient' -import { getMPTIssuance } from '../../../../rippled/lib/rippled' -import { flushPromises, V7_FUTURE_ROUTER_FLAGS } from '../../../test/utils' - -import Mock = jest.Mock - -jest.mock('../../../../rippled/lib/rippled', () => ({ - __esModule: true, - getMPTIssuance: jest.fn(), -})) - -const mockedFetMPTIssuance = getMPTIssuance as Mock - -const mptData = { - account: 'rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', - flags: [], - mptIssuanceID: '000017C2CE76E3E3328AE9E0D80CDD68BA72CC8D8D053DB6', - mptIssuer: 'rKFgd9FNzwu7a7iVYa2Me4dmBC3zzUepSC', - mptAmount: '100', -} - -describe('AccountMPTRow', () => { - const createWrapper = (component: JSX.Element) => - mount( - - - - {component} - - - , - ) - - it('handles Account MPT row', async () => { - const issuanceData = { - node: { - AssetScale: 3, - }, - } - - mockedFetMPTIssuance.mockReset() - - mockedFetMPTIssuance.mockImplementation(() => - Promise.resolve({ ...issuanceData }), - ) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - expect(wrapper.find('td').at(0).html()).toBe( - '000017C2CE76E3E3328AE9E0D80CDD68BA72CC8D8D053DB6', - ) - expect(wrapper.find('td').at(1).html()).toBe( - '', - ) - expect(wrapper.find('td').at(2).html()).toBe('0.100') - wrapper.unmount() - }) -}) diff --git a/src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx b/src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx deleted file mode 100644 index 4c2a1a378..000000000 --- a/src/containers/Accounts/AccountMPTTable/test/AccountMPTTable.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { mount } from 'enzyme' -import { QueryClientProvider } from 'react-query' -import { I18nextProvider } from 'react-i18next' -import { BrowserRouter } from 'react-router-dom' -import { getAccountMPTs } from '../../../../rippled/lib/rippled' -import { AccountMPTTable } from '../AccountMPTTable' -import i18n from '../../../../i18n/testConfig' -import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' -import { testQueryClient } from '../../../test/QueryClient' -import { flushPromises, V7_FUTURE_ROUTER_FLAGS } from '../../../test/utils' - -import Mock = jest.Mock - -jest.mock('../../../../rippled/lib/rippled', () => ({ - __esModule: true, - getAccountMPTs: jest.fn(), -})) - -const mockedGetAccountMPTs = getAccountMPTs as Mock - -const data = { - account: 'rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', - account_objects: [ - { - Account: 'rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', - Flags: 0, - LedgerEntryType: 'MPToken', - MPTAmount: '100', - MPTokenIssuanceID: '000017C2CE76E3E3328AE9E0D80CDD68BA72CC8D8D053DB6', - OwnerNode: '0', - PreviousTxnID: - '98646C9E7C6D7B8461DC92712B83EFF6CDA4203CE3FF3FF7E0B86FD57907949F', - PreviousTxnLgrSeq: 969, - index: '3BAA73912496683A414494218D3CCA33D02F80D588F80C1257C691448E00E486', - }, - ], - ledger_current_index: 970, - status: 'success', - validated: false, -} - -describe('AccountMPTTable component', () => { - const TEST_ACCOUNT_ID = 'rTEST_ACCOUNT' - - const createWrapper = () => - mount( - - - - - - - , - ) - - afterEach(() => { - mockedGetAccountMPTs.mockReset() - }) - - it('should render a table of mpts', async () => { - mockedGetAccountMPTs.mockReset() - - mockedGetAccountMPTs.mockImplementation(() => Promise.resolve(data)) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - expect(wrapper.find('.load-more-btn')).not.toExist() - wrapper.unmount() - }) - - it('should handle load more', async () => { - mockedGetAccountMPTs.mockReset() - - mockedGetAccountMPTs.mockImplementation(() => - Promise.resolve({ - ...data, - marker: 'hello', - }), - ) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - - expect(wrapper.find('.load-more-btn')).toExist() - wrapper.find('.load-more-btn').simulate('click') - expect(mockedGetAccountMPTs.mock.calls[1][2]).toEqual('hello') - wrapper.unmount() - }) - - it(`should handle no results`, async () => { - mockedGetAccountMPTs.mockReset() - - mockedGetAccountMPTs.mockImplementation(() => - Promise.resolve({ - account: 'rLEr9C6kwybj1BFE1iYbhJXNFVz8rnLu8C', - account_objects: [], - ledger_hash: - '7CC58622B7071E675AF63F39AAF81BDB4E87FDFEBB0BA8CE7D753F3C702D2886', - ledger_index: 6086, - validated: true, - marker: 'hello', - }), - ) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - - expect(wrapper.find(EmptyMessageTableRow)).toExist() - wrapper.unmount() - }) -}) diff --git a/src/containers/Accounts/AccountNFTTable/AccountNFTTable.tsx b/src/containers/Accounts/AccountNFTTable/AccountNFTTable.tsx deleted file mode 100644 index bfcbacffe..000000000 --- a/src/containers/Accounts/AccountNFTTable/AccountNFTTable.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useContext } from 'react' -import { useTranslation } from 'react-i18next' -import { useInfiniteQuery } from 'react-query' - -import { Loader } from '../../shared/components/Loader' -import SocketContext from '../../shared/SocketContext' -import { useAnalytics } from '../../shared/analytics' -import { EmptyMessageTableRow } from '../../shared/EmptyMessageTableRow' -import { getAccountNFTs } from '../../../rippled/lib/rippled' -import { Account } from '../../shared/components/Account' -import { LoadMoreButton } from '../../shared/LoadMoreButton' -import { NFTokenLink } from '../../shared/components/NFTokenLink' - -export interface AccountNFTTableProps { - accountId: string -} - -export const AccountNFTTable = ({ accountId }: AccountNFTTableProps) => { - const rippledSocket = useContext(SocketContext) - const { trackException } = useAnalytics() - const { - data: pages, - isFetching: loading, - hasNextPage, - fetchNextPage, - } = useInfiniteQuery( - ['account_nfts', accountId], - ({ pageParam = '' }) => - getAccountNFTs(rippledSocket, accountId, pageParam).catch( - (errorResponse) => { - const errorLocation = `account NFTs ${accountId} at ${pageParam}` - trackException( - `${errorLocation} --- ${JSON.stringify(errorResponse)}`, - ) - }, - ), - { - getNextPageParam: (data) => data.marker, - }, - ) - const { t } = useTranslation() - - function renderNoResults() { - return ( - - {t('assets.no_nfts_message')} - - ) - } - - const renderRow = (nft: any) => ( - - - - - - - - {nft.NFTokenTaxon} - - ) - - const renderLoadMoreButton = () => - hasNextPage && fetchNextPage()} /> - - const nfts = pages?.pages.flatMap((page: any) => page.account_nfts) - return ( -
    - - - - - - - - - - {!loading && (nfts?.length ? nfts.map(renderRow) : renderNoResults())} - -
    {t('token_id')}{t('issuer')}{t('taxon')}
    - {loading ? : renderLoadMoreButton()} -
    - ) -} diff --git a/src/containers/Accounts/AccountNFTTable/test/AccountNFTtable.test.tsx b/src/containers/Accounts/AccountNFTTable/test/AccountNFTtable.test.tsx deleted file mode 100644 index c64a8f417..000000000 --- a/src/containers/Accounts/AccountNFTTable/test/AccountNFTtable.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { mount } from 'enzyme' -import { QueryClientProvider } from 'react-query' -import { I18nextProvider } from 'react-i18next' -import { BrowserRouter } from 'react-router-dom' -import { getAccountNFTs } from '../../../../rippled/lib/rippled' -import { AccountNFTTable } from '../AccountNFTTable' -import i18n from '../../../../i18n/testConfig' -import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' -import { testQueryClient } from '../../../test/QueryClient' -import { flushPromises, V7_FUTURE_ROUTER_FLAGS } from '../../../test/utils' -import Mock = jest.Mock - -jest.mock('../../../../rippled/lib/rippled', () => ({ - __esModule: true, - getAccountNFTs: jest.fn(), -})) - -const mockedGetAccountNFTs = getAccountNFTs as Mock - -const data = { - account: 'rnuweigWrt8Jp1gBmKJT6VLxrVMSJSuu6G', - account_nfts: [ - { - Flags: 0, - Issuer: 'rGymBL8Huct6euA8jtEcLagYXpRgQKh6EC', - NFTokenID: - '00000000AF460F0D7BC0F05B626E19498DF690E00C97080B0000099B00000000', - NFTokenTaxon: 0, - nft_serial: 0, - }, - ], - ledger_current_index: 21174400, - validated: false, -} - -describe('AccountNFTTable component', () => { - const TEST_ACCOUNT_ID = 'rTEST_ACCOUNT' - - const createWrapper = () => - mount( - - - - - - - , - ) - - afterEach(() => { - mockedGetAccountNFTs.mockReset() - }) - - it('should render a table of nfts', async () => { - mockedGetAccountNFTs.mockImplementation(() => Promise.resolve(data)) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - - expect(wrapper.find('tbody tr td').length).toEqual(3) - expect(wrapper.find('.load-more-btn')).not.toExist() - wrapper.unmount() - }) - - it('should handle load more', async () => { - mockedGetAccountNFTs.mockImplementation(() => - Promise.resolve({ - ...data, - marker: 'hello', - }), - ) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - - const columns = wrapper.find('tbody tr td') - expect(columns.at(0).text()).toEqual( - `00000000AF460F0D7BC0F05B626E19498DF690E00C97080B0000099B00000000`, - ) - expect(columns.at(1).text()).toEqual(`rGymBL8Huct6euA8jtEcLagYXpRgQKh6EC`) - expect(columns.at(2).text()).toEqual(`0`) - expect(wrapper.find('.load-more-btn')).toExist() - wrapper.find('.load-more-btn').simulate('click') - expect(mockedGetAccountNFTs.mock.calls[1][2]).toEqual('hello') - wrapper.unmount() - }) - - it(`should handle no results`, async () => { - mockedGetAccountNFTs.mockImplementation(() => - Promise.resolve({ - account: 'rnuweigWrt8Jp1gBmKJT6VLxrVMSJSuu6G', - account_nfts: [], - marker: 'hello', - ledger_current_index: 21174400, - validated: false, - }), - ) - - const wrapper = createWrapper() - await flushPromises() - wrapper.update() - - expect(wrapper.find(EmptyMessageTableRow)).toExist() - wrapper.unmount() - }) -}) From df02a38e123139f0cdf5104562e28c09922c1b81 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 09:20:48 -0400 Subject: [PATCH 15/47] Update non en-US translation files --- public/locales/ca-CA/translations.json | 71 +++++++++++++++++++++++++- public/locales/es-ES/translations.json | 71 +++++++++++++++++++++++++- public/locales/fr-FR/translations.json | 71 +++++++++++++++++++++++++- public/locales/ja-JP/translations.json | 71 +++++++++++++++++++++++++- public/locales/ko-KR/translations.json | 71 +++++++++++++++++++++++++- public/locales/my-MM/translations.json | 71 +++++++++++++++++++++++++- 6 files changed, 420 insertions(+), 6 deletions(-) diff --git a/public/locales/ca-CA/translations.json b/public/locales/ca-CA/translations.json index ecde7c3e8..680ad930e 100644 --- a/public/locales/ca-CA/translations.json +++ b/public/locales/ca-CA/translations.json @@ -610,5 +610,74 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_description_asf_global_freeze": null, + "account_flag_description_asf_no_freeze": null, + "account_flag_description_asf_default_ripple": null, + "account_flag_description_asf_require_dest": null, + "account_flag_description_asf_require_auth": null, + "account_flag_description_asf_disable_master": null, + "account_flag_description_asf_deposit_auth": null, + "account_flag_description_asf_authorized_n_f_token_minter": null, + "account_flag_description_asf_disable_incoming_n_f_token_offers": null, + "account_flag_description_asf_disable_incoming_checks": null, + "account_flag_description_asf_disable_incoming_payment_channels": null, + "account_flag_description_asf_disable_incoming_trustlines": null, + "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/public/locales/es-ES/translations.json b/public/locales/es-ES/translations.json index 91fa11979..62df0581e 100644 --- a/public/locales/es-ES/translations.json +++ b/public/locales/es-ES/translations.json @@ -611,5 +611,74 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_description_asf_global_freeze": null, + "account_flag_description_asf_no_freeze": null, + "account_flag_description_asf_default_ripple": null, + "account_flag_description_asf_require_dest": null, + "account_flag_description_asf_require_auth": null, + "account_flag_description_asf_disable_master": null, + "account_flag_description_asf_deposit_auth": null, + "account_flag_description_asf_authorized_n_f_token_minter": null, + "account_flag_description_asf_disable_incoming_n_f_token_offers": null, + "account_flag_description_asf_disable_incoming_checks": null, + "account_flag_description_asf_disable_incoming_payment_channels": null, + "account_flag_description_asf_disable_incoming_trustlines": null, + "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/public/locales/fr-FR/translations.json b/public/locales/fr-FR/translations.json index 20c8f9de4..9b4a5a173 100644 --- a/public/locales/fr-FR/translations.json +++ b/public/locales/fr-FR/translations.json @@ -614,5 +614,74 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_description_asf_global_freeze": null, + "account_flag_description_asf_no_freeze": null, + "account_flag_description_asf_default_ripple": null, + "account_flag_description_asf_require_dest": null, + "account_flag_description_asf_require_auth": null, + "account_flag_description_asf_disable_master": null, + "account_flag_description_asf_deposit_auth": null, + "account_flag_description_asf_authorized_n_f_token_minter": null, + "account_flag_description_asf_disable_incoming_n_f_token_offers": null, + "account_flag_description_asf_disable_incoming_checks": null, + "account_flag_description_asf_disable_incoming_payment_channels": null, + "account_flag_description_asf_disable_incoming_trustlines": null, + "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index 2dbd8dbf9..a9c2b7575 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -613,5 +613,74 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_description_asf_global_freeze": null, + "account_flag_description_asf_no_freeze": null, + "account_flag_description_asf_default_ripple": null, + "account_flag_description_asf_require_dest": null, + "account_flag_description_asf_require_auth": null, + "account_flag_description_asf_disable_master": null, + "account_flag_description_asf_deposit_auth": null, + "account_flag_description_asf_authorized_n_f_token_minter": null, + "account_flag_description_asf_disable_incoming_n_f_token_offers": null, + "account_flag_description_asf_disable_incoming_checks": null, + "account_flag_description_asf_disable_incoming_payment_channels": null, + "account_flag_description_asf_disable_incoming_trustlines": null, + "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/public/locales/ko-KR/translations.json b/public/locales/ko-KR/translations.json index 172a91f55..ee1768f21 100644 --- a/public/locales/ko-KR/translations.json +++ b/public/locales/ko-KR/translations.json @@ -611,5 +611,74 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_description_asf_global_freeze": null, + "account_flag_description_asf_no_freeze": null, + "account_flag_description_asf_default_ripple": null, + "account_flag_description_asf_require_dest": null, + "account_flag_description_asf_require_auth": null, + "account_flag_description_asf_disable_master": null, + "account_flag_description_asf_deposit_auth": null, + "account_flag_description_asf_authorized_n_f_token_minter": null, + "account_flag_description_asf_disable_incoming_n_f_token_offers": null, + "account_flag_description_asf_disable_incoming_checks": null, + "account_flag_description_asf_disable_incoming_payment_channels": null, + "account_flag_description_asf_disable_incoming_trustlines": null, + "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } diff --git a/public/locales/my-MM/translations.json b/public/locales/my-MM/translations.json index ab6ae68bf..d1fe884b6 100644 --- a/public/locales/my-MM/translations.json +++ b/public/locales/my-MM/translations.json @@ -611,5 +611,74 @@ "vault_create_table_detail": null, "withdraws": null, "deletes": null, - "vault_delete_table_detail": null + "vault_delete_table_detail": null, + "account_flag_description_asf_global_freeze": null, + "account_flag_description_asf_no_freeze": null, + "account_flag_description_asf_default_ripple": null, + "account_flag_description_asf_require_dest": null, + "account_flag_description_asf_require_auth": null, + "account_flag_description_asf_disable_master": null, + "account_flag_description_asf_deposit_auth": null, + "account_flag_description_asf_authorized_n_f_token_minter": null, + "account_flag_description_asf_disable_incoming_n_f_token_offers": null, + "account_flag_description_asf_disable_incoming_checks": null, + "account_flag_description_asf_disable_incoming_payment_channels": null, + "account_flag_description_asf_disable_incoming_trustlines": null, + "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_description_asf_account_txn_id": null, + "account_page_address": null, + "account_page_address_tag": null, + "account_page_classic_address": null, + "account_page_deleted_account_label": null, + "account_page_deleted_account_warning": null, + "account_page_extended_address": null, + "account_page_domain": null, + "account_page_reserve_balance": null, + "account_page_xrp_balance": null, + "account_page_xrp_balance_in_usd": null, + "account_page_account_properties": null, + "account_page_flags": null, + "account_page_flag_status_enabled": null, + "account_page_flag_status_disabled": null, + "account_page_signers": null, + "account_page_signer_weight": null, + "account_page_details": null, + "account_page_current_sequence": null, + "account_page_ticket_count": null, + "account_page_email_hash": null, + "account_page_payment_channels": null, + "account_page_payment_channels_text": null, + "account_page_nft_minter": null, + "account_page_asset_held_title": null, + "account_page_asset_issued_title": null, + "account_page_asset_tab_iou": null, + "account_page_asset_tab_lptoken": null, + "account_page_asset_tab_mpt": null, + "account_page_asset_tab_nft": null, + "account_page_asset_table_column_amm_instance": null, + "account_page_asset_table_column_amm_pair": null, + "account_page_asset_table_column_asset_class": null, + "account_page_asset_table_column_balance": null, + "account_page_asset_table_column_balance_usd": null, + "account_page_asset_table_column_currency_code": null, + "account_page_asset_table_column_frozen": null, + "account_page_asset_table_column_highest_bid": null, + "account_page_asset_table_column_holders": null, + "account_page_asset_table_column_issuer": null, + "account_page_asset_table_column_locked": null, + "account_page_asset_table_column_lowest_ask": null, + "account_page_asset_table_column_price_usd": null, + "account_page_asset_table_column_share": null, + "account_page_asset_table_column_supply": null, + "account_page_asset_table_column_ticker": null, + "account_page_asset_table_column_token_id": null, + "account_page_asset_table_column_transfer_fee": null, + "account_page_asset_table_column_trustlines": null, + "account_page_asset_table_column_url": null, + "account_page_asset_table_mpt_locked_global": null, + "account_page_asset_table_mpt_locked_individual": null, + "account_page_asset_table_no_iou": null, + "account_page_asset_table_no_lptoken": null, + "account_page_asset_table_no_mpt": null, + "account_page_asset_table_no_nft": null } From b82de6edf0888695f4beb0d2418f09c939749e1e Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 09:23:27 -0400 Subject: [PATCH 16/47] Remove unused translations that were used in old account asset components --- public/locales/ca-CA/translations.json | 4 ---- public/locales/en-US/translations.json | 4 ---- public/locales/es-ES/translations.json | 4 ---- public/locales/fr-FR/translations.json | 4 ---- public/locales/ja-JP/translations.json | 4 ---- public/locales/ko-KR/translations.json | 4 ---- public/locales/my-MM/translations.json | 4 ---- 7 files changed, 28 deletions(-) diff --git a/public/locales/ca-CA/translations.json b/public/locales/ca-CA/translations.json index 680ad930e..940a959d3 100644 --- a/public/locales/ca-CA/translations.json +++ b/public/locales/ca-CA/translations.json @@ -1,9 +1,6 @@ { "action": "acció", "assets": "Actius", - "assets.nft_tab_title": "NFTs", - "assets.issued_tab_title": "Tokens encunyats", - "assets.no_issued_message": "No s'han trobat Tokens", "assets.no_nfts_message": "No s'han trobat NFTs", "network": "Xarxa", "amendments": "Amendments", @@ -540,7 +537,6 @@ "holders": null, "trustlines": null, "website": null, - "assets.mpt_tab_title": null, "assets.no_mpts_message": null, "transaction_type_name_MPTokenIssuanceCreate": null, "transaction_type_name_MPTokenIssuanceDestroy": null, diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 173c60647..591aaf773 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -1,10 +1,6 @@ { "action": "action", "assets": "Assets", - "assets.nft_tab_title": "Non-Fungible Tokens", - "assets.mpt_tab_title": "Multi-Purpose Tokens", - "assets.issued_tab_title": "Issued Tokens", - "assets.no_issued_message": "No tokens found.", "assets.no_nfts_message": "No NFTs found.", "assets.no_mpts_message": "No MPTs found.", "network": "Network", diff --git a/public/locales/es-ES/translations.json b/public/locales/es-ES/translations.json index 62df0581e..fe582e1b1 100644 --- a/public/locales/es-ES/translations.json +++ b/public/locales/es-ES/translations.json @@ -1,9 +1,6 @@ { "action": "acción", "assets": "Activos", - "assets.nft_tab_title": "Tokens No-Fungibles", - "assets.issued_tab_title": "Tokens Emitidos", - "assets.no_issued_message": "No se han encontrado tokens.", "assets.no_nfts_message": "NFTs no encontrados.", "network": "Red", "amendments": null, @@ -551,7 +548,6 @@ "can_transfer": "Puede Transferir", "can_clawback": "Puede Recuperar", "search_results_banner": "¡La búsqueda de tokens por nombre y cuenta ya está disponible! Intenta buscar USD", - "assets.mpt_tab_title": null, "assets.no_mpts_message": null, "transaction_type_name_MPTokenIssuanceCreate": null, "transaction_type_name_MPTokenIssuanceDestroy": null, diff --git a/public/locales/fr-FR/translations.json b/public/locales/fr-FR/translations.json index 9b4a5a173..9d6ad4231 100644 --- a/public/locales/fr-FR/translations.json +++ b/public/locales/fr-FR/translations.json @@ -1,9 +1,6 @@ { "action": "action", "assets": "Actifs", - "assets.nft_tab_title": "Jetons non fongibles", - "assets.issued_tab_title": "Tokens émis", - "assets.no_issued_message": "Aucun token trouvé", "assets.no_nfts_message": "Aucun NFT trouvé.", "network": "Réseau", "amendments": null, @@ -537,7 +534,6 @@ "holders": null, "trustlines": null, "website": null, - "assets.mpt_tab_title": null, "assets.no_mpts_message": null, "transaction_type_name_MPTokenIssuanceCreate": null, "transaction_type_name_MPTokenIssuanceDestroy": null, diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index a9c2b7575..92044e03a 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -1,9 +1,6 @@ { "action": "アクション", "assets": "資産", - "assets.nft_tab_title": "非代替性トークン", - "assets.issued_tab_title": "発行済み通貨", - "assets.no_issued_message": "トークンが見つかりません。", "assets.no_nfts_message": "NFTが見つかりません。", "network": "ネットワーク", "amendments": "Amendment", @@ -536,7 +533,6 @@ "holders": "保有者: {{holders}}", "trustlines": "トラストライン: {{trustlines}}", "website": "Webサイト", - "assets.mpt_tab_title": null, "assets.no_mpts_message": null, "transaction_type_name_MPTokenIssuanceCreate": null, "transaction_type_name_MPTokenIssuanceDestroy": null, diff --git a/public/locales/ko-KR/translations.json b/public/locales/ko-KR/translations.json index ee1768f21..c0efb3a28 100644 --- a/public/locales/ko-KR/translations.json +++ b/public/locales/ko-KR/translations.json @@ -1,9 +1,6 @@ { "action": "action", "assets": "자산", - "assets.nft_tab_title": "NFT", - "assets.issued_tab_title": "발행된 토큰", - "assets.no_issued_message": "토큰을 찾을 수 없습니다", "no_tokens_message": "토큰을 찾을 수 없습니다", "assets.no_nfts_message": "NFT를 찾을 수 없습니다.", "network": "네트워크", @@ -534,7 +531,6 @@ "holders": null, "trustlines": null, "website": null, - "assets.mpt_tab_title": null, "assets.no_mpts_message": null, "transaction_type_name_MPTokenIssuanceCreate": null, "transaction_type_name_MPTokenIssuanceDestroy": null, diff --git a/public/locales/my-MM/translations.json b/public/locales/my-MM/translations.json index d1fe884b6..77c331ce6 100644 --- a/public/locales/my-MM/translations.json +++ b/public/locales/my-MM/translations.json @@ -1,10 +1,6 @@ { "action": "လုပ်ဆောင်ချက်", "assets": "ပိုင်ဆိုင်မှုများ", - "assets.nft_tab_title": "မတူညီသော Tokens များ", - "assets.mpt_tab_title": "ရည်ရွယ်ချက်များစွာသုံး Tokens များ", - "assets.issued_tab_title": "ထုတ်ပြန်ထားသော Tokens များ", - "assets.no_issued_message": "Token များ မတွေ့ရှိပါ။", "assets.no_nfts_message": "NFT များ မတွေ့ရှိပါ။", "assets.no_mpts_message": "MPT များ မတွေ့ရှိပါ။", "network": "ကွန်ယက်", From d5573a6e55e8cec3921703881095081ff8782d8b Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 16:27:39 -0400 Subject: [PATCH 17/47] Make the two Asset sections collapsable for better UX on mobile view --- .../Accounts/AccountAsset/index.tsx | 333 ++++++++++-------- .../Accounts/AccountAsset/styles.scss | 47 ++- .../Accounts/AccountSummary/styles.scss | 9 +- 3 files changed, 238 insertions(+), 151 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/index.tsx b/src/containers/Accounts/AccountAsset/index.tsx index 0722c6ec0..e245a0d75 100644 --- a/src/containers/Accounts/AccountAsset/index.tsx +++ b/src/containers/Accounts/AccountAsset/index.tsx @@ -3,6 +3,7 @@ import './styles.scss' import { useTranslation } from 'react-i18next' import { localizeNumber } from '../../shared/utils' import { useLanguage } from '../../shared/hooks' +import ArrowIcon from '../../shared/images/down_arrow.svg' import { HeldIOUs } from './assetTables/HeldIOUs' import { HeldMPTs } from './assetTables/HeldMPTs' import { HeldLPTokens } from './assetTables/HeldLPTokens' @@ -142,164 +143,204 @@ export default function AccountAsset({ const [heldTab, setHeldTab] = useState('iou') const [issuedTab, setIssuedTab] = useState('iou') + // Collapse state - default to expanded (true means open) + const [heldSectionOpen, setHeldSectionOpen] = useState(true) + const [issuedSectionOpen, setIssuedSectionOpen] = useState(true) + return (
    {/* Assets Held */} -

    - {t('account_page_asset_held_title')} -

    -
    - setHeldTab('iou')} - loading={loading.heldIou} - /> - setHeldTab('mpt')} - loading={loading.heldMpt} - /> - setHeldTab('lptoken')} - loading={loading.heldLptoken} - /> - setHeldTab('nft')} - loading={loading.heldNft} - /> -
    - - {/* Render all components to fetch data, but only show active tab */} -
    - -
    -
    - -
    -
    - +
    +

    + {t('account_page_asset_held_title')} +

    +
    - -
    +
    + setHeldTab('iou')} + loading={loading.heldIou} + /> + setHeldTab('mpt')} + loading={loading.heldMpt} + /> + setHeldTab('lptoken')} + loading={loading.heldLptoken} + /> + setHeldTab('nft')} + loading={loading.heldNft} + /> +
    - {/* Assets Issued */} -

    - {t('account_page_asset_issued_title')} -

    -
    - setIssuedTab('iou')} - loading={loading.issuedIou} - /> - setIssuedTab('mpt')} - loading={loading.issuedMpt} - /> - setIssuedTab('nft')} - loading={loading.issuedNft} - /> + {/* Render all components to fetch data, but only show active tab */} +
    + +
    +
    + +
    +
    + +
    +
    + +
    - {/* Render all components to fetch data, but only show active tab */} -
    - -
    -
    - + {/* Assets Issued */} +
    +

    + {t('account_page_asset_issued_title')} +

    +
    - +
    + setIssuedTab('iou')} + loading={loading.issuedIou} + /> + setIssuedTab('mpt')} + loading={loading.issuedMpt} + /> + setIssuedTab('nft')} + loading={loading.issuedNft} + /> +
    + + {/* Render all components to fetch data, but only show active tab */} +
    + +
    +
    + +
    +
    + +
    ) diff --git a/src/containers/Accounts/AccountAsset/styles.scss b/src/containers/Accounts/AccountAsset/styles.scss index 5e85a1151..a3444818c 100644 --- a/src/containers/Accounts/AccountAsset/styles.scss +++ b/src/containers/Accounts/AccountAsset/styles.scss @@ -7,12 +7,45 @@ .account-asset-title { @include bold; - margin: 0 0 20px; font-size: 20px; +} + +.asset-section-header { + display: flex; + align-items: center; + justify-content: flex-start; + margin: 24px 0 20px; + gap: 2px; + + &:first-child { + margin-top: 0; + } - /* Only the second account-asset-title gets extra bottom margin */ - &:nth-of-type(2) { - margin-top: 24px; + .account-asset-title { + margin: 0; + } + + .asset-section-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + } + + .asset-section-arrow { + display: inline-block; + width: 18px; + height: 18px; + transform-origin: center; + transition: transform 180ms ease; + } + + .asset-section-arrow.open { + transform: rotate(180deg); } } @@ -214,6 +247,12 @@ } } +@media (max-width: $tablet-portrait-upper-boundary) { + .account-asset-title { + font-size: 18px; + } +} + /* Clock icon styles */ .future-feature { display: inline-flex; diff --git a/src/containers/Accounts/AccountSummary/styles.scss b/src/containers/Accounts/AccountSummary/styles.scss index 61a713219..c5566586d 100644 --- a/src/containers/Accounts/AccountSummary/styles.scss +++ b/src/containers/Accounts/AccountSummary/styles.scss @@ -465,7 +465,6 @@ .properties { .properties-header { gap: 8px; - h3 { font-size: 18px; } .properties-toggle { padding: 4px; } .properties-arrow { @@ -498,4 +497,12 @@ } } } + + @media (max-width: $tablet-portrait-upper-boundary) { + .properties { + .properties-header { + h3 { font-size: 18px; } + } + } + } } From 0445c4880ce29a9f0282c5bf0048468c5e94735a Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 16:51:17 -0400 Subject: [PATCH 18/47] Use the USD price of IOU token from LOS Token API --- .../AccountAsset/assetTables/HeldIOUs.tsx | 25 +++++++------------ .../AccountAsset/assetTables/IssuedIOUs.tsx | 16 ++++-------- .../Accounts/AccountAsset/index.tsx | 7 +----- .../shared/NumberFormattingUtils.ts | 11 +++----- 4 files changed, 19 insertions(+), 40 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index ca093649b..a56615b07 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -29,11 +29,11 @@ import { calculateFormattedUsdBalance } from '../../../shared/NumberFormattingUt const log = logger({ name: 'HeldIOUs' }) +// The LOS Token API has a batch size limit of 100; larger requests will be rejected. const LOS_TOKEN_API_BATCH_SIZE = 100 interface HeldIOUsProps { accountId: string - xrpToUSDRate: number onChange?: (data: { count: number; isLoading: boolean }) => void } @@ -43,7 +43,7 @@ interface IOU { issuer: string issuerName: string balance: number - priceInXRP: number + priceInUSD: number assetClass: string transferFee?: string frozen?: string @@ -160,7 +160,7 @@ const fetchAccountHeldIOUs = async ( issuer: line.account, issuerName: token?.issuer_name, balance: parseFloat(line.balance), - priceInXRP: token?.price ? parseFloat(token.price) : 0, + priceInUSD: token?.price_usd ? parseFloat(token.price_usd) : 0, assetClass: token?.asset_class || '--', transferFee: '--', frozen: line.freeze || line.freeze_peer ? 'Trustline' : '--', @@ -170,11 +170,7 @@ const fetchAccountHeldIOUs = async ( return iouData } -export const HeldIOUs = ({ - accountId, - xrpToUSDRate, - onChange, -}: HeldIOUsProps) => { +export const HeldIOUs = ({ accountId, onChange }: HeldIOUsProps) => { const lang = useLanguage() const { t } = useTranslation() const rippledSocket = useContext(SocketContext) @@ -189,12 +185,10 @@ export const HeldIOUs = ({ // Get data and sort by USD balance in one step const sortedIOUs = useMemo(() => { const data = heldIOUsQuery.data || [] - return [...data].sort((a, b) => { - const aBalanceUSD = a.priceInXRP * a.balance * xrpToUSDRate - const bBalanceUSD = b.priceInXRP * b.balance * xrpToUSDRate - return bBalanceUSD - aBalanceUSD - }) - }, [heldIOUsQuery.data, xrpToUSDRate]) + return [...data].sort( + (a, b) => b.priceInUSD * b.balance - a.priceInUSD * a.balance, + ) + }, [heldIOUsQuery.data]) // Apply progressive updates to the base data const ious = sortedIOUs.map((token) => { @@ -287,8 +281,7 @@ export const HeldIOUs = ({ formattedBalanceUsd, } = calculateFormattedUsdBalance( token.balance, - token.priceInXRP, - xrpToUSDRate, + token.priceInUSD, lang, ) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index f042f9e21..e1b76c57c 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -26,14 +26,13 @@ const LOS_TOKEN_API_BATCH_SIZE = 100 interface IssuedIOUsProps { accountId: string account: any - xrpToUSDRate: number onChange?: (data: { count: number; isLoading: boolean }) => void } interface IOU { tokenCode: string tokenIcon?: string - priceInXRP: number + priceInUSD: number trustlines: number holders: number supply: number @@ -125,7 +124,7 @@ const fetchAccountIssuedIOUs = async ( return { tokenCode: currency, tokenIcon: token?.icon, - priceInXRP: token?.price ? parseFloat(token.price) : 0, + priceInUSD: token?.price_usd ? parseFloat(token.price_usd) : 0, trustlines: token?.number_of_trustlines || 0, holders: token?.number_of_holders || 0, supply: obligationSupply, @@ -141,7 +140,6 @@ const fetchAccountIssuedIOUs = async ( export const IssuedIOUs = ({ accountId, account, - xrpToUSDRate, onChange, }: IssuedIOUsProps) => { const lang = useLanguage() @@ -155,12 +153,8 @@ export const IssuedIOUs = ({ // Sort by USD price const sortedIOUs = useMemo(() => { const data = issuedIOUsQuery.data || [] - return [...data].sort((a, b) => { - const aBalanceUSD = a.priceInXRP * xrpToUSDRate - const bBalanceUSD = b.priceInXRP * xrpToUSDRate - return bBalanceUSD - aBalanceUSD - }) - }, [issuedIOUsQuery.data, xrpToUSDRate]) + return [...data].sort((a, b) => b.priceInUSD - a.priceInUSD) + }, [issuedIOUsQuery.data]) // Communicate count and loading state back to parent useEffect(() => { @@ -218,7 +212,7 @@ export const IssuedIOUs = ({
    - {formatUsdPrice(token.priceInXRP * xrpToUSDRate, lang)} + {formatUsdPrice(token.priceInUSD, lang)} {localizeNumber(token.trustlines, lang)} {localizeNumber(token.holders, lang)} {formatTokenBalance(token.supply, lang)} diff --git a/src/containers/Accounts/AccountAsset/index.tsx b/src/containers/Accounts/AccountAsset/index.tsx index e245a0d75..e883a1487 100644 --- a/src/containers/Accounts/AccountAsset/index.tsx +++ b/src/containers/Accounts/AccountAsset/index.tsx @@ -226,11 +226,7 @@ export default function AccountAsset({ className="account-asset-table-wrapper account-asset-table-wrapper-fixed" style={{ display: heldTab === 'iou' ? 'block' : 'none' }} > - +
    diff --git a/src/containers/shared/NumberFormattingUtils.ts b/src/containers/shared/NumberFormattingUtils.ts index b06b3b6a2..df77ef793 100644 --- a/src/containers/shared/NumberFormattingUtils.ts +++ b/src/containers/shared/NumberFormattingUtils.ts @@ -114,15 +114,13 @@ export const formatTokenBalance = (balance: number, lang: string): string => { * Three-step calculation for USD balance to ensure mathematical consistency * Formats USD price, formats token balance, then calculates USD balance using displayed values * @param tokenBalance - The token balance - * @param priceInXrp - Price in XRP - * @param xrpToUsdRate - XRP to USD conversion rate + * @param priceInUSD - Price in USD * @param lang - Language for localization * @returns Object with formatted USD price, token balance, and calculated USD balance */ export const calculateFormattedUsdBalance = ( tokenBalance: number, - priceInXrp: number, - xrpToUsdRate: number, + priceInUSD: number, lang: string, ): { formattedUsdPrice: string @@ -133,10 +131,9 @@ export const calculateFormattedUsdBalance = ( let formattedBalance = '--' let formattedBalanceUsd = '--' - if (priceInXrp !== 0) { + if (priceInUSD !== 0) { // Step 1: Format USD Price - const rawUsdPrice = priceInXrp * xrpToUsdRate - formattedUsdPrice = formatUsdPrice(rawUsdPrice, lang) + formattedUsdPrice = formatUsdPrice(priceInUSD, lang) const displayedUsdPrice = parseFloat( (formattedUsdPrice || '0').replace(/[$,]/g, ''), // Removes dollar signs and commas from USD prices like "$4,321.30" → "4321.30" ) From 3224aa411776ffb813771112e621b9adecc252e5 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 17:03:26 -0400 Subject: [PATCH 19/47] Increase limit of getNFTsIssuedByAccount from 25 to 50 --- src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx index 6d6056e74..3a6aa8492 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx @@ -24,7 +24,7 @@ const fetchAccountIssuedNFTs = async ( rippledSocket, accountId, marker, - 25, + 50, ) if (response.nfts) { allNFTs.push(...response.nfts) From c30fe65f98bdd78bc0e00e9f10ca77f9044ebb78 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 17:30:58 -0400 Subject: [PATCH 20/47] Read LOS API from env variable --- src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx | 2 +- src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index a56615b07..e51c8f26e 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -120,7 +120,7 @@ const fetchAccountHeldIOUs = async ( try { // eslint-disable-next-line no-await-in-loop const apiResponse = await fetch( - `https://los.dev.ripplex.io/tokens/batch-get`, + `${process.env.VITE_LOS_URL}/tokens/batch-get`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index e1b76c57c..1e32c9973 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -71,7 +71,7 @@ const fetchAccountIssuedIOUs = async ( try { // eslint-disable-next-line no-await-in-loop const apiResponse = await fetch( - `https://los.dev.ripplex.io/tokens/batch-get`, + `${process.env.VITE_LOS_URL}/tokens/batch-get`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, From e6850adeaccf3c546e8027c1dc6a5024921f26a3 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 21:19:54 -0400 Subject: [PATCH 21/47] Move shortenXyz methods from rippled/lib/utils.js to shared/utils.js --- .../AccountAsset/assetTables/HeldIOUs.tsx | 2 +- .../AccountAsset/assetTables/HeldLPTokens.tsx | 2 +- .../AccountAsset/assetTables/HeldMPTs.tsx | 4 +--- .../AccountAsset/assetTables/IssuedMPTs.tsx | 3 +-- .../AccountAsset/assetTables/NFTTable.tsx | 8 ++++---- .../Accounts/AccountSummary/SignersCard.tsx | 2 +- src/containers/shared/utils.js | 16 +++++++++++++++ src/rippled/lib/utils.js | 20 ------------------- 8 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index e51c8f26e..fd956a44e 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -10,12 +10,12 @@ import { ACCOUNT_FLAGS, buildFlags, formatTransferFee, - shortenAccount, } from '../../../../rippled/lib/utils' import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' import { RouteLink } from '../../../shared/routing' import { TOKEN_ROUTE } from '../../../App/routes' import SocketContext from '../../../shared/SocketContext' +import { shortenAccount } from '../../../shared/utils' import { getBalances, getAccountLines, diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx index df65b286a..fc333da75 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -6,13 +6,13 @@ import SocketContext from '../../../shared/SocketContext' import Currency, { LP_TOKEN_IDENTIFIER, } from '../../../shared/components/Currency' -import { shortenAccount } from '../../../../rippled/lib/utils' import { Account } from '../../../shared/components/Account' import { formatTokenBalance, formatUsdBalance, } from '../../../shared/NumberFormattingUtils' import { useLanguage } from '../../../shared/hooks' +import { shortenAccount } from '../../../shared/utils' import { getBalances, diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx index c7c6d92b2..a1a403f86 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx @@ -10,13 +10,11 @@ import { formatMPTIssuance, formatMPToken, formatTransferFee, - shortenAccount, - shortenMPTID, } from '../../../../rippled/lib/utils' import { getAccountMPTs, getMPTIssuance } from '../../../../rippled/lib/rippled' import SocketContext from '../../../shared/SocketContext' import ClockIcon from '../../../shared/images/clock-icon.svg' -import { localizeNumber } from '../../../shared/utils' +import { localizeNumber, shortenAccount, shortenMPTID } from '../../../shared/utils' import { useLanguage } from '../../../shared/hooks' import logger from '../../../../rippled/lib/logger' diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx index c7f64e027..e8677a2ff 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx @@ -11,9 +11,8 @@ import SocketContext from '../../../shared/SocketContext' import { formatMPTIssuance, formatTransferFee, - shortenMPTID, } from '../../../../rippled/lib/utils' -import { localizeNumber } from '../../../shared/utils' +import { localizeNumber, shortenMPTID } from '../../../shared/utils' import { useLanguage } from '../../../shared/hooks' import logger from '../../../../rippled/lib/logger' diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx index d872bf950..d62185b32 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -11,14 +11,14 @@ import { } from '../../../../rippled/lib/rippled' import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' import { NFTokenLink } from '../../../shared/components/NFTokenLink' +import DomainLink from '../../../shared/components/DomainLink' +import { XRP_BASE } from '../../../shared/transactionUtils' import { + localizeNumber, shortenAccount, shortenDomain, shortenNFTTokenID, -} from '../../../../rippled/lib/utils' -import DomainLink from '../../../shared/components/DomainLink' -import { XRP_BASE } from '../../../shared/transactionUtils' -import { localizeNumber } from '../../../shared/utils' +} from '../../../shared/utils' import { XRP_SMALL_BALANCE_CURRENCY_OPTIONS } from '../../../shared/NumberFormattingUtils' import { useLanguage } from '../../../shared/hooks' import logger from '../../../../rippled/lib/logger' diff --git a/src/containers/Accounts/AccountSummary/SignersCard.tsx b/src/containers/Accounts/AccountSummary/SignersCard.tsx index b96a49600..e525420dc 100644 --- a/src/containers/Accounts/AccountSummary/SignersCard.tsx +++ b/src/containers/Accounts/AccountSummary/SignersCard.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { Account } from '../../shared/components/Account' -import { shortenAccount } from '../../../rippled/lib/utils' +import { shortenAccount } from '../../shared/utils' interface Signer { account: string diff --git a/src/containers/shared/utils.js b/src/containers/shared/utils.js index 3d8365cbc..770cc6c82 100644 --- a/src/containers/shared/utils.js +++ b/src/containers/shared/utils.js @@ -401,3 +401,19 @@ export function convertScaledPrice(assetPrice, scale) { ? `${integerPart}.${fractionalPart.toString().padStart(scale, '0')}` : `${integerPart}` } + +export const shortenAccount = (addr = '') => + addr.length > 12 ? `${addr.slice(0, 7)}...${addr.slice(-5)}` : addr + +export const shortenDomain = (domain = '') => + domain.length > 26 ? `${domain.slice(0, 15)}...${domain.slice(-11)}` : domain + +export const shortenNFTTokenID = (nftTokenID = '') => + nftTokenID.length > 20 + ? `${nftTokenID.slice(0, 10)}...${nftTokenID.slice(-10)}` + : nftTokenID + +export const shortenMPTID = (mptTokenID = '') => + mptTokenID.length > 20 + ? `${mptTokenID.slice(0, 10)}...${mptTokenID.slice(-10)}` + : mptTokenID diff --git a/src/rippled/lib/utils.js b/src/rippled/lib/utils.js index 05aae9083..2859b8e99 100644 --- a/src/rippled/lib/utils.js +++ b/src/rippled/lib/utils.js @@ -183,22 +183,6 @@ const formatMPToken = (info) => ({ mptAmount: info.MPTAmount ? info.MPTAmount.toString(10) : '0', }) -const shortenAccount = (addr = '') => - addr.length > 12 ? `${addr.slice(0, 7)}...${addr.slice(-5)}` : addr - -const shortenDomain = (domain = '') => - domain.length > 26 ? `${domain.slice(0, 15)}...${domain.slice(-11)}` : domain - -const shortenNFTTokenID = (nftTokenID = '') => - nftTokenID.length > 20 - ? `${nftTokenID.slice(0, 10)}...${nftTokenID.slice(-10)}` - : nftTokenID - -const shortenMPTID = (mptTokenID = '') => - mptTokenID.length > 20 - ? `${mptTokenID.slice(0, 10)}...${mptTokenID.slice(-10)}` - : mptTokenID - export { XRP_BASE, RippledError as Error, @@ -211,8 +195,4 @@ export { formatMPTIssuance, formatMPToken, formatTransferFee, - shortenAccount, - shortenDomain, - shortenNFTTokenID, - shortenMPTID, } From 510d6797ec6d13e189470e55b846926703cd3e87 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 2 Oct 2025 23:45:14 -0400 Subject: [PATCH 22/47] Replace browser built-in tooltip with the Explorer custom tooltip to indicate that missing data will be provided in a future release --- .../Accounts/AccountAsset/FutureDataIcon.scss | 14 +++++ .../Accounts/AccountAsset/FutureDataIcon.tsx | 42 +++++++++++++ .../AccountAsset/assetTables/HeldMPTs.tsx | 61 ++++++++----------- .../AccountAsset/assetTables/IssuedMPTs.tsx | 38 ++++++------ .../Accounts/AccountAsset/styles.scss | 14 ----- 5 files changed, 98 insertions(+), 71 deletions(-) create mode 100644 src/containers/Accounts/AccountAsset/FutureDataIcon.scss create mode 100644 src/containers/Accounts/AccountAsset/FutureDataIcon.tsx diff --git a/src/containers/Accounts/AccountAsset/FutureDataIcon.scss b/src/containers/Accounts/AccountAsset/FutureDataIcon.scss new file mode 100644 index 000000000..624894f43 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/FutureDataIcon.scss @@ -0,0 +1,14 @@ +@use '../../shared/css/variables' as *; + +.future-data { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: default; + + .clock-icon { + width: px; + height: 16px; + color: $black-50; + } +} diff --git a/src/containers/Accounts/AccountAsset/FutureDataIcon.tsx b/src/containers/Accounts/AccountAsset/FutureDataIcon.tsx new file mode 100644 index 000000000..faaf2c84d --- /dev/null +++ b/src/containers/Accounts/AccountAsset/FutureDataIcon.tsx @@ -0,0 +1,42 @@ +import ClockIcon from '../../shared/images/clock-icon.svg' +import { useTooltip } from '../../shared/components/Tooltip' +import './FutureDataIcon.scss' + +interface FutureDataIconProps { + message?: string +} +/** + * Displays an icon with a tooltip indicating that the associated data will be available in a future release. + * + * Note: For the tooltip functionality to work, ensure that: + * 1. The parent component is wrapped with ``. + * 2. A `` component is rendered within the component tree. + */ +export const FutureDataIcon = ({ + message = 'This data will be provided in a future release.', +}: FutureDataIconProps) => { + const { showTooltip, hideTooltip } = useTooltip() + + const handleShow = (e: React.MouseEvent | React.FocusEvent) => { + const rect = e.currentTarget.getBoundingClientRect() + showTooltip('text', e as any, message, { + // The tooltip position is adjusted to appear below and slightly to the right of the icon + x: rect.left + window.scrollX + 10, + y: rect.top + window.scrollY - 120, + }) + } + + return ( + hideTooltip()} + onFocus={handleShow} + onBlur={() => hideTooltip()} + tabIndex={0} + role="button" + > + + + ) +} diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx index a1a403f86..5a94ef28c 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx @@ -6,6 +6,11 @@ import { MPT_ROUTE } from '../../../App/routes' import { Loader } from '../../../shared/components/Loader' import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' import { Account } from '../../../shared/components/Account' +import { + Tooltip, + useTooltip, + TooltipProvider, +} from '../../../shared/components/Tooltip' import { formatMPTIssuance, formatMPToken, @@ -13,10 +18,14 @@ import { } from '../../../../rippled/lib/utils' import { getAccountMPTs, getMPTIssuance } from '../../../../rippled/lib/rippled' import SocketContext from '../../../shared/SocketContext' -import ClockIcon from '../../../shared/images/clock-icon.svg' -import { localizeNumber, shortenAccount, shortenMPTID } from '../../../shared/utils' +import { + localizeNumber, + shortenAccount, + shortenMPTID, +} from '../../../shared/utils' import { useLanguage } from '../../../shared/hooks' import logger from '../../../../rippled/lib/logger' +import { FutureDataIcon } from '../FutureDataIcon' const log = logger({ name: 'HeldMPTs' }) @@ -116,10 +125,11 @@ const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { return combinedMPTs } -export const HeldMPTs = ({ accountId, onChange }: HeldMPTsProps) => { +const HeldMPTsContent = ({ accountId, onChange }: HeldMPTsProps) => { const lang = useLanguage() const { t } = useTranslation() const rippledSocket = useContext(SocketContext) + const { tooltip } = useTooltip() const heldMPTsQuery = useQuery(['heldMPTs', accountId], () => fetchAccountHeldMPTs(accountId, rippledSocket), @@ -139,6 +149,7 @@ export const HeldMPTs = ({ accountId, onChange }: HeldMPTsProps) => { return (
    + @@ -166,18 +177,7 @@ export const HeldMPTs = ({ accountId, onChange }: HeldMPTsProps) => { {shortenMPTID(token.tokenId)} - + - {ious.length === 0 ? ( + {iouTokens.length === 0 ? ( {t('account_page_asset_table_no_iou')} ) : ( - ious.map((token) => { + iouTokens.map((token) => { const { formattedUsdPrice, formattedBalance, diff --git a/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx index 6d3475b7f..51bd305f2 100644 --- a/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx +++ b/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx @@ -211,16 +211,21 @@ describe('HeldIOUs', () => { it('shows all tokens including token starting with `03` whose issuer is not an AMM account', async () => { // Override the mock to make `03` token issuer NOT an AMM account - mockedGetAccountInfo.mockImplementation((_, accountId) => { + // Add a delay to simulate async checking and test progressive reveal + mockedGetAccountInfo.mockImplementation((_, accountId): Promise => { if (accountId === 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w') { - // NOT an AMM account (no AMMID) - return Promise.resolve({ - TransferRate: 1020000000, // 2% transfer fee - Flags: 0x00400000, // lsfGlobalFreeze - AMMID: undefined, // This makes it NOT an AMM account + // NOT an AMM account (no AMMID) - but with a delay + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + TransferRate: 1020000000, // 2% transfer fee + Flags: 0x00400000, // lsfGlobalFreeze + AMMID: undefined, // This makes it NOT an AMM account + }) + }, 100) // 100ms delay to simulate network request }) } - // Regular accounts + // Regular accounts - immediate response return Promise.resolve(mockAccountInfo) }) @@ -233,14 +238,39 @@ describe('HeldIOUs', () => { // Verify all column headers are displayed await verifyColumnHeaders() - // Wait for tokens to load + // Wait for initial tokens to load (non-03 tokens) await waitFor(() => { expect(screen.getByText('USD')).toBeInTheDocument() }) - // Get all table rows (excluding header) - const rows = screen.getAllByRole('row') - const dataRows = rows.slice(1) // Skip header row + // Get initial table rows (excluding header) + let rows = screen.getAllByRole('row') + let dataRows = rows.slice(1) + + // Initially, we should only have 3 tokens (USD, EUR, BTC) + // The `03` token should be hidden until confirmed as non-LP + expect(dataRows).toHaveLength(3) + expect( + screen.queryByText( + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + ), + ).not.toBeInTheDocument() + + // Now wait for the `03` token to appear after confirmation + await waitFor( + () => { + expect( + screen.getByText( + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + ), + ).toBeInTheDocument() + }, + { timeout: 5000 }, + ) + + // Get all table rows again (should now include the `03` token) + rows = screen.getAllByRole('row') + dataRows = rows.slice(1) // Skip header row // Verify we have 4 token rows (sorted by Balance USD descending) expect(dataRows).toHaveLength(4) From e9debef7b498ee4e8d49e592544b1ef94081d5a1 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 8 Oct 2025 23:51:34 -0400 Subject: [PATCH 46/47] Make changes to the titles, descriptions and status value to some Account flags --- public/locales/en-US/translations.json | 12 ++-- .../Accounts/AccountSummary/FlagsCard.tsx | 13 +++-- .../Accounts/AccountSummary/styles.scss | 5 +- .../AccountSummary/test/FlagsCard.test.tsx | 56 ++++++++++++++----- 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index a8c617f6a..de71d9737 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -615,22 +615,22 @@ "deletes": "deletes", "vault_delete_table_detail": "vault with id", "account_flag_title_lsf_global_freeze": "Global Freeze", - "account_flag_title_lsf_disable_master": "Master Key", + "account_flag_title_lsf_disable_master": "Master Key Disabled", "account_flag_title_lsf_default_ripple": "Rippling", "account_flag_title_lsf_allow_trustline_clawback": "Clawback", "account_flag_title_lsf_require_destination_tag": "Require Destination Tag", "account_flag_title_lsf_no_freeze": "No Freeze", "account_flag_title_lsf_require_auth": "Require Authorization", "account_flag_title_lsf_disallow_xrp": "No XRP Allowed", - "account_flag_title_lsf_disallow_incoming_trustline": "No Trustlines Allowed", - "account_flag_title_lsf_disallow_incoming_pay_chan": "No Payment Channels Allowed", - "account_flag_title_lsf_disallow_incoming_nft_token_offer": "No NFT Offers Allowed", + "account_flag_title_lsf_disallow_incoming_trustline": "Block Trustlines", + "account_flag_title_lsf_disallow_incoming_pay_chan": "Block Payment Channels", + "account_flag_title_lsf_disallow_incoming_nft_token_offer": "Block NFT Offers", "account_flag_title_asf_authorized_nft_token_minter": "NFT Minter", - "account_flag_title_lsf_disallow_incoming_check": "No Checks Allowed", + "account_flag_title_lsf_disallow_incoming_check": "Block Checks", "account_flag_title_lsf_deposit_auth": "Deposit Authorization", "account_flag_title_asf_account_txn_id": "Track Account Latest Transaction", "account_flag_description_lsf_global_freeze": "Freeze all assets issued by this account.", - "account_flag_description_lsf_disable_master": "Allow use of the master key pair.", + "account_flag_description_lsf_disable_master": "Disable the account's master key pair.", "account_flag_description_lsf_default_ripple": "Enable rippling on this account's trust lines by default.", "account_flag_description_lsf_allow_trustline_clawback": "Allow account to claw back tokens it has issued.", "account_flag_description_lsf_require_destination_tag": "Require a destination tag to send transactions to this account.", diff --git a/src/containers/Accounts/AccountSummary/FlagsCard.tsx b/src/containers/Accounts/AccountSummary/FlagsCard.tsx index a3d9771ef..b98515fa4 100644 --- a/src/containers/Accounts/AccountSummary/FlagsCard.tsx +++ b/src/containers/Accounts/AccountSummary/FlagsCard.tsx @@ -29,7 +29,7 @@ const FlagsCard = ({ account }: FlagsCardProps) => { key: 'lsfDisableMaster', title: t('account_flag_title_lsf_disable_master'), description: t('account_flag_description_lsf_disable_master'), - enabled: !accountFlags.includes('lsfDisableMaster'), + enabled: accountFlags.includes('lsfDisableMaster'), }, { key: 'lsfDefaultRipple', @@ -139,9 +139,14 @@ const FlagsCard = ({ account }: FlagsCardProps) => {
    - {flag.enabled - ? t('account_page_flag_status_enabled') - : t('account_page_flag_status_disabled')} + {(() => { + if (flag.key === 'lsfDisableMaster') { + return flag.enabled ? t('yes') : t('no') + } + return flag.enabled + ? t('account_page_flag_status_enabled') + : t('account_page_flag_status_disabled') + })()}
    ))} diff --git a/src/containers/Accounts/AccountSummary/styles.scss b/src/containers/Accounts/AccountSummary/styles.scss index 9fb7e41bb..14b4d044d 100644 --- a/src/containers/Accounts/AccountSummary/styles.scss +++ b/src/containers/Accounts/AccountSummary/styles.scss @@ -236,14 +236,15 @@ } .flag-status { + min-width: 85px; // Ensures YES/NO and ENABLED/DISABLED have the same width padding: 6px 10px; border-radius: 999px; font-size: 12px; - @include semibold; - letter-spacing: 0.06em; + text-align: center; text-transform: uppercase; white-space: nowrap; + @include semibold; } .flag-status.enabled { diff --git a/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx b/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx index 96c15d876..eaf7993d2 100644 --- a/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx +++ b/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx @@ -43,7 +43,7 @@ describe('FlagsCard Component', () => { }) }) - describe('Flag Status - Enabled/Disabled', () => { + describe('Flag Status - Enabled(Yes)/Disabled(No)', () => { it('shows lsfGlobalFreeze as enabled when flag is present', () => { const account = { info: { @@ -61,6 +61,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('Global Freeze'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfGlobalFreeze as disabled when flag is not present', () => { @@ -80,12 +81,13 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('Global Freeze'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) - it('shows lsfDisableMaster as enabled (inverted) when flag is NOT present', () => { + it('shows lsfDisableMaster as enabled with "Yes" when flag is present', () => { const account = { info: { - flags: [], + flags: ['lsfDisableMaster'], }, } @@ -99,12 +101,13 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('Master Key'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Yes') }) - it('shows lsfDisableMaster as disabled (inverted) when flag IS present', () => { + it('shows lsfDisableMaster as disabled with "No" when flag is not present', () => { const account = { info: { - flags: ['lsfDisableMaster'], + flags: [], }, } @@ -118,6 +121,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('Master Key'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('No') }) it('shows lsfDefaultRipple as enabled when flag is present', () => { @@ -137,6 +141,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('Rippling'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfDefaultRipple as disabled when flag is not present', () => { @@ -156,6 +161,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('Rippling'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows lsfAllowTrustLineClawback as enabled when flag is present', () => { @@ -175,6 +181,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('Clawback'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfAllowTrustLineClawback as disabled when flag is not present', () => { @@ -194,6 +201,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('Clawback'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows lsfRequireDestTag as enabled when flag is present', () => { @@ -213,6 +221,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('Require Destination Tag'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfRequireDestTag as disabled when flag is not present', () => { @@ -232,6 +241,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('Require Destination Tag'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows lsfNoFreeze as enabled when flag is present', () => { @@ -251,6 +261,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('No Freeze'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfNoFreeze as disabled when flag is not present', () => { @@ -270,6 +281,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('No Freeze'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows lsfRequireAuth as enabled when flag is present', () => { @@ -291,6 +303,7 @@ describe('FlagsCard Component', () => { 'Require Authorization', ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfRequireAuth as disabled when flag is not present', () => { @@ -312,6 +325,7 @@ describe('FlagsCard Component', () => { 'Require Authorization', ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows lsfDisallowXRP as enabled when flag is present', () => { @@ -331,6 +345,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('No XRP Allowed'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfDisallowXRP as disabled when flag is not present', () => { @@ -350,6 +365,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('No XRP Allowed'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows lsfDisallowIncomingTrustline as enabled when flag is present', () => { @@ -366,9 +382,10 @@ describe('FlagsCard Component', () => { ) const flag = Array.from(container.querySelectorAll('.flag-item')).find( - (item) => item.textContent?.includes('No Trustlines Allowed'), + (item) => item.textContent?.includes('Block Trustlines'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfDisallowIncomingTrustline as disabled when flag is not present', () => { @@ -385,9 +402,10 @@ describe('FlagsCard Component', () => { ) const flag = Array.from(container.querySelectorAll('.flag-item')).find( - (item) => item.textContent?.includes('No Trustlines Allowed'), + (item) => item.textContent?.includes('Block Trustlines'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows lsfDisallowIncomingPayChannel as enabled when flag is present', () => { @@ -404,9 +422,10 @@ describe('FlagsCard Component', () => { ) const flag = Array.from(container.querySelectorAll('.flag-item')).find( - (item) => item.textContent?.includes('No Payment Channels Allowed'), + (item) => item.textContent?.includes('Block Payment Channels'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfDisallowIncomingPayChannel as disabled when flag is not present', () => { @@ -423,9 +442,10 @@ describe('FlagsCard Component', () => { ) const flag = Array.from(container.querySelectorAll('.flag-item')).find( - (item) => item.textContent?.includes('No Payment Channels Allowed'), + (item) => item.textContent?.includes('Block Payment Channels'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows lsfDisallowIncomingNFTokenOffer as enabled when flag is present', () => { @@ -442,9 +462,10 @@ describe('FlagsCard Component', () => { ) const flag = Array.from(container.querySelectorAll('.flag-item')).find( - (item) => item.textContent?.includes('No NFT Offers Allowed'), + (item) => item.textContent?.includes('Block NFT Offers'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfDisallowIncomingNFTokenOffer as disabled when flag is not present', () => { @@ -461,9 +482,10 @@ describe('FlagsCard Component', () => { ) const flag = Array.from(container.querySelectorAll('.flag-item')).find( - (item) => item.textContent?.includes('No NFT Offers Allowed'), + (item) => item.textContent?.includes('Block NFT Offers'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows asfAuthorizedNFTokenMinter as enabled when nftMinter field is present', () => { @@ -484,6 +506,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('NFT Minter'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows asfAuthorizedNFTokenMinter as disabled when nftMinter field is not present', () => { @@ -503,6 +526,7 @@ describe('FlagsCard Component', () => { (item) => item.textContent?.includes('NFT Minter'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows lsfDisallowIncomingCheck as enabled when flag is present', () => { @@ -519,9 +543,10 @@ describe('FlagsCard Component', () => { ) const flag = Array.from(container.querySelectorAll('.flag-item')).find( - (item) => item.textContent?.includes('No Checks Allowed'), + (item) => item.textContent?.includes('Block Checks'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfDisallowIncomingCheck as disabled when flag is not present', () => { @@ -538,9 +563,10 @@ describe('FlagsCard Component', () => { ) const flag = Array.from(container.querySelectorAll('.flag-item')).find( - (item) => item.textContent?.includes('No Checks Allowed'), + (item) => item.textContent?.includes('Block Checks'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows lsfDepositAuth as enabled when flag is present', () => { @@ -562,6 +588,7 @@ describe('FlagsCard Component', () => { 'Deposit Authorization', ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows lsfDepositAuth as disabled when flag is not present', () => { @@ -583,6 +610,7 @@ describe('FlagsCard Component', () => { 'Deposit Authorization', ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) it('shows asfAccountTxnID as enabled when accountTransactionID field is present', () => { @@ -604,6 +632,7 @@ describe('FlagsCard Component', () => { item.textContent?.includes('Track Account Latest Transaction'), ) expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Enabled') }) it('shows asfAccountTxnID as disabled when accountTransactionID field is not present', () => { @@ -624,6 +653,7 @@ describe('FlagsCard Component', () => { item.textContent?.includes('Track Account Latest Transaction'), ) expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + expect(flag?.querySelector('.flag-status')).toHaveTextContent('Disabled') }) }) From 41fed1aaf130ceb0f3b5f8c51f233e191be0d3d5 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 9 Oct 2025 09:35:59 -0400 Subject: [PATCH 47/47] Update unit tests for HeldIOUs.tsx to verify the logic of sorting by USD after a `03` token is added to the table --- .../AccountAsset/test/HeldIOUs.test.tsx | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx index 51bd305f2..537b9316f 100644 --- a/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx +++ b/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx @@ -61,7 +61,7 @@ const mockBalancesResponse = { currency: '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', value: '1000', - }, // LP Token + }, ], }, } @@ -126,6 +126,13 @@ const mockLOSTokenResponse = { price_usd: '45000.00', asset_class: 'Cryptocurrency', }, + { + currency: + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + issuer_account: 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w', + price_usd: '1.00', + asset_class: 'RWA', + }, ], } @@ -192,7 +199,7 @@ describe('HeldIOUs', () => { // Set up getAccountInfo mock to handle different account types mockedGetAccountInfo.mockImplementation((_, accountId) => { if (accountId === 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w') { - // LP token issuer - make it an AMM account by default to exclude LP tokens + // LP token issuer - make it an AMM account by default make tokens starting with `03` LP tokens return Promise.resolve({ TransferRate: undefined, Flags: 0, @@ -214,7 +221,6 @@ describe('HeldIOUs', () => { // Add a delay to simulate async checking and test progressive reveal mockedGetAccountInfo.mockImplementation((_, accountId): Promise => { if (accountId === 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w') { - // NOT an AMM account (no AMMID) - but with a delay return new Promise((resolve) => { setTimeout(() => { resolve({ @@ -225,6 +231,7 @@ describe('HeldIOUs', () => { }, 100) // 100ms delay to simulate network request }) } + // Regular accounts - immediate response return Promise.resolve(mockAccountInfo) }) @@ -275,20 +282,34 @@ describe('HeldIOUs', () => { // Verify we have 4 token rows (sorted by Balance USD descending) expect(dataRows).toHaveLength(4) - // Verify USD token data in first row (highest balance USD: $100.50) - const usdRow = dataRows[0] + // / Verify `03` non-lp token data in first row (highest balance USD: $1,000.00) + const nonLPTokenRow = dataRows[0] + expect(nonLPTokenRow).toHaveTextContent( + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + ) + expect(nonLPTokenRow).toHaveTextContent('rLNaPoK...4dc6w') + expect(nonLPTokenRow).toHaveTextContent('$1.00') + expect(nonLPTokenRow).toHaveTextContent('1,000') + expect(nonLPTokenRow).toHaveTextContent('$1,000.00') + expect(nonLPTokenRow).toHaveTextContent('RWA') + // Initial state shows -- for both transfer fee and frozen (no trustline freeze) + const nonLPTokenPlaceholders = nonLPTokenRow.textContent?.match(/--/g) || [] + expect(nonLPTokenPlaceholders.length).toBe(2) + + // Verify USD token data in second row (highest balance USD: $100.50) + const usdRow = dataRows[1] expect(usdRow).toHaveTextContent('USD') expect(usdRow).toHaveTextContent('Gatehub') expect(usdRow).toHaveTextContent('$1.00') expect(usdRow).toHaveTextContent('100.5') expect(usdRow).toHaveTextContent('$100.50') - expect(usdRow).toHaveTextContent('Currency') + // Initial state shows -- for both transfer fee and frozen (no trustline freeze) const usdPlaceholders = usdRow.textContent?.match(/--/g) || [] expect(usdPlaceholders.length).toBe(2) - // Verify EUR token data in second row (second highest: $55.28) - const eurRow = dataRows[1] + // Verify EUR token data in third row (second highest: $55.28) + const eurRow = dataRows[2] expect(eurRow).toHaveTextContent('EUR') expect(eurRow).toHaveTextContent('Gatehub') expect(eurRow).toHaveTextContent('$1.10') @@ -299,8 +320,8 @@ describe('HeldIOUs', () => { const eurPlaceholders = eurRow.textContent?.match(/--/g) || [] expect(eurPlaceholders.length).toBe(2) - // Verify BTC token data in third row (third highest: $45.00) - const btcRow = dataRows[2] + // Verify BTC token data in fourth row (third highest: $45.00) + const btcRow = dataRows[3] expect(btcRow).toHaveTextContent('BTC') expect(btcRow).toHaveTextContent('Bitstamp') expect(btcRow).toHaveTextContent('$45,000.00') @@ -312,19 +333,6 @@ describe('HeldIOUs', () => { // Only 1 placeholder for transfer fee (frozen shows Trustline, not --) const btcPlaceholders = btcRow.textContent?.match(/--/g) || [] expect(btcPlaceholders.length).toBe(1) - - // Verify `03` token data in fourth row (no Balance USD info, missing LOS data) - const lpTokenRow = dataRows[3] - expect(lpTokenRow).toHaveTextContent( - '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', - ) - expect(lpTokenRow).toHaveTextContent('rLNaPoK...4dc6w') - expect(lpTokenRow).toHaveTextContent('1,000') - - // Verify placeholders for missing data (no LOS token info for LP token) - // Initial state: Price USD, Balance USD, Asset Class, Transfer Fee, Frozen all show -- - const lpTokenPlaceholders = lpTokenRow.textContent?.match(/--/g) || [] - expect(lpTokenPlaceholders.length).toBe(5) }) it('excludes LP tokens whose issuer is an AMM account', async () => {
    - {token.ticker ? ( - token.ticker - ) : ( - - - - )} - {token.ticker ? token.ticker : } { )} - - - + {localizeNumber(token.balance, lang)} - - - + - {token.assetClass ? ( - token.assetClass - ) : ( - - - - )} + {token.assetClass ? token.assetClass : } {token.transferFee}% @@ -239,3 +220,9 @@ export const HeldMPTs = ({ accountId, onChange }: HeldMPTsProps) => { ) } + +export const HeldMPTs = ({ accountId, onChange }: HeldMPTsProps) => ( + + + +) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx index e8677a2ff..1db8e3a27 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx @@ -5,7 +5,12 @@ import { RouteLink } from '../../../shared/routing' import { MPT_ROUTE } from '../../../App/routes' import { Loader } from '../../../shared/components/Loader' import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' -import ClockIcon from '../../../shared/images/clock-icon.svg' +import { FutureDataIcon } from '../FutureDataIcon' +import { + Tooltip, + useTooltip, + TooltipProvider, +} from '../../../shared/components/Tooltip' import { getAccountObjects } from '../../../../rippled/lib/rippled' import SocketContext from '../../../shared/SocketContext' import { @@ -74,10 +79,11 @@ const fetchAccountIssuedMPTs = async ( return issuedMPTs } -export const IssuedMPTs = ({ accountId, onChange }: IssuedMPTsProps) => { +const IssuedMPTsContent = ({ accountId, onChange }: IssuedMPTsProps) => { const lang = useLanguage() const { t } = useTranslation() const rippledSocket = useContext(SocketContext) + const { tooltip } = useTooltip() const issuedMPTsQuery = useQuery(['issuedMPTs', accountId], () => fetchAccountIssuedMPTs(accountId, rippledSocket), @@ -97,6 +103,7 @@ export const IssuedMPTs = ({ accountId, onChange }: IssuedMPTsProps) => { return (
    + @@ -123,29 +130,14 @@ export const IssuedMPTs = ({ accountId, onChange }: IssuedMPTsProps) => { diff --git a/src/containers/Accounts/AccountSummary/SignersCard.tsx b/src/containers/Accounts/AccountSummary/SignersCard.tsx index e525420dc..3e6caf77b 100644 --- a/src/containers/Accounts/AccountSummary/SignersCard.tsx +++ b/src/containers/Accounts/AccountSummary/SignersCard.tsx @@ -23,7 +23,7 @@ const SignersCard = ({ signers }: SignersCardProps) => {
    {signer.weight !== undefined && ( diff --git a/src/containers/shared/components/Account.tsx b/src/containers/shared/components/Account.tsx index 08734fc62..cafaa5925 100644 --- a/src/containers/shared/components/Account.tsx +++ b/src/containers/shared/components/Account.tsx @@ -3,13 +3,13 @@ import { ACCOUNT_ROUTE } from '../../App/routes' export interface AccountProps { account: string - shortAccount?: string + displayText?: string link?: boolean tag?: number } export const Account = (props: AccountProps) => { - const { account, shortAccount, link = true, tag } = props + const { account, displayText, link = true, tag } = props const parts = account.split(':') const computedTag = tag || parts[1] @@ -23,11 +23,11 @@ export const Account = (props: AccountProps) => { to={ACCOUNT_ROUTE} params={{ id: parts[0] }} > - {shortAccount || parts[0]} + {displayText || parts[0]} ) : ( - {shortAccount || parts[0]} + {displayText || parts[0]} )} {computedTag && ( From 3f40bc5fd9977d520046bab99fdd8d19066cd3fd Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Mon, 6 Oct 2025 21:15:45 -0400 Subject: [PATCH 32/47] Add tests for formatTransferFee --- .../Accounts/AccountAsset/FutureDataIcon.scss | 2 +- .../AccountAsset/assetTables/HeldMPTs.tsx | 2 +- .../AccountAsset/assetTables/HeldNFTs.tsx | 2 +- .../AccountAsset/assetTables/IssuedMPTs.tsx | 2 +- .../AccountAsset/assetTables/IssuedNFTs.tsx | 2 +- src/rippled/lib/test/utils.test.ts | 83 ++++++++++++++++++- src/rippled/lib/utils.js | 10 ++- 7 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/FutureDataIcon.scss b/src/containers/Accounts/AccountAsset/FutureDataIcon.scss index 624894f43..a3495a0ff 100644 --- a/src/containers/Accounts/AccountAsset/FutureDataIcon.scss +++ b/src/containers/Accounts/AccountAsset/FutureDataIcon.scss @@ -7,7 +7,7 @@ cursor: default; .clock-icon { - width: px; + width: 16px; height: 16px; color: $black-50; } diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx index 20f55e3c4..8e0952e3f 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx @@ -99,7 +99,7 @@ const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { issuer: mptIssuance?.issuer || '', issuerName: mptIssuance?.metadata?.IssuerName || null, assetClass: mptIssuance?.metadata?.AssetClass || null, - transferFee: formatTransferFee(mptIssuance?.transferFee), + transferFee: formatTransferFee(mptIssuance?.transferFee, 'MPT'), locked: (() => { if (mptIssuance?.flags?.includes('lsfMPTLocked')) { return 'Global' diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx index 5ad539c78..107d813c3 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx @@ -36,7 +36,7 @@ const fetchAccountHeldNFTs = async ( nftId: nft.NFTokenID, issuer: nft.Issuer, url: nft.URI ? Buffer.from(nft.URI, 'hex').toString('utf8') : '', - transferFee: formatTransferFee(nft.TransferFee), + transferFee: formatTransferFee(nft.TransferFee, 'NFT'), })) } catch (error) { log.error(`Error fetching held NFTs: ${JSON.stringify(error)}`) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx index 517593792..7608e0e5e 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx @@ -65,7 +65,7 @@ const fetchAccountIssuedMPTs = async ( ticker: formattedIssuance?.metadata?.Ticker || null, supply: formattedIssuance?.outstandingAmt || '0', assetClass: formattedIssuance?.metadata?.AssetClass || null, - transferFee: formatTransferFee(formattedIssuance?.transferFee), + transferFee: formatTransferFee(formattedIssuance?.transferFee, 'MPT'), locked: formattedIssuance?.flags?.includes('lsfMPTLocked') ? 'Global' : '', diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx index 2c27dc59f..aab202db3 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx @@ -35,7 +35,7 @@ const fetchAccountIssuedNFTs = async ( return allNFTs.map((nft) => ({ nftId: nft.nft_id, url: nft.uri ? Buffer.from(nft.uri, 'hex').toString('utf8') : '', - transferFee: formatTransferFee(nft.transfer_fee), + transferFee: formatTransferFee(nft.transfer_fee, 'NFT'), })) } catch (error) { log.error(`Error fetching issued NFTs: ${JSON.stringify(error)}`) diff --git a/src/rippled/lib/test/utils.test.ts b/src/rippled/lib/test/utils.test.ts index 1c7f9d0dc..bd6f0478c 100644 --- a/src/rippled/lib/test/utils.test.ts +++ b/src/rippled/lib/test/utils.test.ts @@ -1,4 +1,4 @@ -import { formatAccountInfo, formatNFTInfo } from '../utils' +import { formatAccountInfo, formatNFTInfo, formatTransferFee } from '../utils' describe('rippled utils:', () => { describe('formatNFTInfo', () => { @@ -89,4 +89,85 @@ describe('rippled utils:', () => { ) }) }) + + describe('formatTransferFee', () => { + describe('when no transfer fee is provided', () => { + it('should return "0" for null', () => { + expect(formatTransferFee(null)).toBe('0') + }) + + it('should return "0" for undefined', () => { + expect(formatTransferFee(undefined)).toBe('0') + }) + + it('should return "0" for 0', () => { + expect(formatTransferFee(0)).toBe('0') + }) + }) + + describe('when tokenType is "IOU"', () => { + it('should calculate percentage for IOU tokens using billion-based formula', () => { + // 0.5% transfer fee: 1000000000 + (1000000000 * 0.005) = 1005000000 + expect(formatTransferFee(1005000000, 'IOU')).toBe('0.5') + + // 10% transfer fee: 1000000000 + (1000000000 * 0.1) = 1100000000 + expect(formatTransferFee(1100000000, 'IOU')).toBe('10') + }) + + it('should handle high precision values for IOU tokens', () => { + // Test with a value that needs rounding to 7 decimal places + const transferFee = 1000000000 + 12345678 // should result in 0.1234568% + expect(formatTransferFee(transferFee, 'IOU')).toBe('0.1234568') + }) + + it('should handle very small transfer fees for IOU tokens', () => { + const billion = 1000000000 + const transferFee = billion + 1 // 0.0000001% + expect(formatTransferFee(transferFee, 'IOU')).toBe('1e-7') // JavaScript converts very small numbers to scientific notation + }) + }) + + describe('when tokenType is "MPT" or "NFT"', () => { + it('should calculate percentage for MPT tokens using thousand-based formula', () => { + // 0.5% transfer fee + expect(formatTransferFee(5, 'MPT')).toBe('0.005') + + // 10% transfer fee + expect(formatTransferFee(100, 'MPT')).toBe('0.1') + }) + + it('should calculate percentage for NFT tokens using thousand-based formula', () => { + expect(formatTransferFee(5, 'NFT')).toBe('0.005') + expect(formatTransferFee(100, 'NFT')).toBe('0.1') + }) + + it('should handle high precision values for MPT/NFT tokens', () => { + // Test with a value that needs rounding to 3 decimal places + expect(formatTransferFee(1234, 'MPT')).toBe('1.234') + expect(formatTransferFee(1235, 'NFT')).toBe('1.235') // Test rounding + }) + + it('should handle very small transfer fees for MPT/NFT tokens', () => { + expect(formatTransferFee(1, 'MPT')).toBe('0.001') + expect(formatTransferFee(0.5, 'NFT')).toBe('0.001') // Should round to 3 decimal places + }) + }) + + describe('edge cases', () => { + it('should throw error for unsupported token types', () => { + expect(() => formatTransferFee(100, 'XRP')).toThrow( + 'Unsupported Token type: XRP', + ) + expect(() => formatTransferFee(100, 'INVALID')).toThrow( + 'Unsupported Token type: INVALID', + ) + expect(() => formatTransferFee(100, '')).toThrow( + 'Unsupported Token type: ', + ) + expect(() => formatTransferFee(100, null)).toThrow( + 'Unsupported Token type: null', + ) + }) + }) + }) }) diff --git a/src/rippled/lib/utils.js b/src/rippled/lib/utils.js index 2859b8e99..407f39c7b 100644 --- a/src/rippled/lib/utils.js +++ b/src/rippled/lib/utils.js @@ -70,7 +70,7 @@ export const buildFlags = (flags, flagMap) => { .filter((d) => Boolean(d)) } -const formatTransferFee = (transferFee, tokenType = '') => { +const formatTransferFee = (transferFee, tokenType) => { if (!transferFee) { return '0' } @@ -81,12 +81,14 @@ const formatTransferFee = (transferFee, tokenType = '') => { return parseFloat(transferFeePercentage.toFixed(7)).toString() } - // For MPT and NFT // MTP: https://xrpl.org/docs/references/protocol/data-types/nftoken#transferfee // NFT: https://xrpl.org/docs/concepts/tokens/fungible-tokens/multi-purpose-tokens#transfer-fees - const transferFeePercentage = transferFee / THOUSAND + if (tokenType === 'NFT' || tokenType === 'MPT') { + const transferFeePercentage = transferFee / THOUSAND + return parseFloat(transferFeePercentage.toFixed(3)).toString() + } - return parseFloat(transferFeePercentage.toFixed(3)).toString() + throw new Error(`Unsupported Token type: ${tokenType}`) } const formatAccountInfo = (info, serverInfoValidated) => ({ From 5b2df1478a297247d2f2805159bff8d902d4df43 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Mon, 6 Oct 2025 21:16:42 -0400 Subject: [PATCH 33/47] Add unit tests for FutureDataIcon.tsx --- .../AccountAsset/test/FutureDataIcon.test.tsx | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 src/containers/Accounts/AccountAsset/test/FutureDataIcon.test.tsx diff --git a/src/containers/Accounts/AccountAsset/test/FutureDataIcon.test.tsx b/src/containers/Accounts/AccountAsset/test/FutureDataIcon.test.tsx new file mode 100644 index 000000000..f573e732f --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/FutureDataIcon.test.tsx @@ -0,0 +1,207 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { FutureDataIcon } from '../FutureDataIcon' +import { TooltipProvider } from '../../../shared/components/Tooltip' + +// Mock the SVG import +jest.mock('../../../shared/images/clock-icon.svg', () => ({ + __esModule: true, + default: ({ className }: { className?: string }) => ( + + ), +})) + +// Mock the useTooltip hook +const mockShowTooltip = jest.fn() +const mockHideTooltip = jest.fn() + +jest.mock('../../../shared/components/Tooltip', () => ({ + ...jest.requireActual('../../../shared/components/Tooltip'), + useTooltip: () => ({ + showTooltip: mockShowTooltip, + hideTooltip: mockHideTooltip, + }), +})) + +// Helper to render component with TooltipProvider +const renderWithTooltipProvider = (component: React.ReactElement) => + render({component}) + +describe('FutureDataIcon', () => { + beforeEach(() => { + jest.clearAllMocks() + // Mock getBoundingClientRect + Element.prototype.getBoundingClientRect = jest.fn(() => ({ + left: 100, + top: 200, + right: 120, + bottom: 220, + width: 20, + height: 20, + x: 100, + y: 200, + toJSON: () => {}, + })) + + // Mock window.scrollX and window.scrollY + Object.defineProperty(window, 'scrollX', { value: 0, writable: true }) + Object.defineProperty(window, 'scrollY', { value: 0, writable: true }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('Rendering', () => { + it('renders the clock icon', () => { + renderWithTooltipProvider() + + expect(screen.getByTestId('clock-icon')).toBeInTheDocument() + expect(screen.getByTestId('clock-icon')).toHaveClass('clock-icon') + }) + + it('renders with default message when no message prop provided', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + 'This data will be provided in a future release.', + expect.objectContaining({ + x: 110, // 100 + 10 (TOOLTIP_X_OFFSET) + y: 80, // 200 + 0 - 120 (TOOLTIP_Y_OFFSET) + }), + ) + }) + + it('renders with custom message when message prop provided', () => { + const customMessage = 'Custom future data message' + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + customMessage, + expect.any(Object), + ) + }) + }) + + describe('Mouse Interactions', () => { + it('shows tooltip on mouse over', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledTimes(1) + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + 'This data will be provided in a future release.', + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + }), + ) + }) + + it('hides tooltip on mouse leave', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseLeave(futureDataSpan) + + expect(mockHideTooltip).toHaveBeenCalledTimes(1) + }) + + it('shows and hides tooltip on mouse over and leave sequence', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + + fireEvent.mouseOver(futureDataSpan) + expect(mockShowTooltip).toHaveBeenCalledTimes(1) + + fireEvent.mouseLeave(futureDataSpan) + expect(mockHideTooltip).toHaveBeenCalledTimes(1) + }) + }) + + describe('Tooltip Positioning', () => { + it('calculates correct tooltip position with scroll offset', () => { + // Set scroll values + Object.defineProperty(window, 'scrollX', { value: 50, writable: true }) + Object.defineProperty(window, 'scrollY', { value: 100, writable: true }) + + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + 'This data will be provided in a future release.', + { + x: 160, // 100 (left) + 50 (scrollX) + 10 (TOOLTIP_X_OFFSET) + y: 180, // 200 (top) + 100 (scrollY) - 120 (TOOLTIP_Y_OFFSET) + }, + ) + }) + + it('uses correct offset constants', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + expect.any(String), + { + x: 110, // Confirms TOOLTIP_X_OFFSET = 10 + y: 80, // Confirms TOOLTIP_Y_OFFSET = -120 + }, + ) + }) + }) + + describe('Edge Cases', () => { + it('handles multiple rapid mouse events', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + + // Rapid mouse events + fireEvent.mouseOver(futureDataSpan) + fireEvent.mouseOver(futureDataSpan) + fireEvent.mouseLeave(futureDataSpan) + fireEvent.mouseOver(futureDataSpan) + fireEvent.mouseLeave(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledTimes(3) + expect(mockHideTooltip).toHaveBeenCalledTimes(2) + }) + + it('handles empty message prop', () => { + renderWithTooltipProvider() + + const futureDataSpan = screen.getByRole('button') + fireEvent.mouseOver(futureDataSpan) + + expect(mockShowTooltip).toHaveBeenCalledWith( + 'text', + expect.any(Object), + '', + expect.any(Object), + ) + }) + }) +}) From 28e2de2d518b53a4462001a4a6cb17c212979d90 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Tue, 7 Oct 2025 11:35:30 -0400 Subject: [PATCH 34/47] Replace magic numbers with constants --- .../shared/NumberFormattingUtils.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/containers/shared/NumberFormattingUtils.ts b/src/containers/shared/NumberFormattingUtils.ts index df77ef793..b0efb67f8 100644 --- a/src/containers/shared/NumberFormattingUtils.ts +++ b/src/containers/shared/NumberFormattingUtils.ts @@ -1,5 +1,13 @@ import { localizeNumber } from './utils' +/** + * Thresholds for determining formatting precision. + * Adjust these values as needed to match product or UX requirements. + */ +const USD_REGULAR_BALANCE_LOWER_BOUND = 1 +const USD_SMALL_BALANCE_LOWER_BOUND = 0.0001 +const TOKEN_BALANCE_LARGE_LOWER_BOUND = 999 + // Standard display for most XRP amounts (2 decimals) export const XRP_CURRENCY_OPTIONS = { style: 'currency', @@ -65,9 +73,9 @@ export const formatUsdPrice = (price: number, lang: string): string => { } let options - if (price >= 1) { + if (price >= USD_REGULAR_BALANCE_LOWER_BOUND) { options = USD_CURRENCY_OPTIONS - } else if (price >= 0.0001) { + } else if (price >= USD_SMALL_BALANCE_LOWER_BOUND) { options = USD_SMALL_BALANCE_CURRENCY_OPTIONS } else { options = USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS @@ -88,9 +96,9 @@ export const formatUsdBalance = (balance: number, lang: string): string => { } let options - if (balance >= 1) { + if (balance >= USD_REGULAR_BALANCE_LOWER_BOUND) { options = USD_CURRENCY_OPTIONS - } else if (balance >= 0.0001) { + } else if (balance >= USD_SMALL_BALANCE_LOWER_BOUND) { options = USD_SMALL_BALANCE_CURRENCY_OPTIONS } else { options = USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS @@ -106,7 +114,10 @@ export const formatUsdBalance = (balance: number, lang: string): string => { * @returns Formatted balance string */ export const formatTokenBalance = (balance: number, lang: string): string => { - const options = balance > 999 ? NUMBER_DEFAULT_OPTIONS : NUMBER_SMALL_OPTIONS + const options = + balance > TOKEN_BALANCE_LARGE_LOWER_BOUND + ? NUMBER_DEFAULT_OPTIONS + : NUMBER_SMALL_OPTIONS return localizeNumber(balance, lang, options) || '0' } From 273bf74d254453f553019c24a88c9ec404006fdf Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Tue, 7 Oct 2025 14:06:23 -0400 Subject: [PATCH 35/47] Add unit tests for 7 asset table components and the main AccountAsset `index.tsx` component --- package.json | 2 +- .../AccountAsset/assetTables/HeldMPTs.tsx | 1 + .../AccountAsset/assetTables/IssuedMPTs.tsx | 1 + .../AccountAsset/assetTables/NFTTable.tsx | 2 +- .../AccountAsset/test/AccountAsset.test.tsx | 572 ++++++++++++++++++ .../AccountAsset/test/HeldIOUs.test.tsx | 367 +++++++++++ .../AccountAsset/test/HeldLPTokens.test.tsx | 220 +++++++ .../AccountAsset/test/HeldMPTs.test.tsx | 225 +++++++ .../AccountAsset/test/HeldNFTs.test.tsx | 244 ++++++++ .../AccountAsset/test/IssuedIOUs.test.tsx | 219 +++++++ .../AccountAsset/test/IssuedMPTs.test.tsx | 184 ++++++ .../AccountAsset/test/IssuedNFTs.test.tsx | 242 ++++++++ 12 files changed, 2277 insertions(+), 2 deletions(-) create mode 100644 src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx create mode 100644 src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx create mode 100644 src/containers/Accounts/AccountAsset/test/HeldLPTokens.test.tsx create mode 100644 src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx create mode 100644 src/containers/Accounts/AccountAsset/test/HeldNFTs.test.tsx create mode 100644 src/containers/Accounts/AccountAsset/test/IssuedIOUs.test.tsx create mode 100644 src/containers/Accounts/AccountAsset/test/IssuedMPTs.test.tsx create mode 100644 src/containers/Accounts/AccountAsset/test/IssuedNFTs.test.tsx diff --git a/package.json b/package.json index 069e78d43..c2a6c81bc 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "@rollup/plugin-inject": "^5.0.5", "@vitejs/plugin-react": "^4.2.1", "@xrplf/isomorphic": "^1.0.0-beta.1", - "@xrplf/prettier-config": "^1.9.1", "assert": "^2.1.0", "autoprefixer": "^10.4.20", "axios": "^1.6.5", @@ -70,6 +69,7 @@ "@types/testing-library__jest-dom": "^5.14.9", "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", + "@xrplf/prettier-config": "^1.9.1", "babel-jest": "^29.7.0", "cheerio": "1.0.0-rc.3", "enzyme": "3.11.0", diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx index 8e0952e3f..ddcf90a39 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx @@ -125,6 +125,7 @@ const HeldMPTsContent = ({ accountId, onChange }: HeldMPTsProps) => { const heldMPTsQuery = useQuery(['heldMPTs', accountId], () => fetchAccountHeldMPTs(accountId, rippledSocket), ) + // TODO: When MPT Dex is live, sort MPTs based on USD balances const rows = heldMPTsQuery.data ?? [] // Communicate count and loading state back to parent diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx index 7608e0e5e..018e44701 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx @@ -84,6 +84,7 @@ const IssuedMPTsContent = ({ accountId, onChange }: IssuedMPTsProps) => { const issuedMPTsQuery = useQuery(['issuedMPTs', accountId], () => fetchAccountIssuedMPTs(accountId, rippledSocket), ) + // TODO: When MPT Dex is live, sort MPTs based on USD price const rows = issuedMPTsQuery.data ?? [] // Communicate count and loading state back to parent diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx index f3b040117..deaaea639 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -96,7 +96,7 @@ export const NFTTable = ({ : [] } catch (error: any) { if (error.code === 404 && error.message === 'notFound') { - log.warn(`No offers returned from ${fetchFn.name} for NFT ${nftId}`) + // This means there is no offer for the given NFT } else { log.error(error) } diff --git a/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx b/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx new file mode 100644 index 000000000..ac937710a --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx @@ -0,0 +1,572 @@ +import { + render, + screen, + waitFor, + cleanup, + fireEvent, +} from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import AccountAsset from '../index' +import { queryClient } from '../../../shared/QueryClient' +import { + getBalances, + getAccountLines, + getAccountInfo, + getAccountNFTs, + getAccountMPTs, + getAccountObjects, + getNFTsIssuedByAccount, +} from '../../../../rippled/lib/rippled' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/NFTTransactions') +jest.mock('../../../../rippled/lib/utils', () => ({ + formatTransferFee: jest.fn().mockReturnValue('1.00%'), +})) +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +global.fetch = jest.fn() as jest.Mock + +const mockedGetBalances = getBalances as Mock +const mockedGetAccountLines = getAccountLines as Mock +const mockedGetAccountInfo = getAccountInfo as Mock +const mockedGetAccountNFTs = getAccountNFTs as Mock +const mockedGetAccountMPTs = getAccountMPTs as Mock +const mockedGetAccountObjects = getAccountObjects as Mock +const mockedGetNFTsIssuedByAccount = getNFTsIssuedByAccount as Mock +const mockedFetch = fetch as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +describe('AccountAsset Component', () => { + beforeEach(() => { + jest.clearAllMocks() + queryClient.clear() + cleanup() + + // Default mock implementations - empty data + mockedGetBalances.mockResolvedValue([]) + mockedGetAccountLines.mockResolvedValue({ lines: [] }) + mockedGetAccountInfo.mockResolvedValue({ + account_data: { Account: 'rTest123', Flags: 0 }, + }) + mockedGetAccountNFTs.mockResolvedValue({ account_nfts: [] }) + mockedGetAccountMPTs.mockResolvedValue({ account_objects: [] }) + mockedGetAccountObjects.mockResolvedValue({ account_objects: [] }) + mockedGetNFTsIssuedByAccount.mockResolvedValue({ nfts: [] }) + mockedFetch.mockResolvedValue({ + ok: true, + json: async () => ({ holders: [] }), + } as Response) + }) + + describe('Rendering and Layout', () => { + it('renders both Assets Held and Assets Issued sections', async () => { + render( + + + , + ) + + expect(screen.getByText('Assets Held')).toBeInTheDocument() + expect(screen.getByText('Assets Issued')).toBeInTheDocument() + }) + + it('renders all held asset tabs', async () => { + render( + + + , + ) + + // Wait for tabs to render - use getAllByRole since there are multiple tabs with IOU/MPT/NFT + await waitFor(() => { + const allTabs = screen.getAllByRole('tab') + expect(allTabs.length).toBeGreaterThan(0) + }) + + // Check that the held tabs list contains expected tabs + const heldTabList = screen.getAllByRole('tablist')[0] + expect(heldTabList).toHaveTextContent('IOUs') + expect(heldTabList).toHaveTextContent('MPTs') + expect(heldTabList).toHaveTextContent('LP Tokens') + expect(heldTabList).toHaveTextContent('NFTs') + }) + + it('renders all issued asset tabs', async () => { + render( + + + , + ) + + // Wait for tabs to render + await waitFor(() => { + const allTabs = screen.getAllByRole('tab') + expect(allTabs.length).toBeGreaterThan(0) + }) + + // Check that the issued tabs list contains expected tabs + const issuedTabList = screen.getAllByRole('tablist')[1] + expect(issuedTabList).toHaveTextContent('IOUs') + expect(issuedTabList).toHaveTextContent('MPTs') + expect(issuedTabList).toHaveTextContent('NFTs') + }) + }) + + describe('Tab Navigation', () => { + it('switches between held asset tabs', async () => { + render( + + + , + ) + + await waitFor(() => { + expect(screen.getAllByRole('tablist').length).toBe(2) + }) + + // Find tabs using more specific queries + const heldTabList = screen.getAllByRole('tablist')[0] + const iouTab = heldTabList.querySelector('[aria-selected="true"]') + expect(iouTab).toHaveTextContent('IOUs') + + // Click MPT tab + const mptTab = heldTabList.querySelector('[title*="MPTs"]') as HTMLElement + fireEvent.click(mptTab) + + await waitFor(() => { + expect(mptTab).toHaveAttribute('aria-selected', 'true') + }) + }) + + it('switches between issued asset tabs', async () => { + render( + + + , + ) + + await waitFor(() => { + expect(screen.getAllByRole('tablist').length).toBe(2) + }) + + const issuedTabList = screen.getAllByRole('tablist')[1] + const iouTab = issuedTabList.querySelector('[aria-selected="true"]') + expect(iouTab).toHaveTextContent('IOUs') + + // Click NFT tab + const nftTab = issuedTabList.querySelector( + '[title*="NFTs"]', + ) as HTMLElement + fireEvent.click(nftTab) + + await waitFor(() => { + expect(nftTab).toHaveAttribute('aria-selected', 'true') + }) + }) + }) + + describe('Section Collapse/Expand', () => { + it('collapses and expands held section', async () => { + render( + + + , + ) + + // Find the collapse button for held section + const toggleButtons = screen.getAllByLabelText(/Toggle assets/i) + const heldToggle = toggleButtons[0] + + expect(heldToggle).toHaveAttribute('aria-expanded', 'true') + + // Collapse + fireEvent.click(heldToggle) + + await waitFor(() => { + expect(heldToggle).toHaveAttribute('aria-expanded', 'false') + }) + + // Expand + fireEvent.click(heldToggle) + + await waitFor(() => { + expect(heldToggle).toHaveAttribute('aria-expanded', 'true') + }) + }) + + it('collapses and expands issued section', async () => { + render( + + + , + ) + + const toggleButtons = screen.getAllByLabelText(/Toggle assets/i) + const issuedToggle = toggleButtons[1] + + expect(issuedToggle).toHaveAttribute('aria-expanded', 'true') + + // Collapse + fireEvent.click(issuedToggle) + + await waitFor(() => { + expect(issuedToggle).toHaveAttribute('aria-expanded', 'false') + }) + + // Expand + fireEvent.click(issuedToggle) + + await waitFor(() => { + expect(issuedToggle).toHaveAttribute('aria-expanded', 'true') + }) + }) + }) + + describe('Loading States', () => { + it('shows loading spinner in tabs while data is being fetched', async () => { + // Mock delayed responses + mockedGetBalances.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 100)), + ) + + render( + + + , + ) + + // Check for loading spinner + await waitFor(() => { + const loadingSpinners = screen.getAllByRole('tab') + const hasSpinner = loadingSpinners.some((tab) => + tab.querySelector('.loading-spinner'), + ) + expect(hasSpinner).toBe(true) + }) + + // Wait for loading to complete + await waitFor( + () => { + const tabs = screen.getAllByRole('tab') + const hasSpinner = tabs.some((tab) => + tab.querySelector('.loading-spinner'), + ) + expect(hasSpinner).toBe(false) + }, + { timeout: 3000 }, + ) + }) + }) + + describe('Asset Counts', () => { + // Note: Asset count tests are covered in individual component tests + // Here we just verify that counts are displayed in tabs + it('displays asset counts in tab labels', async () => { + render( + + + , + ) + + await waitFor(() => { + const allTabs = screen.getAllByRole('tab') + // Each tab should have a count in parentheses + allTabs.forEach((tab) => { + expect(tab.textContent).toMatch(/\(\d+\)/) + }) + }) + }) + }) + + describe('Component Integration', () => { + it('renders all held asset components simultaneously', async () => { + render( + + + , + ) + + // All components should call their APIs even if not visible + await waitFor(() => { + expect(mockedGetBalances).toHaveBeenCalled() + expect(mockedGetAccountMPTs).toHaveBeenCalled() + expect(mockedGetAccountNFTs).toHaveBeenCalled() + }) + }) + + it('renders all issued asset components simultaneously', async () => { + render( + + + , + ) + + // All issued components should call their APIs even if not visible + await waitFor(() => { + expect(mockedGetAccountObjects).toHaveBeenCalled() + expect(mockedGetNFTsIssuedByAccount).toHaveBeenCalled() + }) + }) + + it('passes accountId to all child components', async () => { + const accountId = 'rTestAccount123' + + render( + + + , + ) + + await waitFor(() => { + // getBalances is called by child components with socket as first param + expect(mockedGetBalances).toHaveBeenCalled() + expect(mockedGetAccountMPTs).toHaveBeenCalled() + expect(mockedGetAccountNFTs).toHaveBeenCalled() + }) + }) + + it('passes xrpToUSDRate to HeldLPTokens component', async () => { + const xrpToUSDRate = 1.25 + + render( + + + , + ) + + // The component should render with the rate + await waitFor(() => { + expect(mockedGetBalances).toHaveBeenCalled() + }) + }) + + it('passes account data to IssuedIOUs component', async () => { + const mockAccount = { + Account: 'rTest123', + Balance: '1000000', + Flags: 0, + } + + render( + + + , + ) + + await waitFor(() => { + expect(mockedGetBalances).toHaveBeenCalled() + }) + }) + }) + + describe('Tab Active States', () => { + it('has IOU tab active by default in held section', async () => { + render( + + + , + ) + + await waitFor(() => { + const heldTabList = screen.getAllByRole('tablist')[0] + const activeTab = heldTabList.querySelector('[aria-selected="true"]') + expect(activeTab).toHaveTextContent('IOUs') + }) + }) + + it('has IOU tab active by default in issued section', async () => { + render( + + + , + ) + + await waitFor(() => { + expect(screen.getAllByRole('tablist').length).toBe(2) + }) + + const issuedTabList = screen.getAllByRole('tablist')[1] + const activeTab = issuedTabList.querySelector('[aria-selected="true"]') + expect(activeTab).toHaveTextContent('IOUs') + }) + }) + + describe('Accessibility', () => { + it('has proper ARIA labels for tab lists', async () => { + render( + + + , + ) + + await waitFor(() => { + expect(screen.getAllByRole('tablist').length).toBe(2) + }) + + const tabLists = screen.getAllByRole('tablist') + expect(tabLists[0]).toHaveAttribute('aria-label', 'Assets Held Tabs') + expect(tabLists[1]).toHaveAttribute('aria-label', 'Assets Issued Tabs') + }) + + it('has proper ARIA attributes on tabs', async () => { + render( + + + , + ) + + await waitFor(() => { + const tabs = screen.getAllByRole('tab') + expect(tabs.length).toBeGreaterThan(0) + }) + + const tabs = screen.getAllByRole('tab') + tabs.forEach((tab) => { + expect(tab).toHaveAttribute('aria-selected') + expect(tab).toHaveAttribute('role', 'tab') + }) + }) + + it('has proper aria-expanded on toggle buttons', async () => { + render( + + + , + ) + + const toggleButtons = screen.getAllByLabelText(/Toggle assets/i) + expect(toggleButtons).toHaveLength(2) + + toggleButtons.forEach((button) => { + expect(button).toHaveAttribute('aria-expanded') + }) + }) + }) + + describe('Number Localization', () => { + it('displays counts with proper formatting', async () => { + render( + + + , + ) + + // Check that counts appear in a consistent format + await waitFor(() => { + const allTabs = screen.getAllByRole('tab') + // All tabs should show count in format: "Label (N)" + allTabs.forEach((tab) => { + expect(tab.textContent).toMatch(/^.+\s+\(\d+\)$/) + }) + }) + }) + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx new file mode 100644 index 000000000..6d3475b7f --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/HeldIOUs.test.tsx @@ -0,0 +1,367 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { HeldIOUs } from '../assetTables/HeldIOUs' +import { + getBalances, + getAccountLines, + getAccountInfo, +} from '../../../../rippled/lib/rippled' +import { formatTransferFee } from '../../../../rippled/lib/utils' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/utils') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +global.fetch = jest.fn() as jest.Mock + +const mockedGetBalances = getBalances as Mock +const mockedGetAccountLines = getAccountLines as Mock +const mockedGetAccountInfo = getAccountInfo as Mock +const mockedFormatTransferFee = formatTransferFee as Mock +const mockedFetch = fetch as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +const mockBalancesResponse = { + assets: { + rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH: [ + { currency: 'USD', value: '100' }, + { currency: 'EUR', value: '50' }, + ], + rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B: [{ currency: 'BTC', value: '0.001' }], + rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w: [ + { + currency: + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + value: '1000', + }, // LP Token + ], + }, +} + +const mockAccountLinesResponse = { + lines: [ + { + currency: 'USD', + account: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + balance: '100.50', + freeze: false, + freeze_peer: false, + }, + { + currency: 'EUR', + account: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + balance: '50.25', + freeze: false, + freeze_peer: false, + }, + { + currency: 'BTC', + account: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + balance: '0.001', + freeze: true, + freeze_peer: false, + }, + { + currency: + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + account: 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w', + balance: '1000', + freeze: false, + freeze_peer: false, + }, + ], + marker: '', +} + +const mockLOSTokenResponse = { + tokens: [ + { + currency: 'USD', + issuer_account: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + icon: 'https://example.com/usd-icon.png', + issuer_name: 'Gatehub', + price_usd: '1.00', + asset_class: 'Currency', + }, + { + currency: 'EUR', + issuer_account: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + icon: 'https://example.com/eur-icon.png', + issuer_name: 'Gatehub', + price_usd: '1.10', + asset_class: 'Currency', + }, + { + currency: 'BTC', + issuer_account: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + issuer_name: 'Bitstamp', + price_usd: '45000.00', + asset_class: 'Cryptocurrency', + }, + ], +} + +const mockAccountInfo = { + TransferRate: 1010000000, // 1% transfer fee + Flags: 0, + AMMID: undefined, +} + +describe('HeldIOUs', () => { + afterEach(() => { + cleanup() + queryClient.clear() // Clear React Query cache between tests + }) + + // Helper function to verify column headers + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Currency Code')).toBeInTheDocument() + }) + expect(screen.getByText('Issuer')).toBeInTheDocument() + expect(screen.getByText('Price (USD)')).toBeInTheDocument() + expect(screen.getByText('Balance')).toBeInTheDocument() + expect(screen.getByText('Balance (USD)')).toBeInTheDocument() + expect(screen.getByText('Asset Class')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Frozen')).toBeInTheDocument() + } + + it('renders empty state when no IOUs are held', async () => { + // Override the default mocks to return truly empty data + mockedGetBalances.mockResolvedValueOnce({ assets: {} }) + mockedGetAccountLines.mockResolvedValueOnce({ lines: [], marker: '' }) + + // Also mock the LOS token fetch to return empty tokens + mockedFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ tokens: [] }), + } as Response) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed (this is the translated text) + await waitFor(() => { + expect(screen.getByText('No IOUs found')).toBeInTheDocument() + }) + }) + + beforeEach(() => { + jest.clearAllMocks() + process.env.VITE_LOS_URL = 'https://api.los.example.com' + + mockedFormatTransferFee.mockReturnValue('1.0%') + mockedGetBalances.mockResolvedValue(mockBalancesResponse) + mockedGetAccountLines.mockResolvedValue(mockAccountLinesResponse) + + // Set up getAccountInfo mock to handle different account types + mockedGetAccountInfo.mockImplementation((_, accountId) => { + if (accountId === 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w') { + // LP token issuer - make it an AMM account by default to exclude LP tokens + return Promise.resolve({ + TransferRate: undefined, + Flags: 0, + AMMID: 'AMMID123456', + }) + } + // Regular accounts + return Promise.resolve(mockAccountInfo) + }) + + mockedFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockLOSTokenResponse), + } as Response) + }) + + it('shows all tokens including token starting with `03` whose issuer is not an AMM account', async () => { + // Override the mock to make `03` token issuer NOT an AMM account + mockedGetAccountInfo.mockImplementation((_, accountId) => { + if (accountId === 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w') { + // NOT an AMM account (no AMMID) + return Promise.resolve({ + TransferRate: 1020000000, // 2% transfer fee + Flags: 0x00400000, // lsfGlobalFreeze + AMMID: undefined, // This makes it NOT an AMM account + }) + } + // Regular accounts + return Promise.resolve(mockAccountInfo) + }) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for tokens to load + await waitFor(() => { + expect(screen.getByText('USD')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 4 token rows (sorted by Balance USD descending) + expect(dataRows).toHaveLength(4) + + // Verify USD token data in first row (highest balance USD: $100.50) + const usdRow = dataRows[0] + expect(usdRow).toHaveTextContent('USD') + expect(usdRow).toHaveTextContent('Gatehub') + expect(usdRow).toHaveTextContent('$1.00') + expect(usdRow).toHaveTextContent('100.5') + expect(usdRow).toHaveTextContent('$100.50') + expect(usdRow).toHaveTextContent('Currency') + // Initial state shows -- for both transfer fee and frozen (no trustline freeze) + const usdPlaceholders = usdRow.textContent?.match(/--/g) || [] + expect(usdPlaceholders.length).toBe(2) + + // Verify EUR token data in second row (second highest: $55.28) + const eurRow = dataRows[1] + expect(eurRow).toHaveTextContent('EUR') + expect(eurRow).toHaveTextContent('Gatehub') + expect(eurRow).toHaveTextContent('$1.10') + expect(eurRow).toHaveTextContent('50.25') + expect(eurRow).toHaveTextContent('$55.28') + expect(eurRow).toHaveTextContent('Currency') + // Initial state shows -- for both transfer fee and frozen (no trustline freeze) + const eurPlaceholders = eurRow.textContent?.match(/--/g) || [] + expect(eurPlaceholders.length).toBe(2) + + // Verify BTC token data in third row (third highest: $45.00) + const btcRow = dataRows[2] + expect(btcRow).toHaveTextContent('BTC') + expect(btcRow).toHaveTextContent('Bitstamp') + expect(btcRow).toHaveTextContent('$45,000.00') + expect(btcRow).toHaveTextContent('0.001') + expect(btcRow).toHaveTextContent('$45.00') + expect(btcRow).toHaveTextContent('Cryptocurrency') + // BTC has trustline freeze in the account lines + expect(btcRow).toHaveTextContent('Trustline') + // Only 1 placeholder for transfer fee (frozen shows Trustline, not --) + const btcPlaceholders = btcRow.textContent?.match(/--/g) || [] + expect(btcPlaceholders.length).toBe(1) + + // Verify `03` token data in fourth row (no Balance USD info, missing LOS data) + const lpTokenRow = dataRows[3] + expect(lpTokenRow).toHaveTextContent( + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + ) + expect(lpTokenRow).toHaveTextContent('rLNaPoK...4dc6w') + expect(lpTokenRow).toHaveTextContent('1,000') + + // Verify placeholders for missing data (no LOS token info for LP token) + // Initial state: Price USD, Balance USD, Asset Class, Transfer Fee, Frozen all show -- + const lpTokenPlaceholders = lpTokenRow.textContent?.match(/--/g) || [] + expect(lpTokenPlaceholders.length).toBe(5) + }) + + it('excludes LP tokens whose issuer is an AMM account', async () => { + // Use the default mock setup where LP token issuer IS an AMM account (from beforeEach) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for tokens to load + await waitFor(() => { + expect(screen.getByText('USD')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 token rows (LP token excluded) + expect(dataRows).toHaveLength(3) + + // Verify USD token data in first row (highest balance USD: $100.50) + const usdRow = dataRows[0] + expect(usdRow).toHaveTextContent('USD') + expect(usdRow).toHaveTextContent('Gatehub') + expect(usdRow).toHaveTextContent('$1.00') + expect(usdRow).toHaveTextContent('100.5') + expect(usdRow).toHaveTextContent('$100.50') + expect(usdRow).toHaveTextContent('Currency') + const usdPlaceholders = usdRow.textContent?.match(/--/g) || [] + expect(usdPlaceholders.length).toBe(2) + + // Verify EUR token data in second row (second highest: $55.28) + const eurRow = dataRows[1] + expect(eurRow).toHaveTextContent('EUR') + expect(eurRow).toHaveTextContent('Gatehub') + expect(eurRow).toHaveTextContent('$1.10') + expect(eurRow).toHaveTextContent('50.25') + expect(eurRow).toHaveTextContent('$55.28') + expect(eurRow).toHaveTextContent('Currency') + const eurPlaceholders = eurRow.textContent?.match(/--/g) || [] + expect(eurPlaceholders.length).toBe(2) + + // Verify BTC token data in third row (third highest: $45.00) + const btcRow = dataRows[2] + expect(btcRow).toHaveTextContent('BTC') + expect(btcRow).toHaveTextContent('Bitstamp') + expect(btcRow).toHaveTextContent('$45,000.00') + expect(btcRow).toHaveTextContent('0.001') + expect(btcRow).toHaveTextContent('$45.00') + expect(btcRow).toHaveTextContent('Cryptocurrency') + expect(btcRow).toHaveTextContent('Trustline') + const btcPlaceholders = btcRow.textContent?.match(/--/g) || [] + expect(btcPlaceholders.length).toBe(1) + + // Verify LP token is NOT displayed + expect( + screen.queryByText( + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + ), + ).not.toBeInTheDocument() + expect(screen.queryByText('rLNaPoK...4dc6w')).not.toBeInTheDocument() + expect(screen.queryByText('1,000')).not.toBeInTheDocument() + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/HeldLPTokens.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldLPTokens.test.tsx new file mode 100644 index 000000000..8459e209b --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/HeldLPTokens.test.tsx @@ -0,0 +1,220 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { HeldLPTokens } from '../assetTables/HeldLPTokens' +import { + getBalances, + getAMMInfoByAMMAccount, +} from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetBalances = getBalances as Mock +const mockedGetAMMInfoByAMMAccount = getAMMInfoByAMMAccount as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +// Mock data for LP token balances (with LP token identifier prefix '03') +const mockBalancesResponseWithLPTokens = { + assets: { + rp9E3FN9YAJjc7xrr8eTTYVpBheop35uoxu8vM: [ + { + currency: + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + value: '1000.5', + }, + ], + rDMNE7xaqxZ6YqPMJJV6sJ7Bw5UjT3nT6vd4XW: [ + { + currency: + '03BD9C0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C888888', + value: '500.25', + }, + ], + }, +} + +// Mock empty balances response +const mockEmptyBalancesResponse = { + assets: {}, +} + +// Mock AMM info responses +const mockAMMInfoXRP = { + amm: { + amount: '50000000000', // 50,000 XRP (in drops) + amount2: { + currency: 'USD', + issuer: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + value: '50000', + }, + lp_token: { + currency: + '03AD8B0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C999999', + issuer: 'rp9E3FN9YAJjc7xrr8eTTYVpBheop35uoxu8vM', + value: '10000', + }, + }, +} + +const mockAMMInfoTokens = { + amm: { + amount: { + currency: 'USD', + issuer: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + value: '25000', + }, + amount2: { + currency: 'EUR', + issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + value: '22727.27', + }, + lp_token: { + currency: + '03BD9C0558D3C1FC1E7B1C0A0DB0C88D904D500FFE68DE154997F9CC9C888888', + issuer: 'rDMNE7xaqxZ6YqPMJJV6sJ7Bw5UjT3nT6vd4XW', + value: '5000', + }, + }, +} + +describe('HeldLPTokens', () => { + const mockXRPToUSDRate = 0.5 // 1 XRP = $0.50 + + // Helper function to verify column headers + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('AMM Instance')).toBeInTheDocument() + }) + expect(screen.getByText('AMM Pair')).toBeInTheDocument() + expect(screen.getByText('Balance')).toBeInTheDocument() + expect(screen.getByText('Balance (USD)')).toBeInTheDocument() + expect(screen.getByText('Share (%)')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() // Clear React Query cache between tests + }) + + beforeEach(() => { + jest.clearAllMocks() + + mockedGetBalances.mockResolvedValue(mockBalancesResponseWithLPTokens) + + // Set up getAMMInfoByAMMAccount mock to handle different AMM accounts + mockedGetAMMInfoByAMMAccount.mockImplementation((_, ammAccount) => { + if (ammAccount === 'rp9E3FN9YAJjc7xrr8eTTYVpBheop35uoxu8vM') { + return Promise.resolve(mockAMMInfoXRP) + } + if (ammAccount === 'rDMNE7xaqxZ6YqPMJJV6sJ7Bw5UjT3nT6vd4XW') { + return Promise.resolve(mockAMMInfoTokens) + } + return Promise.reject(new Error('AMM not found')) + }) + }) + + it('renders empty state when no LP tokens are held', async () => { + // Override the default mock to return empty assets + mockedGetBalances.mockResolvedValueOnce(mockEmptyBalancesResponse) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed (this is the translated text) + await waitFor(() => { + expect(screen.getByText('No LP Tokens found')).toBeInTheDocument() + }) + }) + + it('handles error when getBalances fails', async () => { + // Mock getBalances to throw an error + mockedGetBalances.mockRejectedValueOnce(new Error('Network error')) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No LP Tokens found')).toBeInTheDocument() + }) + }) + + it('shows two LP tokens with XRP pair and token pair', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for LP tokens to load + await waitFor(() => { + expect(screen.getByText('rp9E3FN...xu8vM')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 2 LP token rows (XRP pair first, then token pair) + expect(dataRows).toHaveLength(2) + + // Verify first LP token (XRP/USD pair) + const xrpUsdRow = dataRows[0] + expect(xrpUsdRow).toHaveTextContent('rp9E3FN...xu8vM') // AMM instance + expect(xrpUsdRow.textContent).toMatch(/XRP\/USD/) // AMM pair + expect(xrpUsdRow).toHaveTextContent('1,000.5') // Balance + expect(xrpUsdRow).toHaveTextContent('$5,002.50') // Balance USD + expect(xrpUsdRow.textContent).toMatch(/10\.01/) // Share % + + // Verify second LP token (USD/EUR pair) + const usdEurRow = dataRows[1] + expect(usdEurRow).toHaveTextContent('rDMNE7x...vd4XW') // AMM instance + expect(usdEurRow.textContent).toMatch(/USD\/EUR/) // AMM pair + expect(usdEurRow).toHaveTextContent('500.25') // Balance + expect(usdEurRow).toHaveTextContent('--') // Balance USD (no XRP in pair) + expect(usdEurRow.textContent).toMatch(/10\.01/) // Share % + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx new file mode 100644 index 000000000..f6d729fc8 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx @@ -0,0 +1,225 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { HeldMPTs } from '../assetTables/HeldMPTs' +import { + getAccountMPTs, + getMPTIssuance, +} from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetAccountMPTs = getAccountMPTs as Mock +const mockedGetMPTIssuance = getMPTIssuance as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +const mockMPTsResponse = { + account_objects: [ + { + MPTokenIssuanceID: '000004C463C52827307480341125DA65C267105D00000001', + MPTAmount: '1000000', + Flags: 1, // lsfMPTLocked + }, + { + MPTokenIssuanceID: '000004C463C52827307480341125DA65C267105D00000002', + MPTAmount: '500000', + Flags: 0, + }, + { + MPTokenIssuanceID: '000004C463C52827307480341125DA65C267105D00000003', + MPTAmount: '250000', + Flags: 0, + }, + ], + marker: '', +} + +const mockMPTIssuanceResponses = { + '000004C463C52827307480341125DA65C267105D00000001': { + node: { + Issuer: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + TransferFee: 5000, + Flags: 0, + MPTokenMetadata: { + Ticker: 'USD', + IssuerName: 'Gatehub', + AssetClass: 'Stablecoin', + }, + }, + }, + '000004C463C52827307480341125DA65C267105D00000002': { + node: { + Issuer: 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w', + TransferFee: 10000, + Flags: 1, // lsfMPTLocked (Global) + MPTokenMetadata: { + Ticker: 'EUR', + IssuerName: 'Bitstamp', + AssetClass: 'Currency', + }, + }, + }, + '000004C463C52827307480341125DA65C267105D00000003': { + node: { + Issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + TransferFee: 15000, + Flags: 0, + MPTokenMetadata: { + Ticker: 'BTC', + IssuerName: 'Kraken', + AssetClass: 'Cryptocurrency', + }, + }, + }, +} + +describe('HeldMPTs', () => { + // Helper function to verify all column headers are displayed + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Token ID')).toBeInTheDocument() + }) + expect(screen.getByText('Ticker')).toBeInTheDocument() + expect(screen.getByText('Issuer')).toBeInTheDocument() + expect(screen.getByText('Price (USD)')).toBeInTheDocument() + expect(screen.getByText('Balance')).toBeInTheDocument() + expect(screen.getByText('Balance (USD)')).toBeInTheDocument() + expect(screen.getByText('Asset Class')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Locked')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() + }) + + beforeEach(() => { + jest.clearAllMocks() + + mockedGetAccountMPTs.mockResolvedValue(mockMPTsResponse) + mockedGetMPTIssuance.mockImplementation((_, mptIssuanceId) => { + const response = + mockMPTIssuanceResponses[ + mptIssuanceId as keyof typeof mockMPTIssuanceResponses + ] + return Promise.resolve(response) + }) + }) + + it('renders empty state when no MPTs are held', async () => { + mockedGetAccountMPTs.mockResolvedValueOnce({ account_objects: [] }) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed + await waitFor(() => { + expect(screen.getByText('No MPTs found')).toBeInTheDocument() + }) + }) + + it('handles error when getAccountMPTs fails', async () => { + mockedGetAccountMPTs.mockRejectedValueOnce(new Error('Network error')) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No MPTs found')).toBeInTheDocument() + }) + }) + + it('shows 3 held MPTs with all column values', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for MPTs to load + await waitFor(() => { + expect(screen.getByText('000004C463...5D00000001')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 MPT rows + expect(dataRows).toHaveLength(3) + + // Verify USD MPT data in first row + const usdRow = dataRows[0] + expect(usdRow).toHaveTextContent('000004C463...5D00000001') + expect(usdRow).toHaveTextContent('rN7n7ot...6fzRH') // issuer + expect(usdRow).toHaveTextContent('1,000,000') // balance + expect(usdRow).toHaveTextContent('5%') // transfer fee + expect(usdRow).toHaveTextContent('Individual') // locked status + + // Verify EUR MPT data in second row + const eurRow = dataRows[1] + expect(eurRow).toHaveTextContent('000004C463...5D00000002') + expect(eurRow).toHaveTextContent('rLNaPoK...4dc6w') // issuer + expect(eurRow).toHaveTextContent('500,000') // balance + expect(eurRow).toHaveTextContent('10%') // transfer fee + expect(eurRow).toHaveTextContent('Global') // locked status + + // Verify BTC MPT data in third row + const btcRow = dataRows[2] + expect(btcRow).toHaveTextContent('000004C463...5D00000003') + expect(btcRow).toHaveTextContent('rvYAfWj...bs59B') // issuer + expect(btcRow).toHaveTextContent('250,000') // balance + expect(btcRow).toHaveTextContent('15%') // transfer fee + expect(btcRow).toHaveTextContent('--') // not locked + + // Verify FutureDataIcon appears for Ticker, Price (USD), Balance (USD), and Asset Class columns + // Each MPT has 4 FutureDataIcons (ticker, price USD, balance USD, asset class) = 3 MPTs * 4 icons = 12 total + const futureDataIcons = document.querySelectorAll('.future-data') + expect(futureDataIcons).toHaveLength(12) + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/HeldNFTs.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldNFTs.test.tsx new file mode 100644 index 000000000..e61e5f2aa --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/HeldNFTs.test.tsx @@ -0,0 +1,244 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { HeldNFTs } from '../assetTables/HeldNFTs' +import { + getAccountNFTs, + getBuyNFToffers, + getSellNFToffers, +} from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetAccountNFTs = getAccountNFTs as Mock +const mockedGetBuyNFToffers = getBuyNFToffers as Mock +const mockedGetSellNFToffers = getSellNFToffers as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +// Mock NFT data +const mockNFTsResponse = { + account_nfts: [ + { + NFTokenID: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000001', + Issuer: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + URI: Buffer.from('https://example.com/nft1').toString('hex'), + TransferFee: 5000, // 5% + }, + { + NFTokenID: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000002', + Issuer: 'rLNaPoKeeBjZe2qs6x52yVPZpZ8td4dc6w', + URI: Buffer.from('https://example.com/nft2').toString('hex'), + TransferFee: 10000, // 10% + }, + { + NFTokenID: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000003', + Issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + URI: '', // No URI + TransferFee: 15000, // 15% + }, + ], + marker: '', +} + +// Mock offer data (XRP only - in drops, 1 XRP = 1,000,000 drops) +const mockSellOffersNFT1 = { + offers: [ + { amount: '5000000' }, // 5 XRP + { amount: '6000000' }, // 6 XRP + ], +} + +const mockBuyOffersNFT1 = { + offers: [ + { amount: '4500000' }, // 4.5 XRP + { amount: '4000000' }, // 4 XRP + ], +} + +const mockSellOffersNFT2 = { + offers: [ + { amount: '10000000' }, // 10 XRP + ], +} + +const mockBuyOffersNFT2 = { + offers: [ + { amount: '9500000' }, // 9.5 XRP + ], +} + +// NFT3 has no offers +const mockNoOffers = { + offers: [], +} + +describe('HeldNFTs', () => { + // Helper function to verify all column headers are displayed + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Token ID')).toBeInTheDocument() + }) + expect(screen.getByText('Issuer')).toBeInTheDocument() + expect(screen.getByText('URL')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Lowest Ask')).toBeInTheDocument() + expect(screen.getByText('Highest Bid')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() + }) + + beforeEach(() => { + jest.clearAllMocks() + mockedGetAccountNFTs.mockResolvedValue(mockNFTsResponse) + mockedGetBuyNFToffers.mockImplementation( + (_rippledSocket: any, nftId: string) => { + if (nftId === mockNFTsResponse.account_nfts[0].NFTokenID) { + return Promise.resolve(mockBuyOffersNFT1) + } + if (nftId === mockNFTsResponse.account_nfts[1].NFTokenID) { + return Promise.resolve(mockBuyOffersNFT2) + } + return Promise.resolve(mockNoOffers) + }, + ) + mockedGetSellNFToffers.mockImplementation( + (_rippledSocket: any, nftId: string) => { + if (nftId === mockNFTsResponse.account_nfts[0].NFTokenID) { + return Promise.resolve(mockSellOffersNFT1) + } + if (nftId === mockNFTsResponse.account_nfts[1].NFTokenID) { + return Promise.resolve(mockSellOffersNFT2) + } + return Promise.resolve(mockNoOffers) + }, + ) + }) + + it('renders empty state when no NFTs are held', async () => { + mockedGetAccountNFTs.mockResolvedValueOnce({ account_nfts: [] }) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed + await waitFor(() => { + expect(screen.getByText('No NFTs found')).toBeInTheDocument() + }) + }) + + it('handles error when getAccountNFTs fails', async () => { + mockedGetAccountNFTs.mockRejectedValueOnce(new Error('Network error')) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No NFTs found')).toBeInTheDocument() + }) + }) + + it('shows 3 held NFTs with all column values', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for NFTs to load + await waitFor(() => { + expect(screen.getByText('00081388F2...C000000001')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 NFT rows + expect(dataRows).toHaveLength(3) + + // Verify NFT1 data in first row + const nft1Row = dataRows[0] + expect(nft1Row).toHaveTextContent('00081388F2...C000000001') + expect(nft1Row).toHaveTextContent('rN7n7ot...6fzRH') + expect(nft1Row).toHaveTextContent('https://example.com/nft1') + expect(nft1Row).toHaveTextContent('5%') + + // Verify NFT2 data in second row + const nft2Row = dataRows[1] + expect(nft2Row).toHaveTextContent('00081388F2...C000000002') + expect(nft2Row).toHaveTextContent('rLNaPoK...4dc6w') + expect(nft2Row).toHaveTextContent('https://example.com/nft2') + expect(nft2Row).toHaveTextContent('10%') + + // Verify NFT3 data in third row + const nft3Row = dataRows[2] + expect(nft3Row).toHaveTextContent('00081388F2...C000000003') + expect(nft3Row).toHaveTextContent('rvYAfWj...bs59B') + expect(nft3Row).toHaveTextContent('15%') + + // Verify offer data (async loaded) + // XRP symbol is \uE900, offers are formatted with localizeNumber with currency: 'XRP' + await waitFor(() => { + expect(nft1Row.textContent).toContain('\uE9005.00') // Lowest Ask for NFT1 + }) + expect(nft1Row.textContent).toContain('\uE9004.50') // Highest Bid for NFT1 + + // NFT2 offers + expect(nft2Row.textContent).toContain('\uE90010.00') // Lowest Ask for NFT2 + expect(nft2Row.textContent).toContain('\uE9009.50') // Highest Bid for NFT2 + + // NFT3: No offers, should show -- + const nft3Placeholders = nft3Row.textContent?.match(/--/g) || [] + expect(nft3Placeholders.length).toBeGreaterThanOrEqual(2) // At least 2: Lowest Ask and Highest Bid (no URL is also --) + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/IssuedIOUs.test.tsx b/src/containers/Accounts/AccountAsset/test/IssuedIOUs.test.tsx new file mode 100644 index 000000000..74233817d --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/IssuedIOUs.test.tsx @@ -0,0 +1,219 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { IssuedIOUs } from '../assetTables/IssuedIOUs' +import { getBalances } from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +global.fetch = jest.fn() as jest.Mock + +const mockedGetBalances = getBalances as Mock +const mockedFetch = fetch as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +// Mock data for issued tokens (obligations) +const mockBalancesResponse = { + obligations: { + USD: '1000000.50', + EUR: '500000.25', + BTC: '50.001', + }, +} + +// Mock LOS Token API response for issued tokens +const mockLOSTokenResponse = { + tokens: [ + { + currency: 'USD', + issuer_account: 'rTest123', + icon: 'https://example.com/usd-icon.png', + price_usd: '1.00', + number_of_trustlines: 15000, + number_of_holders: 12500, + supply: '1000000.50', + // Missing asset_class to test placeholder + }, + { + currency: 'EUR', + issuer_account: 'rTest123', + icon: 'https://example.com/eur-icon.png', + price_usd: '1.10', + number_of_trustlines: 8500, + number_of_holders: 7200, + supply: '500000.25', + asset_class: 'Currency', + }, + { + currency: 'BTC', + issuer_account: 'rTest123', + price_usd: '45000.00', + number_of_trustlines: 2500, + number_of_holders: 2100, + supply: '50.001', + asset_class: 'Cryptocurrency', + }, + ], +} + +// Mock account with transfer fee and global freeze +const mockAccount = { + info: { + rate: 1.5, // 1.5% transfer fee + flags: ['lsfGlobalFreeze'], + }, +} + +describe('IssuedIOUs', () => { + // Helper function to verify all column headers are displayed + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Currency Code')).toBeInTheDocument() + }) + expect(screen.getByText('Price (USD)')).toBeInTheDocument() + expect(screen.getByText('Trustlines')).toBeInTheDocument() + expect(screen.getByText('Holders')).toBeInTheDocument() + expect(screen.getByText('Supply')).toBeInTheDocument() + expect(screen.getByText('Asset Class')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Frozen')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() // Clear React Query cache between tests + }) + + beforeEach(() => { + jest.clearAllMocks() + process.env.VITE_LOS_URL = 'https://api.los.example.com' + + mockedGetBalances.mockResolvedValue(mockBalancesResponse) + mockedFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockLOSTokenResponse), + } as Response) + }) + + it('handles error when getBalances fails', async () => { + // Mock getBalances to throw an error + mockedGetBalances.mockRejectedValueOnce(new Error('Network error')) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No IOUs found')).toBeInTheDocument() + }) + }) + + it('renders empty state when no IOUs are issued', async () => { + // Override the default mocks to return empty obligations + mockedGetBalances.mockResolvedValueOnce({ obligations: {} }) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed (this is the translated text) + await waitFor(() => { + expect(screen.getByText('No IOUs found')).toBeInTheDocument() + }) + }) + + it('shows 3 issued tokens with all column values', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for tokens to load + await waitFor(() => { + expect(screen.getByText('BTC')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 token rows (sorted by Price USD descending) + expect(dataRows).toHaveLength(3) + + // Verify BTC token data in first row (highest price: $45,000.00) + const btcRow = dataRows[0] + expect(btcRow).toHaveTextContent('BTC') + expect(btcRow).toHaveTextContent('$45,000.00') // price + expect(btcRow).toHaveTextContent('2,500') // trustlines + expect(btcRow).toHaveTextContent('2,100') // holders + expect(btcRow).toHaveTextContent('50.001') // supply + expect(btcRow).toHaveTextContent('Cryptocurrency') // asset class + expect(btcRow).toHaveTextContent('1.5%') // transfer fee + expect(btcRow).toHaveTextContent('Global') // frozen + + // Verify EUR token data in second row (second highest: $1.10) + const eurRow = dataRows[1] + expect(eurRow).toHaveTextContent('EUR') + expect(eurRow).toHaveTextContent('$1.10') // price + expect(eurRow).toHaveTextContent('8,500') // trustlines + expect(eurRow).toHaveTextContent('7,200') // holders + expect(eurRow).toHaveTextContent('500,000.25') // supply + expect(eurRow).toHaveTextContent('Currency') // asset class + expect(eurRow).toHaveTextContent('1.5%') // transfer fee + expect(eurRow).toHaveTextContent('Global') // frozen + + // Verify USD token data in third row (lowest price: $1.00) + const usdRow = dataRows[2] + expect(usdRow).toHaveTextContent('USD') + expect(usdRow).toHaveTextContent('$1.00') // price + expect(usdRow).toHaveTextContent('15,000') // trustlines + expect(usdRow).toHaveTextContent('12,500') // holders + expect(usdRow).toHaveTextContent('1,000,000.5') // supply + expect(usdRow).toHaveTextContent('--') // asset class + expect(usdRow).toHaveTextContent('1.5%') // transfer fee + expect(usdRow).toHaveTextContent('Global') // frozen + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/IssuedMPTs.test.tsx b/src/containers/Accounts/AccountAsset/test/IssuedMPTs.test.tsx new file mode 100644 index 000000000..2b2c2ca95 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/IssuedMPTs.test.tsx @@ -0,0 +1,184 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { IssuedMPTs } from '../assetTables/IssuedMPTs' +import { getAccountObjects } from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetAccountObjects = getAccountObjects as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +const mockMPTIssuancesResponse = { + account_objects: [ + { + mpt_issuance_id: '000004C463C52827307480341125DA65C267105D00000001', + OutstandingAmount: '1000000', + TransferFee: 5000, + Flags: 0, + MPTokenMetadata: { + Ticker: 'USD', + AssetClass: 'Stablecoin', + }, + }, + { + mpt_issuance_id: '000004C463C52827307480341125DA65C267105D00000002', + OutstandingAmount: '500000', + TransferFee: 10000, + Flags: 1, // lsfMPTLocked + MPTokenMetadata: { + Ticker: 'EUR', + AssetClass: 'Currency', + }, + }, + { + mpt_issuance_id: '000004C463C52827307480341125DA65C267105D00000003', + OutstandingAmount: '250000', + TransferFee: 15000, + Flags: 0, + MPTokenMetadata: { + Ticker: 'BTC', + AssetClass: 'Cryptocurrency', + }, + }, + ], + marker: '', +} + +describe('IssuedMPTs', () => { + // Helper function to verify all column headers are displayed + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Token ID')).toBeInTheDocument() + }) + expect(screen.getByText('Ticker')).toBeInTheDocument() + expect(screen.getByText('Price (USD)')).toBeInTheDocument() + expect(screen.getByText('Supply')).toBeInTheDocument() + expect(screen.getByText('Asset Class')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Locked')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() + }) + + beforeEach(() => { + jest.clearAllMocks() + mockedGetAccountObjects.mockResolvedValue(mockMPTIssuancesResponse) + }) + + it('renders empty state when no MPTs are issued', async () => { + mockedGetAccountObjects.mockResolvedValueOnce({ account_objects: [] }) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Check that the empty message is displayed + await waitFor(() => { + expect(screen.getByText('No MPTs found')).toBeInTheDocument() + }) + }) + + it('handles error when getAccountObjects fails', async () => { + mockedGetAccountObjects.mockRejectedValueOnce(new Error('Network error')) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No MPTs found')).toBeInTheDocument() + }) + }) + + it('shows 3 issued MPTs with all column values', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Wait for MPTs to load + await waitFor(() => { + expect(screen.getByText('000004C463...5D00000001')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 MPT rows + expect(dataRows).toHaveLength(3) + + // Verify USD MPT data in first row + const usdRow = dataRows[0] + expect(usdRow).toHaveTextContent('000004C463...5D00000001') + expect(usdRow).toHaveTextContent('1,000,000') // supply + expect(usdRow).toHaveTextContent('5%') // transfer fee + expect(usdRow).toHaveTextContent('--') // not locked + + // Verify EUR MPT data in second row + const eurRow = dataRows[1] + expect(eurRow).toHaveTextContent('000004C463...5D00000002') + expect(eurRow).toHaveTextContent('500,000') // supply + expect(eurRow).toHaveTextContent('10%') // transfer fee + expect(eurRow).toHaveTextContent('Global') // locked + + // Verify BTC MPT data in third row + const btcRow = dataRows[2] + expect(btcRow).toHaveTextContent('000004C463...5D00000003') + expect(btcRow).toHaveTextContent('250,000') // supply + expect(btcRow).toHaveTextContent('15%') // transfer fee + expect(btcRow).toHaveTextContent('--') // not locked + + // Verify FutureDataIcon appears for Ticker, Price (USD), and Asset Class columns + // Each MPT has 3 FutureDataIcons (ticker, price, asset class) = 3 MPTs * 3 icons = 9 total + const futureDataIcons = document.querySelectorAll('.future-data') + expect(futureDataIcons).toHaveLength(9) + }) +}) diff --git a/src/containers/Accounts/AccountAsset/test/IssuedNFTs.test.tsx b/src/containers/Accounts/AccountAsset/test/IssuedNFTs.test.tsx new file mode 100644 index 000000000..6b3ae29a5 --- /dev/null +++ b/src/containers/Accounts/AccountAsset/test/IssuedNFTs.test.tsx @@ -0,0 +1,242 @@ +import { render, screen, cleanup, waitFor } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { IssuedNFTs } from '../assetTables/IssuedNFTs' +import { + getNFTsIssuedByAccount, + getBuyNFToffers, + getSellNFToffers, +} from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetNFTsIssuedByAccount = getNFTsIssuedByAccount as Mock +const mockedGetBuyNFToffers = getBuyNFToffers as Mock +const mockedGetSellNFToffers = getSellNFToffers as Mock + +// Mock socket client +const mockSocket = {} as any + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +// Mock NFT data +const mockIssuedNFTsResponse = { + nfts: [ + { + nft_id: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000001', + uri: Buffer.from('https://example.com/issued-nft1').toString('hex'), + transfer_fee: 2500, // 2.5% + }, + { + nft_id: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000002', + uri: Buffer.from('https://example.com/issued-nft2').toString('hex'), + transfer_fee: 7500, // 7.5% + }, + { + nft_id: + '00081388F2C3F5B05AD6C8C0F5C0F5C0F5C0F5C0F5C0F5C0F5C0F5C000000003', + uri: '', // No URI + transfer_fee: 12500, // 12.5% + }, + ], + marker: '', +} + +// Mock offer data (XRP only - in drops, 1 XRP = 1,000,000 drops) +const mockSellOffersNFT1 = { + offers: [ + { amount: '3000000' }, // 3 XRP + { amount: '3500000' }, // 3.5 XRP + ], +} + +const mockBuyOffersNFT1 = { + offers: [ + { amount: '2800000' }, // 2.8 XRP + { amount: '2500000' }, // 2.5 XRP + ], +} + +const mockSellOffersNFT2 = { + offers: [ + { amount: '8000000' }, // 8 XRP + ], +} + +const mockBuyOffersNFT2 = { + offers: [ + { amount: '7500000' }, // 7.5 XRP + ], +} + +// NFT3 has no offers +const mockNoOffers = { + offers: [], +} + +describe('IssuedNFTs', () => { + // Helper function to verify all column headers are displayed + const verifyColumnHeaders = async () => { + await waitFor(() => { + expect(screen.getByText('Token ID')).toBeInTheDocument() + }) + expect(screen.getByText('URL')).toBeInTheDocument() + expect(screen.getByText('Transfer Fee')).toBeInTheDocument() + expect(screen.getByText('Lowest Ask')).toBeInTheDocument() + expect(screen.getByText('Highest Bid')).toBeInTheDocument() + } + + afterEach(() => { + cleanup() + queryClient.clear() + }) + + beforeEach(() => { + jest.clearAllMocks() + mockedGetNFTsIssuedByAccount.mockResolvedValue(mockIssuedNFTsResponse) + mockedGetBuyNFToffers.mockImplementation( + (_rippledSocket: any, nftId: string) => { + if (nftId === mockIssuedNFTsResponse.nfts[0].nft_id) { + return Promise.resolve(mockBuyOffersNFT1) + } + if (nftId === mockIssuedNFTsResponse.nfts[1].nft_id) { + return Promise.resolve(mockBuyOffersNFT2) + } + return Promise.resolve(mockNoOffers) + }, + ) + mockedGetSellNFToffers.mockImplementation( + (_rippledSocket: any, nftId: string) => { + if (nftId === mockIssuedNFTsResponse.nfts[0].nft_id) { + return Promise.resolve(mockSellOffersNFT1) + } + if (nftId === mockIssuedNFTsResponse.nfts[1].nft_id) { + return Promise.resolve(mockSellOffersNFT2) + } + return Promise.resolve(mockNoOffers) + }, + ) + }) + + it('renders empty state when no NFTs are issued', async () => { + mockedGetNFTsIssuedByAccount.mockResolvedValueOnce({ nfts: [] }) + + render( + + + , + ) + + // Verify all column headers are displayed (no Issuer column for issued NFTs) + await verifyColumnHeaders() + + // Check that the empty message is displayed + await waitFor(() => { + expect(screen.getByText('No NFTs found')).toBeInTheDocument() + }) + }) + + it('handles error when getNFTsIssuedByAccount fails', async () => { + mockedGetNFTsIssuedByAccount.mockRejectedValueOnce( + new Error('Network error'), + ) + + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Should show empty state when error occurs + await waitFor(() => { + expect(screen.getByText('No NFTs found')).toBeInTheDocument() + }) + }) + + it('shows 3 issued NFTs with all column values', async () => { + render( + + + , + ) + + // Verify all column headers are displayed + await verifyColumnHeaders() + + // Verify "Issuer" column is NOT displayed for issued NFTs (showIssuer=false) + expect(screen.queryByText('Issuer')).not.toBeInTheDocument() + + // Wait for NFTs to load + await waitFor(() => { + expect(screen.getByText('00081388F2...C000000001')).toBeInTheDocument() + }) + + // Get all table rows (excluding header) + const rows = screen.getAllByRole('row') + const dataRows = rows.slice(1) // Skip header row + + // Verify we have 3 NFT rows + expect(dataRows).toHaveLength(3) + + // Verify NFT1 data in first row + const nft1Row = dataRows[0] + expect(nft1Row).toHaveTextContent('00081388F2...C000000001') + expect(nft1Row).toHaveTextContent('https://example...issued-nft1') + expect(nft1Row).toHaveTextContent('2.5%') + + // Verify NFT2 data in second row + const nft2Row = dataRows[1] + expect(nft2Row).toHaveTextContent('00081388F2...C000000002') + expect(nft2Row).toHaveTextContent('https://example...issued-nft2') + expect(nft2Row).toHaveTextContent('7.5%') + + // Verify NFT3 data in third row + const nft3Row = dataRows[2] + expect(nft3Row).toHaveTextContent('00081388F2...C000000003') + expect(nft3Row).toHaveTextContent('12.5%') + + // Verify offer data (async loaded) + // XRP symbol is \uE900, offers are formatted with localizeNumber with currency: 'XRP' + await waitFor(() => { + expect(nft1Row.textContent).toContain('\uE9003.00') // Lowest Ask for NFT1 + }) + expect(nft1Row.textContent).toContain('\uE9002.80') // Highest Bid for NFT1 + + // NFT2 offers + expect(nft2Row.textContent).toContain('\uE9008.00') // Lowest Ask for NFT2 + expect(nft2Row.textContent).toContain('\uE9007.50') // Highest Bid for NFT2 + + // NFT3: No offers, should show -- + const nft3Placeholders = nft3Row.textContent?.match(/--/g) || [] + expect(nft3Placeholders.length).toBeGreaterThanOrEqual(2) // At least 2: Lowest Ask and Highest Bid (no URL is also --) + }) +}) From 3d5c5f588184852dedaea6d34e2c9728ec8b1346 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Tue, 7 Oct 2025 21:41:00 -0400 Subject: [PATCH 36/47] Add unit test for AccountHeader.tsx --- .../AccountHeader/test/AccountHeader.test.tsx | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx diff --git a/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx b/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx new file mode 100644 index 000000000..d696e38aa --- /dev/null +++ b/src/containers/Accounts/AccountHeader/test/AccountHeader.test.tsx @@ -0,0 +1,239 @@ +import { render, screen } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import i18n from '../../../../i18n/testConfigEnglish' +import AccountHeader from '../index' + +// Mock the DomainLink component +jest.mock('../../../shared/components/DomainLink', () => ({ + __esModule: true, + default: ({ domain }: { domain: string }) => ( + + {domain} + + ), +})) + +// Mock the Account component +jest.mock('../../../shared/components/Account', () => ({ + Account: ({ account }: { account: string }) => ( + {account} + ), +})) + +// Mock the SVG import +jest.mock('../../../shared/images/info-duotone.svg', () => ({ + __esModule: true, + default: ({ className }: { className?: string }) => ( + + ), +})) + +// Test wrapper component +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + +describe('AccountHeader', () => { + const mockAccountId = 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH' + + describe('Basic rendering', () => { + it('renders regular account address', () => { + render( + + + , + ) + + expect(screen.getByText('Address')).toBeInTheDocument() + expect(screen.getByText(mockAccountId)).toBeInTheDocument() + // No deleted banner + expect(screen.queryByTestId('info-icon')).not.toBeInTheDocument() + expect(screen.queryByText('Account Deleted')).not.toBeInTheDocument() + }) + }) + + describe('Deleted account banner', () => { + it('shows deleted banner when account is deleted', () => { + render( + + + , + ) + + expect(screen.getByTestId('info-icon')).toBeInTheDocument() + expect(screen.getByText('Account Deleted')).toBeInTheDocument() + expect( + screen.getByText( + 'This account has been deleted from the XRP Ledger. Historical data is shown for reference only.', + ), + ).toBeInTheDocument() + }) + }) + + describe('X-Address support', () => { + it('renders extended address label when xAddress is provided', () => { + const mockAccount = { + xAddress: { + classicAddress: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + tag: 12345, + }, + } + + render( + + + , + ) + + expect( + screen.getByText('Extended Address (X-Address)'), + ).toBeInTheDocument() + expect( + screen.getByText('X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ'), + ).toBeInTheDocument() + + // Classic address and tag should also be shown + expect(screen.getByText('Classic Address:')).toBeInTheDocument() + expect(screen.getByTestId('classic-address')).toBeInTheDocument() + expect( + screen.getByText('rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH'), + ).toBeInTheDocument() + + expect(screen.getByText('Tag:')).toBeInTheDocument() + expect(screen.getByText('12345')).toBeInTheDocument() + }) + + it('does not render classic address when xAddress classicAddress is missing', () => { + const mockAccount = { + xAddress: { + tag: 12345, + }, + } + + render( + + + , + ) + + expect(screen.queryByText('Classic Address:')).not.toBeInTheDocument() + }) + + it('does not render address tag when xAddress tag is missing', () => { + const mockAccount = { + xAddress: { + classicAddress: 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH', + }, + } + + render( + + + , + ) + + expect(screen.queryByText('Tag:')).not.toBeInTheDocument() + }) + }) + + describe('Domain display', () => { + it('renders domain when account info has domain', () => { + const mockAccount = { + info: { + domain: 'ripple.com', + }, + } + + render( + + + , + ) + + expect(screen.getByText('Domain:')).toBeInTheDocument() + expect(screen.getByTestId('domain-link')).toBeInTheDocument() + expect(screen.getByText('ripple.com')).toBeInTheDocument() + }) + + it('does not render domain when account info is missing', () => { + render( + + + , + ) + + expect(screen.queryByText('Domain:')).not.toBeInTheDocument() + expect(screen.queryByTestId('domain-link')).not.toBeInTheDocument() + }) + + it('does not render domain when domain field is missing', () => { + const mockAccount = { + info: {}, + } + + render( + + + , + ) + + expect(screen.queryByText('Domain:')).not.toBeInTheDocument() + expect(screen.queryByTestId('domain-link')).not.toBeInTheDocument() + }) + + it('renders with all optional fields missing', () => { + render( + + + , + ) + + // Only address should be shown + expect(screen.getByText('Address')).toBeInTheDocument() + expect(screen.getByText(mockAccountId)).toBeInTheDocument() + + // No optional fields + expect(screen.queryByText(/Classic Address/)).not.toBeInTheDocument() + expect(screen.queryByText(/Tag/)).not.toBeInTheDocument() + expect(screen.queryByText(/Domain/)).not.toBeInTheDocument() + expect(screen.queryByText('Account Deleted')).not.toBeInTheDocument() + }) + }) + + describe('Address title attribute', () => { + it('sets title attribute on address value for tooltip', () => { + render( + + + , + ) + + const addressValue = screen.getByTitle(mockAccountId) + expect(addressValue).toBeInTheDocument() + expect(addressValue).toHaveTextContent(mockAccountId) + }) + }) +}) From 1a85708249ae7ae06dbd3749df026ed22bdcc947 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Tue, 7 Oct 2025 21:42:04 -0400 Subject: [PATCH 37/47] Fixed failed unit tests for AccountTransactionTable.tsx --- .../test/AccountTransactionTable.test.tsx | 41 ------------------- src/rippled/lib/rippled.js | 1 - 2 files changed, 42 deletions(-) diff --git a/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx b/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx index 71c57c766..9c8c5aa3a 100644 --- a/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx +++ b/src/containers/Accounts/AccountTransactionTable/test/AccountTransactionTable.test.tsx @@ -22,7 +22,6 @@ describe('AccountTransactionsTable container', () => { const createWrapper = ( getAccountTransactionsImpl: () => Promise = () => new Promise(() => {}), - currencySelected: string = '', state = { hasToken: false }, ) => { ;(getAccountTransactions as Mock).mockImplementation( @@ -33,7 +32,6 @@ describe('AccountTransactionsTable container', () => { , ) @@ -85,7 +83,6 @@ describe('AccountTransactionsTable container', () => { await flushPromises() component.update() - expect(component.find('.load-more-btn')).not.toExist() expect(component.find('.transaction-table')).toExist() expect(component.find('.empty-transactions-message')).toHaveText( 'get_account_transactions_failed', @@ -94,28 +91,9 @@ describe('AccountTransactionsTable container', () => { component.unmount() }) - it('renders try loading more message when no filtered results show but there is a marker', async () => { - const component = createWrapper( - () => Promise.resolve(TEST_TRANSACTIONS_DATA), - 'EUR', - ) - - await flushPromises() - component.update() - - expect(component.find('.load-more-btn')).toExist() - expect(component.find('.transaction-table')).toExist() - expect(component.find('.empty-transactions-message')).toHaveText( - 'get_account_transactions_try', - ) - expect(component.find(Link).length).toBe(0) - component.unmount() - }) - it('renders dynamic content with transaction data and token column', async () => { const component = createWrapper( () => Promise.resolve(TEST_TRANSACTIONS_DATA), - undefined, { hasToken: true }, ) @@ -148,7 +126,6 @@ describe('AccountTransactionsTable container', () => { await flushPromises() component.update() - expect(component.find('.load-more-btn')).not.toExist() expect(component.find('.transaction-table')).toExist() expect(component.find('.empty-transactions-message')).toHaveText( 'get_account_transactions_failed', @@ -156,22 +133,4 @@ describe('AccountTransactionsTable container', () => { expect(component.find(Link).length).toBe(0) component.unmount() }) - - it('renders try loading more message when no filtered results show but there is a marker', async () => { - const component = createWrapper( - () => Promise.resolve(TEST_TRANSACTIONS_DATA), - 'EUR', - ) - - await flushPromises() - component.update() - - expect(component.find('.load-more-btn')).toExist() - expect(component.find('.transaction-table')).toExist() - expect(component.find('.empty-transactions-message')).toHaveText( - 'get_account_transactions_try', - ) - expect(component.find(Link).length).toBe(0) - component.unmount() - }) }) diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js index 08d0ed425..fca22a980 100644 --- a/src/rippled/lib/rippled.js +++ b/src/rippled/lib/rippled.js @@ -352,7 +352,6 @@ const getBalances = (rippledSocket, account, ledgerIndex = 'validated') => return resp }) -// get account transactions const getAccountTransactions = ( rippledSocket, account, From a28d6b2bd5a4952e5cd8ae2a733a71d1f61265ca Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Tue, 7 Oct 2025 22:00:26 -0400 Subject: [PATCH 38/47] More unit tests for AccountAsset index.tsx --- .../AccountAsset/test/AccountAsset.test.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx b/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx index ac937710a..a385e9341 100644 --- a/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx +++ b/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx @@ -344,7 +344,7 @@ describe('AccountAsset Component', () => { describe('Component Integration', () => { it('renders all held asset components simultaneously', async () => { - render( + const { container } = render( { expect(mockedGetAccountMPTs).toHaveBeenCalled() expect(mockedGetAccountNFTs).toHaveBeenCalled() }) + + // Verify all 4 held asset table wrappers and asset tables are rendered + const allSections = container.querySelectorAll('.account-asset-content') + const heldSection = allSections[0] // First section (Held) + const heldWrappers = heldSection.querySelectorAll( + '.account-asset-table-wrapper', + ) + expect(heldWrappers.length).toBe(4) + + const heldTables = heldSection.querySelectorAll('.account-asset-table') + expect(heldTables.length).toBe(4) }) it('renders all issued asset components simultaneously', async () => { - render( + const { container } = render( { expect(mockedGetAccountObjects).toHaveBeenCalled() expect(mockedGetNFTsIssuedByAccount).toHaveBeenCalled() }) + + // Verify all 3 issued asset table wrappers and asset tables are rendered + const allSections = container.querySelectorAll('.account-asset-content') + const issuedSection = allSections[1] // Second section (Issued) + const issuedWrappers = issuedSection.querySelectorAll( + '.account-asset-table-wrapper', + ) + expect(issuedWrappers.length).toBe(3) + + const issuedTables = issuedSection.querySelectorAll( + '.account-asset-table', + ) + expect(issuedTables.length).toBe(3) }) it('passes accountId to all child components', async () => { From 5be7e53289f71a641694c70a6fb5b27ad5869188 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 8 Oct 2025 08:24:05 -0400 Subject: [PATCH 39/47] Add unit tests for AccountSummary components --- public/locales/en-US/translations.json | 2 +- .../Accounts/AccountSummary/FlagsCard.tsx | 8 +- .../Accounts/AccountSummary/SignersCard.tsx | 7 +- .../test/AccountSummary.test.tsx | 175 +++++ .../AccountSummary/test/Balances.test.tsx | 243 ++++++ .../AccountSummary/test/DetailsCard.test.tsx | 304 ++++++++ .../AccountSummary/test/FlagsCard.test.tsx | 738 ++++++++++++++++++ .../AccountSummary/test/SignersCard.test.tsx | 230 ++++++ 8 files changed, 1699 insertions(+), 8 deletions(-) create mode 100644 src/containers/Accounts/AccountSummary/test/AccountSummary.test.tsx create mode 100644 src/containers/Accounts/AccountSummary/test/Balances.test.tsx create mode 100644 src/containers/Accounts/AccountSummary/test/DetailsCard.test.tsx create mode 100644 src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx create mode 100644 src/containers/Accounts/AccountSummary/test/SignersCard.test.tsx diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 591aaf773..a8c617f6a 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -630,7 +630,7 @@ "account_flag_title_lsf_deposit_auth": "Deposit Authorization", "account_flag_title_asf_account_txn_id": "Track Account Latest Transaction", "account_flag_description_lsf_global_freeze": "Freeze all assets issued by this account.", - "account_flag_description_lsf_disable_master": "Disallow use of the master key pair.", + "account_flag_description_lsf_disable_master": "Allow use of the master key pair.", "account_flag_description_lsf_default_ripple": "Enable rippling on this account's trust lines by default.", "account_flag_description_lsf_allow_trustline_clawback": "Allow account to claw back tokens it has issued.", "account_flag_description_lsf_require_destination_tag": "Require a destination tag to send transactions to this account.", diff --git a/src/containers/Accounts/AccountSummary/FlagsCard.tsx b/src/containers/Accounts/AccountSummary/FlagsCard.tsx index 6dda89765..a3d9771ef 100644 --- a/src/containers/Accounts/AccountSummary/FlagsCard.tsx +++ b/src/containers/Accounts/AccountSummary/FlagsCard.tsx @@ -73,7 +73,7 @@ const FlagsCard = ({ account }: FlagsCardProps) => { description: t( 'account_flag_description_lsf_disallow_incoming_trustline', ), - enabled: !accountFlags.includes('lsfDisallowIncomingTrustline'), + enabled: accountFlags.includes('lsfDisallowIncomingTrustline'), }, { key: 'lsfDisallowIncomingPayChannel', @@ -81,7 +81,7 @@ const FlagsCard = ({ account }: FlagsCardProps) => { description: t( 'account_flag_description_lsf_disallow_incoming_pay_chan', ), - enabled: !accountFlags.includes('lsfDisallowIncomingPayChannel'), + enabled: accountFlags.includes('lsfDisallowIncomingPayChannel'), }, { key: 'lsfDisallowIncomingNFTokenOffer', @@ -89,7 +89,7 @@ const FlagsCard = ({ account }: FlagsCardProps) => { description: t( 'account_flag_description_lsf_disallow_incoming_nft_token_offer', ), - enabled: !accountFlags.includes('lsfDisallowIncomingNFTokenOffer'), + enabled: accountFlags.includes('lsfDisallowIncomingNFTokenOffer'), }, { key: 'asfAuthorizedNFTokenMinter', @@ -105,7 +105,7 @@ const FlagsCard = ({ account }: FlagsCardProps) => { key: 'lsfDisallowIncomingCheck', title: t('account_flag_title_lsf_disallow_incoming_check'), description: t('account_flag_description_lsf_disallow_incoming_check'), - enabled: !accountFlags.includes('lsfDisallowIncomingCheck'), + enabled: accountFlags.includes('lsfDisallowIncomingCheck'), }, { key: 'lsfDepositAuth', diff --git a/src/containers/Accounts/AccountSummary/SignersCard.tsx b/src/containers/Accounts/AccountSummary/SignersCard.tsx index 3e6caf77b..76e232a13 100644 --- a/src/containers/Accounts/AccountSummary/SignersCard.tsx +++ b/src/containers/Accounts/AccountSummary/SignersCard.tsx @@ -1,10 +1,10 @@ import { useTranslation } from 'react-i18next' import { Account } from '../../shared/components/Account' -import { shortenAccount } from '../../shared/utils' +import { localizeNumber, shortenAccount } from '../../shared/utils' interface Signer { account: string - weight?: number + weight?: number // Individual weight values cannot exceed 2^16-1. } interface SignersCardProps { @@ -28,7 +28,8 @@ const SignersCard = ({ signers }: SignersCardProps) => { {signer.weight !== undefined && (
    - {t('account_page_signer_weight')} {signer.weight} + {t('account_page_signer_weight')}{' '} + {localizeNumber(signer.weight)}
    )} diff --git a/src/containers/Accounts/AccountSummary/test/AccountSummary.test.tsx b/src/containers/Accounts/AccountSummary/test/AccountSummary.test.tsx new file mode 100644 index 000000000..24ac88370 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/test/AccountSummary.test.tsx @@ -0,0 +1,175 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import i18n from '../../../../i18n/testConfigEnglish' +import { AccountSummary } from '../index' + +// Mock the child components and SVG imports +jest.mock('../Balances', () => ({ + __esModule: true, + default: ({ account, xrpToUSDRate }: any) => ( +
    + Balance: {account.info?.balance} Rate: {xrpToUSDRate} +
    + ), +})) + +jest.mock('../DetailsCard', () => ({ + __esModule: true, + default: ({ account, lang }: any) => ( +
    + Sequence: {account.info?.sequence} Lang: {lang} +
    + ), +})) + +jest.mock('../FlagsCard', () => ({ + __esModule: true, + default: ({ account }: any) => ( +
    + Flags: {account.info?.flags?.length || 0} +
    + ), +})) + +jest.mock('../SignersCard', () => ({ + __esModule: true, + default: ({ signers }: any) => ( +
    Signers: {signers.length}
    + ), +})) + +jest.mock('../../../shared/images/down_arrow.svg', () => ({ + __esModule: true, + default: () => , +})) + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('AccountSummary Component', () => { + const mockAccount = { + info: { + balance: 1000000000, + sequence: 123, + flags: ['lsfGlobalFreeze'], + }, + } + + describe('Rendering and Toggle Behavior', () => { + it('renders balances and collapsed properties by default, then expands/collapses on toggle', () => { + render( + + + , + ) + + // Renders balances and properties header + expect(screen.getByTestId('balances-mock')).toBeInTheDocument() + expect(screen.getByText('Account Properties')).toBeInTheDocument() + + // Properties are collapsed by default + const toggleButton = screen.getByLabelText('Toggle account properties') + expect(toggleButton).toBeInTheDocument() + expect(toggleButton).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByTestId('flags-card-mock')).not.toBeInTheDocument() + expect(screen.queryByTestId('details-card-mock')).not.toBeInTheDocument() + + // Expand properties + fireEvent.click(toggleButton) + expect(toggleButton).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByTestId('flags-card-mock')).toBeInTheDocument() + expect(screen.getByTestId('details-card-mock')).toBeInTheDocument() + + // Collapse again + fireEvent.click(toggleButton) + expect(toggleButton).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByTestId('flags-card-mock')).not.toBeInTheDocument() + }) + }) + + describe('Props and Content', () => { + it('passes correct props to child components', () => { + render( + + + , + ) + + // Balances receives account and rate + expect(screen.getByText(/Balance: 1000000000/)).toBeInTheDocument() + expect(screen.getByText(/Rate: 0.5/)).toBeInTheDocument() + + // FlagsCard receives account + const toggleButton = screen.getByLabelText('Toggle account properties') + fireEvent.click(toggleButton) + expect(screen.getByText('Flags: 1')).toBeInTheDocument() + }) + + it('renders SignersCard when signers exist, hides when none', () => { + // Without signers + render( + + + , + ) + + const toggleButton = screen.getByLabelText('Toggle account properties') + fireEvent.click(toggleButton) + expect(screen.queryByTestId('signers-card-mock')).not.toBeInTheDocument() + }) + + it('renders SignersCard when account has signers', () => { + const accountWithSigners = { + ...mockAccount, + signerList: { + signers: [ + { account: 'rSigner1', weight: 1 }, + { account: 'rSigner2', weight: 2 }, + ], + }, + } + + render( + + + , + ) + + const toggleButton = screen.getByLabelText('Toggle account properties') + fireEvent.click(toggleButton) + expect(screen.getByTestId('signers-card-mock')).toBeInTheDocument() + expect(screen.getByText('Signers: 2')).toBeInTheDocument() + }) + + it('handles empty account gracefully', () => { + render( + + + , + ) + + expect(screen.getByTestId('balances-mock')).toBeInTheDocument() + + const toggleButton = screen.getByLabelText('Toggle account properties') + fireEvent.click(toggleButton) + expect(screen.getByTestId('flags-card-mock')).toBeInTheDocument() + expect(screen.getByTestId('details-card-mock')).toBeInTheDocument() + }) + + it('handles undefined account with default value', () => { + render( + + + , + ) + + expect(screen.getByTestId('balances-mock')).toBeInTheDocument() + + const toggleButton = screen.getByLabelText('Toggle account properties') + fireEvent.click(toggleButton) + expect(screen.getByTestId('flags-card-mock')).toBeInTheDocument() + expect(screen.getByTestId('details-card-mock')).toBeInTheDocument() + }) + }) +}) diff --git a/src/containers/Accounts/AccountSummary/test/Balances.test.tsx b/src/containers/Accounts/AccountSummary/test/Balances.test.tsx new file mode 100644 index 000000000..045063900 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/test/Balances.test.tsx @@ -0,0 +1,243 @@ +import { render, screen } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import i18n from '../../../../i18n/testConfigEnglish' +import Balances from '../Balances' + +// Mock SVG imports +jest.mock('../../../shared/images/usd_icon.svg', () => ({ + __esModule: true, + default: () => , +})) + +jest.mock('../../../shared/images/xrp_balance_icon.svg', () => ({ + __esModule: true, + default: () => , +})) + +jest.mock('../../../shared/images/xrp_reserve_balance_icon.svg', () => ({ + __esModule: true, + default: () => , +})) + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('Balances Component', () => { + describe('Rendering', () => { + it('renders all three balance cards with icons', () => { + const account = { + info: { + balance: 1000000000, // 1000 XRP (in drops) + reserve: 10000000, // 10 XRP + }, + } + + const { container } = render( + + + , + ) + + // Verify three balance cards exist + const balanceCards = container.querySelectorAll('.balance-card') + expect(balanceCards.length).toBe(3) + + // Verify XRP balance card and icon + expect(screen.getByText('XRP Balance')).toBeInTheDocument() + expect(screen.getByTestId('xrp-icon')).toBeInTheDocument() + + // Verify USD balance card and icon + expect(screen.getByText('XRP Balance (USD)')).toBeInTheDocument() + expect(screen.getByTestId('usd-icon')).toBeInTheDocument() + + // Verify Reserve balance card and icon + expect(screen.getByText('Reserve Balance')).toBeInTheDocument() + expect(screen.getByTestId('reserve-icon')).toBeInTheDocument() + }) + }) + + describe('Balance Calculations', () => { + it('converts drops to XRP correctly (1000 XRP)', () => { + const account = { + info: { + balance: 1000000000, // 1000 XRP in drops + reserve: 0, + }, + } + + const { container } = render( + + + , + ) + + // XRP balance should show 1000 + const xrpCard = container.querySelector('.balance-card.xrp') + expect(xrpCard?.textContent).toContain('1,000') + }) + + it('calculates USD value correctly', () => { + const account = { + info: { + balance: 2000000000, // 2000 XRP + reserve: 0, + }, + } + + const { container } = render( + + + , + ) + + // USD should be 2000 * 0.5 = 1000 + const usdCard = container.querySelector('.balance-card.usd') + expect(usdCard?.textContent).toContain('$1,000') + }) + + it('handles zero balance', () => { + const account = { + info: { + balance: 0, + reserve: 0, + }, + } + + const { container } = render( + + + , + ) + + const xrpCard = container.querySelector('.balance-card.xrp') + expect(xrpCard?.textContent).toContain('0') + }) + + it('handles missing balance (defaults to 0)', () => { + const account = { + info: {}, + } + + const { container } = render( + + + , + ) + + const xrpCard = container.querySelector('.balance-card.xrp') + expect(xrpCard?.textContent).toContain('0') + }) + + it('displays reserve balance', () => { + const account = { + info: { + balance: 1000000000, + reserve: 20, // 20 XRP (already in XRP, not drops) + }, + } + + const { container } = render( + + + , + ) + + const reserveCard = container.querySelector('.balance-card.reserve') + expect(reserveCard?.textContent).toContain('20') + }) + }) + + describe('XRP to USD Rate', () => { + it('updates USD value when rate changes', () => { + const account = { + info: { + balance: 1000000000, // 1000 XRP + reserve: 0, + }, + } + + const { container, rerender } = render( + + + , + ) + + // Initial: 1000 XRP * $0.5 = $500 + let usdCard = container.querySelector('.balance-card.usd') + expect(usdCard?.textContent).toContain('$500') + + // Update rate: 1000 XRP * $2 = $2000 + rerender( + + + , + ) + + usdCard = container.querySelector('.balance-card.usd') + expect(usdCard?.textContent).toContain('2,000') + }) + + it('handles zero USD rate', () => { + const account = { + info: { + balance: 1000000000, + reserve: 0, + }, + } + + const { container } = render( + + + , + ) + + const usdCard = container.querySelector('.balance-card.usd') + expect(usdCard?.textContent).toContain('$0') + }) + }) + + describe('Number Formatting', () => { + it('formats all three balances with proper formatting', () => { + const account = { + info: { + balance: 5545000, // 5.545 XRP + reserve: 20.538, // 20.54 XRP + }, + } + + const { container } = render( + + + , + ) + + // XRP balance with decimal + const xrpCard = container.querySelector('.balance-card.xrp') + expect(xrpCard?.textContent).toContain('5.55') + + // USD balance calculated and formatted + const usdCard = container.querySelector('.balance-card.usd') + expect(usdCard?.textContent).toContain('$2.77') // 5.545 * 0.5 = 2.7725 + + // Reserve balance with decimal + const reserveCard = container.querySelector('.balance-card.reserve') + expect(reserveCard?.textContent).toContain('20.54') + }) + }) + + describe('Edge Cases', () => { + it('handles missing info property', () => { + const account = {} + + const { container } = render( + + + , + ) + + const xrpCard = container.querySelector('.balance-card.xrp') + expect(xrpCard).toBeInTheDocument() + }) + }) +}) diff --git a/src/containers/Accounts/AccountSummary/test/DetailsCard.test.tsx b/src/containers/Accounts/AccountSummary/test/DetailsCard.test.tsx new file mode 100644 index 000000000..693eb4e9f --- /dev/null +++ b/src/containers/Accounts/AccountSummary/test/DetailsCard.test.tsx @@ -0,0 +1,304 @@ +import { render, screen } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import i18n from '../../../../i18n/testConfigEnglish' +import DetailsCard from '../DetailsCard' + +// Mock the Account component +jest.mock('../../../shared/components/Account', () => ({ + Account: ({ account }: { account: string }) => ( + {account} + ), +})) + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + +describe('DetailsCard Component', () => { + describe('Rendering', () => { + it('renders card header and required fields', () => { + const account = { + info: { + sequence: 123, + ticketCount: 5, + }, + } + + render( + + + , + ) + + // Verify card header + expect(screen.getByText('Details')).toBeInTheDocument() + + // Verify current sequence field + expect(screen.getByText('Current Sequence')).toBeInTheDocument() + expect(screen.getByText('123')).toBeInTheDocument() + + // Verify ticket count field + expect(screen.getByText('Ticket Count')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + }) + }) + + describe('Optional Fields', () => { + it('renders email hash when present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + emailHash: 'ABC123DEF456', + }, + } + + render( + + + , + ) + + expect(screen.getByText('Email Hash')).toBeInTheDocument() + expect(screen.getByText('ABC123DEF456')).toBeInTheDocument() + }) + + it('does not render email hash when not present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + }, + } + + render( + + + , + ) + + expect(screen.queryByText('Email Hash')).not.toBeInTheDocument() + }) + + it('renders payment channels when present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + }, + paychannels: { + total_available: 100, + channels: [{ id: '1' }, { id: '2' }, { id: '3' }], + }, + } + + render( + + + , + ) + + expect(screen.getByText('Payment Channels')).toBeInTheDocument() + // Should show formatted text with both amount and number of channels + expect(screen.getByText(/100\.00.*3 channel\(s\)/i)).toBeInTheDocument() + }) + + it('does not render payment channels when not present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + }, + } + + render( + + + , + ) + + expect(screen.queryByText('Payment Channels')).not.toBeInTheDocument() + }) + + it('renders NFT minter when present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + nftMinter: 'rNFTMinterAccount123', + }, + } + + render( + + + , + ) + + expect(screen.getByText('NFT Minter')).toBeInTheDocument() + expect(screen.getByTestId('account-component')).toHaveTextContent( + 'rNFTMinterAccount123', + ) + }) + + it('does not render NFT minter when not present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + }, + } + + render( + + + , + ) + + expect(screen.queryByText('NFT Minter')).not.toBeInTheDocument() + }) + }) + + describe('Number Formatting', () => { + it('formats sequence and ticket count with commas for large numbers', () => { + const account = { + info: { + sequence: 1234567, + ticketCount: 1000, + }, + } + + render( + + + , + ) + + expect(screen.getByText('1,234,567')).toBeInTheDocument() + expect(screen.getByText('1,000')).toBeInTheDocument() + }) + + it('handles zero values', () => { + const account = { + info: { + sequence: 0, + ticketCount: 0, + }, + } + + render( + + + , + ) + + const zeroElements = screen.getAllByText('0') + expect(zeroElements.length).toBe(2) + }) + }) + + describe('Combined Optional Fields', () => { + it('renders all optional fields when present', () => { + const account = { + info: { + sequence: 123, + ticketCount: 5, + emailHash: 'ABC123', + nftMinter: 'rMinter123', + }, + paychannels: { + total_available: 100, + channels: [{ id: '1' }, { id: '2' }], + }, + } + + render( + + + , + ) + + expect(screen.getByText('Email Hash')).toBeInTheDocument() + expect(screen.getByText('Payment Channels')).toBeInTheDocument() + expect(screen.getByText('NFT Minter')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('handles undefined sequence', () => { + const account = { + info: { + ticketCount: 0, + }, + } + + render( + + + , + ) + + expect(screen.getByText('Current Sequence')).toBeInTheDocument() + }) + + it('handles undefined ticket count', () => { + const account = { + info: { + sequence: 123, + }, + } + + render( + + + , + ) + + expect(screen.getByText('Ticket Count')).toBeInTheDocument() + }) + + it('handles empty paychannels array', () => { + const account = { + info: { + sequence: 123, + ticketCount: 0, + }, + paychannels: { + total_available: 0, + channels: [], + }, + } + + render( + + + , + ) + + // Should not render payment channels if total_available is 0 (falsy) + expect(screen.queryByText('Payment Channels')).not.toBeInTheDocument() + }) + }) + + describe('Language Support', () => { + it('accepts different language codes', () => { + const account = { + info: { + sequence: 1234567, + ticketCount: 0, + }, + } + + render( + + + , + ) + + expect(screen.getByText('Details')).toBeInTheDocument() + }) + }) +}) diff --git a/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx b/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx new file mode 100644 index 000000000..96c15d876 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/test/FlagsCard.test.tsx @@ -0,0 +1,738 @@ +import { render, screen } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import i18n from '../../../../i18n/testConfigEnglish' +import FlagsCard from '../FlagsCard' + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('FlagsCard Component', () => { + describe('Rendering', () => { + it('renders the card with header', () => { + const account = { + info: { + flags: [], + }, + } + + render( + + + , + ) + + expect(screen.getByText('Flags')).toBeInTheDocument() + }) + + it('renders all 15 flag items', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flagItems = container.querySelectorAll('.flag-item') + expect(flagItems.length).toBe(15) + }) + }) + + describe('Flag Status - Enabled/Disabled', () => { + it('shows lsfGlobalFreeze as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfGlobalFreeze'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Global Freeze'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfGlobalFreeze as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Global Freeze'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfDisableMaster as enabled (inverted) when flag is NOT present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Master Key'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfDisableMaster as disabled (inverted) when flag IS present', () => { + const account = { + info: { + flags: ['lsfDisableMaster'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Master Key'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfDefaultRipple as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDefaultRipple'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Rippling'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfDefaultRipple as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Rippling'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfAllowTrustLineClawback as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfAllowTrustLineClawback'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Clawback'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfAllowTrustLineClawback as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Clawback'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfRequireDestTag as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfRequireDestTag'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Require Destination Tag'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfRequireDestTag as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('Require Destination Tag'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfNoFreeze as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfNoFreeze'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No Freeze'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfNoFreeze as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No Freeze'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfRequireAuth as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfRequireAuth'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.querySelector('.flag-title')?.textContent === + 'Require Authorization', + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfRequireAuth as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.querySelector('.flag-title')?.textContent === + 'Require Authorization', + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfDisallowXRP as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDisallowXRP'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No XRP Allowed'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfDisallowXRP as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No XRP Allowed'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfDisallowIncomingTrustline as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDisallowIncomingTrustline'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No Trustlines Allowed'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfDisallowIncomingTrustline as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No Trustlines Allowed'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfDisallowIncomingPayChannel as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDisallowIncomingPayChannel'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No Payment Channels Allowed'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfDisallowIncomingPayChannel as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No Payment Channels Allowed'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfDisallowIncomingNFTokenOffer as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDisallowIncomingNFTokenOffer'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No NFT Offers Allowed'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfDisallowIncomingNFTokenOffer as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No NFT Offers Allowed'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows asfAuthorizedNFTokenMinter as enabled when nftMinter field is present', () => { + const account = { + info: { + flags: [], + nftMinter: 'rMinterAccount123', + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('NFT Minter'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows asfAuthorizedNFTokenMinter as disabled when nftMinter field is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('NFT Minter'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfDisallowIncomingCheck as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDisallowIncomingCheck'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No Checks Allowed'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfDisallowIncomingCheck as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => item.textContent?.includes('No Checks Allowed'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows lsfDepositAuth as enabled when flag is present', () => { + const account = { + info: { + flags: ['lsfDepositAuth'], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.querySelector('.flag-title')?.textContent === + 'Deposit Authorization', + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows lsfDepositAuth as disabled when flag is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.querySelector('.flag-title')?.textContent === + 'Deposit Authorization', + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + + it('shows asfAccountTxnID as enabled when accountTransactionID field is present', () => { + const account = { + info: { + flags: [], + accountTransactionID: 'ABCD1234', + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.textContent?.includes('Track Account Latest Transaction'), + ) + expect(flag?.querySelector('.flag-status.enabled')).toBeInTheDocument() + }) + + it('shows asfAccountTxnID as disabled when accountTransactionID field is not present', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + const flag = Array.from(container.querySelectorAll('.flag-item')).find( + (item) => + item.textContent?.includes('Track Account Latest Transaction'), + ) + expect(flag?.querySelector('.flag-status.disabled')).toBeInTheDocument() + }) + }) + + describe('Multiple Flags', () => { + it('handles multiple flags enabled simultaneously', () => { + const account = { + info: { + flags: [ + 'lsfGlobalFreeze', + 'lsfDefaultRipple', + 'lsfRequireDestTag', + 'lsfNoFreeze', + ], + }, + } + + const { container } = render( + + + , + ) + + const enabledFlags = container.querySelectorAll('.flag-status.enabled') + // At least 4 should be enabled (the ones we set) + // Note: Some inverted flags might also be enabled by default + expect(enabledFlags.length).toBeGreaterThanOrEqual(4) + }) + }) + + describe('Flag Structure', () => { + it('each flag has non-empty title, description, and status', () => { + const account = { + info: { + flags: ['lsfGlobalFreeze'], + }, + } + + const { container } = render( + + + , + ) + + const flagItems = container.querySelectorAll('.flag-item') + flagItems.forEach((item) => { + // Each flag must have all three elements + const title = item.querySelector('.flag-title') + const desc = item.querySelector('.flag-desc') + const status = item.querySelector('.flag-status') + + expect(title).toBeInTheDocument() + expect(desc).toBeInTheDocument() + expect(status).toBeInTheDocument() + + // And none should be empty + expect(title?.textContent).not.toBe('') + expect(desc?.textContent).not.toBe('') + expect(status?.textContent).not.toBe('') + }) + }) + }) + + describe('Edge Cases', () => { + it('handles undefined flags array', () => { + const account = { + info: {}, + } + + const { container } = render( + + + , + ) + + expect(screen.getByText('Flags')).toBeInTheDocument() + const flagItems = container.querySelectorAll('.flag-item') + expect(flagItems.length).toBe(15) + }) + + it('handles missing info object', () => { + const account = {} + + const { container } = render( + + + , + ) + + expect(screen.getByText('Flags')).toBeInTheDocument() + const flagItems = container.querySelectorAll('.flag-item') + expect(flagItems.length).toBe(15) + }) + + it('handles empty account object', () => { + const account = { + info: { + flags: [], + }, + } + + const { container } = render( + + + , + ) + + expect(screen.getByText('Flags')).toBeInTheDocument() + const flagItems = container.querySelectorAll('.flag-item') + expect(flagItems.length).toBe(15) + }) + }) +}) diff --git a/src/containers/Accounts/AccountSummary/test/SignersCard.test.tsx b/src/containers/Accounts/AccountSummary/test/SignersCard.test.tsx new file mode 100644 index 000000000..668d8ed75 --- /dev/null +++ b/src/containers/Accounts/AccountSummary/test/SignersCard.test.tsx @@ -0,0 +1,230 @@ +import { render, screen } from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { BrowserRouter as Router } from 'react-router-dom' +import i18n from '../../../../i18n/testConfigEnglish' +import SignersCard from '../SignersCard' + +// Mock the Account component +jest.mock('../../../shared/components/Account', () => ({ + Account: ({ account, displayText }: any) => ( + {displayText || account} + ), +})) + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + +describe('SignersCard Component', () => { + describe('Rendering', () => { + it('renders the card with header', () => { + const signers = [{ account: 'rSigner1', weight: 1 }] + + const { container } = render( + + + , + ) + + expect(screen.getByText('Signers')).toBeInTheDocument() + expect(container.querySelector('.card-header')).toBeInTheDocument() + expect(container.querySelector('.header-title')).toBeInTheDocument() + }) + + it('renders a single signer', () => { + const signers = [{ account: 'rSigner', weight: 1 }] + + const { container } = render( + + + , + ) + + expect(screen.getByTestId('account-rSigner')).toBeInTheDocument() + const signerItems = container.querySelectorAll('.signer-item') + expect(signerItems.length).toBe(1) + }) + + it('renders multiple signers', () => { + const signers = [ + { account: 'rSigner1', weight: 1 }, + { account: 'rSigner2', weight: 2 }, + { account: 'rSigner3', weight: 3 }, + ] + + const { container } = render( + + + , + ) + + expect(screen.getByTestId('account-rSigner1')).toBeInTheDocument() + expect(screen.getByTestId('account-rSigner2')).toBeInTheDocument() + expect(screen.getByTestId('account-rSigner3')).toBeInTheDocument() + const signerItems = container.querySelectorAll('.signer-item') + expect(signerItems.length).toBe(3) + }) + }) + + describe('Signer Weight', () => { + it('displays signer weight when present', () => { + const signers = [{ account: 'rSigner1', weight: 5 }] + + render( + + + , + ) + + expect(screen.getByText(/Weight/i)).toBeInTheDocument() + expect(screen.getByText(/5/)).toBeInTheDocument() + }) + + it('displays different weights for different signers', () => { + const signers = [ + { account: 'rSigner1', weight: 1 }, + { account: 'rSigner2', weight: 3 }, + { account: 'rSigner3', weight: 5 }, + ] + + render( + + + , + ) + + expect(screen.getByText(/Weight.*1/)).toBeInTheDocument() + expect(screen.getByText(/Weight.*3/)).toBeInTheDocument() + expect(screen.getByText(/Weight.*5/)).toBeInTheDocument() + }) + + it('does not display weight when undefined', () => { + const signers = [{ account: 'rSigner1' }] + + const { container } = render( + + + , + ) + + expect(container.querySelector('.signer-weight')).not.toBeInTheDocument() + }) + + it('displays weight of zero', () => { + const signers = [{ account: 'rSigner1', weight: 0 }] + + render( + + + , + ) + + expect(screen.getByText(/Weight.*0/)).toBeInTheDocument() + }) + }) + + describe('Account Display', () => { + it('uses shortened account address for display', () => { + const longAccount = 'rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH' + const signers = [{ account: longAccount, weight: 1 }] + + render( + + + , + ) + + // Should show shortened version: 7 chars...5 chars + expect(screen.getByText(/rN7n7ot.*6fzRH/)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('handles empty signers array', () => { + const signers: any[] = [] + + const { container } = render( + + + , + ) + + const signerItems = container.querySelectorAll('.signer-item') + expect(signerItems.length).toBe(0) + }) + + it('uses index as key when account is missing', () => { + const signers = [{ weight: 1 } as any, { weight: 2 } as any] + + const { container } = render( + + + , + ) + + const signerItems = container.querySelectorAll('.signer-item') + expect(signerItems.length).toBe(2) + }) + + it('handles very large weights', () => { + const signers = [{ account: 'rSigner1', weight: 999999 }] + + render( + + + , + ) + + expect(screen.getByText(/999,999/)).toBeInTheDocument() + }) + + it('handles mix of signers with and without weights', () => { + const signers = [ + { account: 'rSigner1', weight: 1 }, + { account: 'rSigner2' }, + { account: 'rSigner3', weight: 3 }, + ] + + const { container } = render( + + + , + ) + + const weights = container.querySelectorAll('.signer-weight') + expect(weights.length).toBe(2) // Only two signers have weights + }) + }) + + describe('List Structure', () => { + it('contains signers-list container', () => { + const signers = [{ account: 'rSigner1', weight: 1 }] + + const { container } = render( + + + , + ) + + expect(container.querySelector('.signers-list')).toBeInTheDocument() + }) + + it('each signer has signer-address element', () => { + const signers = [ + { account: 'rSigner1', weight: 1 }, + { account: 'rSigner2', weight: 2 }, + ] + + const { container } = render( + + + , + ) + + const addresses = container.querySelectorAll('.signer-address') + expect(addresses.length).toBe(2) + }) + }) +}) From 5a2b997755330d31a6c05491c0691f711ce9613a Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 8 Oct 2025 08:36:58 -0400 Subject: [PATCH 40/47] Fixed failed tests for Accounts/index.test.tsx and updated tests to React Testing Library --- src/containers/Accounts/test/index.test.tsx | 56 +++++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/src/containers/Accounts/test/index.test.tsx b/src/containers/Accounts/test/index.test.tsx index 555cff680..734981887 100644 --- a/src/containers/Accounts/test/index.test.tsx +++ b/src/containers/Accounts/test/index.test.tsx @@ -1,11 +1,9 @@ -import { mount } from 'enzyme' +import { render, screen, waitFor } from '@testing-library/react' import { Route } from 'react-router' import i18n from '../../../i18n/testConfig' import { Accounts } from '../index' -import { AccountHeader } from '../AccountHeader' -import { AccountTransactionTable } from '../AccountTransactionTable' import mockAccountState from './mockAccountState.json' -import { QuickHarness, flushPromises } from '../../test/utils' +import { QuickHarness } from '../../test/utils' import { ACCOUNT_ROUTE } from '../../App/routes' import { getAccountState } from '../../../rippled' import Mock = jest.Mock @@ -16,13 +14,37 @@ jest.mock('../../../rippled', () => ({ getAccountTransactions: jest.fn(() => []), })) +jest.mock('../AccountHeader', () => ({ + __esModule: true, + default: () =>
    Account Header
    , +})) + +jest.mock('../AccountSummary', () => ({ + __esModule: true, + AccountSummary: () => ( +
    Account Summary
    + ), +})) + +jest.mock('../AccountAsset', () => ({ + __esModule: true, + default: () =>
    Account Asset
    , +})) + +jest.mock('../AccountTransactionTable', () => ({ + __esModule: true, + AccountTransactionTable: () => ( +
    Account Transaction Table
    + ), +})) + const mockedGetAccountState = getAccountState as Mock describe('Account container', () => { const TEST_ACCOUNT_ID = 'rncKvRcdDq9hVJpdLdTcKoxsS3NSkXsvfM' - const createWrapper = () => - mount( + const renderComponent = () => + render( { ) beforeEach(() => { - jest.resetModules() + jest.clearAllMocks() }) it('renders without crashing', () => { - const wrapper = createWrapper() - wrapper.unmount() + const { unmount } = renderComponent() + unmount() }) it('renders static parts', async () => { @@ -45,13 +67,15 @@ describe('Account container', () => { Promise.resolve(mockAccountState), ) - const wrapper = createWrapper() - await flushPromises() - wrapper.update() + renderComponent() - expect(wrapper.find(AccountHeader).length).toBe(1) - expect(wrapper.find(AccountTransactionTable).length).toBe(1) - wrapper.find('.balance-selector button').simulate('click') - wrapper.unmount() + await waitFor(() => { + expect(screen.getByTestId('account-header')).toBeInTheDocument() + expect(screen.getByTestId('account-summary')).toBeInTheDocument() + expect(screen.getByTestId('account-asset')).toBeInTheDocument() + expect( + screen.getByTestId('account-transaction-table'), + ).toBeInTheDocument() + }) }) }) From 89ed0bd23557b7ad6b4ff6e2affaf6f886c50484 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 8 Oct 2025 09:36:27 -0400 Subject: [PATCH 41/47] Fix typescript-check errors --- .../Accounts/AccountAsset/assetTables/NFTTable.tsx | 6 +++--- src/containers/shared/Interfaces.tsx | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx index deaaea639..f3b3b37b7 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -27,10 +27,10 @@ const log = logger({ name: 'NFTTable' }) const PAGE_SIZE = 10 -interface NFTTableWithOffersProps { +interface NFTTableProps { accountId: string onChange?: (data: { count: number; isLoading: boolean }) => void - fetchNFTs: (rippledSocket: any, accountId: string) => Promise + fetchNFTs: (accountId: string, rippledSocket: any) => Promise queryKey: string showIssuer?: boolean } @@ -50,7 +50,7 @@ export const NFTTable = ({ fetchNFTs, queryKey, showIssuer = false, -}: NFTTableWithOffersProps) => { +}: NFTTableProps) => { const { t } = useTranslation() const lang = useLanguage() const rippledSocket = useContext(SocketContext) diff --git a/src/containers/shared/Interfaces.tsx b/src/containers/shared/Interfaces.tsx index 10ba1b388..174edd337 100644 --- a/src/containers/shared/Interfaces.tsx +++ b/src/containers/shared/Interfaces.tsx @@ -2,12 +2,13 @@ * Values returned by 'formatAccountInfo' from /src/rippled/lib/utils.js */ export interface AccountFormattedInfo { + accountTransactionID?: string sequence?: number ticketCount?: number ownerCount?: number reserve?: number tick?: number - rate?: number + rate?: string domain?: string emailHash?: string flags?: string[] From b4e7dd4f61d2aeb36cd4f8a8c5abcacdad8e8016 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 8 Oct 2025 09:44:13 -0400 Subject: [PATCH 42/47] Fix linting errors --- .../AccountAsset/test/HeldMPTs.test.tsx | 5 +--- .../Accounts/AccountSummary/styles.scss | 28 +++++++++---------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx b/src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx index f6d729fc8..139e96877 100644 --- a/src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx +++ b/src/containers/Accounts/AccountAsset/test/HeldMPTs.test.tsx @@ -5,10 +5,7 @@ import { QueryClientProvider } from 'react-query' import i18n from '../../../../i18n/testConfigEnglish' import SocketContext from '../../../shared/SocketContext' import { HeldMPTs } from '../assetTables/HeldMPTs' -import { - getAccountMPTs, - getMPTIssuance, -} from '../../../../rippled/lib/rippled' +import { getAccountMPTs, getMPTIssuance } from '../../../../rippled/lib/rippled' import { queryClient } from '../../../shared/QueryClient' import Mock = jest.Mock diff --git a/src/containers/Accounts/AccountSummary/styles.scss b/src/containers/Accounts/AccountSummary/styles.scss index 1df29556c..9fb7e41bb 100644 --- a/src/containers/Accounts/AccountSummary/styles.scss +++ b/src/containers/Accounts/AccountSummary/styles.scss @@ -307,6 +307,20 @@ background: $black-90; gap: 16px; + /* Only first two details side by side, rest full width */ + .details-box { + display: flex; + min-width: 0; + flex: 1 1 180px; + flex-direction: row; + align-items: center; + padding: 10px; + border: 1px solid $black-70; + border-radius: 8px; + background: $black-80; + gap: 12px; + } + .details-list { display: grid; gap: 8px; @@ -328,20 +342,6 @@ } } - /* Only first two details side by side, rest full width */ - .details-box { - display: flex; - min-width: 0; - flex: 1 1 180px; - flex-direction: row; - align-items: center; - padding: 10px; - border: 1px solid $black-70; - border-radius: 8px; - background: $black-80; - gap: 12px; - } - .details-label { color: $black-0; font-size: 14px; From 4bc0d527687dbf187f2608f97761117e33ff466f Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 8 Oct 2025 11:03:03 -0400 Subject: [PATCH 43/47] Add and updated unit tests for existing and new functions and components --- .../AccountAsset/assetTables/HeldLPTokens.tsx | 4 +- .../AccountAsset/assetTables/IssuedIOUs.tsx | 4 +- src/containers/App/test/App.test.jsx | 4 +- .../shared/NumberFormattingUtils.ts | 43 +-- .../test/PaginatedTable.test.tsx | 325 ++++++++++++++++++ .../shared/components/test/Account.test.tsx | 25 ++ .../components/test/DomainLink.test.tsx | 84 +++-- .../components/test/NFTokenLink.test.tsx | 36 ++ .../shared/test/NumberFormattingUtils.test.ts | 178 ++++++++++ src/containers/shared/test/utils.test.ts | 56 +++ src/rippled/lib/test/utils.test.ts | 4 +- 11 files changed, 685 insertions(+), 78 deletions(-) create mode 100644 src/containers/shared/components/PaginatedTable/test/PaginatedTable.test.tsx create mode 100644 src/containers/shared/components/test/NFTokenLink.test.tsx create mode 100644 src/containers/shared/test/NumberFormattingUtils.test.ts diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx index 541d1cb64..509b603f4 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -8,8 +8,8 @@ import Currency, { } from '../../../shared/components/Currency' import { Account } from '../../../shared/components/Account' import { + formatUsdValue, formatTokenBalance, - formatUsdBalance, } from '../../../shared/NumberFormattingUtils' import { useLanguage } from '../../../shared/hooks' import { shortenAccount } from '../../../shared/utils' @@ -217,7 +217,7 @@ export const HeldLPTokens = ({ if (row.currency1 === 'XRP' || row.currency2 === 'XRP') { const balanceUSD = row.lpTokenBalance * row.lpTokenPriceInXRP * xrpToUSDRate - formattedBalanceUsd = formatUsdBalance(balanceUSD, lang) + formattedBalanceUsd = formatUsdValue(balanceUSD, lang) } return ( diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index 01bdb6556..5bef7089f 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -13,7 +13,7 @@ import DefaultTokenIcon from '../../../shared/images/default_token_icon.svg' import { localizeNumber } from '../../../shared/utils' import { useLanguage } from '../../../shared/hooks' import { - formatUsdPrice, + formatUsdValue, formatTokenBalance, } from '../../../shared/NumberFormattingUtils' @@ -209,7 +209,7 @@ export const IssuedIOUs = ({ -
    + diff --git a/src/containers/App/test/App.test.jsx b/src/containers/App/test/App.test.jsx index 1525bc321..dc3888625 100644 --- a/src/containers/App/test/App.test.jsx +++ b/src/containers/App/test/App.test.jsx @@ -333,7 +333,7 @@ describe('App container', () => { network: 'mainnet', }, ]) - expect(mockGetAccountInfo).toBeCalledWith( + expect(mockGetAccountInfo).toHaveBeenCalledWith( expect.anything(), 'r35jYntLwkrbc3edisgavDbEdNRSKgcQE6', ) @@ -354,7 +354,7 @@ describe('App container', () => { network: 'mainnet', }, ]) - expect(mockGetAccountInfo).toBeCalledWith( + expect(mockGetAccountInfo).toHaveBeenCalledWith( expect.anything(), 'rKV8HEL3vLc6q9waTiJcewdRdSFyx67QFb', ) diff --git a/src/containers/shared/NumberFormattingUtils.ts b/src/containers/shared/NumberFormattingUtils.ts index b0efb67f8..7f72f5c3c 100644 --- a/src/containers/shared/NumberFormattingUtils.ts +++ b/src/containers/shared/NumberFormattingUtils.ts @@ -62,49 +62,26 @@ export const NUMBER_SMALL_OPTIONS = { } /** - * Formats USD prices with tiered precision based on value - * @param price - The USD price to format + * Formats USD values (price or balance) with tiered precision based on value + * @param value - The USD value to format * @param lang - Language for localization - * @returns Formatted USD price string or '--' for zero prices + * @returns Formatted USD string or '--' for zero values */ -export const formatUsdPrice = (price: number, lang: string): string => { - if (price === 0) { +export const formatUsdValue = (value: number, lang: string): string => { + if (value === 0) { return '--' } let options - if (price >= USD_REGULAR_BALANCE_LOWER_BOUND) { + if (value >= USD_REGULAR_BALANCE_LOWER_BOUND) { options = USD_CURRENCY_OPTIONS - } else if (price >= USD_SMALL_BALANCE_LOWER_BOUND) { + } else if (value >= USD_SMALL_BALANCE_LOWER_BOUND) { options = USD_SMALL_BALANCE_CURRENCY_OPTIONS } else { options = USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS } - return localizeNumber(price, lang, options) || '--' -} - -/** - * Formats USD balances with tiered precision based on value - * @param balance - The USD balance to format - * @param lang - Language for localization - * @returns Formatted USD balance string or '--' for zero balances - */ -export const formatUsdBalance = (balance: number, lang: string): string => { - if (balance === 0) { - return '--' - } - - let options - if (balance >= USD_REGULAR_BALANCE_LOWER_BOUND) { - options = USD_CURRENCY_OPTIONS - } else if (balance >= USD_SMALL_BALANCE_LOWER_BOUND) { - options = USD_SMALL_BALANCE_CURRENCY_OPTIONS - } else { - options = USD_EXTRA_SMALL_BALANCE_CURRENCY_OPTIONS - } - - return localizeNumber(balance, lang, options) || '--' + return localizeNumber(value, lang, options) || '--' } /** @@ -144,7 +121,7 @@ export const calculateFormattedUsdBalance = ( if (priceInUSD !== 0) { // Step 1: Format USD Price - formattedUsdPrice = formatUsdPrice(priceInUSD, lang) + formattedUsdPrice = formatUsdValue(priceInUSD, lang) const displayedUsdPrice = parseFloat( (formattedUsdPrice || '0').replace(/[$,]/g, ''), // Removes dollar signs and commas from USD prices like "$4,321.30" → "4321.30" ) @@ -157,7 +134,7 @@ export const calculateFormattedUsdBalance = ( // Step 3: Calculate USD Balance using displayed values const calculatedBalanceUSD = displayedUsdPrice * displayedBalance - formattedBalanceUsd = formatUsdBalance(calculatedBalanceUSD, lang) + formattedBalanceUsd = formatUsdValue(calculatedBalanceUSD, lang) } else { // If no price, still format the balance formattedBalance = formatTokenBalance(tokenBalance, lang) diff --git a/src/containers/shared/components/PaginatedTable/test/PaginatedTable.test.tsx b/src/containers/shared/components/PaginatedTable/test/PaginatedTable.test.tsx new file mode 100644 index 000000000..b75a102be --- /dev/null +++ b/src/containers/shared/components/PaginatedTable/test/PaginatedTable.test.tsx @@ -0,0 +1,325 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { PaginatedTable } from '../index' + +describe('PaginatedTable', () => { + const mockTableStructure = (data: any[]) => ( +
    - - - + - - - + {localizeNumber(token.supply, lang)} - - - + {token.transferFee}% @@ -161,3 +153,9 @@ export const IssuedMPTs = ({ accountId, onChange }: IssuedMPTsProps) => { ) } + +export const IssuedMPTs = ({ accountId, onChange }: IssuedMPTsProps) => ( + + + +) diff --git a/src/containers/Accounts/AccountAsset/styles.scss b/src/containers/Accounts/AccountAsset/styles.scss index a3444818c..52ec8fc0c 100644 --- a/src/containers/Accounts/AccountAsset/styles.scss +++ b/src/containers/Accounts/AccountAsset/styles.scss @@ -252,17 +252,3 @@ font-size: 18px; } } - -/* Clock icon styles */ -.future-feature { - display: inline-flex; - align-items: center; - justify-content: center; - cursor: default; - - .clock-icon { - width: px; - height: 16px; - color: $black-50; - } -} From e153c3a0c7541e994d46a0cbacca689065925464 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Sat, 4 Oct 2025 15:59:38 -0400 Subject: [PATCH 23/47] Add missing translations for account flag title and reorder some translation keys --- public/locales/ca-CA/translations.json | 42 ++++++++++++++++++-------- public/locales/es-ES/translations.json | 42 ++++++++++++++++++-------- public/locales/fr-FR/translations.json | 42 ++++++++++++++++++-------- public/locales/ja-JP/translations.json | 42 ++++++++++++++++++-------- public/locales/ko-KR/translations.json | 42 ++++++++++++++++++-------- public/locales/my-MM/translations.json | 42 ++++++++++++++++++-------- 6 files changed, 174 insertions(+), 78 deletions(-) diff --git a/public/locales/ca-CA/translations.json b/public/locales/ca-CA/translations.json index 940a959d3..0436db1a1 100644 --- a/public/locales/ca-CA/translations.json +++ b/public/locales/ca-CA/translations.json @@ -607,19 +607,35 @@ "withdraws": null, "deletes": null, "vault_delete_table_detail": null, - "account_flag_description_asf_global_freeze": null, - "account_flag_description_asf_no_freeze": null, - "account_flag_description_asf_default_ripple": null, - "account_flag_description_asf_require_dest": null, - "account_flag_description_asf_require_auth": null, - "account_flag_description_asf_disable_master": null, - "account_flag_description_asf_deposit_auth": null, - "account_flag_description_asf_authorized_n_f_token_minter": null, - "account_flag_description_asf_disable_incoming_n_f_token_offers": null, - "account_flag_description_asf_disable_incoming_checks": null, - "account_flag_description_asf_disable_incoming_payment_channels": null, - "account_flag_description_asf_disable_incoming_trustlines": null, - "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, "account_flag_description_asf_account_txn_id": null, "account_page_address": null, "account_page_address_tag": null, diff --git a/public/locales/es-ES/translations.json b/public/locales/es-ES/translations.json index fe582e1b1..4dbcdd926 100644 --- a/public/locales/es-ES/translations.json +++ b/public/locales/es-ES/translations.json @@ -608,19 +608,35 @@ "withdraws": null, "deletes": null, "vault_delete_table_detail": null, - "account_flag_description_asf_global_freeze": null, - "account_flag_description_asf_no_freeze": null, - "account_flag_description_asf_default_ripple": null, - "account_flag_description_asf_require_dest": null, - "account_flag_description_asf_require_auth": null, - "account_flag_description_asf_disable_master": null, - "account_flag_description_asf_deposit_auth": null, - "account_flag_description_asf_authorized_n_f_token_minter": null, - "account_flag_description_asf_disable_incoming_n_f_token_offers": null, - "account_flag_description_asf_disable_incoming_checks": null, - "account_flag_description_asf_disable_incoming_payment_channels": null, - "account_flag_description_asf_disable_incoming_trustlines": null, - "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, "account_flag_description_asf_account_txn_id": null, "account_page_address": null, "account_page_address_tag": null, diff --git a/public/locales/fr-FR/translations.json b/public/locales/fr-FR/translations.json index 9d6ad4231..b7f236b75 100644 --- a/public/locales/fr-FR/translations.json +++ b/public/locales/fr-FR/translations.json @@ -611,19 +611,35 @@ "withdraws": null, "deletes": null, "vault_delete_table_detail": null, - "account_flag_description_asf_global_freeze": null, - "account_flag_description_asf_no_freeze": null, - "account_flag_description_asf_default_ripple": null, - "account_flag_description_asf_require_dest": null, - "account_flag_description_asf_require_auth": null, - "account_flag_description_asf_disable_master": null, - "account_flag_description_asf_deposit_auth": null, - "account_flag_description_asf_authorized_n_f_token_minter": null, - "account_flag_description_asf_disable_incoming_n_f_token_offers": null, - "account_flag_description_asf_disable_incoming_checks": null, - "account_flag_description_asf_disable_incoming_payment_channels": null, - "account_flag_description_asf_disable_incoming_trustlines": null, - "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, "account_flag_description_asf_account_txn_id": null, "account_page_address": null, "account_page_address_tag": null, diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index 92044e03a..a69ef3856 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -610,19 +610,35 @@ "withdraws": null, "deletes": null, "vault_delete_table_detail": null, - "account_flag_description_asf_global_freeze": null, - "account_flag_description_asf_no_freeze": null, - "account_flag_description_asf_default_ripple": null, - "account_flag_description_asf_require_dest": null, - "account_flag_description_asf_require_auth": null, - "account_flag_description_asf_disable_master": null, - "account_flag_description_asf_deposit_auth": null, - "account_flag_description_asf_authorized_n_f_token_minter": null, - "account_flag_description_asf_disable_incoming_n_f_token_offers": null, - "account_flag_description_asf_disable_incoming_checks": null, - "account_flag_description_asf_disable_incoming_payment_channels": null, - "account_flag_description_asf_disable_incoming_trustlines": null, - "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, "account_flag_description_asf_account_txn_id": null, "account_page_address": null, "account_page_address_tag": null, diff --git a/public/locales/ko-KR/translations.json b/public/locales/ko-KR/translations.json index c0efb3a28..09e410363 100644 --- a/public/locales/ko-KR/translations.json +++ b/public/locales/ko-KR/translations.json @@ -608,19 +608,35 @@ "withdraws": null, "deletes": null, "vault_delete_table_detail": null, - "account_flag_description_asf_global_freeze": null, - "account_flag_description_asf_no_freeze": null, - "account_flag_description_asf_default_ripple": null, - "account_flag_description_asf_require_dest": null, - "account_flag_description_asf_require_auth": null, - "account_flag_description_asf_disable_master": null, - "account_flag_description_asf_deposit_auth": null, - "account_flag_description_asf_authorized_n_f_token_minter": null, - "account_flag_description_asf_disable_incoming_n_f_token_offers": null, - "account_flag_description_asf_disable_incoming_checks": null, - "account_flag_description_asf_disable_incoming_payment_channels": null, - "account_flag_description_asf_disable_incoming_trustlines": null, - "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, "account_flag_description_asf_account_txn_id": null, "account_page_address": null, "account_page_address_tag": null, diff --git a/public/locales/my-MM/translations.json b/public/locales/my-MM/translations.json index 77c331ce6..49b3af57f 100644 --- a/public/locales/my-MM/translations.json +++ b/public/locales/my-MM/translations.json @@ -608,19 +608,35 @@ "withdraws": null, "deletes": null, "vault_delete_table_detail": null, - "account_flag_description_asf_global_freeze": null, - "account_flag_description_asf_no_freeze": null, - "account_flag_description_asf_default_ripple": null, - "account_flag_description_asf_require_dest": null, - "account_flag_description_asf_require_auth": null, - "account_flag_description_asf_disable_master": null, - "account_flag_description_asf_deposit_auth": null, - "account_flag_description_asf_authorized_n_f_token_minter": null, - "account_flag_description_asf_disable_incoming_n_f_token_offers": null, - "account_flag_description_asf_disable_incoming_checks": null, - "account_flag_description_asf_disable_incoming_payment_channels": null, - "account_flag_description_asf_disable_incoming_trustlines": null, - "account_flag_description_asf_allow_trust_line_clawback": null, + "account_flag_title_lsf_global_freeze": null, + "account_flag_title_lsf_disable_master": null, + "account_flag_title_lsf_default_ripple": null, + "account_flag_title_lsf_allow_trustline_clawback": null, + "account_flag_title_lsf_require_destination_tag": null, + "account_flag_title_lsf_no_freeze": null, + "account_flag_title_lsf_require_auth": null, + "account_flag_title_lsf_disallow_xrp": null, + "account_flag_title_lsf_disallow_incoming_trustline": null, + "account_flag_title_lsf_disallow_incoming_pay_chan": null, + "account_flag_title_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_title_asf_authorized_nft_token_minter": null, + "account_flag_title_lsf_disallow_incoming_check": null, + "account_flag_title_lsf_deposit_auth": null, + "account_flag_title_asf_account_txn_id": null, + "account_flag_description_lsf_global_freeze": null, + "account_flag_description_lsf_disable_master": null, + "account_flag_description_lsf_default_ripple": null, + "account_flag_description_lsf_allow_trustline_clawback": null, + "account_flag_description_lsf_require_destination_tag": null, + "account_flag_description_lsf_no_freeze": null, + "account_flag_description_lsf_require_auth": null, + "account_flag_description_lsf_disallow_xrp": null, + "account_flag_description_lsf_disallow_incoming_trustline": null, + "account_flag_description_lsf_disallow_incoming_pay_chan": null, + "account_flag_description_lsf_disallow_incoming_nft_token_offer": null, + "account_flag_description_asf_authorized_nft_token_minter": null, + "account_flag_description_lsf_disallow_incoming_check": null, + "account_flag_description_lsf_deposit_auth": null, "account_flag_description_asf_account_txn_id": null, "account_page_address": null, "account_page_address_tag": null, From 13da037e62d1e26fc09a0c2f98694773a502f745 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Sat, 4 Oct 2025 17:06:50 -0400 Subject: [PATCH 24/47] Minor updates: reduce font-size for No Tokens Found text and replace magic numbers with constants --- src/containers/Accounts/AccountAsset/FutureDataIcon.tsx | 7 +++++-- src/containers/Accounts/AccountAsset/styles.scss | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/FutureDataIcon.tsx b/src/containers/Accounts/AccountAsset/FutureDataIcon.tsx index faaf2c84d..6bdb1199c 100644 --- a/src/containers/Accounts/AccountAsset/FutureDataIcon.tsx +++ b/src/containers/Accounts/AccountAsset/FutureDataIcon.tsx @@ -2,6 +2,9 @@ import ClockIcon from '../../shared/images/clock-icon.svg' import { useTooltip } from '../../shared/components/Tooltip' import './FutureDataIcon.scss' +const TOOLTIP_X_OFFSET = 10 +const TOOLTIP_Y_OFFSET = -120 + interface FutureDataIconProps { message?: string } @@ -21,8 +24,8 @@ export const FutureDataIcon = ({ const rect = e.currentTarget.getBoundingClientRect() showTooltip('text', e as any, message, { // The tooltip position is adjusted to appear below and slightly to the right of the icon - x: rect.left + window.scrollX + 10, - y: rect.top + window.scrollY - 120, + x: rect.left + window.scrollX + TOOLTIP_X_OFFSET, + y: rect.top + window.scrollY + TOOLTIP_Y_OFFSET, }) } diff --git a/src/containers/Accounts/AccountAsset/styles.scss b/src/containers/Accounts/AccountAsset/styles.scss index 52ec8fc0c..ffe56eb01 100644 --- a/src/containers/Accounts/AccountAsset/styles.scss +++ b/src/containers/Accounts/AccountAsset/styles.scss @@ -180,6 +180,11 @@ color: $black-40; font-size: 16px; text-align: center; + + @media (max-width: $tablet-portrait-upper-boundary) { + padding: 12px 14px; + font-size: 14px; + } } } From 3b7e50aa4bb54d0151d7cd2787a64eba6d027c1f Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Sat, 4 Oct 2025 17:07:23 -0400 Subject: [PATCH 25/47] Handle exception thrown from getBalances call --- .../Accounts/AccountAsset/assetTables/HeldIOUs.tsx | 14 +++++++++++--- .../AccountAsset/assetTables/HeldLPTokens.tsx | 13 ++++++++++--- .../AccountAsset/assetTables/IssuedIOUs.tsx | 11 +++++++++-- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index fd956a44e..bda54eb38 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -53,11 +53,19 @@ const fetchAccountHeldIOUs = async ( rippledSocket: any, accountId: string, ): Promise => { - log.info(`Finding held IOUs via 'gatewayBalance' for account ${accountId}`) - const balancesResponse = await getBalances(rippledSocket, accountId) + log.info(`Finding held IOUs via 'gatewayBalances' for account ${accountId}`) + let balancesResponse + try { + balancesResponse = await getBalances(rippledSocket, accountId) + } catch (error) { + log.error( + `Error calling gatewayBalances for account ${accountId}: ${JSON.stringify(error)}`, + ) + return [] + } const iouTokens: any[] = [] - for (const assets of Object.values(balancesResponse?.assets || {})) { + for (const assets of Object.values(balancesResponse?.assets ?? {})) { for (const asset of assets as any[]) { if (asset.currency && !asset.currency.startsWith(LP_TOKEN_IDENTIFIER)) { iouTokens.push(asset.currency) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx index fc333da75..a2c0f554e 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -26,11 +26,18 @@ const log = logger({ name: 'HeldLPTokens' }) const fetchAccountHeldLPTokens = async (rippledSocket, accountId) => { log.info(`Finding LP Tokens via 'gatewayBalance' for account ${accountId}`) - const balancesResponse = await getBalances(rippledSocket, accountId) - + let balancesResponse + try { + balancesResponse = await getBalances(rippledSocket, accountId) + } catch (error) { + log.error( + `Error calling gatewayBalances for account ${accountId}: ${JSON.stringify(error)}`, + ) + return [] + } const lpTokens: any[] = [] for (const [issuerAccount, assets] of Object.entries( - balancesResponse?.assets || {}, + balancesResponse?.assets ?? {}, )) { for (const asset of assets as any[]) { if (asset.currency && asset.currency.startsWith(LP_TOKEN_IDENTIFIER)) { diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index 1e32c9973..88b4172bb 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -47,8 +47,15 @@ const fetchAccountIssuedIOUs = async ( account: any, ): Promise => { log.info(`Finding issued IOUs via 'gatewayBalance' for account ${accountId}`) - const balancesResponse = await getBalances(rippledSocket, accountId) - + let balancesResponse + try { + balancesResponse = await getBalances(rippledSocket, accountId) + } catch (error) { + log.error( + `Error calling gatewayBalances for account ${accountId}: ${JSON.stringify(error)}`, + ) + return [] + } const iouTokens: string[] = [] if (balancesResponse?.obligations) { for (const currency of Object.keys(balancesResponse.obligations)) { From 70d98834f660726be255a343c3209c72cb335567 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Sat, 4 Oct 2025 20:41:59 -0400 Subject: [PATCH 26/47] Create getAMMInfoByAMMAccount and getAMMInfoByAssets --- .../Accounts/AMM/AMMAccounts/index.tsx | 4 +-- .../AMM/AMMAccounts/test/AMMAccounts.test.tsx | 2 +- src/rippled/index.js | 4 +-- src/rippled/lib/rippled.js | 31 +++++-------------- 4 files changed, 12 insertions(+), 29 deletions(-) diff --git a/src/containers/Accounts/AMM/AMMAccounts/index.tsx b/src/containers/Accounts/AMM/AMMAccounts/index.tsx index b3361d61a..5ba391f00 100644 --- a/src/containers/Accounts/AMM/AMMAccounts/index.tsx +++ b/src/containers/Accounts/AMM/AMMAccounts/index.tsx @@ -6,7 +6,7 @@ import '../../styles.scss' import { formatAmount } from '../../../../rippled/lib/txSummary/formatAmount' import { getAccountInfo, - getAMMInfo, + getAMMInfoByAssets, getLedgerEntry, } from '../../../../rippled/lib/rippled' import { Tabs } from '../../../shared/components/Tabs' @@ -76,7 +76,7 @@ export const AMMAccounts = () => { asset1 = temp } - return getAMMInfo(rippledSocket, asset1, asset2) + return getAMMInfoByAssets(rippledSocket, asset1, asset2) }) /* Use the assets to get the AMM Info. diff --git a/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx b/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx index 8f3a4f260..ac0ca7746 100644 --- a/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx +++ b/src/containers/Accounts/AMM/AMMAccounts/test/AMMAccounts.test.tsx @@ -12,7 +12,7 @@ import { ACCOUNT_ROUTE } from '../../../../App/routes' function setSpy(accountInfo: any, getLedgerEntry: any, ammInfo: any) { const spyAccountInfo = jest.spyOn(rippled, 'getAccountInfo') const spyLedgerEntry = jest.spyOn(rippled, 'getLedgerEntry') - const spyInfo = jest.spyOn(rippled, 'getAMMInfo') + const spyInfo = jest.spyOn(rippled, 'getAMMInfoByAssets') spyAccountInfo.mockReturnValue( new Promise((resolve) => { resolve(accountInfo) diff --git a/src/rippled/index.js b/src/rippled/index.js index 7928a12d7..874b45934 100644 --- a/src/rippled/index.js +++ b/src/rippled/index.js @@ -7,7 +7,7 @@ import getNegativeUNL from './nUNL' import getToken from './token' import getOffers from './offers' -import { getAccountInfo, getAMMInfo } from './lib/rippled' +import { getAccountInfo, getAMMInfoByAssets } from './lib/rippled' export { getAccountInfo, @@ -19,5 +19,5 @@ export { getNegativeUNL, getToken, getOffers, - getAMMInfo, + getAMMInfoByAssets, } diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js index 2a2aac00a..d4a83d236 100644 --- a/src/rippled/lib/rippled.js +++ b/src/rippled/lib/rippled.js @@ -599,12 +599,11 @@ const getOffers = ( return resp }) -const getAMMInfo = (rippledSocket, asset, asset2) => { +const getAMMInfo = (rippledSocket, params) => { const request = { command: 'amm_info', - asset, - asset2, ledger_index: 'validated', + ...params, } return query(rippledSocket, request).then((resp) => { @@ -622,27 +621,11 @@ const getAMMInfo = (rippledSocket, asset, asset2) => { }) } -const getAMMInfoByAMMAccount = (rippledSocket, ammAccount) => { - const request = { - command: 'amm_info', - amm_account: ammAccount, - ledger_index: 'validated', - } - - return query(rippledSocket, request).then((resp) => { - if (resp.error_message) { - throw new Error(resp.error_message) - } +const getAMMInfoByAssets = (rippledSocket, asset, asset2) => + getAMMInfo(rippledSocket, { asset, asset2 }) - if (!resp.validated) { - throw new Error( - 'Ledger is not validated. The response data is pending and might change', - ) - } - - return resp - }) -} +const getAMMInfoByAMMAccount = (rippledSocket, ammAccount) => + getAMMInfo(rippledSocket, { amm_account: ammAccount }) // get feature const getFeature = (rippledSocket, amendmentId) => { @@ -787,7 +770,7 @@ export { getBuyNFToffers, getSellNFToffers, getNFTTransactions, - getAMMInfo, + getAMMInfoByAssets, getAMMInfoByAMMAccount, getFeature, getMPTIssuance, From c98fdea0d49b2ec74fd84f159f5c6cf2ebaec88b Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Sat, 4 Oct 2025 20:42:46 -0400 Subject: [PATCH 27/47] Minor refactoring of HeldLPTokens.tsx --- .../AccountAsset/assetTables/HeldLPTokens.tsx | 106 +++++++++--------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx index a2c0f554e..c2582e42b 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -48,6 +48,8 @@ const fetchAccountHeldLPTokens = async (rippledSocket, accountId) => { asset, ) + // The currency code of an IOU could start with `LP_TOKEN_IDENTIFIER` as well, + // but `result` will be null since getAMMInfoByAMMAccount will throw an exception if (result) { lpTokens.push(result) } @@ -60,69 +62,67 @@ const fetchAccountHeldLPTokens = async (rippledSocket, accountId) => { } const processLPTokenAsset = async (rippledSocket, issuerAccount, asset) => { + log.info(`Fetching AMM pool for account ${issuerAccount}`) + let ammInfoResponse try { - log.info(`Fetching AMM pool for account ${issuerAccount}`) - const ammInfoResponse = await getAMMInfoByAMMAccount( - rippledSocket, - issuerAccount, - ) + ammInfoResponse = await getAMMInfoByAMMAccount(rippledSocket, issuerAccount) + } catch (error) { + log.error(`Error fetching AMM pool: ${JSON.stringify(error)}`) + return null + } - if (!ammInfoResponse.amm) { - return null - } + if (!ammInfoResponse.amm) { + return null + } - const ammData = ammInfoResponse.amm + const ammData = ammInfoResponse.amm - // Calculate share percentage - const accountLPTokenBalance = parseFloat(asset.value) - const ammLPTokenBalance = parseFloat(ammData.lp_token?.value || 0) - const sharePercentage = - ammLPTokenBalance > 0 - ? (100 * accountLPTokenBalance) / ammLPTokenBalance - : 0 + // Calculate share percentage + const accountLPTokenBalance = parseFloat(asset.value) + const ammLPTokenBalance = parseFloat(ammData.lp_token?.value || 0) + const sharePercentage = + ammLPTokenBalance > 0 + ? (100 * accountLPTokenBalance) / ammLPTokenBalance + : 0 - const isAmount1XRP = typeof ammData.amount === 'string' - const isAmount2XRP = typeof ammData.amount2 === 'string' + const isAmount1XRP = typeof ammData.amount === 'string' + const isAmount2XRP = typeof ammData.amount2 === 'string' - // Calculate LP Token price in XRP only when one of the assets is XRP - let lpTokenPriceInXRP = 0 - if (isAmount1XRP || isAmount2XRP) { - const xrpAmount = isAmount1XRP - ? parseFloat(ammData.amount) / XRP_BASE - : parseFloat(ammData.amount2) / XRP_BASE + // Calculate LP Token price in XRP only when one of the assets is XRP + let lpTokenPriceInXRP = 0 + if (isAmount1XRP || isAmount2XRP) { + const xrpAmount = isAmount1XRP + ? parseFloat(ammData.amount) / XRP_BASE + : parseFloat(ammData.amount2) / XRP_BASE - if (ammLPTokenBalance > 0 && xrpAmount > 0) { - // Price per LP token = (XRP in pool * 2) / total LP tokens - // We multiply by 2 because LP token represents share of both assets - lpTokenPriceInXRP = (xrpAmount * 2) / ammLPTokenBalance - } + if (ammLPTokenBalance > 0 && xrpAmount > 0) { + // Price per LP token = (XRP in pool * 2) / total LP tokens + // We multiply by 2 because LP token represents share of both assets + lpTokenPriceInXRP = (xrpAmount * 2) / ammLPTokenBalance } + } - let currency1 - let currency2 - if (isAmount1XRP && !isAmount2XRP) { - currency1 = 'XRP' - currency2 = ammData.amount2.currency - } else if (!isAmount1XRP && isAmount2XRP) { - currency1 = 'XRP' - currency2 = ammData.amount.currency - } else { - // Both are tokens (no XRP) - currency1 = ammData.amount.currency - currency2 = ammData.amount2.currency - } + let currency1 + let currency2 + if (isAmount1XRP && !isAmount2XRP) { + currency1 = 'XRP' + currency2 = ammData.amount2.currency + } else if (!isAmount1XRP && isAmount2XRP) { + currency1 = 'XRP' + currency2 = ammData.amount.currency + } else { + // Both are tokens (no XRP) + currency1 = ammData.amount.currency + currency2 = ammData.amount2.currency + } - return { - ammInstance: issuerAccount, - currency1, - currency2, - lpTokenBalance: accountLPTokenBalance, - lpTokenPriceInXRP, - share: sharePercentage, - } - } catch (error) { - log.error(`Error fetching AMM pool: ${JSON.stringify(error)}`) - return null + return { + ammInstance: issuerAccount, + currency1, + currency2, + lpTokenBalance: accountLPTokenBalance, + lpTokenPriceInXRP, + share: sharePercentage, } } From 2c174725b74c69bd1ae8a83569071363fccb4b04 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Sun, 5 Oct 2025 18:10:35 -0400 Subject: [PATCH 28/47] Update how to detect LP Tokens in IOU components --- .../AccountAsset/assetTables/HeldIOUs.tsx | 45 ++++++++++++++++--- .../AccountAsset/assetTables/IssuedIOUs.tsx | 17 +++---- .../Accounts/AccountSummary/styles.scss | 12 ++--- src/rippled/lib/rippled.js | 5 +-- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index bda54eb38..d84e724e5 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -59,15 +59,44 @@ const fetchAccountHeldIOUs = async ( balancesResponse = await getBalances(rippledSocket, accountId) } catch (error) { log.error( - `Error calling gatewayBalances for account ${accountId}: ${JSON.stringify(error)}`, + `Error calling 'gatewayBalances' for account ${accountId}: ${JSON.stringify(error)}`, ) return [] } + // Identify LP tokens by checking if it starts with '03' and the issuer account is an AMM account + const lpTokens = new Set() + const assetsByIssuer = Object.entries(balancesResponse?.assets ?? {}) + for (const [issuer, assets] of assetsByIssuer) { + const potentialLPTokenAssets = (assets as any[]).filter((asset) => + asset.currency?.startsWith(LP_TOKEN_IDENTIFIER), + ) + + if (potentialLPTokenAssets.length > 0) { + try { + // eslint-disable-next-line no-await-in-loop + const accountInfo = await getAccountInfo(rippledSocket, issuer, false) + if (accountInfo?.AMMID) { + for (const asset of potentialLPTokenAssets) { + lpTokens.add(asset.currency) + } + } + } catch (error) { + log.warn( + `Error fetching account info for issuer ${issuer}: ${JSON.stringify(error)}`, + ) + // If we can't fetch account info, assume it's not an AMM to be safe + } + } + } + + log.info(`Found ${lpTokens.size} LP Tokens`) + + // Collect all non-LP tokens const iouTokens: any[] = [] for (const assets of Object.values(balancesResponse?.assets ?? {})) { for (const asset of assets as any[]) { - if (asset.currency && !asset.currency.startsWith(LP_TOKEN_IDENTIFIER)) { + if (asset.currency && !lpTokens.has(asset.currency)) { iouTokens.push(asset.currency) } } @@ -77,6 +106,10 @@ const fetchAccountHeldIOUs = async ( return [] } + log.info( + `Identified ${lpTokens.size} LP tokens, proceeding with ${iouTokens.length} IOU currencies`, + ) + // Get all trust lines using account_lines with pagination const allTrustLines: any[] = [] let marker = '' @@ -105,11 +138,9 @@ const fetchAccountHeldIOUs = async ( } while (marker) log.info(`Found ${allTrustLines.length} trust lines`) - // Filter for positive balances and exclude LP tokens + // Filter for positive balances and exclude LP tokens (using the identified LP token set) const positiveBalanceLines = allTrustLines.filter( - (line: any) => - parseFloat(line.balance) > 0 && - !line.currency.startsWith(LP_TOKEN_IDENTIFIER), + (line: any) => parseFloat(line.balance) > 0 && !lpTokens.has(line.currency), ) log.info( `${positiveBalanceLines.length} IOU trust lines with positive balances`, @@ -219,7 +250,7 @@ export const HeldIOUs = ({ accountId, onChange }: HeldIOUsProps) => { try { log.info(`Fetching account information for account ${issuer}`) // eslint-disable-next-line no-await-in-loop - const accountInfo = await getAccountInfo(rippledSocket, issuer) + const accountInfo = await getAccountInfo(rippledSocket, issuer, false) const transferFee = formatTransferFee(accountInfo?.TransferRate, 'IOU') diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index 88b4172bb..215ea352d 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -1,9 +1,7 @@ import { useTranslation } from 'react-i18next' import { useQuery } from 'react-query' import { useEffect, useContext, useMemo } from 'react' -import Currency, { - LP_TOKEN_IDENTIFIER, -} from '../../../shared/components/Currency' +import Currency from '../../../shared/components/Currency' import { Loader } from '../../../shared/components/Loader' import { EmptyMessageTableRow } from '../../../shared/EmptyMessageTableRow' import { RouteLink } from '../../../shared/routing' @@ -56,14 +54,11 @@ const fetchAccountIssuedIOUs = async ( ) return [] } - const iouTokens: string[] = [] - if (balancesResponse?.obligations) { - for (const currency of Object.keys(balancesResponse.obligations)) { - if (!currency.startsWith(LP_TOKEN_IDENTIFIER)) { - iouTokens.push(currency) - } - } - } + + // We don't need to filter out LP tokens because if an account issued an LP token, + // it would be an AMM account and would be displayed on the AMM account page + // instead of a regular account page + const iouTokens: string[] = Object.keys(balancesResponse?.obligations ?? {}) if (iouTokens.length === 0) { // No IOUs issued by this account, return empty array return [] diff --git a/src/containers/Accounts/AccountSummary/styles.scss b/src/containers/Accounts/AccountSummary/styles.scss index c5566586d..1df29556c 100644 --- a/src/containers/Accounts/AccountSummary/styles.scss +++ b/src/containers/Accounts/AccountSummary/styles.scss @@ -307,13 +307,6 @@ background: $black-90; gap: 16px; - .details-title { - margin-bottom: 8px; - color: $black-0; - font-size: 20px; - @include semibold; - } - .details-list { display: grid; gap: 8px; @@ -387,7 +380,6 @@ .details-card { padding: 16px; } - .details-title { font-size: 18px; } .details-list { /* Keep as grid instead of flex to maintain alignment */ @@ -503,6 +495,10 @@ .properties-header { h3 { font-size: 18px; } } + + .properties-grid .card-header .header-title { + font-size: 18px; + } } } } diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js index d4a83d236..08d0ed425 100644 --- a/src/rippled/lib/rippled.js +++ b/src/rippled/lib/rippled.js @@ -178,14 +178,13 @@ const getTransaction = (rippledSocket, txId) => { }) } -// get account info -const getAccountInfo = (rippledSocket, account) => +const getAccountInfo = (rippledSocket, account, includeSignerLists = true) => query(rippledSocket, { command: 'account_info', api_version: 1, account, ledger_index: 'validated', - signer_lists: true, + signer_lists: includeSignerLists, }).then((resp) => { if (resp.error === 'actNotFound') { throw new Error('account not found', 404) From b540bd5b9befbdc75906db13894cc3021e1e6af3 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Mon, 6 Oct 2025 09:44:33 -0400 Subject: [PATCH 29/47] CSS update to transaction header to make it match other headers on the Account page --- .../AccountTransactionTable.tsx | 1 + .../Accounts/AccountTransactionTable/styles.scss | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 src/containers/Accounts/AccountTransactionTable/styles.scss diff --git a/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx b/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx index e2a7ab731..e8c220a3d 100644 --- a/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx +++ b/src/containers/Accounts/AccountTransactionTable/AccountTransactionTable.tsx @@ -7,6 +7,7 @@ import { useAnalytics } from '../../shared/analytics' import SocketContext from '../../shared/SocketContext' import { getAccountTransactions } from '../../../rippled' +import './styles.scss' export interface AccountTransactionsTableProps { accountId: string diff --git a/src/containers/Accounts/AccountTransactionTable/styles.scss b/src/containers/Accounts/AccountTransactionTable/styles.scss new file mode 100644 index 000000000..12c2cda10 --- /dev/null +++ b/src/containers/Accounts/AccountTransactionTable/styles.scss @@ -0,0 +1,13 @@ +@use '../../shared/css/variables' as *; + +.transactions-header { + @include bold; + + font-size: 20px; +} + +@media (max-width: $tablet-portrait-upper-boundary) { + .transactions-header { + font-size: 18px; + } +} From dbfc5060576b0055e34fa6a366fa26fc214a1888 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Mon, 6 Oct 2025 10:30:58 -0400 Subject: [PATCH 30/47] Remove info console logs to avoid logging debug logs in production --- .../AccountAsset/assetTables/HeldIOUs.tsx | 19 +++---------------- .../AccountAsset/assetTables/HeldLPTokens.tsx | 3 --- .../AccountAsset/assetTables/HeldMPTs.tsx | 9 --------- .../AccountAsset/assetTables/HeldNFTs.tsx | 3 --- .../AccountAsset/assetTables/IssuedIOUs.tsx | 7 +------ .../AccountAsset/assetTables/IssuedMPTs.tsx | 4 ---- .../AccountAsset/assetTables/IssuedNFTs.tsx | 5 ----- 7 files changed, 4 insertions(+), 46 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index d84e724e5..3c0098c0f 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -53,7 +53,6 @@ const fetchAccountHeldIOUs = async ( rippledSocket: any, accountId: string, ): Promise => { - log.info(`Finding held IOUs via 'gatewayBalances' for account ${accountId}`) let balancesResponse try { balancesResponse = await getBalances(rippledSocket, accountId) @@ -64,7 +63,7 @@ const fetchAccountHeldIOUs = async ( return [] } - // Identify LP tokens by checking if it starts with '03' and the issuer account is an AMM account + // Identify LP tokens by checking if it starts with '03' and its issuer account is an AMM account const lpTokens = new Set() const assetsByIssuer = Object.entries(balancesResponse?.assets ?? {}) for (const [issuer, assets] of assetsByIssuer) { @@ -90,8 +89,6 @@ const fetchAccountHeldIOUs = async ( } } - log.info(`Found ${lpTokens.size} LP Tokens`) - // Collect all non-LP tokens const iouTokens: any[] = [] for (const assets of Object.values(balancesResponse?.assets ?? {})) { @@ -106,10 +103,6 @@ const fetchAccountHeldIOUs = async ( return [] } - log.info( - `Identified ${lpTokens.size} LP tokens, proceeding with ${iouTokens.length} IOU currencies`, - ) - // Get all trust lines using account_lines with pagination const allTrustLines: any[] = [] let marker = '' @@ -136,15 +129,12 @@ const fetchAccountHeldIOUs = async ( break } } while (marker) - log.info(`Found ${allTrustLines.length} trust lines`) // Filter for positive balances and exclude LP tokens (using the identified LP token set) const positiveBalanceLines = allTrustLines.filter( (line: any) => parseFloat(line.balance) > 0 && !lpTokens.has(line.currency), ) - log.info( - `${positiveBalanceLines.length} IOU trust lines with positive balances`, - ) + if (positiveBalanceLines.length === 0) { return [] } @@ -185,9 +175,7 @@ const fetchAccountHeldIOUs = async ( ) } } - log.info( - `Successfully fetched LOS data for ${Object.keys(allTokens).length} held IOUs`, - ) + // Combine all data (without transfer fees and Global freeze status for now) const iouData: IOU[] = positiveBalanceLines.map((line: any) => { const tokenId = `${line.currency}.${line.account}` @@ -248,7 +236,6 @@ export const HeldIOUs = ({ accountId, onChange }: HeldIOUsProps) => { const uniqueIssuers = [...new Set(sortedIOUs.map((line) => line.issuer))] for (const issuer of uniqueIssuers) { try { - log.info(`Fetching account information for account ${issuer}`) // eslint-disable-next-line no-await-in-loop const accountInfo = await getAccountInfo(rippledSocket, issuer, false) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx index c2582e42b..4de196d18 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldLPTokens.tsx @@ -25,7 +25,6 @@ import logger from '../../../../rippled/lib/logger' const log = logger({ name: 'HeldLPTokens' }) const fetchAccountHeldLPTokens = async (rippledSocket, accountId) => { - log.info(`Finding LP Tokens via 'gatewayBalance' for account ${accountId}`) let balancesResponse try { balancesResponse = await getBalances(rippledSocket, accountId) @@ -57,12 +56,10 @@ const fetchAccountHeldLPTokens = async (rippledSocket, accountId) => { } } - log.info(`Found ${lpTokens.length} LP Tokens`) return lpTokens } const processLPTokenAsset = async (rippledSocket, issuerAccount, asset) => { - log.info(`Fetching AMM pool for account ${issuerAccount}`) let ammInfoResponse try { ammInfoResponse = await getAMMInfoByAMMAccount(rippledSocket, issuerAccount) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx index 5a94ef28c..b5d971e18 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx @@ -35,8 +35,6 @@ interface HeldMPTsProps { } const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { - log.info(`Fetching MPTs for account ${accountId}`) - const mpts: any[] = [] let marker = '' do { @@ -56,18 +54,14 @@ const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { } } while (marker) - log.info(`Successfully fetched ${mpts.length} MPTs`) - // Format and filter MPTs const positiveBalanceMPTs = mpts .map((mpToken: any) => formatMPToken(mpToken)) .filter((mpToken: any) => parseInt(mpToken.mptAmount || '0', 10) > 0) - log.info(`${positiveBalanceMPTs.length} MPTs with positive MPTAmount`) // For each MPTokenIssuanceID, call getMPTIssuance and format the response const mptIssuancePromises = positiveBalanceMPTs.map(async (mpToken: any) => { try { - log.info(`Fetching MPT issuance for token ${mpToken.mptIssuanceID}`) const mptIssuanceResponse = await getMPTIssuance( rippledSocket, mpToken.mptIssuanceID, @@ -87,9 +81,6 @@ const fetchAccountHeldMPTs = async (accountId: string, rippledSocket: any) => { }) const mptIssuanceResults = await Promise.all(mptIssuancePromises) - log.info( - `Successfully fetched ${mptIssuanceResults.length} MPT issuances for ${positiveBalanceMPTs.length} MPTs`, - ) const mptIssuanceIdToIssuance = new Map() mptIssuanceResults.forEach((result) => { if (result && result.mptIssuance) { diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx index 0e1d59431..5ad539c78 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldNFTs.tsx @@ -15,7 +15,6 @@ const fetchAccountHeldNFTs = async ( rippledSocket: any, ): Promise => { try { - log.info(`Fetching held NFTs for account ${accountId}`) const allNFTs: any[] = [] let marker = '' do { @@ -28,13 +27,11 @@ const fetchAccountHeldNFTs = async ( ) if (response.account_nfts) { allNFTs.push(...response.account_nfts) - log.info(`${allNFTs.length} held NFTs fetched`) } marker = response.marker || '' } while (marker) - log.info(`Successfully fetched ${allNFTs.length} held NFTs`) return allNFTs.map((nft) => ({ nftId: nft.NFTokenID, issuer: nft.Issuer, diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx index 215ea352d..01bdb6556 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedIOUs.tsx @@ -44,7 +44,6 @@ const fetchAccountIssuedIOUs = async ( accountId: string, account: any, ): Promise => { - log.info(`Finding issued IOUs via 'gatewayBalance' for account ${accountId}`) let balancesResponse try { balancesResponse = await getBalances(rippledSocket, accountId) @@ -63,7 +62,6 @@ const fetchAccountIssuedIOUs = async ( // No IOUs issued by this account, return empty array return [] } - log.info(`Found ${iouTokens.length} issued IOUs`) // Batch get token data from LOS Token API const allTokenIds = iouTokens.map((currency) => `${currency}.${accountId}`) @@ -99,9 +97,6 @@ const fetchAccountIssuedIOUs = async ( ) } } - log.info( - `Successfully fetched LOS data for ${Object.keys(allTokens).length} issued IOUs`, - ) const transferFee = account?.info?.rate const accountGlobalFreeze = account?.info?.flags?.includes('lsfGlobalFreeze') @@ -118,7 +113,7 @@ const fetchAccountIssuedIOUs = async ( const apiSupply = token?.supply ? parseFloat(token.supply) : 0 if (apiSupply > obligationSupply) { const diff = apiSupply - obligationSupply - log.info( + log.warn( `Supply for ${currency}: GatewayBalance=${obligationSupply}, API=${apiSupply}, Likely Frozen=${diff}`, ) } diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx index 1db8e3a27..517593792 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedMPTs.tsx @@ -32,8 +32,6 @@ const fetchAccountIssuedMPTs = async ( accountId: string, rippledSocket: any, ) => { - log.info(`Fetching MPT Issuances for account ${accountId}`) - const mptIssuances: any[] = [] let marker = '' do { @@ -58,8 +56,6 @@ const fetchAccountIssuedMPTs = async ( } } while (marker) - log.info(`Successfully fetched ${mptIssuances.length} MPT Issuances`) - // Format the MPT issuances const issuedMPTs = mptIssuances.map((mptIssuance: any) => { const formattedIssuance = formatMPTIssuance(mptIssuance) diff --git a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx index 3a6aa8492..2c27dc59f 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/IssuedNFTs.tsx @@ -15,7 +15,6 @@ const fetchAccountIssuedNFTs = async ( rippledSocket: any, ): Promise => { try { - log.info(`Fetching issued NFTs for account ${accountId}`) const allNFTs: any[] = [] let marker = '' do { @@ -28,15 +27,11 @@ const fetchAccountIssuedNFTs = async ( ) if (response.nfts) { allNFTs.push(...response.nfts) - if (allNFTs.length % 200 === 0) { - log.info(`${allNFTs.length} issued NFTs fetched`) - } } marker = response.marker || '' } while (marker) - log.info(`Successfully fetched ${allNFTs.length} issued NFTs`) return allNFTs.map((nft) => ({ nftId: nft.nft_id, url: nft.uri ? Buffer.from(nft.uri, 'hex').toString('utf8') : '', From 048e67985fcad86bd2443a9364e47bea2aa43acd Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Mon, 6 Oct 2025 21:13:56 -0400 Subject: [PATCH 31/47] Rename shortAccount to displayText for Account.tsx --- .../Accounts/AccountAsset/assetTables/HeldIOUs.tsx | 2 +- .../Accounts/AccountAsset/assetTables/HeldLPTokens.tsx | 2 +- .../Accounts/AccountAsset/assetTables/HeldMPTs.tsx | 7 +++---- .../Accounts/AccountAsset/assetTables/NFTTable.tsx | 2 +- src/containers/Accounts/AccountSummary/SignersCard.tsx | 2 +- src/containers/shared/components/Account.tsx | 8 ++++---- 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index 3c0098c0f..6be842c14 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -334,7 +334,7 @@ export const HeldIOUs = ({ accountId, onChange }: HeldIOUsProps) => { diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx index b5d971e18..20f55e3c4 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldMPTs.tsx @@ -172,11 +172,10 @@ const HeldMPTsContent = ({ accountId, onChange }: HeldMPTsProps) => { - {token.issuerName && ( -
    {token.issuerName}
    - )}
    diff --git a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx index d62185b32..f3b040117 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/NFTTable.tsx @@ -241,7 +241,7 @@ export const NFTTable = ({ {showIssuer && nft.issuer && ( {formatUsdPrice(token.priceInUSD, lang)}{formatUsdValue(token.priceInUSD, lang)} {localizeNumber(token.trustlines, lang)} {localizeNumber(token.holders, lang)} {formatTokenBalance(token.supply, lang)}
    + + {data.map((item) => ( + + + + ))} + +
    {item.name}
    + ) + + const generateMockData = (count: number) => + Array.from({ length: count }, (_, i) => ({ name: `Item ${i + 1}` })) + + describe('Rendering', () => { + it('renders without crashing with empty data', () => { + render() + expect(screen.queryByRole('navigation')).not.toBeInTheDocument() + }) + + it('renders table with data', () => { + const data = generateMockData(5) + render() + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 5')).toBeInTheDocument() + }) + + it('does not render pagination when data fits in one page', () => { + const data = generateMockData(5) + render( + , + ) + expect(screen.queryByRole('navigation')).not.toBeInTheDocument() + }) + + it('renders pagination when data exceeds page size', () => { + const data = generateMockData(15) + render( + , + ) + expect( + screen.getByRole('navigation', { name: 'Table pagination' }), + ).toBeInTheDocument() + }) + }) + + describe('Pagination controls', () => { + it('renders all pagination buttons', () => { + const data = generateMockData(15) + render( + , + ) + expect(screen.getByLabelText('First page')).toBeInTheDocument() + expect(screen.getByLabelText('Previous page')).toBeInTheDocument() + expect(screen.getByLabelText('Next page')).toBeInTheDocument() + expect(screen.getByLabelText('Last page')).toBeInTheDocument() + }) + + it('disables prev/first buttons on first page', () => { + const data = generateMockData(15) + render( + , + ) + expect(screen.getByLabelText('First page')).toBeDisabled() + expect(screen.getByLabelText('Previous page')).toBeDisabled() + expect(screen.getByLabelText('Next page')).not.toBeDisabled() + expect(screen.getByLabelText('Last page')).not.toBeDisabled() + }) + + it('disables next/last buttons on last page', () => { + const data = generateMockData(15) + render( + , + ) + const lastPageButton = screen.getByLabelText('Last page') + fireEvent.click(lastPageButton) + + expect(screen.getByLabelText('First page')).not.toBeDisabled() + expect(screen.getByLabelText('Previous page')).not.toBeDisabled() + expect(screen.getByLabelText('Next page')).toBeDisabled() + expect(screen.getByLabelText('Last page')).toBeDisabled() + }) + + it('marks current page as active', () => { + const data = generateMockData(15) + render( + , + ) + const page1Button = screen.getByRole('button', { + name: '1', + current: 'page', + }) + expect(page1Button).toHaveClass('is-active') + }) + }) + + describe('Navigation', () => { + it('navigates to next page', () => { + const data = generateMockData(25) + render( + , + ) + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.queryByText('Item 11')).not.toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Next page')) + + expect(screen.queryByText('Item 1')).not.toBeInTheDocument() + expect(screen.getByText('Item 11')).toBeInTheDocument() + }) + + it('navigates to previous page', () => { + const data = generateMockData(25) + render( + , + ) + fireEvent.click(screen.getByLabelText('Next page')) + fireEvent.click(screen.getByLabelText('Previous page')) + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.queryByText('Item 11')).not.toBeInTheDocument() + }) + + it('navigates to first page', () => { + const data = generateMockData(25) + render( + , + ) + fireEvent.click(screen.getByLabelText('Last page')) + fireEvent.click(screen.getByLabelText('First page')) + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.queryByText('Item 21')).not.toBeInTheDocument() + }) + + it('navigates to last page', () => { + const data = generateMockData(25) + render( + , + ) + fireEvent.click(screen.getByLabelText('Last page')) + + expect(screen.queryByText('Item 1')).not.toBeInTheDocument() + expect(screen.getByText('Item 21')).toBeInTheDocument() + expect(screen.getByText('Item 25')).toBeInTheDocument() + }) + + it('navigates to specific page by clicking page number', () => { + const data = generateMockData(30) + render( + , + ) + const page2Button = screen.getByRole('button', { name: '2' }) + fireEvent.click(page2Button) + + expect(screen.queryByText('Item 1')).not.toBeInTheDocument() + expect(screen.getByText('Item 11')).toBeInTheDocument() + expect(screen.getByText('Item 20')).toBeInTheDocument() + }) + }) + + describe('Page items display', () => { + it('shows all pages when total pages <= 7', () => { + const data = generateMockData(70) + render( + , + ) + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '7' })).toBeInTheDocument() + expect(screen.queryByText('…')).not.toBeInTheDocument() + }) + + it('shows ellipsis at end when current page <= 4', () => { + const data = generateMockData(100) + render( + , + ) + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '5' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '10' })).toBeInTheDocument() + expect(screen.getAllByText('…')).toHaveLength(1) + }) + + it('shows ellipsis at start when current page >= total - 3', () => { + const data = generateMockData(100) + render( + , + ) + fireEvent.click(screen.getByLabelText('Last page')) + + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '6' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '10' })).toBeInTheDocument() + expect(screen.getAllByText('…')).toHaveLength(1) + }) + + it('shows ellipsis at both ends when in middle pages', () => { + const data = generateMockData(100) + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: '5' })) + + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '5' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '7' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '10' })).toBeInTheDocument() + expect(screen.getAllByText('…')).toHaveLength(2) + }) + }) + + describe('Data pagination', () => { + it('displays correct data for each page', () => { + const data = generateMockData(25) + render( + , + ) + + // Page 1: Items 1-10 + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 10')).toBeInTheDocument() + expect(screen.queryByText('Item 11')).not.toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Next page')) + + // Page 2: Items 11-20 + expect(screen.queryByText('Item 10')).not.toBeInTheDocument() + expect(screen.getByText('Item 11')).toBeInTheDocument() + expect(screen.getByText('Item 20')).toBeInTheDocument() + expect(screen.queryByText('Item 21')).not.toBeInTheDocument() + + fireEvent.click(screen.getByLabelText('Next page')) + + // Page 3: Items 21-25 + expect(screen.queryByText('Item 20')).not.toBeInTheDocument() + expect(screen.getByText('Item 21')).toBeInTheDocument() + expect(screen.getByText('Item 25')).toBeInTheDocument() + }) + + it('uses custom page size', () => { + const data = generateMockData(15) + render( + , + ) + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 5')).toBeInTheDocument() + expect(screen.queryByText('Item 6')).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/containers/shared/components/test/Account.test.tsx b/src/containers/shared/components/test/Account.test.tsx index 958463089..2b7dc1d35 100644 --- a/src/containers/shared/components/test/Account.test.tsx +++ b/src/containers/shared/components/test/Account.test.tsx @@ -72,4 +72,29 @@ describe('Account', () => { expect(element).toHaveAttribute('title', ACCOUNT) expect(screen.queryByTestId('dt')).toHaveTextContent(':123') }) + + it('should render with displayText', () => { + renderComponent( + , + ) + const element = screen.getByTestId('account') + expect(element).toHaveClass('account') + expect(element).toHaveTextContent('Custom Display Name') + expect(element).toHaveAttribute('href', `/accounts/${ACCOUNT}`) + expect(element).toHaveAttribute('title', ACCOUNT) + }) + + it('should render with displayText and no link', () => { + renderComponent( + , + ) + const element = screen.getByTestId('account') + expect(element).toHaveTextContent('Custom Display Name') + expect(element).not.toHaveAttribute('href') + expect(element).toHaveAttribute('title', ACCOUNT) + }) }) diff --git a/src/containers/shared/components/test/DomainLink.test.tsx b/src/containers/shared/components/test/DomainLink.test.tsx index 57e83ad77..2f2993fb1 100644 --- a/src/containers/shared/components/test/DomainLink.test.tsx +++ b/src/containers/shared/components/test/DomainLink.test.tsx @@ -1,72 +1,82 @@ -import { mount } from 'enzyme' +import { render, screen } from '@testing-library/react' import DomainLink from '../DomainLink' describe('DomainLink', () => { it('handles domain link with only domain parameter', () => { - const wrapper = mount() - expect(wrapper.find('a').props().className).toEqual('domain') - expect(wrapper.find('a').text()).toEqual('bithomp.com') - expect(wrapper.find('a').props().href).toEqual('https://bithomp.com') - wrapper.unmount() + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent('bithomp.com') + expect(link).toHaveAttribute('href', 'https://bithomp.com') }) it('handles domain link with decoded domain parameter', () => { - const wrapper = mount( - , - ) - expect(wrapper.find('a').props().className).toEqual('domain') - expect(wrapper.find('a').text()).toEqual('sologenic.com') - expect(wrapper.find('a').props().href).toEqual('https://sologenic.com') - wrapper.unmount() + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent('sologenic.com') + expect(link).toHaveAttribute('href', 'https://sologenic.com') }) it('handles domain link with domain parameter and classname', () => { - const wrapper = mount() - expect(wrapper.find('a').props().className).toEqual('domain test') - expect(wrapper.find('a').text()).toEqual('bithomp.com') - expect(wrapper.find('a').props().href).toEqual('https://bithomp.com') - wrapper.unmount() + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain test') + expect(link).toHaveTextContent('bithomp.com') + expect(link).toHaveAttribute('href', 'https://bithomp.com') }) it('handles domain link with decoded domain parameter and classname', () => { - const wrapper = mount( + render( , ) - expect(wrapper.find('a').props().className).toEqual('domain test') - expect(wrapper.find('a').text()).toEqual('sologenic.com') - expect(wrapper.find('a').props().href).toEqual('https://sologenic.com') - wrapper.unmount() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain test') + expect(link).toHaveTextContent('sologenic.com') + expect(link).toHaveAttribute('href', 'https://sologenic.com') }) it('handles domain link with domain provided in HEX-encoded format', () => { const url = 'https://example.com' const urlInHex = '68747470733A2F2F6578616D706C652E636F6D' - const wrapper = mount() - expect(wrapper.find('a').props().className).toEqual('domain') - expect(wrapper.find('a').text()).toEqual(url) - expect(wrapper.find('a').props().href).toEqual(url) - wrapper.unmount() + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent(url) + expect(link).toHaveAttribute('href', url) }) it('handles IPFS domain link', () => { - const wrapper = mount() - expect(wrapper.find('a').props().className).toEqual('domain') - expect(wrapper.find('a').text()).toEqual('ipfs://random/metadata.json') - expect(wrapper.find('a').props().href).toEqual( + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent('ipfs://random/metadata.json') + expect(link).toHaveAttribute( + 'href', 'https://ipfs.io/ipfs/random/metadata.json', ) - wrapper.unmount() }) it('handles domain link with protocol removal', () => { const url = 'https://example.com/' - const wrapper = mount() - expect(wrapper.find('a').props().className).toEqual('domain') - expect(wrapper.find('a').text()).toEqual('example.com') - expect(wrapper.find('a').props().href).toEqual('https://example.com/') + render() + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent('example.com') + expect(link).toHaveAttribute('href', 'https://example.com/') + }) + + it('handles domain link with displayDomain', () => { + render( + , + ) + const link = screen.getByRole('link') + expect(link).toHaveClass('domain') + expect(link).toHaveTextContent('Custom Display Text') + expect(link).toHaveAttribute('href', 'https://bithomp.com') }) }) diff --git a/src/containers/shared/components/test/NFTokenLink.test.tsx b/src/containers/shared/components/test/NFTokenLink.test.tsx new file mode 100644 index 000000000..aa9985318 --- /dev/null +++ b/src/containers/shared/components/test/NFTokenLink.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react' +import { BrowserRouter } from 'react-router-dom' +import { NFTokenLink } from '../NFTokenLink' + +const renderComponent = (component: JSX.Element) => + render({component}) + +const TOKEN_ID = + '000827103B94ECBB7BF0A0A6ED62B3607801A27B65F4B11F5E1D5E8A3F3D8E9A' + +describe('NFTokenLink', () => { + it('should render with full tokenID', () => { + renderComponent() + const link = screen.getByRole('link') + expect(link).toHaveTextContent(TOKEN_ID) + expect(link).toHaveAttribute('href', `/nft/${TOKEN_ID}`) + expect(link).toHaveAttribute('title', TOKEN_ID) + }) + + it('should render with shortTokenID when provided', () => { + const shortID = '000827...3D8E9A' + renderComponent() + const link = screen.getByRole('link') + expect(link).toHaveTextContent(shortID) + expect(link).toHaveAttribute('href', `/nft/${TOKEN_ID}`) + expect(link).toHaveAttribute('title', TOKEN_ID) + }) + + it('should use full tokenID when shortTokenID is empty string', () => { + renderComponent() + const link = screen.getByRole('link') + expect(link).toHaveTextContent(TOKEN_ID) + expect(link).toHaveAttribute('href', `/nft/${TOKEN_ID}`) + expect(link).toHaveAttribute('title', TOKEN_ID) + }) +}) diff --git a/src/containers/shared/test/NumberFormattingUtils.test.ts b/src/containers/shared/test/NumberFormattingUtils.test.ts new file mode 100644 index 000000000..d282e8532 --- /dev/null +++ b/src/containers/shared/test/NumberFormattingUtils.test.ts @@ -0,0 +1,178 @@ +import { + formatUsdValue, + formatTokenBalance, + calculateFormattedUsdBalance, +} from '../NumberFormattingUtils' + +describe('NumberFormattingUtils', () => { + const lang = 'en-US' + + describe('formatUsdValue', () => { + it('returns "--" for zero value', () => { + expect(formatUsdValue(0, lang)).toBe('--') + }) + + it('formats regular USD value (>= $1) with 2 decimals', () => { + expect(formatUsdValue(1, lang)).toBe('$1.00') + expect(formatUsdValue(100, lang)).toBe('$100.00') + expect(formatUsdValue(23.565, lang)).toBe('$23.57') + expect(formatUsdValue(5678.9, lang)).toBe('$5,678.90') + }) + + it('formats small USD value (>= $0.0001, < $1) with 4 decimals', () => { + expect(formatUsdValue(0.0001, lang)).toBe('$0.0001') + expect(formatUsdValue(0.00014, lang)).toBe('$0.0001') + expect(formatUsdValue(0.00015, lang)).toBe('$0.0002') + expect(formatUsdValue(0.25, lang)).toBe('$0.25') + expect(formatUsdValue(0.5, lang)).toBe('$0.50') + expect(formatUsdValue(0.9999, lang)).toBe('$0.9999') + expect(formatUsdValue(0.99995, lang)).toBe('$1.00') + }) + + it('formats extra small USD value (< $0.0001) with up to 10 decimals', () => { + expect(formatUsdValue(0.00000000051, lang)).toBe('$0.0000000005') + expect(formatUsdValue(0.000001, lang)).toBe('$0.000001') + expect(formatUsdValue(0.0000051, lang)).toBe('$0.0000051') + expect(formatUsdValue(0.000014, lang)).toBe('$0.000014') + expect(formatUsdValue(0.00005, lang)).toBe('$0.00005') + }) + + it('handles negative values', () => { + expect(formatUsdValue(-100, lang)).toBe('-$100.00') + expect(formatUsdValue(-0.0001, lang)).toBe('-$0.0001') + expect(formatUsdValue(-200, lang)).toBe('-$200.00') + expect(formatUsdValue(-0.0005, lang)).toBe('-$0.0005') + }) + }) + + describe('formatTokenBalance', () => { + it('formats large token balance (> 999) with 2 decimals', () => { + expect(formatTokenBalance(1000, lang)).toBe('1,000') + expect(formatTokenBalance(5000, lang)).toBe('5,000') + expect(formatTokenBalance(1234567.891, lang)).toBe('1,234,567.89') + }) + + it('formats small token balance (<= 999) with 4 decimals', () => { + expect(formatTokenBalance(0, lang)).toBe('0') + expect(formatTokenBalance(1, lang)).toBe('1') + expect(formatTokenBalance(100, lang)).toBe('100') + expect(formatTokenBalance(999, lang)).toBe('999') + expect(formatTokenBalance(0.1234, lang)).toBe('0.1234') + expect(formatTokenBalance(500.56784, lang)).toBe('500.5678') + expect(formatTokenBalance(500.56785, lang)).toBe('500.5679') + }) + + it('handles negative token balances', () => { + expect(formatTokenBalance(-1000, lang)).toBe('-1,000') + expect(formatTokenBalance(-100, lang)).toBe('-100') + }) + }) + + describe('calculateFormattedUsdBalance', () => { + it('returns all "--" when price is zero', () => { + const result = calculateFormattedUsdBalance(1000, 0, lang) + expect(result).toEqual({ + formattedUsdPrice: '--', + formattedBalance: '1,000', // Balance is formatted even when price is zero + formattedBalanceUsd: '--', + }) + }) + + it('calculates formatted USD balance correctly for regular values', () => { + const result = calculateFormattedUsdBalance(100, 50, lang) + expect(result).toEqual({ + formattedUsdPrice: '$50.00', + formattedBalance: '100', + formattedBalanceUsd: '$5,000.00', + }) + }) + + it('calculates formatted USD balance correctly for small token balance', () => { + const result = calculateFormattedUsdBalance(0.5, 100, lang) + expect(result).toEqual({ + formattedUsdPrice: '$100.00', + formattedBalance: '0.5', + formattedBalanceUsd: '$50.00', + }) + }) + + it('calculates formatted USD balance correctly for small price', () => { + const result = calculateFormattedUsdBalance(1000, 0.0005, lang) + expect(result).toEqual({ + formattedUsdPrice: '$0.0005', + formattedBalance: '1,000', + formattedBalanceUsd: '$0.50', + }) + }) + + it('calculates formatted USD balance correctly for extra small price', () => { + const result = calculateFormattedUsdBalance(10000, 0.00001, lang) + expect(result).toEqual({ + formattedUsdPrice: '$0.00001', + formattedBalance: '10,000', + formattedBalanceUsd: '$0.10', + }) + }) + + it('handles calculations resulting in small USD balances', () => { + const result = calculateFormattedUsdBalance(10, 0.05, lang) + expect(result).toEqual({ + formattedUsdPrice: '$0.05', + formattedBalance: '10', + formattedBalanceUsd: '$0.50', + }) + }) + + it('handles calculations with decimal token balances', () => { + const result = calculateFormattedUsdBalance(123.4561, 2.5, lang) + expect(result).toEqual({ + formattedUsdPrice: '$2.50', + formattedBalance: '123.4561', + formattedBalanceUsd: '$308.64', + }) + }) + + it('handles large token balances with regular prices', () => { + const result = calculateFormattedUsdBalance(5000, 1.25, lang) + expect(result).toEqual({ + formattedUsdPrice: '$1.25', + formattedBalance: '5,000', + formattedBalanceUsd: '$6,250.00', + }) + }) + + it('handles calculations that result in very small USD balances', () => { + const result = calculateFormattedUsdBalance(0.1, 0.0001, lang) + expect(result).toEqual({ + formattedUsdPrice: '$0.0001', + formattedBalance: '0.1', + formattedBalanceUsd: '$0.00001', + }) + }) + + it('handles zero token balance', () => { + const result = calculateFormattedUsdBalance(0, 100, lang) + expect(result).toEqual({ + formattedUsdPrice: '$100.00', + formattedBalance: '0', + formattedBalanceUsd: '--', + }) + }) + + it('correctly removes commas and dollar signs in calculation', () => { + // Large values that will have commas + const result = calculateFormattedUsdBalance(1234, 5.671, lang) + expect(result.formattedUsdPrice).toBe('$5.67') + expect(result.formattedBalance).toBe('1,234') + // 1234 * 5.67 = 6,996.78 + expect(result.formattedBalanceUsd).toBe('$6,996.78') + }) + }) + + describe('Edge Cases', () => { + it('handles very large numbers', () => { + expect(formatUsdValue(1000000, lang)).toBe('$1,000,000.00') + expect(formatTokenBalance(999999999, lang)).toBe('999,999,999') + }) + }) +}) diff --git a/src/containers/shared/test/utils.test.ts b/src/containers/shared/test/utils.test.ts index bc4994268..234cdfd50 100644 --- a/src/containers/shared/test/utils.test.ts +++ b/src/containers/shared/test/utils.test.ts @@ -7,6 +7,10 @@ import { localizeDate, durationToHuman, formatAsset, + shortenAccount, + shortenDomain, + shortenNFTTokenID, + shortenMPTID, } from '../utils' describe('utils', () => { @@ -174,3 +178,55 @@ describe('AMM utils format asset', () => { expect(formatted).toEqual({ currency: 'USD', issuer: 'your mom' }) }) }) + +describe('Shorten utils', () => { + describe('shortenAccount', () => { + it('shortens long account addresses', () => { + const longAccount = 'rN7n7otQDd6FczFgLdlqtyMVrn5f4W01dn' + expect(shortenAccount(longAccount)).toBe('rN7n7ot...W01dn') + }) + + it('returns short account addresses unchanged', () => { + const shortAccount = 'rShortAddr' + expect(shortenAccount(shortAccount)).toBe(shortAccount) + }) + }) + + describe('shortenDomain', () => { + it('shortens long domain names', () => { + const longDomain = 'verylongdomainnamethatexceedslimit.com' + expect(shortenDomain(longDomain)).toBe('verylongdomainn...dslimit.com') + }) + + it('returns short domain names unchanged', () => { + const shortDomain = 'example.com' + expect(shortenDomain(shortDomain)).toBe(shortDomain) + }) + }) + + describe('shortenNFTTokenID', () => { + it('shortens long NFT token IDs', () => { + const longTokenID = + '000827103B94ECBB7BF0A0A6ED62B3607801A27B65F4B11F5E1D5E8A3F3D8E9A' + expect(shortenNFTTokenID(longTokenID)).toBe('000827103B...8A3F3D8E9A') + }) + + it('returns short NFT token IDs unchanged', () => { + const shortTokenID = '000827103B94ECBB7BF0' + expect(shortenNFTTokenID(shortTokenID)).toBe(shortTokenID) + }) + }) + + describe('shortenMPTID', () => { + it('shortens long MPT token IDs', () => { + const longMPTID = + '00000000A8B71A79C3CE4E8A3F3D8E9A5BEB9D7C6F4B11F5E1D5E8A3F3D8E9A' + expect(shortenMPTID(longMPTID)).toBe('00000000A8...8A3F3D8E9A') + }) + + it('returns short MPT token IDs unchanged', () => { + const shortMPTID = '00000000A8B71A79C3CE' + expect(shortenMPTID(shortMPTID)).toBe(shortMPTID) + }) + }) +}) diff --git a/src/rippled/lib/test/utils.test.ts b/src/rippled/lib/test/utils.test.ts index bd6f0478c..134348d93 100644 --- a/src/rippled/lib/test/utils.test.ts +++ b/src/rippled/lib/test/utils.test.ts @@ -116,8 +116,8 @@ describe('rippled utils:', () => { it('should handle high precision values for IOU tokens', () => { // Test with a value that needs rounding to 7 decimal places - const transferFee = 1000000000 + 12345678 // should result in 0.1234568% - expect(formatTransferFee(transferFee, 'IOU')).toBe('0.1234568') + const transferFee = 1000000000 + 12345678 // should result in 1.2345678% + expect(formatTransferFee(transferFee, 'IOU')).toBe('1.2345678') }) it('should handle very small transfer fees for IOU tokens', () => { From 60db0695e81a31bd3f6c43dfd1939464d7e298e6 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 8 Oct 2025 11:16:33 -0400 Subject: [PATCH 44/47] package-lock.json update by running `npm-install` to add new dependencies --- package-lock.json | 250 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 216 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6906feba4..64f2b04fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,8 @@ "version": "1.5.0", "dependencies": { "@rollup/plugin-inject": "^5.0.5", - "@testing-library/jest-dom": "^5.17.0", - "@testing-library/react": "^12.1.5", "@vitejs/plugin-react": "^4.2.1", "@xrplf/isomorphic": "^1.0.0-beta.1", - "@xrplf/prettier-config": "^1.9.1", "assert": "^2.1.0", "autoprefixer": "^10.4.20", "axios": "^1.6.5", @@ -63,6 +60,9 @@ "@babel/eslint-parser": "^7.22.6", "@babel/preset-env": "^7.25.7", "@babel/preset-react": "^7.27.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^12.1.5", + "@testing-library/react-hooks": "^8.0.1", "@types/create-hash": "^1.2.6", "@types/enzyme": "^3.10.12", "@types/jest": "^26.0.24", @@ -74,6 +74,7 @@ "@types/testing-library__jest-dom": "^5.14.9", "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", + "@xrplf/prettier-config": "^1.9.1", "babel-jest": "^29.7.0", "cheerio": "1.0.0-rc.3", "enzyme": "3.11.0", @@ -119,7 +120,8 @@ "node_modules/@adobe/css-tools": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", - "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==" + "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==", + "dev": true }, "node_modules/@ampproject/remapping": { "version": "2.3.0", @@ -4345,6 +4347,7 @@ "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4363,6 +4366,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "engines": { "node": ">=10" }, @@ -4374,6 +4378,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, "dependencies": { "deep-equal": "^2.0.5" } @@ -4382,6 +4387,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4395,6 +4401,7 @@ "version": "5.17.0", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "dev": true, "dependencies": { "@adobe/css-tools": "^4.0.1", "@babel/runtime": "^7.9.2", @@ -4416,6 +4423,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4428,6 +4436,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, "engines": { "node": ">=8" } @@ -4436,6 +4445,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -4448,6 +4458,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, "dependencies": { "min-indent": "^1.0.0" }, @@ -4459,6 +4470,7 @@ "version": "12.1.5", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "dev": true, "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", @@ -4472,6 +4484,54 @@ "react-dom": "<18.0.0" } }, + "node_modules/@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-test-renderer": "^16.9.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, + "node_modules/@testing-library/react-hooks/node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4507,7 +4567,8 @@ "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4684,6 +4745,7 @@ "version": "26.0.24", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.24.tgz", "integrity": "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==", + "dev": true, "dependencies": { "jest-diff": "^26.0.0", "pretty-format": "^26.0.0" @@ -4768,12 +4830,14 @@ "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "dev": true }, "node_modules/@types/react": { "version": "17.0.85", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.85.tgz", "integrity": "sha512-5oBDUsRDsrYq4DdyHaL99gE1AJCfuDhyxqF6/55fvvOIRkp1PpKuwJ+aMiGJR+GJt7YqMNclPROTHF20vY2cXA==", + "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -4784,6 +4848,7 @@ "version": "17.0.26", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz", "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==", + "dev": true, "peerDependencies": { "@types/react": "^17.0.0" } @@ -4812,7 +4877,8 @@ "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "dev": true }, "node_modules/@types/semver": { "version": "7.7.0", @@ -4829,6 +4895,7 @@ "version": "5.14.9", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "dev": true, "dependencies": { "@types/jest": "*" } @@ -5240,7 +5307,8 @@ "node_modules/@xrplf/prettier-config": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@xrplf/prettier-config/-/prettier-config-1.9.1.tgz", - "integrity": "sha512-1k4WCTstvGiTJQYUNTZhz4BM2DI/kVBRXh1Dw5fVaVDiGVb4YOuzxFNgipOWX/EllrjRTN5NNGLJEDk0nsMFEg==" + "integrity": "sha512-1k4WCTstvGiTJQYUNTZhz4BM2DI/kVBRXh1Dw5fVaVDiGVb4YOuzxFNgipOWX/EllrjRTN5NNGLJEDk0nsMFEg==", + "dev": true }, "node_modules/@xrplf/secret-numbers": { "version": "2.0.0", @@ -5397,6 +5465,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, "engines": { "node": ">=8" } @@ -5481,6 +5550,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -5516,6 +5586,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" @@ -7284,7 +7355,8 @@ "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true }, "node_modules/cssesc": { "version": "3.0.0", @@ -7942,6 +8014,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, "dependencies": { "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.5", @@ -8128,6 +8201,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true, "engines": { "node": ">= 10.14.2" } @@ -8165,7 +8239,8 @@ "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -8677,6 +8752,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -10831,6 +10907,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11259,6 +11336,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -11786,6 +11864,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", @@ -11850,6 +11929,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -11890,6 +11970,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, "dependencies": { "has-bigints": "^1.0.2" }, @@ -11916,6 +11997,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -12004,6 +12086,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -12144,6 +12227,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -12178,6 +12262,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -12242,6 +12327,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -12253,6 +12339,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, "dependencies": { "call-bound": "^1.0.3" }, @@ -12280,6 +12367,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -12301,6 +12389,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", @@ -12336,6 +12425,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -12362,6 +12452,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" @@ -12397,7 +12488,8 @@ "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "node_modules/isexe": { "version": "2.0.0", @@ -12829,6 +12921,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^26.6.2", @@ -13907,6 +14000,7 @@ "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true, "engines": { "node": ">= 10.14.2" } @@ -15479,6 +15573,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, "bin": { "lz-string": "bin/bin.js" } @@ -15947,6 +16042,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, "engines": { "node": ">=4" } @@ -17538,6 +17634,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", @@ -17552,6 +17649,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -17567,6 +17665,7 @@ "version": "15.0.19", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", "integrity": "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==", + "dev": true, "dependencies": { "@types/yargs-parser": "*" } @@ -17874,7 +17973,8 @@ "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true }, "node_modules/react-query": { "version": "3.39.3", @@ -18211,6 +18311,7 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -19264,6 +19365,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -20017,6 +20119,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" @@ -22141,6 +22244,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", @@ -22186,6 +22290,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", @@ -22544,7 +22649,8 @@ "@adobe/css-tools": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", - "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==" + "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==", + "dev": true }, "@ampproject/remapping": { "version": "2.3.0", @@ -25112,6 +25218,7 @@ "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, "requires": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -25126,12 +25233,14 @@ "ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true }, "aria-query": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, "requires": { "deep-equal": "^2.0.5" } @@ -25140,6 +25249,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "requires": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -25152,6 +25262,7 @@ "version": "5.17.0", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz", "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==", + "dev": true, "requires": { "@adobe/css-tools": "^4.0.1", "@babel/runtime": "^7.9.2", @@ -25168,6 +25279,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -25176,12 +25288,14 @@ "indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true }, "redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, "requires": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -25191,6 +25305,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, "requires": { "min-indent": "^1.0.0" } @@ -25201,12 +25316,34 @@ "version": "12.1.5", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "dev": true, "requires": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", "@types/react-dom": "<18.0.0" } }, + "@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "dependencies": { + "react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + } + } + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -25239,7 +25376,8 @@ "@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true }, "@types/babel__core": { "version": "7.20.5", @@ -25418,6 +25556,7 @@ "version": "26.0.24", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.24.tgz", "integrity": "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==", + "dev": true, "requires": { "jest-diff": "^26.0.0", "pretty-format": "^26.0.0" @@ -25495,12 +25634,14 @@ "@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "dev": true }, "@types/react": { "version": "17.0.85", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.85.tgz", "integrity": "sha512-5oBDUsRDsrYq4DdyHaL99gE1AJCfuDhyxqF6/55fvvOIRkp1PpKuwJ+aMiGJR+GJt7YqMNclPROTHF20vY2cXA==", + "dev": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -25511,6 +25652,7 @@ "version": "17.0.26", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz", "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==", + "dev": true, "requires": {} }, "@types/react-router": { @@ -25537,7 +25679,8 @@ "@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "dev": true }, "@types/semver": { "version": "7.7.0", @@ -25554,6 +25697,7 @@ "version": "5.14.9", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "dev": true, "requires": { "@types/jest": "*" } @@ -25807,7 +25951,8 @@ "@xrplf/prettier-config": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@xrplf/prettier-config/-/prettier-config-1.9.1.tgz", - "integrity": "sha512-1k4WCTstvGiTJQYUNTZhz4BM2DI/kVBRXh1Dw5fVaVDiGVb4YOuzxFNgipOWX/EllrjRTN5NNGLJEDk0nsMFEg==" + "integrity": "sha512-1k4WCTstvGiTJQYUNTZhz4BM2DI/kVBRXh1Dw5fVaVDiGVb4YOuzxFNgipOWX/EllrjRTN5NNGLJEDk0nsMFEg==", + "dev": true }, "@xrplf/secret-numbers": { "version": "2.0.0", @@ -25924,7 +26069,8 @@ "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true }, "ansi-styles": { "version": "4.3.0", @@ -25988,7 +26134,8 @@ "aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true }, "arr-diff": { "version": "4.0.0", @@ -26012,6 +26159,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, "requires": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" @@ -27326,7 +27474,8 @@ "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true }, "cssesc": { "version": "3.0.0", @@ -27810,6 +27959,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, "requires": { "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.5", @@ -27953,7 +28103,8 @@ "diff-sequences": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", - "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==" + "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true }, "dir-glob": { "version": "3.0.1", @@ -27982,7 +28133,8 @@ "dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true }, "dom-helpers": { "version": "5.2.1", @@ -28405,6 +28557,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, "requires": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -29946,7 +30099,8 @@ "functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==" + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true }, "gauge": { "version": "3.0.2", @@ -30257,7 +30411,8 @@ "has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==" + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true }, "has-flag": { "version": "4.0.0", @@ -30645,6 +30800,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, "requires": { "es-errors": "^1.3.0", "hasown": "^2.0.2", @@ -30691,6 +30847,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, "requires": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", @@ -30719,6 +30876,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, "requires": { "has-bigints": "^1.0.2" } @@ -30736,6 +30894,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, "requires": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -30794,6 +30953,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, "requires": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" @@ -30884,7 +31044,8 @@ "is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==" + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true }, "is-nan": { "version": "1.3.2", @@ -30904,6 +31065,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, "requires": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -30946,12 +31108,14 @@ "is-set": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==" + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true }, "is-shared-array-buffer": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, "requires": { "call-bound": "^1.0.3" } @@ -30967,6 +31131,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, "requires": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" @@ -30982,6 +31147,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "requires": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", @@ -31004,7 +31170,8 @@ "is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==" + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true }, "is-weakref": { "version": "1.1.1", @@ -31019,6 +31186,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, "requires": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" @@ -31042,7 +31210,8 @@ "isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "isexe": { "version": "2.0.0", @@ -31361,6 +31530,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, "requires": { "chalk": "^4.0.0", "diff-sequences": "^26.6.2", @@ -32210,7 +32380,8 @@ "jest-get-type": { "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", - "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==" + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true }, "jest-haste-map": { "version": "29.7.0", @@ -33435,7 +33606,8 @@ "lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==" + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true }, "magic-string": { "version": "0.30.17", @@ -33757,7 +33929,8 @@ "min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true }, "minimatch": { "version": "9.0.5", @@ -34917,6 +35090,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, "requires": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", @@ -34928,6 +35102,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, "requires": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -34940,6 +35115,7 @@ "version": "15.0.19", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", "integrity": "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==", + "dev": true, "requires": { "@types/yargs-parser": "*" } @@ -35163,7 +35339,8 @@ "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true }, "react-query": { "version": "3.39.3", @@ -35411,6 +35588,7 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, "requires": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", @@ -36193,6 +36371,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, "requires": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -36777,6 +36956,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, "requires": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" @@ -38282,6 +38462,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, "requires": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", @@ -38315,6 +38496,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, "requires": { "is-map": "^2.0.3", "is-set": "^2.0.3", From d38b28fc1322e8e4f47213133f5deb92a417b9b3 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 8 Oct 2025 17:16:28 -0400 Subject: [PATCH 45/47] =?UTF-8?q?UX=20improvement=20to=20Held=20IOUs:=20sh?= =?UTF-8?q?ow=20the=20IOUs=20table=20immediately,=20then=20progressively?= =?UTF-8?q?=20add=20non-LP=20tokens=20starting=20with=2003=20after=20confi?= =?UTF-8?q?rming=20they=20aren=E2=80=99t=20LP=20tokens.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccountAsset/assetTables/HeldIOUs.tsx | 173 ++++++++++++------ .../AccountAsset/test/HeldIOUs.test.tsx | 52 ++++-- 2 files changed, 159 insertions(+), 66 deletions(-) diff --git a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx index 6be842c14..80810a977 100644 --- a/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx +++ b/src/containers/Accounts/AccountAsset/assetTables/HeldIOUs.tsx @@ -63,43 +63,17 @@ const fetchAccountHeldIOUs = async ( return [] } - // Identify LP tokens by checking if it starts with '03' and its issuer account is an AMM account - const lpTokens = new Set() - const assetsByIssuer = Object.entries(balancesResponse?.assets ?? {}) - for (const [issuer, assets] of assetsByIssuer) { - const potentialLPTokenAssets = (assets as any[]).filter((asset) => - asset.currency?.startsWith(LP_TOKEN_IDENTIFIER), - ) - - if (potentialLPTokenAssets.length > 0) { - try { - // eslint-disable-next-line no-await-in-loop - const accountInfo = await getAccountInfo(rippledSocket, issuer, false) - if (accountInfo?.AMMID) { - for (const asset of potentialLPTokenAssets) { - lpTokens.add(asset.currency) - } - } - } catch (error) { - log.warn( - `Error fetching account info for issuer ${issuer}: ${JSON.stringify(error)}`, - ) - // If we can't fetch account info, assume it's not an AMM to be safe - } - } - } - - // Collect all non-LP tokens - const iouTokens: any[] = [] + // Collect all tokens, whether they are LP Tokens or IOUs + const assetTokens: any[] = [] for (const assets of Object.values(balancesResponse?.assets ?? {})) { for (const asset of assets as any[]) { - if (asset.currency && !lpTokens.has(asset.currency)) { - iouTokens.push(asset.currency) + if (asset.currency) { + assetTokens.push(asset.currency) } } } - if (iouTokens.length === 0) { - // No IOUs held, return empty array + if (assetTokens.length === 0) { + // No tokens held, return empty array return [] } @@ -130,9 +104,9 @@ const fetchAccountHeldIOUs = async ( } } while (marker) - // Filter for positive balances and exclude LP tokens (using the identified LP token set) + // Keep positive balances only (no LP token filtering at this stage) const positiveBalanceLines = allTrustLines.filter( - (line: any) => parseFloat(line.balance) > 0 && !lpTokens.has(line.currency), + (line: any) => parseFloat(line.balance) > 0, ) if (positiveBalanceLines.length === 0) { @@ -143,7 +117,7 @@ const fetchAccountHeldIOUs = async ( const allTokenIds = positiveBalanceLines.map( (line: any) => `${line.currency}.${line.account}`, ) - let allTokens: Record = {} + let allTokensFromLOS: Record = {} for (let i = 0; i < allTokenIds.length; i += LOS_TOKEN_API_BATCH_SIZE) { const tokenIds = allTokenIds.slice(i, i + LOS_TOKEN_API_BATCH_SIZE) try { @@ -160,14 +134,14 @@ const fetchAccountHeldIOUs = async ( if (apiResponse.ok) { // eslint-disable-next-line no-await-in-loop const responseBody = await apiResponse.json() - const tokens = + const tokensFromLOS = responseBody.tokens?.reduce((acc: any, token: any) => { acc[`${token.currency}.${token.issuer_account}`] = { ...token, } return acc }, {}) || {} - allTokens = { ...allTokens, ...tokens } + allTokensFromLOS = { ...allTokensFromLOS, ...tokensFromLOS } } } catch (error) { log.error( @@ -177,9 +151,9 @@ const fetchAccountHeldIOUs = async ( } // Combine all data (without transfer fees and Global freeze status for now) - const iouData: IOU[] = positiveBalanceLines.map((line: any) => { + const tokens: IOU[] = positiveBalanceLines.map((line: any) => { const tokenId = `${line.currency}.${line.account}` - const token = allTokens[tokenId] + const token = allTokensFromLOS[tokenId] return { tokenCode: line.currency, @@ -194,31 +168,51 @@ const fetchAccountHeldIOUs = async ( } }) - return iouData + return tokens } +/** + * Held IOUs rendering flow: + * 1. Initially display all tokens whose currency codes don't start with `03` (since `03` may indicate LP tokens). + * 2. After confirming a `03` token is not an LP token, add it to the table. + * 3. Progressively enrich each token row with transfer fee and account-level freeze status as that data loads. + */ export const HeldIOUs = ({ accountId, onChange }: HeldIOUsProps) => { const lang = useLanguage() const { t } = useTranslation() const rippledSocket = useContext(SocketContext) + const [progressiveUpdates, setProgressiveUpdates] = useState< Record >({}) + const [confirmedNonLPTokens, setConfirmedNonLPTokens] = useState>( + new Set(), + ) + const [lpTokenCheckComplete, setLpTokenCheckComplete] = useState(false) const heldIOUsQuery = useQuery(['heldIOUs', accountId], () => fetchAccountHeldIOUs(rippledSocket, accountId), ) - // Get data and sort by USD balance in one step + // Filter out ALL '03' tokens initially, then add back confirmed non-LP tokens const sortedIOUs = useMemo(() => { const data = heldIOUsQuery.data || [] - return [...data].sort( + const filteredData = data.filter((token) => { + // If it starts with '03', only include if confirmed as non-LP + if (token.tokenCode.startsWith(LP_TOKEN_IDENTIFIER)) { + return confirmedNonLPTokens.has(token.tokenCode) + } + // Include all non-'03' tokens + return true + }) + + return [...filteredData].sort( (a, b) => b.priceInUSD * b.balance - a.priceInUSD * a.balance, ) - }, [heldIOUsQuery.data]) + }, [heldIOUsQuery.data, confirmedNonLPTokens]) - // Apply progressive updates to the base data - const ious = sortedIOUs.map((token) => { + // Apply progressive updates to the base IOU token data + const iouTokens = sortedIOUs.map((token) => { const progressiveUpdate = progressiveUpdates[token.issuer] return { ...token, @@ -227,8 +221,8 @@ export const HeldIOUs = ({ accountId, onChange }: HeldIOUsProps) => { } }) - // Progressive fetching of issuer info for transfer fee and Global freeze status - const fetchIssuerInfoProgressively = useCallback(async () => { + // Progressive fetching of account info with transfer fee and Global freeze status + const fetchAccountInfoProgressively = useCallback(async () => { if (sortedIOUs.length === 0) { return } @@ -261,19 +255,88 @@ export const HeldIOUs = ({ accountId, onChange }: HeldIOUsProps) => { } }, [sortedIOUs, rippledSocket]) - // Start progressive updates after initial data is loaded + // Identify non-LP tokens - check which '03' tokens are regular IOUs (not LP tokens) + const identifyNonLPTokensProgressively = useCallback(async () => { + if (!heldIOUsQuery.data || heldIOUsQuery.data.length === 0) { + setLpTokenCheckComplete(true) + return + } + + // Group tokens by issuer to minimize getAccountInfo calls + const tokensByIssuer = new Map() + for (const token of heldIOUsQuery.data) { + if (token.tokenCode.startsWith(LP_TOKEN_IDENTIFIER)) { + const tokens = tokensByIssuer.get(token.issuer) || [] + tokens.push(token.tokenCode) + tokensByIssuer.set(token.issuer, tokens) + } + } + + for (const [issuer, tokenCodes] of tokensByIssuer.entries()) { + try { + // eslint-disable-next-line no-await-in-loop + const accountInfo = await getAccountInfo(rippledSocket, issuer, false) + + // If the issuer does NOT have an AMMID, these are regular IOUs (non-LP tokens) + if (!accountInfo.AMMID) { + setConfirmedNonLPTokens((prev) => { + const newSet = new Set(prev) + for (const tokenCode of tokenCodes) { + newSet.add(tokenCode) + } + return newSet + }) + } + } catch (error) { + log.warn( + `Error checking if issuer ${issuer} is AMM account: ${JSON.stringify(error)}`, + ) + // If we can't fetch account info, assume it's not an AMM to be safe + // Add these tokens to the confirmed non-LP list + setConfirmedNonLPTokens((prev) => { + const newSet = new Set(prev) + for (const tokenCode of tokenCodes) { + newSet.add(tokenCode) + } + return newSet + }) + } + } + + setLpTokenCheckComplete(true) + }, [heldIOUsQuery.data, rippledSocket]) + + // Start identifying non-LP tokens immediately after initial data loads + useEffect(() => { + if (heldIOUsQuery.data && heldIOUsQuery.data.length > 0) { + identifyNonLPTokensProgressively() + } else if (heldIOUsQuery.data && heldIOUsQuery.data.length === 0) { + setLpTokenCheckComplete(true) + } + }, [heldIOUsQuery.data, identifyNonLPTokensProgressively]) + + // Begin progressive updates once the LP token check is complete (we now have the final IOU list) useEffect(() => { - if (sortedIOUs.length > 0) { - fetchIssuerInfoProgressively() + if (lpTokenCheckComplete && sortedIOUs.length > 0) { + fetchAccountInfoProgressively() } - }, [sortedIOUs.length, fetchIssuerInfoProgressively]) + }, [lpTokenCheckComplete, sortedIOUs.length, fetchAccountInfoProgressively]) // Communicate count and loading state back to parent + // Keep loading state true until LP token checks are complete useEffect(() => { if (onChange) { - onChange({ count: ious.length, isLoading: heldIOUsQuery.isLoading }) + onChange({ + count: iouTokens.length, + isLoading: heldIOUsQuery.isLoading || !lpTokenCheckComplete, + }) } - }, [ious.length, heldIOUsQuery.isLoading, onChange]) + }, [ + iouTokens.length, + heldIOUsQuery.isLoading, + lpTokenCheckComplete, + onChange, + ]) if (heldIOUsQuery.isLoading) { return @@ -295,12 +358,12 @@ export const HeldIOUs = ({ accountId, onChange }: HeldIOUsProps) => {