From a6d4309fe83a28cd2f99a780ce36efa1d97df713 Mon Sep 17 00:00:00 2001 From: RomThpt Date: Mon, 12 Jan 2026 18:36:33 +0100 Subject: [PATCH 1/2] feat: Add TanStack Query for cached data fetching Add TanStack Query (React Query) to improve data fetching performance and user experience by caching account balance data across page navigations. Changes: - Add @tanstack/react-query dependency - Create QueryContext with QueryClientProvider and sensible defaults - Add useAccountBalances hook for cached balance fetching - Refactor TokenListing to use the new cached hook - Update TokenListing tests to work with QueryClient --- packages/extension/package.json | 1 + .../TokenListing/TokenListing.test.tsx | 94 +++++--- .../organisms/TokenListing/TokenListing.tsx | 134 +++-------- .../contexts/QueryContext/QueryContext.tsx | 30 +++ .../src/contexts/QueryContext/index.ts | 1 + packages/extension/src/contexts/index.ts | 1 + packages/extension/src/hooks/index.ts | 1 + .../src/hooks/useAccountData/index.ts | 1 + .../hooks/useAccountData/useAccountData.ts | 137 +++++++++++ packages/extension/src/index.tsx | 37 +-- yarn.lock | 213 +++++++++++++++++- 11 files changed, 491 insertions(+), 159 deletions(-) create mode 100644 packages/extension/src/contexts/QueryContext/QueryContext.tsx create mode 100644 packages/extension/src/contexts/QueryContext/index.ts create mode 100644 packages/extension/src/hooks/useAccountData/index.ts create mode 100644 packages/extension/src/hooks/useAccountData/useAccountData.ts diff --git a/packages/extension/package.json b/packages/extension/package.json index 16516e279..08a20505d 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -18,6 +18,7 @@ "@mui/icons-material": "^6.3.0", "@mui/material": "^6.3.0", "@sentry/react": "^7.117.0", + "@tanstack/react-query": "^5.90.12", "copy-to-clipboard": "^3.3.3", "crypto-browserify": "^3.12.0", "crypto-js": "^4.2.0", diff --git a/packages/extension/src/components/organisms/TokenListing/TokenListing.test.tsx b/packages/extension/src/components/organisms/TokenListing/TokenListing.test.tsx index 3390ee715..847e7b0c2 100644 --- a/packages/extension/src/components/organisms/TokenListing/TokenListing.test.tsx +++ b/packages/extension/src/components/organisms/TokenListing/TokenListing.test.tsx @@ -1,13 +1,13 @@ -import * as Sentry from '@sentry/react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Chain, XRPLNetwork } from '@gemwallet/constants'; import { DEFAULT_RESERVE, RESERVE_PER_OWNER } from '../../../constants'; import { formatToken } from '../../../utils'; import { TokenListing, TokenListingProps } from './TokenListing'; -import { vi, Mock } from 'vitest'; +import { vi, Mock, describe, test, expect, beforeEach } from 'vitest'; const user = userEvent.setup(); @@ -21,6 +21,7 @@ vi.mock('@sentry/react', () => { const mockGetBalancesPromise = vi.fn(); const mockFundWalletPromise = vi.fn(); const mockRequestPromise = vi.fn(); +const mockGetAccountInfo = vi.fn(); const mockChain = Chain.XRPL; let mockNetwork = XRPLNetwork.TESTNET; @@ -29,16 +30,6 @@ let mockClient: { getBalances: Mock; request: Mock } | null = { request: mockRequestPromise }; -mockGetBalancesPromise.mockResolvedValueOnce([ - { value: '100', currency: 'XRP', issuer: undefined } -]); - -mockRequestPromise.mockResolvedValueOnce({ - result: { - lines: [] - } -}); - vi.mock('../../../contexts', () => { return { useNetwork: () => ({ @@ -59,22 +50,32 @@ vi.mock('../../../contexts', () => { }), useLedger: () => ({ fundWallet: mockFundWalletPromise, - getAccountInfo: vi.fn().mockImplementation(() => - Promise.resolve({ - result: { - account_data: { - OwnerCount: 2 - } - } - }) - ) + getAccountInfo: mockGetAccountInfo }) }; }); +// Create a wrapper component with QueryClientProvider for testing +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0 + } + } + }); + +const renderWithQueryClient = (component: React.ReactElement) => { + const queryClient = createTestQueryClient(); + return render({component}); +}; + describe('TokenListing', () => { let props: TokenListingProps; + beforeEach(() => { + vi.clearAllMocks(); mockClient = { getBalances: mockGetBalancesPromise, request: mockRequestPromise @@ -82,11 +83,24 @@ describe('TokenListing', () => { props = { address: 'r123' }; + // Default mock responses + mockRequestPromise.mockResolvedValue({ + result: { + lines: [] + } + }); + mockGetAccountInfo.mockResolvedValue({ + result: { + account_data: { + OwnerCount: 2 + } + } + }); }); test('should display an error when client failed to load', () => { mockClient = null; - render(); + renderWithQueryClient(); expect( screen.queryByText( 'There was an error attempting to connect to the network. Please refresh the page and try again.' @@ -95,12 +109,13 @@ describe('TokenListing', () => { }); test('should display the loading token state when the XRPBalance is not calculated', () => { - render(); + mockGetBalancesPromise.mockReturnValue(new Promise(() => {})); // Never resolves + renderWithQueryClient(); expect(screen.getByTestId('token-loader')).toBeInTheDocument(); }); test('should display the XRP balance and trust line balances', async () => { - mockGetBalancesPromise.mockResolvedValueOnce([ + mockGetBalancesPromise.mockResolvedValue([ { value: '100', currency: 'XRP', issuer: undefined }, { value: '50', currency: 'USD', issuer: 'r123' }, { value: '20', currency: 'ETH', issuer: 'r456' } @@ -108,7 +123,7 @@ describe('TokenListing', () => { const reserve = DEFAULT_RESERVE + RESERVE_PER_OWNER * 2; - render(); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByText(`${100 - reserve} XRP`)).toBeInTheDocument(); expect(screen.getByText('50 USD')).toBeInTheDocument(); @@ -117,21 +132,20 @@ describe('TokenListing', () => { }); test('should display an error message when there is an error fetching the balances', async () => { - mockGetBalancesPromise.mockRejectedValueOnce( + mockGetBalancesPromise.mockRejectedValue( new Error('Throw an error if there is an error fetching the balances') ); - render(); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByText('Account not activated')).toBeVisible(); - expect(Sentry.captureException).toHaveBeenCalled(); }); }); test('should open the explanation dialog when the explain button is clicked', async () => { - mockGetBalancesPromise.mockResolvedValueOnce([ + mockGetBalancesPromise.mockResolvedValue([ { value: '100', currency: 'XRP', issuer: undefined } ]); - render(); + renderWithQueryClient(); const explainButton = await screen.findByText('Explain'); await user.click(explainButton); expect( @@ -143,10 +157,10 @@ describe('TokenListing', () => { test('Should display the fund wallet button when the network is testnet and XRP balance is 0', async () => { mockNetwork = XRPLNetwork.TESTNET; - mockGetBalancesPromise.mockRejectedValueOnce( + mockGetBalancesPromise.mockRejectedValue( new Error('Throw an error if there is an error fetching the balances') ); - render(); + renderWithQueryClient(); await waitFor(() => { const button = screen.queryByTestId('fund-wallet-button'); expect(button).toBeInTheDocument(); @@ -155,25 +169,31 @@ describe('TokenListing', () => { test('Should not display the fund wallet button when the network is Mainnet and XRP balance is 0', async () => { mockNetwork = XRPLNetwork.MAINNET; - mockGetBalancesPromise.mockRejectedValueOnce( + mockGetBalancesPromise.mockRejectedValue( new Error('Throw an error if there is an error fetching the balances') ); - render(); + renderWithQueryClient(); await waitFor(() => { const button = screen.queryByTestId('fund-wallet-button'); expect(button).not.toBeInTheDocument(); }); }); - test('Should display the amount of XRP when click on Fund Wallet Button', async () => { + test('Should refetch balances when clicking Fund Wallet Button', async () => { const reserve = DEFAULT_RESERVE + RESERVE_PER_OWNER * 2; mockNetwork = XRPLNetwork.TESTNET; - mockFundWalletPromise.mockResolvedValueOnce({ balance: 10000 }); + // First call fails (account not activated) mockGetBalancesPromise.mockRejectedValueOnce( new Error('Throw an error if there is an error fetching the balances') ); - render(); + // After funding, return balances + mockGetBalancesPromise.mockResolvedValue([ + { value: '10000', currency: 'XRP', issuer: undefined } + ]); + mockFundWalletPromise.mockResolvedValue({ balance: 10000 }); + + renderWithQueryClient(); const button = await screen.findByTestId('fund-wallet-button'); const format = formatToken(10000 - reserve, 'XRP'); diff --git a/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx b/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx index ddd211055..a97332cc9 100644 --- a/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx +++ b/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx @@ -1,9 +1,8 @@ -import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, useCallback, useMemo, useState } from 'react'; import { Button, Link, Typography } from '@mui/material'; -import * as Sentry from '@sentry/react'; +import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; -import { AccountLinesTrustline } from 'xrpl'; import { TrustSetFlags as TrustSetFlagsBitmask } from 'xrpl'; import { Chain, XahauNetwork, XRPLNetwork } from '@gemwallet/constants'; @@ -12,12 +11,10 @@ import { ADD_NEW_TRUSTLINE_PATH, DEFAULT_RESERVE, ERROR_RED, - RESERVE_PER_OWNER, - STORAGE_MESSAGING_KEY, - XAHAU_RESERVE_PER_OWNER + STORAGE_MESSAGING_KEY } from '../../../constants'; import { useLedger, useNetwork, useServer } from '../../../contexts'; -import { useMainToken } from '../../../hooks'; +import { useAccountBalances, accountQueryKeys, useMainToken } from '../../../hooks'; import { convertHexCurrencyString, generateKey, saveInChromeSessionStorage } from '../../../utils'; import { isLPToken } from '../../../utils/trustlines'; import { TokenLoader } from '../../atoms'; @@ -25,95 +22,32 @@ import { InformationMessage } from '../../molecules/InformationMessage'; import { TokenDisplay } from '../../molecules/TokenDisplay'; import { DialogPage } from '../../templates'; -const LOADING_STATE = 'Loading...'; -const ERROR_STATE = 'Error'; - -interface TrustLineBalance { - value: string; - currency: string; - issuer: string; - trustlineDetails?: { - // Details need to be fetched with a separate call - limit: number; - noRipple: boolean; - }; -} export interface TokenListingProps { address: string; } export const TokenListing: FC = ({ address }) => { - const [mainTokenBalance, setMainTokenBalance] = useState(LOADING_STATE); - const [reserve, setReserve] = useState(DEFAULT_RESERVE); - const [ownerReserve, setOwnerReserve] = useState(0); const [errorMessage, setErrorMessage] = useState(''); - const [trustLineBalances, setTrustLineBalances] = useState([]); const [explanationOpen, setExplanationOpen] = useState(false); + const [isFunding, setIsFunding] = useState(false); const { client, reconnectToNetwork, networkName, chainName } = useNetwork(); const { serverInfo } = useServer(); - const { fundWallet, getAccountInfo } = useLedger(); + const { fundWallet } = useLedger(); const mainToken = useMainToken(); const navigate = useNavigate(); + const queryClient = useQueryClient(); - useEffect(() => { - async function fetchBalance() { - try { - // Retrieve balances without trustline details - const balances = await client?.getBalances(address); - const mainTokenBalance = balances?.find((balance) => balance.issuer === undefined); - let trustLineBalances = balances?.filter( - (balance) => balance.issuer !== undefined - ) as TrustLineBalance[]; - - // Retrieve trustlines details - const accountLines = await client?.request({ - command: 'account_lines', - account: address - }); - - if (accountLines?.result?.lines) { - trustLineBalances = trustLineBalances - .map((trustlineBalance) => { - const trustlineDetails = accountLines.result.lines.find( - (line: AccountLinesTrustline) => - line.currency === trustlineBalance.currency && - line.account === trustlineBalance.issuer - ); + // Use cached account balances - data persists across page navigations + const { data: accountData, isLoading, isError } = useAccountBalances(address); - return { - ...trustlineBalance, - trustlineDetails: - trustlineDetails && Number(trustlineDetails.limit) - ? { - limit: Number(trustlineDetails.limit), - noRipple: trustlineDetails.no_ripple === true - } - : undefined - }; - }) - .filter( - (trustlineBalance) => - trustlineBalance.trustlineDetails || trustlineBalance.value !== '0' - ); // Hide revoked trustlines with a balance of 0 - } - - if (mainTokenBalance) { - setMainTokenBalance(mainTokenBalance.value); - } - if (trustLineBalances) { - setTrustLineBalances(trustLineBalances); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - if (e?.data?.error !== 'actNotFound') { - Sentry.captureException(e); - } - setMainTokenBalance(ERROR_STATE); - } - } - - fetchBalance(); - }, [address, client]); + const mainTokenBalance = accountData?.mainTokenBalance || '0'; + const trustLineBalances = accountData?.trustLineBalances || []; + const reserve = accountData?.reserve || DEFAULT_RESERVE; + const ownerReserve = accountData?.ownerReserve || 0; + const baseReserve = + accountData?.baseReserve || + serverInfo?.info.validated_ledger?.reserve_base_xrp || + DEFAULT_RESERVE; const handleOpen = useCallback(() => { setExplanationOpen(true); @@ -136,14 +70,21 @@ export const TokenListing: FC = ({ address }) => { const handleFundWallet = useCallback(() => { setErrorMessage(''); - setMainTokenBalance(LOADING_STATE); + setIsFunding(true); fundWallet() - .then(({ balance }) => setMainTokenBalance(balance.toString())) + .then(() => { + // Invalidate the cache to refetch fresh balances + queryClient.invalidateQueries({ + queryKey: accountQueryKeys.balances(address, networkName) + }); + }) .catch((e) => { - setMainTokenBalance(ERROR_STATE); setErrorMessage(e.message); + }) + .finally(() => { + setIsFunding(false); }); - }, [fundWallet]); + }, [fundWallet, queryClient, address, networkName]); if (client === null) { return ( @@ -167,26 +108,11 @@ export const TokenListing: FC = ({ address }) => { ); } - if (mainTokenBalance === LOADING_STATE) { + if (isLoading || isFunding) { return ; } - const baseReserve = serverInfo?.info.validated_ledger?.reserve_base_xrp || DEFAULT_RESERVE; - const ownerReserveBase = - chainName === Chain.XAHAU - ? serverInfo?.info.validated_ledger?.reserve_inc_xrp || XAHAU_RESERVE_PER_OWNER - : serverInfo?.info.validated_ledger?.reserve_inc_xrp || RESERVE_PER_OWNER; - getAccountInfo() - .then((accountInfo) => { - const ownerReserve = accountInfo.result.account_data.OwnerCount * ownerReserveBase; - setOwnerReserve(ownerReserve); - setReserve(ownerReserve + baseReserve); - }) - .catch(() => { - setOwnerReserve(0); - setReserve(DEFAULT_RESERVE); - }); - if (mainTokenBalance === ERROR_STATE) { + if (isError) { return (
diff --git a/packages/extension/src/contexts/QueryContext/QueryContext.tsx b/packages/extension/src/contexts/QueryContext/QueryContext.tsx new file mode 100644 index 000000000..b1081919e --- /dev/null +++ b/packages/extension/src/contexts/QueryContext/QueryContext.tsx @@ -0,0 +1,30 @@ +import { FC, ReactNode } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +// Configure QueryClient with sensible defaults for a wallet extension +// - staleTime: 30 seconds - data is fresh for 30s, no refetch needed +// - gcTime: 5 minutes - cached data kept for 5min after component unmounts +// - refetchOnWindowFocus: false - don't refetch when popup regains focus +// - retry: 1 - retry failed requests once +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, // 30 seconds + gcTime: 5 * 60 * 1000, // 5 minutes (garbage collection time) + refetchOnWindowFocus: false, + retry: 1, + refetchOnMount: false // Don't refetch if data is still fresh + } + } +}); + +interface Props { + children: ReactNode; +} + +export const QueryProvider: FC = ({ children }) => { + return {children}; +}; + +export { queryClient }; diff --git a/packages/extension/src/contexts/QueryContext/index.ts b/packages/extension/src/contexts/QueryContext/index.ts new file mode 100644 index 000000000..24b920b6b --- /dev/null +++ b/packages/extension/src/contexts/QueryContext/index.ts @@ -0,0 +1 @@ +export * from './QueryContext'; diff --git a/packages/extension/src/contexts/index.ts b/packages/extension/src/contexts/index.ts index bd72036bd..2957e3960 100644 --- a/packages/extension/src/contexts/index.ts +++ b/packages/extension/src/contexts/index.ts @@ -2,6 +2,7 @@ export * from './BrowserContext'; export * from './LedgerContext'; export * from './NavBarContext'; export * from './NetworkContext'; +export * from './QueryContext'; export * from './ServerContext'; export * from './TransactionProgressContext'; export * from './WalletContext'; diff --git a/packages/extension/src/hooks/index.ts b/packages/extension/src/hooks/index.ts index 883e14e08..7cb40f899 100644 --- a/packages/extension/src/hooks/index.ts +++ b/packages/extension/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useAccountData'; export * from './useBeforeUnload'; export * from './useFees'; export * from './useFetchFromSessionStorage'; diff --git a/packages/extension/src/hooks/useAccountData/index.ts b/packages/extension/src/hooks/useAccountData/index.ts new file mode 100644 index 000000000..9f998aa0f --- /dev/null +++ b/packages/extension/src/hooks/useAccountData/index.ts @@ -0,0 +1 @@ +export * from './useAccountData'; diff --git a/packages/extension/src/hooks/useAccountData/useAccountData.ts b/packages/extension/src/hooks/useAccountData/useAccountData.ts new file mode 100644 index 000000000..1797ae458 --- /dev/null +++ b/packages/extension/src/hooks/useAccountData/useAccountData.ts @@ -0,0 +1,137 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { AccountLinesTrustline } from 'xrpl'; + +import { useNetwork, useLedger, useServer } from '../../contexts'; +import { Chain } from '@gemwallet/constants'; +import { DEFAULT_RESERVE, RESERVE_PER_OWNER, XAHAU_RESERVE_PER_OWNER } from '../../constants'; + +interface TrustLineBalance { + value: string; + currency: string; + issuer: string; + trustlineDetails?: { + limit: number; + noRipple: boolean; + }; +} + +interface AccountBalanceData { + mainTokenBalance: string; + trustLineBalances: TrustLineBalance[]; + reserve: number; + ownerReserve: number; + baseReserve: number; +} + +// Query keys for cache management +export const accountQueryKeys = { + all: ['account'] as const, + balances: (address: string, networkName: string) => + [...accountQueryKeys.all, 'balances', address, networkName] as const, + accountInfo: (address: string, networkName: string) => + [...accountQueryKeys.all, 'info', address, networkName] as const +}; + +/** + * Hook to fetch and cache account balances with TanStack Query. + * Data is cached for 30 seconds and won't refetch on page navigation. + */ +export const useAccountBalances = (address: string) => { + const { client, networkName, chainName } = useNetwork(); + const { serverInfo } = useServer(); + const { getAccountInfo } = useLedger(); + + const baseReserve = serverInfo?.info.validated_ledger?.reserve_base_xrp || DEFAULT_RESERVE; + const ownerReserveBase = + chainName === Chain.XAHAU + ? serverInfo?.info.validated_ledger?.reserve_inc_xrp || XAHAU_RESERVE_PER_OWNER + : serverInfo?.info.validated_ledger?.reserve_inc_xrp || RESERVE_PER_OWNER; + + return useQuery({ + queryKey: accountQueryKeys.balances(address, networkName), + queryFn: async (): Promise => { + if (!client) { + throw new Error('Client not connected'); + } + + // Fetch balances + const balances = await client.getBalances(address); + const mainTokenBalance = balances?.find((balance) => balance.issuer === undefined); + let trustLineBalances = balances?.filter( + (balance) => balance.issuer !== undefined + ) as TrustLineBalance[]; + + // Fetch account lines for trustline details + const accountLines = await client.request({ + command: 'account_lines', + account: address + }); + + if (accountLines?.result?.lines) { + trustLineBalances = trustLineBalances + .map((trustlineBalance) => { + const trustlineDetails = accountLines.result.lines.find( + (line: AccountLinesTrustline) => + line.currency === trustlineBalance.currency && + line.account === trustlineBalance.issuer + ); + + return { + ...trustlineBalance, + trustlineDetails: + trustlineDetails && Number(trustlineDetails.limit) + ? { + limit: Number(trustlineDetails.limit), + noRipple: trustlineDetails.no_ripple === true + } + : undefined + }; + }) + .filter( + (trustlineBalance) => + trustlineBalance.trustlineDetails || trustlineBalance.value !== '0' + ); + } + + // Fetch account info for reserve calculation + const accountInfo = await getAccountInfo(); + const calculatedOwnerReserve = accountInfo.result.account_data.OwnerCount * ownerReserveBase; + const reserve = calculatedOwnerReserve + baseReserve; + + return { + mainTokenBalance: mainTokenBalance?.value || '0', + trustLineBalances: trustLineBalances || [], + reserve, + ownerReserve: calculatedOwnerReserve, + baseReserve + }; + }, + enabled: !!client && !!address, + staleTime: 30 * 1000, // Data is fresh for 30 seconds + gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes + refetchOnMount: false, // Don't refetch when component mounts if data is fresh + refetchOnWindowFocus: false + // retry is inherited from QueryClient defaults (false in tests, 1 in production) + }); +}; + +/** + * Hook to invalidate account balance cache. + * Call this after transactions that change balances. + */ +export const useInvalidateAccountBalances = () => { + const queryClient = useQueryClient(); + + return (address?: string, networkName?: string) => { + if (address && networkName) { + queryClient.invalidateQueries({ + queryKey: accountQueryKeys.balances(address, networkName) + }); + } else { + // Invalidate all account balance queries + queryClient.invalidateQueries({ + queryKey: [...accountQueryKeys.all, 'balances'] + }); + } + }; +}; diff --git a/packages/extension/src/index.tsx b/packages/extension/src/index.tsx index 11c304d68..0e360a4a8 100644 --- a/packages/extension/src/index.tsx +++ b/packages/extension/src/index.tsx @@ -19,6 +19,7 @@ import { LedgerProvider, NavBarPositionProvider, NetworkProvider, + QueryProvider, ServerProvider, TransactionProgressProvider, WalletProvider @@ -87,23 +88,25 @@ const GemWallet = () => { - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/yarn.lock b/yarn.lock index 620afd8c5..623277fa5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1232,81 +1232,206 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz#8b613b9725e8f9479d142970b106b6ae878610d5" integrity sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w== +"@rollup/rollup-android-arm-eabi@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz#76e0fef6533b3ce313f969879e61e8f21f0eeb28" + integrity sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg== + "@rollup/rollup-android-arm64@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz#654ca1049189132ff602bfcf8df14c18da1f15fb" integrity sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA== +"@rollup/rollup-android-arm64@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz#d3cfc675a40bbdec97bda6d7fe3b3b05f0e1cd93" + integrity sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg== + "@rollup/rollup-darwin-arm64@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz#6d241d099d1518ef0c2205d96b3fa52e0fe1954b" integrity sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q== +"@rollup/rollup-darwin-arm64@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz#eb912b8f59dd47c77b3c50a78489013b1d6772b4" + integrity sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg== + "@rollup/rollup-darwin-x64@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz#42bd19d292a57ee11734c980c4650de26b457791" integrity sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw== +"@rollup/rollup-darwin-x64@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz#e7d0839fdfd1276a1d34bc5ebbbd0dfd7d0b81a0" + integrity sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ== + +"@rollup/rollup-freebsd-arm64@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz#7ff8118760f7351e48fd0cd3717ff80543d6aac8" + integrity sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg== + +"@rollup/rollup-freebsd-x64@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz#49d330dadbda1d4e9b86b4a3951b59928a9489a9" + integrity sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw== + "@rollup/rollup-linux-arm-gnueabihf@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz#f23555ee3d8fe941c5c5fd458cd22b65eb1c2232" integrity sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ== +"@rollup/rollup-linux-arm-gnueabihf@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz#98c5f1f8b9776b4a36e466e2a1c9ed1ba52ef1b6" + integrity sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ== + "@rollup/rollup-linux-arm-musleabihf@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz#f3bbd1ae2420f5539d40ac1fde2b38da67779baa" integrity sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg== +"@rollup/rollup-linux-arm-musleabihf@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz#b9acecd3672e742f70b0c8a94075c816a91ff040" + integrity sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg== + "@rollup/rollup-linux-arm64-gnu@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz#7abe900120113e08a1f90afb84c7c28774054d15" integrity sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw== +"@rollup/rollup-linux-arm64-gnu@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz#7a6ab06651bc29e18b09a50ed1a02bc972977c9b" + integrity sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ== + "@rollup/rollup-linux-arm64-musl@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz#9e655285c8175cd44f57d6a1e8e5dedfbba1d820" integrity sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA== +"@rollup/rollup-linux-arm64-musl@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz#3c8c9072ba4a4d4ef1156b85ab9a2cbb57c1fad0" + integrity sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA== + +"@rollup/rollup-linux-loong64-gnu@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz#17a7af13530f4e4a7b12cd26276c54307a84a8b0" + integrity sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g== + +"@rollup/rollup-linux-loong64-musl@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz#5cd7a900fd7b077ecd753e34a9b7ff1157fe70c1" + integrity sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw== + "@rollup/rollup-linux-powerpc64le-gnu@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz#9a79ae6c9e9d8fe83d49e2712ecf4302db5bef5e" integrity sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg== +"@rollup/rollup-linux-ppc64-gnu@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz#03a097e70243ddf1c07b59d3c20f38e6f6800539" + integrity sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw== + +"@rollup/rollup-linux-ppc64-musl@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz#a5389873039d4650f35b4fa060d286392eb21a94" + integrity sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw== + "@rollup/rollup-linux-riscv64-gnu@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz#67ac70eca4ace8e2942fabca95164e8874ab8128" integrity sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA== +"@rollup/rollup-linux-riscv64-gnu@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz#789e60e7d6e2b76132d001ffb24ba80007fb17d0" + integrity sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw== + +"@rollup/rollup-linux-riscv64-musl@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz#3556fa88d139282e9a73c337c9a170f3c5fe7aa4" + integrity sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg== + "@rollup/rollup-linux-s390x-gnu@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz#9f883a7440f51a22ed7f99e1d070bd84ea5005fc" integrity sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q== +"@rollup/rollup-linux-s390x-gnu@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz#c085995b10143c16747a67f1a5487512b2ff04b2" + integrity sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg== + "@rollup/rollup-linux-x64-gnu@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz#70116ae6c577fe367f58559e2cffb5641a1dd9d0" integrity sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg== +"@rollup/rollup-linux-x64-gnu@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz#9563a5419dd2604841bad31a39ccfdd2891690fb" + integrity sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg== + "@rollup/rollup-linux-x64-musl@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz#f473f88219feb07b0b98b53a7923be716d1d182f" integrity sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g== +"@rollup/rollup-linux-x64-musl@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz#691bb06e6269a8959c13476b0cd2aa7458facb31" + integrity sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w== + +"@rollup/rollup-openbsd-x64@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz#223e71224746a59ce6d955bbc403577bb5a8be9d" + integrity sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg== + +"@rollup/rollup-openharmony-arm64@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz#0817e5d8ecbfeb8b7939bf58f8ce3c9dd67fce77" + integrity sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw== + "@rollup/rollup-win32-arm64-msvc@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz#4349482d17f5d1c58604d1c8900540d676f420e0" integrity sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw== +"@rollup/rollup-win32-arm64-msvc@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz#de56d8f2013c84570ef5fb917aae034abda93e4a" + integrity sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g== + "@rollup/rollup-win32-ia32-msvc@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz#a6fc39a15db618040ec3c2a24c1e26cb5f4d7422" integrity sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g== +"@rollup/rollup-win32-ia32-msvc@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz#659aff5244312475aeea2c9479a6c7d397b517bf" + integrity sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA== + +"@rollup/rollup-win32-x64-gnu@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz#2cb09549cbb66c1b979f9238db6dd454cac14a88" + integrity sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg== + "@rollup/rollup-win32-x64-msvc@4.22.4": version "4.22.4" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz#3dd5d53e900df2a40841882c02e56f866c04d202" integrity sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q== +"@rollup/rollup-win32-x64-msvc@4.55.1": + version "4.55.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz#f79437939020b83057faf07e98365b1fa51c458b" + integrity sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw== + "@scure/base@^1.1.3", "@scure/base@~1.1.4": version "1.1.5" resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.5.tgz#1d85d17269fe97694b9c592552dd9e5e33552157" @@ -1472,6 +1597,18 @@ resolved "https://registry.yarnpkg.com/@stablelib/wipe/-/wipe-1.0.1.tgz#d21401f1d59ade56a62e139462a97f104ed19a36" integrity sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg== +"@tanstack/query-core@5.90.16": + version "5.90.16" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.90.16.tgz#19a972c2ffbc47727ab6649028af1bee70e28cdf" + integrity sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww== + +"@tanstack/react-query@^5.90.12": + version "5.90.16" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.90.16.tgz#76955d7027b5bff3d7f51163db7ea1c0bd430cb5" + integrity sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ== + dependencies: + "@tanstack/query-core" "5.90.16" + "@testing-library/dom@^9.0.0": version "9.3.4" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce" @@ -1612,6 +1749,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/estree@1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.33": version "4.17.43" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz#10d8444be560cb789c4735aea5eac6e5af45df54" @@ -6681,6 +6823,11 @@ nan@^2.13.2: resolved "https://registry.yarnpkg.com/nan/-/nan-2.18.0.tgz#26a6faae7ffbeb293a39660e88a76b82e30b7554" integrity sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w== +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + nanoid@^3.3.7: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" @@ -7119,6 +7266,11 @@ picocolors@^1.0.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" @@ -7188,6 +7340,15 @@ postcss@^8.4.39: picocolors "^1.0.1" source-map-js "^1.2.0" +postcss@^8.4.43: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -7805,6 +7966,40 @@ rollup@^4.13.0: "@rollup/rollup-win32-x64-msvc" "4.22.4" fsevents "~2.3.2" +rollup@^4.20.0: + version "4.55.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.55.1.tgz#4ec182828be440648e7ee6520dc35e9f20e05144" + integrity sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.55.1" + "@rollup/rollup-android-arm64" "4.55.1" + "@rollup/rollup-darwin-arm64" "4.55.1" + "@rollup/rollup-darwin-x64" "4.55.1" + "@rollup/rollup-freebsd-arm64" "4.55.1" + "@rollup/rollup-freebsd-x64" "4.55.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.55.1" + "@rollup/rollup-linux-arm-musleabihf" "4.55.1" + "@rollup/rollup-linux-arm64-gnu" "4.55.1" + "@rollup/rollup-linux-arm64-musl" "4.55.1" + "@rollup/rollup-linux-loong64-gnu" "4.55.1" + "@rollup/rollup-linux-loong64-musl" "4.55.1" + "@rollup/rollup-linux-ppc64-gnu" "4.55.1" + "@rollup/rollup-linux-ppc64-musl" "4.55.1" + "@rollup/rollup-linux-riscv64-gnu" "4.55.1" + "@rollup/rollup-linux-riscv64-musl" "4.55.1" + "@rollup/rollup-linux-s390x-gnu" "4.55.1" + "@rollup/rollup-linux-x64-gnu" "4.55.1" + "@rollup/rollup-linux-x64-musl" "4.55.1" + "@rollup/rollup-openbsd-x64" "4.55.1" + "@rollup/rollup-openharmony-arm64" "4.55.1" + "@rollup/rollup-win32-arm64-msvc" "4.55.1" + "@rollup/rollup-win32-ia32-msvc" "4.55.1" + "@rollup/rollup-win32-x64-gnu" "4.55.1" + "@rollup/rollup-win32-x64-msvc" "4.55.1" + fsevents "~2.3.2" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -8111,6 +8306,11 @@ source-map-js@^1.2.0: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + source-map-support@0.5.13: version "0.5.13" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" @@ -8842,7 +9042,7 @@ vite-plugin-node-polyfills@^0.19.0: "@rollup/plugin-inject" "^5.0.5" node-stdlib-browser "^1.2.0" -vite@^5.0.0, vite@^5.3.6: +vite@^5.0.0: version "5.3.6" resolved "https://registry.yarnpkg.com/vite/-/vite-5.3.6.tgz#e097c0a7b79adb2e60bec9ef7907354f09d027bd" integrity sha512-es78AlrylO8mTVBygC0gTC0FENv0C6T496vvd33ydbjF/mIi9q3XQ9A3NWo5qLGFKywvz10J26813OkLvcQleA== @@ -8853,6 +9053,17 @@ vite@^5.0.0, vite@^5.3.6: optionalDependencies: fsevents "~2.3.3" +vite@^5.4.18: + version "5.4.21" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" + integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + vitest-canvas-mock@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/vitest-canvas-mock/-/vitest-canvas-mock-0.3.3.tgz#97e3b5f53003c5cbb9540204ff3122cd25be4dcd" From de9836c4265e1432d31f351c16a9a7241159b3f4 Mon Sep 17 00:00:00 2001 From: RomThpt Date: Mon, 12 Jan 2026 18:42:40 +0100 Subject: [PATCH 2/2] feat: Add MPToken display support in tokens page Add support for displaying Multi-Purpose Tokens (MPT) in the wallet's tokens page with metadata support following XLS-89 specification. Changes: - Add MPToken types for XLS-89 metadata structure - Add fetchMPTokenData utility for fetching and parsing MPToken data - Create MPTokenDisplay molecule for individual token rendering - Create MPTokenListing organism for listing all MPTokens - Integrate MPTokenListing into TokenListing component - Add MPTOKEN_REMOVE_PATH constant (for future PR) Features: - Display ticker, name, icon from token metadata - Format balance using AssetScale from issuance - Show issuer name with truncation and tooltips - Support remove button for zero-balance tokens (requires authorization PR) --- .../MPTokenDisplay/MPTokenDisplay.tsx | 123 ++++++++++++ .../molecules/MPTokenDisplay/index.ts | 1 + .../src/components/molecules/index.ts | 1 + .../MPTokenListing/MPTokenListing.tsx | 96 +++++++++ .../organisms/MPTokenListing/index.ts | 1 + .../organisms/TokenListing/TokenListing.tsx | 2 + packages/extension/src/constants/paths.ts | 1 + packages/extension/src/types/index.ts | 1 + packages/extension/src/types/mptoken.types.ts | 129 ++++++++++++ .../extension/src/utils/fetchMPTokenData.ts | 183 ++++++++++++++++++ packages/extension/src/utils/index.ts | 1 + 11 files changed, 539 insertions(+) create mode 100644 packages/extension/src/components/molecules/MPTokenDisplay/MPTokenDisplay.tsx create mode 100644 packages/extension/src/components/molecules/MPTokenDisplay/index.ts create mode 100644 packages/extension/src/components/organisms/MPTokenListing/MPTokenListing.tsx create mode 100644 packages/extension/src/components/organisms/MPTokenListing/index.ts create mode 100644 packages/extension/src/types/mptoken.types.ts create mode 100644 packages/extension/src/utils/fetchMPTokenData.ts diff --git a/packages/extension/src/components/molecules/MPTokenDisplay/MPTokenDisplay.tsx b/packages/extension/src/components/molecules/MPTokenDisplay/MPTokenDisplay.tsx new file mode 100644 index 000000000..f13e9eb09 --- /dev/null +++ b/packages/extension/src/components/molecules/MPTokenDisplay/MPTokenDisplay.tsx @@ -0,0 +1,123 @@ +import { CSSProperties, FC, useMemo } from 'react'; + +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import TokenIcon from '@mui/icons-material/Token'; +import { Avatar, Paper, Tooltip, Typography } from '@mui/material'; + +import { SECONDARY_GRAY } from '../../../constants'; +import { MPTokenDisplayData } from '../../../types/mptoken.types'; +import { formatToken } from '../../../utils'; +import { truncateMPTIssuanceId } from '../../../utils/fetchMPTokenData'; +import { IconTextButton } from '../../atoms/IconTextButton'; + +export interface MPTokenDisplayProps { + mpToken: MPTokenDisplayData; + onRemoveClick?: () => void; + style?: CSSProperties; +} + +const MAX_NAME_LENGTH = 12; +const MAX_ISSUER_LENGTH = 16; + +export const MPTokenDisplay: FC = ({ mpToken, onRemoveClick, style }) => { + const displayName = useMemo(() => { + // Priority: ticker > name > truncated issuance ID + const name = mpToken.ticker || mpToken.name; + if (name) { + return name.length > MAX_NAME_LENGTH ? `${name.slice(0, MAX_NAME_LENGTH)}...` : name; + } + return truncateMPTIssuanceId(mpToken.mptIssuanceId, 6, 4); + }, [mpToken.ticker, mpToken.name, mpToken.mptIssuanceId]); + + const fullName = useMemo(() => { + return mpToken.ticker || mpToken.name || mpToken.mptIssuanceId; + }, [mpToken.ticker, mpToken.name, mpToken.mptIssuanceId]); + + const displayIssuer = useMemo(() => { + if (mpToken.issuerName) { + return mpToken.issuerName.length > MAX_ISSUER_LENGTH + ? `${mpToken.issuerName.slice(0, MAX_ISSUER_LENGTH)}...` + : mpToken.issuerName; + } + if (mpToken.issuer) { + return mpToken.issuer.length > MAX_ISSUER_LENGTH + ? `${mpToken.issuer.slice(0, MAX_ISSUER_LENGTH)}...` + : mpToken.issuer; + } + return undefined; + }, [mpToken.issuerName, mpToken.issuer]); + + const fullIssuer = useMemo(() => { + return mpToken.issuerName || mpToken.issuer || ''; + }, [mpToken.issuerName, mpToken.issuer]); + + return ( + +
+ {mpToken.iconUrl ? ( + + ) : ( + + + + )} +
+ + + {displayName} + {displayIssuer && ( + + + by {displayIssuer} + + + )} + + + + {formatToken(mpToken.formattedBalance, mpToken.ticker || mpToken.name || 'MPT')} + +
+
+ {onRemoveClick && mpToken.canRemove && ( + + + + + Remove + + + + )} +
+ ); +}; diff --git a/packages/extension/src/components/molecules/MPTokenDisplay/index.ts b/packages/extension/src/components/molecules/MPTokenDisplay/index.ts new file mode 100644 index 000000000..02a3b5425 --- /dev/null +++ b/packages/extension/src/components/molecules/MPTokenDisplay/index.ts @@ -0,0 +1 @@ +export * from './MPTokenDisplay'; diff --git a/packages/extension/src/components/molecules/index.ts b/packages/extension/src/components/molecules/index.ts index 0faad43d3..88b85051f 100644 --- a/packages/extension/src/components/molecules/index.ts +++ b/packages/extension/src/components/molecules/index.ts @@ -4,6 +4,7 @@ export * from './TransactionHeader'; export * from './DataCard'; export * from './InformationMessage'; export * from './InsufficientFundsWarning'; +export * from './MPTokenDisplay'; export * from './NetworkIndicator'; export * from './NFTCard'; export * from './RawTransaction'; diff --git a/packages/extension/src/components/organisms/MPTokenListing/MPTokenListing.tsx b/packages/extension/src/components/organisms/MPTokenListing/MPTokenListing.tsx new file mode 100644 index 000000000..ed81f3cad --- /dev/null +++ b/packages/extension/src/components/organisms/MPTokenListing/MPTokenListing.tsx @@ -0,0 +1,96 @@ +import { FC, useCallback, useEffect, useState } from 'react'; + +import { Typography } from '@mui/material'; +import * as Sentry from '@sentry/react'; +import { useNavigate } from 'react-router-dom'; + +import { MPTOKEN_REMOVE_PATH, SECONDARY_GRAY, STORAGE_MESSAGING_KEY } from '../../../constants'; +import { useNetwork } from '../../../contexts'; +import { MPTokenDisplayData } from '../../../types/mptoken.types'; +import { fetchAllMPTokenDisplayData } from '../../../utils/fetchMPTokenData'; +import { generateKey, saveInChromeSessionStorage } from '../../../utils'; +import { MPTokenDisplay } from '../../molecules/MPTokenDisplay'; + +export interface MPTokenListingProps { + address: string; +} + +export const MPTokenListing: FC = ({ address }) => { + const [mpTokens, setMPTokens] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const { client } = useNetwork(); + const navigate = useNavigate(); + + useEffect(() => { + async function fetchMPTokens() { + if (!client) { + setIsLoading(false); + return; + } + + try { + const tokens = await fetchAllMPTokenDisplayData(client, address); + setMPTokens(tokens); + } catch (e) { + Sentry.captureException(e); + console.error('Error fetching MPTokens:', e); + } finally { + setIsLoading(false); + } + } + + fetchMPTokens(); + }, [address, client]); + + const handleRemoveClick = useCallback( + (mpToken: MPTokenDisplayData) => { + const key = generateKey(); + saveInChromeSessionStorage( + key, + JSON.stringify({ + mptIssuanceId: mpToken.mptIssuanceId, + tokenName: mpToken.ticker || mpToken.name || mpToken.mptIssuanceId, + issuer: mpToken.issuer, + issuerName: mpToken.issuerName + }) + ).then(() => { + navigate(`${MPTOKEN_REMOVE_PATH}?inAppCall=true&${STORAGE_MESSAGING_KEY}=${key}`); + }); + }, + [navigate] + ); + + // Don't render anything if no MPTokens + if (!isLoading && mpTokens.length === 0) { + return null; + } + + // Show loading state only when actively loading + if (isLoading) { + return null; + } + + return ( +
+ + MPTokens + + {mpTokens.map((token) => ( + handleRemoveClick(token) : undefined} + /> + ))} +
+ ); +}; diff --git a/packages/extension/src/components/organisms/MPTokenListing/index.ts b/packages/extension/src/components/organisms/MPTokenListing/index.ts new file mode 100644 index 000000000..2909f2640 --- /dev/null +++ b/packages/extension/src/components/organisms/MPTokenListing/index.ts @@ -0,0 +1 @@ +export * from './MPTokenListing'; diff --git a/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx b/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx index a97332cc9..3c08118ae 100644 --- a/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx +++ b/packages/extension/src/components/organisms/TokenListing/TokenListing.tsx @@ -20,6 +20,7 @@ import { isLPToken } from '../../../utils/trustlines'; import { TokenLoader } from '../../atoms'; import { InformationMessage } from '../../molecules/InformationMessage'; import { TokenDisplay } from '../../molecules/TokenDisplay'; +import { MPTokenListing } from '../MPTokenListing'; import { DialogPage } from '../../templates'; export interface TokenListingProps { @@ -195,6 +196,7 @@ export const TokenListing: FC = ({ address }) => { /> ); })} +
diff --git a/packages/extension/src/constants/paths.ts b/packages/extension/src/constants/paths.ts index 0f7c574d1..3db31b895 100644 --- a/packages/extension/src/constants/paths.ts +++ b/packages/extension/src/constants/paths.ts @@ -19,6 +19,7 @@ export const IMPORT_SEED_PATH = '/import-seed'; export const IMPORT_WALLET_PATH = '/import-wallet'; export const LIST_WALLETS_PATH = '/list-wallets'; export const MINT_NFT_PATH = '/mint-nft'; +export const MPTOKEN_REMOVE_PATH = '/mptoken-remove'; export const PERMISSIONS_PATH = '/permissions'; export const RESET_PASSWORD_PATH = '/reset-password'; export const SEND_PATH = '/send'; diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index 699c40403..b5c5cdcd3 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -1,3 +1,4 @@ +export * from './mptoken.types'; export * from './transaction.types'; export * from './utils.types'; export * from './wallet.types'; diff --git a/packages/extension/src/types/mptoken.types.ts b/packages/extension/src/types/mptoken.types.ts new file mode 100644 index 000000000..883cbc524 --- /dev/null +++ b/packages/extension/src/types/mptoken.types.ts @@ -0,0 +1,129 @@ +/** + * MPToken types for Multi-Purpose Tokens on XRPL + */ + +/** + * Raw MPToken object returned from account_objects with type: 'mptoken' + */ +export interface MPTokenObject { + MPTokenIssuanceID: string; + MPTAmount: string; + Flags: number; + LedgerEntryType: 'MPToken'; + OwnerNode?: string; + PreviousTxnID?: string; + PreviousTxnLgrSeq?: number; + index: string; +} + +/** + * MPTokenIssuance object returned from ledger_entry + */ +export interface MPTokenIssuance { + LedgerEntryType: 'MPTokenIssuance'; + Flags: number; + Issuer: string; + AssetScale?: number; + MaximumAmount?: string; + OutstandingAmount?: string; + TransferFee?: number; + MPTokenMetadata?: string; // Hex-encoded JSON following XLS-89 spec + OwnerNode?: string; + index: string; +} + +/** + * Asset class values for MPToken metadata (XLS-89 spec) + */ +export type MPTokenAssetClass = 'rwa' | 'memes' | 'wrapped' | 'gaming' | 'defi' | 'other'; + +/** + * Asset subclass values for MPToken metadata (only applicable when asset_class is 'rwa') + */ +export type MPTokenAssetSubclass = + | 'stablecoin' + | 'commodity' + | 'real_estate' + | 'private_credit' + | 'equity' + | 'treasury' + | 'other'; + +/** + * URI object within the uris array (XLS-89 spec) + */ +export interface MPTokenURI { + // URI value + u?: string; + // URI type/category + c?: string; + // URI title + t?: string; +} + +/** + * Decoded MPToken metadata following XLS-89 spec + * Short field names (t, n, d, i) and long names (ticker, name, desc, icon) are both supported + */ +export interface MPTokenMetadata { + // Ticker/symbol (short: t, long: ticker) + ticker?: string; + t?: string; + // Token name (short: n, long: name) + name?: string; + n?: string; + // Description (short: d, long: desc) + description?: string; + d?: string; + desc?: string; + // Icon URL (short: i, long: icon) + icon?: string; + i?: string; + // Asset class (short: ac, long: asset_class) + assetClass?: MPTokenAssetClass; + ac?: MPTokenAssetClass; + asset_class?: MPTokenAssetClass; + // Asset subclass (short: as, long: asset_subclass) - only required if asset_class is 'rwa' + assetSubclass?: MPTokenAssetSubclass; + as?: MPTokenAssetSubclass; + asset_subclass?: MPTokenAssetSubclass; + // Issuer name (short: in, long: issuer_name) + issuerName?: string; + in?: string; + issuer_name?: string; + // URIs array (short: us, long: uris) + uris?: MPTokenURI[]; + us?: MPTokenURI[]; + // Additional info (short: ai, long: additional_info) - can be object or string + additionalInfo?: Record | string; + ai?: Record | string; + additional_info?: Record | string; +} + +/** + * Parsed MPToken data for display + */ +export interface MPTokenDisplayData { + mptIssuanceId: string; + balance: string; + formattedBalance: number; + issuer: string; + assetScale: number; + // Metadata fields + ticker?: string; + name?: string; + description?: string; + iconUrl?: string; + issuerName?: string; + // Flags + flags: number; + // Can be removed (balance is 0) + canRemove: boolean; +} + +/** + * MPTokenAuthorize transaction flags + */ +export enum MPTokenAuthorizeFlags { + tfMPTUnauthorize = 0x00000001 // 1 - Revoke authorization +} diff --git a/packages/extension/src/utils/fetchMPTokenData.ts b/packages/extension/src/utils/fetchMPTokenData.ts new file mode 100644 index 000000000..74ffde643 --- /dev/null +++ b/packages/extension/src/utils/fetchMPTokenData.ts @@ -0,0 +1,183 @@ +import { Client } from 'xrpl'; + +import { + MPTokenDisplayData, + MPTokenIssuance, + MPTokenMetadata, + MPTokenObject +} from '../types/mptoken.types'; +import { loadFromChromeSessionStorage, saveInChromeSessionStorage } from './storageChromeSession'; + +/** + * Fetch all MPToken objects for an account + */ +export const fetchMPTokens = async (client: Client, address: string): Promise => { + try { + // Use type assertion since xrpl.js doesn't have mptoken type yet + const response = await client.request({ + command: 'account_objects', + account: address, + ledger_index: 'validated', + type: 'mptoken' as 'state' // Type assertion - mptoken is not in xrpl.js types yet + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ((response.result as any).account_objects as MPTokenObject[]) || []; + } catch (error) { + // Account not found or other error - return empty array + console.error('Error fetching MPTokens:', error); + return []; + } +}; + +/** + * Fetch MPTokenIssuance details from ledger + */ +export const fetchMPTokenIssuance = async ( + client: Client, + mptIssuanceId: string +): Promise => { + try { + // Check cache first + const cacheKey = `mptIssuance-${mptIssuanceId}`; + const cachedData = await loadFromChromeSessionStorage(cacheKey); + if (cachedData) { + return cachedData as MPTokenIssuance; + } + + const response = await client.request({ + command: 'ledger_entry', + mpt_issuance: mptIssuanceId, + ledger_index: 'validated' + }); + + const node = response.result.node as unknown as MPTokenIssuance; + if (node) { + // Cache the result + saveInChromeSessionStorage(cacheKey, node); + return node; + } + + return null; + } catch (error) { + console.error('Error fetching MPTokenIssuance:', error); + return null; + } +}; + +/** + * Decode hex-encoded MPToken metadata to JSON + */ +export const decodeMPTokenMetadata = (hexMetadata: string): MPTokenMetadata | null => { + try { + const decoded = Buffer.from(hexMetadata, 'hex').toString('utf8'); + return JSON.parse(decoded) as MPTokenMetadata; + } catch (error) { + console.error('Error decoding MPToken metadata:', error); + return null; + } +}; + +/** + * Extract display-friendly values from MPToken metadata + */ +const extractMetadataFields = ( + metadata: MPTokenMetadata | null +): { + ticker?: string; + name?: string; + description?: string; + iconUrl?: string; + issuerName?: string; +} => { + if (!metadata) { + return {}; + } + + return { + ticker: metadata.ticker || metadata.t, + name: metadata.name || metadata.n, + description: metadata.description || metadata.desc || metadata.d, + iconUrl: metadata.icon || metadata.i, + issuerName: metadata.issuerName || metadata.issuer_name || metadata.in + }; +}; + +/** + * Format MPToken amount based on asset scale + */ +export const formatMPTokenAmount = (amount: string, assetScale: number): number => { + const rawAmount = BigInt(amount); + const divisor = BigInt(10 ** assetScale); + const integerPart = rawAmount / divisor; + const fractionalPart = rawAmount % divisor; + + // Convert to number with proper decimal places + const fractionalStr = fractionalPart.toString().padStart(assetScale, '0'); + const formattedStr = `${integerPart}.${fractionalStr}`; + + return parseFloat(formattedStr); +}; + +/** + * Get full MPToken display data by combining token object with issuance metadata + */ +export const getMPTokenDisplayData = async ( + client: Client, + mpToken: MPTokenObject +): Promise => { + const issuance = await fetchMPTokenIssuance(client, mpToken.MPTokenIssuanceID); + + const assetScale = issuance?.AssetScale ?? 0; + const formattedBalance = formatMPTokenAmount(mpToken.MPTAmount, assetScale); + + let metadataFields = {}; + if (issuance?.MPTokenMetadata) { + const metadata = decodeMPTokenMetadata(issuance.MPTokenMetadata); + metadataFields = extractMetadataFields(metadata); + } + + return { + mptIssuanceId: mpToken.MPTokenIssuanceID, + balance: mpToken.MPTAmount, + formattedBalance, + issuer: issuance?.Issuer || '', + assetScale, + ...metadataFields, + flags: mpToken.Flags, + canRemove: mpToken.MPTAmount === '0' + }; +}; + +/** + * Fetch all MPTokens with their display data for an account + */ +export const fetchAllMPTokenDisplayData = async ( + client: Client, + address: string +): Promise => { + const mpTokens = await fetchMPTokens(client, address); + + if (mpTokens.length === 0) { + return []; + } + + const displayDataPromises = mpTokens.map((token) => getMPTokenDisplayData(client, token)); + const displayData = await Promise.all(displayDataPromises); + + return displayData; +}; + +/** + * Truncate MPTokenIssuanceID for display + */ +export const truncateMPTIssuanceId = ( + issuanceId: string, + prefixLength = 8, + suffixLength = 8 +): string => { + if (issuanceId.length <= prefixLength + suffixLength + 3) { + return issuanceId; + } + return `${issuanceId.slice(0, prefixLength)}...${issuanceId.slice(-suffixLength)}`; +}; diff --git a/packages/extension/src/utils/index.ts b/packages/extension/src/utils/index.ts index f956d6f5b..26af2aedc 100644 --- a/packages/extension/src/utils/index.ts +++ b/packages/extension/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './breakStringByLine'; export * from './convertHexCurrencyString'; export * from './crypto'; +export * from './fetchMPTokenData'; export * from './fetchTokenData'; export * from './format'; export * from './getLastItemFromArray';