diff --git a/src/lib/api/st0xApi.ts b/src/lib/api/st0xApi.ts index 18d0a2e3..356c6c09 100644 --- a/src/lib/api/st0xApi.ts +++ b/src/lib/api/st0xApi.ts @@ -169,6 +169,56 @@ export interface ApiTokenProofsResponse { receipts: ApiTokenProofReceipt[]; } +// ============================================================================ +// Token Detail Types +// ============================================================================ + +export interface ApiTokenDetailsError { + address: string; + message: string; +} + +export interface ApiTokenDetailsSummary { + address: string; + deployTimestamp?: number; + receiptContractAddress?: string | null; + name: string; + symbol: string; + decimals: number; + totalSupply: string; + holderCount: number; + transferCount: number; + bridgedSupply: string; + depositVolume: string; + withdrawVolume: string; + activityVolume: string; +} + +export interface ApiTokenDetailsActivityRow { + id: string; + txHash: string; + caller: string; + amount: string; + timestamp: number; + receiptId: string; +} + +export interface ApiTokenDetails extends ApiTokenDetailsSummary { + sftVaultAddress: string; + deployTimestamp: number; + deployer: string; + admin: string; + activity: { + deposits: ApiTokenDetailsActivityRow[]; + withdraws: ApiTokenDetailsActivityRow[]; + }; +} + +export interface ApiTokenDetailsListResponse { + data: ApiTokenDetailsSummary[]; + errors: ApiTokenDetailsError[]; +} + // ============================================================================ // Wrap Ratio Types // ============================================================================ @@ -277,6 +327,29 @@ export async function apiGetTokenProofs(address: string): Promise(apiUrl(`/v1/tokens/${address}/proofs`)); } +/** + * Fetch ST0x token detail summaries from the REST API. + */ +export async function apiGetTokenDetails(): Promise { + assertBrowser('apiGetTokenDetails'); + return fetchJson(apiUrl('/v1/tokens/details')); +} + +/** + * Fetch ST0x token details and recent activity for a single token. + */ +export async function apiGetTokenDetailsByAddress( + address: string, + options?: { activityLimit?: number } +): Promise { + assertBrowser('apiGetTokenDetailsByAddress'); + return fetchJson( + apiUrl(`/v1/tokens/${address}/details`, { + activityLimit: options?.activityLimit + }) + ); +} + /** * Fetch current wrap ratios for supported wrapped tokens. */ diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 033e02f9..a36bf676 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -32,16 +32,7 @@ $: sortedAssets = $sfts ? [...$sfts] .map((sft) => { - // Calculate total on-chain volume (deposits + withdrawals) - const depositVolume = sft.deposits.reduce( - (sum: bigint, d: { amount: string }) => sum + BigInt(d.amount), - BigInt(0) - ); - const withdrawVolume = sft.withdraws.reduce( - (sum: bigint, w: { amount: string }) => sum + BigInt(w.amount), - BigInt(0) - ); - const totalVolume = depositVolume + withdrawVolume; + const totalVolume = BigInt(sft.activityVolume ?? '0'); // Use token config symbol for price lookup so legacy symbols (e.g. tSTOX) resolve to the wrapped token's price feed (wtSTOX / AMEX:SPLG) const tokenInfo = findApiTokenByAnyAddress(apiTokens, sft.address); const symbolForPrice = tokenInfo?.symbol ?? sft.symbol; diff --git a/src/lib/queries/vaults.ts b/src/lib/queries/vaults.ts index 3db3b0c0..233235b9 100644 --- a/src/lib/queries/vaults.ts +++ b/src/lib/queries/vaults.ts @@ -6,7 +6,13 @@ import { } from '@tanstack/svelte-query'; import { browser } from '$app/environment'; import type { Network } from '$lib/config/network'; -import { getSfts, getSftById } from '$lib/api/subgraph'; +import { + apiGetTokenDetails, + apiGetTokenDetailsByAddress, + type ApiTokenDetails, + type ApiTokenDetailsActivityRow, + type ApiTokenDetailsSummary +} from '$lib/api/st0xApi'; import type { OffchainAssetReceiptVault } from '$lib/types/OffchainAssetReceiptVault'; import { queryClient } from '$lib/clients/queryClient'; import { createRaindexClient } from '$lib/clients/raindex'; @@ -16,13 +22,73 @@ import type { RaindexVault, SgVault } from '@rainlanguage/orderbook'; // SFT/Token Queries (OffchainAssetReceiptVault - the tokenized assets) // ============================================================================= +function toReceiptActivity(row: ApiTokenDetailsActivityRow) { + return { + id: row.id, + transaction: { id: row.txHash }, + emitter: { address: row.caller }, + receipt: { + id: row.receiptId, + receiptId: row.receiptId, + receiptInformations: [] + }, + amount: row.amount, + caller: { address: row.caller }, + timestamp: String(row.timestamp) + }; +} + +function tokenDetailsSummaryToVault( + summary: ApiTokenDetailsSummary, + detail?: ApiTokenDetails, + chainId?: number +): OffchainAssetReceiptVault { + return { + id: summary.address, + totalShares: summary.totalSupply, + holderCount: summary.holderCount, + transferCount: summary.transferCount, + bridgedSupply: summary.bridgedSupply, + depositVolume: summary.depositVolume, + withdrawVolume: summary.withdrawVolume, + activityVolume: summary.activityVolume, + sftVaultAddress: detail?.sftVaultAddress, + address: summary.address as `0x${string}`, + deployer: detail?.deployer ?? '', + admin: detail?.admin ?? '', + name: summary.name, + symbol: summary.symbol, + deployTimestamp: + detail?.deployTimestamp !== undefined + ? String(detail.deployTimestamp) + : summary.deployTimestamp !== undefined + ? String(summary.deployTimestamp) + : '', + receiptContractAddress: summary.receiptContractAddress ?? '', + tokenHolders: [], + receiptVaultInformations: [], + withdraws: detail?.activity.withdraws.map(toReceiptActivity) ?? [], + deposits: detail?.activity.deposits.map(toReceiptActivity) ?? [], + shareTransfers: [], + chainId + }; +} + export function createSftsQuery(network: Network | null) { return createQuery({ queryKey: ['sfts', network?.id], - enabled: Boolean(browser && network?.subgraph_url), + enabled: Boolean(browser && network), staleTime: Infinity, refetchInterval: false, - queryFn: () => getSfts(network as Network) + queryFn: async () => { + const response = await apiGetTokenDetails(); + if (response.errors.length) { + console.warn('Some token details failed to load', response.errors); + } + return response.data.map((summary) => + tokenDetailsSummaryToVault(summary, undefined, network?.chainId) + ); + } }); } @@ -60,7 +126,7 @@ export function createSingleSftQuery( return createQuery({ queryKey: ['sft', network?.id, tokenId], - enabled: Boolean(network?.subgraph_url && tokenId), + enabled: Boolean(browser && network && tokenId), staleTime: 30_000, refetchInterval: false, refetchOnWindowFocus: true, // Only refetch on focus if stale @@ -68,7 +134,8 @@ export function createSingleSftQuery( initialDataUpdatedAt: getCachedTimestamp(), queryFn: async () => { if (!network || !tokenId) return null; - return getSftById(tokenId, network); + const detail = await apiGetTokenDetailsByAddress(tokenId, { activityLimit: 5 }); + return tokenDetailsSummaryToVault(detail, detail, network.chainId); } }); } diff --git a/src/lib/types/OffchainAssetReceiptVault.ts b/src/lib/types/OffchainAssetReceiptVault.ts index 27c924f0..d41f7650 100644 --- a/src/lib/types/OffchainAssetReceiptVault.ts +++ b/src/lib/types/OffchainAssetReceiptVault.ts @@ -16,6 +16,13 @@ export type MetaV1S = { export type OffchainAssetReceiptVault = { id: string; totalShares: string; + holderCount?: number; + transferCount?: number; + bridgedSupply?: string; + depositVolume?: string; + withdrawVolume?: string; + activityVolume?: string; + sftVaultAddress?: string; address: Hex; deployer: string; admin: string; diff --git a/src/routes/(main)/+page.svelte b/src/routes/(main)/+page.svelte index 0cc8812a..05c83b82 100644 --- a/src/routes/(main)/+page.svelte +++ b/src/routes/(main)/+page.svelte @@ -10,12 +10,12 @@ import { formatUnits } from 'viem'; import { goto } from '$app/navigation'; import Table from '$lib/components/ui/table/Table.svelte'; - import type { OffchainAssetReceiptVault } from '$lib/types/OffchainAssetReceiptVault'; import QuickTrade from '$lib/components/QuickTrade.svelte'; import { tutorialActive, tutorialStep } from '$lib/stores/tutorialStore'; import Footer from '$lib/components/Footer.svelte'; import { track, trackPageView } from '$lib/services/analytics'; import { initScrollTracking } from '$lib/utils/scrollTracking'; + import { toBigInt } from '$lib/utils/tokenMath'; function startTour() { tutorialActive.set(true); @@ -70,6 +70,11 @@ let priceFeedsQuery = createPriceFeedsQuery($currentNetwork); $: priceFeedsQuery = createPriceFeedsQuery($currentNetwork); + function formatBaseUnitAmount(value: string | null | undefined): string { + const amount = toBigInt(value); + return amount === null ? '0' : formatUnits(amount, 18); + } + let cleanupScrollTracking: (() => void) | null = null; onMount(() => { @@ -106,7 +111,6 @@ }; let processedTokens: TokenRow[] = []; - let sftLookup = new Map(); let isVaultLoading = false; let vaultsError: string | null = null; let hasVaults = false; @@ -119,13 +123,6 @@ : !hasVaults && $vaultsQuery?.error ? String($vaultsQuery.error) : null; - $: sftLookup = new Map( - ($sfts ?? []).map((vault: OffchainAssetReceiptVault) => [vault.id, vault]) - ); - - function sumAmounts(entries?: Array<{ amount: string }>): bigint { - return (entries ?? []).reduce((sum: bigint, entry) => sum + BigInt(entry.amount), 0n); - } const pioneerLogos = [ { alt: 'Holo', src: '/images/pioneers/holo.svg', scale: 0.8 }, @@ -156,11 +153,9 @@ name: sft.name, symbol: sft.symbol, price, - totalHolders: sft.tokenHolders - .filter((holder: { balance: string }) => BigInt(holder.balance) > BigInt(0)) - .length.toString(), - totalSupply: formatUnits(BigInt(sft.totalShares), 18), - totalTransfers: sft.shareTransfers.length.toString(), + totalHolders: String(sft.holderCount ?? 0), + totalSupply: formatBaseUnitAmount(sft.bridgedSupply ?? sft.totalShares), + totalTransfers: String(sft.transferCount ?? 0), createdAt: sft.deployTimestamp, isSft: true }); @@ -397,16 +392,12 @@ {:else} {#each processedTokens as token (token.id)} - {@const sft = sftLookup.get(token.id)} - {@const deposits = sumAmounts(sft?.deposits)} - {@const withdraws = sumAmounts(sft?.withdraws)} - {@const circulating = deposits - withdraws} - {@const circulatingSupply = parseFloat(formatUnits(circulating, 18))} + {@const bridgedSupply = Number(token.totalSupply)} {@const displayPrice = typeof token.price === 'number' ? token.price : Number(token.price ?? NaN)} {@const marketCap = displayPrice != null && Number.isFinite(displayPrice) - ? circulatingSupply * displayPrice + ? bridgedSupply * displayPrice : null}
- {circulatingSupply >= 1000 - ? `${(circulatingSupply / 1000).toFixed(2)}K` - : circulatingSupply.toFixed(2)} + {bridgedSupply >= 1000 + ? `${(bridgedSupply / 1000).toFixed(2)}K` + : bridgedSupply.toFixed(2)}
diff --git a/src/routes/(main)/dashboard/+page.svelte b/src/routes/(main)/dashboard/+page.svelte index 90fbed72..ede39038 100644 --- a/src/routes/(main)/dashboard/+page.svelte +++ b/src/routes/(main)/dashboard/+page.svelte @@ -343,7 +343,7 @@ // User Vaults Query - no polling, invalidated after order deployment $: vaultsListQuery = createUserVaultsQuery($currentNetwork, $walletAddress); - // Query user's wallet holdings from SFTs - fetches balances via multicall (single RPC request) + // Query user's wallet holdings from REST token details - fetches balances via multicall (single RPC request) // We query balances on wrapped token addresses from the REST API token list since those are traded. const walletHoldingsQuery = createQuery( derived( @@ -363,10 +363,9 @@ staleTime: QUERY_STALE_TIME_MS, queryFn: async () => { if (!$sfts || !$walletAddress || !$wagmiConfig) return []; - const normalizedWalletAddress = $walletAddress.toLowerCase(); - // Map subgraph SFTs to their wrapped token addresses from the API token list. - // The subgraph returns unwrapped addresses, but we need to query wrapped token balances + // Token details already normalize to wrapped addresses, but token lookup keeps + // legacy/unwrapped variants safe for older cached rows. const sftsWithWrappedAddresses = $sfts.map((sft) => { const tokenConfig = findApiTokenByAnyAddress(ALL_TOKENS, sft.address); return { @@ -393,13 +392,6 @@ if (result.status === 'success') { walletBalance = result.result as bigint; - } else { - // Fall back to subgraph data if multicall fails for this token - const userHolder = sft.tokenHolders.find( - (holder: { address: string }) => - holder.address.toLowerCase() === normalizedWalletAddress - ); - walletBalance = userHolder ? BigInt(userHolder.balance) : 0n; } const tokenConfig = findApiTokenByAnyAddress(ALL_TOKENS, sft.address); @@ -415,19 +407,14 @@ }); } catch (error) { console.error('Multicall failed for wallet holdings:', error); - // Fall back to subgraph data for all tokens return $sfts.map((sft) => { - const userHolder = sft.tokenHolders.find( - (holder: { address: string }) => - holder.address.toLowerCase() === normalizedWalletAddress - ); const tokenConfig = findApiTokenByAnyAddress(ALL_TOKENS, sft.address); return { id: sft.id, address: tokenConfig?.address ?? sft.address, name: tokenConfig?.name ?? sft.name, symbol: tokenConfig?.symbol ?? sft.symbol, - walletBalance: userHolder ? BigInt(userHolder.balance) : 0n, + walletBalance: 0n, decimals: 18 }; }); diff --git a/src/routes/(main)/trade/[id]/+page.svelte b/src/routes/(main)/trade/[id]/+page.svelte index 5be68357..d544f284 100644 --- a/src/routes/(main)/trade/[id]/+page.svelte +++ b/src/routes/(main)/trade/[id]/+page.svelte @@ -50,6 +50,7 @@ normalizeAddress, ratioToNumber, toDecimal, + toBigInt, getRaindexVaultUrl } from '$lib/utils/tokenMath'; import type { OracleQuote } from '$lib/queries/oracleQuotes'; @@ -612,6 +613,10 @@ maximumFractionDigits: value > 1 ? 2 : 6 }).format(value); } + function formatBaseUnitAmount(value: string | null | undefined): string { + const amount = toBigInt(value); + return amount === null ? '0' : formatUnits(amount, 18); + } function formatResourceError(error: unknown, fallback: string): string { if (!error) return fallback; if (typeof error === 'string') return error; @@ -1745,15 +1750,19 @@
Total Supply - {formatUnits(BigInt(currentToken.totalShares), 18)} + {formatBaseUnitAmount( + currentToken.bridgedSupply ?? currentToken.totalShares + )}
Holders - {currentToken.tokenHolders.length} + {currentToken.holderCount ?? 0}
Total Transfers - {currentToken.shareTransfers.length} + {currentToken.transferCount ?? 0}
diff --git a/src/routes/(main)/trade/[id]/proofs/+layout.svelte b/src/routes/(main)/trade/[id]/proofs/+layout.svelte index 3fcb2703..2db96ca6 100644 --- a/src/routes/(main)/trade/[id]/proofs/+layout.svelte +++ b/src/routes/(main)/trade/[id]/proofs/+layout.svelte @@ -33,21 +33,20 @@ v.address?.toLowerCase() === id.toLowerCase() || (wrappedAddress && v.address?.toLowerCase() === wrappedAddress) ); - if (foundInSfts) { - currentToken.set(foundInSfts); - } else if ($query?.data) { + if ($query?.data) { const token = getTokenByAnyAddress(id) ?? getTokenByAnyAddress($query.data.address); currentToken.set({ + ...(foundInSfts ?? {}), id: $query.data.address, - totalShares: '0', + totalShares: foundInSfts?.totalShares ?? '0', address: $query.data.address as `0x${string}`, - deployer: '', - admin: '', - name: token?.name ?? token?.symbol ?? $query.data.address, - symbol: token?.symbol ?? '', - deployTimestamp: '', - receiptContractAddress: '', - tokenHolders: [], + deployer: foundInSfts?.deployer ?? '', + admin: foundInSfts?.admin ?? '', + name: foundInSfts?.name ?? token?.name ?? token?.symbol ?? $query.data.address, + symbol: foundInSfts?.symbol ?? token?.symbol ?? '', + deployTimestamp: foundInSfts?.deployTimestamp ?? '', + receiptContractAddress: foundInSfts?.receiptContractAddress ?? '', + tokenHolders: foundInSfts?.tokenHolders ?? [], receiptVaultInformations: $query.data.schemas.map((schema) => ({ id: schema.id, information: schema.information, @@ -55,11 +54,13 @@ caller: { address: '' }, transaction: { blockNumber: '' } })), - withdraws: [], - deposits: [], - shareTransfers: [], + withdraws: foundInSfts?.withdraws ?? [], + deposits: foundInSfts?.deposits ?? [], + shareTransfers: foundInSfts?.shareTransfers ?? [], chainId: $currentNetwork?.chainId }); + } else if (foundInSfts) { + currentToken.set(foundInSfts); } } diff --git a/src/routes/api/st0x/[...path]/+server.ts b/src/routes/api/st0x/[...path]/+server.ts index d19282b2..e79c68b5 100644 --- a/src/routes/api/st0x/[...path]/+server.ts +++ b/src/routes/api/st0x/[...path]/+server.ts @@ -9,6 +9,8 @@ import { env } from '$env/dynamic/private'; import type { RequestEvent, RequestHandler } from './$types'; +const TOKEN_DETAILS_LIST_PATH = 'v1/tokens/details'; + function getApiBase(): string { const url = env.ST0X_API_URL; if (!url) { @@ -30,6 +32,16 @@ function getAuthHeader(): string { const ALLOWED_PROXY_ROUTES: Array<{ method: string; pattern: RegExp; cache?: string }> = [ { method: 'GET', pattern: /^health$/ }, { method: 'GET', pattern: /^v1\/tokens$/ }, + { + method: 'GET', + pattern: /^v1\/tokens\/details$/, + cache: 'public, s-maxage=60, stale-while-revalidate=300' + }, + { + method: 'GET', + pattern: /^v1\/tokens\/[^/]+\/details$/, + cache: 'public, s-maxage=60, stale-while-revalidate=300' + }, { method: 'GET', pattern: /^v1\/tokens\/wrap-ratio$/, @@ -75,6 +87,21 @@ function matchProxyRoute(method: string, pathSuffix: string): { cache?: string } return route ? { cache: route.cache } : null; } +async function shouldCacheResponse(pathSuffix: string, response: Response): Promise { + if (!response.ok) return false; + + if (pathSuffix !== TOKEN_DETAILS_LIST_PATH) return true; + + try { + const body = (await response.clone().json()) as { errors?: unknown }; + return !Array.isArray(body.errors) || body.errors.length === 0; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Unknown parse error'; + console.warn('[st0x-proxy] Skipping token details cache for unreadable response:', msg); + return false; + } +} + const proxyRequest = async ({ request, params, url }: RequestEvent) => { let apiBase: string; let authHeader: string; @@ -127,7 +154,7 @@ const proxyRequest = async ({ request, params, url }: RequestEvent) => { const responseHeaders = new Headers(); responseHeaders.set('Content-Type', response.headers.get('Content-Type') ?? 'application/json'); - if (matched.cache && response.ok) { + if (matched.cache && (await shouldCacheResponse(pathSuffix, response))) { responseHeaders.set('Cache-Control', matched.cache); } diff --git a/tests/integration/ui/wrapRatio.spec.ts b/tests/integration/ui/wrapRatio.spec.ts index bae37be6..e9e09e27 100644 --- a/tests/integration/ui/wrapRatio.spec.ts +++ b/tests/integration/ui/wrapRatio.spec.ts @@ -6,10 +6,9 @@ // ratio is stubbed from the REST API response shape so the test covers the // production data path without depending on staging data. // -// Why SGOV: SGOV isn't in the SFT subgraph yet, so `getSftById` returns -// null quickly — `singleTokenQuery` resolves fast, the page falls back to -// tokens.ts metadata for header/symbols/wrap-ratio plumbing, and the chip -// renders without waiting on subgraph hydration of trades/orders/etc. +// Why SGOV: the token details response is stubbed so `singleTokenQuery` +// resolves fast and the page renders the wrap-ratio surfaces without depending +// on staging API/subgraph freshness. // // This spec deliberately does NOT use the `testClient` (anvil) fixture — // every assertion is UI-only. @@ -49,6 +48,61 @@ test.describe('Wrap ratio UX — non-1:1 wtSGOV (REST API)', () => { }); return; } + if (url.pathname === '/api/st0x/v1/tokens/details') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { + address: WT_SGOV_ADDRESS, + receiptContractAddress: T_SGOV_ADDRESS, + name: 'Wrapped iShares 0-3 Month Treasury Bond ETF ST0x', + symbol: 'wtSGOV', + decimals: 18, + totalSupply: '0', + holderCount: 0, + transferCount: 0, + bridgedSupply: '0', + depositVolume: '0', + withdrawVolume: '0', + activityVolume: '0' + } + ], + errors: [] + }) + }); + return; + } + if (pathname === `/api/st0x/v1/tokens/${WT_SGOV_ADDRESS.toLowerCase()}/details`) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + address: WT_SGOV_ADDRESS, + receiptContractAddress: T_SGOV_ADDRESS, + name: 'Wrapped iShares 0-3 Month Treasury Bond ETF ST0x', + symbol: 'wtSGOV', + decimals: 18, + totalSupply: '0', + holderCount: 0, + transferCount: 0, + bridgedSupply: '0', + depositVolume: '0', + withdrawVolume: '0', + activityVolume: '0', + sftVaultAddress: T_SGOV_ADDRESS, + deployTimestamp: 0, + deployer: '0x0000000000000000000000000000000000000000', + admin: '0x0000000000000000000000000000000000000000', + activity: { + deposits: [], + withdraws: [] + } + }) + }); + return; + } if (pathname === `/api/st0x/v1/tokens/wrap-ratio/${WT_SGOV_ADDRESS.toLowerCase()}/history`) { await route.fulfill({ status: 200, @@ -106,49 +160,6 @@ test.describe('Wrap ratio UX — non-1:1 wtSGOV (REST API)', () => { await route.fallback(); }); - // Stub the SFT subgraph for the SGOV singleTokenQuery so it resolves fast - // and deterministically. SGOV's Goldsky entry is sparse / cold so the - // upstream request can rate-limit or hit the fixtures.ts retry loop - // (~25s of backoff), pushing `$singleTokenQuery.isPending` past the chip - // timeout. We return a minimal `OffchainAssetReceiptVault` matching what - // `getSftById` shapes the response into — the page then renders the chip - // from this stub + tokens.ts metadata. - await page.route(/api\.goldsky\.com\/.*\/sft-base\//, async (route) => { - const body = route.request().postData() ?? ''; - if (body.toLowerCase().includes(WT_SGOV_ADDRESS.toLowerCase().slice(2))) { - await route.fulfill({ - status: 200, - contentType: 'application/json', - headers: { 'access-control-allow-origin': '*' }, - body: JSON.stringify({ - data: { - offchainAssetReceiptVaults: [ - { - id: T_SGOV_ADDRESS.toLowerCase(), - totalShares: '0', - address: T_SGOV_ADDRESS.toLowerCase(), - deployer: '0x0000000000000000000000000000000000000000', - admin: '0x0000000000000000000000000000000000000000', - name: 'Wrapped iShares 0-3 Month Treasury Bond ETF ST0x', - symbol: 'wtSGOV', - deployTimestamp: '0', - receiptContractAddress: '0x0000000000000000000000000000000000000000', - wrappedTokenContractAddress: WT_SGOV_ADDRESS.toLowerCase(), - tokenHolders: [], - receiptVaultInformations: [], - withdraws: [], - deposits: [], - shareTransfers: [] - } - ] - } - }) - }); - return; - } - await route.fallback(); - }); - page.on('pageerror', (err) => console.log(`[pageerror] ${err.message}`)); await page.goto(`http://127.0.0.1:4173/trade/${WT_SGOV_ADDRESS}`); diff --git a/tests/lib/api/st0x-proxy.test.ts b/tests/lib/api/st0x-proxy.test.ts index 3099a4b8..426006e5 100644 --- a/tests/lib/api/st0x-proxy.test.ts +++ b/tests/lib/api/st0x-proxy.test.ts @@ -155,4 +155,95 @@ describe('/api/st0x proxy', () => { expect.objectContaining({ method: 'GET' }) ); }); + + it('allows cached token details list endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [], errors: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await GET(proxyEvent('GET', 'v1/tokens/details')); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe( + 'public, s-maxage=60, stale-while-revalidate=300' + ); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.test/v1/tokens/details?page=1', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('does not cache partial token details list responses', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: [{ address: '0xGood' }], + errors: [{ address: '0xMissing', message: 'subgraph returned error status' }] + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await GET(proxyEvent('GET', 'v1/tokens/details')); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBeNull(); + expect(await response.json()).toEqual({ + data: [{ address: '0xGood' }], + errors: [{ address: '0xMissing', message: 'subgraph returned error status' }] + }); + }); + + it('does not cache unreadable token details list responses', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const fetchMock = vi.fn().mockResolvedValue( + new Response('not json', { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await GET(proxyEvent('GET', 'v1/tokens/details')); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBeNull(); + expect(await response.text()).toBe('not json'); + expect(warn).toHaveBeenCalledWith( + '[st0x-proxy] Skipping token details cache for unreadable response:', + expect.any(String) + ); + }); + + it('allows cached token details by address endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ address: '0xToken', activity: { deposits: [], withdraws: [] } }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await GET(proxyEvent('GET', 'v1/tokens/0xToken/details')); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe( + 'public, s-maxage=60, stale-while-revalidate=300' + ); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.test/v1/tokens/0xToken/details?page=1', + expect.objectContaining({ method: 'GET' }) + ); + }); });