From a6d4309fe83a28cd2f99a780ce36efa1d97df713 Mon Sep 17 00:00:00 2001 From: RomThpt Date: Mon, 12 Jan 2026 18:36:33 +0100 Subject: [PATCH 1/4] 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/4] 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'; From f37fc0a60b0a971267a28e31755106a9aa92fbf1 Mon Sep 17 00:00:00 2001 From: RomThpt Date: Mon, 12 Jan 2026 18:51:35 +0100 Subject: [PATCH 3/4] feat: Add MPToken authorization feature Add ability to authorize (hold) and revoke MPTokens via the UI. Changes: - Add addMPTokenAuthorization and removeMPTokenAuthorization to LedgerContext - Create AddMPToken page for authorizing new MPTokens by issuance ID - Create MPTokenRemove page for revoking MPToken authorization - Add routes for /add-mptoken and /mptoken-remove - Update ledger context mock for tests The MPTokenAuthorize transaction allows users to: - Hold a new MPToken by providing the MPTokenIssuanceID - Remove authorization when balance is 0 --- .../pages/AddMPToken/AddMPToken.tsx | 184 ++++++++++++++++++ .../src/components/pages/AddMPToken/index.ts | 1 + .../pages/MPTokenRemove/MPTokenRemove.tsx | 181 +++++++++++++++++ .../components/pages/MPTokenRemove/index.ts | 1 + .../components/pages/routes/private.routes.ts | 6 + packages/extension/src/constants/paths.ts | 1 + .../contexts/LedgerContext/LedgerContext.tsx | 77 +++++++- packages/extension/src/mocks/ledgerContext.ts | 5 +- 8 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 packages/extension/src/components/pages/AddMPToken/AddMPToken.tsx create mode 100644 packages/extension/src/components/pages/AddMPToken/index.ts create mode 100644 packages/extension/src/components/pages/MPTokenRemove/MPTokenRemove.tsx create mode 100644 packages/extension/src/components/pages/MPTokenRemove/index.ts diff --git a/packages/extension/src/components/pages/AddMPToken/AddMPToken.tsx b/packages/extension/src/components/pages/AddMPToken/AddMPToken.tsx new file mode 100644 index 000000000..aa3dab930 --- /dev/null +++ b/packages/extension/src/components/pages/AddMPToken/AddMPToken.tsx @@ -0,0 +1,184 @@ +import { FC, useCallback, useMemo, useState } from 'react'; + +import TokenIcon from '@mui/icons-material/Token'; +import { Avatar, Button, Container, TextField, Typography } from '@mui/material'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import { HOME_PATH, SECONDARY_GRAY, STORAGE_MESSAGING_KEY } from '../../../constants'; +import { + TransactionProgressStatus, + useLedger, + useNetwork, + useTransactionProgress +} from '../../../contexts'; +import { useFees } from '../../../hooks'; +import { TransactionStatus } from '../../../types'; +import { loadFromChromeSessionStorage } from '../../../utils'; +import { toUIError } from '../../../utils/errors'; +import { InformationMessage } from '../../molecules'; +import { Fee } from '../../organisms'; +import { AsyncTransaction, PageWithReturn } from '../../templates'; + +const DEFAULT_FEES = '12'; + +interface AddMPTokenParams { + mptIssuanceId?: string; + issuer?: string; +} + +export const AddMPToken: FC = () => { + const [mptIssuanceId, setMPTIssuanceId] = useState(''); + const [transaction, setTransaction] = useState(TransactionStatus.Waiting); + const [errorRequestRejection, setErrorRequestRejection] = useState(null); + const [params, setParams] = useState({}); + + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { addMPTokenAuthorization } = useLedger(); + const { networkName } = useNetwork(); + const { setTransactionProgress } = useTransactionProgress(); + + // Load params from session storage if inAppCall + useMemo(() => { + const storageKey = searchParams.get(STORAGE_MESSAGING_KEY); + if (storageKey) { + loadFromChromeSessionStorage(storageKey).then((data) => { + if (data) { + const parsed = data as AddMPTokenParams; + setParams(parsed); + if (parsed.mptIssuanceId) { + setMPTIssuanceId(parsed.mptIssuanceId); + } + } + }); + } + }, [searchParams]); + + // For MPToken authorization, we need a minimal transaction object for fee estimation + const { estimatedFees, errorFees } = useFees( + [ + { + TransactionType: 'AccountSet', // Use AccountSet as placeholder for fee estimation + Account: '' + } + ], + DEFAULT_FEES + ); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + setMPTIssuanceId(e.target.value); + }, []); + + const handleAddMPToken = useCallback(() => { + if (!mptIssuanceId) return; + + setTransaction(TransactionStatus.Pending); + setTransactionProgress(TransactionProgressStatus.IN_PROGRESS); + + addMPTokenAuthorization(mptIssuanceId) + .then(() => { + setTransaction(TransactionStatus.Success); + }) + .catch((e) => { + setErrorRequestRejection(e); + setTransaction(TransactionStatus.Rejected); + }) + .finally(() => { + setTransactionProgress(TransactionProgressStatus.IDLE); + }); + }, [mptIssuanceId, addMPTokenAuthorization, setTransactionProgress]); + + const handleReject = useCallback(() => { + navigate(HOME_PATH); + }, [navigate]); + + if (transaction === TransactionStatus.Success || transaction === TransactionStatus.Rejected) { + return ( + + ); + } + + const isValidIssuanceId = mptIssuanceId.length === 64; + + return ( + + +
+ + + +
+ Authorize MPToken + + Hold a Multi-Purpose Token (MPT) + +
+
+ + + + Authorizing an MPToken allows you to hold and receive this token. You will need the + MPTokenIssuanceID from the token issuer. + + + + + + {params.issuer && ( + + Issuer: {params.issuer} + + )} + + + +
+ + +
+
+
+ ); +}; diff --git a/packages/extension/src/components/pages/AddMPToken/index.ts b/packages/extension/src/components/pages/AddMPToken/index.ts new file mode 100644 index 000000000..854c1ee96 --- /dev/null +++ b/packages/extension/src/components/pages/AddMPToken/index.ts @@ -0,0 +1 @@ +export * from './AddMPToken'; diff --git a/packages/extension/src/components/pages/MPTokenRemove/MPTokenRemove.tsx b/packages/extension/src/components/pages/MPTokenRemove/MPTokenRemove.tsx new file mode 100644 index 000000000..b0b6bb391 --- /dev/null +++ b/packages/extension/src/components/pages/MPTokenRemove/MPTokenRemove.tsx @@ -0,0 +1,181 @@ +import { FC, useCallback, useMemo, useState } from 'react'; + +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import { Avatar, Button, Container, Typography } from '@mui/material'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import { ERROR_RED, HOME_PATH, SECONDARY_GRAY, STORAGE_MESSAGING_KEY } from '../../../constants'; +import { + TransactionProgressStatus, + useLedger, + useNetwork, + useTransactionProgress +} from '../../../contexts'; +import { useFees } from '../../../hooks'; +import { TransactionStatus } from '../../../types'; +import { loadFromChromeSessionStorage } from '../../../utils'; +import { truncateMPTIssuanceId } from '../../../utils/fetchMPTokenData'; +import { toUIError } from '../../../utils/errors'; +import { InformationMessage } from '../../molecules'; +import { Fee } from '../../organisms'; +import { AsyncTransaction, PageWithReturn } from '../../templates'; + +const DEFAULT_FEES = '12'; + +interface MPTokenRemoveParams { + mptIssuanceId: string; + tokenName?: string; + issuer?: string; + issuerName?: string; +} + +export const MPTokenRemove: FC = () => { + const [transaction, setTransaction] = useState(TransactionStatus.Waiting); + const [errorRequestRejection, setErrorRequestRejection] = useState(null); + const [params, setParams] = useState(null); + + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { removeMPTokenAuthorization } = useLedger(); + const { networkName } = useNetwork(); + const { setTransactionProgress } = useTransactionProgress(); + + // Load params from session storage + useMemo(() => { + const storageKey = searchParams.get(STORAGE_MESSAGING_KEY); + if (storageKey) { + loadFromChromeSessionStorage(storageKey).then((data) => { + if (data) { + setParams(data as MPTokenRemoveParams); + } + }); + } + }, [searchParams]); + + const { estimatedFees, errorFees } = useFees( + [ + { + TransactionType: 'AccountSet', // Use AccountSet as placeholder for fee estimation + Account: '' + } + ], + DEFAULT_FEES + ); + + const handleRemoveMPToken = useCallback(() => { + if (!params?.mptIssuanceId) return; + + setTransaction(TransactionStatus.Pending); + setTransactionProgress(TransactionProgressStatus.IN_PROGRESS); + + removeMPTokenAuthorization(params.mptIssuanceId) + .then(() => { + setTransaction(TransactionStatus.Success); + }) + .catch((e) => { + setErrorRequestRejection(e); + setTransaction(TransactionStatus.Rejected); + }) + .finally(() => { + setTransactionProgress(TransactionProgressStatus.IDLE); + }); + }, [params, removeMPTokenAuthorization, setTransactionProgress]); + + const handleReject = useCallback(() => { + navigate(HOME_PATH); + }, [navigate]); + + if (transaction === TransactionStatus.Success || transaction === TransactionStatus.Rejected) { + return ( + + ); + } + + if (!params) { + return ( + + + Loading... + + + ); + } + + const displayName = params.tokenName || truncateMPTIssuanceId(params.mptIssuanceId); + + return ( + + +
+ + + +
+ Remove MPToken Authorization + + You will no longer be able to hold this token + +
+
+ + + + Removing authorization means you cannot receive this token anymore. You can only remove + authorization when your balance is 0. You can re-authorize this token later if needed. + + + + + Token: {displayName} + + + + Issuance ID: {truncateMPTIssuanceId(params.mptIssuanceId, 16, 16)} + + + {(params.issuerName || params.issuer) && ( + + Issuer: {params.issuerName || params.issuer} + + )} + + + +
+ + +
+
+
+ ); +}; diff --git a/packages/extension/src/components/pages/MPTokenRemove/index.ts b/packages/extension/src/components/pages/MPTokenRemove/index.ts new file mode 100644 index 000000000..00c5dd5b9 --- /dev/null +++ b/packages/extension/src/components/pages/MPTokenRemove/index.ts @@ -0,0 +1 @@ +export * from './MPTokenRemove'; diff --git a/packages/extension/src/components/pages/routes/private.routes.ts b/packages/extension/src/components/pages/routes/private.routes.ts index 13f1619f0..fd2900a9f 100644 --- a/packages/extension/src/components/pages/routes/private.routes.ts +++ b/packages/extension/src/components/pages/routes/private.routes.ts @@ -1,6 +1,7 @@ import { ABOUT_PATH, ACCEPT_NFT_OFFER_PATH, + ADD_MPTOKEN_PATH, ADD_NEW_TRUSTLINE_PATH, ADD_NEW_WALLET_PATH, SUBMIT_RAW_TRANSACTION_PATH, @@ -15,6 +16,7 @@ import { HOME_PATH, LIST_WALLETS_PATH, MINT_NFT_PATH, + MPTOKEN_REMOVE_PATH, NFT_VIEWER_PATH, PERMISSIONS_PATH, RECEIVE_PATH, @@ -35,6 +37,7 @@ import { } from '../../../constants'; import { About } from '../About'; import { AcceptNFTOffer } from '../AcceptNFTOffer'; +import { AddMPToken } from '../AddMPToken'; import { AddNewTrustline } from '../AddNewTrustline'; import { AddNewWallet } from '../AddNewWallet'; import { BurnNFT } from '../BurnNFT'; @@ -48,6 +51,7 @@ import { History } from '../History'; import { Home } from '../Home'; import { ListWallets } from '../ListWallets'; import { MintNFT } from '../MintNFT'; +import { MPTokenRemove } from '../MPTokenRemove'; import { NFTViewer } from '../NFTViewer'; import { Permissions } from '../Permissions'; import { ReceivePayment } from '../ReceivePayment'; @@ -75,6 +79,7 @@ type PrivateRouteConfig = { export const privateRoutes: PrivateRouteConfig[] = [ { path: ABOUT_PATH, element: About }, { path: ACCEPT_NFT_OFFER_PATH, element: AcceptNFTOffer }, + { path: ADD_MPTOKEN_PATH, element: AddMPToken }, { path: ADD_NEW_TRUSTLINE_PATH, element: AddNewTrustline }, { path: ADD_NEW_WALLET_PATH, element: AddNewWallet }, { path: BURN_NFT_PATH, element: BurnNFT }, @@ -88,6 +93,7 @@ export const privateRoutes: PrivateRouteConfig[] = [ { path: HOME_PATH, element: Home }, { path: LIST_WALLETS_PATH, element: ListWallets }, { path: MINT_NFT_PATH, element: MintNFT }, + { path: MPTOKEN_REMOVE_PATH, element: MPTokenRemove }, { path: NFT_VIEWER_PATH, element: NFTViewer }, { path: PERMISSIONS_PATH, element: Permissions }, { path: RECEIVE_PATH, element: ReceivePayment }, diff --git a/packages/extension/src/constants/paths.ts b/packages/extension/src/constants/paths.ts index 3db31b895..a08b18e4a 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 ADD_MPTOKEN_PATH = '/add-mptoken'; export const MPTOKEN_REMOVE_PATH = '/mptoken-remove'; export const PERMISSIONS_PATH = '/permissions'; export const RESET_PASSWORD_PATH = '/reset-password'; diff --git a/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx b/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx index 412797883..63600a47f 100644 --- a/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx +++ b/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx @@ -133,6 +133,10 @@ interface SetHookResponse { hash: string; } +interface MPTokenAuthorizeResponse { + hash: string; +} + interface Props { children: React.ReactElement; } @@ -168,6 +172,9 @@ export interface LedgerContextType { getNFTInfo: (NFTokenID: string) => Promise; getLedgerEntry: (ID: string) => Promise; setHook: (payload: SetHook) => Promise; + // MPToken + addMPTokenAuthorization: (mptIssuanceId: string) => Promise; + removeMPTokenAuthorization: (mptIssuanceId: string) => Promise; } const LedgerContext = createContext({ @@ -198,7 +205,10 @@ const LedgerContext = createContext({ deleteAccount: () => new Promise(() => {}), getNFTInfo: () => new Promise(() => {}), getLedgerEntry: () => new Promise(() => {}), - setHook: () => new Promise(() => {}) + setHook: () => new Promise(() => {}), + // MPToken + addMPTokenAuthorization: () => new Promise(() => {}), + removeMPTokenAuthorization: () => new Promise(() => {}) }); const LedgerProvider: FC = ({ children }) => { @@ -724,6 +734,66 @@ const LedgerProvider: FC = ({ children }) => { [chainName, client, getCurrentWallet, handleTransaction] ); + /* + * MPToken Transactions + */ + const addMPTokenAuthorization = useCallback( + async (mptIssuanceId: string): Promise => { + const wallet = getCurrentWallet(); + if (!client) { + throw new Error('You need to be connected to a ledger'); + } else if (!wallet) { + throw new Error('You need to have a wallet connected'); + } else { + try { + // Build MPTokenAuthorize transaction without flags (authorizing) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const transaction: any = { + TransactionType: 'MPTokenAuthorize', + Account: wallet.publicAddress, + MPTokenIssuanceID: mptIssuanceId + }; + const { hash } = await handleTransaction({ transaction, client, wallet }); + if (!hash) throw new Error("Couldn't authorize MPToken"); + return { hash }; + } catch (e) { + Sentry.captureException(e); + throw e; + } + } + }, + [client, getCurrentWallet, handleTransaction] + ); + + const removeMPTokenAuthorization = useCallback( + async (mptIssuanceId: string): Promise => { + const wallet = getCurrentWallet(); + if (!client) { + throw new Error('You need to be connected to a ledger'); + } else if (!wallet) { + throw new Error('You need to have a wallet connected'); + } else { + try { + // Build MPTokenAuthorize transaction with tfMPTUnauthorize flag + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const transaction: any = { + TransactionType: 'MPTokenAuthorize', + Account: wallet.publicAddress, + MPTokenIssuanceID: mptIssuanceId, + Flags: 1 // tfMPTUnauthorize + }; + const { hash } = await handleTransaction({ transaction, client, wallet }); + if (!hash) throw new Error("Couldn't remove MPToken authorization"); + return { hash }; + } catch (e) { + Sentry.captureException(e); + throw e; + } + } + }, + [client, getCurrentWallet, handleTransaction] + ); + /* * Getters */ @@ -889,7 +959,10 @@ const LedgerProvider: FC = ({ children }) => { getNFTInfo, getLedgerEntry, setRegularKey, - setHook + setHook, + // MPToken + addMPTokenAuthorization, + removeMPTokenAuthorization }; return {children}; diff --git a/packages/extension/src/mocks/ledgerContext.ts b/packages/extension/src/mocks/ledgerContext.ts index 80e1194e9..cd4d534f6 100644 --- a/packages/extension/src/mocks/ledgerContext.ts +++ b/packages/extension/src/mocks/ledgerContext.ts @@ -26,5 +26,8 @@ export const valueLedgerContext: LedgerContextType = { getNFTInfo: vi.fn(), getLedgerEntry: vi.fn(), setRegularKey: vi.fn(), - setHook: vi.fn() + setHook: vi.fn(), + // MPToken + addMPTokenAuthorization: vi.fn(), + removeMPTokenAuthorization: vi.fn() }; From 45c72c08bf02dc3106ed1f1d422ee3b7999bd10d Mon Sep 17 00:00:00 2001 From: RomThpt Date: Mon, 12 Jan 2026 18:58:27 +0100 Subject: [PATCH 4/4] feat: Add MPToken issuance creation feature Add ability for users to create their own MPToken issuances (issue new tokens on XRPL). Changes: - Add createMPTokenIssuance function to LedgerContext - Create CreateMPTokenIssuance page with form for: - Asset scale (decimal places) - Maximum amount (supply cap) - Transfer fee percentage - XLS-89 metadata (JSON format) - Token capability flags (transfer, trade, escrow, lock, clawback, etc.) - Add route for /create-mptoken-issuance - Update mock for tests This allows token issuers to create new MPToken issuances with full control over token properties and capabilities. --- .../CreateMPTokenIssuance.tsx | 301 ++++++++++++++++++ .../pages/CreateMPTokenIssuance/index.ts | 1 + .../components/pages/routes/private.routes.ts | 3 + packages/extension/src/constants/paths.ts | 1 + .../contexts/LedgerContext/LedgerContext.tsx | 70 +++- packages/extension/src/mocks/ledgerContext.ts | 3 +- 6 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 packages/extension/src/components/pages/CreateMPTokenIssuance/CreateMPTokenIssuance.tsx create mode 100644 packages/extension/src/components/pages/CreateMPTokenIssuance/index.ts diff --git a/packages/extension/src/components/pages/CreateMPTokenIssuance/CreateMPTokenIssuance.tsx b/packages/extension/src/components/pages/CreateMPTokenIssuance/CreateMPTokenIssuance.tsx new file mode 100644 index 000000000..48b756455 --- /dev/null +++ b/packages/extension/src/components/pages/CreateMPTokenIssuance/CreateMPTokenIssuance.tsx @@ -0,0 +1,301 @@ +import { FC, useCallback, useState } from 'react'; + +import TokenIcon from '@mui/icons-material/Token'; +import { + Avatar, + Button, + Checkbox, + Container, + FormControlLabel, + TextField, + Typography +} from '@mui/material'; +import { useNavigate } from 'react-router-dom'; + +import { HOME_PATH, SECONDARY_GRAY } from '../../../constants'; +import { + TransactionProgressStatus, + useLedger, + useNetwork, + useTransactionProgress +} from '../../../contexts'; +import { useFees } from '../../../hooks'; +import { TransactionStatus } from '../../../types'; +import { toUIError } from '../../../utils/errors'; +import { InformationMessage } from '../../molecules'; +import { Fee } from '../../organisms'; +import { AsyncTransaction, PageWithReturn } from '../../templates'; + +const DEFAULT_FEES = '12'; + +// MPTokenIssuanceCreate flags +const FLAG_CAN_LOCK = 0x0002; // tfMPTCanLock - Issuer can lock tokens +const FLAG_REQUIRE_AUTH = 0x0004; // tfMPTRequireAuth - Requires issuer authorization to hold +const FLAG_CAN_ESCROW = 0x0008; // tfMPTCanEscrow - Can be used in escrow +const FLAG_CAN_TRADE = 0x0010; // tfMPTCanTrade - Can be traded in DEX +const FLAG_CAN_TRANSFER = 0x0020; // tfMPTCanTransfer - Holders can transfer between themselves +const FLAG_CAN_CLAWBACK = 0x0040; // tfMPTCanClawback - Issuer can clawback tokens + +export const CreateMPTokenIssuance: FC = () => { + // Form fields + const [assetScale, setAssetScale] = useState(''); + const [maximumAmount, setMaximumAmount] = useState(''); + const [transferFee, setTransferFee] = useState(''); + const [metadata, setMetadata] = useState(''); + + // Flags + const [canLock, setCanLock] = useState(false); + const [requireAuth, setRequireAuth] = useState(false); + const [canEscrow, setCanEscrow] = useState(false); + const [canTrade, setCanTrade] = useState(true); + const [canTransfer, setCanTransfer] = useState(true); + const [canClawback, setCanClawback] = useState(false); + + const [transaction, setTransaction] = useState(TransactionStatus.Waiting); + const [errorRequestRejection, setErrorRequestRejection] = useState(null); + + const navigate = useNavigate(); + const { createMPTokenIssuance } = useLedger(); + const { networkName } = useNetwork(); + const { setTransactionProgress } = useTransactionProgress(); + + const { estimatedFees, errorFees } = useFees( + [ + { + TransactionType: 'AccountSet', + Account: '' + } + ], + DEFAULT_FEES + ); + + const calculateFlags = useCallback(() => { + let flags = 0; + if (canLock) flags |= FLAG_CAN_LOCK; + if (requireAuth) flags |= FLAG_REQUIRE_AUTH; + if (canEscrow) flags |= FLAG_CAN_ESCROW; + if (canTrade) flags |= FLAG_CAN_TRADE; + if (canTransfer) flags |= FLAG_CAN_TRANSFER; + if (canClawback) flags |= FLAG_CAN_CLAWBACK; + return flags; + }, [canLock, requireAuth, canEscrow, canTrade, canTransfer, canClawback]); + + const handleCreate = useCallback(() => { + setTransaction(TransactionStatus.Pending); + setTransactionProgress(TransactionProgressStatus.IN_PROGRESS); + + const params: { + assetScale?: number; + maximumAmount?: string; + transferFee?: number; + metadata?: string; + flags?: number; + } = {}; + + if (assetScale) { + params.assetScale = parseInt(assetScale, 10); + } + if (maximumAmount) { + params.maximumAmount = maximumAmount; + } + if (transferFee) { + // Transfer fee is in tenths of a basis point (0.001%) + // UI shows percentage, convert to internal format + params.transferFee = Math.round(parseFloat(transferFee) * 1000); + } + if (metadata) { + // Convert metadata JSON to hex + params.metadata = Buffer.from(metadata, 'utf8').toString('hex').toUpperCase(); + } + + const flags = calculateFlags(); + if (flags > 0) { + params.flags = flags; + } + + createMPTokenIssuance(params) + .then(() => { + setTransaction(TransactionStatus.Success); + }) + .catch((e) => { + setErrorRequestRejection(e); + setTransaction(TransactionStatus.Rejected); + }) + .finally(() => { + setTransactionProgress(TransactionProgressStatus.IDLE); + }); + }, [ + assetScale, + maximumAmount, + transferFee, + metadata, + calculateFlags, + createMPTokenIssuance, + setTransactionProgress + ]); + + const handleReject = useCallback(() => { + navigate(HOME_PATH); + }, [navigate]); + + if (transaction === TransactionStatus.Success || transaction === TransactionStatus.Rejected) { + return ( + + ); + } + + // Validate transfer fee (0-50%) + const isValidTransferFee = + !transferFee || (parseFloat(transferFee) >= 0 && parseFloat(transferFee) <= 50); + + // Validate asset scale (0-9) + const isValidAssetScale = + !assetScale || (parseInt(assetScale, 10) >= 0 && parseInt(assetScale, 10) <= 9); + + return ( + + +
+ + + +
+ Create MPToken Issuance + + Issue your own Multi-Purpose Token + +
+
+ + + + Creating an MPToken issuance allows you to issue your own tokens on the XRP Ledger. You + will be the issuer and can control token properties. + + + + setAssetScale(e.target.value)} + fullWidth + type="number" + inputProps={{ min: 0, max: 9 }} + helperText={!isValidAssetScale ? 'Must be between 0 and 9' : 'Number of decimal places'} + error={!isValidAssetScale} + /> + + setMaximumAmount(e.target.value)} + fullWidth + /> + + setTransferFee(e.target.value)} + fullWidth + type="number" + inputProps={{ min: 0, max: 50, step: 0.001 }} + helperText={ + !isValidTransferFee ? 'Must be between 0% and 50%' : 'Fee charged on token transfers' + } + error={!isValidTransferFee} + /> + + setMetadata(e.target.value)} + fullWidth + multiline + rows={3} + helperText="XLS-89 metadata (ticker, name, desc, icon, etc.)" + /> + + + Token Capabilities + + + setCanTransfer(e.target.checked)} /> + } + label="Can Transfer (holders can send to each other)" + /> + setCanTrade(e.target.checked)} />} + label="Can Trade (tradeable on DEX)" + /> + setCanEscrow(e.target.checked)} /> + } + label="Can Escrow" + /> + setCanLock(e.target.checked)} />} + label="Can Lock (issuer can lock holder tokens)" + /> + setCanClawback(e.target.checked)} /> + } + label="Can Clawback (issuer can reclaim tokens)" + /> + setRequireAuth(e.target.checked)} /> + } + label="Require Authorization (holders need approval)" + /> + + + +
+ + +
+
+
+ ); +}; diff --git a/packages/extension/src/components/pages/CreateMPTokenIssuance/index.ts b/packages/extension/src/components/pages/CreateMPTokenIssuance/index.ts new file mode 100644 index 000000000..2f696991a --- /dev/null +++ b/packages/extension/src/components/pages/CreateMPTokenIssuance/index.ts @@ -0,0 +1 @@ +export * from './CreateMPTokenIssuance'; diff --git a/packages/extension/src/components/pages/routes/private.routes.ts b/packages/extension/src/components/pages/routes/private.routes.ts index fd2900a9f..8bdf1286d 100644 --- a/packages/extension/src/components/pages/routes/private.routes.ts +++ b/packages/extension/src/components/pages/routes/private.routes.ts @@ -8,6 +8,7 @@ import { BURN_NFT_PATH, CANCEL_NFT_OFFER_PATH, CANCEL_OFFER_PATH, + CREATE_MPTOKEN_ISSUANCE_PATH, CREATE_NFT_OFFER_PATH, CREATE_OFFER_PATH, DELETE_ACCOUNT_PATH, @@ -43,6 +44,7 @@ import { AddNewWallet } from '../AddNewWallet'; import { BurnNFT } from '../BurnNFT'; import { CancelNFTOffer } from '../CancelNFTOffer'; import { CancelOffer } from '../CancelOffer'; +import { CreateMPTokenIssuance } from '../CreateMPTokenIssuance'; import { CreateNFTOffer } from '../CreateNFTOffer'; import { CreateOffer } from '../CreateOffer'; import { DeleteAccount } from '../DeleteAccount'; @@ -85,6 +87,7 @@ export const privateRoutes: PrivateRouteConfig[] = [ { path: BURN_NFT_PATH, element: BurnNFT }, { path: CANCEL_NFT_OFFER_PATH, element: CancelNFTOffer }, { path: CANCEL_OFFER_PATH, element: CancelOffer }, + { path: CREATE_MPTOKEN_ISSUANCE_PATH, element: CreateMPTokenIssuance }, { path: CREATE_NFT_OFFER_PATH, element: CreateNFTOffer }, { path: CREATE_OFFER_PATH, element: CreateOffer }, { path: DELETE_ACCOUNT_PATH, element: DeleteAccount }, diff --git a/packages/extension/src/constants/paths.ts b/packages/extension/src/constants/paths.ts index a08b18e4a..0bb3dc0e2 100644 --- a/packages/extension/src/constants/paths.ts +++ b/packages/extension/src/constants/paths.ts @@ -20,6 +20,7 @@ export const IMPORT_WALLET_PATH = '/import-wallet'; export const LIST_WALLETS_PATH = '/list-wallets'; export const MINT_NFT_PATH = '/mint-nft'; export const ADD_MPTOKEN_PATH = '/add-mptoken'; +export const CREATE_MPTOKEN_ISSUANCE_PATH = '/create-mptoken-issuance'; export const MPTOKEN_REMOVE_PATH = '/mptoken-remove'; export const PERMISSIONS_PATH = '/permissions'; export const RESET_PASSWORD_PATH = '/reset-password'; diff --git a/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx b/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx index 63600a47f..7393f5721 100644 --- a/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx +++ b/packages/extension/src/contexts/LedgerContext/LedgerContext.tsx @@ -137,6 +137,19 @@ interface MPTokenAuthorizeResponse { hash: string; } +interface MPTokenIssuanceCreateResponse { + hash: string; + mptIssuanceId?: string; +} + +interface MPTokenIssuanceCreateParams { + assetScale?: number; + maximumAmount?: string; + transferFee?: number; + metadata?: string; + flags?: number; +} + interface Props { children: React.ReactElement; } @@ -175,6 +188,9 @@ export interface LedgerContextType { // MPToken addMPTokenAuthorization: (mptIssuanceId: string) => Promise; removeMPTokenAuthorization: (mptIssuanceId: string) => Promise; + createMPTokenIssuance: ( + params: MPTokenIssuanceCreateParams + ) => Promise; } const LedgerContext = createContext({ @@ -208,7 +224,8 @@ const LedgerContext = createContext({ setHook: () => new Promise(() => {}), // MPToken addMPTokenAuthorization: () => new Promise(() => {}), - removeMPTokenAuthorization: () => new Promise(() => {}) + removeMPTokenAuthorization: () => new Promise(() => {}), + createMPTokenIssuance: () => new Promise(() => {}) }); const LedgerProvider: FC = ({ children }) => { @@ -794,6 +811,54 @@ const LedgerProvider: FC = ({ children }) => { [client, getCurrentWallet, handleTransaction] ); + const createMPTokenIssuance = useCallback( + async (params: MPTokenIssuanceCreateParams): Promise => { + const wallet = getCurrentWallet(); + if (!client) { + throw new Error('You need to be connected to a ledger'); + } else if (!wallet) { + throw new Error('You need to have a wallet connected'); + } else { + try { + // Build MPTokenIssuanceCreate transaction + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const transaction: any = { + TransactionType: 'MPTokenIssuanceCreate', + Account: wallet.publicAddress + }; + + // Add optional fields if provided + if (params.assetScale !== undefined) { + transaction.AssetScale = params.assetScale; + } + if (params.maximumAmount !== undefined) { + transaction.MaximumAmount = params.maximumAmount; + } + if (params.transferFee !== undefined) { + transaction.TransferFee = params.transferFee; + } + if (params.metadata !== undefined) { + transaction.MPTokenMetadata = params.metadata; + } + if (params.flags !== undefined) { + transaction.Flags = params.flags; + } + + const { hash } = await handleTransaction({ transaction, client, wallet }); + if (!hash) throw new Error("Couldn't create MPToken issuance"); + + // Note: The MPTokenIssuanceID is returned in the transaction metadata + // For now, we return just the hash - the caller can look up the issuance ID + return { hash }; + } catch (e) { + Sentry.captureException(e); + throw e; + } + } + }, + [client, getCurrentWallet, handleTransaction] + ); + /* * Getters */ @@ -962,7 +1027,8 @@ const LedgerProvider: FC = ({ children }) => { setHook, // MPToken addMPTokenAuthorization, - removeMPTokenAuthorization + removeMPTokenAuthorization, + createMPTokenIssuance }; return {children}; diff --git a/packages/extension/src/mocks/ledgerContext.ts b/packages/extension/src/mocks/ledgerContext.ts index cd4d534f6..434f60df1 100644 --- a/packages/extension/src/mocks/ledgerContext.ts +++ b/packages/extension/src/mocks/ledgerContext.ts @@ -29,5 +29,6 @@ export const valueLedgerContext: LedgerContextType = { setHook: vi.fn(), // MPToken addMPTokenAuthorization: vi.fn(), - removeMPTokenAuthorization: vi.fn() + removeMPTokenAuthorization: vi.fn(), + createMPTokenIssuance: vi.fn() };