CCIP Best Practices (TON)

Before you deploy your cross-chain dApps to mainnet, make sure that your dApps follow the best practices in this document. You are responsible for thoroughly reviewing your code and applying best practices to ensure that your cross-chain dApps are secure and reliable. If you have a unique use case for CCIP that might involve additional cross-chain risk, contact the Chainlink Labs Team before deploying your application to mainnet.

Implement all three mandatory receiver protocol steps

Every TON CCIP receiver must implement three mandatory protocol steps in order. Omitting or reordering any step puts your contract into a broken state — either allowing unauthorized execution or stranding messages that cannot be retried.

Step 1 — Authorize the Router. Accept Receiver_CCIPReceive only from the configured CCIP Router address. Reject any other sender unconditionally.

assert(in.senderAddress == st.router) throw ERROR_UNAUTHORIZED;

Step 2 — Check attached value. The Router attaches TON to cover the cost of routing Router_CCIPReceiveConfirm back through the protocol chain. Verify the attached value meets your MIN_VALUE constant. The Router needs at least 0.02 TON to process the confirmation. Use 0.03 TON as your baseline and increase it to account for your own execution costs.

assert(in.valueCoins >= MIN_VALUE) throw ERROR_LOW_VALUE;

Step 3 — Send Router_CCIPReceiveConfirm. Return the confirmation message to the Router along with enough TON to cover the remaining protocol chain. Using SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE is the simplest correct choice — it forwards all remaining value automatically.

val receiveConfirm = createMessage({
    bounce: true,
    value: 0,
    dest: in.senderAddress,
    body: Router_CCIPReceiveConfirm { execId: msg.execId },
});
receiveConfirm.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);

The MinimalReceiver contract in the TON Starter Kit implements all three steps inline and serves as the reference starting point. For a detailed explanation and multiple implementation options, see Implementing CCIP Receivers.

Set MIN_VALUE correctly

MIN_VALUE is the minimum TON your receiver requires attached to each Receiver_CCIPReceive message. Setting it too low is a critical misconfiguration:

  • If too little TON reaches your contract, the Router_CCIPReceiveConfirm may not propagate through the protocol chain.
  • The message is left in an undelivered state and cannot be retried. Used gas is not refunded.

Start at 0.03 TON and benchmark your contract under realistic load to determine the right value. The 0.03 TON baseline includes 0.02 TON for the Router's confirmation cost plus 0.01 TON margin.

// In your receiver contract
const MIN_VALUE: int = ton("0.03"); // increase if your logic is more expensive

The EVM-side gasLimit (set in extraArgs) controls how much nanoTON the protocol reserves for delivery. If your receiver requires more than the default 0.1 TON allocation, increase the gasLimit in buildExtraArgsForTON accordingly:

// In your EVM sender script — increase gasLimit if your TON receiver is expensive
const extraArgs = buildExtraArgsForTON(200_000_000n, true) // 0.2 TON in nanoTON

Verify destination chain

Before sending a CCIP message from TON, verify that the destination chain is supported. The CCIP Router on TON exposes its supported destination chains through the OnRamp. Sending a message to an unsupported chain selector will be rejected at the Router level, wasting the TON attached to the transaction.

Check the CCIP Directory for the list of supported TON → EVM lanes and their chain selectors before hardcoding a destination. Always use the chain selector constant from helper-config.ts in the TON Starter Kit rather than deriving it manually.

Verify source chain

The CCIP protocol delivers messages to your receiver with a sourceChainSelector field in Any2TVMMessage. Source chain validation is not enforced at the protocol level on TON — it is your responsibility to check this field.

Inside your Receiver_CCIPReceive handler, validate message.sourceChainSelector against an allowlist of chains your application trusts:

// After the three mandatory protocol steps, validate the source chain
assert(isAllowedSourceChain(msg.message.sourceChainSelector)) throw ERROR_UNTRUSTED_SOURCE;

Without this check, a valid CCIP message sent from any supported source chain will be accepted and processed by your receiver.

