/** * Copyright (c) TonTech. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import type { FC, PropsWithChildren } from 'react'; import { formatUnits } from '@ton/appkit'; import type { Network } from '@ton/appkit'; import type { GetSwapQuoteData } from '@ton/appkit/queries'; import type { SwapProvider } from '@ton/appkit'; import { checkTransferBalance } from '@ton/appkit'; import type { TransferShortfall } from '@ton/appkit'; import { calcMaxSpendable } from '@ton/appkit'; import { useSwapQuote } from '../../hooks/use-swap-quote'; import { useSwapProvider } from '../../hooks/use-swap-provider'; import { useSwapProviders } from '../../hooks/use-swap-providers'; import { useBuildSwapTransaction } from '../../hooks/use-build-swap-transaction'; import { useAddress } from '../../../wallets'; import { useBalance } from '../../../balances/hooks/use-balance'; import { useNetwork } from '../../../network'; import { useSendTransaction } from '../../../transaction/hooks/use-send-transaction'; import { useDebounceValue } from '../../../../hooks/use-debounce-value'; import type { LowBalanceMode } from '../../../../components/shared/low-balance-modal/low-balance-modal'; import type { AppkitUIToken } from '../../../../types/appkit-ui-token'; import { mapSwapWidgetTokens } from '../../utils/map-swap-widget-tokens'; import { useSwapTokenState } from './use-swap-token-state'; import { useSwapBalances } from './use-swap-balances'; import { useSwapValidation } from './use-swap-validation'; export type { AppkitUIToken }; /** * Context type for the SwapWidget. * Provides all necessary state and actions for building custom swap UIs. */ export interface SwapContextType { /** Full list of available tokens for swapping */ tokens: AppkitUIToken[]; /** Currently selected source token */ fromToken: AppkitUIToken | null; /** Currently selected target token */ toToken: AppkitUIToken | null; /** Amount the user wants to swap (string to preserve input UX) */ fromAmount: string; /** Calculated receive amount from the current quote */ toAmount: string; /** Fiat currency symbol for price display, e.g. "$" */ fiatSymbol: string; /** User's balance of the "from" token */ fromBalance: string | undefined; /** User's balance of the "to" token */ toBalance: string | undefined; /** True while the "from" balance is being fetched */ isFromBalanceLoading: boolean; /** True while the "to" balance is being fetched */ isToBalanceLoading: boolean; /** Whether the user can proceed with the swap (checks balance, amount, quote) */ canSubmit: boolean; /** Raw swap quote from the provider */ quote: GetSwapQuoteData | undefined; /** True while the quote is being fetched from the API */ isQuoteLoading: boolean; /** Current validation or fetch error, null when everything is ok */ error: string | null; /** Slippage tolerance in basis points (100 = 1%) */ slippage: number; /** Currently selected swap provider (defaults to the first registered one) */ swapProvider: SwapProvider | undefined; /** All registered swap providers */ swapProviders: SwapProvider[]; /** Updates the selected swap provider */ setSwapProviderId: (providerId: string) => void; /** Updates the source token */ setFromToken: (token: AppkitUIToken) => void; /** Updates the target token */ setToToken: (token: AppkitUIToken) => void; /** Updates the swap amount */ setFromAmount: (amount: string) => void; /** Updates the slippage tolerance */ setSlippage: (slippage: number) => void; /** Swaps source and target tokens */ onFlip: () => void; /** Sets the "from" amount to the maximum available balance */ onMaxClick: () => void; /** Executes the swap transaction */ sendSwapTransaction: () => Promise; /** True while a transaction is being built or sent */ isSendingTransaction: boolean; /** True when the built transaction outflow exceeds the user's TON balance */ isLowBalanceWarningOpen: boolean; /** `reduce` when the outgoing token is TON (user can fix by changing amount), `topup` otherwise. */ lowBalanceMode: LowBalanceMode; /** Required TON amount for the pending operation, formatted as a decimal string. Empty when no pending op. */ lowBalanceRequiredTon: string; /** Replace the input with a value that fits into the current TON balance and close the warning */ onLowBalanceChange: () => void; /** Dismiss the low-balance warning without changing the input */ onLowBalanceCancel: () => void; } export const SwapContext = createContext({ tokens: [], fromToken: null, toToken: null, fromAmount: '', toAmount: '', fiatSymbol: '$', fromBalance: undefined, toBalance: undefined, isFromBalanceLoading: false, isToBalanceLoading: false, canSubmit: false, quote: undefined, isQuoteLoading: false, error: null, slippage: 50, swapProvider: undefined, swapProviders: [], setSwapProviderId: () => {}, setFromToken: () => {}, setToToken: () => {}, setFromAmount: () => {}, setSlippage: () => {}, onFlip: () => {}, onMaxClick: () => {}, sendSwapTransaction: () => Promise.resolve(), isSendingTransaction: false, isLowBalanceWarningOpen: false, lowBalanceMode: 'reduce', lowBalanceRequiredTon: '', onLowBalanceChange: () => {}, onLowBalanceCancel: () => {}, }); /** * Hook to access the swap context. * Must be used within a SwapWidgetProvider (or SwapWidget). */ export function useSwapContext() { return useContext(SwapContext); } /** * Props for the SwapWidgetProvider. */ export interface SwapProviderProps extends PropsWithChildren { /** Full list of tokens available for swapping in the UI */ tokens: AppkitUIToken[]; /** Network to use for quote fetching. When omitted, uses the selected wallet's network. */ network?: Network; /** Fiat currency symbol for price display, defaults to "$" */ fiatSymbol?: string; /** Ticker of the token pre-selected for the source */ defaultFromSymbol?: string; /** Ticker of the token pre-selected for the target */ defaultToSymbol?: string; /** Initial slippage in basis points (100 = 1%), defaults to 100 (1%) */ defaultSlippage?: number; } export const SwapWidgetProvider: FC = ({ children, tokens, network: networkProp, fiatSymbol = '$', defaultFromSymbol, defaultToSymbol, defaultSlippage = 100, }) => { // Input prep — derived from props, consumed by local-state hooks below. const mappedTokens = useMemo(() => mapSwapWidgetTokens(tokens), [tokens]); // 2. Queries and external readers (hoisted: `network` gates token filtering for local state below) const walletNetwork = useNetwork(); const network = networkProp ?? walletNetwork; const networkFilteredTokens = useMemo( () => (network ? mappedTokens.filter((t) => t.network.chainId === network.chainId) : mappedTokens), [mappedTokens, network], ); // 1. Local state const { fromToken, toToken, fromAmount, setFromToken, setToToken, setFromAmount, onFlip } = useSwapTokenState({ mappedTokens: networkFilteredTokens, defaultFromSymbol, defaultToSymbol, }); const [slippage, setSlippage] = useState(defaultSlippage); const [fromAmountDebounced] = useDebounceValue(fromAmount, 500); const [pendingSwap, setPendingSwap] = useState(undefined); const address = useAddress(); const [swapProvider, setSwapProviderId] = useSwapProvider(); const swapProviders = useSwapProviders(); // Stabilized query inputs — kept next to the query that consumes them. const fromTokenParam = useMemo( () => fromToken ? { address: fromToken.address, decimals: fromToken.decimals, symbol: fromToken.symbol, name: fromToken.name, } : undefined, [fromToken], ); const toTokenParam = useMemo( () => toToken ? { address: toToken.address, decimals: toToken.decimals, symbol: toToken.symbol, name: toToken.name } : undefined, [toToken], ); const isNetworkSupported = useMemo( () => !swapProvider || !network || swapProvider.getSupportedNetworks().some((n) => n.chainId === network.chainId), [swapProvider, network], ); const { data: quote, isFetching: isQuoteFetching, error: quoteError, } = useSwapQuote({ from: fromTokenParam, to: toTokenParam, amount: fromAmountDebounced, network, slippageBps: slippage, providerId: swapProvider?.providerId, query: { enabled: isNetworkSupported, networkMode: 'always', retry: false, gcTime: 0 }, }); // Also show "loading" while the user is still typing (debounce in-flight) so the UI doesn't flash // the previous quote as if it were final. const isQuoteLoading = isQuoteFetching || fromAmount !== fromAmountDebounced; const { fromBalance, toBalance, isFromBalanceLoading, isToBalanceLoading } = useSwapBalances({ fromToken, toToken, ownerAddress: address ?? undefined, network, }); const { data: tonBalance } = useBalance({ network, query: { refetchInterval: 5000 } }); // 4. Mutations (hoisted above validation: the mutation `error` is one of its inputs) const { mutateAsync: buildTransaction, isPending: isBuildingTransaction, error: buildError, reset: resetBuild, } = useBuildSwapTransaction({ mutation: { networkMode: 'always' } }); const { mutateAsync: sendTransaction, isPending: isSendingPending, error: sendMutationError, reset: resetSend, } = useSendTransaction({ mutation: { networkMode: 'always' } }); const isSendingTransaction = isBuildingTransaction || isSendingPending; const sendError = sendMutationError ?? buildError; // Drop the previous send error when the user changes anything that would invalidate it — // the next attempt is conceptually a new swap, no need to keep the old message on screen. const resetSendError = useCallback(() => { resetBuild(); resetSend(); }, [resetBuild, resetSend]); // 3. Derivations const toAmount = quote?.toAmount ?? ''; const { error, canSubmit } = useSwapValidation({ fromAmount, fromAmountDebounced, fromToken, toToken, fromBalance, quote, quoteError, sendError, isNetworkSupported, }); const isLowBalanceWarningOpen = pendingSwap !== undefined; const lowBalanceMode: LowBalanceMode = pendingSwap?.mode ?? 'reduce'; const lowBalanceRequiredTon = useMemo(() => { if (!pendingSwap) return ''; return formatUnits(pendingSwap.requiredNanos, 9); }, [pendingSwap]); // Drop the previous send error when the user changes anything that would invalidate it — // the next attempt is conceptually a new swap, no need to keep the old message on screen. useEffect(() => { resetSendError(); }, [fromToken?.address, toToken?.address, fromAmount, resetSendError]); // Auto-clear the send error after a short delay so a stale failure doesn't linger in the // submit button — the user is expected to act on it within seconds or move on. useEffect(() => { if (!sendError) return; const id = setTimeout(resetSendError, 5000); return () => clearTimeout(id); }, [sendError, resetSendError]); // 5. Callbacks const handleMaxClick = useCallback(() => { if (!fromBalance || !fromToken) return; setFromAmount(calcMaxSpendable({ balance: fromBalance, token: fromToken, feeReserveNanos: 350_000_000n })); }, [fromBalance, fromToken, setFromAmount]); const sendSwapTransaction = useCallback(async () => { if (!quote || !address || !fromToken) return; const tx = await buildTransaction({ quote, userAddress: address }); const shortfall = checkTransferBalance({ messages: tx.messages, tonBalance, gasBufferNanos: 100_000_000n, fromToken, fromAmount, }); if (shortfall) { setPendingSwap(shortfall); return; } await sendTransaction(tx); }, [quote, address, fromToken, fromAmount, buildTransaction, sendTransaction, tonBalance]); const onLowBalanceChange = useCallback(() => { if (!pendingSwap || pendingSwap.mode !== 'reduce') return; setFromAmount(pendingSwap.suggestedFromAmount); setPendingSwap(undefined); }, [pendingSwap, setFromAmount]); const onLowBalanceCancel = useCallback(() => { setPendingSwap(undefined); }, []); const value = useMemo( () => ({ tokens: networkFilteredTokens, fromToken, toToken, fromAmount, toAmount, fiatSymbol, fromBalance, toBalance, isFromBalanceLoading, isToBalanceLoading, canSubmit, quote, isQuoteLoading, error, slippage, swapProvider, swapProviders, setSwapProviderId, setFromToken, setToToken, setFromAmount, setSlippage, onFlip, onMaxClick: handleMaxClick, sendSwapTransaction, isSendingTransaction, isLowBalanceWarningOpen, lowBalanceMode, lowBalanceRequiredTon, onLowBalanceChange, onLowBalanceCancel, }), [ networkFilteredTokens, fromToken, toToken, fromAmount, toAmount, fiatSymbol, fromBalance, toBalance, isFromBalanceLoading, isToBalanceLoading, canSubmit, quote, isQuoteLoading, error, slippage, swapProvider, swapProviders, setSwapProviderId, setFromToken, setToToken, setFromAmount, setSlippage, onFlip, handleMaxClick, sendSwapTransaction, isSendingTransaction, isLowBalanceWarningOpen, lowBalanceMode, lowBalanceRequiredTon, onLowBalanceChange, onLowBalanceCancel, ], ); return {children}; };