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
6 changes: 3 additions & 3 deletions public/locales/en-US/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -922,15 +922,15 @@
"amm_pool_deleted_label": "AMM Pool Deleted",
"amm_pool_deleted_text": "This AMM pool has been deleted. All assets have been withdrawn and the pool is no longer active. Historical data is still available below.",
"amms": "AMMs",
"top_1000_amms": "Top 1,000 AMMs",
"top_1000_amms": "Top 1000 AMM Pools",
"general_info": "General Info",
"number_of_amms": "# of AMMs",
"number_of_lps": "# of LPs",
"number_of_amms_tooltip": "Number of active AMM pools with deposit/withdrawal activity",
"number_of_lps_tooltip": "Number of unique liquidity providers across all AMM pools",
"search_amms": "Search AMMs",
"tvl_tooltip": "Total value locked across all active AMM pools",
"volume_24h_all_tooltip": "Total DEX trading volume across all active AMM pools from 00:00–23:59 UTC of the previous day",
"tvl_tooltip": "Total value locked across active XRP-based AMM pools",
"volume_24h_all_tooltip": "Total DEX trading volume across active XRP-based AMM pools from 00:00–23:59 UTC of the previous day",
"clawback": "Clawback",
"can_confidential_amount": "Can Confidential Amount",
"convert": "Convert",
Expand Down
82 changes: 68 additions & 14 deletions server/routes/v1/amms.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ async function fetchAMMs() {
size: 1000,
sort_field: 'tvl_usd',
sort_order: 'desc',
// A token/token pool's TVL cannot be priced reliably, so it must not be ranked. Without this,
// token/token pools priced off illiquid IOUs dominate the tvl_usd ranking and
// consume the 1000-result budget.
xrp_only: true,
},
timeout: 30000,
})
Expand Down Expand Up @@ -160,16 +164,22 @@ async function fetchTradingFees(amms) {
return tradingFeeMap
}

