diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 8bb5c5133..0b4a9d9d5 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -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", diff --git a/server/routes/v1/amms.js b/server/routes/v1/amms.js index 2a48cfff7..6f15b5813 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', + // 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, }) @@ -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) => { @@ -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 = @@ -423,10 +466,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 +477,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 +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}...`) @@ -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) { @@ -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, diff --git a/src/containers/AMMPool/InfoCards/AuctionCard.tsx b/src/containers/AMMPool/InfoCards/AuctionCard.tsx index 3cfc58202..8b1bf0df6 100644 --- a/src/containers/AMMPool/InfoCards/AuctionCard.tsx +++ b/src/containers/AMMPool/InfoCards/AuctionCard.tsx @@ -91,13 +91,15 @@ export const AuctionCard: FC = ({ 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 } diff --git a/src/containers/AMMPool/InfoCards/MarketDataCard.tsx b/src/containers/AMMPool/InfoCards/MarketDataCard.tsx index f7141fe19..df1994f9d 100644 --- a/src/containers/AMMPool/InfoCards/MarketDataCard.tsx +++ b/src/containers/AMMPool/InfoCards/MarketDataCard.tsx @@ -39,6 +39,13 @@ 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 @@ -46,6 +53,7 @@ interface MarketDataCardProps { export const MarketDataCard: FC = ({ losData, + isXrpBased = true, balance1, balance2, lpTokenBalance, @@ -53,6 +61,13 @@ 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 gatedValue = ( + value: T | null | undefined, + format: (v: T) => string, + ) => (isXrpBased && value != null ? format(value) : '--') + const renderTooltipIcon = (text: string) => ( = ({
{t('tvl')} - {losData.tvl_usd != null - ? parseCurrencyAmount(losData.tvl_usd) - : '--'} + {gatedValue(losData.tvl_usd, parseCurrencyAmount)}
@@ -90,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)}
@@ -101,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)}
@@ -112,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 574a4950e..c11094dc6 100644 --- a/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx +++ b/src/containers/AMMPool/InfoCards/test/AuctionCard.test.tsx @@ -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') + }) }) diff --git a/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx b/src/containers/AMMPool/InfoCards/test/MarketDataCard.test.tsx index 045b8c060..fb4590ca4 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 = ({ , ) +/** 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() @@ -65,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') @@ -87,6 +91,33 @@ describe('MarketDataCard', () => { expect(getRowValue('apr_24h')).toBe('0.046%') }) + it("shows '--' for TVL, volume, fees and APR on a non-XRP pool", () => { + // 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 }) + + 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', () => { + // 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 balances with correct formatted values', () => { const { container } = renderComponent() const labels = container.querySelectorAll('.info-card-label') diff --git a/src/containers/AMMPool/TablePicker/index.tsx b/src/containers/AMMPool/TablePicker/index.tsx index fd518225c..d92b6d440 100644 --- a/src/containers/AMMPool/TablePicker/index.tsx +++ b/src/containers/AMMPool/TablePicker/index.tsx @@ -60,6 +60,10 @@ 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. + */ + isXrpBased?: boolean } export const AMMPoolTablePicker: FC = ({ @@ -69,6 +73,7 @@ export const AMMPoolTablePicker: FC = ({ lpToken, tvlUsd, isDeleted = false, + isXrpBased = true, }) => { const { t } = useTranslation() const { trackException } = useAnalytics() @@ -170,6 +175,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 +238,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 09f62a1f7..83ad88ee4 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 + // 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' || + 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,8 +297,9 @@ export const AMMPool = () => { tab={tab} isMainnet={isMainnet} lpToken={lpToken} - tvlUsd={losData?.tvl_usd} + tvlUsd={gatedTvlUsd} isDeleted={isDeleted} + isXrpBased={isXrpBased} /> )} 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/AMMPool/types.ts b/src/containers/AMMPool/types.ts index b2f462cdc..4f66568c0 100644 --- a/src/containers/AMMPool/types.ts +++ b/src/containers/AMMPool/types.ts @@ -15,6 +15,8 @@ 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 + 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..d0b761443 100644 --- a/src/containers/AMMRankings/api.ts +++ b/src/containers/AMMRankings/api.ts @@ -15,6 +15,10 @@ export interface AMMPool { annual_percentage_return: number liquidity_provider_count: number amm_created_timestamp: string + // 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 // Token data from LOS /tokens/batch-get (server-cached) @@ -30,6 +34,7 @@ export interface AMMRankingsResponse { size: number sort_field: string sort_order: string + xrp_only?: boolean count: number results: AMMPool[] } @@ -96,6 +101,8 @@ export const fetchHistoricalTrends = async ( params: { amm_account_id: 'aggregated', time_range: timeRange, + // The chart sits beside the XRP-only stat tiles and has to match them. + xrp_only: true, }, }) return response.data @@ -112,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 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') + }) +}) diff --git a/src/rippled/lib/rippled.ts b/src/rippled/lib/rippled.ts index 487b6f380..8c279a440 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 AMM accounts. + * 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, @@ -209,10 +238,22 @@ const getAccountInfo = async ( ledger_index: 'validated', signer_lists: includeSignerLists, }) + if (resp.error === 'actNotFound') { throw new Error('account not found', 404) } + if (resp.error === 'internal') { + log.warn( + `account_info failed for ${account} (${resp.error_message}); retrying via ledger_entry`, + ) + 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) } diff --git a/src/rippled/lib/test/rippled.test.ts b/src/rippled/lib/test/rippled.test.ts index e12d5303f..a233d626c 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,102 @@ 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 INTERNAL_ERROR = { 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 for an AMM account on an internal error', async () => { + const socket = makeSocketSequence(INTERNAL_ERROR, { + 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('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({ + message: 'Internal error.', + code: 500, + }) + }) + + // 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, + }) + expect(socket.send).toHaveBeenCalledTimes(1) + }) + + 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, + }) + }) +})