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:
- Sending a native USDC token cross-chain.
- 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:
- MessageTransmitter contract – to mint USDC at the destination chain.
- CircleBridge contract – to burn USDC at the source chain.
- 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:
- Burn the given amount of USDC by calling the function
depositForBurn
at theCircleBridge
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.
- When the USDC is burned, the
CircleBridge
contract will emit aMessageSent
event. An interface of theMessageSent
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) {