On Sui, CCTP V2 burn and mint are multi-step programmable transactions. Each
flow calls
TokenMessengerMinterV2, MessageTransmitterV2, and the
StablecoinHandler in one transaction. Package and State object IDs differ from
CCTP V1 on Sui.These examples treat Sui USDC as a
SIP-58 address balance:
burn redeems with 0x2::coin::redeem_funds, and after mint a follow-up
0x2::coin::send_funds folds the public_transfer’d Coin into the recipient
address balance. Omit these two transactions if you’re not using SIP-58
balances.- Sui to Arc
- Arc to Sui
This quickstart demonstrates how to transfer USDC from Sui Testnet to Arc
Testnet using CCTP. You use the
Create a Then, update the The When the transfer finishes, the console logs a completion message and the
relevant transaction hashes. Successful output looks similar to the following:Attestation polling can take several minutes depending on network conditions and
the finality threshold you chose. The script retries every 2 seconds with no
timeout, so allow the process to continue while Iris prepares the attestation.
@mysten/sui SDK to burn USDC on
Sui, and viem to mint USDC on Arc Testnet. When you
finish, you will have executed a full burn-attest-mint flow.You should be comfortable using a terminal and Node.js. Familiarity with Sui
programmable transactions and basic EVM usage helps you follow and adapt the
script. Examples use Arc Testnet as the destination, but you can use any
supported EVM blockchain.This script uses
SuiGrpcClient, which requires a Sui gRPC endpoint (not
JSON-RPC). The default fullnode URL below serves gRPC on port 443.Prerequisites
Before you begin this tutorial, ensure you have:- Installed Node.js v22.6+
- Prepared a Sui Testnet wallet with the private key available (
suiprivkey1…)- Funded your account with testnet SUI for transaction fees
- Funded your account with Sui Testnet USDC from the
Circle Faucet as an
address balance
(SIP-58). If USDC is only held as Coin objects, deposit it first with
0x2::coin::send_funds.
- Prepared an EVM wallet with the private key available for Arc Testnet
- Added the Arc Testnet network to your wallet (network details)
- Funded your wallet with Arc Testnet ETH for gas fees
Step 1: Set up the project
This step shows you how to prepare your project and environment.1.1. Set up your development environment
Create a new directory, initialize and set up a new Node.js project, and install the required dependencies:Shell
mkdir cctp-sui-to-arc
cd cctp-sui-to-arc
npm init -y
npm pkg set type=module
npm pkg set scripts.start="node --env-file=.env --import=tsx index.ts"
npm install @mysten/sui viem
npm install --save-dev tsx typescript @types/node
1.2. Initialize and configure the project
This step is optional. It helps prevent missing types in your IDE or editor.
tsconfig.json file:npx tsc --init
tsconfig.json file:cat <<'EOF' > tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["node"]
}
}
EOF
1.3. Configure environment variables
Open.env in your editor and add:EVM_PRIVATE_KEY=YOUR_EVM_PRIVATE_KEY
SUI_PRIVATE_KEY=YOUR_SUI_PRIVATE_KEY
EVM_PRIVATE_KEYis the private key for the EVM wallet used to receive USDC on Arc Testnet.SUI_PRIVATE_KEYis the Sui Testnet private key (suiprivkey1…) used to sign the burn transaction.
Open
.env in your editor rather than writing values with shell commands, and
add .env to your .gitignore. This prevents credentials from leaking into
your shell history or version control.npm run start command loads variables from .env using Node.js native
env-file support.This example uses one or more private keys for local testing. In production,
use a secure key management solution and never expose or share private keys.
Step 2: Configure the script
Define the Sui and Arc Testnet parameters, then configure the wallet clients for each chain.2.1. Define configuration constants
The script predefines the Sui CCTP V2 package and State object IDs, the ArcMessageTransmitterV2 address, the transfer amount, and the CCTP domain IDs.TypeScript
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
import {
createPublicClient,
createWalletClient,
http,
parseAbi,
type Address,
type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
type Attestation = {
message: string;
attestation: string;
};
const SUI_DOMAIN = 8; // Source domain ID for Sui Testnet
const ARC_DOMAIN = 26; // Destination domain ID for Arc Testnet
const ARC_MESSAGE_TRANSMITTER =
"0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275" as Address;
const IRIS_API_URL = "https://iris-api-sandbox.circle.com";
/** Sandbox Sui CCTP V2 package and State object IDs. */
const CCTP_V2_CONFIG = {
mtV2PackageId:
"0xe9678cd42a81886e18e21361088071f275105636a4d3751e1d7f255211f73bbe",
mtV2StateId:
"0xfee3a2b47f9ef2de2405fc63d79194307945f8ea768815cfc46083bc20fbe6ed",
tmmV2PackageId:
"0x267d3c0cb776eace2840f27e4d33da9c6d952f9749403f1fe6579f4962ed3c64",
tmmV2StateId:
"0x72cb55cd14d01e6361386d6ea93eecda9fa1efc3c202b8dd69c1e4683e4c0ca0",
handlerPackageId:
"0xbe8479044396a45e07de2c7f14789c35b8338406eebc53b2527da56839a91561",
handlerStateId:
"0xfbd9c0517c4f0e1817445ee2be598806e3c392a465d7e3d773d1055f3f0eed32",
usdcPackageId:
"0xa1ec7fc00a6f40db9693ad1415d0c193ad3906494428cf252621037bd7117e29",
treasuryId:
"0x7170137d4a6431bf83351ac025baf462909bffe2877d87716374fb42b9629ebe",
};
const USDC_TYPE = `${CCTP_V2_CONFIG.usdcPackageId}::usdc::USDC`;
const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals
const MAX_FEE = 0n;
const MIN_FINALITY_THRESHOLD = 2_000; // Standard Transfer
SUI_DOMAIN is the source domain used when requesting the attestation. The
destination domain for Arc Testnet is 26. Sui Testnet USDC uses 6 decimals, so
1_000_000 represents 1 USDC.2.2. Set up wallet clients
The EVM clients connect to Arc Testnet withviem. The Sui client uses
SuiGrpcClient on Testnet and signs transactions with an Ed25519 keypair
decoded from SUI_PRIVATE_KEY.TypeScript
const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
const suiPrivateKey = process.env.SUI_PRIVATE_KEY;
if (!evmPrivateKey || !suiPrivateKey) {
throw new Error("Set EVM_PRIVATE_KEY and SUI_PRIVATE_KEY in .env");
}
const account = privateKeyToAccount(evmPrivateKey as Hex);
const walletClient = createWalletClient({
account,
chain: arcTestnet,
transport: http(),
});
const publicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
const suiClient = new SuiGrpcClient({
network: "testnet",
baseUrl: "https://fullnode.testnet.sui.io:443",
});
const suiSigner = Ed25519Keypair.fromSecretKey(
decodeSuiPrivateKey(suiPrivateKey).secretKey,
);
const messageTransmitterAbi = parseAbi([
"function receiveMessage(bytes message, bytes attestation) returns (bool success)",
]);
2.3. Format the Arc recipient for Sui
Suideposit_for_burn accepts the mint recipient as a 32-byte address. An EVM
address is converted by left-padding it with zeros.TypeScript
function padAddressToBytes32(address: string): Hex {
return `0x${address.replace(/^0x/, "").padStart(64, "0").toLowerCase()}` as Hex;
}
Step 3: Implement the transfer logic
This step implements the core transfer logic: burn on Sui, poll for an attestation, then mint on Arc. The Sui burn is a single programmable transaction: redeem from the SIP-58 address balance, then the CCTP Move calls.3.1. Burn USDC on Sui
Build a programmable transaction that:- Calls
0x2::coin::redeem_fundsto unwrap the burn amount from the sender’s address balance into aCoin<USDC> - Calls
deposit_for_burnonTokenMessengerMinterV2 - Calls
handler::burnon theStablecoinHandler - Calls
complete_burnto emit the CCTP message
deposit_for_burn:- Burn amount: The amount of USDC to burn in Sui subunits (6 decimals)
- Destination domain: The target blockchain for minting USDC (26 for Arc Testnet)
- Mint recipient: The EVM wallet address that receives minted USDC on Arc, left-padded to 32 bytes
- Destination caller: The zero address, allowing any caller to submit the receive transaction on Arc
- Max fee: The maximum fee allowed for the transfer
- Finality threshold:
2000for a Standard Transfer
TypeScript
async function burnUSDC() {
console.log(`Burning ${Number(AMOUNT) / 1_000_000} USDC on Sui...`);
const tx = new Transaction();
const [coin] = tx.moveCall({
target: "0x2::coin::redeem_funds",
typeArguments: [USDC_TYPE],
arguments: [tx.withdrawal({ amount: AMOUNT, type: USDC_TYPE })],
});
const [burnReceipt, returnedCoin] = tx.moveCall({
target: `${CCTP_V2_CONFIG.tmmV2PackageId}::deposit_for_burn::deposit_for_burn`,
arguments: [
coin,
tx.pure.u32(ARC_DOMAIN),
tx.pure.address(padAddressToBytes32(account.address)),
tx.pure.address("0x0"),
tx.pure.u256(MAX_FEE),
tx.pure.u32(MIN_FINALITY_THRESHOLD),
tx.pure.vector("u8", []),
tx.object(CCTP_V2_CONFIG.tmmV2StateId),
],
typeArguments: [USDC_TYPE],
});
const [completeBurnTicket] = tx.moveCall({
target: `${CCTP_V2_CONFIG.handlerPackageId}::handler::burn`,
arguments: [
tx.object(CCTP_V2_CONFIG.handlerStateId),
burnReceipt,
returnedCoin,
tx.object("0x403"), // Sui deny list
tx.object(CCTP_V2_CONFIG.treasuryId),
],
});
tx.moveCall({
target: `${CCTP_V2_CONFIG.tmmV2PackageId}::deposit_for_burn::complete_burn`,
arguments: [
completeBurnTicket,
tx.object(CCTP_V2_CONFIG.tmmV2StateId),
tx.object(CCTP_V2_CONFIG.mtV2StateId),
],
typeArguments: [
USDC_TYPE,
`${CCTP_V2_CONFIG.handlerPackageId}::handler::Auth`,
],
});
const digest = await executeTransaction(tx);
console.log(
`Deposit-for-burn confirmed: https://suiscan.xyz/testnet/tx/${digest}`,
);
return digest;
}
3.2. Retrieve attestation
Retrieve the attestation required to complete the CCTP transfer by calling Circle’s attestation API.- Call Circle’s
GET /v2/messagesAPI endpoint to retrieve the attestation. - Pass
SUI_DOMAINfor thesourceDomainpath parameter, using the CCTP domain for Sui (8). - Pass the Sui transaction digest returned by
burnUSDCastransactionHash. Sui digests are base58 and must not include a0xprefix.
TypeScript
async function retrieveAttestation(
transactionHash: string,
): Promise<Attestation> {
const url = `${IRIS_API_URL}/v2/messages/${SUI_DOMAIN}?transactionHash=${transactionHash}`;
console.log(`Polling Iris V2 for attestation: ${url}`);
while (true) {
const response = await fetch(url);
if (!response.ok && response.status !== 404) {
throw new Error(`Iris V2 returned HTTP ${response.status}`);
}
if (response.ok) {
const data = (await response.json()) as {
messages?: Array<{ message?: string; attestation?: string }>;
};
const result = data.messages?.[0];
if (
result?.message &&
result.attestation &&
result.attestation !== "PENDING" &&
result.message !== "0x"
) {
return {
message: result.message,
attestation: result.attestation,
};
}
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
3.3. Mint USDC on Arc
Call thereceiveMessage function
from the MessageTransmitterV2 contract
deployed on Arc Testnet.- Pass the signed attestation and message bytes as parameters.
- The contract verifies the attestation and mints USDC to the recipient encoded in the CCTP message.
TypeScript
async function mintUSDC({ message, attestation }: Attestation) {
console.log("Receiving the message on Arc...");
const receiveTransactionHash = await walletClient.writeContract({
account,
chain: arcTestnet,
address: ARC_MESSAGE_TRANSMITTER,
abi: messageTransmitterAbi,
functionName: "receiveMessage",
args: [message as Hex, attestation as Hex],
});
const receiveReceipt = await publicClient.waitForTransactionReceipt({
hash: receiveTransactionHash,
});
if (receiveReceipt.status !== "success") {
throw new Error(
`EVM receive transaction reverted: ${receiveTransactionHash}`,
);
}
console.log(`Receive confirmed: ${receiveTransactionHash}`);
return receiveTransactionHash;
}
Step 4: Full script
Create anindex.ts file in your project directory and paste the full script
following.index.ts
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
import {
createPublicClient,
createWalletClient,
http,
parseAbi,
type Address,
type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
type Attestation = {
message: string;
attestation: string;
};
const SUI_DOMAIN = 8;
const ARC_DOMAIN = 26;
const ARC_MESSAGE_TRANSMITTER =
"0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275" as Address;
const IRIS_API_URL = "https://iris-api-sandbox.circle.com";
const CCTP_V2_CONFIG = {
mtV2PackageId:
"0xe9678cd42a81886e18e21361088071f275105636a4d3751e1d7f255211f73bbe",
mtV2StateId:
"0xfee3a2b47f9ef2de2405fc63d79194307945f8ea768815cfc46083bc20fbe6ed",
tmmV2PackageId:
"0x267d3c0cb776eace2840f27e4d33da9c6d952f9749403f1fe6579f4962ed3c64",
tmmV2StateId:
"0x72cb55cd14d01e6361386d6ea93eecda9fa1efc3c202b8dd69c1e4683e4c0ca0",
handlerPackageId:
"0xbe8479044396a45e07de2c7f14789c35b8338406eebc53b2527da56839a91561",
handlerStateId:
"0xfbd9c0517c4f0e1817445ee2be598806e3c392a465d7e3d773d1055f3f0eed32",
usdcPackageId:
"0xa1ec7fc00a6f40db9693ad1415d0c193ad3906494428cf252621037bd7117e29",
treasuryId:
"0x7170137d4a6431bf83351ac025baf462909bffe2877d87716374fb42b9629ebe",
};
const USDC_TYPE = `${CCTP_V2_CONFIG.usdcPackageId}::usdc::USDC`;
const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals
const MAX_FEE = 0n;
const MIN_FINALITY_THRESHOLD = 2_000; // Standard Transfer
const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
const suiPrivateKey = process.env.SUI_PRIVATE_KEY;
if (!evmPrivateKey || !suiPrivateKey) {
throw new Error("Set EVM_PRIVATE_KEY and SUI_PRIVATE_KEY in .env");
}
const account = privateKeyToAccount(evmPrivateKey as Hex);
const walletClient = createWalletClient({
account,
chain: arcTestnet,
transport: http(),
});
const publicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
const suiClient = new SuiGrpcClient({
network: "testnet",
baseUrl: "https://fullnode.testnet.sui.io:443",
});
const suiSigner = Ed25519Keypair.fromSecretKey(
decodeSuiPrivateKey(suiPrivateKey).secretKey,
);
const messageTransmitterAbi = parseAbi([
"function receiveMessage(bytes message, bytes attestation) returns (bool success)",
]);
function padAddressToBytes32(address: string): Hex {
return `0x${address.replace(/^0x/, "").padStart(64, "0").toLowerCase()}` as Hex;
}
async function executeTransaction(transaction: Transaction) {
const res = await suiClient.signAndExecuteTransaction({
signer: suiSigner,
transaction,
include: { effects: true, events: true },
});
const body =
(
res as {
Transaction?: {
digest?: string;
status?: { success?: boolean; error?: string | null };
};
}
).Transaction ??
(
res as {
FailedTransaction?: {
digest?: string;
status?: { success?: boolean; error?: string | null };
};
}
).FailedTransaction ??
{};
const digest = body.digest ?? "";
await suiClient.waitForTransaction({ digest });
if (!body.status?.success) {
console.dir(res, { depth: null, colors: true });
throw new Error(`Transaction failed! ${body.status?.error ?? ""}`);
}
return digest;
}
async function burnUSDC() {
console.log(`Burning ${Number(AMOUNT) / 1_000_000} USDC on Sui...`);
const tx = new Transaction();
const [coin] = tx.moveCall({
target: "0x2::coin::redeem_funds",
typeArguments: [USDC_TYPE],
arguments: [tx.withdrawal({ amount: AMOUNT, type: USDC_TYPE })],
});
const [burnReceipt, returnedCoin] = tx.moveCall({
target: `${CCTP_V2_CONFIG.tmmV2PackageId}::deposit_for_burn::deposit_for_burn`,
arguments: [
coin,
tx.pure.u32(ARC_DOMAIN),
tx.pure.address(padAddressToBytes32(account.address)),
tx.pure.address("0x0"),
tx.pure.u256(MAX_FEE),
tx.pure.u32(MIN_FINALITY_THRESHOLD),
tx.pure.vector("u8", []),
tx.object(CCTP_V2_CONFIG.tmmV2StateId),
],
typeArguments: [USDC_TYPE],
});
const [completeBurnTicket] = tx.moveCall({
target: `${CCTP_V2_CONFIG.handlerPackageId}::handler::burn`,
arguments: [
tx.object(CCTP_V2_CONFIG.handlerStateId),
burnReceipt,
returnedCoin,
tx.object("0x403"),
tx.object(CCTP_V2_CONFIG.treasuryId),
],
});
tx.moveCall({
target: `${CCTP_V2_CONFIG.tmmV2PackageId}::deposit_for_burn::complete_burn`,
arguments: [
completeBurnTicket,
tx.object(CCTP_V2_CONFIG.tmmV2StateId),
tx.object(CCTP_V2_CONFIG.mtV2StateId),
],
typeArguments: [
USDC_TYPE,
`${CCTP_V2_CONFIG.handlerPackageId}::handler::Auth`,
],
});
const digest = await executeTransaction(tx);
console.log(
`Deposit-for-burn confirmed: https://suiscan.xyz/testnet/tx/${digest}`,
);
return digest;
}
async function retrieveAttestation(
transactionHash: string,
): Promise<Attestation> {
const url = `${IRIS_API_URL}/v2/messages/${SUI_DOMAIN}?transactionHash=${transactionHash}`;
console.log(`Polling Iris V2 for attestation: ${url}`);
while (true) {
const response = await fetch(url);
if (!response.ok && response.status !== 404) {
throw new Error(`Iris V2 returned HTTP ${response.status}`);
}
if (response.ok) {
const data = (await response.json()) as {
messages?: Array<{ message?: string; attestation?: string }>;
};
const result = data.messages?.[0];
if (
result?.message &&
result.attestation &&
result.attestation !== "PENDING" &&
result.message !== "0x"
) {
return {
message: result.message,
attestation: result.attestation,
};
}
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
async function mintUSDC({ message, attestation }: Attestation) {
console.log("Receiving the message on Arc...");
const receiveTransactionHash = await walletClient.writeContract({
account,
chain: arcTestnet,
address: ARC_MESSAGE_TRANSMITTER,
abi: messageTransmitterAbi,
functionName: "receiveMessage",
args: [message as Hex, attestation as Hex],
});
const receiveReceipt = await publicClient.waitForTransactionReceipt({
hash: receiveTransactionHash,
});
if (receiveReceipt.status !== "success") {
throw new Error(
`EVM receive transaction reverted: ${receiveTransactionHash}`,
);
}
console.log(`Receive confirmed: ${receiveTransactionHash}`);
return receiveTransactionHash;
}
export default async function main() {
const burnDigest = await burnUSDC();
const attestation = await retrieveAttestation(burnDigest);
console.log("Attestation received from Iris V2.");
const receiveTransactionHash = await mintUSDC(attestation);
console.log("USDC transfer completed.");
return {
suiTransactionDigest: burnDigest,
evmTransactionHash: receiveTransactionHash,
};
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
console.log("Starting CCTP flow...");
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
Step 5: Test the script
Run the following command to execute the script:Shell
npm run start
Shell
Starting CCTP flow...
Burning 1 USDC on Sui...
Deposit-for-burn confirmed: https://suiscan.xyz/testnet/tx/<sui-transaction-digest>
Polling Iris V2 for attestation: https://iris-api-sandbox.circle.com/v2/messages/8?transactionHash=<sui-transaction-digest>
Attestation received from Iris V2.
Receiving the message on Arc...
Receive confirmed: 0x...
USDC transfer completed.
Rate limit: The attestation service rate limit is 40 requests per second. If
you exceed this limit, the service blocks all API requests for the next five
minutes and returns an HTTP 429 (Too Many Requests) response.
This quickstart demonstrates how to transfer USDC from Arc Testnet to Sui
Testnet using CCTP. You use Create a Then, update the The When the transfer finishes, the console logs a completion message and the
relevant transaction hashes. Successful output looks similar to the following:Attestation polling can take several minutes depending on network conditions and
the finality threshold you chose. The script retries every 2 seconds with no
timeout, so allow the process to continue while Iris prepares the attestation.
viem to approve and burn USDC
on Arc, and the @mysten/sui SDK
to receive and mint USDC on Sui. When you finish, you will have executed a full
burn-attest-mint flow.You should be comfortable using a terminal and Node.js. Familiarity with basic
EVM usage and Sui programmable transactions helps you follow and adapt the
script. Examples use Arc Testnet as the source, but you can use any
supported EVM blockchain.This script uses
SuiGrpcClient, which requires a Sui gRPC endpoint (not
JSON-RPC). The default fullnode URL below serves gRPC on port 443.Prerequisites
Before you begin this tutorial, ensure you have:- Installed Node.js v22.6+
- Prepared an EVM wallet with the private key available for Arc Testnet
- Added the Arc Testnet network to your wallet (network details)
- Funded your wallet with Arc Testnet USDC for the transfer amount from the Circle Faucet
- Funded your wallet with Arc Testnet ETH for gas fees
- Prepared a Sui Testnet wallet with the private key available (
suiprivkey1…)- Funded your account with testnet SUI for the receive transaction fee
Step 1: Set up the project
This step shows you how to prepare your project and environment.1.1. Set up your development environment
Create a new directory, initialize and set up a new Node.js project, and install the required dependencies:Shell
mkdir cctp-arc-to-sui
cd cctp-arc-to-sui
npm init -y
npm pkg set type=module
npm pkg set scripts.start="node --env-file=.env --import=tsx index.ts"
npm install @mysten/sui viem
npm install --save-dev tsx typescript @types/node
1.2. Initialize and configure the project
This step is optional. It helps prevent missing types in your IDE or editor.
tsconfig.json file:npx tsc --init
tsconfig.json file:cat <<'EOF' > tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["node"]
}
}
EOF
1.3. Configure environment variables
Open.env in your editor and add:EVM_PRIVATE_KEY=YOUR_EVM_PRIVATE_KEY
SUI_PRIVATE_KEY=YOUR_SUI_PRIVATE_KEY
EVM_PRIVATE_KEYis the private key for the EVM wallet used to burn USDC on Arc Testnet.SUI_PRIVATE_KEYis the Sui Testnet private key (suiprivkey1…) for the account that receives the minted USDC and signs the receive transaction.
Open
.env in your editor rather than writing values with shell commands, and
add .env to your .gitignore. This prevents credentials from leaking into
your shell history or version control.npm run start command loads variables from .env using Node.js native
env-file support.This example uses one or more private keys for local testing. In production,
use a secure key management solution and never expose or share private keys.
Step 2: Configure the script
Define the contract addresses, transfer amount, finality settings, and wallet clients for Arc Testnet and Sui Testnet.2.1. Define configuration constants
The script predefines the Arc USDC and TokenMessengerV2 addresses, the Sui CCTP V2 package and State object IDs, and the CCTP domain IDs.TypeScript
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
import { normalizeSuiAddress } from "@mysten/sui/utils";
import {
createPublicClient,
createWalletClient,
http,
parseAbi,
type Address,
type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
type Attestation = {
message: string;
attestation: string;
};
const SUI_DOMAIN = 8; // Destination domain ID for Sui Testnet
const ARC_DOMAIN = 26; // Source domain ID for Arc Testnet
const ARC_USDC = "0x3600000000000000000000000000000000000000" as Address;
const ARC_TOKEN_MESSENGER =
"0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA" as Address;
const IRIS_API_URL = "https://iris-api-sandbox.circle.com";
/** Sandbox Sui CCTP V2 package and State object IDs. */
const CCTP_V2_CONFIG = {
mtV2PackageId:
"0xe9678cd42a81886e18e21361088071f275105636a4d3751e1d7f255211f73bbe",
mtV2StateId:
"0xfee3a2b47f9ef2de2405fc63d79194307945f8ea768815cfc46083bc20fbe6ed",
tmmV2PackageId:
"0x267d3c0cb776eace2840f27e4d33da9c6d952f9749403f1fe6579f4962ed3c64",
tmmV2StateId:
"0x72cb55cd14d01e6361386d6ea93eecda9fa1efc3c202b8dd69c1e4683e4c0ca0",
handlerPackageId:
"0xbe8479044396a45e07de2c7f14789c35b8338406eebc53b2527da56839a91561",
handlerStateId:
"0xfbd9c0517c4f0e1817445ee2be598806e3c392a465d7e3d773d1055f3f0eed32",
usdcPackageId:
"0xa1ec7fc00a6f40db9693ad1415d0c193ad3906494428cf252621037bd7117e29",
treasuryId:
"0x7170137d4a6431bf83351ac025baf462909bffe2877d87716374fb42b9629ebe",
};
const USDC_TYPE = `${CCTP_V2_CONFIG.usdcPackageId}::usdc::USDC`;
const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals
const MAX_FEE = 0n;
const MIN_FINALITY_THRESHOLD = 2_000; // Standard Transfer
ARC_DOMAIN is the source domain used when requesting the attestation. The
destination domain for Sui Testnet is 8. This example uses a
Standard Transfer
(minFinalityThreshold of 2000) with a maximum fee of zero.2.2. Set up wallet clients
The wallet clients useviem for Arc Testnet and @mysten/sui for Sui Testnet.
The Sui keypair signs the programmable transaction that receives and mints USDC.TypeScript
const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
const suiPrivateKey = process.env.SUI_PRIVATE_KEY;
if (!evmPrivateKey || !suiPrivateKey) {
throw new Error("Set EVM_PRIVATE_KEY and SUI_PRIVATE_KEY in .env");
}
const account = privateKeyToAccount(evmPrivateKey as Hex);
const walletClient = createWalletClient({
account,
chain: arcTestnet,
transport: http(),
});
const publicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
const suiClient = new SuiGrpcClient({
network: "testnet",
baseUrl: "https://fullnode.testnet.sui.io:443",
});
const suiSigner = Ed25519Keypair.fromSecretKey(
decodeSuiPrivateKey(suiPrivateKey).secretKey,
);
const usdcAbi = parseAbi([
"function approve(address spender, uint256 amount) returns (bool)",
]);
const tokenMessengerV2Abi = parseAbi([
"function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold)",
]);
2.3. Format the Sui recipient for Arc
depositForBurn accepts the destination recipient as bytes32. A Sui address
is already a 32-byte value, so the script passes it as a hexadecimal value to
viem.TypeScript
function padAddressToBytes32(address: string): Hex {
return `0x${address.replace(/^0x/, "").padStart(64, "0").toLowerCase()}` as Hex;
}
Step 3: Implement the transfer logic
This step implements the core transfer logic: approve and burn on Arc, poll for an attestation, then receive and mint on Sui. After mint, a follow-up0x2::coin::send_funds folds the minted Coin into the recipient’s
SIP-58 address balance.
A successful run prints transaction hashes and a completion message in the
console.3.1. Approve USDC on Arc
Grant approval for theTokenMessengerV2 contract to withdraw
USDC from your Arc wallet. This allows the contract to burn USDC when you
initiate the transfer.TypeScript
async function approveUSDC() {
console.log("Approving Arc USDC transfer...");
const approvalHash = await walletClient.writeContract({
account,
chain: arcTestnet,
address: ARC_USDC,
abi: usdcAbi,
functionName: "approve",
args: [ARC_TOKEN_MESSENGER, AMOUNT],
});
const approvalReceipt = await publicClient.waitForTransactionReceipt({
hash: approvalHash,
});
if (approvalReceipt.status !== "success") {
throw new Error(`USDC approval reverted: ${approvalHash}`);
}
console.log(`Approval confirmed: ${approvalHash}`);
}
3.2. Burn USDC on Arc
CalldepositForBurn to burn USDC on Arc. You specify the following parameters:- Burn amount: The amount of USDC to burn in Arc subunits (6 decimals)
- Destination domain: The target blockchain for minting USDC (8 for Sui Testnet)
- Mint recipient: The Sui account address that receives minted USDC
- Destination caller: The zero address, allowing any caller to submit the receive transaction on Sui
- Burn token: The Arc Testnet USDC address
- Max fee: The maximum fee allowed for the transfer
- Finality threshold:
2000for a Standard Transfer
TypeScript
async function burnUSDC() {
console.log(`Burning ${Number(AMOUNT) / 1_000_000} USDC on Arc...`);
const burnHash = await walletClient.writeContract({
account,
chain: arcTestnet,
address: ARC_TOKEN_MESSENGER,
abi: tokenMessengerV2Abi,
functionName: "depositForBurn",
args: [
AMOUNT,
SUI_DOMAIN,
padAddressToBytes32(suiSigner.toSuiAddress()),
ARC_USDC,
`0x${"0".repeat(64)}` as Hex,
MAX_FEE,
MIN_FINALITY_THRESHOLD,
],
});
const burnReceipt = await publicClient.waitForTransactionReceipt({
hash: burnHash,
});
if (burnReceipt.status !== "success") {
throw new Error(`Deposit-for-burn reverted: ${burnHash}`);
}
console.log(`Deposit-for-burn confirmed: ${burnHash}`);
return burnHash;
}
3.3. Retrieve attestation
Retrieve the attestation required to complete the CCTP transfer by calling Circle’s attestation API.- Call Circle’s
GET /v2/messagesAPI endpoint to retrieve the attestation. - Pass
ARC_DOMAINfor thesourceDomainpath parameter, using the CCTP domain for Arc Testnet (26). - Pass the Arc transaction hash returned by
burnUSDCastransactionHash.
TypeScript
async function retrieveAttestation(
transactionHash: string,
): Promise<Attestation> {
const url = `${IRIS_API_URL}/v2/messages/${ARC_DOMAIN}?transactionHash=${transactionHash}`;
console.log(`Polling Iris V2 for attestation: ${url}`);
while (true) {
const response = await fetch(url);
if (!response.ok && response.status !== 404) {
throw new Error(`Iris V2 returned HTTP ${response.status}`);
}
if (response.ok) {
const data = (await response.json()) as {
messages?: Array<{ message?: string; attestation?: string }>;
};
const result = data.messages?.[0];
if (
result?.message &&
result.attestation &&
result.attestation !== "PENDING" &&
result.message !== "0x"
) {
return {
message: result.message,
attestation: result.attestation,
};
}
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
3.4. Receive and mint USDC on Sui
Build a programmable transaction that:- Calls
receive_messageonMessageTransmitterV2 - Calls
prepare_mintonTokenMessengerMinterV2 - Calls
handler::minton theStablecoinHandler - Calls
complete_mintto finish the mint
Coin<USDC> and public_transfers it to the recipient. Folding
that Coin into an address balance requires a separate transaction (step 3.5),
because the recipient cannot sign send_funds inside the CCTP receive PTB.TypeScript
async function receiveMessage({ message, attestation }: Attestation) {
console.log("Receiving the message on Sui...");
const messageBytes = hexToBytes(message);
const attestationBytes = hexToBytes(attestation);
const tx = new Transaction();
const [receipt] = tx.moveCall({
target: `${CCTP_V2_CONFIG.mtV2PackageId}::receive_message::receive_message`,
arguments: [
tx.pure.vector("u8", Array.from(messageBytes)),
tx.pure.vector("u8", Array.from(attestationBytes)),
tx.object(CCTP_V2_CONFIG.mtV2StateId),
],
});
const [mintReceipt] = tx.moveCall({
target: `${CCTP_V2_CONFIG.tmmV2PackageId}::handle_receive_message::prepare_mint`,
arguments: [
receipt,
tx.object(CCTP_V2_CONFIG.tmmV2StateId),
tx.object("0x6"), // Sui clock
],
typeArguments: [USDC_TYPE],
});
const [completeMintTicket] = tx.moveCall({
target: `${CCTP_V2_CONFIG.handlerPackageId}::handler::mint`,
arguments: [
tx.object(CCTP_V2_CONFIG.handlerStateId),
mintReceipt,
tx.object(CCTP_V2_CONFIG.tmmV2StateId),
tx.object(CCTP_V2_CONFIG.treasuryId),
tx.object("0x403"), // Sui deny list
],
});
tx.moveCall({
target: `${CCTP_V2_CONFIG.tmmV2PackageId}::handle_receive_message::complete_mint`,
arguments: [
completeMintTicket,
tx.object(CCTP_V2_CONFIG.tmmV2StateId),
tx.object(CCTP_V2_CONFIG.mtV2StateId),
],
typeArguments: [
USDC_TYPE,
`${CCTP_V2_CONFIG.handlerPackageId}::handler::Auth`,
],
});
const digest = await executeTransaction(tx);
console.log(
`Receive-message confirmed: https://suiscan.xyz/testnet/tx/${digest}`,
);
return digest;
}
3.5. Fold minted USDC into an address balance
After receive confirms, look up the createdCoin<USDC> and call
0x2::coin::send_funds so the recipient holds USDC as an address balance.TypeScript
async function foldUsdcIntoAddressBalance(
coinObjectId: string,
recipient: string,
) {
console.log(
"Folding minted USDC into address balance (SIP-58 send_funds)...",
);
const tx = new Transaction();
tx.moveCall({
target: "0x2::coin::send_funds",
typeArguments: [USDC_TYPE],
arguments: [tx.object(coinObjectId), tx.pure.address(recipient)],
});
const digest = await executeTransaction(tx);
console.log(`send_funds confirmed: https://suiscan.xyz/testnet/tx/${digest}`);
return digest;
}
async function findCreatedUsdcCoin(digest: string, owner: string) {
const result = await suiClient.core.getTransaction({
digest,
include: { effects: true, objectTypes: true },
});
const tx =
result.$kind === "Transaction"
? result.Transaction
: result.FailedTransaction;
if (!tx?.effects || !tx.objectTypes) {
throw new Error(`Missing effects/objectTypes for ${digest}`);
}
const normalizedOwner = normalizeSuiAddress(owner);
const created = tx.effects.changedObjects.filter((obj) => {
if (obj.idOperation !== "Created") return false;
const type = tx.objectTypes![obj.objectId] ?? "";
if (!type.includes("::coin::Coin<") || !type.includes("::usdc::USDC")) {
return false;
}
if (obj.outputOwner?.$kind !== "AddressOwner") return false;
return (
normalizeSuiAddress(obj.outputOwner.AddressOwner) === normalizedOwner
);
});
if (created.length === 0) {
throw new Error(
`No Coin<USDC> created for ${owner} in https://suiscan.xyz/testnet/tx/${digest}`,
);
}
return created[0].objectId;
}
Step 4: Full script
Create anindex.ts file in your project directory and paste the full script
following.index.ts
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
import { normalizeSuiAddress } from "@mysten/sui/utils";
import {
createPublicClient,
createWalletClient,
http,
parseAbi,
type Address,
type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
type Attestation = {
message: string;
attestation: string;
};
const SUI_DOMAIN = 8;
const ARC_DOMAIN = 26;
const ARC_USDC = "0x3600000000000000000000000000000000000000" as Address;
const ARC_TOKEN_MESSENGER =
"0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA" as Address;
const IRIS_API_URL = "https://iris-api-sandbox.circle.com";
const CCTP_V2_CONFIG = {
mtV2PackageId:
"0xe9678cd42a81886e18e21361088071f275105636a4d3751e1d7f255211f73bbe",
mtV2StateId:
"0xfee3a2b47f9ef2de2405fc63d79194307945f8ea768815cfc46083bc20fbe6ed",
tmmV2PackageId:
"0x267d3c0cb776eace2840f27e4d33da9c6d952f9749403f1fe6579f4962ed3c64",
tmmV2StateId:
"0x72cb55cd14d01e6361386d6ea93eecda9fa1efc3c202b8dd69c1e4683e4c0ca0",
handlerPackageId:
"0xbe8479044396a45e07de2c7f14789c35b8338406eebc53b2527da56839a91561",
handlerStateId:
"0xfbd9c0517c4f0e1817445ee2be598806e3c392a465d7e3d773d1055f3f0eed32",
usdcPackageId:
"0xa1ec7fc00a6f40db9693ad1415d0c193ad3906494428cf252621037bd7117e29",
treasuryId:
"0x7170137d4a6431bf83351ac025baf462909bffe2877d87716374fb42b9629ebe",
};
const USDC_TYPE = `${CCTP_V2_CONFIG.usdcPackageId}::usdc::USDC`;
const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals
const MAX_FEE = 0n;
const MIN_FINALITY_THRESHOLD = 2_000; // Standard Transfer
const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
const suiPrivateKey = process.env.SUI_PRIVATE_KEY;
if (!evmPrivateKey || !suiPrivateKey) {
throw new Error("Set EVM_PRIVATE_KEY and SUI_PRIVATE_KEY in .env");
}
const account = privateKeyToAccount(evmPrivateKey as Hex);
const walletClient = createWalletClient({
account,
chain: arcTestnet,
transport: http(),
});
const publicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
const suiClient = new SuiGrpcClient({
network: "testnet",
baseUrl: "https://fullnode.testnet.sui.io:443",
});
const suiSigner = Ed25519Keypair.fromSecretKey(
decodeSuiPrivateKey(suiPrivateKey).secretKey,
);
const usdcAbi = parseAbi([
"function approve(address spender, uint256 amount) returns (bool)",
]);
const tokenMessengerV2Abi = parseAbi([
"function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold)",
]);
function padAddressToBytes32(address: string): Hex {
return `0x${address.replace(/^0x/, "").padStart(64, "0").toLowerCase()}` as Hex;
}
function hexToBytes(hex: string): Uint8Array {
const normalized = hex.startsWith("0x") ? hex.slice(2) : hex;
return Uint8Array.from(Buffer.from(normalized, "hex"));
}
async function executeTransaction(transaction: Transaction) {
const res = await suiClient.signAndExecuteTransaction({
signer: suiSigner,
transaction,
include: { effects: true, events: true },
});
const body =
(
res as {
Transaction?: {
digest?: string;
status?: { success?: boolean; error?: string | null };
};
}
).Transaction ??
(
res as {
FailedTransaction?: {
digest?: string;
status?: { success?: boolean; error?: string | null };
};
}
).FailedTransaction ??
{};
const digest = body.digest ?? "";
await suiClient.waitForTransaction({ digest });
if (!body.status?.success) {
console.dir(res, { depth: null, colors: true });
throw new Error(`Transaction failed! ${body.status?.error ?? ""}`);
}
return digest;
}
async function approveUSDC() {
console.log("Approving Arc USDC transfer...");
const approvalHash = await walletClient.writeContract({
account,
chain: arcTestnet,
address: ARC_USDC,
abi: usdcAbi,
functionName: "approve",
args: [ARC_TOKEN_MESSENGER, AMOUNT],
});
const approvalReceipt = await publicClient.waitForTransactionReceipt({
hash: approvalHash,
});
if (approvalReceipt.status !== "success") {
throw new Error(`USDC approval reverted: ${approvalHash}`);
}
console.log(`Approval confirmed: ${approvalHash}`);
}
async function burnUSDC() {
console.log(`Burning ${Number(AMOUNT) / 1_000_000} USDC on Arc...`);
const burnHash = await walletClient.writeContract({
account,
chain: arcTestnet,
address: ARC_TOKEN_MESSENGER,
abi: tokenMessengerV2Abi,
functionName: "depositForBurn",
args: [
AMOUNT,
SUI_DOMAIN,
padAddressToBytes32(suiSigner.toSuiAddress()),
ARC_USDC,
`0x${"0".repeat(64)}` as Hex,
MAX_FEE,
MIN_FINALITY_THRESHOLD,
],
});
const burnReceipt = await publicClient.waitForTransactionReceipt({
hash: burnHash,
});
if (burnReceipt.status !== "success") {
throw new Error(`Deposit-for-burn reverted: ${burnHash}`);
}
console.log(`Deposit-for-burn confirmed: ${burnHash}`);
return burnHash;
}
async function retrieveAttestation(
transactionHash: string,
): Promise<Attestation> {
const url = `${IRIS_API_URL}/v2/messages/${ARC_DOMAIN}?transactionHash=${transactionHash}`;
console.log(`Polling Iris V2 for attestation: ${url}`);
while (true) {
const response = await fetch(url);
if (!response.ok && response.status !== 404) {
throw new Error(`Iris V2 returned HTTP ${response.status}`);
}
if (response.ok) {
const data = (await response.json()) as {
messages?: Array<{ message?: string; attestation?: string }>;
};
const result = data.messages?.[0];
if (
result?.message &&
result.attestation &&
result.attestation !== "PENDING" &&
result.message !== "0x"
) {
return {
message: result.message,
attestation: result.attestation,
};
}
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
}
async function receiveMessage({ message, attestation }: Attestation) {
console.log("Receiving the message on Sui...");
const messageBytes = hexToBytes(message);
const attestationBytes = hexToBytes(attestation);
const tx = new Transaction();
const [receipt] = tx.moveCall({
target: `${CCTP_V2_CONFIG.mtV2PackageId}::receive_message::receive_message`,
arguments: [
tx.pure.vector("u8", Array.from(messageBytes)),
tx.pure.vector("u8", Array.from(attestationBytes)),
tx.object(CCTP_V2_CONFIG.mtV2StateId),
],
});
const [mintReceipt] = tx.moveCall({
target: `${CCTP_V2_CONFIG.tmmV2PackageId}::handle_receive_message::prepare_mint`,
arguments: [
receipt,
tx.object(CCTP_V2_CONFIG.tmmV2StateId),
tx.object("0x6"),
],
typeArguments: [USDC_TYPE],
});
const [completeMintTicket] = tx.moveCall({
target: `${CCTP_V2_CONFIG.handlerPackageId}::handler::mint`,
arguments: [
tx.object(CCTP_V2_CONFIG.handlerStateId),
mintReceipt,
tx.object(CCTP_V2_CONFIG.tmmV2StateId),
tx.object(CCTP_V2_CONFIG.treasuryId),
tx.object("0x403"),
],
});
tx.moveCall({
target: `${CCTP_V2_CONFIG.tmmV2PackageId}::handle_receive_message::complete_mint`,
arguments: [
completeMintTicket,
tx.object(CCTP_V2_CONFIG.tmmV2StateId),
tx.object(CCTP_V2_CONFIG.mtV2StateId),
],
typeArguments: [
USDC_TYPE,
`${CCTP_V2_CONFIG.handlerPackageId}::handler::Auth`,
],
});
const digest = await executeTransaction(tx);
console.log(
`Receive-message confirmed: https://suiscan.xyz/testnet/tx/${digest}`,
);
return digest;
}
async function foldUsdcIntoAddressBalance(
coinObjectId: string,
recipient: string,
) {
console.log(
"Folding minted USDC into address balance (SIP-58 send_funds)...",
);
const tx = new Transaction();
tx.moveCall({
target: "0x2::coin::send_funds",
typeArguments: [USDC_TYPE],
arguments: [tx.object(coinObjectId), tx.pure.address(recipient)],
});
const digest = await executeTransaction(tx);
console.log(`send_funds confirmed: https://suiscan.xyz/testnet/tx/${digest}`);
return digest;
}
async function findCreatedUsdcCoin(digest: string, owner: string) {
const result = await suiClient.core.getTransaction({
digest,
include: { effects: true, objectTypes: true },
});
const tx =
result.$kind === "Transaction"
? result.Transaction
: result.FailedTransaction;
if (!tx?.effects || !tx.objectTypes) {
throw new Error(`Missing effects/objectTypes for ${digest}`);
}
const normalizedOwner = normalizeSuiAddress(owner);
const created = tx.effects.changedObjects.filter((obj) => {
if (obj.idOperation !== "Created") return false;
const type = tx.objectTypes![obj.objectId] ?? "";
if (!type.includes("::coin::Coin<") || !type.includes("::usdc::USDC")) {
return false;
}
if (obj.outputOwner?.$kind !== "AddressOwner") return false;
return (
normalizeSuiAddress(obj.outputOwner.AddressOwner) === normalizedOwner
);
});
if (created.length === 0) {
throw new Error(
`No Coin<USDC> created for ${owner} in https://suiscan.xyz/testnet/tx/${digest}`,
);
}
return created[0].objectId;
}
export default async function main() {
await approveUSDC();
const burnTransactionHash = await burnUSDC();
const attestation = await retrieveAttestation(burnTransactionHash);
console.log("Attestation received from Iris V2.");
const receiveDigest = await receiveMessage(attestation);
const recipient = suiSigner.toSuiAddress();
const mintedCoinId = await findCreatedUsdcCoin(receiveDigest, recipient);
const foldDigest = await foldUsdcIntoAddressBalance(mintedCoinId, recipient);
console.log("USDC transfer completed (address balance).");
return {
evmTransactionHash: burnTransactionHash,
suiTransactionDigest: receiveDigest,
foldDigest,
};
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
console.log("Starting CCTP flow...");
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
Step 5: Test the script
Run the following command to execute the script:Shell
npm run start
Shell
Starting CCTP flow...
Approving Arc USDC transfer...
Approval confirmed: 0x...
Burning 1 USDC on Arc...
Deposit-for-burn confirmed: 0x...
Polling Iris V2 for attestation: https://iris-api-sandbox.circle.com/v2/messages/26?transactionHash=<arc-transaction-hash>
Attestation received from Iris V2.
Receiving the message on Sui...
Receive-message confirmed: https://suiscan.xyz/testnet/tx/<sui-transaction-digest>
Folding minted USDC into address balance (SIP-58 send_funds)...
send_funds confirmed: https://suiscan.xyz/testnet/tx/<sui-fold-digest>
USDC transfer completed (address balance).
Rate limit: The attestation service rate limit is 40 requests per second. If
you exceed this limit, the service blocks all API requests for the next five
minutes and returns an HTTP 429 (Too Many Requests) response.