The Developer's Guide to Building Hybrid dApps with TAC SDK

TAC.Build
August 5, 2025
 • 
Developers

I just spent the weekend building a token swap that lets TON users hit Uniswap V2 directly from their wallets. No bridges, no network switching, no multiple wallets.

All it takes is 50 lines of code that execute cross-chain swaps. Here's exactly how it works and why you might care. 

This guide walks through building a complete token swap that connects TON users to Uniswap V2 contracts running on TAC EVM. By the end, you’ll understand how to build applications that feel native to TON while leveraging EVM’s ecosystem.

Token swap that lets TON users hit Uniswap V2

The Core Problem

Most EVM developers I know have the same issue - you build something that works great, but good luck getting users. You can deploy to 47 different L2s, but you're still fishing in the same pond of 200k active DeFi users.

Meanwhile, Telegram has actual users (1B+ active users) who barely touch DeFi because everything's built in TON and FunC and the UX is primitive compared to what they're used to on Telegram.

The TAC SDK is a JavaScript library that makes it simple for frontend developers to create hybrid dApps. It abstracts away the complexities of cross-chain messaging, allowing developers to focus on building great user experiences while the SDK handles wallet connections, transaction routing, and asset bridging automatically. I tested this with a simple Uniswap V2 swap.

How TAC Works

TAC consists of three components that work together:

  • TAC EVM Layer: Full EVM-compatible blockchain built on Cosmos SDK. Deploy your existing Solidity contracts unchanged.

  • TON Adapter: Distributed sequencer network that validates and routes messages between TON and TAC EVM. Multiple independent groups must reach consensus before execution.

  • Proxy Contracts: Smart contracts that translate between ecosystems. Handle asset bridging, message formatting, and execution coordination.

When a TON user swaps tokens:

  1. TAC SDK locks or burns (depending on token origin) tokens and creates a cross-chain message
  2. Sequencer network validates the transaction across multiple groups
  3. EVM proxy contract receives the message, mints or unlocks equivalent tokens, and executes the Uniswap swap
  4. Results flow back to the user’s TON wallet

TAC's sequencer network is distributed but not fully decentralized yet. Multiple independent groups validate transactions, but the network is still maturing toward full decentralization. 

The entire process takes 2-5 minutes, but users get real-time status updates.

The Minimal Working Example

Let’s start with the simplest possible token swap. This is the core of what we’re building:

import "dotenv/config";
import { toNano } from "@ton/ton";
import { ethers } from "ethers";
import { AssetType, Network, SenderFactory, TacSdk } from "@tonappchain/sdk";

// Token addresses on TON testnet
const TVM_TKA_ADDRESS = "EQBLi0v_y-KiLlT1VzQJmmMbaoZnLcMAHrIEmzur13dwOmM1"; // test token A

const TVM_TKA_ADDRESS = "EQCsQSo54ajAorOfDUAM-RPdDJgs0obqyrNSEtvbjB7hh2oK"; // test token B


const UNISWAPV2_PROXY_ADDRESS = "0x14Ad9182F54903dFD8215CA2c1aD0F9A47Ac7Edb"; // testnet proxy address

async function executeSwap() {
  // Initialize SDK
  const tacSdk = await TacSdk.create({ network: Network.TESTNET });

  // Create wallet sender
  const sender = await SenderFactory.getSender({
    network: Network.TESTNET,
    version: "V4",
    mnemonic: process.env.TVM_MNEMONICS || "",
  });

  // Get cross-chain token addresses
  const evmTKA = await tacSdk.getEVMTokenAddress(TVM_TKA_ADDRESS);
  const evmTKB = await tacSdk.getEVMTokenAddress(TVM_TKB_ADDRESS);

  // Encode Uniswap parameters
  const abi = new ethers.AbiCoder();
  const encodedParams = abi.encode(
    ["tuple(uint256,uint256,address[],address,uint256)"],
    [
      [
        Number(toNano(2)), // Amount in
        Number(toNano(0)), // Min amount out
        [evmTKA, evmTKB], // Swap path
        UNISWAPV2_PROXY_ADDRESS, // Recipient
        19010987500, // Deadline
      ],
    ]
  );

  // Create cross-chain message
  const evmProxyMsg = {
    evmTargetAddress: UNISWAPV2_PROXY_ADDRESS,
    methodName: "swapExactTokensForTokens",
    encodedParameters: encodedParams,
  };

  // Prepare assets to bridge
  const assets = [
    {
      address: TVM_TKA_ADDRESS,
      amount: 2,
      type: AssetType.FT,
    },
  ];

  // Execute the swap
  const result = await tacSdk.sendCrossChainTransaction(
    evmProxyMsg,
    sender,
    assets
  );

  console.log("Swap executed:", result.operationId);
  tacSdk.closeConnections();

  return result;
}

