Author(s): STON.fi Team
This guide was contributed by the STON.fi Team, and has not been validated by Privy. Please review
and test thoroughly before using in production. If you find a bug or problem with this resource,
please let Privy support know and we’ll alert STON.fi Team.
Prerequisites
Before starting this guide, ensure you have:- Completed the Getting started with Privy and TON guide
- A working React app with Privy authentication for TON
- A deployed and funded TON wallet (minimum 0.05 TON for gas fees)
- The existing utilities and hooks from the previous guide already implemented
Step 1: Install Omniston SDK and STON.fi API
From your existing project root, install the Omniston SDK and STON.fi API:pnpm add @ston-fi/omniston-sdk-react @ston-fi/api
Step 2: Add Omniston provider
Update yoursrc/main.tsx to add the Omniston provider:
// src/main.tsx
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {PrivyProvider} from '@privy-io/react-auth';
import {Omniston, OmnistonProvider} from '@ston-fi/omniston-sdk-react';
import './index.css';
import App from './App';
const omniston = new Omniston({apiUrl: 'wss://omni-ws.ston.fi'});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<PrivyProvider appId={import.meta.env.VITE_PRIVY_APP_ID} config={{loginMethods: ['email']}}>
<OmnistonProvider omniston={omniston}>
<App />
</OmnistonProvider>
</PrivyProvider>
</StrictMode>
);
Step 3: Create asset fetching hook
Create a simple hook to fetch available tokens:// src/hooks/useAssets.ts
import {useState, useEffect} from 'react';
import {StonApiClient, AssetTag, type AssetInfoV2} from '@ston-fi/api';
export const useAssets = () => {
const [assets, setAssets] = useState<AssetInfoV2[]>([]);
useEffect(() => {
const fetchAssets = async () => {
try {
const client = new StonApiClient();
const condition = [
AssetTag.LiquidityVeryHigh,
AssetTag.LiquidityHigh,
AssetTag.LiquidityMedium
].join(' | ');
const result = await client.queryAssets({condition});
setAssets(result);
} catch (err) {
console.error('Failed to fetch assets:', err);
}
};
fetchAssets();
}, []);
return assets;
};
Step 3.5: Add utility files
The swap hook needs some utility files to work with TON and Privy. Create these if you don’t have them already:// src/utils/tonClient.ts
import {TonClient} from '@ton/ton';
export const getTonClient = () => {
const apiKey = import.meta.env.VITE_TON_API_KEY;
return new TonClient({
endpoint: 'https://toncenter.com/api/v2/jsonRPC',
apiKey: apiKey
});
};
// src/utils/tonAddress.ts
import {WalletContractV4} from '@ton/ton';
export function maybeStripEd25519PublicKeyPrefix(publicKey: string) {
if (publicKey.length === 66 && publicKey.startsWith('00')) {
return publicKey.slice(2);
}
return publicKey;
}
export function deriveTonWalletFromPublicKey(publicKey: string) {
const strippedKey = maybeStripEd25519PublicKeyPrefix(publicKey);
const publicKeyBuffer = Buffer.from(strippedKey, 'hex');
const wallet = WalletContractV4.create({
workchain: 0,
publicKey: publicKeyBuffer
});
return {
address: wallet.address.toString(),
wallet
};
}
// src/utils/tonWallet.ts
import {WalletContractV4, type OpenedContract} from '@ton/ton';
export async function getWalletSeqno(contract: OpenedContract<WalletContractV4>): Promise<number> {
try {
return await contract.getSeqno();
} catch {
return 0;
}
}
export function normalizeOmnistonValue(sendAmount: string | number | bigint): bigint {
if (typeof sendAmount === 'string') return BigInt(sendAmount);
if (typeof sendAmount === 'number') return BigInt(sendAmount);
return sendAmount as bigint;
}
export async function retry<T>(
fn: () => Promise<T>,
options: {retries?: number; delay?: number} = {}
): Promise<T> {
const {retries = 3, delay = 1000} = options;
let lastError: unknown;
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < retries) await new Promise((r) => setTimeout(r, delay));
}
}
throw lastError;
}
Step 4: Create the swap hook
This hook handles quoting, building transactions, signing with Privy, and tracking:This hook integrates Omniston’s RFQ (Request for Quote) system to find the best swap rates across
multiple DEXes, then builds and signs transactions using Privy’s embedded wallet.
// src/hooks/useOmnistonSwap.ts
import {useState, useCallback} from 'react';
import {usePrivy} from '@privy-io/react-auth';
import {useSignRawHash} from '@privy-io/react-auth/extended-chains';
import {
useRfq,
useOmniston,
useTrackTrade,
SettlementMethod,
Blockchain,
GaslessSettlement,
type QuoteResponseEvent_QuoteUpdated
} from '@ston-fi/omniston-sdk-react';
import {Address, Cell, internal, SendMode, toNano, WalletContractV4} from '@ton/ton';
import {toHex} from 'viem';
import type {AssetInfoV2} from '@ston-fi/api';
import {useTonWallet} from './useTonWallet';
import {getTonClient} from '../utils/tonClient';
import {getWalletSeqno, normalizeOmnistonValue, retry} from '../utils/tonWallet';
import {deriveTonWalletFromPublicKey} from '../utils/tonAddress';
interface UseOmnistonSwapProps {
fromAsset?: AssetInfoV2;
toAsset?: AssetInfoV2;
amount: string;
}
function toBaseUnits(amount: string, decimals?: number) {
return Math.floor(parseFloat(amount) * 10 ** (decimals ?? 9)).toString();
}
export const useOmnistonSwap = ({fromAsset, toAsset, amount}: UseOmnistonSwapProps) => {
const {user} = usePrivy();
const {signRawHash} = useSignRawHash();
const {address: walletAddress} = useTonWallet();
const omniston = useOmniston();
const [outgoingTxHash, setOutgoingTxHash] = useState('');
const [tradedQuote, setTradedQuote] = useState<QuoteResponseEvent_QuoteUpdated | null>(null);
const [isSwapping, setIsSwapping] = useState(false);
const tonWallet = user?.linkedAccounts?.find(
(account) => account.type === 'wallet' && 'chainType' in account && account.chainType === 'ton'
);
// Get quote from Omniston
const {data: quote, isLoading: quoteLoading} = useRfq(
{
settlementMethods: [SettlementMethod.SETTLEMENT_METHOD_SWAP],
bidAssetAddress: fromAsset
? {blockchain: Blockchain.TON, address: fromAsset.contractAddress}
: undefined,
askAssetAddress: toAsset
? {blockchain: Blockchain.TON, address: toAsset.contractAddress}
: undefined,
amount: {
bidUnits: fromAsset && amount ? toBaseUnits(amount, fromAsset.meta?.decimals) : '0'
},
settlementParams: {
gaslessSettlement: GaslessSettlement.GASLESS_SETTLEMENT_POSSIBLE,
maxPriceSlippageBps: 500
}
},
{
enabled:
!!fromAsset?.contractAddress &&
!!toAsset?.contractAddress &&
parseFloat(amount) > 0 &&
!outgoingTxHash &&
!isSwapping
}
);
// Track trade status
const {data: tradeStatus} = useTrackTrade(
{
quoteId: tradedQuote?.quote?.quoteId || '',
traderWalletAddress: {
blockchain: Blockchain.TON,
address: walletAddress || ''
},
outgoingTxHash
},
{
enabled: !!tradedQuote?.quote?.quoteId && !!walletAddress && !!outgoingTxHash
}
);
const buildTransaction = useCallback(
async (willTradedQuote: QuoteResponseEvent_QuoteUpdated) => {
if (!walletAddress) throw new Error('Wallet not connected');
const tx = await omniston.buildTransfer({
quote: willTradedQuote.quote,
sourceAddress: {blockchain: Blockchain.TON, address: walletAddress},
destinationAddress: {blockchain: Blockchain.TON, address: walletAddress},
gasExcessAddress: {blockchain: Blockchain.TON, address: walletAddress},
useRecommendedSlippage: true
});
return tx.ton?.messages || [];
},
[omniston, walletAddress]
);
const getTxByBOC = useCallback(async (exBoc: string, walletAddr: string): Promise<string> => {
const client = getTonClient();
const myAddress = Address.parse(walletAddr);
return retry(
async () => {
const transactions = await client.getTransactions(myAddress, {limit: 10});
for (const tx of transactions) {
const inMsg = tx.inMessage;
if (inMsg?.info.type === 'external-in') {
try {
if (inMsg.body) {
const inBoc = inMsg.body.toBoc().toString('base64');
if (inBoc === exBoc) {
return tx.hash().toString('hex');
}
}
} catch {
const extHash = Cell.fromBase64(exBoc).hash().toString('hex');
if (inMsg.body) {
const inHash = inMsg.body.hash().toString('hex');
if (extHash === inHash) {
return tx.hash().toString('hex');
}
}
}
}
}
throw new Error('Transaction not found');
},
{retries: 30, delay: 1000}
);
}, []);
const executeSwap = useCallback(async () => {
if (!quote || quote.type !== 'quoteUpdated' || !walletAddress || !tonWallet || !user) {
return;
}
setIsSwapping(true);
try {
setTradedQuote(quote);
const messages = await buildTransaction(quote);
if (!messages || messages.length === 0) {
throw new Error('No transaction messages generated');
}
const isEmbedded = tonWallet.type === 'wallet' && !tonWallet.imported && !tonWallet.delegated;
if (!isEmbedded) {
throw new Error('Only Privy embedded wallets are supported for swaps');
}
if (!tonWallet.publicKey) throw new Error('Unable to find public key');
const {wallet: derivedWallet, address: derivedAddress} = deriveTonWalletFromPublicKey(
tonWallet.publicKey
);
let wallet: WalletContractV4;
if (derivedAddress !== walletAddress) {
const walletAtPrivyAddress = WalletContractV4.create({
workchain: 0,
publicKey: derivedWallet.publicKey
});
wallet = {
...walletAtPrivyAddress,
address: Address.parse(walletAddress)
} as WalletContractV4;
} else {
wallet = derivedWallet;
}
const client = getTonClient();
const contract = client.open(wallet);
// Verify wallet is deployed
const contractState = await client.getContractState(wallet.address);
if (contractState.state !== 'active') {
throw new Error('Wallet not deployed');
}
// Check balance
const walletBalance = await client.getBalance(wallet.address);
const requiredAmount = messages.reduce((sum, msg) => {
return sum + normalizeOmnistonValue(msg.sendAmount);
}, 0n);
const gasReserve = toNano('0.05');
const totalRequired = requiredAmount + gasReserve;
if (walletBalance < totalRequired) {
throw new Error('Insufficient balance for swap');
}
const seqno = await getWalletSeqno(contract);
// Embed createTonSigner logic
const signer = async (msgCell: Cell) => {
const hash = msgCell.hash();
const hexHash = toHex(hash) as `0x${string}`;
const {signature} = await signRawHash({
address: walletAddress,
chainType: 'ton' as const,
hash: hexHash
});
return Buffer.from(signature.slice(2), 'hex');
};
const internalMessages = messages.map((msg) =>
internal({
value: normalizeOmnistonValue(msg.sendAmount),
to: Address.parse(msg.targetAddress),
body: msg.payload ? Cell.fromBase64(msg.payload) : undefined,
bounce: true
})
);
const transfer = await contract.createTransfer({
seqno,
messages: internalMessages,
sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS,
signer
});
await contract.send(transfer);
const exBoc = transfer.toBoc().toString('base64');
const txHash = await getTxByBOC(exBoc, walletAddress);
setOutgoingTxHash(txHash);
} catch (err: unknown) {
console.error('Swap failed:', err);
setTradedQuote(null);
} finally {
setIsSwapping(false);
}
}, [quote, walletAddress, tonWallet, signRawHash, buildTransaction, getTxByBOC, user]);
return {
quote,
quoteLoading,
executeSwap,
isSwapping,
tradeStatus
};
};
Step 5: Create the swap interface component
Build a simple swap interface:// src/components/SwapInterface.tsx
import {useState, useEffect} from 'react';
import type {AssetInfoV2} from '@ston-fi/api';
import {useOmnistonSwap} from '../hooks/useOmnistonSwap';
import {useAssets} from '../hooks/useAssets';
export const SwapInterface: React.FC = () => {
const [amount, setAmount] = useState('');
const [fromAsset, setFromAsset] = useState<AssetInfoV2 | undefined>();
const [toAsset, setToAsset] = useState<AssetInfoV2 | undefined>();
const assets = useAssets();
// Set default assets when loaded
useEffect(() => {
if (assets.length > 0 && !fromAsset) {
setFromAsset(assets[0]);
}
if (assets.length > 1 && !toAsset) {
setToAsset(assets[1]);
}
}, [assets, fromAsset, toAsset]);
const {quote, quoteLoading, executeSwap, isSwapping, tradeStatus} = useOmnistonSwap({
fromAsset,
toAsset,
amount
});
return (
<div className="space-y-6">
{/* From Token */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">From</label>
<select
value={fromAsset?.contractAddress || ''}
onChange={(e) => {
const selected = assets.find((a) => a.contractAddress === e.target.value);
setFromAsset(selected);
}}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg text-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all"
>
{assets.map((asset) => (
<option key={asset.contractAddress} value={asset.contractAddress} className="bg-white">
{asset.meta?.symbol || asset.meta?.displayName || 'Unknown'}
</option>
))}
</select>
</div>
{/* To Token */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">To</label>
<select
value={toAsset?.contractAddress || ''}
onChange={(e) => {
const selected = assets.find((a) => a.contractAddress === e.target.value);
setToAsset(selected);
}}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg text-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all"
>
{assets.map((asset) => (
<option key={asset.contractAddress} value={asset.contractAddress} className="bg-white">
{asset.meta?.symbol || asset.meta?.displayName || 'Unknown'}
</option>
))}
</select>
</div>
{/* Amount */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Amount</label>
<input
type="text"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all"
/>
</div>
{/* Quote Display */}
{quoteLoading && (
<div className="p-4 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-gray-600 text-sm">Fetching best quote...</p>
</div>
)}
{quote && 'quote' in quote && (
<div className="p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-indigo-200">
<p className="font-semibold text-gray-800 mb-2">Quote Details</p>
<div className="space-y-1 text-sm">
<p className="text-gray-600">
Provider: <span className="text-gray-800">{quote.quote.resolverName}</span>
</p>
<p className="text-gray-600">
You send:{' '}
<span className="text-gray-800">
{(parseInt(quote.quote.bidUnits) / 10 ** (fromAsset?.meta?.decimals ?? 9)).toFixed(
4
)}{' '}
{fromAsset?.meta?.symbol}
</span>
</p>
<p className="text-gray-600">
You receive:{' '}
<span className="text-gray-800">
{(parseInt(quote.quote.askUnits) / 10 ** (toAsset?.meta?.decimals ?? 9)).toFixed(4)}{' '}
{toAsset?.meta?.symbol}
</span>
</p>
</div>
</div>
)}
{/* Swap Button */}
{quote && 'quote' in quote && (
<button
onClick={executeSwap}
disabled={isSwapping}
className="w-full py-3 bg-indigo-500 hover:bg-indigo-600 text-white rounded-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 transform hover:scale-[1.02]"
>
{isSwapping ? 'Processing Swap...' : 'Execute Swap'}
</button>
)}
{/* Trade Status */}
{tradeStatus && (
<div className="p-4 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-sm text-gray-600">
{tradeStatus.status?.tradeSettled ? (
<span className="text-green-600">Trade completed successfully</span>
) : (
<span className="text-blue-600">Tracking trade...</span>
)}
</p>
</div>
)}
</div>
);
};
Step 6: Integrate with your TonWalletManager
Add tabs to switch between wallet and swap functionality in your existingTonWalletManager component:
// src/components/TonWalletManager.tsx
import {useState} from 'react';
import {usePrivy} from '@privy-io/react-auth';
import {useTonWallet} from '../hooks/useTonWallet';
import {useTonBalance} from '../hooks/useTonBalance';
import {useWalletDeployment} from '../hooks/useWalletDeployment';
import {LoginButton} from './LoginButton';
import {CreateWalletButton} from './CreateWalletButton';
import {WalletDeployStatus} from './WalletDeployStatus';
import {SignMessageButton} from './SignMessageButton';
import {SendTransactionButton} from './SendTransactionButton';
import {SwapInterface} from './SwapInterface'; // Add this import
export function TonWalletManager() {
const {user, logout, authenticated} = usePrivy();
const {address, exists} = useTonWallet();
const {balance} = useTonBalance();
const {isDeployed} = useWalletDeployment(address);
const [activeTab, setActiveTab] = useState<'wallet' | 'swap'>('wallet'); // Add tab state
if (!authenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md text-center">
<h1 className="text-2xl font-bold mb-4">TON Wallet Manager</h1>
<p className="mb-4">Please login to manage your TON wallet</p>
<LoginButton />
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 p-8">
<div className="max-w-2xl w-full bg-white rounded-lg shadow-md p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">TON Wallet Manager</h1>
<button onClick={logout} className="text-red-500 hover:text-red-700">
Logout
</button>
</div>
<div className="mb-4">
<p className="text-gray-600">User: {user?.email?.address}</p>
</div>
{!exists ? (
<div className="mb-6">
<CreateWalletButton />
</div>
) : (
<div className="space-y-4">
{/* Add tab navigation */}
<div className="flex space-x-2 mb-4">
<button
onClick={() => setActiveTab('wallet')}
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-colors ${
activeTab === 'wallet'
? 'bg-indigo-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Wallet
</button>
<button
onClick={() => setActiveTab('swap')}
disabled={!isDeployed}
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-colors ${
activeTab === 'swap'
? 'bg-indigo-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
Swap
</button>
</div>
{/* Tab content */}
{activeTab === 'wallet' ? (
<>
{/* Existing wallet content */}
{address && <WalletDeployStatus address={address} />}
<div className="bg-gray-50 p-4 rounded">
<p className="font-semibold">Wallet Address:</p>
<p className="text-sm break-all font-mono">{address}</p>
<p className="mt-2">
<span className="font-semibold">Balance:</span> {balance} TON
</p>
{isDeployed && (
<p className="text-sm text-green-600 mt-1">✓ Wallet is deployed</p>
)}
</div>
{isDeployed && (
<>
<SignMessageButton />
<SendTransactionButton />
</>
)}
</>
) : (
<SwapInterface />
)}
</div>
)}
</div>
</div>
);
}
Step 7: Test your integration
- Start your development server:
pnpm dev
- Test the complete flow:
- Log in with your email
- Ensure your wallet is deployed and funded
- Switch to the Swap tab
- Select tokens to swap (e.g., TON → USDT)
- Enter an amount
- Review the quote
- Execute the swap
- Monitor the trade status
Troubleshooting
Common Issues
- Wallet Issues
- Swap Issues
-
“Only embedded wallets are supported”
- The swap functionality only works with Privy embedded wallets
- Imported or delegated wallets are not supported
-
“Wallet not deployed”
- Deploy your wallet first using the deployment flow from the previous guide
- Ensure you have at least 0.05 TON for deployment
-
“Insufficient balance”
- Swaps require the input amount plus gas fees (be sure to have at least ~0.2 TON for gas)
- Top up your wallet and try again
-
No quotes appearing
- Ensure both tokens are selected
- Amount for swap must be greater than 0
- Some token pairs may have low liquidity
-
Transaction tracking fails
- The helper retries automatically
- If it persists, wait a moment and try again
Summary
This guide extends your Privy + TON application with:- Omniston SDK integration for DEX aggregation
- Quote fetching from multiple liquidity sources
- Transaction building and signing with Privy
- Real-time trade tracking
- A polished swap UI with error handling

