diff --git a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx index 045b8c060..9fc7c8224 100644 --- a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx +++ b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx @@ -1,11 +1,16 @@ import { render, screen } from '@testing-library/react' import { I18nextProvider } from 'react-i18next' import { MemoryRouter } from 'react-router' +import { QueryClient, QueryClientProvider } from 'react-query' import i18n from '../../../../i18n/testConfig' import { MarketDataCard } from '../MarketDataCard' import { TooltipProvider } from '../../../shared/components/Tooltip' import { LOSAMMPoolData, FormattedBalance } from '../../types' +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, +}) + interface RenderProps { losData?: LOSAMMPoolData balance1?: FormattedBalance | null @@ -44,18 +49,20 @@ const renderComponent = ({ lpTokenBalance = '1000000', }: RenderProps = {}) => render( - - - - - - - , + + + + + + + + + , ) describe('MarketDataCard', () => { @@ -129,18 +136,20 @@ describe('MarketDataCard', () => { it('does not render balance or LP rows when balances and LP are absent', () => { render( - - - - - - - , + + + + + + + + + , ) const labels = document.querySelectorAll('.info-card-label') const balanceLabels = Array.from(labels).filter((l) => @@ -152,17 +161,19 @@ describe('MarketDataCard', () => { it('renders only balances and LP tokens when losData is undefined', () => { const { container } = render( - - - - - - - , + + + + + + + + + , ) // LOS fields hidden expect(screen.queryByText('tvl')).not.toBeInTheDocument() diff --git a/src/containers/AMMPool/test/AMMPoolHeader.test.tsx b/src/containers/AMMPool/test/AMMPoolHeader.test.tsx index 545edf35a..0526b3835 100644 --- a/src/containers/AMMPool/test/AMMPoolHeader.test.tsx +++ b/src/containers/AMMPool/test/AMMPoolHeader.test.tsx @@ -1,20 +1,27 @@ import { render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { I18nextProvider } from 'react-i18next' +import { QueryClient, QueryClientProvider } from 'react-query' import i18n from '../../../i18n/testConfig' import { AMMPoolHeader } from '../AMMPoolHeader' import { FormattedBalance } from '../types' +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, +}) + const renderComponent = ( asset1: FormattedBalance | null = null, asset2: FormattedBalance | null = null, ) => render( - - - - - , + + + + + + + , ) describe('AMMPoolHeader', () => { diff --git a/src/containers/Transactions/DetailTab/Meta/Offer.tsx b/src/containers/Transactions/DetailTab/Meta/Offer.tsx index 81e77e910..b3134b27b 100644 --- a/src/containers/Transactions/DetailTab/Meta/Offer.tsx +++ b/src/containers/Transactions/DetailTab/Meta/Offer.tsx @@ -8,10 +8,52 @@ import { import { localizeNumber } from '../../../shared/utils' import { Account } from '../../../shared/components/Account' import Currency from '../../../shared/components/Currency' +import type { Amount } from '../../../shared/types' import type { MetaRenderFunctionWithTx, MetaNode } from './types' -const normalize = (value: number | string, currency: string): string => - currency === 'XRP' ? (Number(value) / XRP_BASE).toString() : String(value) +const isMPTOrIOUAmount = ( + takerAmount: Amount | undefined, +): takerAmount is Exclude => + typeof takerAmount === 'object' && takerAmount !== null + +const getCurrency = (takerAmount: Amount | undefined): string => { + if (!isMPTOrIOUAmount(takerAmount)) return 'XRP' + if ('mpt_issuance_id' in takerAmount) return takerAmount.mpt_issuance_id + return takerAmount.currency || 'XRP' +} + +const getIsMPT = (takerAmount: Amount | undefined): boolean => + isMPTOrIOUAmount(takerAmount) && 'mpt_issuance_id' in takerAmount + +const getIssuer = (takerAmount: Amount | undefined): string | undefined => { + if (!isMPTOrIOUAmount(takerAmount)) return undefined + if ('mpt_issuance_id' in takerAmount) return undefined + return takerAmount.issuer +} + +const normalize = ( + value: number | string, + currency: string, + isMPT: boolean = false, +): string => { + if (isMPT) return String(value) + return currency === 'XRP' + ? (Number(value) / XRP_BASE).toString() + : String(value) +} + +// MPTAmount can be up to 2^63 - 1, beyond Number.MAX_SAFE_INTEGER, +// so subtract with BigInt to preserve precision. +const computeChange = ( + prevValue: number | string | undefined, + finalValue: number | string | undefined, + isMPT: boolean, +): number | string => { + if (isMPT && prevValue != null && finalValue != null) { + return (BigInt(prevValue) - BigInt(finalValue)).toString() + } + return Number(prevValue) - Number(finalValue) +} const renderChanges = ( _t: any, @@ -22,14 +64,24 @@ const renderChanges = ( const meta: JSX.Element[] = [] const final = node.FinalFields const prev = node?.PreviousFields - const paysCurrency = final.TakerPays.currency || 'XRP' - const getsCurrency = final.TakerGets.currency || 'XRP' + const paysCurrency = getCurrency(final.TakerPays) + const getsCurrency = getCurrency(final.TakerGets) + const paysIsMPT = getIsMPT(final.TakerPays) + const getsIsMPT = getIsMPT(final.TakerGets) const finalPays = final.TakerPays.value || final.TakerPays const finalGets = final.TakerGets.value || final.TakerGets const prevPays = prev?.TakerPays?.value || prev?.TakerPays const prevGets = prev?.TakerGets?.value || prev?.TakerGets - const changePays = normalize(prevPays - finalPays, paysCurrency) - const changeGets = normalize(prevGets - finalGets, getsCurrency) + const changePays = normalize( + computeChange(prevPays, finalPays, paysIsMPT), + paysCurrency, + paysIsMPT, + ) + const changeGets = normalize( + computeChange(prevGets, finalGets, getsIsMPT), + getsCurrency, + getsIsMPT, + ) if (prevPays && finalPays) { const options = { ...CURRENCY_OPTIONS, currency: paysCurrency } @@ -39,7 +91,8 @@ const renderChanges = ( {' '} @@ -48,7 +101,12 @@ const renderChanges = ( { { - change: localizeNumber(changePays, language, options), + change: localizeNumber( + changePays, + language, + options, + paysIsMPT, + ), } as any } @@ -57,9 +115,10 @@ const renderChanges = ( { { previous: localizeNumber( - normalize(prevPays, paysCurrency), + normalize(prevPays, paysCurrency, paysIsMPT), language, options, + paysIsMPT, ), } as any } @@ -69,9 +128,10 @@ const renderChanges = ( { { final: localizeNumber( - normalize(finalPays, paysCurrency), + normalize(finalPays, paysCurrency, paysIsMPT), language, options, + paysIsMPT, ), } as any } @@ -88,7 +148,8 @@ const renderChanges = ( {' '} @@ -97,7 +158,12 @@ const renderChanges = ( { { - change: localizeNumber(changeGets, language, options), + change: localizeNumber( + changeGets, + language, + options, + getsIsMPT, + ), } as any } @@ -106,9 +172,10 @@ const renderChanges = ( { { previous: localizeNumber( - normalize(prevGets, getsCurrency), + normalize(prevGets, getsCurrency, getsIsMPT), language, options, + getsIsMPT, ), } as any } @@ -118,9 +185,10 @@ const renderChanges = ( { { final: localizeNumber( - normalize(finalGets, getsCurrency), + normalize(finalGets, getsCurrency, getsIsMPT), language, options, + getsIsMPT, ), } as any } @@ -143,11 +211,14 @@ const render: MetaRenderFunctionWithTx = ( ) => { const lines: JSX.Element[] = [] const fields = node.FinalFields || node.NewFields - const paysCurrency = fields.TakerPays.currency || 'XRP' - const getsCurrency = fields.TakerGets.currency || 'XRP' + const paysCurrency = getCurrency(fields.TakerPays) + const getsCurrency = getCurrency(fields.TakerGets) + const paysIsMPT = getIsMPT(fields.TakerPays) + const getsIsMPT = getIsMPT(fields.TakerGets) const takerPaysValue = normalize( fields.TakerPays.value || fields.TakerPays, paysCurrency, + paysIsMPT, ) const invert = CURRENCY_ORDER.indexOf(getsCurrency) > CURRENCY_ORDER.indexOf(paysCurrency) @@ -214,16 +285,22 @@ const render: MetaRenderFunctionWithTx = ( components={{ Currency: ( ), Currency2: ( diff --git a/src/containers/Transactions/simpleTab.scss b/src/containers/Transactions/simpleTab.scss index 9f2681031..938f5da2e 100644 --- a/src/containers/Transactions/simpleTab.scss +++ b/src/containers/Transactions/simpleTab.scss @@ -74,6 +74,14 @@ $subdued-color: $black-40; } } + a.currency { + color: $green-30; + + &:hover { + color: $green-50; + } + } + &.list { margin-bottom: 12px; diff --git a/src/containers/Transactions/test/Meta.test.tsx b/src/containers/Transactions/test/Meta.test.tsx index b92a44950..827cb5a7f 100644 --- a/src/containers/Transactions/test/Meta.test.tsx +++ b/src/containers/Transactions/test/Meta.test.tsx @@ -10,6 +10,11 @@ import DirectMPTPayment from './mock_data/DirectMPTPayment.json' import { TransactionMeta } from '../DetailTab/Meta' import OfferCreateWithPermissionedDomainID from '../../shared/components/Transaction/OfferCreate/test/mock_data/OfferCreateWithPermissionedDomainID.json' +jest.mock('../../shared/hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../shared/hooks/useMPTIssuance'), + useMPTIssuance: () => ({ data: undefined }), +})) + describe('TransactionMeta container', () => { const renderMeta = (data: any = Transaction) => render( @@ -180,4 +185,147 @@ describe('TransactionMeta container', () => { 'Domain: 4A4879496CFF23CA32242D50DA04DDB41F4561167276A62AF21899F83DF28812', ) }) + + // Regression: each localizeNumber call in Offer.tsx needs its isMPT flag + // forwarded so the BigInt branch runs. Without it parseFloat rounds values + // above 2^53 - 1, e.g. 9223372036854775807 -> ...,776,000. + it('renders large MPT TakerPays/TakerGets on modified Offer without precision loss', () => { + const MPT_PAYS = '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F' + const MPT_GETS = '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D60' + const { container } = renderMeta({ + tx: { TransactionType: 'OfferCreate', Account: 'rEGo', Sequence: 1 }, + meta: { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'Offer', + LedgerIndex: 'A'.repeat(64), + PreviousFields: { + TakerPays: { + mpt_issuance_id: MPT_PAYS, + value: '9223372036854775807', + }, + TakerGets: { + mpt_issuance_id: MPT_GETS, + value: '9876543210987654321', + }, + }, + FinalFields: { + Account: 'rEGo', + Sequence: 1, + TakerPays: { + mpt_issuance_id: MPT_PAYS, + value: '8000000000000000007', + }, + TakerGets: { + mpt_issuance_id: MPT_GETS, + value: '1234567890123456789', + }, + }, + }, + }, + ], + TransactionResult: 'tesSUCCESS', + }, + }) + // TakerPays: previous, final, and change (prev - final via BigInt). + expect(container.textContent).toContain('9,223,372,036,854,775,807') + expect(container.textContent).toContain('8,000,000,000,000,000,007') + expect(container.textContent).toContain('1,223,372,036,854,775,800') + // TakerGets: previous, final, and change. + expect(container.textContent).toContain('9,876,543,210,987,654,321') + expect(container.textContent).toContain('1,234,567,890,123,456,789') + expect(container.textContent).toContain('8,641,975,320,864,197,532') + }) + + // Mixed MPT/XRP offer. Catches a flag-swap regression: the pays side must + // hit the BigInt branch (grouped digits at full precision), and the gets + // side must hit the currency branch (XRP_BASE-scaled value with fractions). + it('renders mixed MPT/XRP modified Offer correctly on both branches', () => { + const MPT_ID = '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F' + const { container } = renderMeta({ + tx: { TransactionType: 'OfferCreate', Account: 'rEGo', Sequence: 1 }, + meta: { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'Offer', + LedgerIndex: 'A'.repeat(64), + PreviousFields: { + TakerPays: { + mpt_issuance_id: MPT_ID, + value: '9223372036854775807', + }, + TakerGets: '2000000000', // 2,000 XRP in drops + }, + FinalFields: { + Account: 'rEGo', + Sequence: 1, + TakerPays: { + mpt_issuance_id: MPT_ID, + value: '8000000000000000007', + }, + TakerGets: '1000000000', // 1,000 XRP in drops + }, + }, + }, + ], + TransactionResult: 'tesSUCCESS', + }, + }) + // MPT pays side: BigInt branch preserves precision. + expect(container.textContent).toContain('9,223,372,036,854,775,807') + expect(container.textContent).toContain('1,223,372,036,854,775,800') + // XRP gets side: drops / XRP_BASE rendered with fractional digits via + // the currency branch (BigInt branch outputs no decimals, so the ".00" + // here is what distinguishes the two paths). + expect(container.textContent).toContain('2,000.00') + expect(container.textContent).toContain('1,000.00') + }) + + // MPToken/MPTokenIssuance renderers use BigInt end-to-end with .toString(10), + // so raw digit strings (no thousands separators) appear at full precision. + it('renders MPToken and MPTokenIssuance balances at full precision', () => { + const MPT_ID = '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F' + const { container } = renderMeta({ + tx: { TransactionType: 'Payment', Account: 'rIssuer', Sequence: 1 }, + meta: { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'B'.repeat(64), + PreviousFields: { MPTAmount: '9223372036854775000' }, + FinalFields: { + Account: 'rPayer', + MPTokenIssuanceID: MPT_ID, + MPTAmount: '7000000000000000000', + }, + }, + }, + { + ModifiedNode: { + LedgerEntryType: 'MPTokenIssuance', + LedgerIndex: 'C'.repeat(64), + PreviousFields: { OutstandingAmount: '9223372036854775807' }, + FinalFields: { + Issuer: 'rIssuer', + MPTokenIssuanceID: MPT_ID, + OutstandingAmount: '8000000000000000000', + }, + }, + }, + ], + TransactionResult: 'tesSUCCESS', + }, + }) + // MPToken: prev, final, and change = final - prev (negative since balance dropped). + expect(container.textContent).toContain('9223372036854775000') + expect(container.textContent).toContain('7000000000000000000') + expect(container.textContent).toContain('-2223372036854775000') + // MPTokenIssuance: prev=2^63-1, final, change = final - prev. + expect(container.textContent).toContain('9223372036854775807') + expect(container.textContent).toContain('8000000000000000000') + expect(container.textContent).toContain('-1223372036854775807') + }) }) diff --git a/src/containers/shared/components/Amount.tsx b/src/containers/shared/components/Amount.tsx index 439122d8d..4d789edad 100644 --- a/src/containers/shared/components/Amount.tsx +++ b/src/containers/shared/components/Amount.tsx @@ -1,16 +1,9 @@ -import { useQuery } from 'react-query' -import { useContext } from 'react' import { CURRENCY_OPTIONS, XRP_BASE } from '../transactionUtils' -import { useLanguage } from '../hooks' +import { useLanguage, useMPTIssuance } from '../hooks' import { localizeNumber, convertScaledPrice } from '../utils' import { parseAmount } from '../NumberFormattingUtils' import Currency from './Currency' import { ExplorerAmount } from '../types' -import { FormattedMPTIssuance } from '../Interfaces' -import { getMPTIssuance } from '../../../rippled/lib/rippled' -import { formatMPTIssuance } from '../../../rippled/lib/utils' -import SocketContext from '../SocketContext' -import { useAnalytics } from '../analytics' export interface AmountProps { value: ExplorerAmount | string @@ -31,8 +24,6 @@ export const Amount = ({ useParseAmount: useParsed = false, }: AmountProps) => { const language = useLanguage() - const rippledSocket = useContext(SocketContext) - const { trackException } = useAnalytics() // Handle the special case where amount is '< 0.0001' string const isSmallAmountString = @@ -46,6 +37,9 @@ export const Amount = ({ const options = { ...CURRENCY_OPTIONS, currency } + const mptID = isMPT ? (value as ExplorerAmount).currency : null + const { data: mptIssuanceData } = useMPTIssuance(mptID, isMPT) + const renderAmount = (localizedAmount: any) => ( @@ -68,24 +62,6 @@ export const Amount = ({ ) - const mptID = isMPT ? (value as ExplorerAmount).currency : null - - // fetch MPTIssuance only if isMPT is true - const { data: mptIssuanceData } = - useQuery( - ['getMPTIssuanceScale', mptID], - async () => { - const info = await getMPTIssuance(rippledSocket, mptID) - return formatMPTIssuance(info.node) - }, - { - onError: (e: any) => { - trackException(`mptIssuance ${mptID} --- ${JSON.stringify(e)}`) - }, - enabled: isMPT, - }, - ) || {} - // Handle the special case where amount is '< 0.0001' if (isSmallAmountString) { return renderAmount('< 0.0001') @@ -96,10 +72,9 @@ export const Amount = ({ if (isMPT && typeof value !== 'string') { if (mptIssuanceData) { const scale = mptIssuanceData.assetScale ?? 0 - const scaledAmount = convertScaledPrice( - parseInt(amount as string, 10).toString(16), - scale, - ) + // MPTAmount can be up to 2^63 - 1, beyond Number.MAX_SAFE_INTEGER. + // Use BigInt so the integer value is not truncated before scaling. + const scaledAmount = convertScaledPrice(BigInt(amount as string), scale) return renderAmount(localizeNumber(scaledAmount, language, {}, true)) } diff --git a/src/containers/shared/components/Currency.tsx b/src/containers/shared/components/Currency.tsx index 3b5cdace4..7e6b1b64e 100644 --- a/src/containers/shared/components/Currency.tsx +++ b/src/containers/shared/components/Currency.tsx @@ -1,6 +1,7 @@ import { RouteLink } from '../routing' import { TOKEN_ROUTE, MPT_ROUTE } from '../../App/routes' import { shortenMPTID } from '../utils' +import { useMPTIssuance, getMPTDisplayName } from '../hooks' // https://xrpl.org/currency-formats.html#nonstandard-currency-codes const NON_STANDARD_CODE_LENGTH = 40 @@ -34,10 +35,19 @@ const Currency = (props: Props) => { hideIssuer = false, shortenMPTIssuanceID = false, } = props + + const { data: mptIssuanceData } = useMPTIssuance( + isMPT ? currency : null, + isMPT, + ) + let content: string if (isMPT) { - const display = `${shortenMPTIssuanceID ? shortenMPTID(currency) : currency}` + const displayName = getMPTDisplayName( + shortenMPTIssuanceID ? shortenMPTID(currency) : currency, + mptIssuanceData?.parsedMPTMetadata, + ) if (link) return ( { to={MPT_ROUTE} params={{ id: currency }} > - {display} + {displayName} ) - content = display + content = displayName } else { let currencyCode = currency?.length === NON_STANDARD_CODE_LENGTH && diff --git a/src/containers/shared/components/DexTradeTable/test/DexTradeTable.test.tsx b/src/containers/shared/components/DexTradeTable/test/DexTradeTable.test.tsx index 051eb0757..6bd8a01aa 100644 --- a/src/containers/shared/components/DexTradeTable/test/DexTradeTable.test.tsx +++ b/src/containers/shared/components/DexTradeTable/test/DexTradeTable.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/react' import { I18nextProvider } from 'react-i18next' import { BrowserRouter as Router } from 'react-router' +import { QueryClient, QueryClientProvider } from 'react-query' import i18n from '../../../../../i18n/testConfigEnglish' import { DexTradeTable, DexTradeFormatted } from '../DexTradeTable' @@ -9,10 +10,16 @@ jest.mock('../../Amount', () => ({ Amount: ({ value }: any) =>
{value.amount}
, })) +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, +}) + const TestWrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - + + + {children} + + ) const mockDexTrades: DexTradeFormatted[] = [ diff --git a/src/containers/shared/components/Transaction/AMMClawback/parser.ts b/src/containers/shared/components/Transaction/AMMClawback/parser.ts index fcc245bb6..6facad058 100644 --- a/src/containers/shared/components/Transaction/AMMClawback/parser.ts +++ b/src/containers/shared/components/Transaction/AMMClawback/parser.ts @@ -6,7 +6,6 @@ export function parser(tx: AMMClawback, meta: any) { const holder = tx.Holder const amount = findAssetAmount(meta, tx.Asset, tx) if (tx.Flags) { - // @ts-expect-error - MPT is not being supported for AMM transactions until https://github.com/XRPLF/rippled/pull/5285 is merged const amount2 = findAssetAmount(meta, tx.Asset2, tx) return { amount, diff --git a/src/containers/shared/components/Transaction/AMMClawback/test/AMMClawbackSimple.test.tsx b/src/containers/shared/components/Transaction/AMMClawback/test/AMMClawbackSimple.test.tsx index 098698bbb..edefbe394 100644 --- a/src/containers/shared/components/Transaction/AMMClawback/test/AMMClawbackSimple.test.tsx +++ b/src/containers/shared/components/Transaction/AMMClawback/test/AMMClawbackSimple.test.tsx @@ -5,10 +5,21 @@ import { Simple } from '../Simple' import mockAMMClawbackNoFlag from './mock_data/withoutFlag.json' import mockAMMClawbackWithAmount from './mock_data/withAmount.json' import mockAMMClawbackWithFlag from './mock_data/withFlag.json' +import mockAMMClawbackMPT from './mock_data/withMPT.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) const renderSimple = createSimpleRenderFactory(Simple) describe('AMMClawback: Simple', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders without tfClawTwoAssets flag, only one asset should be clawed back', () => { const { container } = renderSimple(mockAMMClawbackNoFlag) expectSimpleRowText( @@ -51,4 +62,47 @@ describe('AMMClawback: Simple', () => { '$20.00 USD.rK2Du3gUmFbg5UFFHFq9LKywVuGbqNsyyi', ) }) + + it('renders with MPT assets (no ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container } = renderSimple(mockAMMClawbackMPT) + expectSimpleRowText( + container, + 'asset1', + '260 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + expectSimpleRowText( + container, + 'asset2', + '100 00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F', + ) + expectSimpleRowText( + container, + 'holder', + 'r4eWC5DixP74dpk7FDzXcap1BJ2NaoUeZN', + ) + }) + + it('renders with MPT assets (with ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockImplementation((mptID: string) => { + if (mptID === '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F') { + return { + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XGLD' } }, + } + } + return { + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XUSD' } }, + } + }) + const { container } = renderSimple(mockAMMClawbackMPT) + expectSimpleRowText(container, 'asset1', '260 XGLD') + expectSimpleRowText(container, 'asset2', '100 XUSD') + expectSimpleRowText( + container, + 'holder', + 'r4eWC5DixP74dpk7FDzXcap1BJ2NaoUeZN', + ) + }) }) diff --git a/src/containers/shared/components/Transaction/AMMClawback/test/AMMClawbackTableDetail.test.tsx b/src/containers/shared/components/Transaction/AMMClawback/test/AMMClawbackTableDetail.test.tsx index a5fd3bd0d..c0955240b 100644 --- a/src/containers/shared/components/Transaction/AMMClawback/test/AMMClawbackTableDetail.test.tsx +++ b/src/containers/shared/components/Transaction/AMMClawback/test/AMMClawbackTableDetail.test.tsx @@ -3,10 +3,21 @@ import { TableDetail } from '../TableDetail' import AMMClawbackNoFlag from './mock_data/withoutFlag.json' import AMMClawbackWithFlag from './mock_data/withFlag.json' import mockAMMClawbackWithAmount from './mock_data/withAmount.json' +import mockAMMClawbackMPT from './mock_data/withMPT.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) const renderComponent = createTableDetailRenderFactory(TableDetail) describe('AMMClawback: TableDetail', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders without tfClawTwoAssets flag, only one asset should be clawed back', () => { const { container, unmount } = renderComponent(AMMClawbackNoFlag) expect(container).toHaveTextContent( @@ -41,4 +52,43 @@ describe('AMMClawback: TableDetail', () => { ) unmount() }) + + it('renders with MPT assets (no ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container, unmount } = renderComponent(mockAMMClawbackMPT) + expect(container).toHaveTextContent( + 'claws_back' + + '260 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F' + + 'and' + + '100 00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F' + + 'from' + + 'r4eWC5DixP74dpk7FDzXcap1BJ2NaoUeZN', + ) + unmount() + }) + + it('renders with MPT assets (with ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockImplementation((mptID: string) => { + if (mptID === '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F') { + return { + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XGLD' } }, + } + } + return { + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XUSD' } }, + } + }) + const { container, unmount } = renderComponent(mockAMMClawbackMPT) + expect(container).toHaveTextContent( + 'claws_back' + + '260 XGLD' + + 'and' + + '100 XUSD' + + 'from' + + 'r4eWC5DixP74dpk7FDzXcap1BJ2NaoUeZN', + ) + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/AMMClawback/test/mock_data/withMPT.json b/src/containers/shared/components/Transaction/AMMClawback/test/mock_data/withMPT.json new file mode 100644 index 000000000..250443d7e --- /dev/null +++ b/src/containers/shared/components/Transaction/AMMClawback/test/mock_data/withMPT.json @@ -0,0 +1,75 @@ +{ + "tx": { + "Account": "rUuVtbgagFKjHPTxmN639XYVHLATnB6VNk", + "Asset": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Asset2": { + "mpt_issuance_id": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Fee": "12", + "Flags": 1, + "Holder": "r4eWC5DixP74dpk7FDzXcap1BJ2NaoUeZN", + "LastLedgerSequence": 254460, + "Sequence": 254428, + "SigningPubKey": "ED7FBCDEF465DF8D6D25B50EDF4B10ECB4A50C15B4A7F5EE49437DD17B5B67E39B", + "TransactionType": "AMMClawback", + "TxnSignature": "19846DD492CD9F914333DA06CE17545A8447BDC5F0191F76ABDDEFFECA2261E9A75861D66578AF83569B254D6303C475F92DB84B9C4979EE8C0F20AFAD405304", + "ctid": "C003E1EA00000002", + "date": 1739385880000 + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "AABB00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "r4eWC5DixP74dpk7FDzXcap1BJ2NaoUeZN", + "MPTokenIssuanceID": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "740" + }, + "PreviousFields": { + "MPTAmount": "1000" + } + } + }, + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "CCDD00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "r4eWC5DixP74dpk7FDzXcap1BJ2NaoUeZN", + "MPTokenIssuanceID": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "900" + }, + "PreviousFields": { + "MPTAmount": "1000" + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rUuVtbgagFKjHPTxmN639XYVHLATnB6VNk", + "Balance": "99999940", + "Flags": 2155872256, + "OwnerCount": 0, + "Sequence": 254429 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "49648B64B65B612D89AA414A3C352400A3B38DE891D551C7103E4C439F0270FE", + "PreviousFields": { + "Balance": "99999952", + "Sequence": 254428 + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "hash": "9ADAC8C3CF9A7D162EFB93F9CFEBBFFBD8B17F3AFAE26C634E154FF1015AEFC0", + "ledger_index": 254442, + "date": 1739385880000 +} diff --git a/src/containers/shared/components/Transaction/AMMCreate/test/AMMCreate.test.tsx b/src/containers/shared/components/Transaction/AMMCreate/test/AMMCreate.test.tsx index f9a5fc102..8cb5dd626 100644 --- a/src/containers/shared/components/Transaction/AMMCreate/test/AMMCreate.test.tsx +++ b/src/containers/shared/components/Transaction/AMMCreate/test/AMMCreate.test.tsx @@ -1,10 +1,22 @@ import { Simple } from '../Simple' import { createSimpleRenderFactory, expectSimpleRowText } from '../../test' import createMock from './mock_data/amm_create.json' +import createMockMpt from './mock_data/amm_create_mpt.json' +import createMockBothMpt from './mock_data/amm_create_both_mpt.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) describe('AMM Create Tests', () => { const renderComponent = createSimpleRenderFactory(Simple) + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders from transaction', () => { const { container, unmount } = renderComponent(createMock) expectSimpleRowText(container, 'asset1', '\uE90010,000.00 XRP') @@ -21,4 +33,63 @@ describe('AMM Create Tests', () => { ) unmount() }) + + it('renders AMMCreate with XRP + MPT pair (no ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container, unmount } = renderComponent(createMockMpt) + expectSimpleRowText(container, 'asset1', '\uE90010,000.00 XRP') + expectSimpleRowText( + container, + 'asset2', + '10,000 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders AMMCreate with XRP + MPT pair (with ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XGLD' } }, + }) + const { container, unmount } = renderComponent(createMockMpt) + expectSimpleRowText(container, 'asset1', '\uE90010,000.00 XRP') + expectSimpleRowText(container, 'asset2', '10,000 XGLD') + unmount() + }) + + it('renders AMMCreate with both MPT assets (no ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container, unmount } = renderComponent(createMockBothMpt) + expectSimpleRowText( + container, + 'asset1', + '5,000 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + expectSimpleRowText( + container, + 'asset2', + '2,000 00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders AMMCreate with both MPT assets (with ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockImplementation((mptID: string) => { + if (mptID === '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F') { + return { + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XGLD' } }, + } + } + return { + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XUSD' } }, + } + }) + const { container, unmount } = renderComponent(createMockBothMpt) + expectSimpleRowText(container, 'asset1', '5,000 XGLD') + expectSimpleRowText(container, 'asset2', '2,000 XUSD') + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/AMMCreate/test/mock_data/amm_create_both_mpt.json b/src/containers/shared/components/Transaction/AMMCreate/test/mock_data/amm_create_both_mpt.json new file mode 100644 index 000000000..ee41d804f --- /dev/null +++ b/src/containers/shared/components/Transaction/AMMCreate/test/mock_data/amm_create_both_mpt.json @@ -0,0 +1,75 @@ +{ + "tx": { + "Account": "rwRGF9pmfEGT4GcZ379cYC9p3wpJDozy8w", + "Amount": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "5000" + }, + "Amount2": { + "mpt_issuance_id": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F", + "value": "2000" + }, + "Fee": "10", + "Flags": 0, + "Sequence": 3, + "SigningPubKey": "02BFF00BCB2D25845C1D7C1FC5AAAE465B56CBB966BF034E1F6FFC097E8A6FBD28", + "TradingFee": 1, + "TransactionType": "AMMCreate", + "TxnSignature": "304402200CE5EA35FB7545CA8CD3231C2EE6F23D6B9EF7E146F23016905B9516721D5745022062DCC01BB7617C93F85DAE93917CC585FF09AE690BA78FDC5924565EEC186C70", + "date": "2022-11-21T20:58:11Z" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "AABB00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "rwRGF9pmfEGT4GcZ379cYC9p3wpJDozy8w", + "MPTokenIssuanceID": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "495000" + }, + "PreviousFields": { + "MPTAmount": "500000" + } + } + }, + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "CCDD00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "rwRGF9pmfEGT4GcZ379cYC9p3wpJDozy8w", + "MPTokenIssuanceID": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "248000" + }, + "PreviousFields": { + "MPTAmount": "250000" + } + } + }, + { + "CreatedNode": { + "LedgerEntryType": "AMM", + "LedgerIndex": "C7FD06649235AF4CABD8FA6D8BB0CAF6C6EA5038A74D0DDD5025290683636D02", + "NewFields": { + "Account": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "Asset": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Asset2": { + "mpt_issuance_id": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F" + }, + "LPTokenBalance": { + "currency": "03930D02208264E2E40EC1B0C09E4DB96EE197B1", + "issuer": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "value": "10000000" + } + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + } +} diff --git a/src/containers/shared/components/Transaction/AMMCreate/test/mock_data/amm_create_mpt.json b/src/containers/shared/components/Transaction/AMMCreate/test/mock_data/amm_create_mpt.json new file mode 100644 index 000000000..1f707551b --- /dev/null +++ b/src/containers/shared/components/Transaction/AMMCreate/test/mock_data/amm_create_mpt.json @@ -0,0 +1,101 @@ +{ + "tx": { + "Account": "rwRGF9pmfEGT4GcZ379cYC9p3wpJDozy8w", + "Amount": "10000000000", + "Amount2": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "10000" + }, + "Fee": "10", + "Flags": 0, + "Sequence": 3, + "SigningPubKey": "02BFF00BCB2D25845C1D7C1FC5AAAE465B56CBB966BF034E1F6FFC097E8A6FBD28", + "TradingFee": 1, + "TransactionType": "AMMCreate", + "TxnSignature": "304402200CE5EA35FB7545CA8CD3231C2EE6F23D6B9EF7E146F23016905B9516721D5745022062DCC01BB7617C93F85DAE93917CC585FF09AE690BA78FDC5924565EEC186C70", + "date": "2022-11-21T20:58:11Z" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "AABB00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "rwRGF9pmfEGT4GcZ379cYC9p3wpJDozy8w", + "MPTokenIssuanceID": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "990000" + }, + "PreviousFields": { + "MPTAmount": "1000000" + }, + "PreviousTxnID": "98D314D1EC81BE9342EDA1C04BCEFA8F327B0EFE12839F1EAB52065492B20E82", + "PreviousTxnLgrSeq": 114138 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "471AC5276FBA4916D53017D7073D44C5F4780CC73954B1715DC8A65365E8ACAC", + "NewFields": { + "Account": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "Balance": "10000000000", + "Flags": 59768832, + "OwnerCount": 1, + "Sequence": 1 + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rwRGF9pmfEGT4GcZ379cYC9p3wpJDozy8w", + "Balance": "989999999970", + "Flags": 8388608, + "OwnerCount": 2, + "Sequence": 4 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "83A4BB5EA0DB3CFEE89804BC08E1CBD222E869046EE01A7ECCAB57F8433F9655", + "PreviousFields": { + "Balance": "999999999980", + "OwnerCount": 1, + "Sequence": 3 + }, + "PreviousTxnID": "F7998834E30DEB92E62754744328C882CD1C50C59A7568884B14F498B2B91198", + "PreviousTxnLgrSeq": 114136 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "AMM", + "LedgerIndex": "C7FD06649235AF4CABD8FA6D8BB0CAF6C6EA5038A74D0DDD5025290683636D02", + "NewFields": { + "Account": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "Asset2": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "LPTokenBalance": { + "currency": "03930D02208264E2E40EC1B0C09E4DB96EE197B1", + "issuer": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "value": "10000000" + } + } + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Flags": 0, + "Owner": "rwRGF9pmfEGT4GcZ379cYC9p3wpJDozy8w", + "RootIndex": "D69B2631EAE1115AFA8A12284B3780D743CB6CBA74A2F5D878CD68C0352C4CFA" + }, + "LedgerEntryType": "DirectoryNode", + "LedgerIndex": "D69B2631EAE1115AFA8A12284B3780D743CB6CBA74A2F5D878CD68C0352C4CFA" + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + } +} diff --git a/src/containers/shared/components/Transaction/AMMDelete/Description.tsx b/src/containers/shared/components/Transaction/AMMDelete/Description.tsx index 25466d354..a6d74725c 100644 --- a/src/containers/shared/components/Transaction/AMMDelete/Description.tsx +++ b/src/containers/shared/components/Transaction/AMMDelete/Description.tsx @@ -2,22 +2,33 @@ import { Trans } from 'react-i18next' import { type AMMDelete } from 'xrpl' import { TransactionDescriptionProps } from '../types' import Currency from '../../Currency' +import { formatAsset } from '../../../../../rippled/lib/txSummary/formatAmount' export const Description = ({ data, }: TransactionDescriptionProps) => { const { Asset, Asset2 } = data.tx + const asset1 = formatAsset(Asset) + const asset2 = formatAsset(Asset2) return (
, + Asset: ( + + ), Asset2: ( - // @ts-expect-error - MPT is not being supported for AMM transactions until https://github.com/XRPLF/rippled/pull/5285 is merged - + ), }} /> diff --git a/src/containers/shared/components/Transaction/AMMDelete/Simple.tsx b/src/containers/shared/components/Transaction/AMMDelete/Simple.tsx index 6faaede28..26b74e2ce 100644 --- a/src/containers/shared/components/Transaction/AMMDelete/Simple.tsx +++ b/src/containers/shared/components/Transaction/AMMDelete/Simple.tsx @@ -4,20 +4,29 @@ import { type AMMDelete } from 'xrpl' import { SimpleRow } from '../SimpleRow' import { TransactionSimpleProps } from '../types' import Currency from '../../Currency' +import { formatAsset } from '../../../../../rippled/lib/txSummary/formatAmount' export const Simple = ({ data }: TransactionSimpleProps) => { const { t } = useTranslation() const { Asset, Asset2 } = data.instructions + const asset1 = formatAsset(Asset) + const asset2 = formatAsset(Asset2) return ( <> - {/* @ts-expect-error - MPT is not being supported for AMM transactions until https://github.com/XRPLF/rippled/pull/5285 is merged */} - + - {/* @ts-expect-error - MPT is not being supported for AMM transactions until https://github.com/XRPLF/rippled/pull/5285 is merged */} - + ) diff --git a/src/containers/shared/components/Transaction/AMMDelete/TableDetail.tsx b/src/containers/shared/components/Transaction/AMMDelete/TableDetail.tsx index fcebb71a0..379e13011 100644 --- a/src/containers/shared/components/Transaction/AMMDelete/TableDetail.tsx +++ b/src/containers/shared/components/Transaction/AMMDelete/TableDetail.tsx @@ -2,24 +2,33 @@ import { useTranslation } from 'react-i18next' import { type AMMDelete } from 'xrpl' import { TransactionTableDetailProps } from '../types' import Currency from '../../Currency' +import { formatAsset } from '../../../../../rippled/lib/txSummary/formatAmount' export const TableDetail = ({ instructions, }: TransactionTableDetailProps) => { const { t } = useTranslation() const { Asset, Asset2 } = instructions + const asset1 = formatAsset(Asset) + const asset2 = formatAsset(Asset2) return (
{t('asset1')} - {/* @ts-expect-error - MPT is not being supported for AMM transactions until https://github.com/XRPLF/rippled/pull/5285 is merged */} - +
{t('asset2')} - {/* @ts-expect-error - MPT is not being supported for AMM transactions until https://github.com/XRPLF/rippled/pull/5285 is merged */} - +
) diff --git a/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteDescription.test.tsx b/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteDescription.test.tsx index 9292b7c39..10fa7cf2d 100644 --- a/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteDescription.test.tsx +++ b/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteDescription.test.tsx @@ -1,11 +1,22 @@ import i18n from '../../../../../../i18n/testConfigEnglish' import mockAMMDelete from './mock_data/AMMDelete.json' +import mockAMMDeleteMPT from './mock_data/AMMDeleteMPT.json' import { Description } from '../Description' import { createDescriptionRenderFactory } from '../../test' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) const renderComponent = createDescriptionRenderFactory(Description, i18n) describe('AMMDelete: Description', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders description for AMMDelete transaction', () => { const { container, unmount } = renderComponent(mockAMMDelete) @@ -21,4 +32,35 @@ describe('AMMDelete: Description', () => { unmount() }) + + it('renders with MPT asset (no ticker - displays mpt_issuance_id)', () => { + const { container, unmount } = renderComponent(mockAMMDeleteMPT) + + expect( + container.querySelector('[data-testid="amm-delete-description"]'), + ).toHaveTextContent( + 'Attempted to delete the AMM for \uE900 XRP and 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F.', + ) + expect(container.querySelector('a')).toHaveAttribute( + 'href', + '/mpt/000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders with MPT asset (with ticker - displays ticker symbol)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { parsedMPTMetadata: { ticker: 'XMPT' } }, + }) + const { container, unmount } = renderComponent(mockAMMDeleteMPT) + + expect( + container.querySelector('[data-testid="amm-delete-description"]'), + ).toHaveTextContent('Attempted to delete the AMM for \uE900 XRP and XMPT.') + expect(container.querySelector('a')).toHaveAttribute( + 'href', + '/mpt/000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteSimple.test.tsx b/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteSimple.test.tsx index 95a08d3f7..15fa5b212 100644 --- a/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteSimple.test.tsx +++ b/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteSimple.test.tsx @@ -4,12 +4,23 @@ import { expectSimpleRowText } from '../../test' import { createSimpleRenderFactory } from '../../test/createWrapperFactory' import { Simple } from '../Simple' import mockAMMDelete from './mock_data/AMMDelete.json' +import mockAMMDeleteMPT from './mock_data/AMMDeleteMPT.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) const renderComponent = createSimpleRenderFactory(Simple, i18n) describe('AMMDelete: Simple', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders', () => { - const { container, unmount } = renderComponent(mockAMMDelete) // TOOD: - Make this look up asset 1 / asset 2 currency codes + const { container, unmount } = renderComponent(mockAMMDelete) expectSimpleRowText(container, 'asset1', '\uE900 XRP') expectSimpleRowText( container, @@ -18,4 +29,25 @@ describe('AMMDelete: Simple', () => { ) unmount() }) + + it('renders with MPT asset (no ticker - displays mpt_issuance_id)', () => { + const { container, unmount } = renderComponent(mockAMMDeleteMPT) + expectSimpleRowText(container, 'asset1', '\uE900 XRP') + expectSimpleRowText( + container, + 'asset2', + '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders with MPT asset (with ticker - displays ticker symbol)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { parsedMPTMetadata: { ticker: 'XMPT' } }, + }) + const { container, unmount } = renderComponent(mockAMMDeleteMPT) + expectSimpleRowText(container, 'asset1', '\uE900 XRP') + expectSimpleRowText(container, 'asset2', 'XMPT') + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteTableDetail.test.tsx b/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteTableDetail.test.tsx index 5eecff873..fe2501edd 100644 --- a/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteTableDetail.test.tsx +++ b/src/containers/shared/components/Transaction/AMMDelete/test/AMMDeleteTableDetail.test.tsx @@ -1,11 +1,22 @@ import { TableDetail } from '../TableDetail' import mockAMMDelete from './mock_data/AMMDelete.json' +import mockAMMDeleteMPT from './mock_data/AMMDeleteMPT.json' import { createTableDetailRenderFactory } from '../../test' import i18n from '../../../../../../i18n/testConfigEnglish' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) const renderComponent = createTableDetailRenderFactory(TableDetail, i18n) describe('AMMDelete: TableDetail', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders with an expiration and offer', () => { const { container, unmount } = renderComponent(mockAMMDelete) @@ -17,4 +28,35 @@ describe('AMMDelete: TableDetail', () => { ) unmount() }) + + it('renders with MPT asset (no ticker - displays mpt_issuance_id)', () => { + const { container, unmount } = renderComponent(mockAMMDeleteMPT) + + expect(container.querySelector('[data-testid="asset"]')).toHaveTextContent( + 'Asset 1\uE900 XRP', + ) + expect(container.querySelector('[data-testid="asset2"]')).toHaveTextContent( + 'Asset 2000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders with MPT asset (with ticker - displays ticker symbol)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { parsedMPTMetadata: { ticker: 'XMPT' } }, + }) + const { container, unmount } = renderComponent(mockAMMDeleteMPT) + + expect(container.querySelector('[data-testid="asset"]')).toHaveTextContent( + 'Asset 1\uE900 XRP', + ) + expect(container.querySelector('[data-testid="asset2"]')).toHaveTextContent( + 'Asset 2XMPT', + ) + expect(container.querySelector('[data-testid="asset2"] a')).toHaveAttribute( + 'href', + '/mpt/000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/AMMDelete/test/mock_data/AMMDeleteMPT.json b/src/containers/shared/components/Transaction/AMMDelete/test/mock_data/AMMDeleteMPT.json new file mode 100644 index 000000000..b2e3717a5 --- /dev/null +++ b/src/containers/shared/components/Transaction/AMMDelete/test/mock_data/AMMDeleteMPT.json @@ -0,0 +1,47 @@ +{ + "tx": { + "Account": "rm5c42Crqpdch5fbuCdHmSMV1wrL9arV9", + "Asset": { + "currency": "XRP" + }, + "Asset2": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Fee": "12", + "Flags": 0, + "LastLedgerSequence": 372572, + "Sequence": 372548, + "SigningPubKey": "ED6784394D134E202BCCD957A1A3C5A66647092F3929D388A878A16D1910875435", + "TransactionType": "AMMDelete", + "TxnSignature": "F9AA459D8CE593E6E2E69BB6A6F723A4822FD5F40314F642FA9EC5187F7FD937FBD3E8A214119D04C74358A3478DCB6EAABE02EFAC1E6E125E15310E18A36D0D", + "date": 1693268101000 + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "rm5c42Crqpdch5fbuCdHmSMV1wrL9arV9", + "Balance": "9997998976", + "Flags": 8388608, + "OwnerCount": 1, + "Sequence": 372549 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "84CA74ECFDB34F014142013B4CD2FBE3942C7BA9BA7E1FC5A1CB1EF719173812", + "PreviousFields": { + "Balance": "9997998988", + "Sequence": 372548 + }, + "PreviousTxnID": "E5051DA09F143A719521D6ABBB3856EA3E2CA38EF1CFF0E7DF9FE1C31DD73B6D", + "PreviousTxnLgrSeq": 372552 + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tecAMM_NOT_EMPTY" + }, + "hash": "D159883D456646562F51F3E5A2754F7D880D39A6372EDF679A43A7DDB77F735C", + "ledger_index": 372554, + "date": 1693268101000 +} diff --git a/src/containers/shared/components/Transaction/AMMDeposit/test/AMMDeposit.test.tsx b/src/containers/shared/components/Transaction/AMMDeposit/test/AMMDeposit.test.tsx index 3a811f4bc..50b0ea73d 100644 --- a/src/containers/shared/components/Transaction/AMMDeposit/test/AMMDeposit.test.tsx +++ b/src/containers/shared/components/Transaction/AMMDeposit/test/AMMDeposit.test.tsx @@ -11,10 +11,22 @@ import depositEprice from './mock_data/deposit_eprice.json' import depositNonXRP from './mock_data/deposit_nonxrp.json' import depositFail from './mock_data/deposit_fail.json' import depositLPToken from './mock_data/deposit_lptoken.json' +import depositMPT from './mock_data/deposit_mpt.json' +import depositBothMPT from './mock_data/deposit_both_mpt.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) describe('AMM Deposit Tests', () => { const renderComponent = createSimpleRenderFactory(Simple) + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders with both assets', () => { const { container, unmount } = renderComponent(depositBothAssets) expectSimpleRowText(container, 'asset1', '\uE90010,997.290462 XRP') @@ -112,4 +124,73 @@ describe('AMM Deposit Tests', () => { expectSimpleRowText(container, 'lp_tokens', '4,279,342.4') unmount() }) + + it('renders with MPT asset (no ticker - displays mpt_issuance_id)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container, unmount } = renderComponent(depositMPT) + expectSimpleRowText(container, 'asset1', '\uE9001,000.00 XRP') + expectSimpleRowText( + container, + 'asset2', + '10,000 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + expectSimpleRowText( + container, + 'account_id', + 'rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W', + ) + unmount() + }) + + it('renders with MPT asset (with ticker - displays ticker symbol)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XMPT' } }, + }) + const { container, unmount } = renderComponent(depositMPT) + expectSimpleRowText(container, 'asset1', '\uE9001,000.00 XRP') + expectSimpleRowText(container, 'asset2', '10,000 XMPT') + expectSimpleRowText( + container, + 'account_id', + 'rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W', + ) + unmount() + }) + + it('renders with both assets as MPT (no ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container, unmount } = renderComponent(depositBothMPT) + expectSimpleRowText( + container, + 'asset1', + '5,000 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + expectSimpleRowText( + container, + 'asset2', + '2,000 00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders with both assets as MPT (with ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockImplementation((mptID: string) => { + if (mptID === '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F') { + return { + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XGLD' } }, + } + } + return { + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XUSD' } }, + } + }) + const { container, unmount } = renderComponent(depositBothMPT) + expectSimpleRowText(container, 'asset1', '5,000 XGLD') + expectSimpleRowText(container, 'asset2', '2,000 XUSD') + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/AMMDeposit/test/mock_data/deposit_both_mpt.json b/src/containers/shared/components/Transaction/AMMDeposit/test/mock_data/deposit_both_mpt.json new file mode 100644 index 000000000..1cb5885d3 --- /dev/null +++ b/src/containers/shared/components/Transaction/AMMDeposit/test/mock_data/deposit_both_mpt.json @@ -0,0 +1,93 @@ +{ + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "AABB00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "MPTokenIssuanceID": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "495000" + }, + "PreviousFields": { + "MPTAmount": "500000" + }, + "PreviousTxnID": "98D314D1EC81BE9342EDA1C04BCEFA8F327B0EFE12839F1EAB52065492B20E82", + "PreviousTxnLgrSeq": 114138 + } + }, + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "CCDD00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "MPTokenIssuanceID": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "248000" + }, + "PreviousFields": { + "MPTAmount": "250000" + }, + "PreviousTxnID": "88D314D1EC81BE9342EDA1C04BCEFA8F327B0EFE12839F1EAB52065492B20E82", + "PreviousTxnLgrSeq": 114139 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "Asset": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Asset2": { + "mpt_issuance_id": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Flags": 0, + "LPTokenBalance": { + "currency": "03930D02208264E2E40EC1B0C09E4DB96EE197B1", + "issuer": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "value": "10515288.25827238" + }, + "TradingFee": 0 + }, + "LedgerEntryType": "AMM", + "LedgerIndex": "C7FD06649235AF4CABD8FA6D8BB0CAF6C6EA5038A74D0DDD5025290683636D02", + "PreviousFields": { + "LPTokenBalance": { + "currency": "03930D02208264E2E40EC1B0C09E4DB96EE197B1", + "issuer": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "value": "10027169" + } + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "tx": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "Amount": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "5000" + }, + "Amount2": { + "mpt_issuance_id": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F", + "value": "2000" + }, + "Asset": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Asset2": { + "mpt_issuance_id": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Fee": "10", + "Flags": 1048576, + "Sequence": 7, + "SigningPubKey": "023CFED4018084296285DD8A321C099134B9CF6DCD8D91DC067BABCFF0E3F2BE1A", + "TransactionType": "AMMDeposit", + "TxnSignature": "3045022100D1363F0A6D7252690820657B6ACCB35245E65D8DCDB48199578C213ED9D3E24B0220697D4DC057ECCD942BC59B0242411839C125696CD9DE9A3EFE45367EE0D1D29D", + "date": "2022-11-26T00:55:02Z" + } +} diff --git a/src/containers/shared/components/Transaction/AMMDeposit/test/mock_data/deposit_mpt.json b/src/containers/shared/components/Transaction/AMMDeposit/test/mock_data/deposit_mpt.json new file mode 100644 index 000000000..55d25f56b --- /dev/null +++ b/src/containers/shared/components/Transaction/AMMDeposit/test/mock_data/deposit_mpt.json @@ -0,0 +1,111 @@ +{ + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "AABB00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "MPTokenIssuanceID": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "990000" + }, + "PreviousFields": { + "MPTAmount": "1000000" + }, + "PreviousTxnID": "98D314D1EC81BE9342EDA1C04BCEFA8F327B0EFE12839F1EAB52065492B20E82", + "PreviousTxnLgrSeq": 114138 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "Balance": "11027169000", + "Flags": 59768832, + "OwnerCount": 1, + "Sequence": 1 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "471AC5276FBA4916D53017D7073D44C5F4780CC73954B1715DC8A65365E8ACAC", + "PreviousFields": { + "Balance": "10027169000" + }, + "PreviousTxnID": "209B17403B42271F2D50DEC0F808AE07EC04B8B9605FF52B1093BFF31676AD2C", + "PreviousTxnLgrSeq": 233852 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "Balance": "998972830930", + "Flags": 8388608, + "OwnerCount": 2, + "Sequence": 8 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "53383A918D45DEF78DED23CE5141C0FAB44661D602465F5FCFC487792448F1E2", + "PreviousFields": { + "Balance": "999972830940", + "Sequence": 7 + }, + "PreviousTxnID": "209B17403B42271F2D50DEC0F808AE07EC04B8B9605FF52B1093BFF31676AD2C", + "PreviousTxnLgrSeq": 233852 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "Asset": { + "currency": "XRP" + }, + "Asset2": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Flags": 0, + "LPTokenBalance": { + "currency": "03930D02208264E2E40EC1B0C09E4DB96EE197B1", + "issuer": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "value": "10515288.25827238" + }, + "TradingFee": 0 + }, + "LedgerEntryType": "AMM", + "LedgerIndex": "C7FD06649235AF4CABD8FA6D8BB0CAF6C6EA5038A74D0DDD5025290683636D02", + "PreviousFields": { + "LPTokenBalance": { + "currency": "03930D02208264E2E40EC1B0C09E4DB96EE197B1", + "issuer": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "value": "10027169" + } + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "tx": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "Amount": "1000000000", + "Amount2": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "10000" + }, + "Asset": { + "currency": "XRP" + }, + "Asset2": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Fee": "10", + "Flags": 1048576, + "Sequence": 7, + "SigningPubKey": "023CFED4018084296285DD8A321C099134B9CF6DCD8D91DC067BABCFF0E3F2BE1A", + "TransactionType": "AMMDeposit", + "TxnSignature": "3045022100D1363F0A6D7252690820657B6ACCB35245E65D8DCDB48199578C213ED9D3E24B0220697D4DC057ECCD942BC59B0242411839C125696CD9DE9A3EFE45367EE0D1D29D", + "date": "2022-11-26T00:55:02Z" + } +} diff --git a/src/containers/shared/components/Transaction/AMMWithdraw/test/AMMWithdraw.test.tsx b/src/containers/shared/components/Transaction/AMMWithdraw/test/AMMWithdraw.test.tsx index 3b7d6b635..1b1a450be 100644 --- a/src/containers/shared/components/Transaction/AMMWithdraw/test/AMMWithdraw.test.tsx +++ b/src/containers/shared/components/Transaction/AMMWithdraw/test/AMMWithdraw.test.tsx @@ -9,10 +9,22 @@ import withdrawUSDMock from './mock_data/withdraw_usd.json' import withdrawXRPMock from './mock_data/withdraw_xrp.json' import withdrawEpriceMock from './mock_data/withdraw_eprice.json' import withdrawAll from './mock_data/withdraw_all.json' +import withdrawMPT from './mock_data/withdraw_mpt.json' +import withdrawBothMPT from './mock_data/withdraw_both_mpt.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) describe('AMM Withdraw Tests', () => { const renderComponent = createSimpleRenderFactory(Simple) + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders from transaction', () => { const { container, unmount } = renderComponent(withdrawMock) expectSimpleRowText(container, 'asset1', '\uE9003,666.580862 XRP') @@ -84,4 +96,63 @@ describe('AMM Withdraw Tests', () => { expectSimpleRowText(container, 'asset1', '\uE9000.000005 XRP') unmount() }) + + it('renders with XRP + MPT withdraw (no ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container, unmount } = renderComponent(withdrawMPT) + expectSimpleRowText(container, 'asset1', '\uE900999.99998 XRP') + expectSimpleRowText( + container, + 'asset2', + '5,000 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders with XRP + MPT withdraw (with ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XGLD' } }, + }) + const { container, unmount } = renderComponent(withdrawMPT) + expectSimpleRowText(container, 'asset1', '\uE900999.99998 XRP') + expectSimpleRowText(container, 'asset2', '5,000 XGLD') + unmount() + }) + + it('renders with both MPT assets withdraw (no ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container, unmount } = renderComponent(withdrawBothMPT) + expectSimpleRowText( + container, + 'asset1', + '3,000 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + expectSimpleRowText( + container, + 'asset2', + '1,500 00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders with both MPT assets withdraw (with ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockImplementation((mptID: string) => { + if (mptID === '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F') { + return { + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XGLD' } }, + } + } + return { + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XUSD' } }, + } + }) + const { container, unmount } = renderComponent(withdrawBothMPT) + expectSimpleRowText(container, 'asset1', '3,000 XGLD') + expectSimpleRowText(container, 'asset2', '1,500 XUSD') + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/AMMWithdraw/test/mock_data/withdraw_both_mpt.json b/src/containers/shared/components/Transaction/AMMWithdraw/test/mock_data/withdraw_both_mpt.json new file mode 100644 index 000000000..91605e2c8 --- /dev/null +++ b/src/containers/shared/components/Transaction/AMMWithdraw/test/mock_data/withdraw_both_mpt.json @@ -0,0 +1,93 @@ +{ + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "AABB00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "MPTokenIssuanceID": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "503000" + }, + "PreviousFields": { + "MPTAmount": "500000" + }, + "PreviousTxnID": "98D314D1EC81BE9342EDA1C04BCEFA8F327B0EFE12839F1EAB52065492B20E82", + "PreviousTxnLgrSeq": 114138 + } + }, + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "CCDD00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "MPTokenIssuanceID": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "251500" + }, + "PreviousFields": { + "MPTAmount": "250000" + }, + "PreviousTxnID": "88D314D1EC81BE9342EDA1C04BCEFA8F327B0EFE12839F1EAB52065492B20E82", + "PreviousTxnLgrSeq": 114139 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "Asset": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Asset2": { + "mpt_issuance_id": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Flags": 0, + "LPTokenBalance": { + "currency": "03930D02208264E2E40EC1B0C09E4DB96EE197B1", + "issuer": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "value": "9515288.25827238" + }, + "TradingFee": 0 + }, + "LedgerEntryType": "AMM", + "LedgerIndex": "C7FD06649235AF4CABD8FA6D8BB0CAF6C6EA5038A74D0DDD5025290683636D02", + "PreviousFields": { + "LPTokenBalance": { + "currency": "03930D02208264E2E40EC1B0C09E4DB96EE197B1", + "issuer": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "value": "10027169" + } + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "tx": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "Amount": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "3000" + }, + "Amount2": { + "mpt_issuance_id": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F", + "value": "1500" + }, + "Asset": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Asset2": { + "mpt_issuance_id": "00000ABC2E631B9DFA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Fee": "10", + "Flags": 1048576, + "Sequence": 7, + "SigningPubKey": "023CFED4018084296285DD8A321C099134B9CF6DCD8D91DC067BABCFF0E3F2BE1A", + "TransactionType": "AMMWithdraw", + "TxnSignature": "3045022100D1363F0A6D7252690820657B6ACCB35245E65D8DCDB48199578C213ED9D3E24B0220697D4DC057ECCD942BC59B0242411839C125696CD9DE9A3EFE45367EE0D1D29D", + "date": "2022-11-26T00:55:02Z" + } +} diff --git a/src/containers/shared/components/Transaction/AMMWithdraw/test/mock_data/withdraw_mpt.json b/src/containers/shared/components/Transaction/AMMWithdraw/test/mock_data/withdraw_mpt.json new file mode 100644 index 000000000..dce212470 --- /dev/null +++ b/src/containers/shared/components/Transaction/AMMWithdraw/test/mock_data/withdraw_mpt.json @@ -0,0 +1,111 @@ +{ + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "LedgerEntryType": "MPToken", + "LedgerIndex": "AABB00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDD", + "FinalFields": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "MPTokenIssuanceID": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "MPTAmount": "1005000" + }, + "PreviousFields": { + "MPTAmount": "1000000" + }, + "PreviousTxnID": "98D314D1EC81BE9342EDA1C04BCEFA8F327B0EFE12839F1EAB52065492B20E82", + "PreviousTxnLgrSeq": 114138 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "Balance": "9027169000", + "Flags": 59768832, + "OwnerCount": 1, + "Sequence": 1 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "471AC5276FBA4916D53017D7073D44C5F4780CC73954B1715DC8A65365E8ACAC", + "PreviousFields": { + "Balance": "10027169000" + }, + "PreviousTxnID": "209B17403B42271F2D50DEC0F808AE07EC04B8B9605FF52B1093BFF31676AD2C", + "PreviousTxnLgrSeq": 233852 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "Balance": "1000972830930", + "Flags": 8388608, + "OwnerCount": 2, + "Sequence": 8 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "53383A918D45DEF78DED23CE5141C0FAB44661D602465F5FCFC487792448F1E2", + "PreviousFields": { + "Balance": "999972830940", + "Sequence": 7 + }, + "PreviousTxnID": "209B17403B42271F2D50DEC0F808AE07EC04B8B9605FF52B1093BFF31676AD2C", + "PreviousTxnLgrSeq": 233852 + } + }, + { + "ModifiedNode": { + "FinalFields": { + "Account": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "Asset": { + "currency": "XRP" + }, + "Asset2": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Flags": 0, + "LPTokenBalance": { + "currency": "03930D02208264E2E40EC1B0C09E4DB96EE197B1", + "issuer": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "value": "9515288.25827238" + }, + "TradingFee": 0 + }, + "LedgerEntryType": "AMM", + "LedgerIndex": "C7FD06649235AF4CABD8FA6D8BB0CAF6C6EA5038A74D0DDD5025290683636D02", + "PreviousFields": { + "LPTokenBalance": { + "currency": "03930D02208264E2E40EC1B0C09E4DB96EE197B1", + "issuer": "rMEdVzU8mtEArzjrN9avm3kA675GX7ez8W", + "value": "10027169" + } + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "tx": { + "Account": "rUwaiErsYE5kibUUtaPczXZVVd73VNy4R9", + "Amount": "1000000000", + "Amount2": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "5000" + }, + "Asset": { + "currency": "XRP" + }, + "Asset2": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F" + }, + "Fee": "10", + "Flags": 1048576, + "Sequence": 7, + "SigningPubKey": "023CFED4018084296285DD8A321C099134B9CF6DCD8D91DC067BABCFF0E3F2BE1A", + "TransactionType": "AMMWithdraw", + "TxnSignature": "3045022100D1363F0A6D7252690820657B6ACCB35245E65D8DCDB48199578C213ED9D3E24B0220697D4DC057ECCD942BC59B0242411839C125696CD9DE9A3EFE45367EE0D1D29D", + "date": "2022-11-26T00:55:02Z" + } +} diff --git a/src/containers/shared/components/Transaction/Clawback/test/ClawbackSimple.test.tsx b/src/containers/shared/components/Transaction/Clawback/test/ClawbackSimple.test.tsx index 95a7d056b..065fdac0e 100644 --- a/src/containers/shared/components/Transaction/Clawback/test/ClawbackSimple.test.tsx +++ b/src/containers/shared/components/Transaction/Clawback/test/ClawbackSimple.test.tsx @@ -1,19 +1,23 @@ -import { useQuery } from 'react-query' import { createSimpleRenderFactory, expectSimpleRowText } from '../../test' import { Simple } from '../Simple' import transaction from './mock_data/Clawback.json' import transactionFailure from './mock_data/Clawback_Failure.json' import transactionMPT from './mock_data/ClawbackMPT.json' import transactionMPTFailure from './mock_data/ClawbackMPT_Failure.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) const renderComponent = createSimpleRenderFactory(Simple) describe('Clawback', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('handles Clawback simple view ', () => { const { container, unmount } = renderComponent(transaction) expectSimpleRowText( @@ -34,10 +38,9 @@ describe('Clawback', () => { assetScale: 3, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent(transactionMPT) expectSimpleRowText( container, @@ -73,10 +76,9 @@ describe('Clawback', () => { assetScale: 3, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent(transactionMPTFailure) expectSimpleRowText( diff --git a/src/containers/shared/components/Transaction/DefaultSimple.tsx b/src/containers/shared/components/Transaction/DefaultSimple.tsx index 481616688..a1d3d21ea 100644 --- a/src/containers/shared/components/Transaction/DefaultSimple.tsx +++ b/src/containers/shared/components/Transaction/DefaultSimple.tsx @@ -27,18 +27,35 @@ const DEFAULT_TX_ELEMENTS = [ 'warnings', ] +// Currency objects have at most 2 keys: `currency` and optionally `issuer` +const MAX_CURRENCY_OBJECT_KEYS = 2 +// Amount objects have exactly 3 keys: `currency`, `issuer`, and `value` +const AMOUNT_OBJECT_KEY_COUNT = 3 + const displayKey = (key: string) => key.replace(/([a-z])([A-Z])/g, '$1 $2') -const isCurrency = (value: any) => +export const isMPTAsset = (value: any) => + typeof value === 'object' && + typeof value.mpt_issuance_id === 'string' && + !value.value + +export const isMPTAmount = (value: any) => typeof value === 'object' && - Object.keys(value).length <= 2 && - (value.issuer == null || typeof value.issuer === 'string') && - typeof value.currency === 'string' + typeof value.mpt_issuance_id === 'string' && + typeof value.value === 'string' + +const isCurrency = (value: any) => + isMPTAsset(value) || + (typeof value === 'object' && + Object.keys(value).length <= MAX_CURRENCY_OBJECT_KEYS && + (value.issuer == null || typeof value.issuer === 'string') && + typeof value.currency === 'string') const isAmount = (amount: any, key: any = null) => key === 'Amount' || + isMPTAmount(amount) || (typeof amount === 'object' && - Object.keys(amount).length === 3 && + Object.keys(amount).length === AMOUNT_OBJECT_KEY_COUNT && typeof amount.issuer === 'string' && typeof amount.currency === 'string' && typeof amount.value === 'string') @@ -111,7 +128,11 @@ const getRowNested = (key: any, value: any, uniqueKey: string = '') => { label={displayKey(key)} data-testid={key} > - + ) } diff --git a/src/containers/shared/components/Transaction/EscrowCancel/test/EscrowCancelDescription.test.tsx b/src/containers/shared/components/Transaction/EscrowCancel/test/EscrowCancelDescription.test.tsx index a6a5714bb..dedbb6438 100644 --- a/src/containers/shared/components/Transaction/EscrowCancel/test/EscrowCancelDescription.test.tsx +++ b/src/containers/shared/components/Transaction/EscrowCancel/test/EscrowCancelDescription.test.tsx @@ -1,21 +1,25 @@ -import { useQuery } from 'react-query' import mockEscrowCancel from './mock_data/EscrowCancel.json' import { Description } from '../Description' import { createDescriptionRenderFactory } from '../../test' import i18n from '../../../../../../i18n/testConfigEnglish' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -const renderComponent = createDescriptionRenderFactory(Description, i18n) - -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) +const renderComponent = createDescriptionRenderFactory(Description, i18n) + function getTestByName(name: string) { return mockEscrowCancel[name] } describe('EscrowCancelDescription', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders description for EscrowCancel', () => { const { container, unmount } = renderComponent( getTestByName('EscrowCancel having XRP escrowed'), @@ -56,10 +60,9 @@ describe('EscrowCancelDescription', () => { assetScale: 4, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent( getTestByName('EscrowCancel having MPT escrowed'), diff --git a/src/containers/shared/components/Transaction/EscrowCancel/test/EscrowCancelSimple.test.tsx b/src/containers/shared/components/Transaction/EscrowCancel/test/EscrowCancelSimple.test.tsx index 41402b2c1..8a0716a65 100644 --- a/src/containers/shared/components/Transaction/EscrowCancel/test/EscrowCancelSimple.test.tsx +++ b/src/containers/shared/components/Transaction/EscrowCancel/test/EscrowCancelSimple.test.tsx @@ -1,20 +1,24 @@ -import { useQuery } from 'react-query' import { createSimpleRenderFactory } from '../../test/createWrapperFactory' import { Simple } from '../Simple' import mockEscrowCancel from './mock_data/EscrowCancel.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -const renderComponent = createSimpleRenderFactory(Simple) - -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) +const renderComponent = createSimpleRenderFactory(Simple) + function getTestByName(name: string) { return mockEscrowCancel[name] } describe('EscrowCancelSimple', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders with an expiration and offer', () => { const { container, unmount } = renderComponent( getTestByName('EscrowCancel having XRP escrowed'), @@ -59,10 +63,9 @@ describe('EscrowCancelSimple', () => { assetScale: 4, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent( getTestByName('EscrowCancel having MPT escrowed'), diff --git a/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateDescription.test.tsx b/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateDescription.test.tsx index d61a7e63e..b7d178c1a 100644 --- a/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateDescription.test.tsx +++ b/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateDescription.test.tsx @@ -1,12 +1,12 @@ -import { useQuery } from 'react-query' import i18n from '../../../../../../i18n/testConfigEnglish' import mockEscrowCreateTests from './mock_data/EscrowCreate.json' import { Description } from '../Description' import { createDescriptionRenderFactory } from '../../test' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) const renderComponent = createDescriptionRenderFactory(Description, i18n) @@ -16,6 +16,10 @@ function getTestByName(name: string) { } describe('EscrowCreateDescription', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders description for EscrowCreate', () => { const { container, unmount } = renderComponent( getTestByName('renders EscrowCreate'), @@ -55,10 +59,9 @@ describe('EscrowCreateDescription', () => { assetScale: 4, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent( getTestByName('test MPT amount'), diff --git a/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateSimple.test.tsx b/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateSimple.test.tsx index 30c9a8d39..901512c14 100644 --- a/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateSimple.test.tsx +++ b/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateSimple.test.tsx @@ -1,12 +1,12 @@ -import { useQuery } from 'react-query' import { Simple } from '../Simple' import mockEscrowCreateTests from './mock_data/EscrowCreate.json' import mockEscrowCreateFinishFunction from './mock_data/EscrowCreateFinishFunction.json' import { createSimpleRenderFactory } from '../../test/createWrapperFactory' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) const renderComponent = createSimpleRenderFactory(Simple) @@ -16,6 +16,10 @@ function getTestByName(name: string) { } describe('EscrowCreateSimple', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders with an expiration and offer', () => { const { container, unmount } = renderComponent( getTestByName('renders EscrowCreate'), @@ -87,10 +91,9 @@ describe('EscrowCreateSimple', () => { assetScale: 4, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent( getTestByName('test MPT amount'), diff --git a/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateTableDetail.test.tsx b/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateTableDetail.test.tsx index ccb318cc0..70a4a587d 100644 --- a/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateTableDetail.test.tsx +++ b/src/containers/shared/components/Transaction/EscrowCreate/test/EscrowCreateTableDetail.test.tsx @@ -1,21 +1,25 @@ -import { useQuery } from 'react-query' import { createTableDetailRenderFactory } from '../../test' import { TableDetail } from '../TableDetail' import i18n from '../../../../../../i18n/testConfigEnglish' import mockEscrowCreate from './mock_data/EscrowCreate.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -const renderComponent = createTableDetailRenderFactory(TableDetail, i18n) - -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) +const renderComponent = createTableDetailRenderFactory(TableDetail, i18n) + function getTestByName(name: string) { return mockEscrowCreate[name] } describe('EscrowCreateTableDetail', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders EscrowCreate without crashing', () => { const { container, unmount } = renderComponent( getTestByName('renders EscrowCreate'), @@ -68,10 +72,9 @@ describe('EscrowCreateTableDetail', () => { assetScale: 4, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent( getTestByName('test MPT amount'), diff --git a/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishDescription.test.tsx b/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishDescription.test.tsx index 3be5151d0..1d6b277dc 100644 --- a/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishDescription.test.tsx +++ b/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishDescription.test.tsx @@ -1,21 +1,25 @@ -import { useQuery } from 'react-query' import mockEscrowFinish from './mock_data/EscrowFinish.json' import { Description } from '../Description' import i18n from '../../../../../../i18n/testConfigEnglish' import { createDescriptionRenderFactory } from '../../test' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -const renderComponent = createDescriptionRenderFactory(Description, i18n) - -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) +const renderComponent = createDescriptionRenderFactory(Description, i18n) + function getTestByName(name: string) { return mockEscrowFinish[name] } describe('EscrowFinishDescription', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders description for EscrowFinish', () => { const { container, unmount } = renderComponent( getTestByName('EscrowFinish having XRP escrowed'), @@ -56,10 +60,9 @@ describe('EscrowFinishDescription', () => { assetScale: 4, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent( getTestByName('EscrowFinish having MPT escrowed'), diff --git a/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishSimple.test.tsx b/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishSimple.test.tsx index 797319ec9..bb15a1ac1 100644 --- a/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishSimple.test.tsx +++ b/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishSimple.test.tsx @@ -1,22 +1,26 @@ -import { useQuery } from 'react-query' import { createSimpleRenderFactory } from '../../test/createWrapperFactory' import { Simple } from '../Simple' import mockEscrowFinish from './mock_data/EscrowFinish.json' import mockEscrowFinishCompAllow from './mock_data/EscrowFinishComputationAllowance.json' import mockEscrowFinishCredentialIDs from './mock_data/EscrowFinishWithCredentialIDs.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -const renderComponent = createSimpleRenderFactory(Simple) - -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) +const renderComponent = createSimpleRenderFactory(Simple) + function getTestByName(name: string) { return mockEscrowFinish[name] } describe('EscrowFinishSimple', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders with an expiration and offer', () => { const { container, unmount } = renderComponent( getTestByName('EscrowFinish having XRP escrowed'), @@ -74,10 +78,9 @@ describe('EscrowFinishSimple', () => { assetScale: 4, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent( getTestByName('EscrowFinish having MPT escrowed'), diff --git a/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishTableDetail.test.tsx b/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishTableDetail.test.tsx index f43c97b96..71045a98c 100644 --- a/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishTableDetail.test.tsx +++ b/src/containers/shared/components/Transaction/EscrowFinish/test/EscrowFinishTableDetail.test.tsx @@ -1,21 +1,25 @@ -import { useQuery } from 'react-query' import { createTableDetailRenderFactory } from '../../test' import { TableDetail } from '../TableDetail' import mockEscrowFinish from './mock_data/EscrowFinish.json' import i18n from '../../../../../../i18n/testConfigEnglish' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -const renderComponent = createTableDetailRenderFactory(TableDetail, i18n) - -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) +const renderComponent = createTableDetailRenderFactory(TableDetail, i18n) + function getTestByName(name: string) { return mockEscrowFinish[name] } describe('EscrowFinishTableDetail', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders EscrowFinish without crashing', () => { const { container, unmount } = renderComponent( getTestByName('EscrowFinish having XRP escrowed'), @@ -59,10 +63,9 @@ describe('EscrowFinishTableDetail', () => { assetScale: 4, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent( getTestByName('EscrowFinish having MPT escrowed'), diff --git a/src/containers/shared/components/Transaction/LoanBrokerCoverClawback/test/LoanBrokerCoverClawbackSimple.test.tsx b/src/containers/shared/components/Transaction/LoanBrokerCoverClawback/test/LoanBrokerCoverClawbackSimple.test.tsx index d245bd31d..3781fe749 100644 --- a/src/containers/shared/components/Transaction/LoanBrokerCoverClawback/test/LoanBrokerCoverClawbackSimple.test.tsx +++ b/src/containers/shared/components/Transaction/LoanBrokerCoverClawback/test/LoanBrokerCoverClawbackSimple.test.tsx @@ -1,19 +1,23 @@ -import { useQuery } from 'react-query' import { createSimpleRenderFactory, expectSimpleRowText } from '../../test' import { Simple } from '../Simple' import LoanBrokerCoverClawback from './mock_data/LoanBrokerCoverClawback.json' import LoanBrokerCoverClawbackZeroAmount from './mock_data/LoanBrokerCoverClawbackZeroAmount.json' import LoanBrokerCoverClawbackNoAmount from './mock_data/LoanBrokerCoverClawbackNoAmount.json' import LoanBrokerCoverClawbackMPT from './mock_data/LoanBrokerCoverClawbackMPT.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) const renderComponent = createSimpleRenderFactory(Simple) describe('LoanBrokerCoverClawback: Simple', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders with explicit amount', () => { const { container, unmount } = renderComponent(LoanBrokerCoverClawback) expectSimpleRowText( @@ -64,11 +68,11 @@ describe('LoanBrokerCoverClawback: Simple', () => { }) it('renders with calculated MPT amount when Amount is undefined', () => { - ;(useQuery as jest.Mock).mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: { assetScale: 2, }, - })) + }) const { container, unmount } = renderComponent(LoanBrokerCoverClawbackMPT) expectSimpleRowText( diff --git a/src/containers/shared/components/Transaction/LoanBrokerCoverClawback/test/LoanBrokerCoverClawbackTableDetail.test.tsx b/src/containers/shared/components/Transaction/LoanBrokerCoverClawback/test/LoanBrokerCoverClawbackTableDetail.test.tsx index a404a71de..c4f2186f7 100644 --- a/src/containers/shared/components/Transaction/LoanBrokerCoverClawback/test/LoanBrokerCoverClawbackTableDetail.test.tsx +++ b/src/containers/shared/components/Transaction/LoanBrokerCoverClawback/test/LoanBrokerCoverClawbackTableDetail.test.tsx @@ -1,4 +1,3 @@ -import { useQuery } from 'react-query' import i18n from '../../../../../../i18n/testConfigEnglish' import { createTableDetailRenderFactory } from '../../test' import { TableDetail } from '../TableDetail' @@ -6,15 +5,20 @@ import LoanBrokerCoverClawback from './mock_data/LoanBrokerCoverClawback.json' import LoanBrokerCoverClawbackZeroAmount from './mock_data/LoanBrokerCoverClawbackZeroAmount.json' import LoanBrokerCoverClawbackNoAmount from './mock_data/LoanBrokerCoverClawbackNoAmount.json' import LoanBrokerCoverClawbackMPT from './mock_data/LoanBrokerCoverClawbackMPT.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) const renderComponent = createTableDetailRenderFactory(TableDetail, i18n) describe('LoanBrokerCoverClawbackTableDetail', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders with explicit amount', () => { const { container, unmount } = renderComponent(LoanBrokerCoverClawback) @@ -56,12 +60,11 @@ describe('LoanBrokerCoverClawbackTableDetail', () => { }) it('renders with calculated MPT amount when Amount is undefined', () => { - // Mock MPT issuance data for scaling - ;(useQuery as jest.Mock).mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: { assetScale: 2, }, - })) + }) const { container, unmount } = renderComponent(LoanBrokerCoverClawbackMPT) diff --git a/src/containers/shared/components/Transaction/OfferCreate/Description.tsx b/src/containers/shared/components/Transaction/OfferCreate/Description.tsx index eb3b463ee..1aace8c6e 100644 --- a/src/containers/shared/components/Transaction/OfferCreate/Description.tsx +++ b/src/containers/shared/components/Transaction/OfferCreate/Description.tsx @@ -13,9 +13,16 @@ import { import { convertRippleDate } from '../../../../../rippled/lib/utils' import Currency from '../../Currency' import { Amount } from '../../Amount' -import { formatAmount } from '../../../../../rippled/lib/txSummary/formatAmount' +import { + formatAmount, + formatAsset, + isMPTAmount, +} from '../../../../../rippled/lib/txSummary/formatAmount' -const normalize = (amount: any) => amount.value || amount / XRP_BASE +const normalize = (amount: any) => { + if (isMPTAmount(amount)) return Number(amount.value) + return amount.value ? Number(amount.value) : amount / XRP_BASE +} const Description: TransactionDescriptionComponent = ( props: TransactionDescriptionProps, @@ -23,8 +30,10 @@ const Description: TransactionDescriptionComponent = ( const { t, i18n } = useTranslation() const language = i18n.resolvedLanguage const { data } = props - const paysCurrency = data.tx.TakerPays.currency || 'XRP' - const getsCurrency = data.tx.TakerGets.currency || 'XRP' + const paysAsset = formatAsset(data.tx.TakerPays) + const getsAsset = formatAsset(data.tx.TakerGets) + const paysCurrency = paysAsset.currency + const getsCurrency = getsAsset.currency const paysValue = normalize(data.tx.TakerPays) const getsValue = normalize(data.tx.TakerGets) const invert = @@ -38,14 +47,16 @@ const Description: TransactionDescriptionComponent = ( pair = ( / @@ -54,14 +65,16 @@ const Description: TransactionDescriptionComponent = ( pair = ( / diff --git a/src/containers/shared/components/Transaction/OfferCreate/Simple.tsx b/src/containers/shared/components/Transaction/OfferCreate/Simple.tsx index ad079a9d2..26ef3096a 100644 --- a/src/containers/shared/components/Transaction/OfferCreate/Simple.tsx +++ b/src/containers/shared/components/Transaction/OfferCreate/Simple.tsx @@ -19,12 +19,14 @@ const Simple: TransactionSimpleComponent = (props: TransactionSimpleProps) => { /
diff --git a/src/containers/shared/components/Transaction/OfferCreate/TableDetail.tsx b/src/containers/shared/components/Transaction/OfferCreate/TableDetail.tsx index f084829c3..f905a75af 100644 --- a/src/containers/shared/components/Transaction/OfferCreate/TableDetail.tsx +++ b/src/containers/shared/components/Transaction/OfferCreate/TableDetail.tsx @@ -18,12 +18,14 @@ export const TableDetail = (props: any) => { /
diff --git a/src/containers/shared/components/Transaction/OfferCreate/parser.ts b/src/containers/shared/components/Transaction/OfferCreate/parser.ts index 742c58fd7..96b8900a8 100644 --- a/src/containers/shared/components/Transaction/OfferCreate/parser.ts +++ b/src/containers/shared/components/Transaction/OfferCreate/parser.ts @@ -1,11 +1,14 @@ import { CURRENCY_ORDER } from '../../../transactionUtils' -import { formatAmount } from '../../../../../rippled/lib/txSummary/formatAmount' +import { + formatAmount, + formatAsset, +} from '../../../../../rippled/lib/txSummary/formatAmount' export function parser(tx: any) { const gets = formatAmount(tx.TakerGets) - const base = tx.TakerGets.currency ? tx.TakerGets : { currency: 'XRP' } - const counter = tx.TakerPays.currency ? tx.TakerPays : { currency: 'XRP' } const pays = formatAmount(tx.TakerPays) + const base = formatAsset(tx.TakerGets) + const counter = formatAsset(tx.TakerPays) const price = Number(pays.amount) / Number(gets.amount) const invert = CURRENCY_ORDER.indexOf(counter.currency) > diff --git a/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateDescription.test.tsx b/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateDescription.test.tsx index e93c8d24a..cee8d8e3e 100644 --- a/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateDescription.test.tsx +++ b/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateDescription.test.tsx @@ -1,12 +1,23 @@ import OfferCreate from './mock_data/OfferCreateWithExpirationAndCancel.json' import OfferCreateInvertedCurrencies from './mock_data/OfferCreateInvertedCurrencies.json' import OfferCreateWithPermissionedDomainID from './mock_data/OfferCreateWithPermissionedDomainID.json' +import mockOfferCreateMPT from './mock_data/OfferCreateMPT.json' import { Description } from '../Description' import { createDescriptionRenderFactory } from '../../test' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) const renderComponent = createDescriptionRenderFactory(Description) describe('OfferCreate: Description', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders description for transaction with cancel and expiration', () => { const { container, unmount } = renderComponent(OfferCreate) @@ -37,4 +48,46 @@ describe('OfferCreate: Description', () => { ) unmount() }) + + it('renders OfferCreate with MPT (no ticker - displays mpt_issuance_id)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container, unmount } = renderComponent(mockOfferCreateMPT) + const currencies = container.querySelectorAll('[data-testid="currency"]') + const mptCurrencies = Array.from(currencies).filter((el) => + el.getAttribute('href')?.includes('/mpt/'), + ) + + // MPT should display full mpt_issuance_id when no ticker is available + expect(mptCurrencies[0]).toHaveTextContent( + '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + // Links should point to the MPT page + expect(mptCurrencies[0]).toHaveAttribute( + 'href', + '/mpt/000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders OfferCreate with MPT (with ticker - displays ticker symbol)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XMPT' } }, + }) + const { container, unmount } = renderComponent(mockOfferCreateMPT) + const currencies = container.querySelectorAll('[data-testid="currency"]') + const mptCurrencies = Array.from(currencies).filter((el) => + el.getAttribute('href')?.includes('/mpt/'), + ) + + // MPT should display ticker symbol when available + expect(mptCurrencies[0]).toHaveTextContent('XMPT') + // Links should still point to the MPT page using the full ID + expect(mptCurrencies[0]).toHaveAttribute( + 'href', + '/mpt/000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateSimple.test.tsx b/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateSimple.test.tsx index e2980298e..59f261bc8 100644 --- a/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateSimple.test.tsx +++ b/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateSimple.test.tsx @@ -2,11 +2,23 @@ import { Simple } from '../Simple' import mockOfferCreateWithCancel from './mock_data/OfferCreateWithExpirationAndCancel.json' import mockOfferCreate from './mock_data/OfferCreate.json' import mockOfferCreateWithPermissionedDomainID from './mock_data/OfferCreateWithPermissionedDomainID.json' +import mockOfferCreateMPT from './mock_data/OfferCreateMPT.json' +import mockOfferCreateMPTPayIOU from './mock_data/OfferCreateMPTPayIOU.json' import { createSimpleRenderFactory } from '../../test/createWrapperFactory' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) const renderComponent = createSimpleRenderFactory(Simple) describe('OfferCreate: Simple', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders with an expiration and offer', () => { const { container, unmount } = renderComponent(mockOfferCreateWithCancel) expect( @@ -49,4 +61,64 @@ describe('OfferCreate: Simple', () => { '4A4879496CFF23CA32242D50DA04DDB41F4561167276A62AF21899F83DF28812', ) }) + + it('renders OfferCreate with MPT TakerGets and XRP TakerPays (no ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container } = renderComponent(mockOfferCreateMPT) + expect( + container.querySelector('[data-testid="amount-buy"] .value'), + ).toHaveTextContent('XRP') + expect( + container.querySelector('[data-testid="amount-sell"] .value'), + ).toHaveTextContent( + '1,000 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + }) + + it('renders OfferCreate with MPT TakerGets and XRP TakerPays (with ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { + assetScale: 0, + parsedMPTMetadata: { ticker: 'XMPT' }, + }, + }) + const { container } = renderComponent(mockOfferCreateMPT) + expect( + container.querySelector('[data-testid="amount-buy"] .value'), + ).toHaveTextContent('XRP') + expect( + container.querySelector('[data-testid="amount-sell"] .value'), + ).toHaveTextContent('1,000 XMPT') + }) + + it('renders OfferCreate with MPT TakerPays and IOU TakerGets (no ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container } = renderComponent(mockOfferCreateMPTPayIOU) + expect( + container.querySelector('[data-testid="amount-buy"] .value'), + ).toHaveTextContent('500 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F') + expect( + container.querySelector('[data-testid="amount-sell"] .value'), + ).toHaveTextContent('100') + }) + + it('renders OfferCreate with MPT TakerPays and IOU TakerGets (with ticker)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { + assetScale: 0, + parsedMPTMetadata: { ticker: 'XMPT' }, + }, + }) + const { container } = renderComponent(mockOfferCreateMPTPayIOU) + expect( + container.querySelector('[data-testid="amount-buy"] .value'), + ).toHaveTextContent('500 XMPT') + expect( + container.querySelector('[data-testid="amount-sell"] .value'), + ).toHaveTextContent('100') + }) }) diff --git a/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateTableDetail.test.tsx b/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateTableDetail.test.tsx index 7a948bd26..2fcdff40e 100644 --- a/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateTableDetail.test.tsx +++ b/src/containers/shared/components/Transaction/OfferCreate/test/OfferCreateTableDetail.test.tsx @@ -3,11 +3,22 @@ import mockOfferCreateInvertedCurrencies from './mock_data/OfferCreateInvertedCu import mockOfferCreateWithCancel from './mock_data/OfferCreateWithExpirationAndCancel.json' import mockOfferCreate from './mock_data/OfferCreate.json' import mockOfferCreateWithPermissionedDomainID from './mock_data/OfferCreateWithPermissionedDomainID.json' +import mockOfferCreateMPT from './mock_data/OfferCreateMPT.json' import { createTableDetailRenderFactory } from '../../test' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) const renderComponent = createTableDetailRenderFactory(TableDetail) describe('OfferCreate: TableDetail', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders with an expiration and offer', () => { const { container, unmount } = renderComponent(mockOfferCreateWithCancel) @@ -69,4 +80,51 @@ describe('OfferCreate: TableDetail', () => { ) unmount() }) + + it('renders OfferCreate with MPT (no ticker - displays mpt_issuance_id)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0 }, + }) + const { container, unmount } = renderComponent(mockOfferCreateMPT) + + // Price pair should show MPT ID + expect(container.querySelector('[data-testid="pair"]')).toHaveTextContent( + 'price:0.02 \uE900 XRP/000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + // Sell amount should show full MPT ID + const amounts = container.querySelectorAll('[data-testid="amount"]') + expect(amounts[1]).toHaveTextContent('50,000.00 XRP') + expect(amounts[2]).toHaveTextContent( + '1,000 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + // MPT link should point to the MPT page + const mptLink = container.querySelector('a[href*="/mpt/"]') + expect(mptLink).toHaveAttribute( + 'href', + '/mpt/000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders OfferCreate with MPT (with ticker - displays ticker symbol)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 0, parsedMPTMetadata: { ticker: 'XMPT' } }, + }) + const { container, unmount } = renderComponent(mockOfferCreateMPT) + + // Price pair should show ticker instead of shortened ID + expect(container.querySelector('[data-testid="pair"]')).toHaveTextContent( + 'price:0.02 \uE900 XRP/XMPT', + ) + // Sell amount should show ticker instead of full ID + const amounts = container.querySelectorAll('[data-testid="amount"]') + expect(amounts[2]).toHaveTextContent('1,000 XMPT') + // MPT link should still point to the MPT page using full ID + const mptLink = container.querySelector('a[href*="/mpt/"]') + expect(mptLink).toHaveAttribute( + 'href', + '/mpt/000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/OfferCreate/test/mock_data/OfferCreateMPT.json b/src/containers/shared/components/Transaction/OfferCreate/test/mock_data/OfferCreateMPT.json new file mode 100644 index 000000000..159fab244 --- /dev/null +++ b/src/containers/shared/components/Transaction/OfferCreate/test/mock_data/OfferCreateMPT.json @@ -0,0 +1,64 @@ +{ + "tx": { + "Account": "r3rhWeE31Jt5sWmi4QiGLMZnY3ENgqw96W", + "Fee": "5176", + "Flags": 0, + "LastLedgerSequence": 71724753, + "Sequence": 56768893, + "SigningPubKey": "03C48299E57F5AE7C2BE1391B581D313F1967EA2301628C07AC412092FDC15BA22", + "TakerGets": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "1000" + }, + "TakerPays": "50000000000", + "TransactionType": "OfferCreate", + "TxnSignature": "3044022069287DAA493E6C5754D32121408F42034F26BE8C8111EC27D8F9FBD9F01448A60220015FB59A284F388322835F9C4EC3C2C9ABCE763F9327C474D0BA668A8632E922", + "date": "2022-05-18T02:40:11Z" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "r3rhWeE31Jt5sWmi4QiGLMZnY3ENgqw96W", + "Balance": "220102267009", + "Flags": 0, + "OwnerCount": 108, + "Sequence": 56768894 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "B1B9AAC12B56B1CFC93DDC8AF6958B50E89509F377ED4825A3D970F249892CE3", + "PreviousFields": { + "Balance": "220102272185", + "OwnerCount": 107, + "Sequence": 56768893 + }, + "PreviousTxnID": "41336A151E6F8F91852E03A04204F62BB186B486691B5AB27AB00A70C3B26B37", + "PreviousTxnLgrSeq": 71724751 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "E284EF740006031D49F6EDACA80AD8CC3A7507318ADAAE8781A846CF26351C6F", + "NewFields": { + "Account": "r3rhWeE31Jt5sWmi4QiGLMZnY3ENgqw96W", + "BookDirectory": "33C81D720DBA84863A1510FD5C6C3E9224F0F5778261CF175D111AFA1F573DF6", + "OwnerNode": "80a6", + "Sequence": 56768893, + "TakerGets": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "1000" + }, + "TakerPays": "50000000000" + } + } + } + ], + "TransactionIndex": 16, + "TransactionResult": "tesSUCCESS" + }, + "hash": "DB244B742B5C4CDD11A53D0F487C616D18E8C0EC60FF38F059E167095506820A", + "ledger_index": 71724751, + "date": "2022-05-18T02:40:11Z" +} diff --git a/src/containers/shared/components/Transaction/OfferCreate/test/mock_data/OfferCreateMPTPayIOU.json b/src/containers/shared/components/Transaction/OfferCreate/test/mock_data/OfferCreateMPTPayIOU.json new file mode 100644 index 000000000..57ef45a19 --- /dev/null +++ b/src/containers/shared/components/Transaction/OfferCreate/test/mock_data/OfferCreateMPTPayIOU.json @@ -0,0 +1,72 @@ +{ + "tx": { + "Account": "r3rhWeE31Jt5sWmi4QiGLMZnY3ENgqw96W", + "Fee": "5176", + "Flags": 0, + "LastLedgerSequence": 71724753, + "Sequence": 56768893, + "SigningPubKey": "03C48299E57F5AE7C2BE1391B581D313F1967EA2301628C07AC412092FDC15BA22", + "TakerGets": { + "currency": "USD", + "issuer": "rcyS4CeCZVYvTiKcxj6Sx32ibKwcDHLds", + "value": "100" + }, + "TakerPays": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "500" + }, + "TransactionType": "OfferCreate", + "TxnSignature": "3044022069287DAA493E6C5754D32121408F42034F26BE8C8111EC27D8F9FBD9F01448A60220015FB59A284F388322835F9C4EC3C2C9ABCE763F9327C474D0BA668A8632E922", + "date": "2022-05-18T02:40:11Z" + }, + "meta": { + "AffectedNodes": [ + { + "ModifiedNode": { + "FinalFields": { + "Account": "r3rhWeE31Jt5sWmi4QiGLMZnY3ENgqw96W", + "Balance": "220102267009", + "Flags": 0, + "OwnerCount": 108, + "Sequence": 56768894 + }, + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "B1B9AAC12B56B1CFC93DDC8AF6958B50E89509F377ED4825A3D970F249892CE3", + "PreviousFields": { + "Balance": "220102272185", + "OwnerCount": 107, + "Sequence": 56768893 + }, + "PreviousTxnID": "41336A151E6F8F91852E03A04204F62BB186B486691B5AB27AB00A70C3B26B37", + "PreviousTxnLgrSeq": 71724751 + } + }, + { + "CreatedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "E284EF740006031D49F6EDACA80AD8CC3A7507318ADAAE8781A846CF26351C6F", + "NewFields": { + "Account": "r3rhWeE31Jt5sWmi4QiGLMZnY3ENgqw96W", + "BookDirectory": "33C81D720DBA84863A1510FD5C6C3E9224F0F5778261CF175D111AFA1F573DF6", + "OwnerNode": "80a6", + "Sequence": 56768893, + "TakerGets": { + "currency": "USD", + "issuer": "rcyS4CeCZVYvTiKcxj6Sx32ibKwcDHLds", + "value": "100" + }, + "TakerPays": { + "mpt_issuance_id": "000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F", + "value": "500" + } + } + } + } + ], + "TransactionIndex": 16, + "TransactionResult": "tesSUCCESS" + }, + "hash": "DB244B742B5C4CDD11A53D0F487C616D18E8C0EC60FF38F059E167095506820A", + "ledger_index": 71724751, + "date": "2022-05-18T02:40:11Z" +} diff --git a/src/containers/shared/components/Transaction/Payment/parser.ts b/src/containers/shared/components/Transaction/Payment/parser.ts index 44bc5f82c..0422df879 100644 --- a/src/containers/shared/components/Transaction/Payment/parser.ts +++ b/src/containers/shared/components/Transaction/Payment/parser.ts @@ -1,4 +1,4 @@ -// import type { Payment } from 'xrpl' +import type { Payment } from 'xrpl' import { formatAmount } from '../../../../../rippled/lib/txSummary/formatAmount' import { PaymentInstructions } from './types' import { Amount, ExplorerAmount } from '../../../types' @@ -10,8 +10,7 @@ const formatFailedPartialAmount = (d: Amount): ExplorerAmount => ({ export const isPartialPayment = (flags: any) => 0x00020000 & flags -// TODO: use MPTAmount type from xrpl.js -export const parser = (tx: any, meta: any): PaymentInstructions => { +export const parser = (tx: Payment, meta: any): PaymentInstructions => { const max = tx.SendMax ? formatAmount(tx.SendMax) : undefined const partial = !!isPartialPayment(tx.Flags) const failedPartial = partial && meta.TransactionResult !== 'tesSUCCESS' diff --git a/src/containers/shared/components/Transaction/Payment/test/PaymentDescription.test.tsx b/src/containers/shared/components/Transaction/Payment/test/PaymentDescription.test.tsx index 3a47113e4..a9859f72b 100644 --- a/src/containers/shared/components/Transaction/Payment/test/PaymentDescription.test.tsx +++ b/src/containers/shared/components/Transaction/Payment/test/PaymentDescription.test.tsx @@ -8,11 +8,22 @@ import mockPaymentPartial from './mock_data/PaymentWithPartial.json' import mockPaymentSendMax from './mock_data/PaymentWithSendMax.json' import mockPaymentSourceTag from './mock_data/PaymentWithSourceTag.json' import mockPermDomainID from './mock_data/PaymentWithPermDomainID.json' +import mockPaymentMPT from './mock_data/PaymentMPT.json' import { createDescriptionRenderFactory } from '../../test' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) const renderComponent = createDescriptionRenderFactory(Description, i18n) describe('Payment: Description', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders', () => { const { container, unmount } = renderComponent(mockPayment) @@ -164,4 +175,30 @@ describe('Payment: Description', () => { `Domain ID: D3261DF48CDA3B860ED3FA99F02138856393CD44556E028D5CB66192A18A8D02`, ) }) + + it('renders direct MPT payment (no ticker - displays mpt_issuance_id)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 3 }, + }) + const { container, unmount } = renderComponent(mockPaymentMPT) + + expect( + container.querySelector('[data-testid="amount-line"]'), + ).toHaveTextContent( + 'It was instructed to deliver 0.1 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F', + ) + unmount() + }) + + it('renders direct MPT payment (with ticker - displays ticker symbol)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 3, parsedMPTMetadata: { ticker: 'XMPT' } }, + }) + const { container, unmount } = renderComponent(mockPaymentMPT) + + expect( + container.querySelector('[data-testid="amount-line"]'), + ).toHaveTextContent('It was instructed to deliver 0.1 XMPT') + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/Payment/test/PaymentSimple.test.tsx b/src/containers/shared/components/Transaction/Payment/test/PaymentSimple.test.tsx index 0c95a41d5..186487635 100644 --- a/src/containers/shared/components/Transaction/Payment/test/PaymentSimple.test.tsx +++ b/src/containers/shared/components/Transaction/Payment/test/PaymentSimple.test.tsx @@ -1,4 +1,3 @@ -import { useQuery } from 'react-query' import { createSimpleRenderFactory, expectSimpleRowLabel, @@ -14,15 +13,20 @@ import mockPaymentSourceTag from './mock_data/PaymentWithSourceTag.json' import mockPaymentMPT from './mock_data/PaymentMPT.json' import mockPermDomainID from './mock_data/PaymentWithPermDomainID.json' import mockPaymentWithCredentialIDs from './mock_data/PaymentWithCredentialIDs.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) const renderComponent = createSimpleRenderFactory(Simple) describe('Payment: Simple', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders', () => { const { container, unmount } = renderComponent(mockPayment) @@ -131,10 +135,9 @@ describe('Payment: Simple', () => { assetScale: 3, } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const { container, unmount } = renderComponent(mockPaymentMPT) @@ -154,6 +157,28 @@ describe('Payment: Simple', () => { unmount() }) + it('renders direct MPT payment with ticker', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { + assetScale: 3, + parsedMPTMetadata: { ticker: 'XMPT' }, + }, + }) + + const { container, unmount } = renderComponent(mockPaymentMPT) + + expectSimpleRowText(container, 'amount', `0.1 XMPT`) + expectSimpleRowLabel(container, 'amount', `send`) + + expectSimpleRowText( + container, + 'destination', + `rw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu`, + ) + + unmount() + }) + it(`renders with Permissioned Domain ID`, () => { const { container, unmount } = renderComponent(mockPermDomainID) diff --git a/src/containers/shared/components/Transaction/Payment/test/PaymentTableDetail.test.tsx b/src/containers/shared/components/Transaction/Payment/test/PaymentTableDetail.test.tsx index 25bdc830b..14ea81abf 100644 --- a/src/containers/shared/components/Transaction/Payment/test/PaymentTableDetail.test.tsx +++ b/src/containers/shared/components/Transaction/Payment/test/PaymentTableDetail.test.tsx @@ -8,10 +8,21 @@ import mockPaymentSendMax from './mock_data/PaymentWithSendMax.json' import mockPaymentSourceTag from './mock_data/PaymentWithSourceTag.json' import mockPermDomainID from './mock_data/PaymentWithPermDomainID.json' import mockPaymentCredentialIDs from './mock_data/PaymentWithCredentialIDs.json' +import mockPaymentMPT from './mock_data/PaymentMPT.json' +import { useMPTIssuance } from '../../../../hooks/useMPTIssuance' + +jest.mock('../../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) const renderComponent = createTableDetailRenderFactory(TableDetail) describe('Payment: TableDetail', () => { + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) + it('renders', () => { const { container, unmount } = renderComponent(mockPayment) @@ -103,4 +114,28 @@ describe('Payment: TableDetail', () => { unmount() }) + + it('renders direct MPT payment (no ticker - displays mpt_issuance_id)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 3 }, + }) + const { container, unmount } = renderComponent(mockPaymentMPT) + + expect(container.querySelector('.payment')).toHaveTextContent( + 'send0.1 000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5Ftorw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', + ) + unmount() + }) + + it('renders direct MPT payment (with ticker - displays ticker symbol)', () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { assetScale: 3, parsedMPTMetadata: { ticker: 'XMPT' } }, + }) + const { container, unmount } = renderComponent(mockPaymentMPT) + + expect(container.querySelector('.payment')).toHaveTextContent( + 'send0.1 XMPTtorw6UtpfBFaGht6SiC1HpDPNw6Yt25pKvnu', + ) + unmount() + }) }) diff --git a/src/containers/shared/components/Transaction/test/DefaultSimple.test.tsx b/src/containers/shared/components/Transaction/test/DefaultSimple.test.tsx index 4027dab8c..39c71ea89 100644 --- a/src/containers/shared/components/Transaction/test/DefaultSimple.test.tsx +++ b/src/containers/shared/components/Transaction/test/DefaultSimple.test.tsx @@ -1,12 +1,18 @@ +import MPTTokenSwapPropose from './mock_data/MPTTokenSwapPropose.json' import NewEscrowCreate from './mock_data/NewEscrowCreate.json' import SetHook from './mock_data/SetHook.json' import SetHook2 from './mock_data/SetHook2.json' import TokenSwapPropose from './mock_data/TokenSwapPropose.json' -import { DefaultSimple } from '../DefaultSimple' +import { DefaultSimple, isMPTAsset, isMPTAmount } from '../DefaultSimple' import { renderWithProviders } from './createWrapperFactory' import { expectSimpleRowText } from './expectations' import summarizeTransaction from '../../../../../rippled/lib/txSummary' +jest.mock('../../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../../hooks/useMPTIssuance'), + useMPTIssuance: () => ({ data: undefined }), +})) + function renderComponent(tx: { tx: any; meta: any }) { // eslint-disable-next-line no-param-reassign -- needed so parsers aren't triggered tx.tx.TransactionType = 'DummyTx' @@ -145,4 +151,61 @@ describe('DefaultSimple', () => { '$33.00 USD.rnz5f1MFcgbVxzYhU5hUKbKquEJHJady5K', ) }) + + it('renders Currency with isMPT=true when given an MPT asset', () => { + const MPT_ID = '00000BDE5B4F868ECE457207E2C1750065987730B8839E0D' + const { container } = renderComponent(MPTTokenSwapPropose) + + // When isMPT=true, Currency renders a RouteLink to MPT_ROUTE (/mpt/:id). + // This is the behavioral proof that DefaultSimple passed isMPT=true. + const assetLink = container.querySelector( + `[data-testid="Asset"] a[href="/mpt/${MPT_ID}"]`, + ) + expect(assetLink).toBeInTheDocument() + }) +}) + +describe('isMPTAsset', () => { + it('returns true for an asset object with mpt_issuance_id and no value', () => { + expect(isMPTAsset({ mpt_issuance_id: 'ABC123' })).toBe(true) + }) + + it('returns false when value is present (that is an amount, not an asset)', () => { + expect(isMPTAsset({ mpt_issuance_id: 'ABC123', value: '10' })).toBe(false) + }) + + it('returns false when mpt_issuance_id is missing', () => { + expect(isMPTAsset({ currency: 'USD' })).toBe(false) + }) + + it('returns false when mpt_issuance_id is not a string', () => { + expect(isMPTAsset({ mpt_issuance_id: 123 })).toBe(false) + }) + + it('returns false for non-objects', () => { + expect(isMPTAsset('ABC123')).toBe(false) + expect(isMPTAsset(42)).toBe(false) + }) +}) + +describe('isMPTAmount', () => { + it('returns true when both mpt_issuance_id and value are strings', () => { + expect(isMPTAmount({ mpt_issuance_id: 'ABC123', value: '10' })).toBe(true) + }) + + it('returns false when value is missing (that is an asset, not an amount)', () => { + expect(isMPTAmount({ mpt_issuance_id: 'ABC123' })).toBe(false) + }) + + it('returns false when value is not a string', () => { + expect(isMPTAmount({ mpt_issuance_id: 'ABC123', value: 10 })).toBe(false) + }) + + it('returns false when mpt_issuance_id is missing', () => { + expect(isMPTAmount({ currency: 'USD', value: '10' })).toBe(false) + }) + + it('returns false for non-objects', () => { + expect(isMPTAmount('ABC123')).toBe(false) + }) }) diff --git a/src/containers/shared/components/Transaction/test/mock_data/MPTTokenSwapPropose.json b/src/containers/shared/components/Transaction/test/mock_data/MPTTokenSwapPropose.json new file mode 100644 index 000000000..85a9cf834 --- /dev/null +++ b/src/containers/shared/components/Transaction/test/mock_data/MPTTokenSwapPropose.json @@ -0,0 +1,29 @@ +{ + "tx": { + "Account": "ratB3Rp7pcid4hzwSYTWiqWXYXFmWUFDv1", + "AccountOther": "rPTScb8m3wq6r3Ys93Ec5at7LYDmWrtndi", + "Amount": { + "mpt_issuance_id": "00000BDE5B4F868ECE457207E2C1750065987730B8839E0D", + "value": "42" + }, + "Asset": { + "mpt_issuance_id": "00000BDE5B4F868ECE457207E2C1750065987730B8839E0D" + }, + "Fee": "10", + "Flags": 2147483648, + "Sequence": 5, + "SigningPubKey": "ED2D7565F8E1432940E5B3BEB4562DE99DB880323B5C8A178376D5B481A7DB6E1C", + "TransactionType": "TokenSwapPropose", + "TxnSignature": "153E5473EB70B6E073F22A8281AE1E5593C96369CACDC1E2DB9FA54C5B3EC7C07F458061858DD7AE061198E7CB8BF56A7034F102016DFDB5F38EF4C28466B308", + "ctid": "C000005800000000", + "date": 749136066 + }, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }, + "hash": "F84CCA4C6948B0D068A2D97A8CA92F557CE3615D98F6E90540369D4A4F122D0E", + "inLedger": 88, + "ledger_index": 88 +} diff --git a/src/containers/shared/components/TxToken.tsx b/src/containers/shared/components/TxToken.tsx index d7127d774..b9b36de18 100644 --- a/src/containers/shared/components/TxToken.tsx +++ b/src/containers/shared/components/TxToken.tsx @@ -11,8 +11,8 @@ interface Props { function getTokenPair( type: string, fee: number, - amount: { currency: string; amount: number }, - amount2: { currency: string; amount: number }, + amount: { currency: string; amount: number; isMPT?: boolean }, + amount2: { currency: string; amount: number; isMPT?: boolean }, ) { if ( type === 'AMMWithdraw' || @@ -22,11 +22,11 @@ function getTokenPair( ) { const first = amount?.amount && amount.amount !== fee ? ( - + ) : undefined const second = amount2?.amount && amount2.amount !== fee ? ( - + ) : undefined if (first && second) { diff --git a/src/containers/shared/components/test/Amount.test.tsx b/src/containers/shared/components/test/Amount.test.tsx index 5d8f57d3a..0b97c3657 100644 --- a/src/containers/shared/components/test/Amount.test.tsx +++ b/src/containers/shared/components/test/Amount.test.tsx @@ -1,17 +1,20 @@ import { I18nextProvider } from 'react-i18next' import { BrowserRouter } from 'react-router' import { cleanup, render, screen } from '@testing-library/react' -import { useQuery } from 'react-query' import { Amount } from '../Amount' import i18n from '../../../../i18n/testConfig' +import { useMPTIssuance } from '../../hooks/useMPTIssuance' -jest.mock('react-query', () => ({ - ...jest.requireActual('react-query'), - useQuery: jest.fn(), +jest.mock('../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), })) describe('Amount', () => { afterEach(cleanup) + beforeEach(() => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data: undefined }) + }) const renderComponent = (component: JSX.Element) => render( @@ -141,10 +144,9 @@ describe('Amount', () => { flags: [], } - // @ts-ignore - useQuery.mockImplementation(() => ({ + ;(useMPTIssuance as jest.Mock).mockReturnValue({ data, - })) + }) const value = { amount: '1043001', @@ -158,4 +160,59 @@ describe('Amount', () => { '1,043.001', ) }) + + it('preserves MPT precision for value at the spec maximum (2^63 - 1)', async () => { + // 2^63 - 1 scaled by 6 decimals is 9,223,372,036,854.775807. With the + // pre-BigInt parseInt path this overflowed to ~9.223372036854776e18 and + // rendered as "9,223,372,036,854.776" (last three digits lost). + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { + issuer: 'rL2LzUhsBJMqsaVCXVvzedPjePbjVzBCC', + assetScale: 6, + maxAmt: '9223372036854775807', + outstandingAmt: '9223372036854775807', + sequence: 1, + metadata: '', + flags: [], + }, + }) + + const value = { + amount: '9223372036854775807', + currency: '0000098F03B3BCE934EE8CAA1DF25A42032388361B9E5A65', + isMPT: true, + } + renderComponent() + + expect(screen.getByTestId('amount-localized')).toHaveTextContent( + '9,223,372,036,854.775807', + ) + }) + + it('preserves MPT precision when assetScale is 0', async () => { + ;(useMPTIssuance as jest.Mock).mockReturnValue({ + data: { + issuer: 'rL2LzUhsBJMqsaVCXVvzedPjePbjVzBCC', + assetScale: 0, + maxAmt: '9223372036854775807', + outstandingAmt: '9223372036854775807', + sequence: 1, + metadata: '', + flags: [], + }, + }) + + const value = { + amount: '9223372036854775807', + currency: '0000098F03B3BCE934EE8CAA1DF25A42032388361B9E5A65', + isMPT: true, + } + renderComponent() + + // With scale 0 the entire 2^63 - 1 must render as a clean grouped integer + // with no missing low-order digits. + expect(screen.getByTestId('amount-localized')).toHaveTextContent( + '9,223,372,036,854,775,807', + ) + }) }) diff --git a/src/containers/shared/components/test/Currency.test.tsx b/src/containers/shared/components/test/Currency.test.tsx index b7c772bb4..4d6f79159 100644 --- a/src/containers/shared/components/test/Currency.test.tsx +++ b/src/containers/shared/components/test/Currency.test.tsx @@ -1,9 +1,22 @@ import { BrowserRouter } from 'react-router' import { cleanup, render, screen } from '@testing-library/react' import Currency from '../Currency' +import { useMPTIssuance } from '../../hooks/useMPTIssuance' + +jest.mock('../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../hooks/useMPTIssuance'), + useMPTIssuance: jest.fn(), +})) + +const mockedUseMPTIssuance = useMPTIssuance as jest.Mock describe('Currency', () => { afterEach(cleanup) + + beforeEach(() => { + mockedUseMPTIssuance.mockReturnValue({ data: undefined }) + }) + it('handles currency codes that are 3 characters ', () => { render() const element = screen.getByTestId('currency') @@ -100,3 +113,58 @@ describe('Currency', () => { ) }) }) + +describe('Currency with MPT ticker', () => { + afterEach(cleanup) + + beforeEach(() => { + mockedUseMPTIssuance.mockReturnValue({ + data: { + issuer: 'r9Kokzc4FC1BW81pDarodghf3n8w2vufhW', + sequence: 3038, + assetScale: 2, + parsedMPTMetadata: { ticker: 'MPT01' }, + isMPTMetadataCompliant: true, + }, + }) + }) + + it('displays ticker symbol instead of MPT ID when metadata has ticker', () => { + render( + + + , + ) + const mpt = screen.getByTestId('currency') + + expect(mpt).toHaveTextContent('MPT01') + expect(mpt).toHaveAttribute( + 'href', + '/mpt/00000BDE5B4F868ECE457207E2C1750065987730B8839E0D', + ) + }) + + it('displays ticker symbol when link is disabled', () => { + render( + , + ) + const mpt = screen.getByTestId('currency') + + expect(mpt).toHaveTextContent('MPT01') + expect(mpt.tagName).toBe('SPAN') + }) + + it('does not affect non-MPT currencies when MPT data is available', () => { + render() + const element = screen.getByTestId('currency') + + expect(element).toHaveTextContent('USD') + }) +}) diff --git a/src/containers/shared/components/test/TxToken.test.tsx b/src/containers/shared/components/test/TxToken.test.tsx index e6bc0f7c3..2f5c28f13 100644 --- a/src/containers/shared/components/test/TxToken.test.tsx +++ b/src/containers/shared/components/test/TxToken.test.tsx @@ -10,6 +10,11 @@ import paymentMock from '../Transaction/Payment/test/mock_data/Payment.json' import summarizeTransaction from '../../../../rippled/lib/txSummary' +jest.mock('../../hooks/useMPTIssuance', () => ({ + ...jest.requireActual('../../hooks/useMPTIssuance'), + useMPTIssuance: () => ({ data: undefined }), +})) + describe('TxToken', () => { const renderTxToken = (transaction: any) => render( diff --git a/src/containers/shared/hooks/index.ts b/src/containers/shared/hooks/index.ts index ac74ecbb3..0df3873a8 100644 --- a/src/containers/shared/hooks/index.ts +++ b/src/containers/shared/hooks/index.ts @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next' import { useLocalStorage } from './useLocalStorage' export * from './useLocalStorage' +export * from './useMPTIssuance' export const useLanguage = () => useTranslation().i18n.resolvedLanguage || 'en-US' diff --git a/src/containers/shared/hooks/test/useMPTIssuance.test.tsx b/src/containers/shared/hooks/test/useMPTIssuance.test.tsx new file mode 100644 index 000000000..0b55e1c30 --- /dev/null +++ b/src/containers/shared/hooks/test/useMPTIssuance.test.tsx @@ -0,0 +1,110 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from 'react-query' +import SocketContext from '../../SocketContext' +import { useMPTIssuance } from '../useMPTIssuance' +import { getMPTIssuance } from '../../../../rippled/lib/rippled' +import { formatMPTIssuance } from '../../../../rippled/lib/utils' +import { useAnalytics } from '../../analytics' +import { FormattedMPTIssuance } from '../../Interfaces' + +jest.mock('../../../../rippled/lib/rippled', () => ({ + getMPTIssuance: jest.fn(), +})) +jest.mock('../../../../rippled/lib/utils', () => ({ + formatMPTIssuance: jest.fn(), +})) +jest.mock('../../analytics', () => ({ + useAnalytics: jest.fn(), +})) + +const mockGetMPTIssuance = getMPTIssuance as jest.Mock +const mockFormatMPTIssuance = formatMPTIssuance as jest.Mock +const mockUseAnalytics = useAnalytics as jest.Mock +const mockSocket = {} as any + +const MPT_ID = '00000001ABC123' + +const formatted: FormattedMPTIssuance = { + issuer: 'rIssuer', + sequence: 1, + isMPTMetadataCompliant: true, +} + +const createWrapper = + ( + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 0 } }, + }), + ) => + ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ) + +describe('useMPTIssuance', () => { + const trackException = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + mockUseAnalytics.mockReturnValue({ trackException }) + mockFormatMPTIssuance.mockReturnValue(formatted) + }) + + it('fetches and returns formatted MPT issuance data', async () => { + mockGetMPTIssuance.mockResolvedValueOnce({ node: { foo: 'bar' } }) + + const { result } = renderHook(() => useMPTIssuance(MPT_ID), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockGetMPTIssuance).toHaveBeenCalledWith(mockSocket, MPT_ID) + expect(mockFormatMPTIssuance).toHaveBeenCalledWith({ foo: 'bar' }) + expect(result.current.data).toEqual(formatted) + }) + + it('does not fire the query when mptID is null', () => { + const { result } = renderHook(() => useMPTIssuance(null), { + wrapper: createWrapper(), + }) + + expect(mockGetMPTIssuance).not.toHaveBeenCalled() + expect(result.current.data).toBeUndefined() + }) + + it('does not fire the query when mptID is undefined', () => { + const { result } = renderHook(() => useMPTIssuance(undefined), { + wrapper: createWrapper(), + }) + + expect(mockGetMPTIssuance).not.toHaveBeenCalled() + expect(result.current.data).toBeUndefined() + }) + + it('does not fire the query when enabled is false, even with a valid mptID', () => { + const { result } = renderHook(() => useMPTIssuance(MPT_ID, false), { + wrapper: createWrapper(), + }) + + expect(mockGetMPTIssuance).not.toHaveBeenCalled() + expect(result.current.data).toBeUndefined() + }) + + it('calls trackException when the query fails', async () => { + mockGetMPTIssuance.mockRejectedValueOnce(new Error('boom')) + + const { result } = renderHook(() => useMPTIssuance(MPT_ID), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(trackException).toHaveBeenCalledTimes(1) + const message = trackException.mock.calls[0][0] as string + expect(message).toContain(`Error fetching mptIssuanceID metadata ${MPT_ID}`) + }) +}) diff --git a/src/containers/shared/hooks/useMPTIssuance.ts b/src/containers/shared/hooks/useMPTIssuance.ts new file mode 100644 index 000000000..d6e5fdcad --- /dev/null +++ b/src/containers/shared/hooks/useMPTIssuance.ts @@ -0,0 +1,49 @@ +import { useContext } from 'react' +import { useQuery } from 'react-query' +import SocketContext from '../SocketContext' +import { getMPTIssuance } from '../../../rippled/lib/rippled' +import { formatMPTIssuance } from '../../../rippled/lib/utils' +import { FormattedMPTIssuance } from '../Interfaces' +import { useAnalytics } from '../analytics' + +/** + * Fetches and caches MPT issuance data for a given MPT ID. + * Returns the formatted issuance data including parsed metadata (ticker, name, etc.). + * + * @param mptID - The MPT issuance ID, or null/undefined if not applicable. + * @param enabled - Whether to enable the query (default: true when mptID is truthy). + */ +export const useMPTIssuance = ( + mptID: string | null | undefined, + enabled = true, +) => { + const rippledSocket = useContext(SocketContext) + const { trackException } = useAnalytics() + + return useQuery( + ['getMPTIssuance', mptID], + async () => { + const info = await getMPTIssuance(rippledSocket, mptID!) + return formatMPTIssuance(info.node) + }, + { + enabled: enabled && !!mptID, + onError: (e: any) => { + trackException( + `Error fetching mptIssuanceID metadata ${mptID} --- ${JSON.stringify(e)}`, + ) + }, + }, + ) +} + +/** + * Returns the display name for an MPT: ticker symbol if available, otherwise the MPT ID. + */ +export const getMPTDisplayName = ( + mptID: string, + parsedMPTMetadata?: Record, +): string => { + const ticker = parsedMPTMetadata?.ticker as string | undefined + return ticker || mptID +} diff --git a/src/containers/shared/metaParser.tsx b/src/containers/shared/metaParser.tsx index f071e29c3..946ccd0e3 100644 --- a/src/containers/shared/metaParser.tsx +++ b/src/containers/shared/metaParser.tsx @@ -5,6 +5,7 @@ import { ExplorerAmount } from './types' export const LedgerEntryTypes = { AccountRoot: 'AccountRoot', RippledState: 'RippleState', + MPToken: 'MPToken', AMM: 'AMM', } // TODO: fix fee logic - filter out the fee only nodes - make sure fee isnt included in xrp deposits/withdraws @@ -59,9 +60,14 @@ changes. */ export function findAssetAmount( meta: any, - asset: { currency: string; issuer?: string }, + asset: { currency?: string; issuer?: string; mpt_issuance_id?: string }, tx: BaseTransaction, ): ExplorerAmount | undefined { + // Handle MPT assets + if (asset.mpt_issuance_id) { + return findMPTAmount(meta, asset.mpt_issuance_id) + } + if (asset.currency === 'XRP') return findXRPAmount(meta, tx) const assetNode = findNodeWithAsset( @@ -78,7 +84,7 @@ export function findAssetAmount( : Number(assetNode?.NewFields?.Balance) return amount - ? { currency: asset.currency, issuer: asset.issuer, amount } + ? { currency: asset.currency!, issuer: asset.issuer, amount } : undefined } @@ -89,6 +95,33 @@ export function findAssetAmount( i.e. if we deposit into the amm, the amm balance will go up by the same amount that the account balance decreases, therefore it doesnt matter which node we use. */ +function findMPTAmount( + meta: any, + mptIssuanceId: string, +): ExplorerAmount | undefined { + const mptNodes = findNodes(meta, LedgerEntryTypes.MPToken).filter( + (n: any) => + (n.FinalFields?.MPTokenIssuanceID || n.NewFields?.MPTokenIssuanceID) === + mptIssuanceId, + ) + + if (mptNodes.length === 0) return undefined + + const node = mptNodes[0] + // MPTAmount can be up to 2^63 - 1, beyond Number.MAX_SAFE_INTEGER, + // so parse and subtract with BigInt to preserve precision. + const delta = + node.FinalFields?.MPTAmount != null + ? BigInt(node.FinalFields.MPTAmount) - + BigInt(node.PreviousFields?.MPTAmount ?? 0) + : BigInt(node.NewFields?.MPTAmount ?? 0) + const amount = (delta < 0n ? -delta : delta).toString() + + return amount !== '0' + ? { currency: mptIssuanceId, amount, isMPT: true } + : undefined +} + function findXRPAmount( meta: any, tx: BaseTransaction, @@ -117,7 +150,7 @@ function findXRPAmount( export function findNodeWithAsset( meta: any, entryType: string, - asset: { currency: string; issuer?: string; amount?: number }, + asset: { currency?: string; issuer?: string; amount?: number }, ) { return findNodes(meta, entryType)?.filter( (n: any) => diff --git a/src/containers/shared/test/metaParser.test.ts b/src/containers/shared/test/metaParser.test.ts new file mode 100644 index 000000000..c4aa3aa66 --- /dev/null +++ b/src/containers/shared/test/metaParser.test.ts @@ -0,0 +1,362 @@ +import { findAssetAmount, findNodes, LedgerEntryTypes } from '../metaParser' + +const TEST_MPT_ID = '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F' + +describe('findAssetAmount', () => { + const baseTx = { Account: 'rTestAccount', Fee: '12' } as any + + it('finds XRP amount from AccountRoot', () => { + const meta = { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'AccountRoot', + LedgerIndex: 'ABC123', + FinalFields: { + Account: 'rTestAccount', + Balance: '990000000', + }, + PreviousFields: { + Balance: '1000000000', + }, + }, + }, + ], + } + const result = findAssetAmount(meta, { currency: 'XRP' }, baseTx) + expect(result).toBeDefined() + expect(result!.currency).toBe('XRP') + // Balance change is 10000000, fee is 12, so amount = (10000000 - 12) / 1000000 + expect(result!.amount).toBeGreaterThan(0) + }) + + it('finds IOU amount from RippleState', () => { + const meta = { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'RippleState', + LedgerIndex: 'DEF456', + FinalFields: { + Balance: { + currency: 'USD', + issuer: 'rrrrrrrrrrrrrrrrrrrrBZbvji', + value: '-990000', + }, + }, + PreviousFields: { + Balance: { + currency: 'USD', + issuer: 'rrrrrrrrrrrrrrrrrrrrBZbvji', + value: '-1000000', + }, + }, + }, + }, + ], + } + const result = findAssetAmount( + meta, + { currency: 'USD', issuer: 'rIssuer' }, + baseTx, + ) + expect(result).toBeDefined() + expect(result!.currency).toBe('USD') + expect(result!.amount).toBe(10000) + }) + + it('finds MPT amount from MPToken', () => { + const meta = { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'GHI789', + FinalFields: { + Account: 'rTestAccount', + MPTokenIssuanceID: TEST_MPT_ID, + MPTAmount: '990000', + }, + PreviousFields: { + MPTAmount: '1000000', + }, + }, + }, + ], + } + const result = findAssetAmount( + meta, + { + mpt_issuance_id: TEST_MPT_ID, + }, + baseTx, + ) + expect(result).toBeDefined() + expect(result!.currency).toBe(TEST_MPT_ID) + expect(result!.amount).toBe('10000') + expect(result!.isMPT).toBe(true) + }) + + it('returns undefined for MPT when no matching MPToken node exists', () => { + const meta = { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'AccountRoot', + LedgerIndex: 'ABC123', + FinalFields: { + Account: 'rTestAccount', + Balance: '999999988', + }, + PreviousFields: { Balance: '1000000000' }, + }, + }, + ], + } + const result = findAssetAmount( + meta, + { + mpt_issuance_id: TEST_MPT_ID, + }, + baseTx, + ) + expect(result).toBeUndefined() + }) + + it('finds MPT amount from newly created MPToken', () => { + const meta = { + AffectedNodes: [ + { + CreatedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'JKL012', + NewFields: { + Account: 'rTestAccount', + MPTokenIssuanceID: TEST_MPT_ID, + MPTAmount: '5000', + }, + }, + }, + ], + } + const result = findAssetAmount( + meta, + { + mpt_issuance_id: TEST_MPT_ID, + }, + baseTx, + ) + expect(result).toBeDefined() + expect(result!.amount).toBe('5000') + expect(result!.isMPT).toBe(true) + }) + + it('returns the absolute value when MPTAmount increased', () => { + const meta = { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'INC1', + FinalFields: { + Account: 'rTestAccount', + MPTokenIssuanceID: TEST_MPT_ID, + MPTAmount: '1500', + }, + PreviousFields: { + MPTAmount: '1000', + }, + }, + }, + ], + } + const result = findAssetAmount( + meta, + { mpt_issuance_id: TEST_MPT_ID }, + baseTx, + ) + expect(result).toBeDefined() + expect(result!.amount).toBe('500') + expect(result!.isMPT).toBe(true) + }) + + it('returns undefined when MPTAmount did not change (zero delta)', () => { + const meta = { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'ZERO1', + FinalFields: { + Account: 'rTestAccount', + MPTokenIssuanceID: TEST_MPT_ID, + MPTAmount: '1000', + }, + PreviousFields: { + MPTAmount: '1000', + }, + }, + }, + ], + } + const result = findAssetAmount( + meta, + { mpt_issuance_id: TEST_MPT_ID }, + baseTx, + ) + expect(result).toBeUndefined() + }) + + it('treats missing PreviousFields.MPTAmount as zero', () => { + const meta = { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'NOPREV1', + FinalFields: { + Account: 'rTestAccount', + MPTokenIssuanceID: TEST_MPT_ID, + MPTAmount: '250', + }, + PreviousFields: {}, + }, + }, + ], + } + const result = findAssetAmount( + meta, + { mpt_issuance_id: TEST_MPT_ID }, + baseTx, + ) + expect(result).toBeDefined() + expect(result!.amount).toBe('250') + }) + + it('picks only MPToken nodes matching the requested issuance id', () => { + const OTHER_MPT_ID = '000099999999999999999999999999999999999999999999' + const meta = { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'OTHER1', + FinalFields: { + Account: 'rTestAccount', + MPTokenIssuanceID: OTHER_MPT_ID, + MPTAmount: '777', + }, + PreviousFields: { + MPTAmount: '0', + }, + }, + }, + { + ModifiedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'TARGET1', + FinalFields: { + Account: 'rTestAccount', + MPTokenIssuanceID: TEST_MPT_ID, + MPTAmount: '300', + }, + PreviousFields: { + MPTAmount: '100', + }, + }, + }, + ], + } + const result = findAssetAmount( + meta, + { mpt_issuance_id: TEST_MPT_ID }, + baseTx, + ) + expect(result).toBeDefined() + expect(result!.currency).toBe(TEST_MPT_ID) + expect(result!.amount).toBe('200') + }) + + it('returns undefined for a newly created MPToken with no MPTAmount', () => { + const meta = { + AffectedNodes: [ + { + CreatedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'NEW_NOAMT', + NewFields: { + Account: 'rTestAccount', + MPTokenIssuanceID: TEST_MPT_ID, + }, + }, + }, + ], + } + const result = findAssetAmount( + meta, + { mpt_issuance_id: TEST_MPT_ID }, + baseTx, + ) + expect(result).toBeUndefined() + }) + + it('preserves precision for MPT amounts beyond Number.MAX_SAFE_INTEGER', () => { + // MPTAmount can be up to 2^63 - 1 = 9223372036854775807, + // which exceeds Number.MAX_SAFE_INTEGER (2^53 - 1 = 9007199254740991). + const finalAmount = '9223372036854775807' // 2^63 - 1 + const previousAmount = '9223372036854775000' + const expectedDelta = '807' + + const meta = { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'LARGE1', + FinalFields: { + Account: 'rTestAccount', + MPTokenIssuanceID: TEST_MPT_ID, + MPTAmount: finalAmount, + }, + PreviousFields: { + MPTAmount: previousAmount, + }, + }, + }, + ], + } + const result = findAssetAmount( + meta, + { mpt_issuance_id: TEST_MPT_ID }, + baseTx, + ) + expect(result).toBeDefined() + expect(result!.amount).toBe(expectedDelta) + }) +}) + +describe('findNodes', () => { + it('finds MPToken nodes', () => { + const meta = { + AffectedNodes: [ + { + ModifiedNode: { + LedgerEntryType: 'MPToken', + LedgerIndex: 'ABC', + FinalFields: { MPTAmount: '100' }, + }, + }, + { + ModifiedNode: { + LedgerEntryType: 'AccountRoot', + LedgerIndex: 'DEF', + FinalFields: { Balance: '1000' }, + }, + }, + ], + } + const nodes = findNodes(meta, LedgerEntryTypes.MPToken) + expect(nodes).toHaveLength(1) + expect(nodes[0].FinalFields.MPTAmount).toBe('100') + }) +}) diff --git a/src/containers/shared/test/utils.test.ts b/src/containers/shared/test/utils.test.ts index 1227abc40..b06a3e47d 100644 --- a/src/containers/shared/test/utils.test.ts +++ b/src/containers/shared/test/utils.test.ts @@ -7,7 +7,6 @@ import { localizeDate, durationToHuman, formatDurationDetailed, - formatAsset, shortenAccount, shortenDomain, shortenNFTTokenID, @@ -94,6 +93,25 @@ describe('utils', () => { ).toEqual('12.233400') }) + it('localizeNumber preserves precision for MPT-flagged string values', () => { + // 2^63 - 1 — the max representable MPTAmount per the XRPL spec. Without + // the BigInt branch this would round to "9,223,372,036,854,776,000". + expect(localizeNumber('9223372036854775807', 'en-US', {}, true)).toEqual( + '9,223,372,036,854,775,807', + ) + // Scaled MPTAmount — integer part is formatted via BigInt, fractional + // digits are appended verbatim. + expect(localizeNumber('9223372036854.775807', 'en-US', {}, true)).toEqual( + '9,223,372,036,854.775807', + ) + // Negative MPT-shaped string (defensive — unusual in practice). + expect(localizeNumber('-9223372036854775807', 'en-US', {}, true)).toEqual( + '-9,223,372,036,854,775,807', + ) + // Small values still work through the same path. + expect(localizeNumber('1000', 'en-US', {}, true)).toEqual('1,000') + }) + it('formatPrice', () => { expect(formatPrice(22.35)).toEqual('$22.35') expect( @@ -191,22 +209,6 @@ describe('utils', () => { }) }) -describe('AMM utils format asset', () => { - it('formats XRP asset', () => { - const asset = '10000000000' - const formatted = formatAsset(asset) - - expect(formatted).toEqual({ currency: 'XRP' }) - }) - - it('formats non XRP asset', () => { - const asset = { currency: 'USD', amount: '100000', issuer: 'your mom' } - const formatted = formatAsset(asset) - - expect(formatted).toEqual({ currency: 'USD', issuer: 'your mom' }) - }) -}) - describe('Shorten utils', () => { describe('shortenAccount', () => { it('shortens long account addresses', () => { diff --git a/src/containers/shared/utils.js b/src/containers/shared/utils.js index df507a416..2618e2fb6 100644 --- a/src/containers/shared/utils.js +++ b/src/containers/shared/utils.js @@ -167,9 +167,42 @@ export const localizeNumber = ( options = {}, isMPT = false, ) => { - const number = Number.parseFloat(num) const config = { ...NUMBER_DEFAULT_OPTIONS, ...options } + // MPT amounts can exceed Number.MAX_SAFE_INTEGER (up to 2^63 - 1) and their + // scaled forms can keep many fractional digits. Group the integer part via + // BigInt and append the fraction so no digits are lost in Number.parseFloat. + if (isMPT && typeof num === 'string' && /^-?\d+(\.\d+)?$/.test(num)) { + const [intPart, rawFrac = ''] = num.split('.') + const minFrac = config.minimumFractionDigits ?? 0 + const maxFrac = config.maximumFractionDigits ?? rawFrac.length + let fracPart = rawFrac.slice(0, maxFrac) + // Trim trailing zeros down to minFrac so '0.100' renders as '0.1'. + let end = fracPart.length + while (end > minFrac && fracPart[end - 1] === '0') end -= 1 + fracPart = fracPart.slice(0, end) + if (fracPart.length < minFrac) fracPart = fracPart.padEnd(minFrac, '0') + + const sign = intPart.startsWith('-') ? '-' : '' + const absInt = sign ? intPart.slice(1) : intPart + // Intl.NumberFormat validates `currency` regardless of `style`, and for + // MPT amounts the "currency" is an mpt_issuance_id (48-hex), which is + // not a valid ISO code — strip it before formatting. + const groupedConfig = { + ...config, + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + } + delete groupedConfig.currency + const grouped = new Intl.NumberFormat(lang, groupedConfig).format( + BigInt(absInt), + ) + return fracPart ? `${sign}${grouped}.${fracPart}` : `${sign}${grouped}` + } + + const number = Number.parseFloat(num) + if (Number.isNaN(number)) { return null } @@ -436,14 +469,6 @@ export const formatDurationDetailed = (totalSeconds, maxUnits = 4) => { export const removeRoutes = (routes, ...routesToRemove) => routes.filter((route) => !routesToRemove.includes(route.title)) -export const formatAsset = (asset) => - typeof asset === 'string' - ? { currency: 'XRP' } - : { - currency: asset.currency, - issuer: asset.issuer, - } - // For AMM, the trading fee is in units of 1/100,000; a value of 1 is equivalent to a 0.001% fee. export const formatTradingFee = (tradingFee) => tradingFee !== undefined diff --git a/src/rippled/lib/txSummary/formatAmount.test.ts b/src/rippled/lib/txSummary/formatAmount.test.ts new file mode 100644 index 000000000..970f564d4 --- /dev/null +++ b/src/rippled/lib/txSummary/formatAmount.test.ts @@ -0,0 +1,204 @@ +import { + formatAmount, + isMPTAmount, + formatAsset, + formatAmountWithAsset, +} from './formatAmount' + +const TEST_MPT_ID = '000003C31D321B7DDA58324DC38CDF18934FAFFFCDF69D5F' + +// MPTAmount upper bound from the XRPL spec: 2^63 - 1. +const MAX_MPT_VALUE = '9223372036854775807' + +// IOU amounts (per ripple-binary-codec) are bounded by a 16-digit mantissa and +// an exponent in [-96, 80]. These are representative near-limit values. +const MAX_IOU_VALUE_STR = '9.999999999999999e+80' +const MIN_POSITIVE_IOU_VALUE_STR = '1e-96' + +describe('formatAmount', () => { + it('formats XRP string amount', () => { + const result = formatAmount('24755081083') + expect(result).toEqual({ currency: 'XRP', amount: 24755.081083 }) + }) + + it('formats IOU amount', () => { + const result = formatAmount({ + currency: 'USD', + issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + value: '100.5', + }) + expect(result).toEqual({ + currency: 'USD', + issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + amount: 100.5, + }) + }) + + it('formats MPTAmount', () => { + const result = formatAmount({ + mpt_issuance_id: TEST_MPT_ID, + value: '1000', + }) + expect(result).toEqual({ + currency: TEST_MPT_ID, + amount: '1000', + isMPT: true, + }) + }) + + it('preserves precision for MPTAmount at the spec maximum (2^63 - 1)', () => { + const result = formatAmount({ + mpt_issuance_id: TEST_MPT_ID, + value: MAX_MPT_VALUE, + }) + expect(result).toEqual({ + currency: TEST_MPT_ID, + amount: MAX_MPT_VALUE, + isMPT: true, + }) + // Confirm the digits survived round-trip and weren't coerced through Number. + expect(typeof result!.amount).toBe('string') + expect(BigInt(result!.amount as string)).toBe(BigInt(MAX_MPT_VALUE)) + }) + + it('formats IOU near the spec maximum value', () => { + const result = formatAmount({ + currency: 'USD', + issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + value: MAX_IOU_VALUE_STR, + }) + // IOU amounts intentionally go through Number; the 16-digit mantissa fits + // within JS Number's 15-17 significant-digit range, so the literal survives + // unchanged round-tripping through parseFloat. + expect(result!.currency).toBe('USD') + expect(result!.amount).toBe(parseFloat(MAX_IOU_VALUE_STR)) + expect(Number.isFinite(result!.amount as number)).toBe(true) + }) + + it('formats IOU at the spec minimum positive value', () => { + const result = formatAmount({ + currency: 'USD', + issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + value: MIN_POSITIVE_IOU_VALUE_STR, + }) + expect(result!.amount).toBe(parseFloat(MIN_POSITIVE_IOU_VALUE_STR)) + expect(result!.amount).toBeGreaterThan(0) + }) + + it('handles null and undefined', () => { + expect(formatAmount(null as any)).toBeNull() + expect(formatAmount(undefined as any)).toBeUndefined() + }) +}) + +describe('isMPTAmount', () => { + it('returns true for MPTAmount', () => { + expect( + isMPTAmount({ + mpt_issuance_id: TEST_MPT_ID, + value: '100', + }), + ).toBe(true) + }) + + it('returns false for XRP string', () => { + expect(isMPTAmount('12345' as any)).toBe(false) + }) + + it('returns false for IOU', () => { + expect( + isMPTAmount({ + currency: 'USD', + issuer: 'rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B', + value: '100', + } as any), + ).toBe(false) + }) + + it('returns false for MPT asset without value', () => { + expect( + isMPTAmount({ + mpt_issuance_id: TEST_MPT_ID, + } as any), + ).toBe(false) + }) +}) + +describe('formatAsset', () => { + it('formats XRP string', () => { + expect(formatAsset('XRP')).toEqual({ currency: 'XRP' }) + }) + + it('formats IOU asset', () => { + expect(formatAsset({ currency: 'USD', issuer: 'rXXX' })).toEqual({ + currency: 'USD', + issuer: 'rXXX', + }) + }) + + it('formats MPT asset', () => { + const result = formatAsset({ + mpt_issuance_id: TEST_MPT_ID, + }) + expect(result).toEqual({ + currency: TEST_MPT_ID, + mpt_issuance_id: TEST_MPT_ID, + isMPT: true, + }) + }) +}) + +describe('formatAmountWithAsset', () => { + it('formats XRP amount with asset', () => { + expect(formatAmountWithAsset(1000000, { currency: 'XRP' })).toEqual({ + currency: 'XRP', + amount: 1, + }) + }) + + it('formats MPT amount with asset (preserves string)', () => { + const result = formatAmountWithAsset('500', { + currency: TEST_MPT_ID, + isMPT: true, + }) + expect(result).toEqual({ + currency: TEST_MPT_ID, + amount: '500', + isMPT: true, + }) + }) + + it('preserves MPT precision for value at the spec maximum (2^63 - 1)', () => { + const result = formatAmountWithAsset(MAX_MPT_VALUE, { + currency: TEST_MPT_ID, + isMPT: true, + }) + expect(result).toEqual({ + currency: TEST_MPT_ID, + amount: MAX_MPT_VALUE, + isMPT: true, + }) + expect(BigInt(result!.amount as string)).toBe(BigInt(MAX_MPT_VALUE)) + }) + + it('preserves MPT precision when raw amount is given as a numeric input', () => { + // Some upstream callers may pass `amount` as a number for small values. + // We still want the result to be a string so the downstream Amount + // component handles it via BigInt without coercion. + const result = formatAmountWithAsset(1234567890, { + currency: TEST_MPT_ID, + isMPT: true, + }) + expect(result).toEqual({ + currency: TEST_MPT_ID, + amount: '1234567890', + isMPT: true, + }) + }) + + it('returns undefined for null amount', () => { + expect( + formatAmountWithAsset(null as any, { currency: 'XRP' }), + ).toBeUndefined() + }) +}) diff --git a/src/rippled/lib/txSummary/formatAmount.ts b/src/rippled/lib/txSummary/formatAmount.ts index 2839a3c7b..05fecc324 100644 --- a/src/rippled/lib/txSummary/formatAmount.ts +++ b/src/rippled/lib/txSummary/formatAmount.ts @@ -84,6 +84,17 @@ export function formatAmountWithAsset(amount: string | number, asset: Asset) { return undefined } + // MPTAmount values can be up to 2^63 - 1, beyond Number.MAX_SAFE_INTEGER. + // Keep them as strings so downstream consumers (e.g. via BigInt) + // can preserve precision. + if (asset.isMPT) { + return { + currency: asset.currency, + amount: typeof amount === 'string' ? amount : String(amount), + isMPT: true, + } + } + const numericAmount = typeof amount === 'string' ? parseFloat(amount) : amount if (asset.currency === 'XRP') { @@ -93,14 +104,6 @@ export function formatAmountWithAsset(amount: string | number, asset: Asset) { } } - if (asset.isMPT) { - return { - currency: asset.currency, - amount: numericAmount, - isMPT: true, - } - } - return { currency: asset.currency, issuer: asset.issuer!, diff --git a/src/rippled/offers.ts b/src/rippled/offers.ts index 41d7ca08e..88ab90347 100644 --- a/src/rippled/offers.ts +++ b/src/rippled/offers.ts @@ -35,8 +35,8 @@ const getBookOffers = async ( let highestExchangeRate = 0 let lowestExchangeRate = Number.MAX_VALUE for (const offer of offers) { - const takerPays = offer.TakerPays.value || offer.TakerPays - const takerGets = offer.TakerGets.value || offer.TakerGets + const takerPays = Number(offer.TakerPays.value || offer.TakerPays) + const takerGets = Number(offer.TakerGets.value || offer.TakerGets) const rate = takerPays / takerGets if (rate > highestExchangeRate) { highestExchangeRate = rate