This process might be a little complicated, so please reach out to us on
Discord if you have any questions.
A TAC Proxy is a Solidity contract that receives cross-chain messages and tokens bridged from the TON blockchain. When a user on TON initiates a message (and potentially sends tokens), the TAC infrastructure delivers the tokens and data to your EVM-based contract’s function. The proxy contract then processes that data—often by calling another Dapp contract or by performing some bridging logic—and optionally sends tokens back to TON using the same TAC infrastructure.
The guide below shows how to:
- Install the necessary dependencies.
- Write a simple proxy contract (both non-upgradeable and upgradeable).
- Implement your custom logic in a proxy function that adheres to TAC’s required function signature.
- Encode your arguments properly on the frontend.
- Test your proxy contract locally using Hardhat and the tac-l2-ccl testing utilities.
Installation
-
Install the tac-l2-ccl
package in your Solidity contract repository. This package includes core functionalities for cross-chain messaging and local test SDKs:
npm install --save tac-l2-ccl@latest
-
In most cases, you will be using Hardhat for development. Make sure you have a typical Hardhat setup and the recommended testing libraries. For instance, your package.json
may include:
{
"devDependencies": {
"@nomicfoundation/hardhat-toolbox": "^5.0.0",
"hardhat": "^2.22.5",
"ethers": "^6.13.2",
"chai": "^4.3.7",
"ts-node": "^10.9.2",
"typescript": "^5.6.3",
"tac-l2-ccl": "^latest",
"...": "..."
}
}
-
If you cannot deploy your Dapp contracts directly for local testing, consider forking another network where the necessary contracts are already deployed. This can simplify local development and testing.
Creating the Proxy Contract
In your contracts
folder, create a new .sol
file (e.g. MyProxy.sol
). Below are two variations:
Non-upgradeable Contract
For a simple, non-upgradeable contract, you can extend TacProxyV1
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { TacProxyV1 } from "tac-l2-ccl/contracts/proxies/TacProxyV1.sol";
import { IDappContract } from "./IDappContract.sol"; // your Dapp interface (if needed)
import { TokenAmount, OutMessageV1, TacHeaderV1 } from "tac-l2-ccl/contracts/L2/Structs.sol";
contract MyProxy is TacProxyV1 {
IDappContract public dappContract;
constructor(address _dappContract, address _crossChainLayer)
TacProxyV1(_crossChainLayer)
{
dappContract = IDappContract(_dappContract);
}
// Add your proxy functions here
function myProxyFunction(bytes calldata tacHeader, bytes calldata arguments)
external
_onlyCrossChainLayer
{
// Implementation here
}
}
Key Points
- We pass
_crossChainLayer
(the CrossChainLayer contract address) to TacProxyV1
’s constructor.
_onlyCrossChainLayer
is a security modifier inherited from TacProxyV1
. It ensures that only the recognized cross-chain layer can call this function.
IDappContract
is just an example interface for some external logic contract you may want to call.
Upgradeable Contract
If you need to upgrade your contract over time, use OpenZeppelin’s upgradeable libraries and the TacProxyV1Upgradeable
contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import { TacProxyV1Upgradeable } from "tac-l2-ccl/contracts/proxies/TacProxyV1Upgradeable.sol";
import { TokenAmount, OutMessageV1, TacHeaderV1 } from "tac-l2-ccl/contracts/L2/Structs.sol";
contract MyProxyUpgradeable is
Initializable,
OwnableUpgradeable,
UUPSUpgradeable,
TacProxyV1Upgradeable
{
function initialize(address owner, address crossChainLayer) public initializer {
__UUPSUpgradeable_init();
__Ownable_init(owner);
__TacProxyV1Upgradeable_init(crossChainLayer);
}
function _authorizeUpgrade(address) internal override onlyOwner {}
// Add your proxy functions here
function myProxyFunction(bytes calldata tacHeader, bytes calldata arguments)
external
_onlyCrossChainLayer
{
// Implementation here
}
}
Key Points
- Inherits from
Initializable
, OwnableUpgradeable
, UUPSUpgradeable
, and TacProxyV1Upgradeable
.
- Has an
initialize
function in place of a constructor.
_authorizeUpgrade
ensures only the owner can perform contract upgrades.
Defining and Implementing Proxy Functions
Every proxy function that TAC calls must have the signature:
function <function_name>(bytes calldata, bytes calldata) external;
You can name the function as you wish (e.g. myProxyFunction
, invokeWithCallback
, etc.), but it must accept two bytes
arguments:
- The first is always the encoded TAC header.
- The second is always the encoded arguments that you define.
Example Implementation
Below is an extended example of how you might implement myProxyFunction
in a non-upgradeable contract. The logic is the same for an upgradeable contract.
function myProxyFunction(bytes calldata tacHeader, bytes calldata arguments)
external
_onlyCrossChainLayer
{
// 1. Decode the custom arguments
MyProxyFunctionArguments memory args = abi.decode(arguments, (MyProxyFunctionArguments));
// 2. Approve tokens to your Dapp contract for some action
IERC20(args.tokenFrom).approve(address(dappContract), args.amount);
// 3. Call the Dapp contract
uint256 tokenToAmount = dappContract.doSomething(
args.tokenFrom,
args.tokenTo,
args.amount
);
// 4. Prepare tokens to send back to TON, if desired
TokenAmount[] memory tokensToBridge = new TokenAmount[](1);
tokensToBridge[0] = TokenAmount(args.tokenTo, tokenToAmount);
// 5. Approve the CrossChainLayer to pull them
IERC20(tokensToBridge[0].l2Address).approve(
_getCrossChainLayerAddress(),
tokensToBridge[0].amount
);
// 6. Decode the TAC header
TacHeaderV1 memory header = _decodeTacHeader(tacHeader);
// 7. Form an OutMessage
// This is how you instruct TAC to deliver tokens or data back to TON
OutMessageV1 memory outMsg = OutMessageV1({
shardsKey: header.shardsKey,
tvmTarget: header.tvmCaller, // the original user (TON wallet)
tvmPayload: "",
toBridge: tokensToBridge
});
// 8. Send message back through CrossChainLayer
_sendMessageV1(outMsg);
}
Important Notes
function myProxyFunction(bytes calldata, bytes calldata) external _onlyCrossChainLayer
ensures only the TAC infrastructure can call this function.
_decodeTacHeader(...)
is inherited from TacProxyV1
(or TacProxyV1Upgradeable
); it transforms the raw bytes
into TacHeaderV1
data.
- You typically decode the second argument (
arguments
) using abi.decode(...)
with a struct you define.
How the Cross-Chain Call Works
When a user on TON sends a message via your Dapp (e.g., using the @tonappchain/sdk
on the frontend), the CrossChainLayer contract on EVM receives bridged tokens and data from the TON side. Then:
- Tokens (if any) are automatically transferred from the CrossChainLayer contract to your proxy contract before the function call.
- The CrossChainLayer calls
myProxyFunction(tacHeader, arguments)
on your proxy contract.
tacHeader
is the encoded TacHeaderV1
struct containing data like shardsKey
, operationId
, the user’s tvmCaller
address on TON, etc.
arguments
is a bytes
array containing data you defined in the Dapp’s frontend (encoded via @tonappchain/sdk
or manually using ethers.AbiCoder
).
- Your proxy function processes the tokens, calls external contracts if necessary, and optionally prepares an
OutMessageV1
with tokens to be sent back to TON.
- The
_sendMessageV1(...)
function sends everything back to the CrossChainLayer so that tokens (and an optional message) can be bridged to TON.
Encoding Arguments on the Frontend
You typically define a struct that represents the arguments your proxy function expects. For example:
struct MyProxyFunctionArguments {
address tokenFrom;
address tokenTo;
uint256 amount;
}
You then encode these fields in your frontend code using ethers.js
(or another library).
Basic Example
import { ethers } from "ethers";
const abiCoder = ethers.AbiCoder.defaultAbiCoder();
const myProxyFunctionArguments = abiCoder.encode(
["address", "address", "uint256"],
[tokenFromAddress, tokenToAddress, tokenFromAmount]
);
Complex Example with Nested Structures
struct AnyExtraInfo {
address feeCollector;
uint256 feeRate;
}
struct MyProxyFunctionArguments {
AnyExtraInfo extraInfo;
address tokenFrom;
address tokenTo;
uint256 amount;
}
Encoding:
const extraInfo = [feeCollectorAddress, feeRate];
const myProxyFunctionArguments = abiCoder.encode(
["tuple(address,uint256)", "address", "address", "uint256"],
[extraInfo, tokenFromAddress, tokenToAddress, tokenAmount]
);
Complex Example with Dynamic Arrays
struct MyProxyFunctionArguments {
address[] path;
uint256 amount;
}
Encoding:
const path = [tokenFromAddress, tokenToAddress];
const myProxyFunctionArguments = abiCoder.encode(
["tuple(address[],uint256)"],
[[path, tokenFromAmount]]
);
Specifying the Function Name in @tonappchain/sdk
When using the @tonappchain/sdk
to create messages for bridging, you must provide:
target
: the address of your Proxy contract.
method_name
: the complete function signature, e.g. "myProxyFunction(bytes,bytes)"
.
arguments
: the ABI-encoded arguments (second parameter in your proxy function).
Example:
const myProxyFunctionName = "myProxyFunction(bytes,bytes)";
const userMessage = {
target: MyProxyContractAddress,
method_name: myProxyFunctionName,
arguments: myProxyFunctionArguments // from the previous encoding step
gasLimit: the parameter that will be passed to the TAC side. The executor must allocate at least gasLimit gas for executing the transaction on the TAC side. If this parameter is not specified, it will be calculated using the `simulateEVMMessage` method(preferred).
};
Testing the Proxy Contract
Example Minimal Proxy for Testing
In many cases, you want a stripped-down contract to test basic cross-chain behavior. Below is a minimal TestProxy
that:
- Inherits
TacProxyV1
.
- Has a single function
invokeWithCallback(...)
.
- Emits an event for logging.
- Demonstrates bridging tokens back to TON.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { OutMessageV1, TacHeaderV1, TokenAmount } from "tac-l2-ccl/contracts/L2/Structs.sol";
import { TacProxyV1 } from "tac-l2-ccl/contracts/proxies/TacProxyV1.sol";
contract TestProxy is TacProxyV1 {
event InvokeWithCallback(
uint64 shardsKey,
uint256 timestamp,
bytes32 operationId,
string tvmCaller,
bytes extraData,
TokenAmount[] receivedTokens
);
constructor(address _crossChainLayer) TacProxyV1(_crossChainLayer) {}
function invokeWithCallback(bytes calldata tacHeader, bytes calldata arguments)
external
_onlyCrossChainLayer
{
// 1. Decode the header
TacHeaderV1 memory header = _decodeTacHeader(tacHeader);
// 2. Decode the array of TokenAmount structs
TokenAmount[] memory receivedTokens = abi.decode(arguments, (TokenAmount[]));
// Optional: Here you could call an external Dapp contract with these tokens
// 3. Log an event for testing
emit InvokeWithCallback(
header.shardsKey,
header.timestamp,
header.operationId,
header.tvmCaller,
header.extraData,
receivedTokens
);
// 4. Approve and forward the tokens back via the cross-chain layer
for (uint i = 0; i < receivedTokens.length; i++) {
IERC20(receivedTokens[i].l2Address).approve(
_getCrossChainLayerAddress(),
receivedTokens[i].amount
);
}
// 5. Create and send an OutMessage
_sendMessageV1(
OutMessageV1({
shardsKey: header.shardsKey,
tvmTarget: header.tvmCaller,
tvmPayload: "",
toBridge: receivedTokens
})
);
}
}
Also define the TestToken contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract TestToken is ERC20 {
constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) {}
function mint(address _to, uint256 _amount) external {
_mint(_to, _amount);
}
}
Test Setup Example (Hardhat + tac-l2-ccl)
Create a test file such as TestProxy.spec.ts
under your test
directory. Below is a basic example test:
import hre, { ethers } from "hardhat";
import { Signer } from "ethers";
import { expect } from "chai";
// The following items come from 'tac-l2-ccl' to help test cross-chain logic locally.
import {
deploy,
TacLocalTestSdk,
JettonInfo,
TokenMintInfo,
TokenUnlockInfo,
} from "tac-l2-ccl";
// Types for your compiled contracts
import { TestProxy, TestToken } from "../typechain-types";
import { InvokeWithCallbackEvent } from "../typechain-types/contracts/TestProxy";
describe("TestProxy with tac-l2-ccl", () => {
let admin: Signer;
let testSdk: TacLocalTestSdk;
let proxyContract: TestProxy;
let existedToken: TestToken;
before(async () => {
[admin] = await ethers.getSigners();
// 1. Initialize local test SDK
testSdk = new TacLocalTestSdk();
const crossChainLayerAddress = testSdk.create(ethers.provider);
// 2. Deploy a sample ERC20 token
existedToken = await deploy<TestToken>(
admin,
hre.artifacts.readArtifactSync("TestToken"),
["TestToken", "TTK"],
undefined,
false
);
// 3. Deploy the proxy contract
proxyContract = await deploy<TestProxy>(
admin,
hre.artifacts.readArtifactSync("TestProxy"),
[crossChainLayerAddress],
undefined,
false
);
});
it("Should correctly handle invokeWithCallback", async () => {
// Prepare call parameters
const shardsKey = 1n;
const operationId = ethers.encodeBytes32String("operationId");
const extraData = "0x"; // untrusted data from the executor
const timestamp = BigInt(Math.floor(Date.now() / 1000));
const tvmWalletCaller = "TVMCallerAddress";
// Example bridging: create a Jetton and specify how many tokens to mint
const jettonInfo: JettonInfo = {
tvmAddress: "JettonMinterAddress",
name: "TestJetton",
symbol: "TJT",
decimals: 9n,
};
const tokenMintInfo: TokenMintInfo = {
info: jettonInfo,
mintAmount: 10n ** 9n,
};
// Also handle an existing EVM token to simulate bridging
const tokenUnlockInfo: TokenUnlockInfo = {
evmAddress: await existedToken.getAddress(),
unlockAmount: 10n ** 18n,
};
// Lock existedToken in the cross-chain layer to emulate bridging from EVM
await existedToken.mint(
testSdk.getCrossChainLayerAddress(),
tokenUnlockInfo.unlockAmount
);
// You can define a native TAC amount to bridge to your proxy,
// but you must first lock this amount on the CrossChainLayer contract
// use the testSdk.lockNativeTacOnCrossChainLayer(nativeTacAmount) function
const tacAmountToBridge = 0n;
// Determine the EVM address of the bridged Jetton (for minted jettons)
const bridgedJettonAddress = testSdk.getEVMJettonAddress(
jettonInfo.tvmAddress
);
// Prepare the method call
const target = await proxyContract.getAddress();
const methodName = "invokeWithCallback(bytes,bytes)";
// Our 'arguments' is an array of TokenAmount: (address, uint256)[]
const receivedTokens = [
[bridgedJettonAddress, tokenMintInfo.mintAmount],
[tokenUnlockInfo.evmAddress, tokenUnlockInfo.unlockAmount],
];
const encodedArguments = ethers.AbiCoder.defaultAbiCoder().encode(
["tuple(address,uint256)[]"],
[receivedTokens]
);
// 4. Use testSdk to simulate a cross-chain message
const { receipt, deployedTokens, outMessages } = await testSdk.sendMessage(
shardsKey,
target,
methodName,
encodedArguments,
tvmWalletCaller,
[tokenMintInfo], // which jettons to mint
[tokenUnlockInfo], // which EVM tokens to unlock
tacAmountToBridge,
extraData,
operationId,
timestamp,
0, // gasLimit - if 0 - simulate and fill inside sendMessage
false // force send (if simulation failed)
);
// 5. Assertions
expect(receipt.status).to.equal(1);
// - Check if the Jetton was deployed
expect(deployedTokens.length).to.equal(1);
expect(deployedTokens[0].evmAddress).to.equal(bridgedJettonAddress);
// - Check the outMessages array
expect(outMessages.length).to.equal(1);
const outMessage = outMessages[0];
expect(outMessage.shardsKey).to.equal(shardsKey);
expect(outMessage.operationId).to.equal(operationId);
expect(outMessage.callerAddress).to.equal(await proxyContract.getAddress());
expect(outMessage.targetAddress).to.equal(tvmWalletCaller);
// - The returned tokens should be burned or locked as bridging back to TON
expect(outMessage.tokensBurned.length).to.equal(1);
expect(outMessage.tokensBurned[0].evmAddress).to.equal(
bridgedJettonAddress
);
expect(outMessage.tokensBurned[0].amount).to.equal(
tokenMintInfo.mintAmount
);
expect(outMessage.tokensLocked.length).to.equal(1);
expect(outMessage.tokensLocked[0].evmAddress).to.equal(
tokenUnlockInfo.evmAddress
);
expect(outMessage.tokensLocked[0].amount).to.equal(
tokenUnlockInfo.unlockAmount
);
// - Confirm the event was emitted
let eventFound = false;
receipt.logs.forEach((log) => {
const parsed = proxyContract.interface.parseLog(log);
if (parsed && parsed.name === "InvokeWithCallback") {
eventFound = true;
const typedEvent =
parsed as unknown as InvokeWithCallbackEvent.LogDescription;
expect(typedEvent.args.shardsKey).to.equal(shardsKey);
expect(typedEvent.args.timestamp).to.equal(timestamp);
expect(typedEvent.args.operationId).to.equal(operationId);
expect(typedEvent.args.tvmCaller).to.equal(tvmWalletCaller);
expect(typedEvent.args.extraData).to.equal(extraData);
expect(typedEvent.args.receivedTokens.length).to.equal(2);
expect(typedEvent.args.receivedTokens[0].l2Address).to.equal(
bridgedJettonAddress
);
expect(typedEvent.args.receivedTokens[1].l2Address).to.equal(
tokenUnlockInfo.evmAddress
);
}
});
expect(eventFound).to.be.true;
});
});
Test Flow:
-
Initialization
- Create a local cross-chain environment (
TacLocalTestSdk
).
- Deploy a test token (
TestToken
).
- Deploy your
TestProxy
.
-
Bridging Simulation
- Mint or lock tokens on the cross-chain layer.
- Create the parameters (
shardsKey
, operationId
, etc.).
-
Invoke Proxy
- Use the
testSdk.sendMessage(...)
to simulate a cross-chain call to your proxy’s function.
-
Verification
- Confirm the transaction succeeded.
- Inspect the
deployedTokens
(if you minted new tokens).
- Inspect the
outMessages
for tokens returning to TON.
- Check emitted events for correct data.
Running the Tests
Inside your project directory, simply run:
Hardhat will compile all contracts and run the test suite. The tac-l2-ccl
local test SDK helps emulate bridging logic, ensuring your proxy behaves as expected in a cross-chain scenario.
Conclusion
By following these steps, you can develop, deploy, and test a TAC Proxy contract that handles cross-chain messages and tokens from TON. Key points include:
- Inheritance from
TacProxyV1
or TacProxyV1Upgradeable
to gain built-in cross-chain functionality and security modifiers.
- Function Signatures must match
functionName(bytes, bytes) external
.
- Decoding the TAC header and your custom arguments to implement your Dapp’s logic.
- **Use
_sendMessageV1(...)