diff --git a/apps/demo-wallet/src/components/AppRouter.tsx b/apps/demo-wallet/src/components/AppRouter.tsx index 632f166de..2b64864f3 100644 --- a/apps/demo-wallet/src/components/AppRouter.tsx +++ b/apps/demo-wallet/src/components/AppRouter.tsx @@ -12,6 +12,7 @@ import { useWalletStore, useWallet } from '@demo/wallet-core'; import { ProtectedRoute } from './ProtectedRoute'; import { LoaderCircle } from './LoaderCircle'; +import { SwapNotifications } from './swap/SwapNotifications'; import { SetupPassword, UnlockWallet, @@ -80,6 +81,7 @@ export const AppRouter: React.FC = () => { return ( + {/* Public routes */} } /> diff --git a/apps/demo-wallet/src/components/HoldToSignButton.tsx b/apps/demo-wallet/src/components/HoldToSignButton.tsx index 85bb3149d..2d65160ff 100644 --- a/apps/demo-wallet/src/components/HoldToSignButton.tsx +++ b/apps/demo-wallet/src/components/HoldToSignButton.tsx @@ -14,6 +14,7 @@ interface HoldToSignButtonProps { isLoading?: boolean; holdDuration?: number; // Duration in milliseconds className?: string; + idleLabel?: string; } export const HoldToSignButton: React.FC = ({ @@ -22,6 +23,7 @@ export const HoldToSignButton: React.FC = ({ isLoading = false, holdDuration = 3000, className = '', + idleLabel = 'Hold to Sign', }) => { const [isHolding, setIsHolding] = useState(false); const [progress, setProgress] = useState(0); @@ -221,7 +223,7 @@ export const HoldToSignButton: React.FC = ({ {isHolding ? `Hold (${Math.ceil((holdDuration - (progress * holdDuration) / 100) / 1000)}s)` - : 'Hold to Sign'} + : idleLabel} )} diff --git a/apps/demo-wallet/src/components/swap/QuoteTimer.tsx b/apps/demo-wallet/src/components/swap/QuoteTimer.tsx index b7c764cfe..adc2cb078 100644 --- a/apps/demo-wallet/src/components/swap/QuoteTimer.tsx +++ b/apps/demo-wallet/src/components/swap/QuoteTimer.tsx @@ -9,66 +9,47 @@ import type { FC } from 'react'; import { useEffect, useState } from 'react'; -import { Button } from '../Button'; - interface QuoteTimerProps { expiresAt?: number; // Unix timestamp in seconds - onRefresh: () => void; - isLoading?: boolean; + isRefreshing?: boolean; } -export const QuoteTimer: FC = ({ expiresAt, onRefresh, isLoading = false }) => { - const [timeLeft, setTimeLeft] = useState(0); +/** + * Slim, inline indicator that lives inside the quote details block. + * The quote refreshes itself silently before expiry, so this is a passive + * status line — not an interactive banner. + */ +export const QuoteTimer: FC = ({ expiresAt, isRefreshing = false }) => { + const [secondsLeft, setSecondsLeft] = useState(0); useEffect(() => { if (!expiresAt) { - setTimeLeft(0); + setSecondsLeft(0); return; } - const updateTimer = () => { - const now = Math.floor(Date.now() / 1000); // Current time in seconds - const remaining = Math.max(0, expiresAt - now); - setTimeLeft(remaining * 1000); // Convert to milliseconds for display + const update = () => { + const now = Math.floor(Date.now() / 1000); + setSecondsLeft(Math.max(0, expiresAt - now)); }; - updateTimer(); - const interval = setInterval(updateTimer, 100); - + update(); + const interval = setInterval(update, 500); return () => clearInterval(interval); }, [expiresAt]); - const totalSeconds = Math.ceil(timeLeft / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - const isExpired = !expiresAt || timeLeft === 0; - - if (isExpired) { + if (isRefreshing || secondsLeft === 0) { return ( -
- Quote expired - -
+ + + Refreshing quote… + ); } return ( -
- - Quote valid for{' '} - - {minutes > 0 && `${minutes}m `} - {seconds}s - - -
+ + Refreshes in {secondsLeft}s + ); }; diff --git a/apps/demo-wallet/src/components/swap/SwapInterface.tsx b/apps/demo-wallet/src/components/swap/SwapInterface.tsx index 7840223f9..80053fc7e 100644 --- a/apps/demo-wallet/src/components/swap/SwapInterface.tsx +++ b/apps/demo-wallet/src/components/swap/SwapInterface.tsx @@ -7,18 +7,22 @@ */ import type { FC } from 'react'; -import { useState } from 'react'; -import { useSwap } from '@demo/wallet-core'; +import { useEffect, useRef, useState } from 'react'; +import { useSwap, useAuth, useJettons } from '@demo/wallet-core'; import { useNavigate } from 'react-router-dom'; -import type { SwapToken } from '@ton/walletkit'; import { SwapSettings } from './SwapSettings'; import { TokenInput } from './TokenInput'; import { QuoteTimer } from './QuoteTimer'; import { Button } from '../Button'; import { Card } from '../Card'; +import { HoldToSignButton } from '../HoldToSignButton'; import { cn } from '@/lib/utils'; +import { resolveTokenSymbol } from '@/utils/swapToken'; + +const QUOTE_DEBOUNCE_MS = 500; +const QUOTE_REFRESH_LEAD_MS = 5000; function getPriceImpactColor(priceImpact: number): string { if (priceImpact > 500) return 'text-destructive'; @@ -51,32 +55,80 @@ export const SwapInterface: FC = ({ className }) => { swapTokens, getSwapQuote: getQuote, executeSwap, + validateSwapInputs, } = useSwap(); + const { holdToSign } = useAuth(); + const { userJettons } = useJettons(); const navigate = useNavigate(); const [showSlippageSettings, setShowSlippageSettings] = useState(false); const [useCustomDestination, setUseCustomDestination] = useState(false); + const [isRefreshingQuote, setIsRefreshingQuote] = useState(false); - const getTokenSymbol = (token: SwapToken): string => { - if (token.symbol) return token.symbol; - if (token.address === 'ton') return 'TON'; - return 'Unknown'; - }; + const fromSymbol = resolveTokenSymbol(fromToken, userJettons); + const toSymbol = resolveTokenSymbol(toToken, userJettons); - const fromSymbol = getTokenSymbol(fromToken); - const toSymbol = getTokenSymbol(toToken); + // Debounced auto-quote: refetch the quote whenever the meaningful inputs change. + // We deliberately re-run on quote becoming null (e.g. after a token switch) + // so the new pair fetches a quote without requiring an extra keystroke. + useEffect(() => { + if (!amount || parseFloat(amount) <= 0) return; + if (validateSwapInputs()) return; + if (currentQuote) return; - const handleGetQuote = async () => { - await getQuote(); - }; + const handle = setTimeout(() => { + void getQuote(); + }, QUOTE_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [ + amount, + fromToken.address, + toToken.address, + isReverseSwap, + slippageBps, + currentQuote, + validateSwapInputs, + getQuote, + ]); + + // Silent refresh: re-fetch the quote ~5s before it expires so the + // hold-to-sign gesture always has a fresh quote available. + useEffect(() => { + if (!currentQuote?.expiresAt) return; + + const expiresAtMs = currentQuote.expiresAt * 1000; + const refreshAt = expiresAtMs - QUOTE_REFRESH_LEAD_MS; + const delay = Math.max(0, refreshAt - Date.now()); + + const handle = setTimeout(async () => { + setIsRefreshingQuote(true); + try { + await getQuote(); + } finally { + setIsRefreshingQuote(false); + } + }, delay); + + return () => clearTimeout(handle); + }, [currentQuote?.expiresAt, getQuote]); + + // Guard against double-firing executeSwap on accidental re-entries (e.g. the + // hold-to-sign button completing twice). Navigate to /wallet only when the + // broadcast actually succeeded; on failure the inline error stays put. + const isExecutingRef = useRef(false); const handleExecuteSwap = async () => { - await executeSwap(); - - navigate('/wallet', { - state: { message: `${fromSymbol} sent successfully!` }, - }); + if (isExecutingRef.current) return; + isExecutingRef.current = true; + try { + const hash = await executeSwap(); + if (hash) { + navigate('/wallet'); + } + } finally { + isExecutingRef.current = false; + } }; const handleFromAmountChange = (val: string) => { @@ -90,21 +142,24 @@ export const SwapInterface: FC = ({ className }) => { }; const fromAmount = !isReverseSwap ? amount : currentQuote ? currentQuote.fromAmount : ''; - const toAmount = isReverseSwap ? amount : currentQuote ? currentQuote.toAmount : ''; - const getSwapButtonText = () => { - if (!fromToken || !toToken) return 'Select tokens'; - const hasFromAmount = fromAmount && parseFloat(fromAmount) > 0; - const hasToAmount = toAmount && parseFloat(toAmount) > 0; - if (!hasFromAmount && !hasToAmount) return 'Enter amount'; - if (isLoadingQuote) return 'Getting quote...'; - if (error) return 'Error'; - if (!currentQuote) return 'Get Quote'; - return `Swap ${fromSymbol} for ${toSymbol}`; - }; + const hasFromAmount = !!fromAmount && parseFloat(fromAmount) > 0; + const hasToAmount = !!toAmount && parseFloat(toAmount) > 0; + const hasInput = hasFromAmount || hasToAmount; - const isSwapDisabled = !!error || isLoadingQuote || isSwapping; + const validationError = validateSwapInputs(); + const isQuoteReady = !!currentQuote && !error; + const canSwap = isQuoteReady && !isSwapping && !validationError; + + const idleCtaLabel = (() => { + if (!fromToken || !toToken) return 'Select tokens'; + if (!hasInput) return 'Enter an amount'; + if (validationError && validationError !== 'Please enter an amount') return validationError; + if (isLoadingQuote) return 'Getting best quote…'; + if (error) return 'Try again'; + return null; + })(); return ( @@ -126,6 +181,7 @@ export const SwapInterface: FC = ({ className }) => { onAmountChange={handleFromAmountChange} onTokenSelect={setFromToken} token={fromToken} + isLoading={isReverseSwap && isLoadingQuote} />
@@ -152,6 +208,7 @@ export const SwapInterface: FC = ({ className }) => { onTokenSelect={setToToken} token={toToken} className="-mt-2" + isLoading={!isReverseSwap && isLoadingQuote} /> {/* Destination Address */} @@ -189,12 +246,6 @@ export const SwapInterface: FC = ({ className }) => { {currentQuote && ( <> - -
@@ -210,9 +261,9 @@ export const SwapInterface: FC = ({ className }) => {
- {currentQuote.priceImpact && ( + {currentQuote.priceImpact !== undefined && (
- Price Impact + Price Impact {(currentQuote.priceImpact / 100).toFixed(2)}% @@ -223,6 +274,14 @@ export const SwapInterface: FC = ({ className }) => { Slippage {slippageBps / 100}%
+ +
+ Quote + +
)} @@ -235,25 +294,20 @@ export const SwapInterface: FC = ({ className }) => {
- {!currentQuote && ( - - )} - - {currentQuote && ( - )}
diff --git a/apps/demo-wallet/src/components/swap/SwapNotifications.tsx b/apps/demo-wallet/src/components/swap/SwapNotifications.tsx new file mode 100644 index 000000000..29633a668 --- /dev/null +++ b/apps/demo-wallet/src/components/swap/SwapNotifications.tsx @@ -0,0 +1,101 @@ +/** + * 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 { useEffect, useRef } from 'react'; +import { useSwap } from '@demo/wallet-core'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; + +/** + * Bridge between the swap slice's background confirmation watcher and the + * global sonner toaster. Mounted once at the app root so toasts fire on the + * wallet page (or wherever the user is) after the swap form has navigated away. + * + * Uses a notification id from the slice to ensure each swap fires its terminal + * toast exactly once, regardless of re-renders. + */ +export function SwapNotifications() { + const { lastSwapNotificationId, lastSwapStatus, lastSwapDurationMs, lastSwapReceipt, lastSwapErrorMessage, lastSwapHash } = + useSwap(); + const navigate = useNavigate(); + + const notifiedTerminalIdRef = useRef(null); + const startedIdRef = useRef(null); + + // Fire a transient "broadcasting" toast when a new swap starts so the + // wallet page has immediate feedback while the watcher is polling. + useEffect(() => { + if (!lastSwapNotificationId) return; + if (startedIdRef.current === lastSwapNotificationId) return; + if (lastSwapStatus !== 'broadcasting' && lastSwapStatus !== 'confirming') return; + + startedIdRef.current = lastSwapNotificationId; + toast.loading('Swap broadcasting…', { + id: `swap-${lastSwapNotificationId}`, + description: lastSwapReceipt + ? `${lastSwapReceipt.fromAmount} ${lastSwapReceipt.fromSymbol} → ${lastSwapReceipt.toSymbol}` + : undefined, + }); + }, [lastSwapNotificationId, lastSwapStatus, lastSwapReceipt]); + + // Replace the broadcasting toast with the terminal result. + useEffect(() => { + if (!lastSwapNotificationId) return; + if (notifiedTerminalIdRef.current === lastSwapNotificationId) return; + + const toastId = `swap-${lastSwapNotificationId}`; + + if (lastSwapStatus === 'completed') { + notifiedTerminalIdRef.current = lastSwapNotificationId; + const duration = lastSwapDurationMs ?? 0; + const description = lastSwapReceipt + ? `Sent ${lastSwapReceipt.fromAmount} ${lastSwapReceipt.fromSymbol} · Received ${lastSwapReceipt.toAmount} ${lastSwapReceipt.toSymbol}` + : undefined; + + toast.success(`Swap confirmed in ${formatDuration(duration)}`, { + id: toastId, + description, + duration: 6000, + action: lastSwapHash + ? { + label: 'View', + onClick: () => navigate(`/wallet/transactions/${lastSwapHash}`), + } + : undefined, + }); + return; + } + + if (lastSwapStatus === 'failed') { + notifiedTerminalIdRef.current = lastSwapNotificationId; + toast.error('Swap failed', { + id: toastId, + description: lastSwapErrorMessage ?? 'Something went wrong while broadcasting the swap.', + duration: 8000, + }); + return; + } + + if (lastSwapStatus === 'timeout') { + notifiedTerminalIdRef.current = lastSwapNotificationId; + toast('Swap is taking longer than expected', { + id: toastId, + description: 'It is still propagating — check Recent Transactions.', + duration: 8000, + }); + } + }, [lastSwapNotificationId, lastSwapStatus, lastSwapDurationMs, lastSwapReceipt, lastSwapErrorMessage, lastSwapHash, navigate]); + + return null; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.round(ms / 1000)}s`; +} diff --git a/apps/demo-wallet/src/components/swap/TokenInput.tsx b/apps/demo-wallet/src/components/swap/TokenInput.tsx index 84f9dfff9..a33fa42c2 100644 --- a/apps/demo-wallet/src/components/swap/TokenInput.tsx +++ b/apps/demo-wallet/src/components/swap/TokenInput.tsx @@ -23,6 +23,7 @@ interface Props { onAmountChange: (amount: string) => void; excludeToken?: SwapToken; isOutput?: boolean; + isLoading?: boolean; className?: string; } @@ -34,6 +35,7 @@ export const TokenInput: FC = ({ onAmountChange, excludeToken, isOutput = false, + isLoading = false, className, }) => { const { balance } = useWallet(); @@ -93,9 +95,12 @@ export const TokenInput: FC = ({
-
+
onAmountChange(e.target.value)} @@ -103,6 +108,11 @@ export const TokenInput: FC = ({ type="text" value={amount} /> + {isLoading && !amount && ( +
+
+
+ )}
diff --git a/apps/demo-wallet/src/components/swap/TokenSelector.tsx b/apps/demo-wallet/src/components/swap/TokenSelector.tsx index dd0c0e147..674432d4a 100644 --- a/apps/demo-wallet/src/components/swap/TokenSelector.tsx +++ b/apps/demo-wallet/src/components/swap/TokenSelector.tsx @@ -9,12 +9,11 @@ import { useMemo } from 'react'; import type { FC } from 'react'; import { useJettons } from '@demo/wallet-core'; -import type { Jetton } from '@ton/walletkit'; import type { SwapToken } from '@ton/walletkit'; import { cn } from '@/lib/utils'; -import { USDT_ADDRESS } from '@/constants/swap'; -import { getJettonsImage, getJettonsSymbol } from '@/utils/jetton'; +import { getJettonsImage } from '@/utils/jetton'; +import { resolveTokenSymbol } from '@/utils/swapToken'; import { CircleLogo } from '@/components/CircleLogo'; interface TokenSelectorProps { @@ -25,19 +24,6 @@ interface TokenSelectorProps { className?: string; } -const getTokenSymbol = (token: SwapToken, jetton?: Jetton): string => { - if (token.symbol) return token.symbol; - if (token.address === 'ton') return 'TON'; - if (token.address === USDT_ADDRESS) return 'USDT'; - - if (jetton) { - const symbol = getJettonsSymbol(jetton); - return symbol || 'Unknown'; - } - - return 'Unknown'; -}; - export const TokenSelector: FC = ({ selectedToken, // onTokenSelect, @@ -55,7 +41,7 @@ export const TokenSelector: FC = ({ // }; const selectedTokenInfo = useMemo(() => { - const symbol = getTokenSymbol(selectedToken); + const symbol = resolveTokenSymbol(selectedToken, userJettons); if (selectedToken.address !== 'ton') { const jetton = userJettons.find((j) => j.address === selectedToken.address); diff --git a/apps/demo-wallet/src/pages/Swap.tsx b/apps/demo-wallet/src/pages/Swap.tsx index a7e5223e0..f0b8eda67 100644 --- a/apps/demo-wallet/src/pages/Swap.tsx +++ b/apps/demo-wallet/src/pages/Swap.tsx @@ -21,8 +21,8 @@ export const Swap: FC = () => { const { setFromToken, setToToken, clearSwap } = useSwap(); useEffect(() => { - setFromToken({ address: 'ton', decimals: 9 }); - setToToken({ address: USDT_ADDRESS, decimals: 6 }); + setFromToken({ address: 'ton', decimals: 9, symbol: 'TON' }); + setToToken({ address: USDT_ADDRESS, decimals: 6, symbol: 'USDT' }); return () => clearSwap(); }, []); @@ -30,26 +30,6 @@ export const Swap: FC = () => { return ( navigate('/wallet')}> - - {/* Warning */} -
-
-
- - - -
-
-

- Always verify the swap details before executing. Quotes may expire and need to be refreshed. -

-
-
-
); }; diff --git a/apps/demo-wallet/src/utils/swapToken.ts b/apps/demo-wallet/src/utils/swapToken.ts new file mode 100644 index 000000000..4d2c9d335 --- /dev/null +++ b/apps/demo-wallet/src/utils/swapToken.ts @@ -0,0 +1,36 @@ +/** + * 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 type { Jetton, SwapToken } from '@ton/walletkit'; + +import { getJettonsSymbol } from '@/utils/jetton'; +import { USDT_ADDRESS } from '@/constants/swap'; + +/** + * Single source of truth for resolving a human-readable symbol from a `SwapToken`. + * + * Priority: + * 1. The symbol baked into the token (e.g. when seeded by the swap page). + * 2. Hardcoded well-known addresses (TON, USDT) so the UI looks right even + * if a caller forgets to pass `symbol`. + * 3. The user's loaded jetton list (covers any jetton they actually hold). + * 4. `'Unknown'` as a last-resort fallback. + */ +export function resolveTokenSymbol(token: SwapToken, userJettons: Jetton[] = []): string { + if (token.symbol) return token.symbol; + if (token.address === 'ton') return 'TON'; + if (token.address === USDT_ADDRESS) return 'USDT'; + + const jetton = userJettons.find((j) => j.address === token.address); + if (jetton) { + const symbol = getJettonsSymbol(jetton); + if (symbol) return symbol; + } + + return 'Unknown'; +} diff --git a/demo/wallet-core/src/hooks/useWalletStore.ts b/demo/wallet-core/src/hooks/useWalletStore.ts index 3f431415a..9bd3fadbf 100644 --- a/demo/wallet-core/src/hooks/useWalletStore.ts +++ b/demo/wallet-core/src/hooks/useWalletStore.ts @@ -219,6 +219,14 @@ export const useSwap = () => { error: state.swap.error, slippageBps: state.swap.slippageBps, isReverseSwap: state.swap.isReverseSwap, + preparedTransaction: state.swap.preparedTransaction, + isPreparingTransaction: state.swap.isPreparingTransaction, + lastSwapHash: state.swap.lastSwapHash, + lastSwapStatus: state.swap.lastSwapStatus, + lastSwapDurationMs: state.swap.lastSwapDurationMs, + lastSwapReceipt: state.swap.lastSwapReceipt, + lastSwapErrorMessage: state.swap.lastSwapErrorMessage, + lastSwapNotificationId: state.swap.lastSwapNotificationId, setFromToken: state.setFromToken, setToToken: state.setToToken, setSwapAmount: state.setSwapAmount, @@ -227,7 +235,9 @@ export const useSwap = () => { setIsReverseSwap: state.setIsReverseSwap, swapTokens: state.swapTokens, getSwapQuote: state.getSwapQuote, + prepareSwapTransaction: state.prepareSwapTransaction, executeSwap: state.executeSwap, + watchSwapConfirmation: state.watchSwapConfirmation, clearSwap: state.clearSwap, validateSwapInputs: state.validateSwapInputs, })), diff --git a/demo/wallet-core/src/index.ts b/demo/wallet-core/src/index.ts index 2e1f8fdd9..4c76e7363 100644 --- a/demo/wallet-core/src/index.ts +++ b/demo/wallet-core/src/index.ts @@ -46,6 +46,9 @@ export type { JettonsSlice, NftsSlice, SwapSlice, + SwapState, + SwapConfirmationStatus, + SwapReceipt, StakingSlice, } from './types/store'; diff --git a/demo/wallet-core/src/store/slices/swapSlice.ts b/demo/wallet-core/src/store/slices/swapSlice.ts index 64c7f86a5..3bac3a1ec 100644 --- a/demo/wallet-core/src/store/slices/swapSlice.ts +++ b/demo/wallet-core/src/store/slices/swapSlice.ts @@ -6,8 +6,8 @@ * */ -import type { SwapQuoteParams, SwapToken } from '@ton/walletkit'; -import { getMaxOutgoingMessages } from '@ton/walletkit'; +import type { Jetton, SwapQuoteParams, SwapToken, TransactionRequest } from '@ton/walletkit'; +import { getMaxOutgoingMessages, getTransactionStatus } from '@ton/walletkit'; import { createComponentLogger } from '../../utils/logger'; import { parseUnits } from '../../utils/units'; @@ -15,6 +15,34 @@ import type { SetState, SwapSliceCreator } from '../../types/store'; const log = createComponentLogger('SwapSlice'); +const POLL_INTERVAL_MS = 300; +const POLL_TIMEOUT_MS = 30_000; + +/** + * Resolve a human-readable symbol for a SwapToken used in receipts/toasts. + * Prefers the symbol baked into the token, then matches a known jetton by + * address, then falls back to a sibling token (e.g. the slice's own + * `fromToken`/`toToken`) whose symbol is more likely to be set, then + * `'Unknown'` as a last resort. + */ +function getTokenSymbol(token: SwapToken, userJettons: Jetton[] = [], fallback?: SwapToken): string { + if (token.symbol) return token.symbol; + if (token.address === 'ton') return 'TON'; + + const jetton = userJettons.find((j) => j.address === token.address); + if (jetton?.info?.symbol) return jetton.info.symbol; + + if (fallback && fallback.address === token.address && fallback.symbol) { + return fallback.symbol; + } + + return 'Unknown'; +} + +function generateSwapId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ swap: { fromToken: { address: 'ton', decimals: 9, symbol: 'TON' }, @@ -27,12 +55,22 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ error: null, slippageBps: 100, isReverseSwap: false, + preparedTransaction: null, + isPreparingTransaction: false, + lastSwapHash: null, + swapStartedAt: null, + lastSwapNotificationId: null, + lastSwapStatus: 'idle', + lastSwapDurationMs: null, + lastSwapReceipt: null, + lastSwapErrorMessage: null, }, setFromToken: (token: SwapToken) => { set((state) => { state.swap.fromToken = token; state.swap.currentQuote = null; + state.swap.preparedTransaction = null; state.swap.amount = ''; }); }, @@ -41,6 +79,7 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ set((state) => { state.swap.toToken = token; state.swap.currentQuote = null; + state.swap.preparedTransaction = null; state.swap.amount = ''; }); }, @@ -57,6 +96,7 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ if (amount === '' || /^\d*\.?\d*$/.test(amount)) { state.swap.amount = amount; state.swap.currentQuote = null; + state.swap.preparedTransaction = null; state.swap.error = null; } }); @@ -65,12 +105,15 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ setDestinationAddress: (address: string) => { set((state) => { state.swap.destinationAddress = address; + state.swap.preparedTransaction = null; }); }, setSlippageBps: (slippage: number) => { set((state) => { state.swap.slippageBps = slippage; + state.swap.currentQuote = null; + state.swap.preparedTransaction = null; }); }, @@ -82,6 +125,7 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ state.swap.fromToken = state.swap.toToken; state.swap.toToken = tempToken; state.swap.currentQuote = null; + state.swap.preparedTransaction = null; state.swap.error = null; }); }, @@ -232,11 +276,16 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ // Update the opposite amount based on which one was specified set((state) => { state.swap.currentQuote = quote; + state.swap.preparedTransaction = null; state.swap.isLoadingQuote = false; state.swap.error = null; }); log.info('Successfully got swap quote', { quote }); + + // Pre-build the transaction in the background so the hold-to-sign gesture + // signs immediately without an extra round-trip. + void get().prepareSwapTransaction(); } catch (error) { log.error('Failed to get swap quote:', error); @@ -246,11 +295,66 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ state.swap.isLoadingQuote = false; state.swap.error = errorMessage; state.swap.currentQuote = null; + state.swap.preparedTransaction = null; state.swap.amount = ''; }); } }, + prepareSwapTransaction: async () => { + const state = get(); + const { currentQuote, destinationAddress } = state.swap; + + if (!currentQuote) { + return; + } + + if (!state.walletCore.walletKit) { + return; + } + + if (!state.walletManagement.address) { + return; + } + + // Capture the quote we are building for so we can detect a stale build + const quoteAtRequest = currentQuote; + + set((state) => { + state.swap.isPreparingTransaction = true; + }); + + try { + const transaction = await state.walletCore.walletKit.swap.buildSwapTransaction({ + quote: currentQuote, + userAddress: state.walletManagement.address, + destinationAddress: destinationAddress || undefined, + }); + + // If the quote changed while we were building, drop this prepared tx. + const latest = get().swap.currentQuote; + if (latest !== quoteAtRequest) { + set((state) => { + state.swap.isPreparingTransaction = false; + }); + return; + } + + set((state) => { + state.swap.preparedTransaction = transaction; + state.swap.isPreparingTransaction = false; + }); + + log.info('Pre-built swap transaction'); + } catch (error) { + log.error('Failed to pre-build swap transaction:', error); + set((state) => { + state.swap.preparedTransaction = null; + state.swap.isPreparingTransaction = false; + }); + } + }, + executeSwap: async () => { const state = get(); const { currentQuote } = state.swap; @@ -262,7 +366,7 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ set((state) => { state.swap.error = validationError; }); - return; + return null; } if (!currentQuote) { @@ -270,7 +374,7 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ set((state) => { state.swap.error = 'No quote available. Please get a quote first.'; }); - return; + return null; } if (!state.walletCore.walletKit) { @@ -278,7 +382,7 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ set((state) => { state.swap.error = 'WalletKit not initialized'; }); - return; + return null; } if (!state.walletManagement.currentWallet) { @@ -286,7 +390,7 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ set((state) => { state.swap.error = 'No active wallet'; }); - return; + return null; } if (!state.walletManagement.address) { @@ -294,7 +398,7 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ set((state) => { state.swap.error = 'No wallet address'; }); - return; + return null; } set((state) => { @@ -305,27 +409,59 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ try { log.info('Executing swap', { quote: currentQuote }); - const transaction = await state.walletCore.walletKit.swap.buildSwapTransaction({ - quote: currentQuote, - userAddress: state.walletManagement.address, - destinationAddress: state.swap.destinationAddress || undefined, + // Use the pre-built transaction if available, otherwise build inline as a fallback. + let transaction: TransactionRequest | null = state.swap.preparedTransaction; + if (!transaction) { + log.info('No prepared transaction, building inline'); + transaction = await state.walletCore.walletKit.swap.buildSwapTransaction({ + quote: currentQuote, + userAddress: state.walletManagement.address, + destinationAddress: state.swap.destinationAddress || undefined, + }); + } + + const swapStartedAt = Date.now(); + const notificationId = generateSwapId(); + const userJettons = state.jettons.userJettons; + const receipt = { + fromSymbol: getTokenSymbol(currentQuote.fromToken, userJettons, state.swap.fromToken), + fromAmount: currentQuote.fromAmount, + toSymbol: getTokenSymbol(currentQuote.toToken, userJettons, state.swap.toToken), + toAmount: currentQuote.toAmount, + }; + + // Mark broadcasting state up-front so any UI listener can react immediately. + set((state) => { + state.swap.swapStartedAt = swapStartedAt; + state.swap.lastSwapNotificationId = notificationId; + state.swap.lastSwapStatus = 'broadcasting'; + state.swap.lastSwapHash = null; + state.swap.lastSwapDurationMs = null; + state.swap.lastSwapReceipt = receipt; + state.swap.lastSwapErrorMessage = null; }); - if (state.walletCore.walletKit) { - await state.walletCore.walletKit.handleNewTransaction( - state.walletManagement.currentWallet, - transaction, - ); - } + // Bypass the TransactionRequestModal for self-initiated swaps: + // sign and broadcast directly through the wallet adapter. + const { normalizedHash } = await state.walletManagement.currentWallet.sendTransaction(transaction); set((state) => { + state.swap.lastSwapHash = normalizedHash; + state.swap.lastSwapStatus = 'confirming'; state.swap.isSwapping = false; + // Clear the form so the next swap starts clean. state.swap.amount = ''; state.swap.currentQuote = null; + state.swap.preparedTransaction = null; state.swap.isReverseSwap = false; }); - log.info('Swap executed successfully'); + // Watch for on-chain confirmation in the background; do NOT await, + // so the UI can navigate immediately. + void get().watchSwapConfirmation(normalizedHash); + + log.info('Swap broadcast successfully', { normalizedHash }); + return normalizedHash; } catch (error) { log.error('Failed to execute swap:', error); @@ -334,10 +470,73 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ set((state) => { state.swap.isSwapping = false; state.swap.error = errorMessage; + state.swap.lastSwapStatus = 'failed'; + state.swap.lastSwapErrorMessage = errorMessage; }); + return null; } }, + watchSwapConfirmation: async (normalizedHash: string) => { + const state = get(); + if (!state.walletCore.walletKit) return; + + const network = state.walletManagement.currentWallet?.getNetwork(); + if (!network) return; + + const apiClient = state.walletCore.walletKit.getApiClient(network); + const startedAt = state.swap.swapStartedAt ?? Date.now(); + const watchedNotificationId = state.swap.lastSwapNotificationId; + + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + let elapsed = 0; + while (elapsed < POLL_TIMEOUT_MS) { + // Bail out if a newer swap has started or the slice was cleared. + const current = get().swap; + if (current.lastSwapNotificationId !== watchedNotificationId) { + log.info('Confirmation watcher superseded by newer swap'); + return; + } + + try { + const status = await getTransactionStatus(apiClient, { normalizedHash }); + + if (status.status === 'completed') { + const durationMs = Date.now() - startedAt; + set((state) => { + if (state.swap.lastSwapNotificationId !== watchedNotificationId) return; + state.swap.lastSwapStatus = 'completed'; + state.swap.lastSwapDurationMs = durationMs; + }); + log.info('Swap confirmed on-chain', { normalizedHash, durationMs }); + return; + } + + if (status.status === 'failed') { + set((state) => { + if (state.swap.lastSwapNotificationId !== watchedNotificationId) return; + state.swap.lastSwapStatus = 'failed'; + state.swap.lastSwapErrorMessage = 'Transaction failed on-chain'; + }); + log.warn('Swap failed on-chain', { normalizedHash }); + return; + } + } catch (error) { + log.warn('Confirmation polling error (will retry)', { error }); + } + + await sleep(POLL_INTERVAL_MS); + elapsed = Date.now() - startedAt; + } + + set((state) => { + if (state.swap.lastSwapNotificationId !== watchedNotificationId) return; + state.swap.lastSwapStatus = 'timeout'; + }); + log.warn('Swap confirmation timed out', { normalizedHash }); + }, + clearSwap: () => { set((state) => { state.swap.fromToken = { address: 'ton', decimals: 9, symbol: 'TON' }; @@ -348,11 +547,16 @@ export const createSwapSlice: SwapSliceCreator = (set: SetState, get) => ({ }; state.swap.amount = ''; state.swap.currentQuote = null; + state.swap.preparedTransaction = null; + state.swap.isPreparingTransaction = false; state.swap.isLoadingQuote = false; state.swap.isSwapping = false; state.swap.error = null; state.swap.slippageBps = 100; state.swap.isReverseSwap = false; + // Note: we intentionally do NOT clear lastSwap* fields here. The + // confirmation watcher and the toast listener still need them after + // the swap form is cleared/unmounted on navigation to /wallet. }); }, }); diff --git a/demo/wallet-core/src/types/store.ts b/demo/wallet-core/src/types/store.ts index c44f84708..364185457 100644 --- a/demo/wallet-core/src/types/store.ts +++ b/demo/wallet-core/src/types/store.ts @@ -27,6 +27,7 @@ import type { StakingBalance, StakingProviderInfo, StakeParams, + TransactionRequest, UnstakeModes, } from '@ton/walletkit'; @@ -235,6 +236,15 @@ export interface NftsSlice { } // Swap slice interface +export type SwapConfirmationStatus = 'idle' | 'broadcasting' | 'confirming' | 'completed' | 'failed' | 'timeout'; + +export interface SwapReceipt { + fromSymbol: string; + fromAmount: string; + toSymbol: string; + toAmount: string; +} + export interface SwapState { fromToken: SwapToken; toToken: SwapToken; @@ -246,6 +256,15 @@ export interface SwapState { error: string | null; slippageBps: number; isReverseSwap: boolean; + preparedTransaction: TransactionRequest | null; + isPreparingTransaction: boolean; + lastSwapHash: string | null; + swapStartedAt: number | null; + lastSwapNotificationId: string | null; + lastSwapStatus: SwapConfirmationStatus; + lastSwapDurationMs: number | null; + lastSwapReceipt: SwapReceipt | null; + lastSwapErrorMessage: string | null; } // Staking slice interface @@ -287,7 +306,9 @@ export interface SwapSlice { setSlippageBps: (slippage: number) => void; swapTokens: () => void; getSwapQuote: () => Promise; - executeSwap: () => Promise; + prepareSwapTransaction: () => Promise; + executeSwap: () => Promise; + watchSwapConfirmation: (normalizedHash: string) => Promise; clearSwap: () => void; validateSwapInputs: () => string | null; }