Circle USYC 백서

Circle USYC

원문

bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 75쪽 중 53쪽

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,
프로젝트로 돌아가기