+
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;
}