TAC proxy contracts can receive, process, and bridge NFTs between TON and EVM networks. This guide shows the exact implementation patterns from the TAC engineering team.
NFT proxy contracts must inherit from IERC721Receiver
and implement the
required onERC721Received
function to correctly receive ERC-721 tokens.
NFT Proxy Implementation
The NFT proxy contract must inherit from IERC721Receiver
and implement the required onERC721Received
function to correctly receive ERC‑721 tokens.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import { IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import { TacProxyV1 } from "@tonappchain/evm-ccl/contracts/proxies/TacProxyV1.sol";
import { TacHeaderV1, TokenAmount, NFTAmount, OutMessageV1 } from "@tonappchain/evm-ccl/contracts/core/Structs.sol";
contract TestNFTProxy is TacProxyV1, IERC721Receiver {
constructor(address crossChainLayer) TacProxyV1(crossChainLayer) {}
/**
* @dev Handles the receipt of an ERC-721 token.
*
* Returns its Solidity selector to confirm the token transfer.
*/
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external pure override(IERC721Receiver) returns (bytes4) {
return this.onERC721Received.selector;
}
/**
* @dev Receives NFTs bridged from TON.
*/
function receiveNFT(bytes calldata tacHeader, bytes calldata arguments) external _onlyCrossChainLayer {
// this arguments just for example, you can define your own
NFTAmount[] memory nfts = abi.decode(arguments, (NFTAmount[]));
for (uint i = 0; i < nfts.length; i++) {
IERC721(nfts[i].evmAddress).approve(_getCrossChainLayerAddress(), nfts[i].tokenId);
}
TacHeaderV1 memory header = _decodeTacHeader(tacHeader);
// Bridge NFT back by creating an OutMessageV1
OutMessageV1 memory outMessage = OutMessageV1(
header.shardsKey,
header.tvmCaller,
"",
0, // roundTripMessages don't require tvmProtocolFee as it's already paid on TON
0, // roundTripMessages don't require tvmExecutorFee as it's already paid on TON
new string[](0), // no need to specify validExecutors as it's already specified in initial tx on TON
new TokenAmount[](0), // No ERC20 tokens bridged
nfts // NFTs to bridge (the 'amount' field is ignored for ERC721)
);
_sendMessageV1(outMessage, 0);
}
}
Test ERC‑721 Token Contract
For testing purposes, you can use this simple ERC-721 implementation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract TestERC721Token is ERC721 {
string private __baseURI;
constructor(string memory _name, string memory _symbol, string memory baseURI) ERC721(_name, _symbol) {
__baseURI = baseURI;
}
function mint(address _to, uint256 _tokenId) external {
_mint(_to, _tokenId);
}
function _baseURI() internal view override returns (string memory) {
return __baseURI;
}
}
Key Implementation Points
1. Required Inheritance
contract TestNFTProxy is TacProxyV1, IERC721Receiver {
- Must inherit from both
TacProxyV1
and IERC721Receiver
- Order of inheritance matters for proper functionality
2. onERC721Received Implementation
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external pure override(IERC721Receiver) returns (bytes4) {
return this.onERC721Received.selector;
}
- Must return the correct selector to confirm token receipt
- Without this, NFT transfers to your contract will fail
3. NFT Approval Pattern
for (uint i = 0; i < nfts.length; i++) {
IERC721(nfts[i].evmAddress).approve(_getCrossChainLayerAddress(), nfts[i].tokenId);
}
- Approve each NFT individually by tokenId before cross-chain transfer
- Use
_getCrossChainLayerAddress()
to get the correct approval target
4. OutMessageV1 Structure for NFTs
OutMessageV1 memory outMessage = OutMessageV1(
header.shardsKey, // Use from incoming header
header.tvmCaller, // Send back to caller
"", // Must be empty
0, // roundTrip - already paid on TON
0, // roundTrip - already paid on TON
new string[](0), // roundTrip - already defined on TON
new TokenAmount[](0), // No ERC20 tokens
nfts // NFTs to bridge
);
5. NFTAmount Structure
The NFTAmount
struct contains:
evmAddress
- The NFT contract address
tokenId
- The specific token ID
amount
- Ignored for ERC721 (always 0)
Function Signature Requirements
NFT proxy functions follow the same signature requirements as regular proxy functions:
function receiveNFT(bytes calldata tacHeader, bytes calldata arguments)
external
_onlyCrossChainLayer
{
// Implementation
}
You can name the function whatever you want (e.g., processNFT
, handleNFT
, etc.) as long as it follows the function <name>(bytes calldata, bytes calldata) external
pattern.
Argument Encoding for NFTs
When calling NFT proxy functions from the frontend, encode the NFTAmount array:
// Example: Encoding NFTAmount[]
const nftAmounts = [
[nftContractAddress1, tokenId1, 0], // amount is always 0 for ERC-721
[nftContractAddress2, tokenId2, 0],
];
const encodedArguments = ethers.AbiCoder.defaultAbiCoder().encode(
["tuple(address,uint256,uint256)[]"],
[nftAmounts]
);
const userMessage = {
target: nftProxyAddress,
method_name: "receiveNFT(bytes,bytes)",
arguments: encodedArguments,
};
What’s Next?
Now that you understand NFT handling, learn how to test these contracts:
Important: NFT proxy contracts must implement IERC721Receiver
or NFT
transfers will fail. Always test NFT functionality thoroughly before deploying
to mainnet.