async function fetchAggregatedStats() {
function fetchAggregate(xrpOnly) {
const url = `${process.env.VITE_LOS_URL}/amms/aggregated`
log.info(`Fetching aggregated stats from: ${url}`)
log.info(`Fetching aggregated stats from: ${url} (xrp_only=${!!xrpOnly})`)

return axios
.get(url, {
// axios omits undefined params, so the all-pools call sends nothing and LOS defaults.
params: {
xrp_only: xrpOnly || undefined,
},
timeout: 30000,
})
.then((resp) => {
log.info(`Successfully fetched aggregated stats, status: ${resp.status}`)
log.info(
`Successfully fetched aggregated stats (xrp_only=${!!xrpOnly}), status: ${resp.status}`,
)
return resp.data
})
.catch((e) => {
Expand All @@ -195,6 +205,39 @@ async function fetchAggregatedStats() {
})
}

/**
* Build the stat tiles from both LOS aggregates, which are deliberately mixed-scope:
* - counts (# of AMMs, # of LPs) come from the all-pools aggregate - they describe the
* whole ecosystem and are accurate for every pool
* - values (TVL, volume, fees) come from the XRP-only aggregate - only XRP pools have a
* priceable anchor, so token/token figures are often wildly overstated
*
* Each LOS aggregate is internally consistent; mixing them is this page's presentation
* choice, so it lives here rather than in the API.
*/
async function fetchAggregatedStats() {
const [allPools, xrpPools] = await Promise.all([
fetchAggregate(false),
fetchAggregate(true),
])

// Null unless both succeed, so the caller keeps its last good result rather than caching
// a partial one.
if (!allPools || !xrpPools) {
return null
}

return {
...allPools,
tvl_xrp: xrpPools.tvl_xrp,
tvl_usd: xrpPools.tvl_usd,
trading_volume_xrp: xrpPools.trading_volume_xrp,
trading_volume_usd: xrpPools.trading_volume_usd,
fees_collected_xrp: xrpPools.fees_collected_xrp,
fees_collected_usd: xrpPools.fees_collected_usd,
}
}

function enrichAMMs(amms, tokenDataMap) {
return amms.map((amm) => {
const token1Key =
Expand Down Expand Up @@ -423,17 +466,19 @@ const getAggregatedStats = async (req, res) => {
* GET /api/v1/amms/historical-trends
* Fetch historical trends for AMM data
*/
async function fetchHistoricalTrends(ammAccountId, timeRange) {
async function fetchHistoricalTrends(ammAccountId, timeRange, xrpOnly) {
const url = `${process.env.VITE_LOS_URL}/amms/historical-trends`
log.info(
`Fetching historical trends from: ${url} (amm_account_id=${ammAccountId}, time_range=${timeRange})`,
`Fetching historical trends from: ${url} (amm_account_id=${ammAccountId}, time_range=${timeRange}, xrp_only=${!!xrpOnly})`,
)

return axios
.get(url, {
params: {
amm_account_id: ammAccountId,
time_range: timeRange,
// axios omits undefined params, so the opted-out case sends nothing and LOS defaults.
xrp_only: xrpOnly || undefined,
},
timeout: 30000,
})
Expand Down Expand Up @@ -468,14 +513,21 @@ async function fetchHistoricalTrends(ammAccountId, timeRange) {
})
}

function getCachedTrends(ammAccountId, timeRange) {
const cacheKey = `${ammAccountId}:${timeRange}`
// The xrp_only flag MUST be part of the cache key. Without it, one `aggregated:6M` entry
// is shared between XRP-only and all-pool requests and whichever lands first wins, serving
// the wrong series with no error.
function trendsCacheKey(ammAccountId, timeRange, xrpOnly) {
return `${ammAccountId}:${timeRange}:${xrpOnly ? 'xrp' : 'all'}`
}

function getCachedTrends(ammAccountId, timeRange, xrpOnly) {
const cacheKey = trendsCacheKey(ammAccountId, timeRange, xrpOnly)
return cachedHistoricalTrends.get(cacheKey) || null
}

async function cacheTrends(ammAccountId, timeRange) {
const cacheKey = `${ammAccountId}:${timeRange}`
const trends = await fetchHistoricalTrends(ammAccountId, timeRange)
async function cacheTrends(ammAccountId, timeRange, xrpOnly) {
const cacheKey = trendsCacheKey(ammAccountId, timeRange, xrpOnly)
const trends = await fetchHistoricalTrends(ammAccountId, timeRange, xrpOnly)

if (trends) {
log.info(`Fetched historical trends from LOS for ${cacheKey}...`)
Expand Down Expand Up @@ -505,13 +557,15 @@ const getHistoricalTrends = async (req, res) => {
const {
amm_account_id: ammAccountId = 'aggregated',
time_range: timeRange = '6M',
xrp_only: xrpOnlyParam,
} = req.query
const xrpOnly = xrpOnlyParam === 'true' || xrpOnlyParam === true

log.info(
`Fetching historical trends from cache: amm_account_id=${ammAccountId}, time_range=${timeRange}`,
`Fetching historical trends from cache: amm_account_id=${ammAccountId}, time_range=${timeRange}, xrp_only=${xrpOnly}`,
)

const cached = getCachedTrends(ammAccountId, timeRange)
const cached = getCachedTrends(ammAccountId, timeRange, xrpOnly)

// If cached and fresh (within REFETCH_INTERVAL), return it
if (cached && Date.now() - cached.last_updated < REFETCH_INTERVAL) {
Expand All @@ -523,9 +577,9 @@ const getHistoricalTrends = async (req, res) => {
}

// Cache miss or stale — fetch fresh data
await cacheTrends(ammAccountId, timeRange)
await cacheTrends(ammAccountId, timeRange, xrpOnly)

const updated = getCachedTrends(ammAccountId, timeRange)
const updated = getCachedTrends(ammAccountId, timeRange, xrpOnly)

return res.status(200).json({
...updated?.data,
Expand Down
8 changes: 5 additions & 3 deletions src/containers/AMMPool/InfoCards/AuctionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,15 @@ export const AuctionCard: FC<AuctionCardProps> = ({
if (lpValue == null) {
return null
}
// Must precede the zero shortcut: with no TVL there is no USD figure, and returning 0
// renders "≈ $0.00", which reads as a measurement rather than an absence.
if (!lpTokenBalance || tvlUsd == null) {
return null
}
const num = Number(lpValue)
if (num === 0) {
return 0
}
if (!lpTokenBalance || tvlUsd == null) {
return null
}
return (num / Number(lpTokenBalance)) * tvlUsd
}

Expand Down
33 changes: 21 additions & 12 deletions src/containers/AMMPool/InfoCards/MarketDataCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,35 @@ const BalanceLabel = ({

interface MarketDataCardProps {
losData?: LOSAMMPoolData
/**
* When false, the TVL / volume / fees / APR rows still render but read '--': those values
* can only be priced reliably for XRP-based pools. Keeping the rows preserves the
* card's shape and makes the absence explicit rather than looking like a missing feature.
* Defaults to true so existing callers and tests are unaffected.
*/
isXrpBased?: boolean
balance1: FormattedBalance | null
balance2: FormattedBalance | null
lpTokenBalance: string | undefined
}

export const MarketDataCard: FC<MarketDataCardProps> = ({
losData,
isXrpBased = true,
balance1,
balance2,
lpTokenBalance,
}) => {
const { t } = useTranslation()
const { showTooltip, hideTooltip } = useTooltip()

// APR is fees / TVL, so it inherits TVL's accuracy and is gated alongside it.
// The on-ledger balances and the liquidity-provider count below are unaffected.
const gatedValue = <T,>(
value: T | null | undefined,
format: (v: T) => string,
) => (isXrpBased && value != null ? format(value) : '--')

const renderTooltipIcon = (text: string) => (
<HoverIcon
className="hover"
Expand All @@ -79,9 +94,7 @@ export const MarketDataCard: FC<MarketDataCardProps> = ({
<div className="info-card-row">
<span className="info-card-label">{t('tvl')}</span>
<span className="info-card-value">
{losData.tvl_usd != null
? parseCurrencyAmount(losData.tvl_usd)
: '--'}
{gatedValue(losData.tvl_usd, parseCurrencyAmount)}
</span>
</div>
<div className="info-card-row">
Expand All @@ -90,9 +103,7 @@ export const MarketDataCard: FC<MarketDataCardProps> = ({
{renderTooltipIcon(t('volume_24h_tooltip'))}
</span>
<span className="info-card-value">
{losData.trading_volume_usd != null
? parseCurrencyAmount(losData.trading_volume_usd)
: '--'}
{gatedValue(losData.trading_volume_usd, parseCurrencyAmount)}
</span>
</div>
<div className="info-card-row">
Expand All @@ -101,9 +112,7 @@ export const MarketDataCard: FC<MarketDataCardProps> = ({
{renderTooltipIcon(t('fees_24h_tooltip'))}
</span>
<span className="info-card-value">
{losData.fees_collected_usd != null
? parseCurrencyAmount(losData.fees_collected_usd)
: '--'}
{gatedValue(losData.fees_collected_usd, parseCurrencyAmount)}
</span>
</div>
<div className="info-card-row">
Expand All @@ -112,9 +121,9 @@ export const MarketDataCard: FC<MarketDataCardProps> = ({
{renderTooltipIcon(t('apr_24h_tooltip'))}
</span>
<span className="info-card-value">
{losData.annual_percentage_return != null
? parsePercent(losData.annual_percentage_return, 3, 0.001)
: '--'}
{gatedValue(losData.annual_percentage_return, (v) =>
parsePercent(v, 3, 0.001),
)}
</span>
</div>
</>
Expand Down
45 changes: 45 additions & 0 deletions src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,49 @@ describe('AuctionCard', () => {
expect(screen.getByText(/53\.4K/)).toBeInTheDocument()
})
})

describe('when tvlUsd is suppressed for a non-XRP pool', () => {
// The pool page passes undefined (never 0) so these derived USD values disappear
// rather than rendering a convincing fake $0.00.
it('renders no USD subtitles', () => {
const { container } = renderComponent({
auctionSlot: defaultAuctionSlot,
tvlUsd: undefined,
lpTokenBalance: '5000000',
tradingFee: 500,
})

expect(container.querySelector('.info-card-subtitle')).toBeNull()
expect(container.textContent).not.toContain('$0.00')
expect(container.textContent).not.toContain('NaN')
})

it('still renders the LP token amounts', () => {
renderComponent({
auctionSlot: defaultAuctionSlot,
tvlUsd: undefined,
lpTokenBalance: '5000000',
tradingFee: 500,
})

expect(screen.getByText('price_paid')).toBeInTheDocument()
expect(screen.getByText('replacement_cost')).toBeInTheDocument()
})
})

it('shows no USD estimate for a zero auction price on a non-XRP pool', () => {
// A zero price must not produce "≈ $0.00" when there is no TVL to value it against.
const { container } = renderComponent({
tvlUsd: undefined,
auctionSlot: {
...defaultAuctionSlot,
price: { ...defaultAuctionSlot.price, value: '0' },
},
lpTokenBalance: '1000000',
tradingFee: 500,
})

expect(container.textContent).not.toContain('$0.00')
expect(container.textContent).not.toContain('NaN')
})
})
Loading
Loading