rc, poll for
an attestation, then receive and mint on Sui. After mint, a follow-up
`0x2::coin::send_funds` folds the minted Coin into the recipient's
[SIP-58 address balance](https://docs.sui.io/onchain-finance/asset-custody/address-balances).
A successful run prints transaction hashes and a completion message in the
console.
### 3.1. Approve USDC on Arc
Grant approval for the
[`TokenMessengerV2` contract](/cctp/references/contract-addresses) to withdraw
USDC from your Arc wallet. This allows the contract to burn USDC when you
initiate the transfer.
```ts TypeScript theme={null}
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
Call `depositForBurn` 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](/cctp/concepts/fees) allowed for the transfer
* **Finality threshold**: `2000` for a
[Standard Transfer](/cctp/concepts/finality-and-block-confirmations#standard-transfer-attestation-times)
```ts TypeScript theme={null}
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/messages`](/api-reference/cctp/all/get-messages-v2)
API endpoint to retrieve the attestation.
* Pass `ARC_DOMAIN` for the `sourceDomain` path parameter, using the
[CCTP domain](/cctp/concepts/supported-chains-and-domains#domain-identifiers)
for Arc Testnet (26).
* Pass the Arc transaction hash returned by `burnUSDC` as `transactionHash`.
```ts TypeScript theme={null}
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:
1. Calls `receive_message` on `MessageTransmitterV2`
2. Calls `prepare_mint` on `TokenMessengerMinterV2`
3. Calls `handler::mint` on the `StablecoinHandler`
4. Calls `complete_mint` to finish the mint
CCTP mints a `Coin<USDC>` and `public_transfer`s 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.
```ts TypeScript theme={null}
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 created `Coin<USDC>` and call
`0x2::coin::send_funds` so the recipient holds USDC as an address balance.
```ts TypeScript theme={null}
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 an `index.ts` file in your project directory and paste the full script
following.
```ts index.ts expandable theme={null}
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 Shell theme={null}
npm run start
```
When the transfer finishes, the console logs a completion message and the
relevant transaction hashes. Successful output looks similar to the following:
```bash Shell theme={null}
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).
```
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.
<Note>
**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.
</Note>
</Tab>
</Tabs>
# CCTP Aptos packages and interfaces
Source: https://developers.circle.com/cctp/references/aptos-packages
Move packages and function interfaces for CCTP on Aptos
CCTP on Aptos is distributed across four Move packages:
`TokenMessengerMinterV2`, `MessageTransmitterV2`, `StablecoinHandler`, and
`CctpExtensions`. Because Move uses static dispatch, contracts cannot call each
other dynamically at runtime. Instead, these functions return receipt structs
(`BurnReceipt` and `MintReceipt`) that act as hot potatoes: they have no
abilities (they cannot be dropped,프로젝트로 돌아가기