/** * 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 type { Network, StakingProvider, StakingQuoteDirection, TransferShortfall } from '@ton/appkit'; import { calcMaxSpendable, checkTransferBalance, formatUnits, setDefaultStakingProvider, validateNumericString, } from '@ton/appkit'; import type { StakingQuote, StakingProviderInfo, StakingBalance, UnstakeModes, StakingProviderMetadata, } from '@ton/appkit'; import { UnstakeMode } from '@ton/appkit'; import type { LowBalanceMode } from '../../../../components/shared/low-balance-modal/low-balance-modal'; import { useNetwork } from '../../../network'; import { useAppKit } from '../../../settings/hooks/use-app-kit'; import { useStakingQuote } from '../../hooks/use-staking-quote'; import type { UseStakingQuoteParameters } from '../../hooks/use-staking-quote'; import { useStakingProvider } from '../../hooks/use-staking-provider'; import { useStakingProviders } from '../../hooks/use-staking-providers'; import { useStakingProviderInfo } from '../../hooks/use-staking-provider-info'; import { useStakingProviderMetadata } from '../../hooks/use-staking-provider-metadata'; import { useStakedBalance } from '../../hooks/use-staked-balance'; import { useBuildStakeTransaction } from '../../hooks/use-build-stake-transaction'; import { useAddress } from '../../../wallets'; import { useBalance } from '../../../balances/hooks/use-balance'; import { useJettonBalanceByAddress } from '../../../jettons/hooks/use-jetton-balance-by-address'; import { useSendTransaction } from '../../../transaction/hooks/use-send-transaction'; import { useDebounceValue } from '../../../../hooks/use-debounce-value'; import { useStakingValidation } from './use-staking-validation'; /** * Context type for the StakingWidget. * Provides all necessary state and actions for building custom staking UIs. */ export interface StakingContextType { /** Amount the user wants to stake (string to preserve input UX) */ amount: string; /** Whether the user can proceed with staking (checks balance, amount validity, etc.) */ canSubmit: boolean; /** Raw staking quote from the provider */ quote: StakingQuote | undefined; /** True while the stake quote is being fetched */ isQuoteLoading: boolean; /** Current validation/fetch error for staking, null when everything is ok */ error: string | null; /** Staking provider dynamic info (APY, instant unstake availability, etc.) */ providerInfo: StakingProviderInfo | undefined; /** Staking provider static metadata */ providerMetadata: StakingProviderMetadata | undefined; /** Currently selected staking provider (defaults to the first registered one) */ stakingProvider: StakingProvider | undefined; /** All registered staking providers */ stakingProviders: StakingProvider[]; /** Updates the selected staking provider */ setStakingProviderId: (providerId: string) => void; /** Network the widget is operating on (resolved from prop or wallet) */ network: Network | undefined; /** Current operation direction: 'stake' or 'unstake' */ direction: StakingQuoteDirection; /** True while provider info is being fetched */ isProviderInfoLoading: boolean; /** Base balance (native or jetton) available for staking */ balance: string | undefined; /** True while base balance is being fetched */ isBalanceLoading: boolean; /** User's currently staked balance */ stakedBalance: StakingBalance | undefined; /** True while staked balance is being fetched */ isStakedBalanceLoading: boolean; /** Selected unstake mode (e.g. instant or delayed) */ unstakeMode: UnstakeModes; /** Sets the input amount */ setAmount: (amount: string) => void; /** Sets the unstake mode */ setUnstakeMode: (mode: UnstakeModes) => void; /** Triggers the staking/unstaking transaction */ sendTransaction: () => Promise; /** Changes the direction (stake/unstake) */ onChangeDirection: (direction: StakingQuoteDirection) => void; /** True while a transaction is being processed */ isSendingTransaction: boolean; /** True if the user is inputting the output amount ("I want to get X") */ isReversed: boolean; /** Toggles between inputting from amount and output amount */ toggleReversed: () => void; /** Amount displayed in the reversed (bottom) input */ reversedAmount: string; /** Sets the input amount to the maximum available balance (leaves room for TON gas on native stake) */ onMaxClick: () => void; /** 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 StakingContext = createContext({ amount: '', canSubmit: false, quote: undefined, isQuoteLoading: false, error: null, providerInfo: undefined, providerMetadata: undefined, stakingProvider: undefined, stakingProviders: [], setStakingProviderId: () => {}, network: undefined, direction: 'stake', isProviderInfoLoading: false, balance: undefined, isBalanceLoading: false, stakedBalance: undefined, isStakedBalanceLoading: false, unstakeMode: UnstakeMode.INSTANT, setAmount: () => {}, setUnstakeMode: () => {}, sendTransaction: () => Promise.resolve(), onChangeDirection: () => {}, isSendingTransaction: false, isReversed: false, toggleReversed: () => {}, reversedAmount: '0', onMaxClick: () => {}, isLowBalanceWarningOpen: false, lowBalanceMode: 'reduce', lowBalanceRequiredTon: '', onLowBalanceChange: () => {}, onLowBalanceCancel: () => {}, }); /** * Hook to access the staking context. * Must be used within a StakingWidgetProvider (or StakingWidget). */ export const useStakingContext = () => { return useContext(StakingContext); }; /** * Props for the StakingWidgetProvider. */ export interface StakingProviderProps extends PropsWithChildren { /** * Network to use for quote fetching and transactions. * When omitted, uses the selected wallet's network. */ network?: Network; } export const StakingWidgetProvider: FC = ({ children, network: networkProp }) => { const [amount, setAmountRaw] = useState(''); const [unstakeMode, setUnstakeMode] = useState(UnstakeMode.INSTANT); const [direction, setDirection] = useState('stake'); const [isReversed, setIsReversed] = useState(false); const [pendingStake, setPendingStake] = useState(undefined); const walletNetwork = useNetwork(); const network = networkProp ?? walletNetwork; const address = useAddress(); const appKit = useAppKit(); const stakingProvider = useStakingProvider(); const stakingProviders = useStakingProviders(); const setStakingProviderId = useCallback( (providerId: string) => { setDefaultStakingProvider(appKit, { providerId }); }, [appKit], ); const isNetworkSupported = useMemo( () => !stakingProvider || !network || stakingProvider.getSupportedNetworks().some((n) => n.chainId === network.chainId), [stakingProvider, network], ); const { data: providerInfo, isLoading: isProviderInfoLoading } = useStakingProviderInfo({ network }); const providerMetadata = useStakingProviderMetadata({ network }); const isNativeTon = providerMetadata?.stakeToken.address === 'ton'; // Always fetch TON balance: even when the stake token is a jetton we need it to check whether the user has // enough TON to cover network fees before sending. const { data: nativeBalanceData, isLoading: isNativeBalanceLoading } = useBalance({ network, query: { refetchInterval: 5000 }, }); const { data: jettonBalanceData, isLoading: isJettonBalanceLoading } = useJettonBalanceByAddress({ jettonAddress: !isNativeTon ? providerMetadata?.stakeToken.address : undefined, ownerAddress: address ?? undefined, network, query: { enabled: !isNativeTon && !!providerMetadata?.stakeToken.address && !!address, refetchInterval: 5000 }, }); const balance = isNativeTon ? nativeBalanceData : jettonBalanceData; const isBalanceLoading = isNativeTon ? isNativeBalanceLoading : isJettonBalanceLoading; const { data: stakedBalanceData, isLoading: isStakedBalanceLoading } = useStakedBalance({ userAddress: address ?? undefined, network, query: { refetchInterval: 5000 }, }); const { mutateAsync: buildTransaction, isPending: isBuildingTransaction, error: buildError, reset: resetBuild, } = useBuildStakeTransaction({ mutation: { networkMode: 'always' } }); const { mutateAsync: sendTransaction, isPending: isSendingPending, error: sendMutationError, reset: resetSend, } = useSendTransaction({ mutation: { networkMode: 'always' } }); const isSendingTransaction = isBuildingTransaction || isSendingPending; const sendError = sendMutationError ?? buildError; const resetSendError = useCallback(() => { resetBuild(); resetSend(); }, [resetBuild, resetSend]); // Drop the previous send error when the user changes anything that invalidates it — // the next attempt is conceptually a new stake, no need to keep the old message on screen. useEffect(() => { resetSendError(); }, [direction, amount, isReversed, 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]); const amountDecimals = useMemo(() => { const unstakeDecimals = isReversed ? providerMetadata?.stakeToken.decimals : providerMetadata?.receiveToken?.decimals; return direction === 'stake' ? providerMetadata?.stakeToken.decimals : unstakeDecimals; }, [direction, providerMetadata?.stakeToken.decimals, providerMetadata?.receiveToken?.decimals, isReversed]); const setAmount = useCallback( (value: string) => { if (value === '' || validateNumericString(value, amountDecimals)) setAmountRaw(value); }, [amountDecimals], ); const [quoteParamsDebounced] = useDebounceValue( { direction, amount, unstakeMode, isReversed, network, }, 500, ); const { data: quote, isFetching: isQuoteLoading, error: quoteError, } = useStakingQuote({ ...quoteParamsDebounced, query: { enabled: isNetworkSupported, networkMode: 'always', retry: false, gcTime: 0 }, }); const reversedAmount = useMemo(() => { if (direction === 'unstake' && isReversed) return quote?.amountIn || '0'; return quote?.amountOut || '0'; }, [direction, isReversed, quote?.amountOut, quote?.amountIn]); const toggleReversed = useCallback(() => { setAmountRaw(reversedAmount); setIsReversed((prev) => !prev); }, [reversedAmount]); const handleMaxClick = useCallback(() => { const outgoingToken = direction === 'stake' ? providerMetadata?.stakeToken : providerMetadata?.receiveToken; const available = direction === 'stake' ? balance : stakedBalanceData?.stakedBalance; if (direction === 'unstake') setIsReversed(false); if (!available || !outgoingToken) { setAmountRaw(available ?? ''); return; } setAmountRaw(calcMaxSpendable({ balance: available, token: outgoingToken, feeReserveNanos: 1_200_000_000n })); }, [ direction, balance, stakedBalanceData?.stakedBalance, providerMetadata?.stakeToken, providerMetadata?.receiveToken, ]); const handleSendTransaction = useCallback(async () => { if (!quote || !address || !providerMetadata) return; const transactionParams = await buildTransaction({ quote, userAddress: address }); const outgoingTokenAddress = direction === 'stake' ? providerMetadata.stakeToken.address : providerMetadata.receiveToken?.address; if (outgoingTokenAddress) { const shortfall = checkTransferBalance({ messages: transactionParams.messages, tonBalance: nativeBalanceData, gasBufferNanos: 100_000_000n, fromToken: { address: outgoingTokenAddress }, fromAmount: quote.amountIn, }); if (shortfall) { setPendingStake(shortfall); return; } } await sendTransaction(transactionParams); }, [quote, address, providerMetadata, direction, nativeBalanceData, buildTransaction, sendTransaction]); const onLowBalanceChange = useCallback(() => { if (!pendingStake || pendingStake.mode !== 'reduce') return; // The suggested amount is always a direct (non-reversed) outgoing amount. if (isReversed) setIsReversed(false); setAmountRaw(pendingStake.suggestedFromAmount); setPendingStake(undefined); }, [pendingStake, isReversed]); const onLowBalanceCancel = useCallback(() => { setPendingStake(undefined); }, []); const isLowBalanceWarningOpen = pendingStake !== undefined; const lowBalanceMode: LowBalanceMode = pendingStake?.mode ?? 'reduce'; const lowBalanceRequiredTon = useMemo(() => { if (!pendingStake) return ''; return formatUnits(pendingStake.requiredNanos, 9); }, [pendingStake]); const { error, canSubmit } = useStakingValidation({ amount, amountDebounced: quoteParamsDebounced.amount || '', balance, quoteError, sendError, direction, stakedBalance: stakedBalanceData?.stakedBalance, quote, isReversed, amountDecimals, isNetworkSupported, }); const value = useMemo( () => ({ amount, canSubmit, direction, quote, isQuoteLoading: isQuoteLoading || isProviderInfoLoading || amount !== quoteParamsDebounced.amount, error, providerInfo, providerMetadata, stakingProvider, stakingProviders, setStakingProviderId, network, isProviderInfoLoading, balance, isBalanceLoading, stakedBalance: stakedBalanceData, isStakedBalanceLoading, unstakeMode, setAmount, setUnstakeMode, sendTransaction: handleSendTransaction, isSendingTransaction, isReversed, toggleReversed, reversedAmount, onMaxClick: handleMaxClick, onChangeDirection: setDirection, isLowBalanceWarningOpen, lowBalanceMode, lowBalanceRequiredTon, onLowBalanceChange, onLowBalanceCancel, }), [ amount, quoteParamsDebounced.amount, canSubmit, direction, quote, isQuoteLoading, error, providerInfo, providerMetadata, stakingProvider, stakingProviders, setStakingProviderId, network, isProviderInfoLoading, balance, isBalanceLoading, stakedBalanceData, isStakedBalanceLoading, unstakeMode, setAmount, setUnstakeMode, handleSendTransaction, isSendingTransaction, isReversed, toggleReversed, reversedAmount, handleMaxClick, setDirection, isLowBalanceWarningOpen, lowBalanceMode, lowBalanceRequiredTon, onLowBalanceChange, onLowBalanceCancel, ], ); return {children}; };