Skip to content
Developers
General Message Passing
Example Composable Usdc

Example: Composable USDC

Circle has announced a plan to support cross-chain transactions in native USDC (opens in a new tab). Currently, it’s available on the Ethereum Goerli and Avalanche Fuji testnets. In this tutorial, we’ll learn how to build a cross-chain USDC dApp using Circle’s Cross-Chain Transfer Protocol (CCTP) and Axelar’s General Message Passing (GMP).

What that means is, users will be able to issue a single transaction with a GMP payload. On the backend, the application takes care of USDC bridging, plus any other action that the user wishes — as indicated in the payload. Axelar services, also working on the backend, can handle conversion and payment for destination-chain gas fees, so the user only has to transact once, using one gas token.

In this example, we will build a cross-chain swap dApp. It converts a native token from one chain to another chain, using native USDC as a routing asset. For example: send ETH to a contract on Ethereum Goerli testnet and receive AVAX on Avalanche Fuji testnet, or vice versa.

There are two parts we have to learn to achieve this:

  1. Sending a native USDC token cross-chain.
  2. Sending a swap payload cross-chain.

Part 1: Sending a native USDC token cross-chain

There are three components from Circle that we’ll use in this part:

  1. MessageTransmitter contract – to mint USDC at the destination chain.
  2. CircleBridge contract  – to burn USDC at the source chain.
  3. Attestation API – to retrieve attestation to be used for minting USDC at the destination chain.

Let’s take a look at how to implement this step-by-step:

  1. Burn the given amount of USDC by calling the function depositForBurnat the CircleBridge contract. The example Solidity code is below. At this step, the contract does nothing except provide a function to burn the USDC in _depositForBurn function.

CrosschainNativeSwap.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
 
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ICircleBridge} from "./ICircleBridge.sol";
import "./StringAddressUtils.sol";
 
contract CrosschainNativeSwap {
    IERC20 public usdc;
    ICircleBridge public circleBridge;
 
    // mapping chain name to domain number;
    mapping(string => uint32) public circleDestinationDomains;
    bytes32 constant CHAIN_ETHEREUM = keccak256(abi.encodePacked("ethereum"));
    bytes32 constant CHAIN_AVALANCHE = keccak256(abi.encodePacked("avalanche"));
 
    constructor(address _usdc, address _circleBridge) {
        usdc = IERC20(_usdc);
        circleBridge = ICircleBridge(_circleBridge);
        circleDestinationDomains["ethereum"] = 0;
        circleDestinationDomains["avalanche"] = 1;
    }
 
    modifier isValidChain(string memory destinationChain) {
        require(
            keccak256(abi.encodePacked(destinationChain)) == CHAIN_ETHEREUM ||
            keccak256(abi.encodePacked(destinationChain)) == CHAIN_AVALANCHE,
            "Invalid chain"
        );
        _;
    }
 
    // Step 1: Burn USDC on the source chain with given amount
    function _depositForBurn(
        uint256 amount,
        string memory destinationChain,
        address recipient
    ) private isValidChain(destinationChain) {
        IERC20(address(usdc)).approve(address(circleBridge), amount);
 
        circleBridge.depositForBurn(
            amount,
            this.circleDestinationDomains(destinationChain),
            bytes32(uint256(uint160(recipient))),
            address(usdc)
        );
    }
}

ICircleBridge.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;
 
interface ICircleBridge {
    // this event will be emitted when `depositForBurn` function is called.
    event MessageSent(bytes message);
 
    /**
    * @param _amount amount of tokens to burn
    * @param _destinationDomain destination domain
    * @param _mintRecipient address of mint recipient on destination domain
    * @param _burnToken address of contract to burn deposited tokens, on local
    domain
    * @return _nonce uint64, unique nonce for each burn
    */
    function depositForBurn(
        uint256 _amount,
        uint32 _destinationDomain,
        bytes32 _mintRecipient,
        address _burnToken
    ) external returns (uint64 _nonce);
}

That's it for the contract. We'll continue to add our business logic to it later in Part 2.

  1. When the USDC is burned, the CircleBridge contract will emit a MessageSent event. An interface of the MessageSent event looks like this:
event MessageSent(bytes message)

At this step, we’ll extract message from the transaction hash. The code snippet below provides an example of such logic.

constants.ts

import { ethers } from "ethers";
 
export const MESSAGE_TRANSMITTER_ADDRESS = {
  ethereum: "0x40A61D3D2AfcF5A5d31FcDf269e575fB99dd87f7",
  avalanche: "0x52FfFb3EE8Fa7838e9858A2D5e454007b9027c3C",
};
export const PROVIDERS = {
  ethereum: new ethers.providers.WebSocketProvider(
    "wss://goerli.infura.io/ws/v3/INFURA_PROJECT_ID"
  ),
  avalanche: new ethers.providers.WebSocketProvider(
    "wss://api.avax-test.network/ext/bc/C/ws"
  ),
};

step2.ts

import { ethers } from "ethers";
import { MESSAGE_TRANSMITTER_ADDRESS, PROVIDERS } from "./constant";
 
// Extract the `message` from the `MessageSent` event
const getMessageFromMessageSentEvent = (
  contract: ethers.Contract,
  txReceipt: ethers.providers.TransactionReceipt
) => {
  const eventLogs = txReceipt.logs;
  const messageSentEventId = ethers.utils.id("MessageSent(bytes)");
  for (const log of eventLogs) {
    if (log.topics[0] === messageSentEventId) {