hreshold**: Determines whether it's a
[Fast Transfer](/cctp/concepts/finality-and-block-confirmations#fast-transfer-attestation-times)
(1000 or less) or a
[Standard Transfer](/cctp/concepts/finality-and-block-confirmations#standard-transfer-attestation-times)
(2000 or more)
```ts TypeScript theme={null}
async function burnUSDC() {
console.log("Burning USDC on Ethereum Sepolia...");
const burnTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_TOKEN_MESSENGER,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "depositForBurn",
stateMutability: "nonpayable",
inputs: [
{ name: "amount", type: "uint256" },
{ name: "destinationDomain", type: "uint32" },
{ name: "mintRecipient", type: "bytes32" },
{ name: "burnToken", type: "address" },
{ name: "destinationCaller", type: "bytes32" },
{ name: "maxFee", type: "uint256" },
{ name: "minFinalityThreshold", type: "uint32" },
],
outputs: [],
},
],
functionName: "depositForBurn",
args: [
AMOUNT,
ARC_TESTNET_DOMAIN,
DESTINATION_ADDRESS_BYTES32,
ETHEREUM_SEPOLIA_USDC,
DESTINATION_CALLER_BYTES32,
maxFee,
1000, // minFinalityThreshold (1000 or less for Fast Transfer)
],
}),
});
console.log(`Burn Tx: ${burnTx}`);
return burnTx;
}
```
### 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 the `srcDomain` argument from the
[CCTP domain](/cctp/concepts/supported-chains-and-domains#domain-identifiers)
for your source chain.
* Pass `transactionHash` from the value returned by `sendTransaction` in the
`burnUSDC` function preceding.
```ts TypeScript theme={null}
async function retrieveAttestation(transactionHash: string) {
console.log("Retrieving attestation...");
const url = `https://iris-api-sandbox.circle.com/v2/messages/${ETHEREUM_SEPOLIA_DOMAIN}?transactionHash=${transactionHash}`;
while (true) {
try {
const response = await fetch(url, { method: "GET" });
if (!response.ok) {
if (response.status !== 404) {
const text = await response.text().catch(() => "");
console.error(
"Error fetching attestation:",
`${response.status} ${response.statusText}${
text ? ` - ${text}` : ""
}`,
);
}
await new Promise((resolve) => setTimeout(resolve, 5000));
continue;
}
const data = (await response.json()) as AttestationResponse;
if (data?.messages?.[0]?.status === "complete") {
console.log("Attestation retrieved successfully!");
return data.messages[0];
}
console.log("Waiting for attestation...");
await new Promise((resolve) => setTimeout(resolve, 5000));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("Error fetching attestation:", message);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
}
```
### 3.4. Mint USDC
Call the
[`receiveMessage` function](/cctp/references/contract-interfaces#receivemessage)
from the [`MessageTransmitterV2` contract](/cctp/references/contract-addresses)
deployed on the Arc testnet to mint USDC on that destination chain.
* Pass the signed attestation and the message data as parameters.
* The function processes the attestation and mints USDC to the specified Arc
testnet wallet address.
```ts TypeScript theme={null}
async function mintUSDC(attestation: AttestationMessage) {
console.log("Minting USDC on Arc testnet...");
const mintTx = await arcClient.sendTransaction({
to: ARC_TESTNET_MESSAGE_TRANSMITTER,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "receiveMessage",
stateMutability: "nonpayable",
inputs: [
{ name: "message", type: "bytes" },
{ name: "attestation", type: "bytes" },
],
outputs: [],
},
],
functionName: "receiveMessage",
args: [
attestation.message as `0x${string}`,
attestation.attestation as `0x${string}`,
],
}),
});
console.log(`Mint Tx: ${mintTx}`);
}
```
</Tab>
</Tabs>
## Step 4: Complete script
Create a `index.ts` file in your project directory and populate it with the
complete code below for the path you want to test.
<Tabs>
<Tab title="Forwarding Service">
```ts index.ts expandable theme={null}
import {
createPublicClient,
createWalletClient,
http,
encodeFunctionData,
pad,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";
type FeeQuote = {
finalityThreshold: number;
minimumFee: number;
forwardFee: { med: number };
};
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);
const ETHEREUM_SEPOLIA_USDC = "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238";
const ETHEREUM_SEPOLIA_TOKEN_MESSENGER =
"0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa";
const DESTINATION_ADDRESS = account.address;
const AMOUNT = 1_000_000n;
const ETHEREUM_SEPOLIA_DOMAIN = 0;
const ARC_TESTNET_DOMAIN = 26;
const DESTINATION_ADDRESS_BYTES32 = pad(DESTINATION_ADDRESS, { size: 32 });
const DESTINATION_CALLER_BYTES32 = pad("0x", { size: 32 });
const FORWARDING_SERVICE_HOOK_DATA =
"0x636374702d666f72776172640000000000000000000000000000000000000000" as `0x${string}`;
const sepoliaClient = createWalletClient({
chain: sepolia,
transport: http(),
account,
});
const sepoliaPublicClient = createPublicClient({
chain: sepolia,
transport: http(),
});
async function approveUSDC(amount: bigint) {
console.log("Approving USDC transfer...");
const approveTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_USDC,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "approve",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
],
functionName: "approve",
args: [ETHEREUM_SEPOLIA_TOKEN_MESSENGER, amount],
}),
});
console.log(`USDC Approval Tx: ${approveTx}`);
await sepoliaPublicClient.waitForTransactionReceipt({ hash: approveTx });
}
async function getForwardingFeeQuote() {
const response = await fetch(
`https://iris-api-sandbox.circle.com/v2/burn/USDC/fees/${ETHEREUM_SEPOLIA_DOMAIN}/${ARC_TESTNET_DOMAIN}?forward=true`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
if (!response.ok) {
throw new Error(`Failed to fetch fees: ${await response.text()}`);
}
const fees = (await response.json()) as FeeQuote[];
const feeData = fees.find((fee) => fee.finalityThreshold === 1000);
if (!feeData) {
throw new Error("Fast-transfer forwarding fees not available");
}
return feeData;
}
async function calculateForwardingAmounts() {
const feeData = await getForwardingFeeQuote();
const forwardFee = BigInt(feeData.forwardFee.med);
const protocolFee =
(AMOUNT * BigInt(Math.round(feeData.minimumFee * 100))) / 1_000_000n;
const maxFee = forwardFee + protocolFee;
const totalAmount = AMOUNT + maxFee;
console.log("Forward fee:", Number(forwardFee) / 1_000_000, "USDC");
console.log("Protocol fee:", Number(protocolFee) / 1_000_000, "USDC");
console.log("Max fee:", Number(maxFee) / 1_000_000, "USDC");
console.log("Total to burn:", Number(totalAmount) / 1_000_000, "USDC");
return { maxFee, totalAmount };
}
async function burnUSDCWithForwarding(totalAmount: bigint, maxFee: bigint) {
console.log("Burning USDC on Ethereum Sepolia with Forwarding Service...");
const burnTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_TOKEN_MESSENGER,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "depositForBurnWithHook",
stateMutability: "nonpayable",
inputs: [
{ name: "amount", type: "uint256" },
{ name: "destinationDomain", type: "uint32" },
{ name: "mintRecipient", type: "bytes32" },
{ name: "burnToken", type: "address" },
{ name: "destinationCaller", type: "bytes32" },
{ name: "maxFee", type: "uint256" },
{ name: "minFinalityThreshold", type: "uint32" },
{ name: "hookData", type: "bytes" },
],
outputs: [],
},
],
functionName: "depositForBurnWithHook",
args: [
totalAmount,
ARC_TESTNET_DOMAIN,
DESTINATION_ADDRESS_BYTES32,
ETHEREUM_SEPOLIA_USDC,
DESTINATION_CALLER_BYTES32,
maxFee,
1000,
FORWARDING_SERVICE_HOOK_DATA,
],
}),
});
console.log(`Burn Tx: ${burnTx}`);
return burnTx;
}
async function waitForForwardedMint(transactionHash: string) {
console.log("Waiting for Forwarding Service to mint on Arc...");
while (true) {
const response = await fetch(
`https://iris-api-sandbox.circle.com/v2/messages/${ETHEREUM_SEPOLIA_DOMAIN}?transactionHash=${transactionHash}`,
{ method: "GET" },
);
if (!response.ok) {
await new Promise((resolve) => setTimeout(resolve, 5000));
continue;
}
const data = await response.json();
const forwardTxHash = data?.messages?.[0]?.forwardTxHash;
if (forwardTxHash) {
console.log(`Forwarded Mint Tx: ${forwardTxHash}`);
return forwardTxHash;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
async function main() {
console.log("Wallet address:", account.address);
// [1] Quote forwarding fees and derive the total source-chain burn amount.
const { maxFee, totalAmount } = await calculateForwardingAmounts();
// [2] Approve the total burn amount, including forwarding and protocol fees.
await approveUSDC(totalAmount);
// [3] Burn on the source chain with the forwarding hook enabled.
const burnTx = await burnUSDCWithForwarding(totalAmount, maxFee);
// [4] Poll until Iris returns the destination mint transaction hash.
await waitForForwardedMint(burnTx);
console.log("USDC transfer completed with Forwarding Service.");
}
main().catch(console.error);
```
</Tab>
<Tab title="Direct mint">
```ts index.ts expandable theme={null}
import {
createPublicClient,
createWalletClient,
http,
encodeFunctionData,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet, sepolia } from "viem/chains";
interface AttestationMessage {
message: string;
attestation: string;
status: string;
}
interface AttestationResponse {
messages: AttestationMessage[];
}
// ============ Configuration Constants ============
// Authentication
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const account = privateKeyToAccount(PRIVATE_KEY as `0x${string}`);
// Contract Addresses
const ETHEREUM_SEPOLIA_USDC = "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238";
const ETHEREUM_SEPOLIA_TOKEN_MESSENGER =
"0x8fe6b999dc680ccfdd5bf7eb0974218be2542daa";
const ARC_TESTNET_MESSAGE_TRANSMITTER =
"0xe737e5cebeeba77efe34d4aa090756590b1ce275";
// Transfer Parameters
const DESTINATION_ADDRESS = account.address; // Address to receive minted tokens on destination chain
const AMOUNT = 1_000_000n; // 1 USDC (1 USDC = 1,000,000 subunits)
const maxFee = 500n; // 0.0005 USDC (500 subunits)
// Bytes32 Formatted Parameters
const DESTINATION_ADDRESS_BYTES32 = `0x000000000000000000000000${DESTINATION_ADDRESS.slice(
2,
)}`; // Destination address in bytes32 format
const DESTINATION_CALLER_BYTES32 =
"0x0000000000000000000000000000000000000000000000000000000000000000"; // Empty bytes32 allows any address to call MessageTransmitterV2.receiveMessage()
// Chain-specific Parameters
const ETHEREUM_SEPOLIA_DOMAIN = 0; // Source domain ID for Ethereum Sepolia
const ARC_TESTNET_DOMAIN = 26; // Destination domain ID for Arc testnet
// Set up wallet clients
const sepoliaClient = createWalletClient({
chain: sepolia,
transport: http(),
account,
});
const sepoliaPublicClient = createPublicClient({
chain: sepolia,
transport: http(),
});
const arcClient = createWalletClient({
chain: arcTestnet,
transport: http(),
account,
});
// ============ CCTP Flow Functions ============
async function approveUSDC() {
console.log("Approving USDC transfer...");
const approveTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_USDC,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "approve",
stateMutability: "nonpayable",
inputs: [
{ name: "spender", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
],
functionName: "approve",
args: [ETHEREUM_SEPOLIA_TOKEN_MESSENGER, 10_000_000n], // 10 USDC allowance
}),
});
console.log(`USDC Approval Tx: ${approveTx}`);
await sepoliaPublicClient.waitForTransactionReceipt({ hash: approveTx });
}
async function burnUSDC() {
console.log("Burning USDC on Ethereum Sepolia...");
const burnTx = await sepoliaClient.sendTransaction({
to: ETHEREUM_SEPOLIA_TOKEN_MESSENGER,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "depositForBurn",
stateMutability: "nonpayable",
inputs: [
{ name: "amount", type: "uint256" },
{ name: "destinationDomain", type: "uint32" },
{ name: "mintRecipient", type: "bytes32" },
{ name: "burnToken", type: "address" },
{ name: "destinationCaller", type: "bytes32" },
{ name: "maxFee", type: "uint256" },
{ name: "minFinalityThreshold", type: "uint32" },
],
outputs: [],
},
],
functionName: "depositForBurn",
args: [
AMOUNT,
ARC_TESTNET_DOMAIN,
DESTINATION_ADDRESS_BYTES32 as `0x${string}`,
ETHEREUM_SEPOLIA_USDC,
DESTINATION_CALLER_BYTES32,
maxFee,
1000, // minFinalityThreshold (1000 or less for Fast Transfer)
],
}),
});
console.log(`Burn Tx: ${burnTx}`);
return burnTx;
}
async function retrieveAttestation(transactionHash: string) {
console.log("Retrieving attestation...");
const url = `https://iris-api-sandbox.circle.com/v2/messages/${ETHEREUM_SEPOLIA_DOMAIN}?transactionHash=${transactionHash}`;
while (true) {
try {
const response = await fetch(url, { method: "GET" });
if (!response.ok) {
if (response.status !== 404) {
const text = await response.text().catch(() => "");
console.error(
"Error fetching attestation:",
`${response.status} ${response.statusText}${
text ? ` - ${text}` : ""
}`,
);
}
await new Promise((resolve) => setTimeout(resolve, 5000));
continue;
}
const data = (await response.json()) as AttestationResponse;
if (data?.messages?.[0]?.status === "complete") {
console.log("Attestation retrieved successfully!");
return data.messages[0];
}
console.log("Waiting for attestation...");
await new Promise((resolve) => setTimeout(resolve, 5000));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("Error fetching attestation:", message);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
}
async function mintUSDC(attestation: AttestationMessage) {
console.log("Minting USDC on Arc testnet...");
const mintTx = await arcClient.sendTransaction({
to: ARC_TESTNET_MESSAGE_TRANSMITTER,
data: encodeFunctionData({
abi: [
{
type: "function",
name: "receiveMessage",
stateMutability: "nonpayable",
inputs: [
{ name: "message", type: "bytes" },
{ name: "attestation", type: "bytes" },
],
outputs: [],
},
],
functionName: "receiveMessage",
args: [
attestation.message as `0x${string}`,
attestation.attestation as `0x${string}`,
],
}),
});
console.log(`Mint Tx: ${mintTx}`);
}
// ============ Main Execution ============
async function main() {
await approveUSDC();
const burnTx = await burnUSDC();
const attestation = await retrieveAttestation(burnTx);
await mintUSDC(attestation);
console.log("USDC transfer completed.");
}
main().catch(console.error);
```
</Tab>
</Tabs>
## Step 5: Test the script
Run the following command to execute the script:
```shell Shell theme={null}
npm run start
```
Once the script runs and the transfer is finalized, a confirmation receipt is
logged in the console.
<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>
# Transfer USDC from Solana to Arc
Source: https://developers.circle.com/cctp/quickstarts/transfer-usdc-solana-to-arc
Transfer USDC from Solana to an EVM blockchain using CCTP
This guide demonstrates how to transfer USDC from Solana Devnet to Arc Testnet
using CCTP. You use the [Solana Kit](https://github.com/anza-xyz/kit) library to
interact with [Solana CCTP programs](/cctp/references/solana-programs), and viem
to mint USDC on Arc Testnet.
<Tip>
**Use [Bridge Kit](https://www.npmjs.com/package/@circle-fin/bridge-kit) to
simplify crosschain transfers with CCTP.**
This quickstart shows how to transfer USDC from to
 using a manual CCTP integration. The example is for learning
or for developers who need a manual integration.
To streamline this, use Bridge Kit to transfer USDC in just a few lines of code.
</Tip>
## Prerequisites
Before you begin, ensure that you've:
* Installed [Node.js v22.18+](https://nodejs.org/)
* Prepared a Solana wallet and have the private key array available
* Funded your Solana wallet with the following testnet tokens:
* Solana Devnet SOL (native token) from a
[public faucet](https://faucet.solana.com/)
* Solana Devnet USDC from the [Circle Faucet](https://faucet.circle.com)
* Prepared an EVM testnet wallet with the private key available
* Added Arc testnet network to your wallet
([network details](https://docs.arc.io/arc/references/connect-to-arc#wallet-setup))
* Funded your EVM wallet with Arc Testnet USDC from the
[Circle Faucet](https://faucet.circle.com) if you choose the direct mint path
below, because the destination wallet must pay gas to call `receiveMessage`
## Step 1. Set up the project
### 1.1. Create the project and install dependencies
```shell theme={null}
# Set up your directory and initialize a Node.js project
mkdir cctp-solana-transfer
cd cctp-solana-transfer
npm init -y
# Set up module type and start command
npm pkg set type=module
npm pkg set scripts.start="node --env-file=.env index.ts"
# Install runtime dependencies
npm install @solana/kit @solana-program/system @solana-program/token viem
# Install dev dependencies
npm install --save-dev @types/node typescript
```
### 1.2. Configure TypeScript (optional)
<Tip>
This step is optional. It helps prevent missing types in your IDE or editor.
</Tip>
Create a `tsconfig.json` file:
```shell theme={null}
npx tsc --init
```
Then, update the `tsconfig.json` file:
```shell theme={null}
cat <<'EOF' > tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["node"]
}
}
EOF
```
### 1.3. Set environment variables
Open `.env` in your editor and add:
```text theme={null}
SOLANA_PRIVATE_KEY=YOUR_SOLANA_PRIVATE_KEY_ARRAY
EVM_PRIVATE_KEY=YOUR_ARC_PRIVATE_KEY
```
* `SOLANA_PRIVATE_KEY` is the private key array for the Solana Devnet wallet
that signs the source-chain burn transaction.
* `EVM_PRIVATE_KEY` is used to derive the Arc recipient address. The direct-mint
path also uses this key to submit the destination mint on Arc.
<Tip>
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.
</Tip>
The `npm run start` command loads variables from `.env` using Node.js native
env-file support.
<Warning>
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.
</Warning>
## Step 2: Configure the script
Define the configuration constants for interacting with Solana and Arc Testnet.
### 2.1. Setup chains and wallets
The script predefines the program addresses, transfer amount, and other
parameters:
```ts TypeScript expandable theme={null}
// Solana Configuration
const SOLANA_RPC = "https://api.devnet.solana.com";
const SOLANA_WS = "wss://api.devnet.solana.com";
const rpc = createSolanaRpc(SOLANA_RPC);
const rpcSubscriptions = createSolanaRpcSubscriptions(SOLANA_WS);
const solanaPrivateK프로젝트로 돌아가기