Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 74 additions & 11 deletions server/routes/v1/tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Comment thread
kuan121 marked this conversation as resolved.
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`)
Expand Down Expand Up @@ -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)
Expand All @@ -148,6 +210,7 @@ function queryTokens(tokenList, query) {
currencyMatch ||
parsedCurrencyMatch ||
nameMatch ||
fullNameMatch ||
issuerNameMatch ||
issuerAccountStartsMatch
)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,21 @@ 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,
})

// clear current search on navigation
setCurrentSearchInput('')
}

return tokens.length > 0 ? (
if (tokens.length === 0) {
return null
}

return (
<div className="search-results-menu">
<div className="search-results-header">
{t('tokens')} ({tokens.length})
Expand All @@ -85,13 +89,17 @@ const SearchResults = ({
{tokens.map((token) => (
<TokenSearchRow
token={token}
onClick={onLinkClick}
onClick={onLinkClick(token.token_type === 'MPT' ? 'mpt' : 'token')}
xrpPrice={XRPUSDPrice}
key={`${token.currency}.${token.issuer_account}`}
key={
token.token_type === 'MPT'
? (token.mpt_issuance_id ?? token.currency)
: `${token.currency}.${token.issuer_account}`
}
/>
))}
</div>
) : null
)
}

export default SearchResults
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -23,11 +23,9 @@ const TokenLogo: FC<{ token: LOSToken }> = ({ token }) =>
<div className="result-row-icon no-logo" />
)

const TokenName: FC<{ token: LOSToken }> = ({ token }) =>
token && token.name ? (
<div>
({token.name.trim().toUpperCase().replace('(', '').replace(')', '')})
</div>
const TokenName: FC<{ name?: string }> = ({ name }) =>
name ? (
<div>({name.trim().toUpperCase().replace('(', '').replace(')', '')})</div>
) : null

const IssuerAddress: FC<{ token: LOSToken; onClick: any }> = ({
Expand Down Expand Up @@ -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 (
<Link
to={`/token/${token.currency}.${token.issuer_account}`}
to={
isMPT
? `/mpt/${token.mpt_issuance_id}`
: `/token/${token.currency}.${token.issuer_account}`
}
className="search-result-row"
onClick={onClick}
>
Expand All @@ -72,37 +80,51 @@ export const TokenSearchRow = ({
<TokenLogo token={token} />
</div>
<div className="result-currency">
<Currency currency={token.currency} />
{isMPT ? (
<span className="currency" data-testid="currency">
{token.name ??
shortenMPTID(token.mpt_issuance_id ?? token.currency)}
</span>
) : (
<Currency currency={token.currency} />
)}
</div>
<div className="result-token-name">
<TokenName token={token} />
<TokenName name={displayName} />
</div>
<div className="metric-chip">
{token.price ? (
<Amount
value={{
currency: 'USD',
amount: parsePrice(token.price, xrpPrice),
}}
displayIssuer={false}
modifier={
parsePrice(token.price, xrpPrice) === 0 ? '~' : undefined
}
/>
) : (
<div className="no-price">{DEFAULT_VALUE}</div>
)}
<div className={`metric-chip type-chip ${isMPT ? 'mpt' : 'iou'}`}>
{isMPT ? t('token_type.mpt') : t('token_type.iou')}
</div>
{!isMPT && (
<div className="metric-chip">
{token.price ? (
<Amount
value={{
currency: 'USD',
amount: parsePrice(token.price, xrpPrice),
}}
displayIssuer={false}
modifier={
parsePrice(token.price, xrpPrice) === 0 ? '~' : undefined
}
/>
) : (
<div className="no-price">{DEFAULT_VALUE}</div>
)}
</div>
)}
<div className="metric-chip">
{t('holders_count', {
holders: localizeNumber(token.holders),
})}
</div>
<div className="metric-chip">
{t('trustlines', {
trustlines: localizeNumber(token.trustlines),
})}
</div>
{!isMPT && (
<div className="metric-chip">
{t('trustlines', {
trustlines: localizeNumber(token.trustlines),
})}
</div>
)}
</div>
<div className="result-issuer-line">
<div className="issuer-title">{t('issuer')}:</div>
Expand Down
14 changes: 14 additions & 0 deletions src/containers/shared/components/TokenSearchResults/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading