From 4d901800825e58e6b9ffb2eff579a68dd0b12332 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:07:05 +0000 Subject: [PATCH] fix: refresh DTF and token prices on the quote cadence Co-Authored-By: Patrick --- CHANGELOG.md | 6 +++ package.json | 2 +- src/hooks/use-index-basket.ts | 7 ++- src/hooks/usePrice.ts | 10 ++-- tests/price-refresh.test.tsx | 91 +++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 tests/price-refresh.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b9b05c..5f33804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2.7.3] - 2026-07-24 + +### Fixed + +- The DTF price (and the selected token's price) now refreshes on the same cadence as the quote (`refreshRate`, 9s by default). Both prices were fetched once on mount — the DTF price had no refresh at all and the token price ran on its own 30s timer — so with the widget open the USD value shown for the token being redeemed, and the price impact derived from it, could be computed from a price minutes old while the quote itself kept refreshing. Because both legs are now priced from the same moment, the impact no longer drifts as one side ages. + ## [2.7.1] - 2026-07-24 ### Changed diff --git a/package.json b/package.json index cccbcbd..8e399e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@reserve-protocol/react-zapper", - "version": "2.7.1", + "version": "2.7.3", "type": "module", "packageManager": "pnpm@11.1.1", "description": "React component for DTF minting with zap functionality", diff --git a/src/hooks/use-index-basket.ts b/src/hooks/use-index-basket.ts index d7c5d73..b6f2bdf 100644 --- a/src/hooks/use-index-basket.ts +++ b/src/hooks/use-index-basket.ts @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { Address } from 'viem' import { Token } from '../types' -import { apiUrlAtom } from '@/state/atoms' +import { apiUrlAtom, refreshRateAtom } from '@/state/atoms' type Response = { price: number @@ -17,6 +17,7 @@ type Response = { const useIndexPrice = (token: string | undefined, chainId: number) => { const api = useAtomValue(apiUrlAtom) + const refreshRate = useAtomValue(refreshRateAtom) return useQuery({ queryKey: ['index-price', token, chainId, api], queryFn: async (): Promise => { @@ -35,6 +36,10 @@ const useIndexPrice = (token: string | undefined, chainId: number) => { return (await response.json()) as Response }, enabled: !!token, + // The DTF price feeds the redeem input's USD value and the price impact of + // every quote, so it refreshes on the same cadence as the quote itself. + refetchInterval: refreshRate, + staleTime: refreshRate, }) } diff --git a/src/hooks/usePrice.ts b/src/hooks/usePrice.ts index a918fff..ca63ecd 100644 --- a/src/hooks/usePrice.ts +++ b/src/hooks/usePrice.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { Address } from 'viem' import { useAtomValue } from 'jotai' -import { apiUrlAtom } from '@/state/atoms' +import { apiUrlAtom, refreshRateAtom } from '@/state/atoms' /** * Hook to fetch price data for a token using Reserve API @@ -12,6 +12,7 @@ export function usePrice( apiUrl?: string ): number | null { const atomUrl = useAtomValue(apiUrlAtom) + const refreshRate = useAtomValue(refreshRateAtom) const { data } = useQuery({ queryKey: ['reserveAPIPrice', chainId, tokenAddress, apiUrl, atomUrl], queryFn: async () => { @@ -41,8 +42,11 @@ export function usePrice( } }, enabled: !!chainId && !!tokenAddress, - refetchInterval: 30000, // Refetch every 30 seconds - staleTime: 15000, // Consider data stale after 15 seconds + // Both sides of the trade are priced on the quote's own cadence, so the + // USD values and the price impact derived from them never mix a fresh + // price with a stale one. + refetchInterval: refreshRate, + staleTime: refreshRate, retry: 2, }) diff --git a/tests/price-refresh.test.tsx b/tests/price-refresh.test.tsx new file mode 100644 index 0000000..c1af348 --- /dev/null +++ b/tests/price-refresh.test.tsx @@ -0,0 +1,91 @@ +/** + * The USD values and the price impact shown for a mint/redeem are derived from + * the Reserve prices of both legs, so those prices must refresh on the same + * cadence as the quote — a frozen DTF price silently skews the redeem input + * value and the impact of every refreshed quote. + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, render, waitFor } from '@testing-library/react' +import { createStore, Provider as JotaiProvider } from 'jotai' +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useIndexBasket } from '../src/hooks/use-index-basket' +import { usePrice } from '../src/hooks/usePrice' +import { apiUrlAtom, refreshRateAtom } from '../src/state/atoms' + +const API = 'https://api.test/' +const DTF = '0x1000000000000000000000000000000000000001' +const TOKEN = '0x2000000000000000000000000000000000000002' +const REFRESH_RATE = 4_000 + +const countCalls = (fetchMock: ReturnType, path: string) => + fetchMock.mock.calls.filter(([url]) => String(url).includes(path)).length + +const renderWithProviders = (ui: React.ReactElement) => { + const store = createStore() + store.set(apiUrlAtom, API) + store.set(refreshRateAtom, REFRESH_RATE) + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, + }) + + return render( + + {ui} + + ) +} + +const DtfPrice = () => { + const { data } = useIndexBasket(DTF, 1) + return
{data.price}
+} + +const TokenPrice = () => { + const price = usePrice(1, TOKEN) + return
{price}
+} + +describe('price refresh cadence', () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn(async (url: RequestInfo | URL) => { + const body = String(url).includes('current/dtf') + ? { price: 1.23, basket: [] } + : [{ price: 4.56 }] + return new Response(JSON.stringify(body), { status: 200 }) + }) + vi.stubGlobal('fetch', fetchMock) + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('refetches the DTF price on the quote refresh rate', async () => { + renderWithProviders() + + await waitFor(() => expect(countCalls(fetchMock, 'current/dtf')).toBe(1)) + + await vi.advanceTimersByTimeAsync(REFRESH_RATE) + await waitFor(() => expect(countCalls(fetchMock, 'current/dtf')).toBe(2)) + + await vi.advanceTimersByTimeAsync(REFRESH_RATE) + await waitFor(() => expect(countCalls(fetchMock, 'current/dtf')).toBe(3)) + }) + + it('refetches the selected token price on the quote refresh rate', async () => { + renderWithProviders() + + await waitFor(() => expect(countCalls(fetchMock, 'current/prices')).toBe(1)) + + await vi.advanceTimersByTimeAsync(REFRESH_RATE) + await waitFor(() => expect(countCalls(fetchMock, 'current/prices')).toBe(2)) + }) +})