Skip to content

Commit

Permalink
Add Token Search Results (#1056)
Browse files Browse the repository at this point in the history
## High Level Overview of Change

In order to improve the usability of the search bar inside of Explorer,
matching tokens will now auto-populate as a user is typing. The results
are clickable and redirect to the respective token page. The data is
pulled from XRPLMeta and cached inside of the Explorer backend to
display an open sourced list of tokens and relevant statistics.

Backend changes implement serverside caching of tokens data from XRPL
Meta, and exposes an API from the explorer backend for search result
querying. This is less expensive than the previous approach of
websockets (which remain open for the duration of a user session) and
are susceptible to rate limits on XRPLMeta's public node infrastructure.
This also offers resilience in the case that the public XRPLMeta node
has downtime.

Desktop:
<img width="906" alt="Screenshot 2024-11-05 at 4 36 33 PM"
src="https://github.com/user-attachments/assets/8f19503d-74fc-474f-b946-534c028ce4a9">

Mobile:
<img width="453" alt="Screenshot 2024-11-05 at 4 37 26 PM"
src="https://github.com/user-attachments/assets/3ba5ba0d-1fe0-4bfd-97fe-a32f12420490">


<!--
Please include a summary/list of the changes.
If too broad, please consider splitting into multiple PRs.
-->

### Context of Change

<!--
Please include the context of a change.
If a bug fix, when was the bug introduced? What was the behavior?
If a new feature, why was this architecture chosen? What were the
alternatives?
If a refactor, how is this better than the previous implementation?

If there is a design document for this feature, please link it here.
-->

### Type of Change

<!--
Please check relevant options, delete irrelevant ones.
-->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Refactor (non-breaking change that only restructures code)
- [ ] Tests (You added tests for code that already exists, or your new
feature included in this PR)
- [ ] Documentation Updates
- [ ] Translation Updates
- [ ] Release

### TypeScript/Hooks Update

<!--
In an effort to modernize the codebase, you should convert the files
that you work with to React Hooks and TypeScript.
If this is not possible (e.g. it's too many changes, touching too many
files, etc.) please explain why here.
-->

- [ ] Updated files to React Hooks
- [ ] Updated files to TypeScript

## Before / After

<!--
If just refactoring / back-end changes, this can be just an in-English
description of the change at a technical level.
If a UI change, screenshots should be included.
-->

## Test Plan

<!--
Please describe the tests that you ran to verify your changes and
provide instructions so that others can reproduce.
-->

<!--
## Future Tasks
For future tasks related to PR.
-->

---------

Co-authored-by: Phu Pham <[email protected]>
Co-authored-by: pdp2121 <[email protected]>
Co-authored-by: Caleb Kniffen <[email protected]>
  • Loading branch information
4 people authored Nov 6, 2024
1 parent 4cb0975 commit 5a93a6d
Show file tree
Hide file tree
Showing 22 changed files with 822 additions and 20 deletions.
6 changes: 5 additions & 1 deletion public/locales/ca-CA/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,9 @@
"asset_class": null,
"trading_pairs": null,
"deleted": null,
"holders": null,
"trustlines": null,
"website": null,
"assets.mpt_tab_title": null,
"assets.no_mpts_message": null,
"transaction_type_name_MPTokenIssuanceCreate": null,
Expand All @@ -553,5 +556,6 @@
"can_escrow": null,
"can_trade": null,
"can_transfer": null,
"can_clawback": null
"can_clawback": null,
"search_results_banner": null
}
8 changes: 6 additions & 2 deletions public/locales/en-US/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"explorer": "Explorer",
"xrpl_org": "XRPL.org",
"github": "GitHub",
"header.search.placeholder": "Search by Address, Ledger or Txn",
"header.search.placeholder": "Search by Token, Address, Ledger or Txn",
"xrp": "XRP",
"xrpl_explorer": "XRPL Explorer",
"ledgers": "Ledgers",
Expand Down Expand Up @@ -540,6 +540,9 @@
"asset_class": "Asset Class",
"trading_pairs": "Trading Pairs",
"deleted": "Deleted",
"holders": "HOLDERS: {{holders}}",
"trustlines": " TRUSTLINES: {{trustlines}}",
"website": "Wesbite",
"mpt_issuance_id": "MPT Issuance ID",
"asset_scale": "Asset Scale",
"metadata": "Metadata",
Expand All @@ -553,5 +556,6 @@
"can_escrow": "Can Escrow",
"can_trade": "Can Trade",
"can_transfer": "Can Transfer",
"can_clawback": "Can Clawback"
"can_clawback": "Can Clawback",
"search_results_banner": "Token search by name and account is now available! Try searching for USD"
}
6 changes: 5 additions & 1 deletion public/locales/es-ES/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,9 @@
"asset_class": null,
"trading_pairs": null,
"deleted": null,
"holders": null,
"trustlines": null,
"website": null,
"assets.mpt_tab_title": null,
"assets.no_mpts_message": null,
"transaction_type_name_MPTokenIssuanceCreate": null,
Expand All @@ -549,5 +552,6 @@
"can_escrow": null,
"can_trade": null,
"can_transfer": null,
"can_clawback": null
"can_clawback": null,
"search_results_banner": null
}
6 changes: 5 additions & 1 deletion public/locales/fr-FR/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,9 @@
"asset_class": null,
"trading_pairs": null,
"deleted": null,
"holders": null,
"trustlines": null,
"website": null,
"assets.mpt_tab_title": null,
"assets.no_mpts_message": null,
"transaction_type_name_MPTokenIssuanceCreate": null,
Expand All @@ -550,5 +553,6 @@
"can_escrow": null,
"can_trade": null,
"can_transfer": null,
"can_clawback": null
"can_clawback": null,
"search_results_banner": null
}
6 changes: 5 additions & 1 deletion public/locales/ja-JP/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,9 @@
"asset_class": null,
"trading_pairs": null,
"deleted": null,
"holders": null,
"trustlines": null,
"website": null,
"assets.mpt_tab_title": null,
"assets.no_mpts_message": null,
"transaction_type_name_MPTokenIssuanceCreate": null,
Expand All @@ -549,5 +552,6 @@
"can_escrow": null,
"can_trade": null,
"can_transfer": null,
"can_clawback": null
"can_clawback": null,
"search_results_banner": null
}
6 changes: 5 additions & 1 deletion public/locales/ko-KR/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,9 @@
"asset_class": null,
"trading_pairs": null,
"deleted": null,
"holders": null,
"trustlines": null,
"website": null,
"assets.mpt_tab_title": null,
"assets.no_mpts_message": null,
"transaction_type_name_MPTokenIssuanceCreate": null,
Expand All @@ -547,5 +550,6 @@
"can_escrow": null,
"can_trade": null,
"can_transfer": null,
"can_clawback": null
"can_clawback": null,
"search_results_banner": null
}
2 changes: 2 additions & 0 deletions server/routes/v1/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const api = require('express').Router()
const getTokenDiscovery = require('./tokenDiscovery')
const getHealth = require('./health')
const getCurrentMetrics = require('./currentMetrics')
const getTokensSearch = require('./tokens')

