Circle USYC 백서

Circle USYC

원문

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

60e68d49ce305f8c7fcd0c90b035496" } ], "nonce": "0", "metadata": { "exchangeRates": { "feeTokenUsd": "1.000000", "destinationTokenUsd": "1.000000" } } } ``` The `signedQuote` is the blob you submit onchain (truncated here for brevity). The `feeTotalAmount` is the total fee in `feeToken` minor units, and `items` breaks it down per fee type. This quote uses `BLOCK_NUMBER` expiry, so it stays valid until `expiresAtBlock` on the source blockchain. <Tip> Quotes expire after a short, per-blockchain window (see [Quote expiry](/cctp/concepts/upfront-fee-quotes#quote-expiry)). Request the quote immediately before you submit the transfer, and submit before it expires. </Tip> ### Step 2: Calculate the approval amount To pay the fee in USDC, approve the `TokenMessengerWithFees` contract to spend both the transfer amount and the quoted fee. The contract pulls the transfer amount for the burn and the fee for collection. ```typescript theme={null} import { formatUnits } from "viem"; // Total fee from the quote response const feeAmount = BigInt(quote.feeTotalAmount); // Approve the transfer amount (from Step 1) plus the fee const approvalAmount = transferAmount + feeAmount; const { decimals, symbol } = sourceUsdc; console.log("Transfer:", formatUnits(transferAmount, decimals), symbol); console.log("Fee:", formatUnits(feeAmount, decimals), symbol); console.log("Approval:", formatUnits(approvalAmount, decimals), symbol); ``` Because you pay the fee upfront, the recipient receives the full `transferAmount` on Arc Testnet. The fee is charged separately from the amount minted. ### Step 3: Approve the USDC transfer Grant approval for the [`TokenMessengerWithFees`](/cctp/references/contract-addresses#tokenmessengerwithfees-testnet) contract on Base Sepolia to spend USDC from your wallet. Approve at least the `approvalAmount` calculated in Step 2. Registering the `usdc` token on the wallet client gives you the `client.token.approve` action, so you don't have to supply the ERC-20 ABI yourself: ```typescript theme={null} import { createPublicClient, createWalletClient, http } from "viem"; import { baseSepolia } from "viem/chains"; import { privateKeyToAccount } from "viem/accounts"; import { usdc } from "viem/tokens"; // Configuration // TokenMessengerWithFees has this same address on every supported testnet // blockchain, including Base Sepolia. const TESTNET_TOKEN_MESSENGER_WITH_FEES = "0x8745D906D67C346E5eb1aEEED38Eb87F34DF0C0A"; // Set up wallet and public clients const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); const client = createWalletClient({ chain: baseSepolia, tokens: [usdc], transport: http(), account, }); const publicClient = createPublicClient({ chain: baseSepolia, transport: http(), }); async function approveUSDC(amount: bigint) { console.log("Approving USDC..."); const approveTx = await client.token.approve({ amount, spender: TESTNET_TOKEN_MESSENGER_WITH_FEES, token: "usdc", }); console.log("USDC Approval Tx:", approveTx); // Wait for the approval to be mined before you submit the burn await publicClient.waitForTransactionReceipt({ hash: approveTx }); return approveTx; } // Approve the transfer amount plus the fee (from Step 2) await approveUSDC(approvalAmount); ``` <Note> `sendTransaction` resolves once the node accepts the transaction, not once it's mined. Without the wait, a burn submitted immediately after the approval can revert because the allowance isn't set yet. </Note> ### Step 4: Sign and broadcast a `depositForBurnWithFees` transaction Call `depositForBurnWithFees` on the `TokenMessengerWithFees` contract, passing a `QuoteClaim` that bundles the `signedQuote` with your `refundAddress`. The contract infers Fast Transfer from the `PRE_FINALITY` fee and adds the default `cctp-forward` hook because the quote includes a `FORWARD` fee, so you don't pass `maxFee`, `minFinalityThreshold`, or hook data. This example uses the `QuoteClaim` overload, which takes the opaque `signedQuote` bytes directly. The contract also exposes a `DecodedQuoteClaim` overload for advanced cases where you decode the quote in your own code; see the full ABI in [Contract interfaces](/cctp/references/contract-interfaces#tokenmessengerwithfees). <Note> Use `transferAmount` (not the amount plus fee) for the `amount` parameter. The fee is collected separately, and the recipient receives the full `transferAmount`. </Note> ```typescript theme={null} import { pad, encodeFunctionData } from "viem"; // Configuration const ARC_TESTNET_DOMAIN = 26; // This example mints back to your own wallet. Set this to any address you want // to receive the USDC on the destination blockchain. const DESTINATION_ADDRESS = account.address; // Convert the recipient address to bytes32 const mintRecipientBytes32 = pad(DESTINATION_ADDRESS, { size: 32 }); // Use your own address to attribute any future fee refund to const refundAddress = account.address; async function depositForBurnWithFees() { console.log("Burning USDC on Base Sepolia with upfront fees..."); const burnTx = await client.sendTransaction({ to: TESTNET_TOKEN_MESSENGER_WITH_FEES, data: encodeFunctionData({ abi: [ { type: "function", name: "depositForBurnWithFees", stateMutability: "payable", inputs: [ { name: "amount", type: "uint256" }, { name: "destinationDomain", type: "uint32" }, { name: "mintRecipient", type: "bytes32" }, { name: "burnToken", type: "address" }, { name: "destinationCaller", type: "bytes32" }, { name: "claim", type: "tuple", components: [ { name: "signedQuote", type: "bytes" }, { name: "refundAddress", type: "address" }, ], }, ], outputs: [], }, ], functionName: "depositForBurnWithFees", args: [ transferAmount, ARC_TESTNET_DOMAIN, mintRecipientBytes32, sourceUsdc.address, pad("0x", { size: 32 }), // destinationCaller (empty = any caller) { signedQuote: quote.signedQuote, refundAddress }, ], }), }); console.log("Burn Tx:", burnTx); // Wait for the burn to be mined before you poll for the attestation await publicClient.waitForTransactionReceipt({ hash: burnTx }); return burnTx; } const burnTx = await depositForBurnWithFees(); ``` <Accordion title="Pay the fee in the native gas token"> To pay the fee in the source blockchain's native gas token instead of USDC, omit `feeToken` from the quote request in Step 1. Then approve only the `transferAmount` in Step 3, and attach the quoted fee as the transaction's native value in Step 4: ```typescript theme={null} const burnTx = await client.sendTransaction({ to: TESTNET_TOKEN_MESSENGER_WITH_FEES, value: feeAmount, // pay the quoted fee in native currency data: encodeFunctionData({ // ...same abi and args as above }), }); ``` </Accordion> Because the quote binds to your call arguments, a mismatch reverts onchain and costs you gas. Request the quote with the same amount, destination domain, and burn token you submit onchain so the values match. Once the burn transaction is confirmed on Base Sepolia, Circle attests the burn and the Forwarding Service mints USDC on Arc Testnet. Because the fee is paid upfront, the recipient receives the full `transferAmount`. ### Step 5: Verify the mint transaction After the burn is confirmed, query the Circle Iris API to retrieve the forwarding details. The API returns the `forwardTxHash`, which is the mint transaction hash on the destination blockchain. The attestation may take time to become available, so poll the API until the message is ready: ```typescript theme={null} // Configuration const BASE_SEPOLIA_DOMAIN = 6; process.stdout.write("Waiting for attestation..."); let mintTx; while (!mintTx) { const messageResponse = await fetch( `https://iris-api-sandbox.circle.com/v2/messages/${BASE_SEPOLIA_DOMAIN}?transactionHash=${burnTx}`, ); const data = await messageResponse.json(); if (data.messages?.[0]?.forwardTxHash) { mintTx = data.messages[0].forwardTxHash; console.log(); // New line after dots } else { process.stdout.write("."); await new Promise((resolve) => setTimeout(resolve, 2000)); } } console.log("Mint Tx:", mintTx); ``` ## Full example code The following is a complete example of how to transfer USDC from Base Sepolia to Arc Testnet with upfront fees. Remember to set the `PRIVATE_KEY` environment variable. ```typescript script.ts expandable theme={null} import { createPublicClient, createWalletClient, http, encodeFunctionData, formatUnits, pad, parseUnits, } from "viem"; import { baseSepolia } from "viem/chains"; import { privateKeyToAccount } from "viem/accounts"; import { usdc } from "viem/tokens"; // Validate environment variables if (!process.env.PRIVATE_KEY) { throw new Error("PRIVATE_KEY environment variable is required"); } // Configuration const sourceUsdc = usdc(baseSepolia.id); // TokenMessengerWithFees has this same address on every supported testnet // blockchain, including Base Sepolia. Mainnet uses a different shared address. // See: https://developers.circle.com/cctp/references/contract-addresses#tokenmessengerwithfees-testnet const TESTNET_TOKEN_MESSENGER_WITH_FEES = "0x8745D906D67C346E5eb1aEEED38Eb87F34DF0C0A"; const BASE_SEPOLIA_DOMAIN = 6; const ARC_TESTNET_DOMAIN = 26; // The quote binds to this amount, so the same value is used for the burn const transferAmount = parseUnits("10", sourceUsdc.decimals); // Set up wallet and public clients const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); const client = createWalletClient({ chain: baseSepolia, tokens: [usdc], transport: http(), account, }); const publicClient = createPublicClient({ chain: baseSepolia, transport: http(), }); // This example mints back to your own wallet. Set this to any address you want // to receive the USDC on the destination blockchain. const DESTINATION_ADDRESS = account.address; async function main() { console.log("Wallet address:", account.address); console.log("Destination address:", DESTINATION_ADDRESS); // Step 1: Request a signed quote console.log("\nStep 1: Requesting a signed quote..."); const quoteResponse = await fetch( `https://iris-api-sandbox.circle.com/v2/quote/burn/usdc/${BASE_SEPOLIA_DOMAIN}/${ARC_TESTNET_DOMAIN}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: transferAmount.toString(), // minor units feeToken: sourceUsdc.address, // pay the fee in USDC requests: [ { type: "FORWARD" }, // Omit if the source blockchain doesn't support Fast Transfer { type: "PRE_FINALITY" }, ], }), }, ); const quote = await quoteResponse.json(); console.log("Quote:", JSON.stringify(quote, null, 2)); // Step 2: Calculate amounts console.log("\nStep 2: Calculating amounts..."); const feeAmount = BigInt(quote.feeTotalAmount); const approvalAmount = transferAmount + feeAmount; const { decimals, symbol } = sourceUsdc; console.log("Transfer:", formatUnits(transferAmount, decimals), symbol); console.log("Fee:", formatUnits(feeAmount, decimals), symbol); console.log("Approval:", formatUnits(approvalAmount, decimals), symbol); // Step 3: Approve USDC (transfer amount + fee) console.log("\nStep 3: Approving USDC..."); const approveTx = await client.token.approve({ amount: approvalAmount, spender: TESTNET_TOKEN_MESSENGER_WITH_FEES, token: "usdc", }); console.log("Approval Tx:", approveTx); // Wait for the approval to be mined so the burn doesn't revert await publicClient.waitForTransactionReceipt({ hash: approveTx }); // Step 4: Burn USDC with upfront fees console.log("\nStep 4: Burning USDC with upfront fees..."); const burnTx = await client.sendTransaction({ to: TESTNET_TOKEN_MESSENGER_WITH_FEES, data: encodeFunctionData({ abi: [ { type: "function", name: "depositForBurnWithFees", stateMutability: "payable", inputs: [ { name: "amount", type: "uint256" }, { name: "destinationDomain", type: "uint32" }, { name: "mintRecipient", type: "bytes32" }, { name: "burnToken", type: "address" }, { name: "destinationCaller", type: "bytes32" }, { name: "claim", type: "tuple", components: [ { name: "signedQuote", type: "bytes" }, { name: "refundAddress", type: "address" }, ], }, ], outputs: [], }, ], functionName: "depositForBurnWithFees", args: [ transferAmount, ARC_TESTNET_DOMAIN, pad(DESTINATION_ADDRESS, { size: 32 }), sourceUsdc.address, pad("0x", { size: 32 }), // destinationCaller (empty = any caller) { signedQuote: quote.signedQuote, refundAddress: account.address }, ], }), }); console.log("Burn Tx:", burnTx); // Wait for the burn to be mined before polling for the attestation await publicClient.waitForTransactionReceipt({ hash: burnTx }); console.log( "\nTransfer initiated. The Forwarding Service will mint the full amount on Arc Testnet.", ); // Step 5: Verify the mint transaction console.log("\nStep 5: Verifying mint transaction..."); process.stdout.write("Waiting for attestation..."); let mintTx; while (!mintTx) { const messageResponse = await fetch( `https://iris-api-sandbox.circle.com/v2/messages/${BASE_SEPOLIA_DOMAIN}?transactionHash=${burnTx}`, ); const data = await messageResponse.json(); if (data.messages?.[0]?.forwardTxHash) { mintTx = data.messages[0].forwardTxHash; console.log(); // New line after dots } else { process.stdout.write("."); await new Promise((resolve) => setTimeout(resolve, 2000)); } } console.log("Mint Tx:", mintTx); } main().catch(console.error); ``` Run the script: ```bash theme={null} npx tsx script.ts ``` ## Pay fees upfront with a custom hook The preceding steps use `depositForBurnWithFees`, which builds the default `cctp-forward` hook for you. To attach your own hook data on the destination blockchain, alongside forwarding, use `depositForBurnWithHookAndFees` and provide the hook data explicitly. <Warning> The quote binds to your call parameters, including the hook data. Request the quote with the same hook data you submit onchain. A quote requested with custom hook data is rejected by `depositForBurnWithFees` (which applies the default hook), and a quote requested without custom hook data is rejected by `depositForBurnWithHookAndFees`. Always pair the entry point with a matching quote. </Warning> ### Build the hook data Hook data is a sequence of hooks, each with a fixed 32-byte header followed by its payload: | Bytes | Type | Data | | - | - | - | | 0-23 | `bytes24` | Hook name (magic) | | 24-27 | `uint32` | Version | | 28-31 | `uint32` | Payload length in bytes (`n`) | | 32+ | `bytes` | Payload (`n` bytes) | The first hook must be the `cctp-forward` hook so that forwarding runs. You append your own hook as another complete, correctly framed entry: appending raw, unframed bytes causes the destination to reject the hook data. For the `cctp-forward` layout, see [Forwarding Service hook format](/cctp/concepts/forwarding-service#hook-format). ```typescript theme={null} import { concat, stringToHex, toHex } from "viem"; // cctp-forward hook, framed with the shared 32-byte header const forwardHook = concat([ stringToHex("cctp-forward", { size: 24 }), // hook name toHex(1, { size: 4 }), // version toHex(0, { size: 4 }), // payload length (no payload) ]); // A placeholder custom hook, framed the same way. The destination ignores it // unless it recognizes the hook name; replace it with a hook your destination // logic supports. const customPayload = "0xdeadbeef"; const customHook = concat([ stringToHex("my-custom-hook", { size: 24 }), // hook name toHex(1, { size: 4 }), // version toHex(4, { size: 4 }), // payload length (4 bytes) customPayload, ]); const hookData = concat([forwardHook, customHook]); console.log("Hook data:", hookData); ``` ### Request a matching quote Include the same `hookData` in the `FORWARD` request's `params` so the quote binds to it: ```typescript theme={null} const quoteResponse = await fetch( "https://iris-api-sandbox.circle.com/v2/quote/burn/usdc/6/26", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: transferAmount.toString(), feeToken: sourceUsdc.address, requests: [ { type: "FORWARD", params: { hookData } }, { type: "PRE_FINALITY" }, ], }), }, ); const quote = await quoteResponse.json(); console.log("Quote:", JSON.stringify(quote, null, 2)); ``` ### Submit the transfer with `depositForBurnWithHookAndFees` Pass the same `hookData` as a parameter, along with the `QuoteClaim`: ```typescript theme={null} import { pad, encodeFunctionData } from "viem"; const burnTx = await client.sendTransaction({ to: TESTNET_TOKEN_MESSENGER_WITH_FEES, data: encodeFunctionData({ abi: [ { type: "function", name: "depositForBurnWithHookAndFees", stateMutability: "payable", inputs: [ { name: "amount", type: "uint256" }, { name: "destinationDomain", type: "uint32" }, { name: "mintRecipient", type: "bytes32" }, { name: "burnToken", type: "address" }, { name: "destinationCaller", type: "bytes32" }, { name: "hookData", type: "bytes" }, { name: "claim", type: "tuple", components: [ { name: "signedQuote", type: "bytes" }, { name: "refundAddress", type: "address" }, ], }, ], outputs: [], }, ], functionName: "depositForBurnWithHookAndFees", args: [ transferAmount, ARC_TESTNET_DOMAIN, pad(DESTINATION_ADDRESS, { size: 32 }), sourceUsdc.address, pad("0x", { size: 32 }), // destinationCaller (empty = any caller) hookData, { signedQuote: quote.signedQuote, refundAddress: account.address }, ], }), }); console.log("Burn Tx:", burnTx); ``` Approve USDC and verify the mint the same way as [Step 3](#step-3-approve-the-usdc-transfer) and [Step 5](#step-5-verify-the-mint-transaction). # Troubleshoot CCTP transfers Source: https://developers.circle.com/cctp/howtos/troubleshoot-transfers Diagnose and resolve stuck or failed crosschain USDC transfers This guide helps you diagnose and resolve issues with CCTP transfers that appear stuck or fail to complete. A CCTP transfer involves three stages, and problems can occur at any point in the process. ## Transfer stages A CCTP transfer consists of three stages: 1. **Burn**: USDC is burned on the source blockchain 2. **Attestation**: Circle's Attestation Service observes the burn and signs a message 3. **Mint**: The signed attestation is submitted to mint USDC on the destination blockchain If your transfer appears stuck, first identify which stage has the issue. ## Identify where your transfer is stuck Use the following steps to determine the current state of your transfer: <Steps> <Step title="Check the burn transaction"> Verify the burn transaction succeeded on the source blockchain using a block explorer. If the transaction failed or is pending, the issue is at the burn stage. </Step> <Step title="Query the attestation API"> Call the [`GET /v2/messages`](/api-reference/cctp/all/get-messages-v2) endpoint with your transaction hash. Interpret the response: * **404 response**: The attestation service hasn't observed the burn yet. This is normal and expected. See [Why 404 responses are expected](/cctp/howtos/resolve-stuck-attestation#why-404-responses-are-expected). * **Empty `messages` array**: The burn exists but hasn't been processed yet. * **Status `pending`**: The burn is awaiting block confirmations. * **Status `complete` with attestation**: The attestation is ready. If your transfer is stuck, the issue is at the mint stage. </Step> <Step title="Check the mint transaction"> If you have an attestation but your destination wallet doesn't have the USDC, either: * The mint transaction was never submitted * The mint transaction failed Check your destination blockchain for any failed `receiveMessage` transactions. </Step> </Steps> ## Common issues and solutions | Issue | Cause | Solution | | - | - | - | | 404 persists for extended time | Burn transaction may have failed or is still confirming | Verify burn succeeded on block explorer. I
프로젝트로 돌아가기