executeSwap().catch(console.error);

That’s it. 30 lines of code that execute a cross-chain token swap from TON to Uniswap V2. Let’s break down what’s happening.

Breaking Down the Core Implementation

Project Setup

First, install the dependencies:

npm install @tonappchain/sdk ethers dotenv @ton/ton

Create your environment file:

TVM_MNEMONICS="your 24 word mnemonic phrase here"

Important: This is for development only. Production apps use TON Connect for user wallets.

SDK Initialization

const tacSdk = await TacSdk.create({ network: Network.TESTNET });

This single line:

  • Connects to both TON and TAC EVM networks
  • Sets up sequencer communication
  • Initializes internal caching systems

Always start on Network.TESTNET. It has identical functionality to mainnet but uses free test tokens.

Wallet Integration

const sender = await SenderFactory.getSender({
  network: Network.TESTNET,
  version: "V4",
  mnemonic: process.env.TVM_MNEMONICS || "",
});
The SenderFactory creates a unified interface for different wallet types. In production, you’d use TON Connect:
// Production pattern
const sender = await SenderFactory.getSender({
  tonConnect: tonConnectUI,
});


Cross-Chain Address Resolution

const evmTKA = await tacSdk.getEVMTokenAddress(TVM_TKA_ADDRESS);
const evmTKB = await tacSdk.getEVMTokenAddress(
  "EQCsQSo54ajAorOfDUAM-RPdDJgs0obqyrNSEtvbjB7hh2oK"
);

TON uses 9 decimals, EVM uses 18. TAC preserves the original precision, but make sure your UI needs to handle this.

Every TON token has a deterministic corresponding address on TAC EVM. The SDK calculates these automatically. This works even before tokens have been bridged, enabling UI pre-computation.


Parameter Encoding

const abi = new ethers.AbiCoder();
const encodedParams = abi.encode(
  ["tuple(uint256,uint256,address[],address,uint256)"],
  [
    [
      Number(toNano(2)), // Amount in
      Number(toNano(0)), // Min amount out
      [evmTKA, evmTKB], // Swap path
      UNISWAPV2_PROXY_ADDRESS, // Recipient
      19010987500, // Deadline
    ],
  ]
);

This encodes JavaScript values into the format Uniswap V2 expects. Key points:

  • Tuple encoding: TAC proxy contracts always receive two parameters: TAC header and your data
  • toNano(): Converts human-readable amounts to TON’s 9-decimal format
  • Long deadline: Prevents expiration during cross-chain processing


Cross-Chain Message

const evmProxyMsg: EvmProxyMsg = {
  evmTargetAddress: UNISWAPV2_PROXY_ADDRESS,
  methodName: "swapExactTokensForTokens(bytes, bytes)",
  encodedParameters: encodedParams,
};

This tells TAC:

  • Which contract to call on the EVM side
  • Which method to execute
  • What parameters to pass

The methodName must match your Solidity function exactly.


Asset Preparation

const assets = [
  {
    address: TVM_TKA_ADDRESS,
    amount: 2,
    type: AssetType.FT,
  },
];

This specifies which assets to lock on TON and mint on TAC EVM. AssetType.FT means fungible token (vs AssetType.NFT).

