Note: This quickstart provides all the code you need to create a unified crosschain balance and transfer it to a new chain. You can find the full code for this quickstart on GitHub.
This guide walks you through the process of creating a Unified Crosschain USDC Balance on two chains using Circle Gateway, and transferring it to a third chain.
This quickstart shows you how to use Circle Gateway to create chain abstracted USDC balances to build an application experience similar to traditional finance.
Note: This quickstart provides all the code you need to create a unified crosschain balance and transfer it to a new chain. You can find the full code for this quickstart on GitHub.
Before you start building the sample app, ensure that Node.js and npm are installed. You can download Node.js directly, or use a version manager like nvm. The npm binary comes with Node.js.
You should also have created an Ethereum testnet wallet and have the address and the private key available.
The following sections demonstrate how to initialize your project and set up the required clients and smart contracts to interact with Circle Gateway.
Create a new directory for your project and initialize it with npm. Set the
package type to module
and install the necessary dependencies:
mkdir unified-balance-quickstart && cd unified-balance-quickstart
npm init -y
npm pkg set type="module"
npm install --save viem dotenv
Create a new .env
file.
touch .env
Edit the .env
file and add the following variables, replacing
{YOUR_PRIVATE_KEY}
with the private key for your testnet wallet:
PRIVATE_KEY={YOUR_PRIVATE_KEY}
Create a file called abis.js
in the project directory and add the following
code to it. This helper code defines the contract functions that you use to
establish a unified USDC balance and mint it on a new chain.
Note: For brevity, the ABIs in this code are not complete ABIs for the wallet and minter contracts. They only define the functions necessary for this quickstart. See Contract Interfaces and Events for a full description of each contract's methods.
///////////////////////////////////////////////////////////////////////////////
// ABIs used for the Gateway contracts
// The subset of the GatewayWallet ABI that is used in the quickstart guide
export const gatewayWalletAbi = [
{
type: "function",
name: "deposit",
inputs: [
{
name: "token",
type: "address",
internalType: "address",
},
{
name: "value",
type: "uint256",
internalType: "uint256",
},
],
outputs: [],
stateMutability: "nonpayable",
},
];
// The subset of the GatewayMinter ABI that is used in the quickstart guide
export const gatewayMinterAbi = [
{
type: "function",
name: "gatewayMint",
inputs: [
{
name: "attestationPayload",
type: "bytes",
internalType: "bytes",
},
{
name: "signature",
type: "bytes",
internalType: "bytes",
},
],
outputs: [],
stateMutability: "nonpayable",
},
];
Create a file called gateway-client.js
in the project directory and add the
following code to it. This helper code provides data and methods for interacting
with the Gateway API.
/////////////////////////////////////////////////////////////////
// A lightweight API client for interacting with the Gateway API.
export class GatewayClient {
static GATEWAY_API_BASE_URL = "https://gateway-api-testnet.circle.com/v1";
// Identifiers used for supported blockchains
// See https://developers.circle.com/cctp/supported-domains
static DOMAINS = {
ethereum: 0,
mainnet: 0,
sepolia: 0,
avalanche: 1,
avalancheFuji: 1,
base: 6,
baseSepolia: 6,
};
// Human-readable names for the supported blockchains, by domain
static CHAINS = {
0: "Ethereum",
1: "Avalanche",
6: "Base",
};
// Gets info about supported chains and contracts
async info() {
return this.#get("/info");
}
// Checks balances for a given depositor for the given domains. If no domains
// are specified, it defaults to all supported domains.
async balances(token, depositor, domains) {
if (!domains)
domains = Object.keys(GatewayClient.CHAINS).map((d) => parseInt(d));
return this.#post("/balances", {
token,
sources: domains.map((domain) => ({ depositor, domain })),
});
}
// Sends burn intents to the API to retrieve an attestation
async transfer(body) {
return this.#post("/transfer", body);
}
// Private method to do a GET request to the Gateway API
async #get(path) {
const url = GatewayClient.GATEWAY_API_BASE_URL + path;
const response = await fetch(url);
return response.json();
}
// Private method to do a POST request to the Gateway API
async #post(path, body) {
const url = GatewayClient.GATEWAY_API_BASE_URL + path;
const headers = { "Content-Type": "application/json" };
const response = await fetch(url, {
method: "POST",
headers,
// Serialize bigints as strings
body: JSON.stringify(body, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
),
});
return response.json();
}
}
Create a file called setup.js
in the project directory and add the following
code to it. This code provides a client and contract addresses on the relevant
chains for this quickstart:
import "dotenv/config";
import { createPublicClient, getContract, http, erc20Abi } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import * as chains from "viem/chains";
import { GatewayClient } from "./gateway-client.js";
import { gatewayWalletAbi, gatewayMinterAbi } from "./abis.js";
// Addresses that are needed across networks
const gatewayWalletAddress = "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
const gatewayMinterAddress = "0x0022222ABE238Cc2C7Bb1f21003F0a260052475B";
const usdcAddresses = {
sepolia: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
baseSepolia: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
avalancheFuji: "0x5425890298aed601595a70ab815c96711a31bc65",
};
// Sets up a client and contracts for the given chain and account
function setup(chainName, account) {
const chain = chains[chainName];
const client = createPublicClient({
chain,
account,
// Use the flashblocks-aware RPC for Base Sepolia, otherwise use the default RPC
transport:
chainName === "baseSepolia"
? http("https://sepolia-preconf.base.org")
: http(),
});
return {
client,
name: chain.name,
domain: GatewayClient.DOMAINS[chainName],
currency: chain.nativeCurrency.symbol,
usdc: getContract({
address: usdcAddresses[chainName],
abi: erc20Abi,
client,
}),
gatewayWallet: getContract({
address: gatewayWalletAddress,
abi: gatewayWalletAbi,
client,
}),
gatewayMinter: getContract({
address: gatewayMinterAddress,
abi: gatewayMinterAbi,
client,
}),
};
}
// Create an account from the private key set in .env
export const account = privateKeyToAccount(process.env.PRIVATE_KEY);
console.log(`Using account: ${account.address}`);
// Set up clients and contracts for each chain
export const ethereum = setup("sepolia", account);
export const base = setup("baseSepolia", account);
export const avalanche = setup("avalancheFuji", account);
To interact with Gateway, you need USDC and testnet native tokens in your wallet on each chain that you deposit from. You also need testnet native tokens on the destination chain to make the call to the Gateway Minter contract. Use the Circle Faucet to get 10 USDC on the Avalanche Fuji and Ethereum Sepolia testnets.
Use the following faucets to get testnet native tokens in your wallet:
In this step, you deposit USDC into the Gateway Wallet contract on Avalanche Fuji and Ethereum Sepolia, creating a unified crosschain balance for your address in Gateway.
Create a file called deposit.js
in the project directory and add the following
code to it. This code deposits USDC into the Gateway Wallet contracts.
Note that for the transfer, the code is not calling the ERC-20 transfer
function, as doing so would result in a loss of funds. Instead, the code first
calls the approve
function on the USDC contract to allow the Gateway Wallet
contract to transfer USDC on behalf of your account. Then the code calls the
deposit
function on the Gateway Wallet contract to move the USDC.
import { account, ethereum, base, avalanche } from "./setup.js";
const DEPOSIT_AMOUNT = 10_000000n; // 10 USDC
// Deposit into the GatewayWallet contract on all chains
for (const chain of [ethereum, base, avalanche]) {
// Get the wallet's current USDC balance
console.log(`Checking USDC balance on ${chain.name}...`);
const balance = await chain.usdc.read.balanceOf([account.address]);
// Ensure the balance is sufficient for the deposit
if (balance < DEPOSIT_AMOUNT) {
console.error(`Insufficient USDC balance on ${chain.name}!`);
console.error("Please top up at https://faucet.circle.com.");
process.exit(1);
}
// Attempt to approve and deposit USDC into the GatewayWallet contract, and
// handle the error if the waallet does not have enough funds to pay for gas
try {
// Approve the GatewayWallet contract for the wallet's USDC
console.log("Approving the GatewayWallet contract for USDC...");
const approvalTx = await chain.usdc.write.approve([
chain.gatewayWallet.address,
DEPOSIT_AMOUNT,
]);
await chain.client.waitForTransactionReceipt({ hash: approvalTx });
console.log("Done! Transaction hash:", approvalTx);
// Deposit USDC into the GatewayWallet contract
console.log("Depositing USDC into the GatewayWallet contract...");
const depositTx = await chain.gatewayWallet.write.deposit([
chain.usdc.address,
DEPOSIT_AMOUNT,
]);
await chain.client.waitForTransactionReceipt({ hash: depositTx });
console.log("Done! Transaction hash:", depositTx);
} catch (error) {
if (error.details.includes("insufficient funds")) {
// If there wasn't enough for gas, log an error message and exit
console.error(
`The wallet does not have enough ${chain.currency} to pay for gas on ${chain.name}!`,
);
console.error(`Please top up using a faucet.`);
} else {
// Log any other errors for debugging
console.error(error);
}
process.exit(1);
}
}
Run the deposit.js
script to make the deposits. Note that if you don't have
enough USDC or testnet native tokens, the code returns an error message.
node deposit.js
Note: You must wait for finality on both chains for the unified balance to be updated. On Avalanche, finality is reached immediately. On Ethereum, you may need to wait up to 20 minutes for finality.
In this step, you create a burn intent, submit it to the API and receive an attestation, which you can use to mint USDC on Base Sepolia. Funds are burned from the crosschain balance on Avalanche and Base.
Create a file called transfer.js
in the project directory and add the
following code to it. This code performs checks that the API supports the chains
that you want to interact with, and verifies that your balance on the source
chains is enough to cover the transfer.
import { account, ethereum, base, avalanche } from "./setup.js";
import { GatewayClient } from "./gateway-client.js";
import { burnIntent, burnIntentTypedData } from "./typed-data.js";
// Initialize a lightweight API client for interacting with Gateway
const gatewayClient = new GatewayClient();
// Check the info endpoint to confirm which chains are supported
// Not necessary for the transfer, but useful information
console.log("Fetching Gateway API info...");
const info = await gatewayClient.info();
for (const domain of info.domains) {
console.log(
` - ${domain.chain} ${domain.network}`,
`(wallet: ${"walletContract" in domain}, minter: ${"minterContract" in domain})`,
);
}
// Check the account's balances with the Gateway API
console.log(`Checking balances...`);
const { balances } = await gatewayClient.balances("USDC", account.address);
for (const balance of balances) {
console.log(
` - ${GatewayClient.CHAINS[balance.domain]}:`,
`${balance.balance} USDC`,
);
}
// These are the amounts we intent on transferring from each chain we deposited on
const fromEthereumAmount = 2;
const fromAvalancheAmount = 3;
// Check to see if Gateway has picked up the Avalanche deposit yet
// Since Avalanche has instant finality, this should be quick
const avalancheBalance = balances.find(
(b) => b.domain === GatewayClient.DOMAINS.avalanche,
).balance;
if (parseFloat(avalancheBalance) < fromAvalancheAmount) {
console.error(
"Gateway deposit not yet picked up on Avalanche, wait until finalization",
);
process.exit(1);
} else {
console.error("Gateway deposit picked up on Avalanche!");
}
// Check to see if Gateway has picked up the Ethereum deposit yet
// Ethereum takes about 20 minutes to finalize blocks, so you may need to wait a bit
const ethereumBalance = balances.find(
(b) => b.domain === GatewayClient.DOMAINS.ethereum,
).balance;
if (parseFloat(ethereumBalance) < fromEthereumAmount) {
console.error(
"Gateway deposit not yet picked up on Ethereum, wait until finalization",
);
process.exit(1);
} else {
console.error("Gateway deposit picked up on Ethereum!");
}
// Construct the burn intents
console.log("Constructing burn intent set...");
const burnIntents = [
burnIntent({
account,
from: ethereum,
to: base,
amount: fromEthereumAmount,
recipient: account.address,
}),
burnIntent({
account,
from: avalanche,
to: base,
amount: fromAvalancheAmount,
recipient: account.address,
}),
];
// Sign the burn intents
console.log("Signing burn intents...");
const request = await Promise.all(
burnIntents.map(async (intent) => {
const typedData = burnIntentTypedData(intent);
const signature = await account.signTypedData(typedData);
return { burnIntent: typedData.message, signature };
}),
);
Add the following code to transfer.js
to submit the burn intents to the
Gateway in exchange for an attestation:
// Request the attestation
console.log("Requesting attestation from Gateway API...");
const start = performance.now();
const response = await gatewayClient.transfer(request);
const end = performance.now();
if (response.success === false) {
console.error("Error from Gateway API:", response.message);
process.exit(1);
}
console.log(
"Received attestation from Gateway API in",
(end - start).toFixed(2),
"ms",
);
Add the following code to transfer.js
to mint USDC on Base Sepolia using the
attestation:
// Mint the funds on Base
console.log("Minting funds on Base...");
const { attestation, signature } = response;
const mintTx = await base.gatewayMinter.write.gatewayMint([
attestation,
signature,
]);
await base.client.waitForTransactionReceipt({ hash: mintTx });
console.log("Done! Transaction hash:", mintTx);
process.exit(0);
Run the transfer.js
script to perform the USDC transfer from the crosschain
balance to Base.
node transfer.js