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