Transaction Execution

const result = await tacSdk.sendCrossChainTransaction(
  evmProxyMsg,
  sender,
  assets
);

This single call:

  • Locks your tokens on TON
  • Creates a validated cross-chain message
  • Routes it through the sequencer network
  • Triggers execution on TAC EVM
  • Returns a transaction handle for tracking

Adding Transaction Tracking

Cross-chain transactions take time. Users need status updates:

import { startTracking, OperationTracker } from "@tonappchain/sdk";

// Simple console tracking
async function executeAndTrack() {
  const result = await executeSwap();
  await startTracking(result, Network.TESTNET);
}

// Programmatic tracking for UIs
async function trackStatus(transactionLinker) {
  const tracker = new OperationTracker(Network.TESTNET);

  let status = "PENDING";
  while (status === "PENDING") {
    status = await tracker.getSimplifiedOperationStatus(transactionLinker);
    console.log("Status:", status);

    if (status === "PENDING") {
      await new Promise((resolve) => setTimeout(resolve, 10000));
    }
  }

  return status; // 'SUCCESSFUL', 'FAILED', or 'OPERATION_ID_NOT_FOUND'
}

Adding Transaction Simulation

Always simulate before executing to catch errors early:

async function executeSwapWithSimulation() {
  const tacSdk = await TacSdk.create({ network: Network.TESTNET });
  const sender = await SenderFactory.getSender({
    network: Network.TESTNET,
    version: "V4",
    mnemonic: process.env.TVM_MNEMONICS || "",
  });

  // ... prepare evmProxyMsg and assets as before

  // Simulate first
  const simulation = await tacSdk.getTransactionSimulationInfo(
    evmProxyMsg,
    sender,
    assets
  );

  if (!simulation.simulation) {
    throw new Error(`Simulation failed: ${simulation.error}`);
  }

  console.log("Simulation successful:", {
    estimatedGas: simulation.feeParams
  });

  // Now execute for real
  const result = await tacSdk.sendCrossChainTransaction(
    evmProxyMsg,
    sender,
    assets
  );

  tacSdk.closeConnections();
  return result;
}

Adding Error Handling

Production applications need robust error handling:

async function executeSwapSafely(amountIn, minAmountOut, tokenAddress) {
  let tacSdk;

  try {
    // Validate inputs
    if (amountIn <= 0) {
      throw new Error("Amount must be positive");
    }

    if (minAmountOut < 0) {
      throw new Error("Minimum output cannot be negative");
    }

    tacSdk = await TacSdk.create({ network: Network.TESTNET });

    // ... setup sender, addresses, etc.

    // Simulate first
    const simulation = await tacSdk.getTransactionSimulationInfo(
      evmProxyMsg,
      sender,
      assets
    );

    if (!simulation.success) {
      return {
        success: false,
        error: `Transaction would fail: ${simulation.error}`,
      };
    }

    // Execute
    const result = await tacSdk.sendCrossChainTransaction(
      evmProxyMsg,
      sender,
      assets
    );

    return {
      success: true,
      operationId: result.operationId,
      transactionLinker: result,
    };
  } catch (error) {
    console.error("Swap failed:", error);

    // Categorize errors for better UX
    if (error.message.includes("insufficient balance")) {
      return {
        success: false,
        error: "Insufficient token balance for swap and fees",
      };
    }

    if (error.message.includes("invalid address")) {
      return {
        success: false,
        error: "Invalid token or contract address",
      };
    }

    return {
      success: false,
      error: `Transaction failed: ${error.message}`,
    };
  } finally {
    if (tacSdk) {
      tacSdk.closeConnections();
    }
  }
}

Frontend Integration

For React applications, wrap the swap logic in a hook:

import { useState, useCallback } from "react";
import { Network } from "@tonappchain/sdk";