Verify sender

The sender field in Any2TVMMessage is a CrossChainAddress (a slice containing the encoded source-chain address). For EVM-to-TON messages, it contains the 20-byte EVM address of the sender contract.

Sender validation is also not enforced at the protocol level. Your contract is responsible for checking this field if your application depends on messages arriving from specific addresses:

// After source chain validation
// For EVM senders, msg.message.sender is a slice containing the 20-byte EVM address
assert(isTrustedSender(msg.message.sender)) throw ERROR_UNTRUSTED_SENDER;

Use messageId for deduplication

The messageId field in Any2TVMMessage is a uint256 that uniquely identifies each CCIP message in the protocol. Store received message IDs and reject any message whose ID has already been processed:

// Check deduplication before processing
assert(!hasBeenProcessed(msg.message.messageId)) throw ERROR_ALREADY_PROCESSED;
// Mark as processed before executing business logic
markProcessed(msg.message.messageId);

This prevents replay attacks in the event of any re-delivery edge case. The messageId should be stored in your contract's persistent storage.

Configure extraArgs correctly

The extraArgs field controls execution parameters for the destination chain. Its content and meaning differ depending on which chain is the source and which is the destination.

TON sending to EVM

When sending from TON to an EVM destination, use buildExtraArgsForEVM from the Starter Kit. Two fields must be set correctly:

gasLimit — EVM gas units allocated for _ccipReceive execution on the destination contract. 100_000 units covers simple message storage (for example, MessageReceiver.sol). Increase this for contracts with heavier logic. If the gas limit is too low, execution fails on EVM. For EVM destinations, failed messages can be retried with a higher gas limit via the CCIP manual execution path, but unused gas is not refunded.

allowOutOfOrderExecution — Must always be true for TON-to-EVM messages. The TON CCIP Router rejects any message where this flag is false.

scripts/utils/utils.ts
// gasLimit: EVM gas units for ccipReceive. allowOutOfOrderExecution must be true.
const extraArgs = buildExtraArgsForEVM(100_000, true)

EVM sending to TON

When sending from EVM to a TON destination, use buildExtraArgsForTON from the Starter Kit. The parameter meanings differ from the EVM case:

gasLimit — Denominated in nanoTON, not EVM gas units. This is the amount of nanoTON reserved for execution on the TON destination chain. A starting value of 100_000_000n (0.1 TON) covers most receive operations. Any unused nanoTON is returned to the contract after execution.

allowOutOfOrderExecution — Must be true for TON-bound lanes.

scripts/utils/utils.ts
// gasLimit: nanoTON reserved for TON execution (1 TON = 1_000_000_000 nanoTON)
const extraArgs = buildExtraArgsForTON(100_000_000n, true) // 0.1 TON

Estimate fees accurately and apply a buffer

The CCIP protocol fee for TON-to-EVM messages is denominated in nanoTON and 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

Use the getCCIPFeeForEVM helper in the TON Starter Kit to perform this lookup. The CCIP message Cell passed to it must be fully populated — all fields must match the values used in the final send.

Apply two buffers on top of the quoted fee:

  • 10% fee buffer: Accounts for small fluctuations between quote time and execution time.
  • 0.5 TON gas reserve: Covers the wallet-level transaction cost and source-chain execution overhead. Any surplus above actual costs 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

For EVM-to-TON messages, apply the same 10% buffer to the fee returned by router.getFee():

scripts/evm2ton/sendMessage.ts
const fee = await router.getFee(TON_TESTNET_CHAIN_SELECTOR, message)
const feeWithBuffer = (fee * 110n) / 100n

Decouple CCIP message reception from business logic

Your Receiver_CCIPReceive handler should perform the three mandatory protocol steps and then delegate to a separate internal function for application logic. Keep the handler itself minimal:

  1. Perform the three mandatory protocol steps (Router check, value check, send confirm).
  2. Optionally validate source chain and sender.
  3. Check deduplication using messageId.
  4. Store the incoming message or its processed result in contract storage.
  5. Emit an event (if needed for monitoring).
  6. Call your application logic from a separate function.

