Building CCIP Messages from TON to EVM

Introduction

This guide explains how to construct CCIP messages from the TON blockchain to EVM chains (e.g., Ethereum Sepolia, Arbitrum Sepolia). TON's CCIP integration currently supports arbitrary messaging only — token transfers are not supported on TON lanes.

You send a CCIP message from TON by constructing a Cell in the specific TL-B layout expected by the CCIP Router contract, then sending that Cell as the body of an internal TON message to the Router address with enough TON attached to cover both the CCIP protocol fee and source-chain execution costs.

CCIP Message Cell Layout on TON

CCIP messages from TON are sent by constructing a Router_CCIPSend Cell and delivering it as the body of an internal message to the CCIP Router address on TON Testnet (EQB9QIw22sgwNKMfqsMKGepkhnjXYJmXlzCgcBSAlaiF9VCj).

The buildCCIPMessageForEVM helper in the TON Starter Kit assembles this Cell:

scripts/utils/utils.ts
import { Address, beginCell, Cell } from "@ton/core"

const CCIP_SEND_OPCODE = 0x31768d95

export function buildCCIPMessageForEVM(
  queryID: bigint | number,
  destChainSelector: bigint | number,
  receiverBytes: Buffer, // 32 bytes: 12 zero-bytes + 20-byte EVM address
  data: Cell, // message payload
  feeToken: Address, // native TON address
  extraArgs: Cell // GenericExtraArgsV2 cell
): Cell {
  return beginCell()
    .storeUint(CCIP_SEND_OPCODE, 32) // Router opcode
    .storeUint(queryID, 64) // unique message identifier (wallet seqno)
    .storeUint(destChainSelector, 64) // destination chain selector
    .storeUint(receiverBytes.length, 8) // receiver byte-length prefix (always 32)
    .storeBuffer(receiverBytes) // encoded EVM receiver address
    .storeRef(data) // message payload cell
    .storeRef(Cell.EMPTY) // tokenAmounts — always empty for TON
    .storeAddress(feeToken) // fee token (native TON only)
    .storeRef(extraArgs) // GenericExtraArgsV2 cell
    .endCell()
}

The following sections describe each field in detail.


queryID

  • Type: uint64
  • Purpose: A unique identifier that lets the TON CCIP Router correlate Router_CCIPSendACK and Router_CCIPSendNACK responses back to the originating send.
  • Recommended value: Use the sending wallet's current sequence number (seqno). Wallet seqnos are monotonically increasing and unique per wallet, making them collision-free.
scripts/ton2evm/sendMessage.ts
const seqno = await walletContract.getSeqno()
// pass BigInt(seqno) as queryID

destChainSelector

  • Type: uint64
  • Purpose: Identifies the destination EVM chain where the message will be delivered.
  • Supported chains: See the CCIP Directory for the complete list of supported TON → EVM lanes and their chain selectors.
helper-config.ts
// From helper-config.ts in the Starter Kit
const destChainSelector = BigInt(networkConfig["sepolia"].chainSelector)
// => 16015286601757825753n (Ethereum Sepolia)

receiver

  • Definition: The address of the contract on the destination EVM chain that will receive the CCIP message.
  • Encoding: EVM addresses are 20 bytes, but the TON CCIP Router expects a 32-byte buffer. Left-pad the 20-byte address with 12 zero-bytes.
scripts/utils/utils.ts
export function encodeEVMAddress(evmAddr: string): Buffer {
  const addrBytes = Buffer.from(evmAddr.slice(2), "hex") // strip '0x'
  return Buffer.concat([Buffer.alloc(12, 0), addrBytes]) // 12 zero-bytes + 20-byte address
}

Usage:

scripts/ton2evm/sendMessage.ts
const receiverBytes = encodeEVMAddress("0xYourEVMReceiverAddress")
// receiverBytes.length === 32

data

  • Definition: The raw bytes delivered to the _ccipReceive function on the destination EVM contract via Client.Any2EVMMessage.data.
  • Format: A TL-B Cell containing your message payload.

For sending a plain text string:

scripts/ton2evm/sendMessage.ts
import { beginCell } from "@ton/core"

const data = beginCell().storeStringTail("Hello EVM from TON").endCell()

The EVM receiver contract receives these bytes as message.data and is responsible for interpreting them. The MessageReceiver.sol contract in the Starter Kit emits the raw bytes in a MessageFromTON event, which can be decoded with ethers.toUtf8String(message.data).


