Skip to content
Merged
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 })
Comment on lines +118 to +119

@semgrep-code-ripple-github-org semgrep-code-ripple-github-org Bot Oct 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High severity issue identified in your code:

Risk: Affected versions of axios are vulnerable to Inefficient Regular Expression Complexity. The axios Node.js HTTP adapter parses data: URLs in GET requests with a regex (lib/helpers/fromDataURI.js) that suffers from catastrophic backtracking; a long malformed data: URL made of slashes without a comma blocks the event loop for minutes, causing a denial of service.

Fix: Upgrade this library to at least version 1.20.0 at explorer/package-lock.json:7447.

Reference(s): https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-88378, GHSA-c29m-xwm3-cm6r, CVE-2026-101903

✨ Fixed in commit 4514ba9 ✨

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thx u🫶

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Axios upgrades will go in a different PR

Comment on lines +118 to +119

@semgrep-code-ripple-github-org semgrep-code-ripple-github-org Bot Oct 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High severity issue identified in your code:

Risk: Affected versions of axios are vulnerable to Inefficient Regular Expression Complexity / Uncontrolled Resource Consumption. Axios is vulnerable to a Regular Expression Denial of Service (ReDoS) in the internal shouldBypassProxy() helper used by the Node.js HTTP adapter. Hostnames are normalized with hostname.replace(/\.+$/, ''), which backtracks quadratically on hostnames made of many dots followed by a single character. When a proxy is taken from the environment and NO_PROXY is set, a malicious server can return a redirect whose Location host is crafted this way, blocking the Node.js event loop and causing a denial of service.

Manual Review Advice: A vulnerability from this advisory is reachable if you use Axios in Node.js with the default http adapter and a proxy configured via the HTTP_PROXY/HTTPS_PROXY environment variables and NO_PROXY set, without setting proxy: false, and redirects are followed

Fix: Upgrade this library to at least version 1.20.0 at explorer/package-lock.json:7447.

Reference(s): https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-88383, GHSA-mghh-pgcx-3jjj, CVE-2026-101906

✨ Fixed in commit 4514ba9 ✨

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Axios upgrades will go in a different PR

Comment on lines +118 to +119

@semgrep-code-ripple-github-org semgrep-code-ripple-github-org Bot Oct 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High severity issue identified in your code:

Risk: Affected versions of axios are vulnerable to Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution') / Unintended Proxy or Intermediary ('Confused Deputy'). The axios Node.js HTTP adapter does not set a safe own value for createConnection on the request options it passes to Node http/https. If Object.prototype.createConnection is polluted anywhere in the same process, Node inherits and runs the attacker function, which silently sends the socket to attacker infrastructure while the request URL still looks legitimate. Authorization headers, cookies, API keys and response bodies can then be stolen or tampered with.

Fix: Upgrade this library to at least version 1.20.0 at explorer/package-lock.json:7447.

Reference(s): https://euvd.enisa.europa.eu/vulnerability/EUVD-2026-88382, GHSA-m8m8-qj5v-23w3, CVE-2026-101905

🎉 Fixed in commit 4514ba9 🎉

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Axios upgrades will go in a different PR

.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 cachedTokenList.searchTokens.filter((t) => t.token_type === 'MPT')
})
}

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