diff --git a/server/routes/v1/tokens.js b/server/routes/v1/tokens.js
index 9186e8c03..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
@@ -82,24 +87,78 @@ 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...`)
-
- cachedTokenList.tokens = losTokens.tokens.sort(
- (a, b) => Number(b.holders ?? 0) - Number(a.holders ?? 0),
+ log.info(
+ `Fetched ${losTokens.tokens.length} tokens from LOS, ${mpts.length} MPTs from XRPL Meta...`,
)
- 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`)
@@ -137,6 +196,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 +210,7 @@ function queryTokens(tokenList, query) {
currencyMatch ||
parsedCurrencyMatch ||
nameMatch ||
+ fullNameMatch ||
issuerNameMatch ||
issuerAccountStartsMatch
)
@@ -167,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/TokenSearchResults.tsx b/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx
index fb5456de0..46aa2a47f 100644
--- a/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx
+++ b/src/containers/shared/components/TokenSearchResults/TokenSearchResults.tsx
@@ -66,9 +66,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,7 +76,11 @@ const SearchResults = ({
setCurrentSearchInput('')
}
- return tokens.length > 0 ? (
+ if (tokens.length === 0) {
+ return null
+ }
+
+ return (
{t('tokens')} ({tokens.length})
@@ -85,13 +89,17 @@ const SearchResults = ({
{tokens.map((token) => (
))}
- ) : null
+ )
}
export default SearchResults
diff --git a/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx b/src/containers/shared/components/TokenSearchResults/TokenSearchRow.tsx
index 3f2248a1f..d6c44a583 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,37 +80,51 @@ export const TokenSearchRow = ({
-
+ {isMPT ? (
+
+ {token.name ??
+ shortenMPTID(token.mpt_issuance_id ?? token.currency)}
+
+ ) : (
+
+ )}
-
+
-
- {token.price ? (
-
- ) : (
-
{DEFAULT_VALUE}
- )}
+
+ {isMPT ? t('token_type.mpt') : t('token_type.iou')}
+ {!isMPT && (
+
+ {token.price ? (
+
+ ) : (
+
{DEFAULT_VALUE}
+ )}
+
+ )}
{t('holders_count', {
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..9f3293fab 100644
--- a/src/containers/shared/components/TokenSearchResults/styles.scss
+++ b/src/containers/shared/components/TokenSearchResults/styles.scss
@@ -115,6 +115,20 @@
}
}
+// Mirrors the category pill colors used on the token detail page, so
+// IOU/MPT chips read consistently across the app.
+.type-chip {
+ &.mpt {
+ border-color: $mpt;
+ color: $mpt;
+ }
+
+ &.iou {
+ border-color: $white;
+ color: $white;
+ }
+}
+
.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 1c136b4ae..c55e5cd2a 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(
@@ -64,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(
- ``,
+ ``,
)
expect(searchMenu.querySelector('.currency').outerHTML).toBe(
`SOLO `,
@@ -76,13 +78,73 @@ 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 together with tokens in a single list', async () => {
+ const { container } = renderSearchResults()
+ await flushPromises()
+
+ await waitFor(() => {
+ expect(
+ container.querySelectorAll('.search-results-header').length,
+ ).toEqual(1)
+ })
+
+ const header = container.querySelector('.search-results-header')
+ expect(header.outerHTML).toBe(
+ ``,
+ )
+
+ const rows = container.querySelectorAll('.search-result-row')
+ const mptRow = rows[1]
+ expect(mptRow.getAttribute('href')).toBe(
+ '/mpt/00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8',
+ )
+ // 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(
+ `Example MPT `,
+ )
+ expect(mptRow.querySelector('.type-chip').textContent).toBe(
+ 'token_type.mpt',
+ )
+ })
+
+ 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(1)
+ })
+
+ const header = container.querySelector('.search-results-header')
+ expect(header.outerHTML).toBe(
+ ``,
+ )
+
+ const issuerNames = Array.from(
+ container.querySelectorAll('.issuer-name'),
+ ).map((el) => el.textContent)
+ expect(issuerNames).toEqual(
+ sortedTestTokens.tokens.map((token) => `${token.issuer_name} (`),
+ )
+ })
})
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/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..92eef30d5
--- /dev/null
+++ b/src/containers/shared/components/TokenSearchResults/test/mock_data/tokens_sorted.json
@@ -0,0 +1,116 @@
+{
+ "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": "Verra Carbon Registry",
+ "currency": "00000002B5F762798A53D543A014CAF8B297CFF8F2F937E9",
+ "mpt_issuance_id": "00000002B5F762798A53D543A014CAF8B297CFF8F2F937E9",
+ "name": "Carbon Credit Token",
+ "issuer_account": "rCarbonCreditIssuer0000000000000000",
+ "holders": 3,
+ "price": "1.0"
+ },
+ {
+ "token_type": "MPT",
+ "issuer_name": "Ondo Finance",
+ "currency": "00000003B5F762798A53D543A014CAF8B297CFF8F2F937EA",
+ "mpt_issuance_id": "00000003B5F762798A53D543A014CAF8B297CFF8F2F937EA",
+ "name": "Tokenized Treasury Fund",
+ "issuer_account": "rTreasuryFundIssuer00000000000000000",
+ "holders": 9000,
+ "price": "2.0"
+ },
+ {
+ "token_type": "MPT",
+ "issuer_name": "SkyMiles Rewards",
+ "currency": "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8",
+ "mpt_issuance_id": "00000001B5F762798A53D543A014CAF8B297CFF8F2F937E8",
+ "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 4486b1ff5..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
@@ -13,8 +14,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