if (process.env.VITE_ENVIRONMENT === 'mainnet') {
api.use('/token/top', getTokenDiscovery)
Expand All @@ -13,6 +14,7 @@ if (process.env.VITE_ENVIRONMENT !== 'custom') {
// these require a single hardcoded rippled node to connect to
api.use('/health', getHealth)
api.use('/metrics', getCurrentMetrics)
api.use('/tokens/search/:query', getTokensSearch)
}

module.exports = api
121 changes: 121 additions & 0 deletions server/routes/v1/tokens.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
const axios = require('axios')
const log = require('../../lib/logger')({ name: 'tokens search' })

const REFETCH_INTERVAL = 60 * 60 * 1000 // 1 hour
const XRPLMETA_QUERY_LIMIT = 1000
const cachedTokenSearchList = { tokens: [], last_updated: null }

const parseCurrency = (currency) => {
const NON_STANDARD_CODE_LENGTH = 40
const LP_TOKEN_IDENTIFIER = '03'

const hexToString = (hex) => {
let string = ''
for (let i = 0; i < hex.length; i += 2) {
const part = hex.substring(i, i + 2)
const code = parseInt(part, 16)
if (!isNaN(code) && code !== 0) {
string += String.fromCharCode(code)
}
}
return string
}

return currency.length === NON_STANDARD_CODE_LENGTH &&
currency?.substring(0, 2) !== LP_TOKEN_IDENTIFIER
? hexToString(currency)
: currency
}

async function fetchXRPLMetaTokens(offset) {
log.info(`caching tokens from ${process.env.XRPL_META_URL}`)
return axios
.get(
`https://${process.env.XRPL_META_URL}/tokens?trust_level=1&trust_level=2&trust_level=3`,
{
params: {
sort_by: 'holders',
offset,
limit: XRPLMETA_QUERY_LIMIT,
},
},
)
.then((resp) => resp.data)
.catch((e) => log.error(e))
}

async function cacheXRPLMetaTokens() {
let offset = 0
let tokensDataBatch = []
const allTokensFetched = []

tokensDataBatch = await fetchXRPLMetaTokens(0)
const { count } = tokensDataBatch
while (offset < count) {
allTokensFetched.push(...tokensDataBatch.tokens)
offset += XRPLMETA_QUERY_LIMIT
// eslint-disable-next-line no-await-in-loop
tokensDataBatch = await fetchXRPLMetaTokens(offset)
}

cachedTokenSearchList.tokens = allTokensFetched.filter(
(result) =>
result.metrics.trustlines > 50 &&
result.metrics.holders > 50 &&
result.metrics.marketcap > 0 &&
result.metrics.volume_7d > 0,
)
cachedTokenSearchList.last_updated = Date.now()

// nonstandard from XRPLMeta, check for hex codes in currencies and store parsed
cachedTokenSearchList.tokens.map((token) => ({
...token,
currency: parseCurrency(token.currency),
}))
}

function startCaching() {
if (process.env.VITE_ENVIRONMENT !== 'mainnet') {
return
}
cacheXRPLMetaTokens()
setInterval(() => cacheXRPLMetaTokens(), REFETCH_INTERVAL)
}

startCaching()

function queryTokens(tokenList, query) {
const sanitizedQuery = query.toLowerCase()

return tokenList.filter(
(token) =>
token.currency?.toLowerCase().includes(sanitizedQuery) ||
token.meta?.token?.name?.toLowerCase().includes(sanitizedQuery) ||
token.meta?.issuer?.name?.toLowerCase().includes(sanitizedQuery) ||
token.issuer?.toLowerCase().startsWith(sanitizedQuery),
)
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}

module.exports = async (req, res) => {
try {
log.info('getting tokens list for search')
const { query } = req.params
while (cachedTokenSearchList.tokens.length === 0) {
// eslint-disable-next-line no-await-in-loop -- necessary here to wait for cache to be filled
await sleep(1000)
}
const queriedTokens = await queryTokens(cachedTokenSearchList.tokens, query)
return res.status(200).json({
result: 'success',
updated: cachedTokenSearchList.last_updated,
tokens: queriedTokens,
})
} catch (error) {
log.error(error)
return res.status(error.code || 500).json({ message: error.message })
}
}
72 changes: 63 additions & 9 deletions src/containers/Header/Search.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { KeyboardEventHandler, useContext } from 'react'
import {
FC,
KeyboardEventHandler,
useContext,
useEffect,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { XrplClient } from 'xrpl-client'

import {
isValidClassicAddress,
isValidXAddress,
classicAddressToXAddress,
} from 'ripple-address-codec'
import CloseIcon from '../shared/images/close.png'

import { useAnalytics } from '../shared/analytics'
import SocketContext from '../shared/SocketContext'
import {
Expand All @@ -33,6 +40,7 @@ import {
VALIDATOR_ROUTE,
MPT_ROUTE,
} from '../App/routes'
import TokenSearchResults from '../shared/components/TokenSearchResults/TokenSearchResults'

const determineHashType = async (id: string, rippledContext: XrplClient) => {
try {
Expand Down Expand Up @@ -153,6 +161,26 @@ const normalizeAccount = (id: string) => {
return id
}

const SearchBanner: FC<{ setIsBannerVisible: (visible: boolean) => void }> = ({
setIsBannerVisible,
}) => {
const { t } = useTranslation()
return (
<div className="banner-search">
<div className="banner-content">
<div>{t('search_results_banner')}</div>
<button
className="banner-button"
type="button"
onClick={() => setIsBannerVisible(false)}
>
<img src={CloseIcon} alt="close-icon" width={10} height={10} />
</button>
</div>
</div>
)
}

export interface SearchProps {
callback?: Function
}
Expand All @@ -163,6 +191,8 @@ export const Search = ({ callback = () => {} }: SearchProps) => {
const socket = useContext(SocketContext)
const navigate = useNavigate()

const [currentSearchInput, setCurrentSearchInput] = useState('')

const handleSearch = async (id: string) => {
const strippedId = id.replace(/^["']|["']$/g, '')
const route = await getRoute(strippedId, socket)
Expand All @@ -178,16 +208,40 @@ export const Search = ({ callback = () => {} }: SearchProps) => {
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === 'Enter') {
handleSearch(event.currentTarget?.value?.trim())
setCurrentSearchInput('')
}
}

const [isBannerVisible, setIsBannerVisible] = useState(true)

useEffect(() => {
const timeoutId = setTimeout(() => {
setIsBannerVisible(false)
}, 10000) // Disappear after 10 seconds

return () => clearTimeout(timeoutId)
}, [])

return (
<div className="search">
<input
type="text"
placeholder={t('header.search.placeholder')}
onKeyDown={onKeyDown}
/>
</div>
<>
{process.env.VITE_ENVIRONMENT === 'mainnet' && isBannerVisible && (
<SearchBanner setIsBannerVisible={setIsBannerVisible} />
)}
<div className="search">
<input
type="text"
placeholder={t('header.search.placeholder')}
onKeyDown={onKeyDown}
value={currentSearchInput}
onChange={(e) => setCurrentSearchInput(e.target.value)}
/>
{process.env.VITE_ENVIRONMENT === 'mainnet' && (
<TokenSearchResults
setCurrentSearchInput={setCurrentSearchInput}
currentSearchValue={currentSearchInput}
/>
)}
</div>
</>
)
}
Loading

0 comments on commit 5a93a6d

Please sign in to comment.