export function useTokenSwap() {
  const [status, setStatus] = useState("idle");
  const [error, setError] = useState(null);
  const [operationId, setOperationId] = useState(null);

  const executeSwap = useCallback(
    async (amountIn, minAmountOut, tokenAddress) => {
      setStatus("pending");
      setError(null);
      setOperationId(null);

      try {
        const result = await executeSwapSafely(
          amountIn,
          minAmountOut,
          tokenAddress
        );

        if (result.success) {
          setOperationId(result.operationId);
          setStatus("submitted");

          // Track in background
          trackStatus(result.transactionLinker)
            .then((finalStatus) => {
              setStatus(finalStatus === "SUCCESSFUL" ? "success" : "failed");
            })
            .catch((err) => {
              console.error("Tracking failed:", err);
              setStatus("unknown");
            });
        } else {
          setError(result.error);
          setStatus("error");
        }
      } catch (err) {
        setError(err.message);
        setStatus("error");
      }
    },
    []
  );

  return {
    executeSwap,
    status,
    error,
    operationId,
  };
}

Use it in your component:

function SwapInterface() {
  const { executeSwap, status, error, operationId } = useTokenSwap();

  const handleSwap = () => {
    executeSwap(2, 0, "EQBLi0v_y-KiLlT1VzQJmmMbaoZnLcMAHrIEmzur13dwOmM1");
  };

  return (
    <div>
      <button onClick={handleSwap} disabled={status === "pending"}>
        {status === "pending" ? "Swapping..." : "Execute Swap"}
      </button>

      {status === "submitted" && <p>Transaction submitted: {operationId}</p>}

      {status === "success" && <p>Swap completed successfully!</p>}

      {error && <p style={{ color: "red" }}>Error: {error}</p>}
    </div>
  );
}

Understanding the EVM Proxy Contract

While we’re using a pre-deployed proxy, understanding the pattern helps with custom implementations:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import { TacProxyV1 } from "@tonappchain/evm-ccl/contracts/proxies/TacProxyV1.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract UniswapV2Proxy is TacProxyV1 {
    IUniswapV2Router02 public immutable uniswapRouter;

    constructor(address _crossChainLayer, address _uniswapRouter)
        TacProxyV1(_crossChainLayer)
    {
        uniswapRouter = IUniswapV2Router02(_uniswapRouter);
    }

    function swapExactTokensForTokens(
        bytes calldata tacHeader,
        bytes calldata arguments
    ) external _onlyCrossChainLayer {
        // Decode TAC header for user info
        TacHeaderV1 memory header = _decodeTacHeader(tacHeader);

        // Decode swap parameters
        SwapParams memory params = abi.decode(arguments, (SwapParams));

        // Tokens are already transferred to this contract
        IERC20 tokenIn = IERC20(params.path[0]);

        // Approve and execute swap
        tokenIn.approve(address(uniswapRouter), params.amountIn);
        uint256[] memory amounts = uniswapRouter.swapExactTokensForTokens(
            params.amountIn,
            params.amountOutMin,
            params.path,
            address(this),
            params.deadline
        );

        // Send results back to TON user
        IERC20 tokenOut = IERC20(params.path[params.path.length - 1]);
        uint256 amountOut = amounts[amounts.length - 1];

        tokenOut.approve(_getCrossChainLayerAddress(), amountOut);

        _sendMessageV1(OutMessageV1({
            shardsKey: header.shardsKey,
            tvmTarget: header.tvmCaller,
            tvmPayload: "",
            tvmProtocolFee: 0,
            tvmExecutorFee: 0,
            tvmValidExecutors: new string[](0),
            toBridge: _createTokenArray(address(tokenOut), amountOut),
            toBridgeNFT: new NFTAmount[](0)
        }), 0);
    }
}

Key Points:

  • Function signature must be (bytes calldata, bytes calldata)
  • _onlyCrossChainLayer ensures only TAC can call the function
  • Assets are automatically minted/unlocked during execution
  • Use _sendMessageV1 to return results to TON

Complete Working Example

Here’s the full implementation with all the improvements:

