Skip to main content


Assuming you have familiarized yourself with ZRC-20 Tokens and zEVM, this example walks through how you'd create an omnichain Curve pool! This means you can leverage the existing Curve contracts and orchestrate external, native assets as if they were all on one chain.

Deploy Curve on ZetaChain

Since zEVM is fully EVM compatible, you can download the Curve repo as it is and deploy it on zEVM, simply pointing the RPC to zEVM RPC. You can find all the ZetaChain RPC information here: testnet and here mainnet.

Deploy a tri-token pool of ZRC-20 tokens

Let's say we already deployed a tri-token pool (if you don't know how to deploy it take a look to official script, does all the work for you out of the box deployment script), using the address of three ZRC-2020 tokens. You can find the ZetaChain addresses of ZRC-20 tokens supported right now on the Athens-2 testnet using this endpoint.

Implement a cross-chain stableswap

Now that you have Curve and the pool you want deployed, swapping would look just like this:

pragma solidity 0.8.13;
contract ZetaCurveSwapDemo is zContract, ZetaCurveSwapErrors {
address public crv3pool; // ex. gETH/tBNB/tMATIC pool
address[3] public crvZRC20s; // addresses of the three tokens
constructor(address crv3pool_, address[3] memory ZRC20s_) {
crv3pool = crv3pool_;
crvZRC20s = ZRC20s_;
function encode(
address zrc20,
address recipient,
uint256 minAmountOut
) public pure returns (bytes memory) {
return abi.encode(zrc20, recipient, minAmountOut);
function addr2idx(address zrc20) public view returns (uint256) {
for (uint256 i = 0; i < 3; i++) {
if (crvZRC20s[i] == zrc20) {
return i;
return 18;
function _doWithdrawal(
address targetZRC20,
uint256 amount,
bytes32 receipient
) private {
(address gasZRC20, uint256 gasFee) = IZRC20(targetZRC20).withdrawGasFee();
if (gasZRC20 != targetZRC20) revert WrongGasContract();
if (gasFee >= amount) revert NotEnoughToPayGasFee();
IZRC20(targetZRC20).approve(targetZRC20, gasFee);
IZRC20(targetZRC20).withdraw(abi.encodePacked(receipient), amount - gasFee);
// called on the deposit a ZRC-20 compatible token from an external chain, to do a cross-chain call
function onCrossChainCall(
address zrc20,
uint256 amount,
bytes calldata message
) external override {
(address targetZRC20, bytes32 receipient, ) = abi.decode(message, (address, bytes32, uint256));
address[] memory path = new address[](2);
path[0] = zrc20;
path[1] = targetZRC20;
IZRC20(zrc20).approve(address(crv3pool), amount);
uint256 i = addr2idx(zrc20);
uint256 j = addr2idx(targetZRC20);
require(i >= 0 && i < 3 && j >= 0 && j < 3 && i != j, "i,j error");
uint256 outAmount = ICRV3(crv3pool).exchange(i, j, amount, 0, false);
_doWithdrawal(targetZRC20, outAmount, receipient);

In this example crvZRC20s is an array of three ZRC20 tokens, for example gETH, tBNB and tMATIC. And crv3pool is the address of the pool you deployed with Curve's code.

Easy right? In order to swap, you just need to write onCrossChainCall. This function simply extracts params from the message, calls the Curve pool's exchange, and then withdraws to the designated destination. All swap/pool logic remains in the core Curve contract deployment. Users can interact by depositing and calling this zEVM contract from an external chain. You can see how you'd call this for a user programmatically here.