Skip to main content
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.
This guide extends the Getting started with Privy and TON application by adding token swap functionality using Omniston - a protocol that aggregates liquidity from multiple DEXes including STON.fi and DeDust.

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 your src/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 existing TonWalletManager 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

  1. Start your development server:
pnpm dev
  1. 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

  • “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

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
You can now seamlessly swap tokens on TON blockchain using Privy embedded wallets, with the best rates aggregated from multiple DEXes.