From 7108fafe748c58a2d804145388d9b6da7e0a8ca0 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 17 Sep 2026 17:47:28 -0400 Subject: [PATCH 01/11] Initial commit --- public/locales/en-US/translations.json | 6 +- server/routes/v1/amms.js | 98 ++++++++++++++++--- .../AMMPool/InfoCards/MarketDataCard.tsx | 13 ++- .../InfoCards/test/AuctionCard.test.tsx | 29 ++++++ .../InfoCards/test/MarketDataCard.test.tsx | 35 +++++++ src/containers/AMMPool/index.tsx | 20 +++- src/containers/AMMPool/types.ts | 6 ++ src/containers/AMMRankings/api.ts | 7 ++ 8 files changed, 193 insertions(+), 21 deletions(-) diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 8c55a6a7f..fdbe3c972 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -923,15 +923,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 XRP 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", diff --git a/server/routes/v1/amms.js b/server/routes/v1/amms.js index 2a48cfff7..3a2ff0ffd 100644 --- a/server/routes/v1/amms.js +++ b/server/routes/v1/amms.js @@ -20,6 +20,10 @@ async function fetchAMMs() { size: 1000, sort_field: 'tvl_usd', sort_order: 'desc', + // Only XRP-based pools have their TVL refreshed from the ledger. 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, }) @@ -160,16 +164,22 @@ async function fetchTradingFees(amms) { return tradingFeeMap } -async function fetchAggregatedStats() { +function fetchAggregatedRollup(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) => { @@ -195,6 +205,55 @@ async function fetchAggregatedStats() { }) } +/** + * Build the stat tiles from both LOS rollups, because they are deliberately mixed-scope: + * + * - counts (# of AMMs, # of LPs) describe the whole ecosystem, so they come from the + * all-pools rollup + * - every value figure (TVL, volume, fees) comes from the XRP-only rollup, because those + * are refreshed from the ledger only for XRP-based pools; for token/token pools they + * are derived from illiquid issued-token pricing and are often wildly overstated + * + * LOS keeps the two rollups separate and internally consistent; mixing them is a + * presentation choice for this page, so it lives here rather than in the API. + */ +async function fetchAggregatedStats() { + const [allPools, xrpPools] = await Promise.all([ + fetchAggregatedRollup(false), + fetchAggregatedRollup(true), + ]) + + if (!allPools) { + return null + } + + const merged = { ...allPools } + + if (xrpPools) { + merged.tvl_xrp = xrpPools.tvl_xrp + merged.tvl_usd = xrpPools.tvl_usd + merged.trading_volume_xrp = xrpPools.trading_volume_xrp + merged.trading_volume_usd = xrpPools.trading_volume_usd + merged.fees_collected_xrp = xrpPools.fees_collected_xrp + merged.fees_collected_usd = xrpPools.fees_collected_usd + } else { + // The XRP rollup does not exist until the LOS backfill has run. Omit these rather than + // leaving the all-pools figures in place: those are the inflated numbers this change + // exists to stop showing. Absent values render as "--". + delete merged.tvl_xrp + delete merged.tvl_usd + delete merged.trading_volume_xrp + delete merged.trading_volume_usd + delete merged.fees_collected_xrp + delete merged.fees_collected_usd + log.warn( + 'XRP-only aggregate rollup unavailable - omitting TVL, volume and fees from stats', + ) + } + + return merged +} + function enrichAMMs(amms, tokenDataMap) { return amms.map((amm) => { const token1Key = @@ -423,10 +482,10 @@ 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 @@ -434,6 +493,8 @@ async function fetchHistoricalTrends(ammAccountId, timeRange) { 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, }) @@ -468,14 +529,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}...`) @@ -505,13 +573,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) { @@ -523,9 +593,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, diff --git a/src/containers/AMMPool/InfoCards/MarketDataCard.tsx b/src/containers/AMMPool/InfoCards/MarketDataCard.tsx index f7141fe19..3c0f985a8 100644 --- a/src/containers/AMMPool/InfoCards/MarketDataCard.tsx +++ b/src/containers/AMMPool/InfoCards/MarketDataCard.tsx @@ -39,6 +39,12 @@ const BalanceLabel = ({ interface MarketDataCardProps { losData?: LOSAMMPoolData + /** + * When false, the TVL / volume / fees / APR rows are hidden: those values are only + * refreshed from the ledger for XRP-based pools. Defaults to true so existing callers + * and tests are unaffected. + */ + isXrpBased?: boolean balance1: FormattedBalance | null balance2: FormattedBalance | null lpTokenBalance: string | undefined @@ -46,6 +52,7 @@ interface MarketDataCardProps { export const MarketDataCard: FC = ({ losData, + isXrpBased = true, balance1, balance2, lpTokenBalance, @@ -53,6 +60,10 @@ export const MarketDataCard: FC = ({ 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 showMarketMetrics = !!losData && isXrpBased + const renderTooltipIcon = (text: string) => ( = ({ {t('market_data')}
- {losData && ( + {showMarketMetrics && losData && ( <>
{t('tvl')} diff --git a/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx b/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx index 574a4950e..f87b5c5d1 100644 --- a/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx +++ b/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx @@ -137,4 +137,33 @@ 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() + }) + }) }) diff --git a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx index 045b8c060..8118d755c 100644 --- a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx +++ b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx @@ -8,6 +8,7 @@ import { LOSAMMPoolData, FormattedBalance } from '../../types' interface RenderProps { losData?: LOSAMMPoolData + isXrpBased?: boolean balance1?: FormattedBalance | null balance2?: FormattedBalance | null lpTokenBalance?: string @@ -39,6 +40,7 @@ const defaultBalance2: FormattedBalance = { currency: 'XRP', amount: 50000 } const renderComponent = ({ losData = defaultLosData, + isXrpBased = true, balance1 = defaultBalance1, balance2 = defaultBalance2, lpTokenBalance = '1000000', @@ -49,6 +51,7 @@ const renderComponent = ({ { expect(getRowValue('apr_24h')).toBe('0.046%') }) + it('hides TVL, volume, fees and APR for a non-XRP pool', () => { + // Those values are only refreshed from the ledger for XRP-based pools. + renderComponent({ isXrpBased: false }) + + expect(screen.queryByText('tvl')).not.toBeInTheDocument() + expect(screen.queryByText('volume_24h')).not.toBeInTheDocument() + expect(screen.queryByText('fees_24h')).not.toBeInTheDocument() + expect(screen.queryByText('apr_24h')).not.toBeInTheDocument() + }) + + it('still renders balances and the LP provider count for a non-XRP pool', () => { + // These come from amm_info / are counts, so they stay trustworthy. + const { container } = renderComponent({ isXrpBased: false }) + + const balanceLabels = Array.from( + container.querySelectorAll('.info-card-label'), + ).filter((l) => l.textContent?.includes('BALANCE')) + expect(balanceLabels.length).toBe(2) + + expect(screen.getByText('lp_tokens')).toBeInTheDocument() + expect( + container.querySelector('.info-card-subtitle')?.textContent, + ).toContain('747') + }) + + it('renders no fake zero values when metrics are hidden', () => { + const { container } = renderComponent({ isXrpBased: false }) + + expect(container.textContent).not.toContain('$0.00') + expect(container.textContent).not.toContain('NaN') + }) + it('renders balances with correct formatted values', () => { const { container } = renderComponent() const labels = container.querySelectorAll('.info-card-label') diff --git a/src/containers/AMMPool/index.tsx b/src/containers/AMMPool/index.tsx index 09f62a1f7..098d6e1a2 100644 --- a/src/containers/AMMPool/index.tsx +++ b/src/containers/AMMPool/index.tsx @@ -214,6 +214,19 @@ export const AMMPool = () => { const [asset1, asset2] = orderAssets(balance1, balance2) const hasData = !!ammData || !!deletedData + // Only XRP-based pools have their TVL refreshed from the ledger; for token/token pools + // it is derived from illiquid issued-token pricing and is often overstated, so it must + // not be displayed. The ledger-derived check comes first and is authoritative: it also + // covers pools whose LOS document predates the asset fields or lacks them entirely. + const isXrpBased = + balance1?.currency === 'XRP' || + balance2?.currency === 'XRP' || + losData?.is_xrp_based === true + + // Suppress with undefined, never 0: downstream guards are `!= null`, so 0 would pass + // them and render a convincing fake "$0.00" instead of "--". + const gatedTvlUsd = isXrpBased ? losData?.tvl_usd : undefined + return ( {ammAccountId && isLoading && } @@ -243,6 +256,7 @@ export const AMMPool = () => { {!isDeleted && ( { {!isDeleted && ( )}
- {isMainnet && ( + {isMainnet && isXrpBased && ( ({ @@ -283,7 +297,7 @@ export const AMMPool = () => { tab={tab} isMainnet={isMainnet} lpToken={lpToken} - tvlUsd={losData?.tvl_usd} + tvlUsd={gatedTvlUsd} isDeleted={isDeleted} /> diff --git a/src/containers/AMMPool/types.ts b/src/containers/AMMPool/types.ts index b2f462cdc..0ce20efe8 100644 --- a/src/containers/AMMPool/types.ts +++ b/src/containers/AMMPool/types.ts @@ -15,6 +15,12 @@ export interface LOSAMMPoolData { issuer_2: string | null currency_2: string last_updated_timestamp: string + /** + * True when one side of the pair is XRP. Only these pools have TVL refreshed from the + * ledger, so TVL and volume for other pools must not be displayed. Optional so the field + * surviving a LOS rollback is not a type error - compare explicitly, never on truthiness. + */ + is_xrp_based?: boolean } /** Single data point from LOS /amms/historical-trends response */ diff --git a/src/containers/AMMRankings/api.ts b/src/containers/AMMRankings/api.ts index 8bc55d04f..eab01c02f 100644 --- a/src/containers/AMMRankings/api.ts +++ b/src/containers/AMMRankings/api.ts @@ -15,6 +15,9 @@ export interface AMMPool { annual_percentage_return: number liquidity_provider_count: number amm_created_timestamp: string + // True when one side of the pair is XRP. Only these pools have TVL refreshed from the + // ledger. Optional so a LOS rollback that drops the field is not a type error. + is_xrp_based?: boolean // Trading fee from amm_info RPC (0-1000, where 1000 = 1%) trading_fee?: number // Token data from LOS /tokens/batch-get (server-cached) @@ -30,6 +33,7 @@ export interface AMMRankingsResponse { size: number sort_field: string sort_order: string + xrp_only?: boolean count: number results: AMMPool[] } @@ -96,6 +100,9 @@ export const fetchHistoricalTrends = async ( params: { amm_account_id: 'aggregated', time_range: timeRange, + // Must match the XRP-only list and stat tiles, which the server scopes for us. + // Unlike those, this route is generic over amm_account_id, so the flag is threaded. + xrp_only: true, }, }) return response.data From 2daab38f2d8fe8b0c45682132fdc7b8a3710182f Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Mon, 21 Sep 2026 17:10:47 -0400 Subject: [PATCH 02/11] Add market data banner for token/token pool --- public/locales/en-US/translations.json | 2 + src/containers/AMMPool/index.tsx | 15 +++++++ src/containers/AMMPool/styles.scss | 12 ++++-- src/containers/AMMPool/test/index.test.tsx | 47 ++++++++++++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index fdbe3c972..0a839f78c 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -921,6 +921,8 @@ "asset_2": "Asset 2", "usd_value": "USD Value", "amm_pool_deleted_label": "AMM Pool Deleted", + "amm_pool_limited_data_label": "Limited Market Data", + "amm_pool_limited_data_text": "TVL, volume and fees are not shown for pools without XRP. These values depend on the price of both assets, which cannot be determined reliably for token-to-token pools. On-ledger balances and the liquidity provider count are unaffected.", "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 XRP AMM Pools", diff --git a/src/containers/AMMPool/index.tsx b/src/containers/AMMPool/index.tsx index 098d6e1a2..e3b38845a 100644 --- a/src/containers/AMMPool/index.tsx +++ b/src/containers/AMMPool/index.tsx @@ -246,6 +246,21 @@ export const AMMPool = () => {
)} + {/* Without this, the hidden TVL/volume/fees rows and the missing chart read as a + defect rather than a deliberate omission. Suppressed for deleted pools, whose + market data card is already hidden and which show their own banner above. */} + {!isDeleted && !isXrpBased && ( +
+
+
+
+ {t('amm_pool_limited_data_text')} +
+
+ )} +
{ expect(document.querySelector('.amm-pool-header')).not.toBeInTheDocument() }) }) + + it('shows the limited-data banner for a pool without XRP', async () => { + // Both sides are issued tokens, so isXrpBased is false and the market metrics, + // the derived USD values and the chart are all suppressed. Without the banner that + // reads as missing data rather than a deliberate omission. + mockGetAMMInfo.mockResolvedValue({ + amm: { + ...mockAmmInfoResponse.amm, + amount: { + currency: '524C555344000000000000000000000000000000', + issuer: 'rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De', + value: '1000', + }, + amount2: { + currency: '5553444300000000000000000000000000000000', + issuer: 'rcEGREd8NmkKRE8GE424sksyt1tJVFZwu', + value: '2000', + }, + }, + }) + mockDetectDeleted.mockResolvedValue(null) + + renderComponent() + + await waitFor(() => { + expect( + screen.getByText('amm_pool_limited_data_label'), + ).toBeInTheDocument() + }) + expect(screen.getByText('amm_pool_limited_data_text')).toBeInTheDocument() + // The chart is gated on the same flag + expect(screen.queryByTestId('tvl-volume-chart')).not.toBeInTheDocument() + }) + + it('does not show the limited-data banner for an XRP pool', async () => { + mockGetAMMInfo.mockResolvedValue(mockAmmInfoResponse) + mockDetectDeleted.mockResolvedValue(null) + + renderComponent() + + await waitFor(() => { + expect(screen.getByText('basic_info')).toBeInTheDocument() + }) + expect( + screen.queryByText('amm_pool_limited_data_label'), + ).not.toBeInTheDocument() + }) }) From d227c58150139a2ecfff12cf2e1fa901d0c926ff Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 23 Sep 2026 10:45:09 -0400 Subject: [PATCH 03/11] Withhold non-XRP AMM metrics as -- and drop the limited-data banner --- public/locales/en-US/translations.json | 2 - .../AMMPool/InfoCards/AuctionCard.tsx | 9 ++-- .../AMMPool/InfoCards/MarketDataCard.tsx | 32 ++++++------ .../InfoCards/test/AuctionCard.test.tsx | 17 ++++++ .../InfoCards/test/MarketDataCard.test.tsx | 33 ++++++------ src/containers/AMMPool/TablePicker/index.tsx | 21 +++++++- .../AMMPool/TablePicker/test/index.test.tsx | 52 +++++++++++++++++++ src/containers/AMMPool/index.tsx | 16 +----- src/containers/AMMPool/styles.scss | 12 ++--- src/containers/AMMPool/test/index.test.tsx | 47 ----------------- 10 files changed, 132 insertions(+), 109 deletions(-) diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 0a839f78c..fdbe3c972 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -921,8 +921,6 @@ "asset_2": "Asset 2", "usd_value": "USD Value", "amm_pool_deleted_label": "AMM Pool Deleted", - "amm_pool_limited_data_label": "Limited Market Data", - "amm_pool_limited_data_text": "TVL, volume and fees are not shown for pools without XRP. These values depend on the price of both assets, which cannot be determined reliably for token-to-token pools. On-ledger balances and the liquidity provider count are unaffected.", "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 XRP AMM Pools", diff --git a/src/containers/AMMPool/InfoCards/AuctionCard.tsx b/src/containers/AMMPool/InfoCards/AuctionCard.tsx index 3cfc58202..386ba849d 100644 --- a/src/containers/AMMPool/InfoCards/AuctionCard.tsx +++ b/src/containers/AMMPool/InfoCards/AuctionCard.tsx @@ -91,13 +91,16 @@ export const AuctionCard: FC = ({ if (lpValue == null) { return null } + // This must precede the zero shortcut below. Without a TVL there is no USD figure to + // report at all, and returning 0 renders "≈ $0.00", which reads as a measurement rather + // than an absence — the exact thing suppressing TVL for non-XRP pools is meant to avoid. + 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 } diff --git a/src/containers/AMMPool/InfoCards/MarketDataCard.tsx b/src/containers/AMMPool/InfoCards/MarketDataCard.tsx index 3c0f985a8..72d4d4247 100644 --- a/src/containers/AMMPool/InfoCards/MarketDataCard.tsx +++ b/src/containers/AMMPool/InfoCards/MarketDataCard.tsx @@ -40,9 +40,10 @@ const BalanceLabel = ({ interface MarketDataCardProps { losData?: LOSAMMPoolData /** - * When false, the TVL / volume / fees / APR rows are hidden: those values are only - * refreshed from the ledger for XRP-based pools. Defaults to true so existing callers - * and tests are unaffected. + * When false, the TVL / volume / fees / APR rows still render but read '--': those values + * are only refreshed from the ledger 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 @@ -62,7 +63,10 @@ export const MarketDataCard: FC = ({ // 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 showMarketMetrics = !!losData && isXrpBased + const gatedValue = ( + value: T | null | undefined, + format: (v: T) => string, + ) => (isXrpBased && value != null ? format(value) : '--') const renderTooltipIcon = (text: string) => ( = ({ {t('market_data')}
- {showMarketMetrics && losData && ( + {losData && ( <>
{t('tvl')} - {losData.tvl_usd != null - ? parseCurrencyAmount(losData.tvl_usd) - : '--'} + {gatedValue(losData.tvl_usd, parseCurrencyAmount)}
@@ -101,9 +103,7 @@ export const MarketDataCard: FC = ({ {renderTooltipIcon(t('volume_24h_tooltip'))} - {losData.trading_volume_usd != null - ? parseCurrencyAmount(losData.trading_volume_usd) - : '--'} + {gatedValue(losData.trading_volume_usd, parseCurrencyAmount)}
@@ -112,9 +112,7 @@ export const MarketDataCard: FC = ({ {renderTooltipIcon(t('fees_24h_tooltip'))} - {losData.fees_collected_usd != null - ? parseCurrencyAmount(losData.fees_collected_usd) - : '--'} + {gatedValue(losData.fees_collected_usd, parseCurrencyAmount)}
@@ -123,9 +121,9 @@ export const MarketDataCard: FC = ({ {renderTooltipIcon(t('apr_24h_tooltip'))} - {losData.annual_percentage_return != null - ? parsePercent(losData.annual_percentage_return, 3, 0.001) - : '--'} + {gatedValue(losData.annual_percentage_return, (v) => + parsePercent(v, 3, 0.001), + )}
diff --git a/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx b/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx index f87b5c5d1..153232c02 100644 --- a/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx +++ b/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx @@ -166,4 +166,21 @@ describe('AuctionCard', () => { expect(screen.getByText('replacement_cost')).toBeInTheDocument() }) }) + + it('shows no USD estimate for a zero auction price on a non-XRP pool', () => { + // Regression: getLPTokenUSD used to shortcut `num === 0` to 0 before checking tvlUsd, so a + // zero-priced slot rendered "≈ $0.00" even with TVL suppressed. + 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') + }) }) diff --git a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx index 8118d755c..474d06dff 100644 --- a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx +++ b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx @@ -61,6 +61,14 @@ const renderComponent = ({ , ) +/** Reads the value cell of the info-card row whose label contains `label`. */ +const getRowValue = (label: string) => { + const row = Array.from(document.querySelectorAll('.info-card-row')).find( + (r) => r.querySelector('.info-card-label')?.textContent?.includes(label), + ) + return row?.querySelector('.info-card-value')?.textContent +} + describe('MarketDataCard', () => { it('renders Market Data title', () => { renderComponent() @@ -68,14 +76,7 @@ describe('MarketDataCard', () => { }) it('renders LOS fields with correct formatted values', () => { - const { container } = renderComponent() - const rows = container.querySelectorAll('.info-card-row') - const getRowValue = (label: string) => { - const row = Array.from(rows).find((r) => - r.querySelector('.info-card-label')?.textContent?.includes(label), - ) - return row?.querySelector('.info-card-value')?.textContent - } + renderComponent() expect(screen.getByText('tvl')).toBeInTheDocument() expect(getRowValue('tvl')).toBe('$1.2M') @@ -90,14 +91,16 @@ describe('MarketDataCard', () => { expect(getRowValue('apr_24h')).toBe('0.046%') }) - it('hides TVL, volume, fees and APR for a non-XRP pool', () => { - // Those values are only refreshed from the ledger for XRP-based pools. + it("shows '--' for TVL, volume, fees and APR on a non-XRP pool", () => { + // Those values are only refreshed from the ledger for XRP-based pools. The rows stay so + // the card keeps its shape and the absence is explicit; only the figures are withheld. renderComponent({ isXrpBased: false }) - expect(screen.queryByText('tvl')).not.toBeInTheDocument() - expect(screen.queryByText('volume_24h')).not.toBeInTheDocument() - expect(screen.queryByText('fees_24h')).not.toBeInTheDocument() - expect(screen.queryByText('apr_24h')).not.toBeInTheDocument() + expect(screen.getByText('tvl')).toBeInTheDocument() + expect(getRowValue('tvl')).toBe('--') + expect(getRowValue('volume_24h')).toBe('--') + expect(getRowValue('fees_24h')).toBe('--') + expect(getRowValue('apr_24h')).toBe('--') }) it('still renders balances and the LP provider count for a non-XRP pool', () => { @@ -115,7 +118,7 @@ describe('MarketDataCard', () => { ).toContain('747') }) - it('renders no fake zero values when metrics are hidden', () => { + it('renders no fake zero values when metrics are withheld', () => { const { container } = renderComponent({ isXrpBased: false }) expect(container.textContent).not.toContain('$0.00') diff --git a/src/containers/AMMPool/TablePicker/index.tsx b/src/containers/AMMPool/TablePicker/index.tsx index fd518225c..01eaca14b 100644 --- a/src/containers/AMMPool/TablePicker/index.tsx +++ b/src/containers/AMMPool/TablePicker/index.tsx @@ -60,6 +60,14 @@ interface AMMPoolTablePickerProps { lpToken?: { currency: string; issuer: string; value: string } tvlUsd?: number isDeleted?: boolean + /** + * When false, the USD column on the deposits and withdrawals tables is suppressed. + * + * Unlike the holders column, that figure is not derived from `tvlUsd` — LOS backfills it + * from Caspian's `total_value_usd`, which is populated for token/token pools too and is + * priced the same untrustworthy way as their TVL. Gating on `tvlUsd` alone would miss it. + */ + isXrpBased?: boolean } export const AMMPoolTablePicker: FC = ({ @@ -69,6 +77,7 @@ export const AMMPoolTablePicker: FC = ({ lpToken, tvlUsd, isDeleted = false, + isXrpBased = true, }) => { const { t } = useTranslation() const { trackException } = useAnalytics() @@ -170,6 +179,14 @@ export const AMMPoolTablePicker: FC = ({ }, ) + // Suppressed with null rather than 0: AMMDepositWithdrawTable guards with `!= null` and + // renders '--', whereas 0 would render a convincing fake $0.00. + const gateValueUsd = useCallback( + (items: AMMDepositWithdrawFormatted[]) => + isXrpBased ? items : items.map((tx) => ({ ...tx, valueUsd: null })), + [isXrpBased], + ) + const handleTabChange = useCallback((tabId: string) => { setActiveTab(tabId) }, []) @@ -225,7 +242,7 @@ export const AMMPoolTablePicker: FC = ({ {activeTab === 'deposits' && isMainnet && ( = ({ {activeTab === 'withdrawals' && isMainnet && ( ({ fetchAMMTransactions: jest.fn().mockResolvedValue({ data: [], total: 0 }), })) +// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require +const { fetchAMMTransactions } = require('../../api') + jest.mock('../../../Token/IOU/api/holders', () => jest.fn().mockResolvedValue({ holders: [], totalHolders: 0 }), ) @@ -48,6 +51,7 @@ interface RenderProps { lpToken?: { currency: string; issuer: string; value: string } tvlUsd?: number isDeleted?: boolean + isXrpBased?: boolean } const renderComponent = ({ @@ -61,6 +65,7 @@ const renderComponent = ({ }, tvlUsd = 1000000, isDeleted = false, + isXrpBased = true, }: RenderProps = {}) => render( @@ -73,6 +78,7 @@ const renderComponent = ({ lpToken={lpToken} tvlUsd={tvlUsd} isDeleted={isDeleted} + isXrpBased={isXrpBased} /> @@ -143,3 +149,49 @@ describe('AMMPoolTablePicker', () => { }) }) }) + +describe('AMMPoolTablePicker deposit/withdraw USD gating', () => { + // LOS backfills amm.value_usd from Caspian's total_value_usd, which is populated for + // token/token pools as well and priced the same unreliable way as their TVL. It does not + // come from tvlUsd, so suppressing tvlUsd alone leaves this column showing a bad figure. + const RAW_DEPOSIT = { + hash: 'DEPOSIT_HASH', + ledger_index: 100, + timestamp: 1000000, + account: 'rDepositor', + amm: { + asset1: { currency: 'USD', issuer: 'rIssuerA', value: '500' }, + asset2: { currency: 'EUR', issuer: 'rIssuerB', value: '250' }, + lp_tokens_received: '1000', + value_usd: 987654, + }, + } + + beforeEach(() => { + jest.clearAllMocks() + queryClient.clear() + ;(fetchAMMTransactions as jest.Mock).mockResolvedValue({ + results: [RAW_DEPOSIT], + }) + }) + + it('renders the USD value for an XRP-based pool', async () => { + renderComponent({ tab: 'deposits', isXrpBased: true }) + + expect(await screen.findByText('DEPOSIT_HASH')).toBeInTheDocument() + expect(screen.getByText(/987/)).toBeInTheDocument() + }) + + it('suppresses the USD value for a non-XRP pool but keeps the row', async () => { + const { container } = renderComponent({ + tab: 'deposits', + isXrpBased: false, + }) + + // The transaction itself is real and still worth browsing — only the valuation goes. + expect(await screen.findByText('DEPOSIT_HASH')).toBeInTheDocument() + expect(screen.queryByText(/987/)).not.toBeInTheDocument() + expect(container.textContent).not.toContain('NaN') + expect(container.textContent).not.toContain('$0.00') + }) +}) diff --git a/src/containers/AMMPool/index.tsx b/src/containers/AMMPool/index.tsx index e3b38845a..fc2688571 100644 --- a/src/containers/AMMPool/index.tsx +++ b/src/containers/AMMPool/index.tsx @@ -246,21 +246,6 @@ export const AMMPool = () => {
)} - {/* Without this, the hidden TVL/volume/fees rows and the missing chart read as a - defect rather than a deliberate omission. Suppressed for deleted pools, whose - market data card is already hidden and which show their own banner above. */} - {!isDeleted && !isXrpBased && ( -
-
-
-
- {t('amm_pool_limited_data_text')} -
-
- )} -
{ lpToken={lpToken} tvlUsd={gatedTvlUsd} isDeleted={isDeleted} + isXrpBased={isXrpBased} /> )} diff --git a/src/containers/AMMPool/styles.scss b/src/containers/AMMPool/styles.scss index 4fad69f2d..15444036d 100644 --- a/src/containers/AMMPool/styles.scss +++ b/src/containers/AMMPool/styles.scss @@ -11,8 +11,7 @@ // ============================================================ // Header: "AMM Pool [CRYPTO.rRbi/XRP]" -.amm-deleted-banner, -.amm-info-banner { +.amm-deleted-banner { display: flex; flex-direction: column; padding: 25px; @@ -22,8 +21,7 @@ background: $black-90; gap: 12px; - .deleted-label, - .banner-label { + .deleted-label { display: flex; align-items: center; align-self: flex-start; @@ -37,16 +35,14 @@ text-transform: uppercase; @include semibold; - .deleted-info-icon, - .banner-info-icon { + .deleted-info-icon { width: 16px; height: 16px; margin-right: 3px; } } - .deleted-message, - .banner-message { + .deleted-message { margin: 0; color: $black-0; font-size: 16px; diff --git a/src/containers/AMMPool/test/index.test.tsx b/src/containers/AMMPool/test/index.test.tsx index 254c27b61..30fc06b49 100644 --- a/src/containers/AMMPool/test/index.test.tsx +++ b/src/containers/AMMPool/test/index.test.tsx @@ -165,51 +165,4 @@ describe('AMMPool Page', () => { expect(document.querySelector('.amm-pool-header')).not.toBeInTheDocument() }) }) - - it('shows the limited-data banner for a pool without XRP', async () => { - // Both sides are issued tokens, so isXrpBased is false and the market metrics, - // the derived USD values and the chart are all suppressed. Without the banner that - // reads as missing data rather than a deliberate omission. - mockGetAMMInfo.mockResolvedValue({ - amm: { - ...mockAmmInfoResponse.amm, - amount: { - currency: '524C555344000000000000000000000000000000', - issuer: 'rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De', - value: '1000', - }, - amount2: { - currency: '5553444300000000000000000000000000000000', - issuer: 'rcEGREd8NmkKRE8GE424sksyt1tJVFZwu', - value: '2000', - }, - }, - }) - mockDetectDeleted.mockResolvedValue(null) - - renderComponent() - - await waitFor(() => { - expect( - screen.getByText('amm_pool_limited_data_label'), - ).toBeInTheDocument() - }) - expect(screen.getByText('amm_pool_limited_data_text')).toBeInTheDocument() - // The chart is gated on the same flag - expect(screen.queryByTestId('tvl-volume-chart')).not.toBeInTheDocument() - }) - - it('does not show the limited-data banner for an XRP pool', async () => { - mockGetAMMInfo.mockResolvedValue(mockAmmInfoResponse) - mockDetectDeleted.mockResolvedValue(null) - - renderComponent() - - await waitFor(() => { - expect(screen.getByText('basic_info')).toBeInTheDocument() - }) - expect( - screen.queryByText('amm_pool_limited_data_label'), - ).not.toBeInTheDocument() - }) }) From 75f37e0b1b2dd33d6668248e9d5e288f46ab8878 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 23 Sep 2026 10:59:47 -0400 Subject: [PATCH 04/11] naming refactoring --- public/locales/en-US/translations.json | 6 +++--- server/routes/v1/amms.js | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index fdbe3c972..90a0d400e 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -923,15 +923,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 XRP AMM Pools", + "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 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.", + "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", diff --git a/server/routes/v1/amms.js b/server/routes/v1/amms.js index 3a2ff0ffd..8642f81fd 100644 --- a/server/routes/v1/amms.js +++ b/server/routes/v1/amms.js @@ -164,7 +164,7 @@ async function fetchTradingFees(amms) { return tradingFeeMap } -function fetchAggregatedRollup(xrpOnly) { +function fetchAggregate(xrpOnly) { const url = `${process.env.VITE_LOS_URL}/amms/aggregated` log.info(`Fetching aggregated stats from: ${url} (xrp_only=${!!xrpOnly})`) @@ -206,21 +206,21 @@ function fetchAggregatedRollup(xrpOnly) { } /** - * Build the stat tiles from both LOS rollups, because they are deliberately mixed-scope: + * Build the stat tiles from both LOS aggregates, because they are deliberately mixed-scope: * * - counts (# of AMMs, # of LPs) describe the whole ecosystem, so they come from the - * all-pools rollup - * - every value figure (TVL, volume, fees) comes from the XRP-only rollup, because those + * all-pools aggregate + * - every value figure (TVL, volume, fees) comes from the XRP-only aggregate, because those * are refreshed from the ledger only for XRP-based pools; for token/token pools they * are derived from illiquid issued-token pricing and are often wildly overstated * - * LOS keeps the two rollups separate and internally consistent; mixing them is a + * LOS keeps the two aggregates separate and internally consistent; mixing them is a * presentation choice for this page, so it lives here rather than in the API. */ async function fetchAggregatedStats() { const [allPools, xrpPools] = await Promise.all([ - fetchAggregatedRollup(false), - fetchAggregatedRollup(true), + fetchAggregate(false), + fetchAggregate(true), ]) if (!allPools) { @@ -237,9 +237,9 @@ async function fetchAggregatedStats() { merged.fees_collected_xrp = xrpPools.fees_collected_xrp merged.fees_collected_usd = xrpPools.fees_collected_usd } else { - // The XRP rollup does not exist until the LOS backfill has run. Omit these rather than - // leaving the all-pools figures in place: those are the inflated numbers this change - // exists to stop showing. Absent values render as "--". + // The XRP aggregate is missing - an ETL outage, or a LOS version without it. Omit these + // rather than leaving the all-pools figures in place: those are the inflated numbers this + // change exists to stop showing. Absent values render as "--". delete merged.tvl_xrp delete merged.tvl_usd delete merged.trading_volume_xrp @@ -247,7 +247,7 @@ async function fetchAggregatedStats() { delete merged.fees_collected_xrp delete merged.fees_collected_usd log.warn( - 'XRP-only aggregate rollup unavailable - omitting TVL, volume and fees from stats', + 'XRP-only aggregate unavailable - omitting TVL, volume and fees from stats', ) } From 31fb8f2270d67d1b8b8a42188a6134e3da78085c Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 23 Sep 2026 11:20:12 -0400 Subject: [PATCH 05/11] test update --- .../InfoCards/test/MarketDataCard.test.tsx | 7 -- src/containers/AMMPool/test/index.test.tsx | 64 +++++++++++++++++++ src/containers/AMMRankings/test/api.test.ts | 36 +++++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 src/containers/AMMRankings/test/api.test.ts diff --git a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx index 474d06dff..58aad6aee 100644 --- a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx +++ b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx @@ -118,13 +118,6 @@ describe('MarketDataCard', () => { ).toContain('747') }) - it('renders no fake zero values when metrics are withheld', () => { - const { container } = renderComponent({ isXrpBased: false }) - - expect(container.textContent).not.toContain('$0.00') - expect(container.textContent).not.toContain('NaN') - }) - it('renders balances with correct formatted values', () => { const { container } = renderComponent() const labels = container.querySelectorAll('.info-card-label') diff --git a/src/containers/AMMPool/test/index.test.tsx b/src/containers/AMMPool/test/index.test.tsx index 30fc06b49..c6b9dbdd8 100644 --- a/src/containers/AMMPool/test/index.test.tsx +++ b/src/containers/AMMPool/test/index.test.tsx @@ -6,6 +6,7 @@ import { QuickHarness } from '../../test/utils' import { AMM_POOL_ROUTE } from '../../App/routes' import * as rippled from '../../../rippled/lib/rippled' import * as ammUtils from '../utils' +import * as api from '../api' jest.mock('../../../rippled/lib/rippled') jest.mock('../utils') @@ -80,6 +81,7 @@ describe('AMMPool Page', () => { expect(screen.getByText('basic_info')).toBeInTheDocument() expect(screen.getByText('auction')).toBeInTheDocument() expect(screen.getByTestId('table-picker')).toBeInTheDocument() + expect(screen.getByTestId('tvl-volume-chart')).toBeInTheDocument() }) }) @@ -165,4 +167,66 @@ describe('AMMPool Page', () => { expect(document.querySelector('.amm-pool-header')).not.toBeInTheDocument() }) }) + + // isXrpBased decides whether TVL-derived figures and the chart are shown at all. It reads + // the ledger first and only then LOS's flag, so these cover both clauses and their order. + describe('isXrpBased resolution', () => { + const tokenTokenAmmInfo = { + amm: { + ...mockAmmInfoResponse.amm, + amount: { + currency: '524C555344000000000000000000000000000000', + issuer: 'rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De', + value: '1000', + }, + amount2: { + currency: '5553444300000000000000000000000000000000', + issuer: 'rcEGREd8NmkKRE8GE424sksyt1tJVFZwu', + value: '2000', + }, + }, + } + + it('hides the TVL/volume chart for a token/token pool', async () => { + mockGetAMMInfo.mockResolvedValue(tokenTokenAmmInfo) + + renderComponent() + + await waitFor(() => { + expect(screen.getByText('basic_info')).toBeInTheDocument() + }) + expect(screen.queryByTestId('tvl-volume-chart')).not.toBeInTheDocument() + }) + + it('trusts the ledger over a false is_xrp_based flag', async () => { + // A pool whose LOS document lacks asset fields reports is_xrp_based: false. The XRP + // balance from amm_info is authoritative, so the chart must still render — trusting the + // flag alone would hide TVL for a genuine XRP pool. + mockGetAMMInfo.mockResolvedValue(mockAmmInfoResponse) + ;(api.fetchAMMPoolData as jest.Mock).mockResolvedValue({ + tvl_usd: 1000, + is_xrp_based: false, + }) + + renderComponent() + + await waitFor(() => { + expect(screen.getByTestId('tvl-volume-chart')).toBeInTheDocument() + }) + }) + + it('falls back to is_xrp_based when neither balance is XRP', async () => { + mockGetAMMInfo.mockResolvedValue(tokenTokenAmmInfo) + ;(api.fetchAMMPoolData as jest.Mock).mockResolvedValue({ + tvl_usd: 1000, + is_xrp_based: true, + }) + + renderComponent() + + await waitFor(() => { + expect(screen.getByTestId('tvl-volume-chart')).toBeInTheDocument() + }) + }) + }) }) diff --git a/src/containers/AMMRankings/test/api.test.ts b/src/containers/AMMRankings/test/api.test.ts new file mode 100644 index 000000000..89c8ba328 --- /dev/null +++ b/src/containers/AMMRankings/test/api.test.ts @@ -0,0 +1,36 @@ +import axios from 'axios' +import { fetchHistoricalTrends, fetchAMMHistoricalTrends } from '../api' + +jest.mock('axios') + +describe('AMMRankings historical trends API', () => { + const mockAxios = axios as jest.Mocked + + beforeEach(() => { + jest.clearAllMocks() + mockAxios.get.mockResolvedValue({ data: { data_points: [] } }) + }) + + const paramsOfLastCall = () => mockAxios.get.mock.calls[0][1]?.params + + // The rankings page list and stat tiles are scoped to XRP pools by the explorer server. This + // route is generic over amm_account_id, so the flag has to be sent explicitly — without it + // the chart silently plots the all-pools series beside XRP-only tiles. + it('requests the XRP-only series for the aggregate chart', async () => { + await fetchHistoricalTrends('6M') + + expect(paramsOfLastCall()).toMatchObject({ + amm_account_id: 'aggregated', + time_range: '6M', + xrp_only: true, + }) + }) + + // A single pool's series is already pool-scoped, and LOS ignores xrp_only when + // amm_account_id names a specific pool. + it('does not send xrp_only for a single pool series', async () => { + await fetchAMMHistoricalTrends('rLjUKpwUVmz3vCTmFkXungxwzdoyrWRsFG', '1M') + + expect(paramsOfLastCall()).not.toHaveProperty('xrp_only') + }) +}) From 23205ae4b224dd8ccbf1d8b1f63d35ed7eb6b33e Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 23 Sep 2026 11:28:20 -0400 Subject: [PATCH 06/11] update comments --- server/routes/v1/amms.js | 7 ++++--- src/containers/AMMPool/InfoCards/MarketDataCard.tsx | 2 +- .../AMMPool/InfoCards/test/MarketDataCard.test.tsx | 2 +- src/containers/AMMPool/TablePicker/index.tsx | 4 ---- src/containers/AMMPool/index.tsx | 13 +++++++++---- src/containers/AMMPool/types.ts | 2 +- src/containers/AMMRankings/api.ts | 5 +++-- 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/server/routes/v1/amms.js b/server/routes/v1/amms.js index 8642f81fd..eaa6638f6 100644 --- a/server/routes/v1/amms.js +++ b/server/routes/v1/amms.js @@ -20,7 +20,7 @@ async function fetchAMMs() { size: 1000, sort_field: 'tvl_usd', sort_order: 'desc', - // Only XRP-based pools have their TVL refreshed from the ledger. Without this, + // 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, @@ -211,8 +211,9 @@ function fetchAggregate(xrpOnly) { * - counts (# of AMMs, # of LPs) describe the whole ecosystem, so they come from the * all-pools aggregate * - every value figure (TVL, volume, fees) comes from the XRP-only aggregate, because those - * are refreshed from the ledger only for XRP-based pools; for token/token pools they - * are derived from illiquid issued-token pricing and are often wildly overstated + * can only be priced reliably for XRP-based pools, where one side has a market price; + * for token/token pools both sides are issued tokens and the figure is often wildly + * overstated * * LOS keeps the two aggregates separate and internally consistent; mixing them is a * presentation choice for this page, so it lives here rather than in the API. diff --git a/src/containers/AMMPool/InfoCards/MarketDataCard.tsx b/src/containers/AMMPool/InfoCards/MarketDataCard.tsx index 72d4d4247..df1994f9d 100644 --- a/src/containers/AMMPool/InfoCards/MarketDataCard.tsx +++ b/src/containers/AMMPool/InfoCards/MarketDataCard.tsx @@ -41,7 +41,7 @@ interface MarketDataCardProps { losData?: LOSAMMPoolData /** * When false, the TVL / volume / fees / APR rows still render but read '--': those values - * are only refreshed from the ledger for XRP-based pools. Keeping the rows preserves the + * 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. */ diff --git a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx index 58aad6aee..fb4590ca4 100644 --- a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx +++ b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx @@ -92,7 +92,7 @@ describe('MarketDataCard', () => { }) it("shows '--' for TVL, volume, fees and APR on a non-XRP pool", () => { - // Those values are only refreshed from the ledger for XRP-based pools. The rows stay so + // Those values can only be priced reliably for XRP-based pools. The rows stay so // the card keeps its shape and the absence is explicit; only the figures are withheld. renderComponent({ isXrpBased: false }) diff --git a/src/containers/AMMPool/TablePicker/index.tsx b/src/containers/AMMPool/TablePicker/index.tsx index 01eaca14b..d92b6d440 100644 --- a/src/containers/AMMPool/TablePicker/index.tsx +++ b/src/containers/AMMPool/TablePicker/index.tsx @@ -62,10 +62,6 @@ interface AMMPoolTablePickerProps { isDeleted?: boolean /** * When false, the USD column on the deposits and withdrawals tables is suppressed. - * - * Unlike the holders column, that figure is not derived from `tvlUsd` — LOS backfills it - * from Caspian's `total_value_usd`, which is populated for token/token pools too and is - * priced the same untrustworthy way as their TVL. Gating on `tvlUsd` alone would miss it. */ isXrpBased?: boolean } diff --git a/src/containers/AMMPool/index.tsx b/src/containers/AMMPool/index.tsx index fc2688571..83a0243d7 100644 --- a/src/containers/AMMPool/index.tsx +++ b/src/containers/AMMPool/index.tsx @@ -214,10 +214,15 @@ export const AMMPool = () => { const [asset1, asset2] = orderAssets(balance1, balance2) const hasData = !!ammData || !!deletedData - // Only XRP-based pools have their TVL refreshed from the ledger; for token/token pools - // it is derived from illiquid issued-token pricing and is often overstated, so it must - // not be displayed. The ledger-derived check comes first and is authoritative: it also - // covers pools whose LOS document predates the asset fields or lacks them entirely. + // A token/token pool's TVL cannot be priced reliably, so it must not be displayed: both + // sides are issued tokens, and a pool seeded by an issuer valuing its own freely-minted + // token is often overstated by orders of magnitude. An XRP pool has one side with a market + // price to anchor on. Both figures come from Caspian - what differs is whether an anchor + // exists, not where the number is sourced. + // + // The balances come from amm_info, so that check is a ledger fact and takes precedence over + // the flag: a pool whose LOS document lacks asset fields reports is_xrp_based: false, and + // trusting the flag alone would hide TVL for a genuine XRP pool. const isXrpBased = balance1?.currency === 'XRP' || balance2?.currency === 'XRP' || diff --git a/src/containers/AMMPool/types.ts b/src/containers/AMMPool/types.ts index 0ce20efe8..5bea9efa7 100644 --- a/src/containers/AMMPool/types.ts +++ b/src/containers/AMMPool/types.ts @@ -16,7 +16,7 @@ export interface LOSAMMPoolData { currency_2: string last_updated_timestamp: string /** - * True when one side of the pair is XRP. Only these pools have TVL refreshed from the + * True when one side of the pair is XRP, which is what makes the pool's TVL priceable. * ledger, so TVL and volume for other pools must not be displayed. Optional so the field * surviving a LOS rollback is not a type error - compare explicitly, never on truthiness. */ diff --git a/src/containers/AMMRankings/api.ts b/src/containers/AMMRankings/api.ts index eab01c02f..809eaec70 100644 --- a/src/containers/AMMRankings/api.ts +++ b/src/containers/AMMRankings/api.ts @@ -15,8 +15,9 @@ export interface AMMPool { annual_percentage_return: number liquidity_provider_count: number amm_created_timestamp: string - // True when one side of the pair is XRP. Only these pools have TVL refreshed from the - // ledger. Optional so a LOS rollback that drops the field is not a type error. + // True when one side of the pair is XRP, which is what makes the pool's TVL priceable: + // that side has a market price to anchor on. Optional so a LOS rollback that drops the + // field is not a type error. is_xrp_based?: boolean // Trading fee from amm_info RPC (0-1000, where 1000 = 1%) trading_fee?: number From 73981e7c2995f11b43deb4f247c2d55a69f65b9a Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 23 Sep 2026 12:59:32 -0400 Subject: [PATCH 07/11] Update comments --- server/routes/v1/amms.js | 17 +++++++---------- src/containers/AMMPool/index.tsx | 13 ++++--------- src/containers/AMMRankings/api.ts | 4 ++-- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/server/routes/v1/amms.js b/server/routes/v1/amms.js index eaa6638f6..88d4034e8 100644 --- a/server/routes/v1/amms.js +++ b/server/routes/v1/amms.js @@ -206,17 +206,14 @@ function fetchAggregate(xrpOnly) { } /** - * Build the stat tiles from both LOS aggregates, because they are deliberately mixed-scope: + * 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 * - * - counts (# of AMMs, # of LPs) describe the whole ecosystem, so they come from the - * all-pools aggregate - * - every value figure (TVL, volume, fees) comes from the XRP-only aggregate, because those - * can only be priced reliably for XRP-based pools, where one side has a market price; - * for token/token pools both sides are issued tokens and the figure is often wildly - * overstated - * - * LOS keeps the two aggregates separate and internally consistent; mixing them is a - * presentation choice for this page, so it lives here rather than in the API. + * 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([ diff --git a/src/containers/AMMPool/index.tsx b/src/containers/AMMPool/index.tsx index 83a0243d7..83ad88ee4 100644 --- a/src/containers/AMMPool/index.tsx +++ b/src/containers/AMMPool/index.tsx @@ -214,15 +214,10 @@ export const AMMPool = () => { const [asset1, asset2] = orderAssets(balance1, balance2) const hasData = !!ammData || !!deletedData - // A token/token pool's TVL cannot be priced reliably, so it must not be displayed: both - // sides are issued tokens, and a pool seeded by an issuer valuing its own freely-minted - // token is often overstated by orders of magnitude. An XRP pool has one side with a market - // price to anchor on. Both figures come from Caspian - what differs is whether an anchor - // exists, not where the number is sourced. - // - // The balances come from amm_info, so that check is a ledger fact and takes precedence over - // the flag: a pool whose LOS document lacks asset fields reports is_xrp_based: false, and - // trusting the flag alone would hide TVL for a genuine XRP pool. + // An XRP pool has one side with a market price to anchor its TVL; a token/token pool has + // none, so its TVL must not be displayed. Both come from Caspian - the anchor is what differs. + // The amm_info balances outrank the flag: a pool whose LOS document lacks asset fields + // reports is_xrp_based: false, and trusting the flag alone would hide a real XRP pool's TVL. const isXrpBased = balance1?.currency === 'XRP' || balance2?.currency === 'XRP' || diff --git a/src/containers/AMMRankings/api.ts b/src/containers/AMMRankings/api.ts index 809eaec70..d0b761443 100644 --- a/src/containers/AMMRankings/api.ts +++ b/src/containers/AMMRankings/api.ts @@ -101,8 +101,7 @@ export const fetchHistoricalTrends = async ( params: { amm_account_id: 'aggregated', time_range: timeRange, - // Must match the XRP-only list and stat tiles, which the server scopes for us. - // Unlike those, this route is generic over amm_account_id, so the flag is threaded. + // The chart sits beside the XRP-only stat tiles and has to match them. xrp_only: true, }, }) @@ -120,6 +119,7 @@ export const fetchAMMHistoricalTrends = async ( params: { amm_account_id: ammAccountId, time_range: timeRange, + // No xrp_only: LOS ignores it when amm_account_id names a specific pool. }, }) return response.data From 1b8e8650102c55716b82a4bc1fdfda2333cdc479 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Wed, 23 Sep 2026 15:53:15 -0400 Subject: [PATCH 08/11] update comment --- src/containers/AMMPool/InfoCards/AuctionCard.tsx | 5 ++--- src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/containers/AMMPool/InfoCards/AuctionCard.tsx b/src/containers/AMMPool/InfoCards/AuctionCard.tsx index 386ba849d..8b1bf0df6 100644 --- a/src/containers/AMMPool/InfoCards/AuctionCard.tsx +++ b/src/containers/AMMPool/InfoCards/AuctionCard.tsx @@ -91,9 +91,8 @@ export const AuctionCard: FC = ({ if (lpValue == null) { return null } - // This must precede the zero shortcut below. Without a TVL there is no USD figure to - // report at all, and returning 0 renders "≈ $0.00", which reads as a measurement rather - // than an absence — the exact thing suppressing TVL for non-XRP pools is meant to avoid. + // 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 } diff --git a/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx b/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx index 153232c02..c11094dc6 100644 --- a/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx +++ b/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx @@ -168,8 +168,7 @@ describe('AuctionCard', () => { }) it('shows no USD estimate for a zero auction price on a non-XRP pool', () => { - // Regression: getLPTokenUSD used to shortcut `num === 0` to 0 before checking tvlUsd, so a - // zero-priced slot rendered "≈ $0.00" even with TVL suppressed. + // A zero price must not produce "≈ $0.00" when there is no TVL to value it against. const { container } = renderComponent({ tvlUsd: undefined, auctionSlot: { From 159b3e2378b0135cb9ddc921f82d2cd7c103c567 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 24 Sep 2026 11:00:52 -0400 Subject: [PATCH 09/11] Retry failed account_info calls via ledger_entry so AMM account pages redirect instead of erroring --- src/rippled/lib/rippled.ts | 34 +++++++++- src/rippled/lib/test/rippled.test.ts | 93 ++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/rippled/lib/rippled.ts b/src/rippled/lib/rippled.ts index 487b6f380..f56d3c1aa 100644 --- a/src/rippled/lib/rippled.ts +++ b/src/rippled/lib/rippled.ts @@ -4,6 +4,9 @@ import type { ExplorerXrplClient } from '../../containers/shared/SocketContext' import { CTID_REGEX, HASH256_REGEX } from '../../containers/shared/utils' import { formatAmount } from './txSummary/formatAmount' import { Error, XRP_BASE, convertRippleDate } from './utils' +import logger from './logger' + +const log = logger({ name: 'rippled' }) const N_UNL_INDEX = '2E8A59AA9D3B5B186B0B9E0F62E6C02587CA74A4D778938E957B6357D364B244' @@ -197,6 +200,32 @@ const getTransaction = async ( return resp } +/** + * Read an AccountRoot via ledger_entry, for nodes whose account_info fails on pseudo-accounts + * (AMM, vault). Returns the same fields, AMMID included; signer lists are unavailable here. + */ +const getAccountRoot = async ( + rippledSocket: ExplorerXrplClient, + account: string | unknown, +): Promise => { + const resp = await query(rippledSocket, { + command: 'ledger_entry', + account_root: account, + ledger_index: 'validated', + }) + if (resp.error === 'entryNotFound') { + throw new Error('account not found', 404) + } + + if (resp.error_message) { + throw new Error(resp.error_message, 500) + } + + return Object.assign(resp.node, { + ledger_index: resp.ledger_index, + }) +} + const getAccountInfo = async ( rippledSocket: ExplorerXrplClient, account: string | unknown, @@ -214,7 +243,10 @@ const getAccountInfo = async ( } if (resp.error_message) { - throw new Error(resp.error_message, 500) + log.warn( + `account_info failed for ${account} (${resp.error_message}); retrying via ledger_entry`, + ) + return getAccountRoot(rippledSocket, account) } return Object.assign(resp.account_data, { diff --git a/src/rippled/lib/test/rippled.test.ts b/src/rippled/lib/test/rippled.test.ts index e12d5303f..e70927205 100644 --- a/src/rippled/lib/test/rippled.test.ts +++ b/src/rippled/lib/test/rippled.test.ts @@ -3,6 +3,7 @@ import { getLoanBroker, getMPTIssuance, getNegativeUNL, + getAccountInfo, } from '../rippled' const VAULT_INDEX = @@ -226,3 +227,95 @@ describe('getNegativeUNL', () => { await expect(getNegativeUNL(socket)).resolves.toEqual([]) }) }) + +describe('getAccountInfo', () => { + const AMM_ACCOUNT = 'rLjUKpwUVmz3vCTmFkXungxwzdoyrWRsFG' + + const ACCOUNT_ROOT = { + AMMID: '2858D58ECD99108CEB105A3D9F84D293E507701878EE25612F5BF6D4A93F3B69', + Account: AMM_ACCOUNT, + Balance: '2716797990409', + LedgerEntryType: 'AccountRoot', + } + + const ACCOUNT_INFO_FAILURE = { + error: 'internal', + error_message: 'Internal error.', + } + + const makeSocketSequence = (...responses: any[]) => { + const send = jest.fn() + responses.forEach((r) => send.mockResolvedValueOnce(r)) + return { send } as any + } + + let warn: jest.SpyInstance + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warn.mockRestore() + }) + + it('returns account_data when account_info succeeds', async () => { + const socket = makeSocket({ account_data: ACCOUNT_ROOT, ledger_index: 1 }) + + await expect(getAccountInfo(socket, AMM_ACCOUNT)).resolves.toEqual({ + ...ACCOUNT_ROOT, + ledger_index: 1, + }) + expect(socket.send).toHaveBeenCalledTimes(1) + }) + + it('falls back to ledger_entry when account_info fails', async () => { + const socket = makeSocketSequence(ACCOUNT_INFO_FAILURE, { + node: ACCOUNT_ROOT, + ledger_index: 1, + }) + + // AMMID must survive: AccountsRouter uses it to route AMM accounts to the pool page. + await expect(getAccountInfo(socket, AMM_ACCOUNT)).resolves.toEqual({ + ...ACCOUNT_ROOT, + ledger_index: 1, + }) + expect(socket.send).toHaveBeenNthCalledWith(2, { + command: 'ledger_entry', + account_root: AMM_ACCOUNT, + ledger_index: 'validated', + }) + expect(warn).toHaveBeenCalled() + }) + + it('reports 404 without falling back when the account does not exist', async () => { + const socket = makeSocket({ error: 'actNotFound' }) + + await expect(getAccountInfo(socket, AMM_ACCOUNT)).rejects.toMatchObject({ + code: 404, + }) + expect(socket.send).toHaveBeenCalledTimes(1) + }) + + it('reports 404 when ledger_entry finds no account', async () => { + const socket = makeSocketSequence(ACCOUNT_INFO_FAILURE, { + error: 'entryNotFound', + error_message: 'Entry not found.', + }) + + await expect(getAccountInfo(socket, AMM_ACCOUNT)).rejects.toMatchObject({ + code: 404, + }) + }) + + it('rejects when ledger_entry also fails', async () => { + const socket = makeSocketSequence( + ACCOUNT_INFO_FAILURE, + ACCOUNT_INFO_FAILURE, + ) + + await expect(getAccountInfo(socket, AMM_ACCOUNT)).rejects.toMatchObject({ + code: 500, + }) + }) +}) From 4322e0c01d1dfd94388720dac37a5266f4ad6d86 Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Thu, 24 Sep 2026 11:08:23 -0400 Subject: [PATCH 10/11] Fix a cache bug --- server/routes/v1/amms.js | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/server/routes/v1/amms.js b/server/routes/v1/amms.js index 88d4034e8..6f15b5813 100644 --- a/server/routes/v1/amms.js +++ b/server/routes/v1/amms.js @@ -221,35 +221,21 @@ async function fetchAggregatedStats() { fetchAggregate(true), ]) - if (!allPools) { + // Null unless both succeed, so the caller keeps its last good result rather than caching + // a partial one. + if (!allPools || !xrpPools) { return null } - const merged = { ...allPools } - - if (xrpPools) { - merged.tvl_xrp = xrpPools.tvl_xrp - merged.tvl_usd = xrpPools.tvl_usd - merged.trading_volume_xrp = xrpPools.trading_volume_xrp - merged.trading_volume_usd = xrpPools.trading_volume_usd - merged.fees_collected_xrp = xrpPools.fees_collected_xrp - merged.fees_collected_usd = xrpPools.fees_collected_usd - } else { - // The XRP aggregate is missing - an ETL outage, or a LOS version without it. Omit these - // rather than leaving the all-pools figures in place: those are the inflated numbers this - // change exists to stop showing. Absent values render as "--". - delete merged.tvl_xrp - delete merged.tvl_usd - delete merged.trading_volume_xrp - delete merged.trading_volume_usd - delete merged.fees_collected_xrp - delete merged.fees_collected_usd - log.warn( - 'XRP-only aggregate unavailable - omitting TVL, volume and fees from stats', - ) + 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, } - - return merged } function enrichAMMs(amms, tokenDataMap) { From c614eaf00c7eb5f5e1f543efd22d970e8d3d80ea Mon Sep 17 00:00:00 2001 From: Kuan Lin Date: Mon, 28 Sep 2026 15:20:19 -0400 Subject: [PATCH 11/11] address review comments --- src/containers/AMMPool/types.ts | 6 +--- src/rippled/lib/rippled.ts | 17 +++++++--- src/rippled/lib/test/rippled.test.ts | 51 ++++++++++++++++------------ 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/src/containers/AMMPool/types.ts b/src/containers/AMMPool/types.ts index 5bea9efa7..4f66568c0 100644 --- a/src/containers/AMMPool/types.ts +++ b/src/containers/AMMPool/types.ts @@ -15,11 +15,7 @@ export interface LOSAMMPoolData { issuer_2: string | null currency_2: string last_updated_timestamp: string - /** - * True when one side of the pair is XRP, which is what makes the pool's TVL priceable. - * ledger, so TVL and volume for other pools must not be displayed. Optional so the field - * surviving a LOS rollback is not a type error - compare explicitly, never on truthiness. - */ + // True when one side of the pair is XRP, which is what makes the pool's TVL priceable is_xrp_based?: boolean } diff --git a/src/rippled/lib/rippled.ts b/src/rippled/lib/rippled.ts index f56d3c1aa..8c279a440 100644 --- a/src/rippled/lib/rippled.ts +++ b/src/rippled/lib/rippled.ts @@ -201,8 +201,8 @@ const getTransaction = async ( } /** - * Read an AccountRoot via ledger_entry, for nodes whose account_info fails on pseudo-accounts - * (AMM, vault). Returns the same fields, AMMID included; signer lists are unavailable here. + * Read an AccountRoot via ledger_entry, for nodes whose account_info fails on AMM accounts. + * Returns the same fields, AMMID included; signer lists are unavailable here. */ const getAccountRoot = async ( rippledSocket: ExplorerXrplClient, @@ -238,15 +238,24 @@ const getAccountInfo = async ( ledger_index: 'validated', signer_lists: includeSignerLists, }) + if (resp.error === 'actNotFound') { throw new Error('account not found', 404) } - if (resp.error_message) { + if (resp.error === 'internal') { log.warn( `account_info failed for ${account} (${resp.error_message}); retrying via ledger_entry`, ) - return getAccountRoot(rippledSocket, account) + const accountRoot = await getAccountRoot(rippledSocket, account) + // AMM accounts have no signer lists, so nothing is lost by returning this. + if (accountRoot.AMMID) { + return accountRoot + } + } + + if (resp.error_message) { + throw new Error(resp.error_message, 500) } return Object.assign(resp.account_data, { diff --git a/src/rippled/lib/test/rippled.test.ts b/src/rippled/lib/test/rippled.test.ts index e70927205..a233d626c 100644 --- a/src/rippled/lib/test/rippled.test.ts +++ b/src/rippled/lib/test/rippled.test.ts @@ -238,10 +238,7 @@ describe('getAccountInfo', () => { LedgerEntryType: 'AccountRoot', } - const ACCOUNT_INFO_FAILURE = { - error: 'internal', - error_message: 'Internal error.', - } + const INTERNAL_ERROR = { error: 'internal', error_message: 'Internal error.' } const makeSocketSequence = (...responses: any[]) => { const send = jest.fn() @@ -269,8 +266,8 @@ describe('getAccountInfo', () => { expect(socket.send).toHaveBeenCalledTimes(1) }) - it('falls back to ledger_entry when account_info fails', async () => { - const socket = makeSocketSequence(ACCOUNT_INFO_FAILURE, { + it('falls back to ledger_entry for an AMM account on an internal error', async () => { + const socket = makeSocketSequence(INTERNAL_ERROR, { node: ACCOUNT_ROOT, ledger_index: 1, }) @@ -288,34 +285,44 @@ describe('getAccountInfo', () => { expect(warn).toHaveBeenCalled() }) - it('reports 404 without falling back when the account does not exist', async () => { - const socket = makeSocket({ error: 'actNotFound' }) + it('keeps the original error when the fallback finds a non-AMM account', async () => { + // A regular account would lose its signer lists. + const { AMMID, ...regularAccount } = ACCOUNT_ROOT + const socket = makeSocketSequence(INTERNAL_ERROR, { + node: regularAccount, + ledger_index: 1, + }) await expect(getAccountInfo(socket, AMM_ACCOUNT)).rejects.toMatchObject({ - code: 404, + message: 'Internal error.', + code: 500, }) - expect(socket.send).toHaveBeenCalledTimes(1) }) - it('reports 404 when ledger_entry finds no account', async () => { - const socket = makeSocketSequence(ACCOUNT_INFO_FAILURE, { - error: 'entryNotFound', - error_message: 'Entry not found.', - }) + // actNotFound must stay a 404: AccountsRouter uses it to detect deleted accounts. + it.each([ + ['actNotFound', 404], + ['tooBusy', 500], + ])('does not fall back on %s', async (error, code) => { + const socket = makeSocket({ error, error_message: error }) await expect(getAccountInfo(socket, AMM_ACCOUNT)).rejects.toMatchObject({ - code: 404, + code, }) + expect(socket.send).toHaveBeenCalledTimes(1) }) - it('rejects when ledger_entry also fails', async () => { - const socket = makeSocketSequence( - ACCOUNT_INFO_FAILURE, - ACCOUNT_INFO_FAILURE, - ) + it.each([ + ['entryNotFound', 404], + ['internal', 500], + ])('maps a ledger_entry %s to %i', async (error, code) => { + const socket = makeSocketSequence(INTERNAL_ERROR, { + error, + error_message: error, + }) await expect(getAccountInfo(socket, AMM_ACCOUNT)).rejects.toMatchObject({ - code: 500, + code, }) }) })