Keeping reception and business logic separate provides a natural "escape hatch": if your business logic encounters a critical issue, you can upgrade or replace that function without touching the protocol-facing entry point. This pattern also simplifies testing — you can unit-test the business logic independently of the Receiver protocol.

Monitor your dApps

TON → EVM: track ACK and NACK responses

After sending a message from TON, the CCIP Router sends back a Router_CCIPSendACK (accepted) or Router_CCIPSendNACK (rejected). Use the queryID field to correlate responses to their originating send. The recommended value for queryID is the wallet's current sequence number (seqno), which is monotonically increasing and collision-free.

Use the Starter Kit verification scripts to check the status of your sends:

Terminal
# Check ACK/NACK for recent transactions
npm run utils:checkLastTxs -- --ccipSendOnly true

# Filter to a specific send by QueryID
npm run utils:checkLastTxs -- --queryId <QUERY_ID>

An ACK contains the CCIP Message ID and a CCIP Explorer URL. A NACK means the Router rejected the message — both the fee and any TON surplus are returned to the sender.

EVM → TON: track delivery on TON

After sending from EVM, track delivery on TON using the message ID from the ccipSend transaction:

Terminal
# Check delivery of an EVM-to-TON message
npm run utils:checkTON -- \
  --sourceChain sepolia \
  --tonReceiver <YOUR_TON_RECEIVER_ADDRESS> \
  --msg "your message"

EVM-to-TON delivery typically takes 5–15 minutes. You can also track the full message lifecycle on the CCIP Explorer using the message ID printed by the send script.

Set up monitoring alerts

Build monitoring on top of the verification scripts or TON Testnet block explorer data. Create alerts for:

  • NACKs on TON → EVM sends (rejected messages, insufficient fees).
  • Messages that remain undelivered on TON beyond the expected delivery window.
  • Sudden changes in CCIP fee levels that may indicate network conditions affecting your buffer assumptions.

Evaluate the security and reliability of the networks that you use

Although CCIP has been thoroughly reviewed and audited, inherent risks might still exist based on your use case, the blockchain networks where you deploy your contracts, and the network conditions on those blockchains.

Review and audit your code

Before securing value with contracts that implement CCIP interfaces and routers, ensure that your code is secure and reliable. If you have a unique use case for CCIP that might involve additional cross-chain risk, contact the Chainlink Labs Team before deploying your application to mainnet.

Soak test your dApps

Be aware of the Service Limits for Supported Networks. Before you provide access to end users or secure value, soak test your cross-chain dApps. Ensure that your dApps can operate within these limits and operate correctly during usage spikes or unfavorable network conditions.

Monitor your dApps

When you build applications that depend on CCIP, include monitoring and safeguards to protect against the negative impact of extreme market events, possible malicious activity on your dApp, potential delays, and outages.

Create your own monitoring alerts based on deviations from normal activity. This will notify you when potential issues occur so you can respond to them.

Multi-Signature Authorities

Multi-signature authorities enhance security by requiring multiple signatures to authorize transactions.

Threshold configuration

Set an optimal threshold for signers based on the trust level of participants and the required security.

Role-based access control

Assign roles with specific permissions to different signers, limiting access to critical operations to trusted individuals.

Hardware wallet integration

Use hardware wallets for signers to safeguard private keys from online vulnerabilities. Ensure that these devices are secure and regularly updated.

Regular audits and updates

Conduct periodic audits of signer access and authority settings. Update the multisig setup as necessary, especially when personnel changes occur.

Emergency recovery plans

Implement procedures for recovering from lost keys or compromised accounts, such as a predefined recovery multisig or recovery key holders.

Transaction review process

Establish a standard process for reviewing and approving transactions, which can include a waiting period for large transfers to mitigate risks.

Documentation and training

Maintain thorough documentation of multisig operations and provide training for all signers to ensure familiarity with processes and security protocols.

Get the latest Chainlink content straight to your inbox.