From 2d83fc6c6fcbeb27be6967e20a5c41c23839f83f Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Tue, 8 Sep 2026 13:50:25 -0700 Subject: [PATCH 1/6] Add MPT ticker/issuer search alongside IOU search Split the token search dropdown into separate IOU and MPT sections, keyed off the new token_type/mpt_issuance_id fields LOS will return once DGE-6575 (ledger-object-service#131) lands. --- public/locales/en-US/translations.json | 1 + .../TokenSearchResults/MPTSearchRow.tsx | 123 ++++++++++++++++++ .../TokenSearchResults/TokenSearchResults.tsx | 61 ++++++--- .../test/TokenSearchResults.test.js | 26 ++++ .../test/mock_data/tokens.json | 12 ++ src/containers/shared/losTypes.ts | 4 +- 6 files changed, 210 insertions(+), 17 deletions(-) create mode 100644 src/containers/shared/components/TokenSearchResults/MPTSearchRow.tsx diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 8c55a6a7f..a9472bbbf 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -422,6 +422,7 @@ "ticket_used": "a Ticket was used for this Transaction", "token": "Token", "tokens": "Tokens", + "mpts": "MPTs", "total_issuers": "Total Issuers", "total_tokens": "Total Tokens", "top_trading_pairs": "Top Trading Pairs", diff --git a/src/containers/shared/components/TokenSearchResults/MPTSearchRow.tsx b/src/containers/shared/components/TokenSearchResults/MPTSearchRow.tsx new file mode 100644 index 000000000..7dc350f0c --- /dev/null +++ b/src/containers/shared/components/TokenSearchResults/MPTSearchRow.tsx @@ -0,0 +1,123 @@ +import { Link } from 'react-router' +import { useTranslation } from 'react-i18next' +import { FC } from 'react' +import { Amount } from '../Amount' +import { localizeNumber } from '../../utils' +import Currency from '../Currency' +import DomainLink from '../DomainLink' +import { LOSToken } from '../../losTypes' + +const parsePrice = (dollarPrice: string, xrpPrice: number): number => { + const parsedDollar = Number(dollarPrice) + return Number((parsedDollar * xrpPrice).toFixed(6)) +} + +const DEFAULT_VALUE = '--' + +const TokenLogo: FC<{ token: LOSToken }> = ({ token }) => + token && token.icon ? ( + +
+
+ ) : ( +
+ ) + +const TokenName: FC<{ token: LOSToken }> = ({ token }) => + token && token.name ? ( +
+ ({token.name.trim().toUpperCase().replace('(', '').replace(')', '')}) +
+ ) : null + +const IssuerAddress: FC<{ token: LOSToken; onClick: any }> = ({ + token, + onClick, +}) => + token && token.issuer_account ? ( + +
+ {token.issuer_name && `${token.issuer_name} (`} +
+
{token.issuer_account}
+ {token.issuer_name &&
)
} + + ) : null + +interface SearchResultRowProps { + token: LOSToken + onClick: () => void + xrpPrice: number +} + +export const MPTSearchRow = ({ + token, + onClick, + xrpPrice, +}: SearchResultRowProps): JSX.Element => { + const { t } = useTranslation() + + return ( + +
+
+ +
+
+ +
+
+ +
+
+ {token.price ? ( + + ) : ( +
{DEFAULT_VALUE}
+ )} +
+
+ {t('holders_count', { + holders: localizeNumber(token.holders), + })} +
+
+
+
{t('issuer')}:
+ +
+
+ {token.issuer_domain && ( + <> +
{t('website')}:
+
+ +
+ + )} +
+ + ) +} diff --git a/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx b/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx index fb5456de0..6cabac906 100644 --- a/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx +++ b/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx @@ -6,6 +6,7 @@ import axios from 'axios' import { useQuery } from 'react-query' import { useAnalytics } from '../../analytics' import { TokenSearchRow } from './TokenSearchRow' +import { MPTSearchRow } from './MPTSearchRow' import SocketContext from '../../SocketContext' import Log from '../../log' import { getAccountLines } from '../../../../rippled/lib/rippled' @@ -66,9 +67,9 @@ const SearchResults = ({ .then((response) => response.data.tokens) } - const onLinkClick = () => { + const onLinkClick = (searchCategory: 'token' | 'mpt') => () => { analytics.track('token_search_click', { - search_category: 'token', + search_category: searchCategory, search_term: currentSearchValue, }) @@ -76,22 +77,50 @@ const SearchResults = ({ setCurrentSearchInput('') } - return tokens.length > 0 ? ( + const iouTokens = tokens.filter((token) => token.token_type !== 'MPT') + const mptTokens = tokens.filter((token) => token.token_type === 'MPT') + + if (tokens.length === 0) { + return null + } + + return (
-
- {t('tokens')} ({tokens.length}) -
- - {tokens.map((token) => ( - - ))} + {iouTokens.length > 0 && ( + <> +
+ {t('tokens')} ({iouTokens.length}) +
+ + {iouTokens.map((token) => ( + + ))} + + )} + + {mptTokens.length > 0 && ( + <> +
+ {t('mpts')} ({mptTokens.length}) +
+ + {mptTokens.map((token) => ( + + ))} + + )}
- ) : null + ) } export default SearchResults diff --git a/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js b/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js index 1c136b4ae..1c390ecdb 100644 --- a/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js +++ b/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js @@ -85,4 +85,30 @@ describe('Testing tokens search', () => { `sologenic.com`, ) }) + + it('renders mpts in a separate section from tokens', async () => { + const { container } = renderSearchResults() + await flushPromises() + + await waitFor(() => { + expect( + container.querySelectorAll('.search-results-header').length, + ).toEqual(2) + }) + + const headers = container.querySelectorAll('.search-results-header') + expect(headers[0].outerHTML).toBe( + `
tokens (1)
`, + ) + expect(headers[1].outerHTML).toBe( + `
mpts (1)
`, + ) + + const rows = container.querySelectorAll('.search-result-row') + const mptRow = rows[1] + expect(mptRow.getAttribute('href')).toBe( + '/mpt/00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8', + ) + expect(mptRow.querySelectorAll('.metric-chip').length).toEqual(2) + }) }) diff --git a/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens.json b/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens.json index 8a046b454..f3ea3313d 100644 --- a/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens.json +++ b/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens.json @@ -20,6 +20,18 @@ "trustlines": 283772, "trust_level": 3, "price": "0.110990003788651" + }, + { + "token_type": "MPT", + "issuer_name": "Example Issuer", + "currency": "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8", + "mpt_issuance_id": "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8", + "icon": "https://s1.xrplmeta.org/icon/mpt-example.png", + "name": "Example MPT", + "issuer_account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "holders": 10, + "issuer_domain": "example.com", + "price": "1.5" } ] } diff --git a/src/containers/shared/losTypes.ts b/src/containers/shared/losTypes.ts index 4486b1ff5..4a717fc85 100644 --- a/src/containers/shared/losTypes.ts +++ b/src/containers/shared/losTypes.ts @@ -13,8 +13,10 @@ export interface LOSToken { icon?: string ttl?: number social_links?: SocialLink[] - trustlines: number + trustlines?: number transfer_fee?: number + token_type?: 'IOU' | 'MPT' + mpt_issuance_id?: string issuer_domain?: string issuer_name?: string tvl_xrp?: number From ad5fb7bb59102cb42ada5fcebe18550d1a803195 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Tue, 15 Sep 2026 12:58:06 -0700 Subject: [PATCH 2/6] Sort MPT and IOU search results by holder count Search results within each group were rendered in whatever order the API returned; sort by holders descending so more widely-held tokens surface first. --- .../TokenSearchResults/TokenSearchResults.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx b/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx index 6cabac906..75e640816 100644 --- a/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx +++ b/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx @@ -77,8 +77,15 @@ const SearchResults = ({ setCurrentSearchInput('') } - const iouTokens = tokens.filter((token) => token.token_type !== 'MPT') - const mptTokens = tokens.filter((token) => token.token_type === 'MPT') + const byHoldersDesc = (a: LOSToken, b: LOSToken) => + (b.holders ?? 0) - (a.holders ?? 0) + + const iouTokens = tokens + .filter((token) => token.token_type !== 'MPT') + .sort(byHoldersDesc) + const mptTokens = tokens + .filter((token) => token.token_type === 'MPT') + .sort(byHoldersDesc) if (tokens.length === 0) { return null From c6885b9ede633bcd8139e57cd4e8f8f54bdfcad9 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Tue, 15 Sep 2026 13:41:27 -0700 Subject: [PATCH 3/6] Add mock data and test to preview holder-count sorting Adds a mixed IOU/MPT dataset with varying holder counts and a test verifying both groups render in holder-count descending order. --- .../test/TokenSearchResults.test.js | 42 ++++++++++++-- .../test/mock_data/tokens_sorted.json | 58 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 src/containers/shared/components/TokenSearchResults/test/mock_data/tokens_sorted.json diff --git a/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js b/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js index 1c390ecdb..cff416779 100644 --- a/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js +++ b/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js @@ -2,6 +2,7 @@ import { render, cleanup, waitFor } from '@testing-library/react' import moxios from 'moxios' import i18n from '../../../../../i18n/testConfig' import testTokens from './mock_data/tokens.json' +import sortedTestTokens from './mock_data/tokens_sorted.json' import SocketContext from '../../../SocketContext' import SearchResults from '../TokenSearchResults' import MockWsClient from '../../../../test/mockWsClient' @@ -12,17 +13,17 @@ const testQuery = 'test' describe('Testing tokens search', () => { let client - const renderSearchResults = () => { - const searchURL = `/api/v1/tokens/search/${testQuery}` + const renderSearchResults = (query = testQuery, response = testTokens) => { + const searchURL = `/api/v1/tokens/search/${query}` moxios.stubRequest(searchURL, { status: 200, - response: testTokens, + response, }) return render( @@ -111,4 +112,37 @@ describe('Testing tokens search', () => { ) expect(mptRow.querySelectorAll('.metric-chip').length).toEqual(2) }) + + it('sorts tokens and mpts by holder count descending', async () => { + const { container } = renderSearchResults('sort-test', sortedTestTokens) + await flushPromises() + + await waitFor(() => { + expect( + container.querySelectorAll('.search-results-header').length, + ).toEqual(2) + }) + + const headers = container.querySelectorAll('.search-results-header') + expect(headers[0].outerHTML).toBe( + `
tokens (3)
`, + ) + expect(headers[1].outerHTML).toBe( + `
mpts (3)
`, + ) + + // tokens: SOLO (218547) > MID (4200) > LOW (5) + // mpts: Big MPT (9000) > Example MPT (10) > Small MPT (3) + const issuerNames = Array.from( + container.querySelectorAll('.issuer-name'), + ).map((el) => el.textContent) + expect(issuerNames).toEqual([ + 'Sologenic (', + 'Mid Holder Token (', + 'Low Holder Token (', + 'Big MPT Issuer (', + 'Example Issuer (', + 'Small MPT Issuer (', + ]) + }) }) diff --git a/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens_sorted.json b/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens_sorted.json new file mode 100644 index 000000000..e4245c3a2 --- /dev/null +++ b/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens_sorted.json @@ -0,0 +1,58 @@ +{ + "tokens": [ + { + "issuer_name": "Low Holder Token", + "currency": "4C4F5700000000000000000000000000000000", + "name": "LOW", + "issuer_account": "rLowHolderIssuer0000000000000000000", + "holders": 5, + "price": "0.01" + }, + { + "issuer_name": "Sologenic", + "currency": "534F4C4F00000000000000000000000000000000", + "name": "SOLO", + "issuer_account": "rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz", + "holders": 218547, + "price": "0.110990003788651" + }, + { + "issuer_name": "Mid Holder Token", + "currency": "4D494400000000000000000000000000000000", + "name": "MID", + "issuer_account": "rMidHolderIssuer00000000000000000000", + "holders": 4200, + "price": "0.5" + }, + { + "token_type": "MPT", + "issuer_name": "Small MPT Issuer", + "currency": "00000002B5F762798A53D543A014CAF8B297CFF8F2F937E9", + "mpt_issuance_id": "00000002B5F762798A53D543A014CAF8B297CFF8F2F937E9", + "name": "Small MPT", + "issuer_account": "rSmallMPTIssuer00000000000000000000", + "holders": 3, + "price": "1.0" + }, + { + "token_type": "MPT", + "issuer_name": "Big MPT Issuer", + "currency": "00000003B5F762798A53D543A014CAF8B297CFF8F2F937EA", + "mpt_issuance_id": "00000003B5F762798A53D543A014CAF8B297CFF8F2F937EA", + "name": "Big MPT", + "issuer_account": "rBigMPTIssuer000000000000000000000", + "holders": 9000, + "price": "2.0" + }, + { + "token_type": "MPT", + "issuer_name": "Example Issuer", + "currency": "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8", + "mpt_issuance_id": "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8", + "name": "Example MPT", + "issuer_account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "holders": 10, + "price": "1.5" + } + ] +} From 3e29347e52f3a9397d9f91d0d5a2eab3c8b8bf2f Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Wed, 23 Sep 2026 15:04:59 -0700 Subject: [PATCH 4/6] Merge MPT search into unified IOU list backed by live XRPL Meta data MPTs no longer render in a separate section - they're fetched from the XRPL Meta v2 MPT endpoint (LOS has no MPT support yet), merged with IOUs, and sorted together by holder count. Only MPTs with at least one holder are surfaced. Search now also matches on a full product name field (distinct from the short ticker), so tokens like a "VGOLD CORE+" MPT ticked "VCORE" are findable by either. Removed an exact/prefix match ranking boost that was added and then reverted per product direction - plain holder-count ordering is preserved. Each row now also shows an IOU/MPT type indicator chip, and MPT rows use the same ticker + full-name display structure as IOUs instead of a shortened issuance ID. --- public/locales/en-US/translations.json | 1 - server/routes/v1/tokens.js | 55 +++++++- .../TokenSearchResults/MPTSearchRow.tsx | 123 ------------------ .../TokenSearchResults/TokenSearchResults.tsx | 60 +++------ .../TokenSearchResults/TokenSearchRow.tsx | 48 +++++-- .../components/TokenSearchResults/styles.scss | 7 + .../test/TokenSearchResults.test.js | 60 ++++----- .../test/mock_data/tokens_sorted.json | 74 +++++++++-- src/containers/shared/losTypes.ts | 1 + 9 files changed, 207 insertions(+), 222 deletions(-) delete mode 100644 src/containers/shared/components/TokenSearchResults/MPTSearchRow.tsx diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index a9472bbbf..8c55a6a7f 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -422,7 +422,6 @@ "ticket_used": "a Ticket was used for this Transaction", "token": "Token", "tokens": "Tokens", - "mpts": "MPTs", "total_issuers": "Total Issuers", "total_tokens": "Total Tokens", "top_trading_pairs": "Top Trading Pairs", diff --git a/server/routes/v1/tokens.js b/server/routes/v1/tokens.js index 9186e8c03..621fcf7b9 100644 --- a/server/routes/v1/tokens.js +++ b/server/routes/v1/tokens.js @@ -82,13 +82,58 @@ async function fetchTokens() { }) } +// MPTs aren't tradeable on the DEX yet, so they have no price/market cap to +// rank on or filter by. Zero-holder issuances are mostly test/abandoned +// tokens, so only ones with at least one holder are made searchable. +const MPT_MIN_HOLDERS = 0 + +function mapMPT(mpt) { + return { + token_type: 'MPT', + mpt_issuance_id: mpt.mpt_issuance_id, + currency: mpt.mpt_issuance_id, + issuer_account: mpt.issuer, + issuer_name: mpt.meta?.token?.issuer_name ?? mpt.meta?.issuer?.name, + issuer_domain: mpt.meta?.issuer?.domain, + // `name` stays the short ticker for display (matches the IOU convention + // of a short code shown next to the currency), but the fuller product + // name (e.g. "Car Parts" for a token ticked "SCPO") is kept separately + // so it's still searchable even though it's never the display name. + name: mpt.meta?.token?.ticker ?? mpt.meta?.token?.name, + full_name: mpt.meta?.token?.name, + icon: mpt.meta?.token?.icon, + holders: mpt.metrics?.holders, + } +} + +async function fetchMPTs() { + const url = `https://${process.env.XRPL_META_URL}/v2/tokens/mpt?limit=1000` + log.info(`Fetching MPTs from: ${url}`) + + return axios + .get(url, { timeout: 30000 }) + .then((resp) => { + const mpts = resp.data?.tokens || [] + log.info(`Successfully fetched MPTs, count: ${mpts.length}`) + return mpts + .filter((mpt) => (mpt.metrics?.holders ?? 0) > MPT_MIN_HOLDERS) + .map(mapMPT) + }) + .catch((e) => { + log.error(`Failed to fetch MPTs from ${url}:`, { message: e.message }) + return [] + }) +} + async function cacheTokens() { - const losTokens = await fetchTokens() + const [losTokens, mpts] = await Promise.all([fetchTokens(), fetchMPTs()]) if (losTokens.tokens) { - log.info(`Fetched ${losTokens.tokens.length} tokens from LOS...`) + log.info( + `Fetched ${losTokens.tokens.length} tokens from LOS, ${mpts.length} MPTs from XRPL Meta...`, + ) - cachedTokenList.tokens = losTokens.tokens.sort( + cachedTokenList.tokens = [...losTokens.tokens, ...mpts].sort( (a, b) => Number(b.holders ?? 0) - Number(a.holders ?? 0), ) @@ -137,6 +182,9 @@ function queryTokens(tokenList, query) { ?.toLowerCase() .includes(sanitizedQuery) const nameMatch = token.name?.toLowerCase().includes(sanitizedQuery) + const fullNameMatch = token.full_name + ?.toLowerCase() + .includes(sanitizedQuery) const issuerNameMatch = token.issuer_name ?.toLowerCase() .includes(sanitizedQuery) @@ -148,6 +196,7 @@ function queryTokens(tokenList, query) { currencyMatch || parsedCurrencyMatch || nameMatch || + fullNameMatch || issuerNameMatch || issuerAccountStartsMatch ) diff --git a/src/containers/shared/components/TokenSearchResults/MPTSearchRow.tsx b/src/containers/shared/components/TokenSearchResults/MPTSearchRow.tsx deleted file mode 100644 index 7dc350f0c..000000000 --- a/src/containers/shared/components/TokenSearchResults/MPTSearchRow.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Link } from 'react-router' -import { useTranslation } from 'react-i18next' -import { FC } from 'react' -import { Amount } from '../Amount' -import { localizeNumber } from '../../utils' -import Currency from '../Currency' -import DomainLink from '../DomainLink' -import { LOSToken } from '../../losTypes' - -const parsePrice = (dollarPrice: string, xrpPrice: number): number => { - const parsedDollar = Number(dollarPrice) - return Number((parsedDollar * xrpPrice).toFixed(6)) -} - -const DEFAULT_VALUE = '--' - -const TokenLogo: FC<{ token: LOSToken }> = ({ token }) => - token && token.icon ? ( - -
-
- ) : ( -
- ) - -const TokenName: FC<{ token: LOSToken }> = ({ token }) => - token && token.name ? ( -
- ({token.name.trim().toUpperCase().replace('(', '').replace(')', '')}) -
- ) : null - -const IssuerAddress: FC<{ token: LOSToken; onClick: any }> = ({ - token, - onClick, -}) => - token && token.issuer_account ? ( - -
- {token.issuer_name && `${token.issuer_name} (`} -
-
{token.issuer_account}
- {token.issuer_name &&
)
} - - ) : null - -interface SearchResultRowProps { - token: LOSToken - onClick: () => void - xrpPrice: number -} - -export const MPTSearchRow = ({ - token, - onClick, - xrpPrice, -}: SearchResultRowProps): JSX.Element => { - const { t } = useTranslation() - - return ( - -
-
- -
-
- -
-
- -
-
- {token.price ? ( - - ) : ( -
{DEFAULT_VALUE}
- )} -
-
- {t('holders_count', { - holders: localizeNumber(token.holders), - })} -
-
-
-
{t('issuer')}:
- -
-
- {token.issuer_domain && ( - <> -
{t('website')}:
-
- -
- - )} -
- - ) -} diff --git a/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx b/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx index 75e640816..46aa2a47f 100644 --- a/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx +++ b/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx @@ -6,7 +6,6 @@ import axios from 'axios' import { useQuery } from 'react-query' import { useAnalytics } from '../../analytics' import { TokenSearchRow } from './TokenSearchRow' -import { MPTSearchRow } from './MPTSearchRow' import SocketContext from '../../SocketContext' import Log from '../../log' import { getAccountLines } from '../../../../rippled/lib/rippled' @@ -77,55 +76,28 @@ const SearchResults = ({ setCurrentSearchInput('') } - const byHoldersDesc = (a: LOSToken, b: LOSToken) => - (b.holders ?? 0) - (a.holders ?? 0) - - const iouTokens = tokens - .filter((token) => token.token_type !== 'MPT') - .sort(byHoldersDesc) - const mptTokens = tokens - .filter((token) => token.token_type === 'MPT') - .sort(byHoldersDesc) - if (tokens.length === 0) { return null } return (
- {iouTokens.length > 0 && ( - <> -
- {t('tokens')} ({iouTokens.length}) -
- - {iouTokens.map((token) => ( - - ))} - - )} - - {mptTokens.length > 0 && ( - <> -
- {t('mpts')} ({mptTokens.length}) -
- - {mptTokens.map((token) => ( - - ))} - - )} +
+ {t('tokens')} ({tokens.length}) +
+ + {tokens.map((token) => ( + + ))}
) } diff --git a/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx b/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx index 3f2248a1f..9a883b76d 100644 --- a/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx +++ b/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx @@ -2,7 +2,7 @@ import { Link } from 'react-router' import { useTranslation } from 'react-i18next' import { FC } from 'react' import { Amount } from '../Amount' -import { localizeNumber } from '../../utils' +import { localizeNumber, shortenMPTID } from '../../utils' import Currency from '../Currency' import DomainLink from '../DomainLink' import { LOSToken } from '../../losTypes' @@ -23,11 +23,9 @@ const TokenLogo: FC<{ token: LOSToken }> = ({ token }) =>
) -const TokenName: FC<{ token: LOSToken }> = ({ token }) => - token && token.name ? ( -
- ({token.name.trim().toUpperCase().replace('(', '').replace(')', '')}) -
+const TokenName: FC<{ name?: string }> = ({ name }) => + name ? ( +
({name.trim().toUpperCase().replace('(', '').replace(')', '')})
) : null const IssuerAddress: FC<{ token: LOSToken; onClick: any }> = ({ @@ -60,10 +58,20 @@ export const TokenSearchRow = ({ xrpPrice, }: SearchResultRowProps): JSX.Element => { const { t } = useTranslation() + const isMPT = token.token_type === 'MPT' + + // MPTs use the same ticker + full-name structure as IOUs: the ticker as + // the primary currency label, the fuller product name in parentheses. + // `full_name` is only ever set on MPTs, so this is a no-op for IOUs. + const displayName = token.full_name ?? token.name return ( @@ -72,10 +80,20 @@ export const TokenSearchRow = ({
- + {isMPT ? ( + + {token.name ?? + shortenMPTID(token.mpt_issuance_id ?? token.currency)} + + ) : ( + + )}
- + +
+
+ {isMPT ? t('token_type.mpt') : t('token_type.iou')}
{token.price ? ( @@ -98,11 +116,13 @@ export const TokenSearchRow = ({ holders: localizeNumber(token.holders), })}
-
- {t('trustlines', { - trustlines: localizeNumber(token.trustlines), - })} -
+ {!isMPT && ( +
+ {t('trustlines', { + trustlines: localizeNumber(token.trustlines), + })} +
+ )}
{t('issuer')}:
diff --git a/src/containers/shared/components/TokenSearchResults/styles.scss b/src/containers/shared/components/TokenSearchResults/styles.scss index 75700652b..4fe4af076 100644 --- a/src/containers/shared/components/TokenSearchResults/styles.scss +++ b/src/containers/shared/components/TokenSearchResults/styles.scss @@ -115,6 +115,13 @@ } } +// Reuses the $mpt color already used for the MPT category pill on the +// token page, so IOU/MPT chips read consistently across the app. +.type-chip { + border-color: $mpt; + color: $mpt; +} + .issuer-link { display: inline-flex !important; overflow: hidden; diff --git a/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js b/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js index cff416779..adcb99364 100644 --- a/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js +++ b/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js @@ -65,8 +65,9 @@ describe('Testing tokens search', () => { const searchMenu = container.querySelector('.search-results-menu') + // SOLO (218547 holders) outranks the mock MPT (10 holders), so it's first expect(searchMenu.querySelector('.search-results-header').outerHTML).toBe( - `
tokens (1)
`, + `
tokens (2)
`, ) expect(searchMenu.querySelector('.currency').outerHTML).toBe( `SOLO`, @@ -77,32 +78,30 @@ describe('Testing tokens search', () => { expect(searchMenu.querySelector('.issuer-address').outerHTML).toBe( `
rsoLo2S1kiGeCcn6hCUXVrCpGMWLrRrLZz
`, ) + // type chip + price + holders + trustlines expect( searchMenu .querySelector('.search-result-row') .querySelectorAll('.metric-chip').length, - ).toEqual(3) + ).toEqual(4) expect(searchMenu.querySelector('.domain').outerHTML).toBe( `sologenic.com`, ) }) - it('renders mpts in a separate section from tokens', async () => { + it('renders mpts together with tokens in a single list', async () => { const { container } = renderSearchResults() await flushPromises() await waitFor(() => { expect( container.querySelectorAll('.search-results-header').length, - ).toEqual(2) + ).toEqual(1) }) - const headers = container.querySelectorAll('.search-results-header') - expect(headers[0].outerHTML).toBe( - `
tokens (1)
`, - ) - expect(headers[1].outerHTML).toBe( - `
mpts (1)
`, + const header = container.querySelector('.search-results-header') + expect(header.outerHTML).toBe( + `
tokens (2)
`, ) const rows = container.querySelectorAll('.search-result-row') @@ -110,39 +109,42 @@ describe('Testing tokens search', () => { expect(mptRow.getAttribute('href')).toBe( '/mpt/00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8', ) - expect(mptRow.querySelectorAll('.metric-chip').length).toEqual(2) + // type chip + price + holders (no trustlines chip, unlike IOUs) + expect(mptRow.querySelectorAll('.metric-chip').length).toEqual(3) + + // MPTs show ticker + full name, same structure as IOUs + expect(mptRow.querySelector('.currency').outerHTML).toBe( + `Example MPT`, + ) + expect(mptRow.querySelector('.type-chip').textContent).toBe( + 'token_type.mpt', + ) }) - it('sorts tokens and mpts by holder count descending', async () => { + it('renders tokens in the exact order the API returns, without re-sorting client-side', async () => { + // Ranking (by holder count) is the backend's job (see + // server/routes/v1/tokens.js). This fixture is deliberately NOT + // pre-sorted by holders, so this test fails if the component ever + // re-sorts client-side and clobbers that ranking. const { container } = renderSearchResults('sort-test', sortedTestTokens) await flushPromises() await waitFor(() => { expect( container.querySelectorAll('.search-results-header').length, - ).toEqual(2) + ).toEqual(1) }) - const headers = container.querySelectorAll('.search-results-header') - expect(headers[0].outerHTML).toBe( - `
tokens (3)
`, - ) - expect(headers[1].outerHTML).toBe( - `
mpts (3)
`, + const header = container.querySelector('.search-results-header') + expect(header.outerHTML).toBe( + `
tokens (12)
`, ) - // tokens: SOLO (218547) > MID (4200) > LOW (5) - // mpts: Big MPT (9000) > Example MPT (10) > Small MPT (3) const issuerNames = Array.from( container.querySelectorAll('.issuer-name'), ).map((el) => el.textContent) - expect(issuerNames).toEqual([ - 'Sologenic (', - 'Mid Holder Token (', - 'Low Holder Token (', - 'Big MPT Issuer (', - 'Example Issuer (', - 'Small MPT Issuer (', - ]) + expect(issuerNames).toEqual( + sortedTestTokens.tokens.map((token) => `${token.issuer_name} (`), + ) }) }) diff --git a/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens_sorted.json b/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens_sorted.json index e4245c3a2..92eef30d5 100644 --- a/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens_sorted.json +++ b/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens_sorted.json @@ -26,33 +26,91 @@ }, { "token_type": "MPT", - "issuer_name": "Small MPT Issuer", + "issuer_name": "Verra Carbon Registry", "currency": "00000002B5F762798A53D543A014CAF8B297CFF8F2F937E9", "mpt_issuance_id": "00000002B5F762798A53D543A014CAF8B297CFF8F2F937E9", - "name": "Small MPT", - "issuer_account": "rSmallMPTIssuer00000000000000000000", + "name": "Carbon Credit Token", + "issuer_account": "rCarbonCreditIssuer0000000000000000", "holders": 3, "price": "1.0" }, { "token_type": "MPT", - "issuer_name": "Big MPT Issuer", + "issuer_name": "Ondo Finance", "currency": "00000003B5F762798A53D543A014CAF8B297CFF8F2F937EA", "mpt_issuance_id": "00000003B5F762798A53D543A014CAF8B297CFF8F2F937EA", - "name": "Big MPT", - "issuer_account": "rBigMPTIssuer000000000000000000000", + "name": "Tokenized Treasury Fund", + "issuer_account": "rTreasuryFundIssuer00000000000000000", "holders": 9000, "price": "2.0" }, { "token_type": "MPT", - "issuer_name": "Example Issuer", + "issuer_name": "SkyMiles Rewards", "currency": "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8", "mpt_issuance_id": "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8", - "name": "Example MPT", + "name": "Airline Miles Token", "issuer_account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", "holders": 10, "price": "1.5" + }, + { + "token_type": "MPT", + "issuer_name": "Acme Bond Issuance", + "currency": "00000004B5F762798A53D543A014CAF8B297CFF8F2F937EB", + "mpt_issuance_id": "00000004B5F762798A53D543A014CAF8B297CFF8F2F937EB", + "name": "New Corporate Bond", + "issuer_account": "rCorporateBondIssuer00000000000000000", + "holders": 0, + "price": "0.75" + }, + { + "token_type": "MPT", + "issuer_name": "Meridian Stablecoin Co", + "currency": "00000005B5F762798A53D543A014CAF8B297CFF8F2F937EC", + "mpt_issuance_id": "00000005B5F762798A53D543A014CAF8B297CFF8F2F937EC", + "name": "Regional Stablecoin", + "issuer_account": "rStablecoinIssuer00000000000000000000", + "price": "0.25" + }, + { + "token_type": "MPT", + "issuer_name": "Marriott Bonvoy Points", + "currency": "00000006B5F762798A53D543A014CAF8B297CFF8F2F937ED", + "mpt_issuance_id": "00000006B5F762798A53D543A014CAF8B297CFF8F2F937ED", + "name": "Hotel Loyalty Points", + "issuer_account": "rHotelPointsIssuer000000000000000000", + "holders": 50, + "price": "3.0" + }, + { + "token_type": "MPT", + "issuer_name": "Retail Rewards Co", + "currency": "00000007B5F762798A53D543A014CAF8B297CFF8F2F937EE", + "mpt_issuance_id": "00000007B5F762798A53D543A014CAF8B297CFF8F2F937EE", + "name": "Gift Card Points", + "issuer_account": "rGiftCardIssuer0000000000000000000000", + "holders": 50, + "price": "3.1" + }, + { + "token_type": "MPT", + "issuer_name": "New Issuer Corp", + "currency": "00000008B5F762798A53D543A014CAF8B297CFF8F2F937EF", + "mpt_issuance_id": "00000008B5F762798A53D543A014CAF8B297CFF8F2F937EF", + "issuer_account": "rNewIssuerCorp0000000000000000000000" + }, + { + "token_type": "MPT", + "issuer_name": "SIVAX.io", + "currency": "0627B6859931344A3871ED63805BBF07DECBF03F9C2698BD", + "mpt_issuance_id": "0627B6859931344A3871ED63805BBF07DECBF03F9C2698BD", + "name": "SVXV1", + "icon": "https://s1.xrplmeta.org/icon/CE971EDDBC.png", + "issuer_account": "rNyrL3hjvM3mDYtTDhHLWMiGtbnV2wdBv6", + "issuer_domain": "www.sivax.io", + "holders": 17, + "price": "0" } ] } diff --git a/src/containers/shared/losTypes.ts b/src/containers/shared/losTypes.ts index 4a717fc85..66ce687e4 100644 --- a/src/containers/shared/losTypes.ts +++ b/src/containers/shared/losTypes.ts @@ -6,6 +6,7 @@ export interface LOSToken { currency: string issuer_account: string name?: string + full_name?: string asset_class?: string asset_subclass?: string price_change?: number From d6c3d57278930d3ccdbccae9fc98a124ddc0bd07 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Fri, 25 Sep 2026 13:29:11 -0700 Subject: [PATCH 5/6] Hide the price chip on MPT rows MPTs aren't tradeable on the DEX yet, so price is always blank - showing a dashed placeholder chip for it was confusing. Drop the chip entirely for MPT rows instead, matching how the trustlines chip is already IOU-only. --- .../TokenSearchResults/TokenSearchRow.tsx | 34 ++++++++++--------- .../test/TokenSearchResults.test.js | 4 +-- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx b/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx index 9a883b76d..7924b5001 100644 --- a/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx +++ b/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx @@ -95,22 +95,24 @@ export const TokenSearchRow = ({
{isMPT ? t('token_type.mpt') : t('token_type.iou')}
-
- {token.price ? ( - - ) : ( -
{DEFAULT_VALUE}
- )} -
+ {!isMPT && ( +
+ {token.price ? ( + + ) : ( +
{DEFAULT_VALUE}
+ )} +
+ )}
{t('holders_count', { holders: localizeNumber(token.holders), diff --git a/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js b/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js index adcb99364..c55e5cd2a 100644 --- a/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js +++ b/src/containers/shared/components/TokenSearchResults/test/TokenSearchResults.test.js @@ -109,8 +109,8 @@ describe('Testing tokens search', () => { expect(mptRow.getAttribute('href')).toBe( '/mpt/00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8', ) - // type chip + price + holders (no trustlines chip, unlike IOUs) - expect(mptRow.querySelectorAll('.metric-chip').length).toEqual(3) + // type chip + holders (no price or trustlines chip, unlike IOUs) + expect(mptRow.querySelectorAll('.metric-chip').length).toEqual(2) // MPTs show ticker + full name, same structure as IOUs expect(mptRow.querySelector('.currency').outerHTML).toBe( From 9c6be89db788d0e063f3ebffe1878b57c492c0f3 Mon Sep 17 00:00:00 2001 From: Cybele Reed Date: Mon, 28 Sep 2026 15:14:55 -0700 Subject: [PATCH 6/6] Fix IOU/MPT chip colors and keep MPTs out of Token Ranking Addresses PR review comments: the search-result type chip now uses white for IOU and blue for MPT to match the token detail page's category pill, and the token cache is split into an IOU-only list (ranking/metrics) and a merged search list, so MPTs no longer show up on the Token Ranking page. Co-Authored-By: Claude Sonnet 5 --- server/routes/v1/tokens.js | 34 +++++++++++++------ .../TokenSearchResults/TokenSearchRow.tsx | 2 +- .../components/TokenSearchResults/styles.scss | 15 +++++--- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/server/routes/v1/tokens.js b/server/routes/v1/tokens.js index 621fcf7b9..b521ae5a3 100644 --- a/server/routes/v1/tokens.js +++ b/server/routes/v1/tokens.js @@ -2,7 +2,12 @@ const axios = require('axios') const log = require('../../lib/logger')({ name: 'tokens search' }) const REFETCH_INTERVAL = 10 * 60 * 1000 // 10 minutes -const cachedTokenList = { tokens: [], last_updated: null, metrics: null } +const cachedTokenList = { + tokens: [], + searchTokens: [], + last_updated: null, + metrics: null, +} const parseCurrency = (currency) => { const NON_STANDARD_CODE_LENGTH = 40 @@ -133,18 +138,27 @@ async function cacheTokens() { `Fetched ${losTokens.tokens.length} tokens from LOS, ${mpts.length} MPTs from XRPL Meta...`, ) - cachedTokenList.tokens = [...losTokens.tokens, ...mpts].sort( - (a, b) => Number(b.holders ?? 0) - Number(a.holders ?? 0), - ) - - cachedTokenList.last_updated = Date.now() - // nonstandard from XRPLMeta, check for hex codes in currencies and store parsed - cachedTokenList.tokens = cachedTokenList.tokens.map((token) => ({ + const iouTokens = losTokens.tokens + .sort((a, b) => Number(b.holders ?? 0) - Number(a.holders ?? 0)) + .map((token) => ({ + ...token, + parsedCurrency: parseCurrency(token.currency), + })) + const mptTokens = mpts.map((token) => ({ ...token, parsedCurrency: parseCurrency(token.currency), })) + // The Token Ranking page (getAllTokens) shows IOUs only — MPTs aren't + // tradeable yet, so mixing them into ranking/metrics would be misleading. + cachedTokenList.tokens = iouTokens + cachedTokenList.searchTokens = [...iouTokens, ...mptTokens].sort( + (a, b) => Number(b.holders ?? 0) - Number(a.holders ?? 0), + ) + + cachedTokenList.last_updated = Date.now() + // Calculate and cache metrics cachedTokenList.metrics = calculateMetrics(cachedTokenList.tokens) log.info(`Cached metrics for ${cachedTokenList.metrics.count} tokens`) @@ -216,12 +230,12 @@ const getTokensSearch = async (req, res) => { log.info('getting tokens list for search') const { query } = req.params let timeoutLimit = 10 - while (cachedTokenList.tokens.length === 0 && timeoutLimit > 0) { + while (cachedTokenList.searchTokens.length === 0 && timeoutLimit > 0) { // eslint-disable-next-line no-await-in-loop -- necessary here to wait for cache to be filled await sleep(1000) timeoutLimit -= 1 } - const queriedTokens = await queryTokens(cachedTokenList.tokens, query) + const queriedTokens = await queryTokens(cachedTokenList.searchTokens, query) return res.status(200).json({ result: 'success', updated: cachedTokenList.last_updated, diff --git a/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx b/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx index 7924b5001..d6c44a583 100644 --- a/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx +++ b/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx @@ -92,7 +92,7 @@ export const TokenSearchRow = ({
-
+
{isMPT ? t('token_type.mpt') : t('token_type.iou')}
{!isMPT && ( diff --git a/src/containers/shared/components/TokenSearchResults/styles.scss b/src/containers/shared/components/TokenSearchResults/styles.scss index 4fe4af076..9f3293fab 100644 --- a/src/containers/shared/components/TokenSearchResults/styles.scss +++ b/src/containers/shared/components/TokenSearchResults/styles.scss @@ -115,11 +115,18 @@ } } -// Reuses the $mpt color already used for the MPT category pill on the -// token page, so IOU/MPT chips read consistently across the app. +// Mirrors the category pill colors used on the token detail page, so +// IOU/MPT chips read consistently across the app. .type-chip { - border-color: $mpt; - color: $mpt; + &.mpt { + border-color: $mpt; + color: $mpt; + } + + &.iou { + border-color: $white; + color: $white; + } } .issuer-link {