Skip to content

Instantly share code, notes, and snippets.

@drichar
Created June 25, 2025 17:57
Show Gist options
  • Save drichar/c1f4b0049aa4d9bd5b00972d7d330ba5 to your computer and use it in GitHub Desktop.
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.
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