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( - `
tokens (1)
`, + `
tokens (2)
`, ) 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( + `
tokens (2)
`, + ) + + 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( + `
tokens (12)
`, + ) + + 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