import "dotenv/config";
import { toNano } from "@ton/ton";
import { ethers } from "ethers";
import {
  AssetType,
  Network,
  SenderFactory,
  TacSdk,
  startTracking,
} from "@tonappchain/sdk";

const TVM_TKA_ADDRESS = "EQBLi0v_y-KiLlT1VzQJmmMbaoZnLcMAHrIEmzur13dwOmM1";
const TVM_TKB_ADDRESS = "EQCsQSo54ajAorOfDUAM-RPdDJgs0obqyrNSEtvbjB7hh2oK";
const UNISWAPV2_PROXY_ADDRESS = "0x14Ad9182F54903dFD8215CA2c1aD0F9A47Ac7Edb";

async function executeSwapWithAllFeatures(amountIn, minAmountOut) {
  let tacSdk;

  try {
    console.log(`Executing swap: ${amountIn} TKA for min ${minAmountOut} TKB`);

    // Initialize SDK
    tacSdk = await TacSdk.create({ network: Network.TESTNET });

    // Create sender
    const sender = await SenderFactory.getSender({
      network: Network.TESTNET,
      version: "V4",
      mnemonic: process.env.TVM_MNEMONICS || "",
    });

    // Get cross-chain addresses
    const [evmTKA, evmTKB] = await Promise.all([
      tacSdk.getEVMTokenAddress(TVM_TKA_ADDRESS),
      tacSdk.getEVMTokenAddress(TVM_TKB_ADDRESS),
    ]);

    console.log("Token addresses resolved:", { evmTKA, evmTKB });

    // Encode parameters
    const abi = new ethers.AbiCoder();
    const encodedParams = abi.encode(
      ["tuple(uint256,uint256,address[],address,uint256)"],
      [
        [
          Number(toNano(amountIn)),
          Number(toNano(minAmountOut)),
          [evmTKA, evmTKB],
          UNISWAPV2_PROXY_ADDRESS,
          19010987500,
        ],
      ]
    );

    // Create message
    const evmProxyMsg = {
      evmTargetAddress: UNISWAPV2_PROXY_ADDRESS,
      methodName: "swapExactTokensForTokens",
      encodedParameters: encodedParams,
    };

    // Prepare assets
    const assets = [
      {
        address: TVM_TKA_ADDRESS,
        amount: amountIn,
        type: AssetType.FT,
      },
    ];

    // Simulate first
    console.log("Simulating transaction...");
    const simulation = await tacSdk.getTransactionSimulationInfo(
      evmProxyMsg,
      sender,
      assets
    );

    if (!simulation.success) {
      throw new Error(`Simulation failed: ${simulation.error}`);
    }

    console.log("Simulation successful:", {
      estimatedGas: simulation.estimatedGas?.toString(),
      protocolFee: simulation.protocolFee?.toString(),
    });

    // Execute transaction
    console.log("Executing cross-chain transaction...");
    const result = await tacSdk.sendCrossChainTransaction(
      evmProxyMsg,
      sender,
      assets
    );

    console.log("Transaction submitted:", result.operationId);

    // Track progress
    console.log("Tracking transaction progress...");
    await startTracking(result, Network.TESTNET);

    console.log("Swap completed successfully!");
    return result;
  } catch (error) {
    console.error("Swap failed:", error.message);
    throw error;
  } finally {
    if (tacSdk) {
      tacSdk.closeConnections();
    }
  }
}

// Execute the swap
async function main() {
  try {
    await executeSwapWithAllFeatures(2, 0);
  } catch (error) {
    console.error("Fatal error:", error);
    process.exit(1);
  }
}

main()

What We’ve Built

In about 50 lines of core code, we’ve created a production-ready token swap that:

  • Connects TON wallets to Uniswap V2
  • Handles all cross-chain complexity automatically
  • Provides transaction simulation and validation
  • Includes comprehensive error handling
  • Offers real-time status tracking
  • Works with existing EVM development patterns


The user experience is indistinguishable from a native TON application, but it’s powered by battle-tested EVM DeFi infrastructure.