tokenAmounts

  • Value: Always Cell.EMPTY.
  • Reason: Token transfers are not supported on TON CCIP lanes. All messages from TON carry data only.
.storeRef(Cell.EMPTY) // tokenAmounts — must always be an empty cell

feeToken

  • Definition: The token used to pay the CCIP protocol fee on TON.
  • Supported value: Only native TON is supported. Paying fees in LINK is not available for TON-to-EVM messages.
  • Address: EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAd99
scripts/ton2evm/sendMessage.ts
const feeToken = Address.parse(networkConfig.tonTestnet.nativeTokenAddress)
// "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAd99"

The CCIP protocol fee is deducted from the TON value attached to the Router message, not from a separate token transfer.


extraArgs

The extraArgs field is a Cell encoding GenericExtraArgsV2 parameters required by the destination EVM chain. The tag 0x181dcf10 is the GENERIC_EXTRA_ARGS_V2_TAG. The Cell layout is:

FieldTypeDescription
taguint320x181dcf10 — identifies GenericExtraArgsV2 format
hasGasLimitbitMust be 1 (gas limit is always present)
gasLimituint256EVM gas units allocated for receiver execution
allowOutOfOrderExecutionbitMust be 1 for TON-to-EVM messages
scripts/utils/utils.ts
export function buildExtraArgsForEVM(gasLimitEVMUnits: number, allowOutOfOrderExecution: boolean): Cell {
  return beginCell()
    .storeUint(0x181dcf10, 32) // GenericExtraArgsV2 tag
    .storeBit(true) // gasLimit IS present
    .storeUint(gasLimitEVMUnits, 256) // gasLimit in EVM gas units
    .storeBit(allowOutOfOrderExecution) // must be true
    .endCell()
}

Estimating the CCIP Fee

Before sending, query the protocol fee. The fee is returned in nanoTON and is computed by the FeeQuoter contract, reachable through a chain of on-chain getter calls:

Router.onRamp(destChainSelector)         → OnRamp address
OnRamp.feeQuoter(destChainSelector)      → FeeQuoter address
FeeQuoter.validatedFeeCell(ccipSendCell) → fee in nanoTON

The getCCIPFeeForEVM helper in the Starter Kit performs this lookup. The CCIP message Cell passed to it must be fully populated — queryID, destChainSelector, receiver, data, feeToken, and extraArgs must all match the values used in the final send.

scripts/ton2evm/sendMessage.ts
import { TonClient } from "@ton/ton"
import { fromNano } from "@ton/core"

const fee = await getCCIPFeeForEVM(client, routerAddress, destChainSelector, ccipSendMessage)
console.log(`CCIP fee: ${fromNano(fee)} TON`)

Applying a buffer and gas reserve

Add a buffer on top of the quoted fee to account for minor variations between quote time and execution:

  • 10% fee buffer: Covers small fluctuations in the protocol fee.
  • 0.5 TON gas reserve: Covers the wallet-level transaction fee and source-chain execution. This is sent to the Router along with the fee and any surplus is returned via the ACK message.
scripts/ton2evm/sendMessage.ts
const fee = await getCCIPFeeForEVM(client, routerAddress, destChainSelector, ccipSendMessage)
const feeWithBuffer = (fee * 110n) / 100n // +10%
const gasReserve = 500_000_000n // 0.5 TON in nanoTON

const valueToAttach = feeWithBuffer + gasReserve // total value sent to Router

Sending the Message

After building the Cell and calculating the total value to attach, you have two options.

Send the Router_CCIPSend Cell directly from your wallet to the CCIP Router address. This is the simplest path when your application logic is entirely off-chain.

Wallet ──(Router_CCIPSend)──► CCIP Router
scripts/ton2evm/sendMessage.ts
import { Address, internal as createInternal } from "@ton/core"

const routerAddress = Address.parse(networkConfig.tonTestnet.router)

await walletContract.sendTransfer({
  seqno,
  secretKey: keyPair.secretKey,
  messages: [
    createInternal({
      to: routerAddress,
      value: valueToAttach, // feeWithBuffer + gasReserve
      body: ccipSendCell, // the Router_CCIPSend cell
    }),
  ],
})

Reference: Full Message Construction

The complete flow from wallet setup to sending is available in the TON Starter Kit:

scripts/ton2evm/sendMessage.ts

Full script including wallet setup, message construction, fee estimation, and send with CLI options for chain, receiver, message content, and optional Sender contract routing.

To see these concepts in action with a step-by-step implementation guide, check out the following tutorial:

Get the latest Chainlink content straight to your inbox.