Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 6 additions & 1 deletion src/hooks/use-index-basket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Response> => {
Expand All @@ -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,
})
}

Expand Down
10 changes: 7 additions & 3 deletions src/hooks/usePrice.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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,
})

Expand Down
91 changes: 91 additions & 0 deletions tests/price-refresh.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>, 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(
<QueryClientProvider client={queryClient}>
<JotaiProvider store={store}>{ui}</JotaiProvider>
</QueryClientProvider>
)
}

const DtfPrice = () => {
const { data } = useIndexBasket(DTF, 1)
return <div>{data.price}</div>
}

const TokenPrice = () => {
const price = usePrice(1, TOKEN)
return <div>{price}</div>
}

describe('price refresh cadence', () => {
let fetchMock: ReturnType<typeof vi.fn>

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(<DtfPrice />)

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(<TokenPrice />)

await waitFor(() => expect(countCalls(fetchMock, 'current/prices')).toBe(1))

await vi.advanceTimersByTimeAsync(REFRESH_RATE)
await waitFor(() => expect(countCalls(fetchMock, 'current/prices')).toBe(2))
})
})