Created
June 25, 2025 17:57
-
-
Save drichar/c1f4b0049aa4d9bd5b00972d7d330ba5 to your computer and use it in GitHub Desktop.
React hook for fetching swap quotes from Deflex order router using TanStack Query. Includes smart caching, debounced input, tiered fee calculation, and loading/error states.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useQuery, useQueryClient } from '@tanstack/react-query' | |
import { useSingleAssetMarketData } from '@/hooks/useAssetMarketData' | |
import { useDebounce } from '@/hooks/useDebounce' | |
import { fetchDeflexQuote } from '@/lib/deflex' | |
import { addBreadcrumb } from '@/lib/sentry/config' | |
import { calculateAssetInAmount } from '@/utils/amount' | |
import { AssetAmount } from '@/utils/asset-amount' | |
type Asset = { | |
id: bigint // ASA ID | |
creator: string | |
decimals: bigint | |
image: string | |
name: string | |
unitName: string | |
url: string | |
} | |
type AssetBalance = { | |
id: bigint // ASA ID | |
amount: number // Raw amount with decimals | |
amountUsd: number // Balance in USD | |
availableAmount: number // Amount available to spend | |
availableAmountUsd: number // Available balance in USD | |
} | |
type AssetWithBalance = Asset & AssetBalance | |
// Quote staleness threshold in milliseconds (15 seconds) - matches refetch interval | |
const QUOTE_STALE_TIME_MS = 15 * 1000 | |
/** | |
* Calculate fee basis points based on USD trade size | |
*/ | |
export function calculateFeeBps(inputUsdAmount: string): number { | |
const amount = parseFloat(inputUsdAmount) | |
if (amount < 25) { | |
return 100 // 1.00% | |
} else if (amount < 250) { | |
return 85 // 0.85% | |
} else if (amount < 2500) { | |
return 75 // 0.75% | |
} else { | |
return 50 // 0.50% | |
} | |
} | |
interface UseDeflexQuoteParams { | |
amount: string | |
assetIn: AssetWithBalance | undefined | |
assetOut: Asset | |
isMaxAmount: boolean | |
swapLoading: boolean | |
enabled?: boolean | |
context: 'buy' | 'sell' // For logging context | |
} | |
/** | |
* Hook for fetching and caching Deflex quotes using TanStack Query | |
* | |
* Features: | |
* - Automatic caching with 15-second staleness threshold | |
* - Built-in loading and error states | |
* - Automatic deduplication of identical requests | |
* - Background refetching when stale | |
* - Debounced input to prevent excessive API calls | |
*/ | |
export function useDeflexQuote({ | |
amount, | |
assetIn, | |
assetOut, | |
isMaxAmount, | |
swapLoading, | |
enabled = true, | |
context, | |
}: UseDeflexQuoteParams) { | |
// Debounce the amount to prevent excessive API calls while user types | |
const debouncedAmount = useDebounce(amount, 500) | |
// Normalize amount using parseFloat to handle both trailing zeros and incomplete decimals | |
// This prevents separate cache entries for equivalent values like "3.50"/"3.5" and "3."/"3" | |
const normalizedAmount = parseFloat(debouncedAmount).toString() | |
// Get query client for manual cache operations | |
const queryClient = useQueryClient() | |
// Get fresh prices for both assets | |
const { refetchPrice: refetchAssetInPrice } = useSingleAssetMarketData( | |
assetIn?.id || BigInt(0), | |
) | |
const { refetchPrice: refetchAssetOutPrice } = useSingleAssetMarketData( | |
assetOut.id, | |
) | |
// Create query key that includes all parameters that affect the quote | |
// Use normalized amount to prevent duplicate cache entries for equivalent values | |
const queryKey = [ | |
'deflex-quote', | |
{ | |
amount: normalizedAmount, | |
assetInId: assetIn?.id?.toString(), | |
assetOutId: assetOut.id.toString(), | |
isMaxAmount, | |
context, | |
}, | |
] | |
// Use TanStack Query for caching and state management | |
const query = useQuery({ | |
queryKey, | |
queryFn: async () => { | |
if (!assetIn) { | |
throw new Error('Input asset is required') | |
} | |
const numericAmount = parseFloat(normalizedAmount) | |
if (numericAmount <= 0) { | |
throw new Error('Amount must be greater than 0') | |
} | |
// Calculate fee based on USD amount (use normalized amount) | |
const feeBps = calculateFeeBps(normalizedAmount) | |
addBreadcrumb(`${context} fetching Deflex quote`, context, 'info', { | |
inputAssetId: assetIn.id.toString(), | |
targetAssetId: assetOut.id.toString(), | |
usdAmount: normalizedAmount, | |
feeBps, | |
}) | |
// Get fresh prices for both assets concurrently | |
const [assetInPrice, assetOutPrice] = await Promise.all([ | |
refetchAssetInPrice(), | |
refetchAssetOutPrice(), | |
]) | |
if (!assetInPrice || !assetOutPrice) { | |
throw new Error('Failed to fetch asset prices') | |
} | |
// Calculate input amount | |
const availableInputAmount = AssetAmount.StandardUnits( | |
assetIn, | |
assetIn.availableAmount, | |
).microUnits | |
const assetInAmount = calculateAssetInAmount({ | |
assetIn, | |
assetUsdPrice: assetInPrice, | |
inputUsdAmount: normalizedAmount, | |
availableAmount: availableInputAmount, | |
isMaxAmount, | |
}) | |
// Fetch the quote from Deflex | |
const quote = await fetchDeflexQuote({ | |
fromAssetId: Number(assetIn.id), | |
toAssetId: Number(assetOut.id), | |
amount: Number(assetInAmount.microUnits), | |
type: 'fixed-input', | |
disabledProtocols: [], | |
feeBps, | |
}) | |
addBreadcrumb( | |
`${context} Deflex quote fetched successfully`, | |
context, | |
'info', | |
{ | |
inputAssetId: assetIn.id.toString(), | |
targetAssetId: assetOut.id.toString(), | |
usdAmount: normalizedAmount, | |
quoteAmount: quote.quote?.toString(), | |
}, | |
) | |
return { | |
quote, | |
assetInPrice, | |
assetOutPrice, | |
} | |
}, | |
enabled: | |
enabled && !!assetIn && parseFloat(normalizedAmount) > 0 && !swapLoading, | |
staleTime: QUOTE_STALE_TIME_MS, // Data is fresh for 30 seconds | |
gcTime: QUOTE_STALE_TIME_MS * 2, // Keep in cache for 60 seconds | |
refetchInterval: 15 * 1000, // Refetch every 15 seconds for fresh quotes | |
retry: (failureCount, error) => { | |
// Don't retry on user input errors, only on network errors | |
if (error instanceof Error && error.message.includes('Failed to fetch')) { | |
return failureCount < 2 | |
} | |
return false | |
}, | |
}) | |
// Check if we're waiting for debounce to complete (immediate feedback) | |
// Normalize the current amount to avoid showing loading for equivalent values like "2" vs "2." | |
const normalizedCurrentAmount = parseFloat(amount).toString() | |
const isWaitingForDebounce = | |
normalizedCurrentAmount !== normalizedAmount && parseFloat(amount) > 0 | |
// Return a clean interface | |
return { | |
// Quote data (null when not loaded, undefined when loading) | |
// Contains: { quote: DeflexQuote, assetInPrice: number, assetOutPrice: number } | |
quoteData: query.data || null, | |
// Loading states | |
isQuoteFetching: query.isFetching, | |
isQuoteLoading: query.isLoading || isWaitingForDebounce, // Show loading during debounce | |
isQuoteError: query.isError, | |
// Error information | |
quoteError: query.error, | |
// Manual controls | |
clearCache: () => queryClient.removeQueries({ queryKey: ['deflex-quote'] }), | |
refetchQuote: query.refetch, | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment