@shogun-sdk/one-shot
Version:
Shogun SDK - One Shot: React Components and hooks for cross-chain swaps
328 lines • 15.5 kB
JavaScript
'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