UNPKG

@shogun-sdk/one-shot

Version:

Shogun SDK - One Shot: React Components and hooks for cross-chain swaps

328 lines 15.5 kB
'use client'; import React from 'react'; import { useDerivedState } from '../hooks/useDerivedState.js'; import { useFeeCalculations } from '../hooks/useFeeCalculation.js'; import { useTokenBalances } from '../hooks/useTokenBalances.js'; import { OneShotClient, HighSlippageValidationService, decodeErrorMessage, DEFAULT_ERROR_MESSAGE, GasStationCalculator, getRecipientAddress, } from '@shogun-sdk/money-legos'; import { AffiliateFeeService, buildInsufficientFundsWebAppError, createQuoteParams, getDummyAddress, getMaxAmount, getSwapWeiAmount, isEVMChain, } from '@shogun-sdk/money-legos'; import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useBalance } from '../hooks/useBalance.js'; import { useShogunBalances } from './ShogunBalancesContext.js'; const ShogunQuoteContext = createContext(undefined); const defaultQueryClient = new QueryClient({}); export function ShogunQuoteProvider({ queryClient: externalQueryClient = defaultQueryClient, ...props }) { return (React.createElement(QueryClientProvider, { client: externalQueryClient }, React.createElement(ShogunQuoteProviderInner, { ...props }))); } function ShogunQuoteProviderInner(props) { const { swap, system, isEoaAccount, children } = props; const { tokenIn, tokenOut, setLatestSuggestedAutoSlippageValue, inputAmount: incomingInputAmount, setInputAmount, recipientAddress, slippage, dynamicSlippage, } = swap; const { userEVMAddress, userSolanaAddress, affiliateWallets, systemFeePercent, api, notifyAboutError, externalServiceTxCostUsd, } = system; // Get user addresses based on chain const userInputAddress = useMemo(() => (isEVMChain(tokenIn?.chainId) ? userEVMAddress : userSolanaAddress), [tokenIn?.chainId, userEVMAddress, userSolanaAddress]); const userOutputAddress = useMemo(() => (isEVMChain(tokenOut?.chainId) ? userEVMAddress : userSolanaAddress), [tokenOut?.chainId, userEVMAddress, userSolanaAddress]); // Local state const [inputValue, setInputValue] = useState(''); const [queryParams, setQueryParams] = useDerivedState(null); const [tempMaxInputAmount, setTempMaxInputAmount] = useState(); const [feeValidationError, setFeeValidationError] = useState(null); const [slippageError, setSlippageError] = useState(null); const [quoteError, setQuoteError] = useState(null); const confirmSlippage = useCallback(() => { setSlippageError(null); }, [setSlippageError]); // Check if input exists const hasInput = useMemo(() => !!tempMaxInputAmount || !!(inputValue && inputValue !== '0'), [tempMaxInputAmount, inputValue]); const lastQuoteFetchTimeRef = useRef(0); // Fetch token balances const balances = useTokenBalances({ userEVMAddress, userSolanaAddress, tokenIn, tokenOut, }); const allBalances = useMemo(() => [...(balances?.evmBalances || []), ...(balances?.solanaBalances || [])], [balances?.evmBalances, balances?.solanaBalances]); const { client: balancesClient } = useShogunBalances(); const { balance: tokenInBalance, loading: tokenInBalanceLoading } = useBalance(userInputAddress, tokenIn?.address, tokenIn?.chainId); // Create query parameters const makeQueryParams = useCallback((userAddress, totalFeePercent, tempInputAmount) => { if (!tokenIn || !tokenOut) return null; const destinationAddress = recipientAddress || getDummyAddress(tokenOut.chainId); const params = createQuoteParams(tokenIn, tokenOut, tempInputAmount || inputValue, destinationAddress, userAddress || getDummyAddress(tokenIn.chainId), slippage, totalFeePercent || systemFeePercent, affiliateWallets, dynamicSlippage); return params; }, [tokenIn, tokenOut, inputValue, recipientAddress, slippage, affiliateWallets, systemFeePercent, dynamicSlippage]); // Fee calculations const { fees, balanceError, calculateFees, setFees, resetBalanceError } = useFeeCalculations({ address: userInputAddress, inputAmount: tempMaxInputAmount || inputValue, tokenIn, hasInput, tokenInBalance, queryParams, setLatestSuggestedAutoSlippageValue, notifyAboutError, }); // Clear the query cache for the current quote to ensure fresh data const queryClient = useQueryClient(); const abortQuote = useCallback(() => { const key = ['quote', queryParams, allBalances.length]; queryClient.removeQueries({ queryKey: key, }); queryClient.cancelQueries({ queryKey: key, }); }, [queryClient, queryParams, allBalances.length]); const handleSetInputValue = useCallback((value) => { resetBalanceError(); setFeeValidationError(null); setSlippageError(null); setFees(undefined); setQuoteError(null); abortQuote(); setInputValue(value); setTempMaxInputAmount(undefined); }, [setTempMaxInputAmount, setInputValue, resetBalanceError, setFees, abortQuote]); // Initialize quote client const quoteClient = useMemo(() => new OneShotClient(api.key, api.url), [api.key, api.url]); // Fetch quotes const { data: quotes, isLoading, isRefetching, refetch, } = useQuery({ queryKey: ['quote', queryParams, allBalances.length], queryFn: async ({ signal }) => { try { // Set a minimum time between fetches to prevent continuous fetching const now = Date.now(); const timeSinceLastFetch = now - lastQuoteFetchTimeRef.current; // If less than 3 seconds have passed since the last fetch, delay this fetch if (timeSinceLastFetch < 3000 && !tempMaxInputAmount) { await new Promise((resolve) => setTimeout(resolve, 3000 - timeSinceLastFetch)); } lastQuoteFetchTimeRef.current = Date.now(); const quote = await quoteClient.fetchQuote(queryParams, signal); // Always check balance and validation with every new quote const { maxAmount } = await calculateFees(quote); if (tempMaxInputAmount && maxAmount) { setInputAmount(maxAmount); handleSetInputValue(maxAmount); } return quote; } catch (e) { console.error('Error fetching quote:', e); notifyAboutError(e); if (e instanceof Error) { const errorMessage = decodeErrorMessage(e.message); if (errorMessage !== DEFAULT_ERROR_MESSAGE) { setQuoteError(errorMessage); } } throw e; } }, enabled: !!queryParams && !!hasInput, retry: 2, staleTime: 30000, refetchOnWindowFocus: false, }); useEffect(() => { const validateSlippage = async () => { if (quotes && (inputValue || tempMaxInputAmount)) { setSlippageError(null); const slippageService = new HighSlippageValidationService(); const tokenInUsdPrice = await balancesClient.getTokenUSDPrice(quotes.inputAmount.address, quotes.inputAmount.chainId); if (!tokenInUsdPrice?.priceUsd) { setSlippageError('Can not predict slippage: token in USD price not found'); return; } const inputValueUsd = slippageService.getInputUsdValue(BigInt(quotes.inputAmount.value), quotes.inputAmount.decimals, tokenInUsdPrice?.priceUsd || 0); const tokenOutUsdPrice = await balancesClient.getTokenUSDPrice(quotes.outputAmount.address, quotes.outputAmount.chainId); if (!tokenOutUsdPrice?.priceUsd) { setSlippageError('Can not predict slippage: token out USD price not found'); return; } if (inputValueUsd) { const { expectedSlippage } = slippageService.getExpectedSlippage(quotes, inputValueUsd, { decimals: quotes.outputAmount.decimals, usdPrice: tokenOutUsdPrice?.priceUsd || 0, }); const isSlippageValid = slippageService.isSlippageValid(expectedSlippage); if (!isSlippageValid) { setSlippageError(`High slippage detected: ${expectedSlippage}%. Please confirm to proceed.`); } } } }; validateSlippage(); }, [quotes, balancesClient, inputValue, tempMaxInputAmount]); // Handle max balance input const handleMaxBalanceInput = useCallback((quote) => { if (!tokenInBalance || !tokenIn) return; const { maxAmount, needRecalculateMaxValue } = getMaxAmount({ balance: tokenInBalance, tokenAddress: tokenIn.address, chainId: tokenIn.chainId, decimals: tokenIn.decimals, fees, quote, }); if (maxAmount) { if (needRecalculateMaxValue) { setTempMaxInputAmount(maxAmount); } else { handleSetInputValue(maxAmount); } } }, [tokenInBalance, tokenIn, fees, handleSetInputValue]); // Sync input value with inputAmount useEffect(() => { setInputValue((prev) => { if (prev !== incomingInputAmount) { return incomingInputAmount; } return prev; }); }, [incomingInputAmount]); const [gasRefuelAmount, setGasRefuelAmount] = useState(undefined); // Main effect for loading quotes useEffect(() => { const calculate = async () => { setGasRefuelAmount(undefined); if (!tokenIn || !tokenOut || !hasInput || tokenInBalanceLoading) { setFeeValidationError(null); return; } try { if (!userInputAddress) { setFeeValidationError(null); setQueryParams(makeQueryParams(userInputAddress, systemFeePercent, tempMaxInputAmount)); return; } const excludeAffiliateFeeForQuote = false; /*excludeAffiliateFee({ srcToken: tokenIn.address, destToken: tokenOut.address, srcChainId: tokenIn.chainId, destChainId: tokenOut.chainId, });*/ let validateAffiliateFee = { isValid: true, error: null, totalFeePercent: undefined }; const gasStationService = new GasStationCalculator(buildInsufficientFundsWebAppError, balancesClient); const weiAmount = getSwapWeiAmount(tempMaxInputAmount || inputValue, tokenIn.decimals).toString(); const destinationAddress = recipientAddress || getDummyAddress(tokenOut.chainId); const targetDestinationAddress = getRecipientAddress(tokenIn, tokenOut, userInputAddress, destinationAddress); const additionalAffiliateFee = await gasStationService.calculateGasRefuelAmount(userInputAddress, targetDestinationAddress, allBalances, { weiAmount: BigInt(weiAmount), srcToken: tokenIn.address, destToken: tokenOut.address, srcNetwork: tokenIn.chainId, destNetwork: tokenOut.chainId, }, systemFeePercent, isEoaAccount, externalServiceTxCostUsd); validateAffiliateFee = AffiliateFeeService.validateAffiliateFeeWithServiceFees({ balances: allBalances, excludeAffiliateFee: excludeAffiliateFeeForQuote, isTransfer: false, tokenInAddress: tokenIn.address, tokeinInDecimals: tokenIn.decimals, sourceNetwork: tokenIn.chainId, amountBigInt: weiAmount, systemFeePercent, errorBuilder: buildInsufficientFundsWebAppError, isEoaAccount, additionalAffiliateFee: additionalAffiliateFee.additionalAffiliateFeePercent, externalServiceTxCostUsd: externalServiceTxCostUsd, }); setGasRefuelAmount(additionalAffiliateFee.refuelAmount); console.log('[GasStationService] AdditionalAffiliateFee', additionalAffiliateFee?.additionalAffiliateFeePercent?.toFixed(6)); // Store validation error but still proceed with quote if (!validateAffiliateFee.isValid) { setFeeValidationError(validateAffiliateFee.error || null); } else { setFeeValidationError(null); } const newQueryParams = makeQueryParams(userInputAddress, validateAffiliateFee.totalFeePercent, tempMaxInputAmount); setQueryParams(newQueryParams); } catch (error) { console.error('Error in quote loading effect:', error); notifyAboutError(error); setFeeValidationError(null); } }; calculate(); }, [ makeQueryParams, allBalances, tokenIn, tokenOut, userInputAddress, recipientAddress, inputValue, setFeeValidationError, tokenInBalanceLoading, tokenInBalance, tempMaxInputAmount, hasInput, systemFeePercent, setQueryParams, feeValidationError, notifyAboutError, isEoaAccount, balancesClient, externalServiceTxCostUsd, incomingInputAmount, ]); const value = useMemo(() => ({ quotes, isLoading, isRefetching, errors: { feeValidationError: feeValidationError, balanceError, slippageError, quoteError, }, quoteRefetch: refetch, fees, handleMaxBalanceInput, inputValue, setInputValue: handleSetInputValue, userInputAddress, userOutputAddress, isEoaAccount, confirmSlippage, gasRefuelAmount, quoteParams: queryParams, }), [ quotes, isLoading, isRefetching, refetch, fees, handleMaxBalanceInput, inputValue, userInputAddress, userOutputAddress, balanceError, feeValidationError, isEoaAccount, slippageError, quoteError, confirmSlippage, handleSetInputValue, gasRefuelAmount, queryParams, ]); return React.createElement(ShogunQuoteContext.Provider, { value: value }, children); } export const useShogunQuote = () => { const context = useContext(ShogunQuoteContext); if (context === undefined) { console.error('useShogunQuote used outside of provider'); throw new Error('useShogunQuote must be used within a ShogunQuoteProvider'); } return context; }; //# sourceMappingURL=ShogunQuoteContext.js.map