Use CCTP to transfer USDC with Stellar Testnet as the source or destination.Documentation Index
Fetch the complete documentation index at: https://developers.circle.com/llms.txt
Use this file to discover all available pages before exploring further.
On Stellar, USDC precision and address encoding differ from other CCTP-supported
blockchains. Before you integrate beyond these examples, read
CCTP on Stellar.
- Stellar to Arc
- Arc to Stellar
This quickstart demonstrates how to transfer USDC from Stellar Testnet to Arc
Testnet using CCTP. You use the
@stellar/stellar-sdk library to
interact with Stellar Soroban contracts, and Create a Then, update the When the transfer finishes, the console logs a completion message and the
relevant transaction hashes. Successful output looks similar to the following:Attestation polling can take several minutes depending on network conditions and
the finality threshold you chose. The script retries every 5 seconds with no
timeout, so if it appears to hang at
viem to mint
USDC on Arc Testnet. When you finish, you will have executed a full
burn-attest-mint flow.You should be comfortable using a terminal and Node.js. Familiarity with Stellar
Soroban transactions and basic EVM usage helps you follow and adapt the script.
Examples use Arc Testnet as the destination, but you can use any
supported blockchain.Prerequisites
Before you begin this tutorial, ensure you have:- Installed Node.js v22+
- Prepared an EVM wallet with the private key available for Arc Testnet
- Added the Arc Testnet network to your wallet (network details)
- Funded your wallet with Arc Testnet USDC (for gas fees) from the Circle Faucet
- Prepared a Stellar Testnet wallet with the secret key (
S...) available- Funded your Stellar wallet with testnet XLM from the Stellar Friendbot (for Soroban fees on Stellar Testnet)
- Established a USDC trustline on your Stellar account so you can hold the testnet USDC you burn
- Funded your Stellar wallet with Stellar Testnet USDC from the Circle Faucet
You can use Stellar Lab on Stellar Testnet to fund
accounts and establish USDC trustlines.
Step 1: Set up the project
This step shows you how to prepare your project and environment.1.1. Set up your development environment
Create a new directory and install the required dependencies:Shell
# Set up your directory and initialize a Node.js project
mkdir cctp-stellar-to-arc
cd cctp-stellar-to-arc
npm init -y
# Set up module type and start command
npm pkg set type=module
npm pkg set scripts.start="npx tsx --env-file=.env index.ts"
# Install runtime dependencies
npm install @stellar/stellar-sdk viem
# Install dev dependencies
npm install --save-dev typescript @types/node
1.2. Initialize and configure the project
This step is optional. It helps prevent missing types in your IDE or editor.
tsconfig.json file:npx tsc --init
tsconfig.json file:cat <<'EOF' > tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["node"]
}
}
EOF
1.3. Configure environment variables
Add your wallet keys
Open
.env in your IDE or editor and add the following. Editing the file
directly (instead of using shell redirection) helps keep credentials out of
shell history..env
STELLAR_SECRET_KEY=YOUR_STELLAR_SECRET_KEY
EVM_PRIVATE_KEY=YOUR_EVM_PRIVATE_KEY
STELLAR_SECRET_KEYis the Stellar secret key (S...) used to sign Soroban transactions on Stellar Testnet.EVM_PRIVATE_KEYis the private key for the EVM wallet you use on Arc Testnet.
This use of a private key is simplified for demonstration purposes. In
production, store and access your private key securely and never share it.
Step 2: Configure the script
Define contract addresses, amounts, and clients for Stellar Testnet and Arc Testnet.2.1. Define configuration constants
The script predefines the contract addresses, transfer amount, and maximum fee.TypeScript
import {
Address,
Contract,
Keypair,
nativeToScVal,
rpc,
TransactionBuilder,
xdr,
} from "@stellar/stellar-sdk";
import {
createPublicClient,
createWalletClient,
encodeFunctionData,
http,
pad,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
interface AttestationMessage {
message: string;
attestation: string;
status: string;
}
interface AttestationResponse {
messages: AttestationMessage[];
}
// Contract Addresses
const STELLAR_TOKEN_MESSENGER_MINTER =
"CDNG7HXAPBWICI2E3AUBP3YZWZELJLYSB6F5CC7WLDTLTHVM74SLRTHP";
const STELLAR_USDC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const ARC_MESSAGE_TRANSMITTER = "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275";
// Transfer Parameters
const AMOUNT = 10_000_000n; // 1 USDC (Stellar has 7 decimals)
const MAX_FEE = 100_000n; // 0.01 USDC in Stellar subunits (7 decimals)
// Chain-specific Parameters
const STELLAR_DOMAIN = 27; // Source domain ID for Stellar Testnet
const ARC_TESTNET_DOMAIN = 26; // Destination domain ID for Arc Testnet
// Stellar Soroban Configuration
const STELLAR_RPC_URL = "https://soroban-testnet.stellar.org";
const STELLAR_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015";
// Authentication
const stellarKeypair = Keypair.fromSecret(
process.env.STELLAR_SECRET_KEY as string,
);
2.2. Set up wallet clients
The wallet client configures the appropriate network settings usingviem. In
this example, the script connects to Arc Testnet.TypeScript
const evmAccount = privateKeyToAccount(
process.env.EVM_PRIVATE_KEY as `0x${string}`,
);
const arcWalletClient = createWalletClient({
chain: arcTestnet,
transport: http(),
account: evmAccount,
});
const arcPublicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
2.3. Add helper function
ThesubmitSorobanTx helper builds, signs, submits, and confirms a Soroban
contract transaction.TypeScript
async function submitSorobanTx(
server: rpc.Server,
contractId: string,
method: string,
args: xdr.ScVal[],
) {
const account = await server.getAccount(stellarKeypair.publicKey());
const contract = new Contract(contractId);
const tx = new TransactionBuilder(account, {
fee: "10000000",
networkPassphrase: STELLAR_NETWORK_PASSPHRASE,
})
.addOperation(contract.call(method, ...args))
.setTimeout(120)
.build();
const simulated = await server.simulateTransaction(tx);
if (rpc.Api.isSimulationError(simulated)) {
throw new Error(`Simulation failed: ${JSON.stringify(simulated)}`);
}
const prepared = rpc.assembleTransaction(tx, simulated).build();
prepared.sign(stellarKeypair);
const sendResult = await server.sendTransaction(prepared);
if (sendResult.status === "ERROR") {
throw new Error(`Send failed: ${JSON.stringify(sendResult)}`);
}
let getResult = await server.getTransaction(sendResult.hash);
while (getResult.status === "NOT_FOUND") {
await new Promise((resolve) => setTimeout(resolve, 2000));
getResult = await server.getTransaction(sendResult.hash);
}
if (getResult.status !== "SUCCESS") {
throw new Error(`Transaction failed: ${JSON.stringify(getResult)}`);
}
return sendResult.hash;
}
Step 3: Implement the transfer logic
This step implements the core transfer logic: approve and burn on Stellar, poll for an attestation, then mint on Arc. A successful run prints transaction hashes and a completion message in the console.3.1. Approve USDC on Stellar
Approve theTokenMessengerMinterV2 contract to spend your USDC. The
submitSorobanTx helper is used to submit the approve call to the Stellar USDC
contract.TypeScript
async function approveUSDC() {
console.log("Approving USDC spend on Stellar...");
const server = new rpc.Server(STELLAR_RPC_URL);
const latestLedger = await server.getLatestLedger();
const expirationLedger = latestLedger.sequence + 100_000;
const approveHash = await submitSorobanTx(server, STELLAR_USDC, "approve", [
new Address(stellarKeypair.publicKey()).toScVal(),
new Address(STELLAR_TOKEN_MESSENGER_MINTER).toScVal(),
nativeToScVal(AMOUNT, { type: "i128" }),
nativeToScVal(expirationLedger, { type: "u32" }),
]);
console.log(`Approve Tx: ${approveHash}`);
}
3.2. Burn USDC on Stellar
Calldeposit_for_burn to burn USDC on Stellar. The submitSorobanTx helper is
used to submit the burn call with the transfer parameters listed below:- Burn amount: The amount of USDC to burn (in Stellar subunits, 7 decimals)
- Destination domain: The target blockchain for minting USDC (see supported blockchains and domains)
- Mint recipient: The wallet address that receives the minted USDC on Arc
- Burn token: The contract address of the USDC token on Stellar
- Destination caller: The address on the target blockchain that may call
receiveMessage - Max fee: The maximum fee allowed for the transfer (in Stellar subunits, 7 decimals)
- Finality threshold: Determines whether it’s a Fast Transfer (1000 or less) or a Standard Transfer (2000 or more)
TypeScript
async function burnUSDC() {
console.log("Burning USDC on Stellar...");
// Bytes32 Formatted Parameters
const evmAddressBytes32 = pad(evmAccount.address);
const mintRecipient = xdr.ScVal.scvBytes(
Buffer.from(evmAddressBytes32.slice(2), "hex"),
);
const server = new rpc.Server(STELLAR_RPC_URL);
const txHash = await submitSorobanTx(
server,
STELLAR_TOKEN_MESSENGER_MINTER,
"deposit_for_burn",
[
new Address(stellarKeypair.publicKey()).toScVal(),
nativeToScVal(AMOUNT, { type: "i128" }),
nativeToScVal(ARC_TESTNET_DOMAIN, { type: "u32" }),
mintRecipient,
new Address(STELLAR_USDC).toScVal(),
xdr.ScVal.scvBytes(Buffer.alloc(32)), // destination_caller
nativeToScVal(MAX_FEE, { type: "i128" }),
nativeToScVal(1000, { type: "u32" }), // Fast Transfer finality threshold
],
);
console.log(`Burn Tx: ${txHash}`);
return txHash;
}
3.3. Retrieve attestation
Retrieve the attestation required to complete the CCTP transfer by calling Circle’s attestation API.- Call Circle’s
GET /v2/messagesAPI endpoint to retrieve the attestation. - Pass
STELLAR_DOMAINfor thesourceDomainpath parameter, using the CCTP domain for Stellar Testnet (27). - Pass
transactionHashfrom the value returned bysubmitSorobanTxwithin theburnUSDCfunction above.
TypeScript
async function retrieveAttestation(transactionHash: string) {
console.log("Retrieving attestation...");
const url = `https://iris-api-sandbox.circle.com/v2/messages/${STELLAR_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 on Arc
Call thereceiveMessage function
from the MessageTransmitterV2 contract
deployed on Arc Testnet to mint USDC on the destination blockchain.- Pass the signed attestation and the message bytes as parameters.
- The contract verifies the attestation and mints USDC to the recipient encoded in the CCTP message.
TypeScript
async function mintUSDCOnArc(attestation: AttestationMessage) {
console.log("Minting USDC on Arc Testnet...");
const hash = await arcWalletClient.sendTransaction({
to: ARC_MESSAGE_TRANSMITTER as `0x${string}`,
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}`,
],
}),
});
await arcPublicClient.waitForTransactionReceipt({ hash });
console.log(`Mint Tx: ${hash}`);
}
Step 4: Full script
Create anindex.ts file in your project directory and paste the full script
below so you can run the flow from one file.index.ts
import {
Address,
Contract,
Keypair,
nativeToScVal,
rpc,
TransactionBuilder,
xdr,
} from "@stellar/stellar-sdk";
import {
createPublicClient,
createWalletClient,
encodeFunctionData,
http,
pad,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
interface AttestationMessage {
message: string;
attestation: string;
status: string;
}
interface AttestationResponse {
messages: AttestationMessage[];
}
// ============ Configuration Constants ============
// Contract Addresses
const STELLAR_TOKEN_MESSENGER_MINTER =
"CDNG7HXAPBWICI2E3AUBP3YZWZELJLYSB6F5CC7WLDTLTHVM74SLRTHP";
const STELLAR_USDC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const ARC_MESSAGE_TRANSMITTER = "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275";
// Transfer Parameters
const AMOUNT = 10_000_000n; // 1 USDC (Stellar has 7 decimals)
const MAX_FEE = 100_000n; // 0.01 USDC in Stellar subunits (7 decimals)
// Chain-specific Parameters
const STELLAR_DOMAIN = 27; // Source domain ID for Stellar Testnet
const ARC_TESTNET_DOMAIN = 26; // Destination domain ID for Arc Testnet
// Stellar Soroban Configuration
const STELLAR_RPC_URL = "https://soroban-testnet.stellar.org";
const STELLAR_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015";
// Authentication
const stellarKeypair = Keypair.fromSecret(
process.env.STELLAR_SECRET_KEY as string,
);
// Set up wallet clients
const evmAccount = privateKeyToAccount(
process.env.EVM_PRIVATE_KEY as `0x${string}`,
);
const arcWalletClient = createWalletClient({
chain: arcTestnet,
transport: http(),
account: evmAccount,
});
const arcPublicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
async function submitSorobanTx(
server: rpc.Server,
contractId: string,
method: string,
args: xdr.ScVal[],
) {
const account = await server.getAccount(stellarKeypair.publicKey());
const contract = new Contract(contractId);
const tx = new TransactionBuilder(account, {
fee: "10000000",
networkPassphrase: STELLAR_NETWORK_PASSPHRASE,
})
.addOperation(contract.call(method, ...args))
.setTimeout(120)
.build();
const simulated = await server.simulateTransaction(tx);
if (rpc.Api.isSimulationError(simulated)) {
throw new Error(`Simulation failed: ${JSON.stringify(simulated)}`);
}
const prepared = rpc.assembleTransaction(tx, simulated).build();
prepared.sign(stellarKeypair);
const sendResult = await server.sendTransaction(prepared);
if (sendResult.status === "ERROR") {
throw new Error(`Send failed: ${JSON.stringify(sendResult)}`);
}
let getResult = await server.getTransaction(sendResult.hash);
while (getResult.status === "NOT_FOUND") {
await new Promise((resolve) => setTimeout(resolve, 2000));
getResult = await server.getTransaction(sendResult.hash);
}
if (getResult.status !== "SUCCESS") {
throw new Error(`Transaction failed: ${JSON.stringify(getResult)}`);
}
return sendResult.hash;
}
// ============ CCTP Flow Functions ============
async function approveUSDC() {
console.log("Approving USDC spend on Stellar...");
const server = new rpc.Server(STELLAR_RPC_URL);
const latestLedger = await server.getLatestLedger();
const expirationLedger = latestLedger.sequence + 100_000;
const approveHash = await submitSorobanTx(server, STELLAR_USDC, "approve", [
new Address(stellarKeypair.publicKey()).toScVal(),
new Address(STELLAR_TOKEN_MESSENGER_MINTER).toScVal(),
nativeToScVal(AMOUNT, { type: "i128" }),
nativeToScVal(expirationLedger, { type: "u32" }),
]);
console.log(`Approve Tx: ${approveHash}`);
}
async function burnUSDC() {
console.log("Burning USDC on Stellar...");
// Bytes32 Formatted Parameters
const evmAddressBytes32 = pad(evmAccount.address);
const mintRecipient = xdr.ScVal.scvBytes(
Buffer.from(evmAddressBytes32.slice(2), "hex"),
);
const server = new rpc.Server(STELLAR_RPC_URL);
const txHash = await submitSorobanTx(
server,
STELLAR_TOKEN_MESSENGER_MINTER,
"deposit_for_burn",
[
new Address(stellarKeypair.publicKey()).toScVal(),
nativeToScVal(AMOUNT, { type: "i128" }),
nativeToScVal(ARC_TESTNET_DOMAIN, { type: "u32" }),
mintRecipient,
new Address(STELLAR_USDC).toScVal(),
xdr.ScVal.scvBytes(Buffer.alloc(32)), // destination_caller
nativeToScVal(MAX_FEE, { type: "i128" }),
nativeToScVal(1000, { type: "u32" }), // Fast Transfer finality threshold
],
);
console.log(`Burn Tx: ${txHash}`);
return txHash;
}
async function retrieveAttestation(transactionHash: string) {
console.log("Retrieving attestation...");
const url = `https://iris-api-sandbox.circle.com/v2/messages/${STELLAR_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 mintUSDCOnArc(attestation: AttestationMessage) {
console.log("Minting USDC on Arc Testnet...");
const hash = await arcWalletClient.sendTransaction({
to: ARC_MESSAGE_TRANSMITTER as `0x${string}`,
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}`,
],
}),
});
await arcPublicClient.waitForTransactionReceipt({ hash });
console.log(`Mint Tx: ${hash}`);
}
// ============ Main Execution ============
async function main() {
await approveUSDC();
const burnTx = await burnUSDC();
const attestation = await retrieveAttestation(burnTx);
await mintUSDCOnArc(attestation);
console.log("USDC transfer from Stellar to Arc completed!");
}
main().catch(console.error);
Step 5: Test the script
Run the following command to execute the script:Shell
npm run start
Shell
Approving USDC spend on Stellar...
Approve Tx: <stellar-transaction-hash>
Burning USDC on Stellar...
Burn Tx: <stellar-transaction-hash>
Retrieving attestation...
Waiting for attestation...
Waiting for attestation...
Attestation retrieved successfully!
Minting USDC on Arc Testnet...
Mint Tx: 0x...
USDC transfer from Stellar to Arc completed!
Waiting for attestation..., allow at
least five minutes before investigating.Rate limit: The attestation service rate limit is 35 requests per second. If
you exceed this limit, the service blocks all API requests for the next 5
minutes and returns an HTTP 429 (Too Many Requests) response.
This quickstart demonstrates how to transfer USDC from Arc Testnet to Stellar
Testnet using CCTP. You use the viem library to burn USDC on
Arc, and Create a Then, update the Example: For forward recipient “GABC…XYZ” (56 chars):
Use the When the transfer finishes, the console logs a completion message and the
relevant transaction hashes. Successful output looks similar to the following:Attestation polling can take several minutes depending on network conditions and
the finality threshold you chose. The script retries every 5 seconds with no
timeout, so if it appears to hang at
@stellar/stellar-sdk to
mint and forward tokens on the Stellar CCTP Forwarder. When you finish, you will
have executed a full burn-attest-mint flow.You should be comfortable using a terminal and Node.js. Familiarity with basic
EVM usage and Stellar Soroban transactions helps you follow and adapt the
script. Examples use Arc Testnet as the source, but you can use any
supported blockchain.Always
use
CctpForwarder
when routing CCTP USDC to a Stellar address. Set both mintRecipient and
destinationCaller to the CctpForwarder
contract address.- If
destinationCalleris wrong, the forwarder cannot complete the transfer. - If
mintRecipientis set to a user account or muxed address, USDC is not sent to the forwarder.
Prerequisites
Before you begin this tutorial, ensure you have:- Installed Node.js v22+
- Prepared an EVM wallet with the private key available for Arc Testnet
- Added the Arc Testnet network to your wallet (network details)
- Funded your wallet with Arc Testnet USDC (for gas fees and the transfer amount) from the Circle Faucet
- Prepared a Stellar Testnet wallet with the secret key (
S...) available- Funded your Stellar wallet with testnet XLM from the Stellar Friendbot (for Soroban fees on Stellar Testnet)
- If needed, identified the forward recipient Stellar
strkey(G...,C..., orM...). ForG...orM...recipients, established a USDC trustline before receiving minted USDC
You can use Stellar Lab on Stellar Testnet to fund
accounts and establish USDC trustlines.
Step 1: Set up the project
This step shows you how to prepare your project and environment.1.1. Set up your development environment
Create a new directory and install the required dependencies:Shell
# Set up your directory and initialize a Node.js project
mkdir cctp-arc-to-stellar
cd cctp-arc-to-stellar
npm init -y
# Set up module type and start command
npm pkg set type=module
npm pkg set scripts.start="npx tsx --env-file=.env index.ts"
# Install runtime dependencies
npm install @stellar/stellar-sdk viem
# Install dev dependencies
npm install --save-dev typescript @types/node
1.2. Initialize and configure the project
This step is optional. It helps prevent missing types in your IDE or editor.
tsconfig.json file:npx tsc --init
tsconfig.json file:cat <<'EOF' > tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["node"]
}
}
EOF
1.3. Configure environment variables
Add your wallet keys
Open
.env in your IDE or editor and add the following. Editing the file
directly (instead of using shell redirection) helps keep credentials out of
shell history..env
EVM_PRIVATE_KEY=YOUR_EVM_PRIVATE_KEY
STELLAR_SECRET_KEY=YOUR_STELLAR_SECRET_KEY
# FORWARD_RECIPIENT=G_OR_C_OR_M_STELLAR_STRKEY
EVM_PRIVATE_KEYis the private key for the EVM wallet you use on Arc Testnet.STELLAR_SECRET_KEYis the Stellar secret key (S...) used to sign Soroban transactions on Stellar Testnet.FORWARD_RECIPIENTis required when the final recipient is aG...orM...address. When omitted, the script defaults to the public key derived fromSTELLAR_SECRET_KEY.
This use of a private key is simplified for demonstration purposes. In
production, store and access your private key securely and never share it.
Step 2: Configure the script
This section covers the necessary setup for the transfer script, including defining keys and addresses, and configuring the wallet clients for interacting with Arc and Stellar.2.1. Define configuration constants
The script predefines the contract addresses, transfer amount, and other parameters.TypeScript
import {
Contract,
Keypair,
StrKey,
rpc,
TransactionBuilder,
xdr,
} from "@stellar/stellar-sdk";
import {
createPublicClient,
createWalletClient,
encodeFunctionData,
http,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
interface AttestationMessage {
message: string;
attestation: string;
status: string;
}
interface AttestationResponse {
messages: AttestationMessage[];
}
// ============ Configuration Constants ============
// Contract Addresses
const ARC_USDC = "0x3600000000000000000000000000000000000000";
const ARC_TOKEN_MESSENGER = "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA";
const STELLAR_CCTP_FORWARDER =
"CA66Q2WFBND6V4UEB7RD4SAXSVIWMD6RA4X3U32ELVFGXV5PJK4T4VSZ";
// Transfer Parameters
const AMOUNT = 1_000_000n; // 1 USDC (1 USDC = 1,000,000 subunits)
const MAX_FEE = 500n; // 0.0005 USDC (500 subunits)
// Chain-specific Parameters
const ARC_TESTNET_DOMAIN = 26; // Source domain ID for Arc Testnet
const STELLAR_DOMAIN = 27; // Destination domain ID for Stellar
// Stellar Soroban Configuration
const STELLAR_RPC_URL = "https://soroban-testnet.stellar.org";
const STELLAR_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015";
2.2. Set up wallet clients
The wallet clients configure the appropriate network settings usingviem. In
this example, the script connects to Arc Testnet.TypeScript
const evmAccount = privateKeyToAccount(
process.env.EVM_PRIVATE_KEY as `0x${string}`,
);
const arcWalletClient = createWalletClient({
chain: arcTestnet,
transport: http(),
account: evmAccount,
});
const arcPublicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
2.3. Encode the CCTP Forwarder hook data
When transferring to Stellar, the Arc burn encodes the forward recipient in thehookData field. The Stellar CCTP Forwarder contract is set as both the
mintRecipient and destinationCaller.The hook data encodes the forward recipient in a specific binary format:Byte layout
[32-byte header + forward recipient bytes]
├─ Bytes 0-23: Zero padding (0x000...000)
├─ Bytes 24-27: Hook version (uint32, currently 0)
├─ Bytes 28-31: Forward recipient strkey length (uint32, byte length)
└─ Bytes 32+: Forward recipient strkey as UTF-8 bytes
- Zero padding: 0x000000000000000000000000000000000000000000000000
- Hook version: 0x00000000
- Length: 0x00000038 (56 in hex)
- Forward recipient: “GABC…XYZ” encoded as UTF-8
G and M strkey forward recipients need an established
USDC trustline
before receiving funds. Transfers without a trustline fail.How the CCTP Forwarder flow works
How the CCTP Forwarder flow works
This path follows the
CCTP Forwarder
pattern:
- Burn with hook:
depositForBurnWithHookon the ArcTokenMessengerV2contract.mintRecipientis the Stellar CCTP Forwarder, andhookDatacarries the forward recipient strkey. - Attest: Circle’s attestation service signs the burn event.
- Mint and forward:
mint_and_forwardon the Stellar CCTP Forwarder callsreceive_messageonMessageTransmitter, mints tokens, and forwards them per the hook data.
The CCTP Forwarder flow is non-custodial. In one atomic Soroban transaction,
mint_and_forward mints to CctpForwarder and pays forwardRecipient onchain.
Circle does not take custody of the minted balance in between.TypeScript
// Convert a Stellar contract address (C...) to 0x-prefixed bytes32
function contractStrkeyToBytes32(strkey: string): `0x${string}` {
if (!StrKey.isValidContract(strkey)) {
throw new Error(`Invalid contract strkey: ${strkey}`);
}
return `0x${Buffer.from(StrKey.decodeContract(strkey)).toString("hex")}`;
}
// Build hook data encoding the forward recipient
function buildCctpForwarderHookData(
forwardRecipientStrkey: string,
): `0x${string}` {
const isValid =
StrKey.isValidEd25519PublicKey(forwardRecipientStrkey) ||
StrKey.isValidContract(forwardRecipientStrkey) ||
StrKey.isValidMed25519PublicKey(forwardRecipientStrkey);
if (!isValid) {
throw new Error(
`Invalid forward recipient: ${forwardRecipientStrkey} (expected G..., C..., or M... address)`,
);
}
const recipientBytes = Buffer.from(forwardRecipientStrkey, "utf8");
const hookData = Buffer.alloc(32 + recipientBytes.length);
hookData.writeUInt32BE(0, 24); // hook version = 0
hookData.writeUInt32BE(recipientBytes.length, 28); // recipient byte length
recipientBytes.copy(hookData, 32); // recipient strkey as UTF-8
return `0x${hookData.toString("hex")}`;
}
const stellarKeypair = Keypair.fromSecret(
process.env.STELLAR_SECRET_KEY as string,
);
// Falls back to the Stellar public key derived from STELLAR_SECRET_KEY
// when FORWARD_RECIPIENT is unset or empty.
const forwardRecipient =
process.env.FORWARD_RECIPIENT || stellarKeypair.publicKey();
const hookData = buildCctpForwarderHookData(forwardRecipient);
Step 3: Implement the transfer logic
This step implements the core transfer logic: approve and burn on Arc, poll for an attestation, then mint and forward on Stellar. A successful run prints transaction hashes and a completion message in the console.3.1. Approve USDC on Arc
Grant approval for theTokenMessengerV2 contract to withdraw
USDC from your wallet. This allows the contract to burn USDC when you initiate
the transfer.TypeScript
async function approveUSDC() {
console.log("Approving USDC spend on Arc...");
const approveTx = await arcWalletClient.sendTransaction({
to: ARC_USDC as `0x${string}`,
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: [ARC_TOKEN_MESSENGER as `0x${string}`, AMOUNT],
}),
});
await arcPublicClient.waitForTransactionReceipt({ hash: approveTx });
console.log(`Approve Tx: ${approveTx}`);
}
3.2. Burn USDC on Arc
CalldepositForBurnWithHook to burn USDC with the CCTP Forwarder hook data.
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 (see supported blockchains and domains)
- Mint recipient: The CCTP Forwarder contract address on Stellar (encoded as
bytes32) - Burn token: The contract address of the USDC token being burned on Arc
- Destination caller: The CCTP Forwarder contract address on Stellar
(restricts who can call
receive_message) - Max fee: The maximum fee allowed for the transfer
- Finality threshold: Determines whether it’s a Fast Transfer (1000 or less) or a Standard Transfer (2000 or more)
- Hook data: Encodes the final Stellar recipient address for the CCTP Forwarder
Always
use
CctpForwarder
when routing CCTP USDC to a Stellar address. Set both mintRecipient and
destinationCaller to the CctpForwarder
contract address.- If
destinationCalleris wrong, the forwarder cannot complete the transfer. - If
mintRecipientis set to a user account or muxed address, USDC is not sent to the forwarder.
mintRecipient and destinationCaller must be the Stellar CCTP Forwarder
contract address. TokenMessengerMinter assumes mintRecipient is a contract
address, so this example validates STELLAR_CCTP_FORWARDER as a contract
strkey and uses the final recipient only in hookData.TypeScript
async function burnUSDC() {
console.log("Burning USDC on Arc (with hook)...");
const cctpForwarderBytes32 = contractStrkeyToBytes32(STELLAR_CCTP_FORWARDER);
const burnTx = await arcWalletClient.sendTransaction({
to: ARC_TOKEN_MESSENGER as `0x${string}`,
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: [
AMOUNT,
STELLAR_DOMAIN,
cctpForwarderBytes32, // mintRecipient = Stellar CCTP Forwarder
ARC_USDC as `0x${string}`,
cctpForwarderBytes32, // destinationCaller = Stellar CCTP Forwarder
MAX_FEE,
2000, // Standard Transfer finality threshold
hookData,
],
}),
});
await arcPublicClient.waitForTransactionReceipt({ hash: burnTx });
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/messagesAPI endpoint to retrieve the attestation. - Pass
ARC_TESTNET_DOMAINfor thesourceDomainpath parameter, using the CCTP domain for Arc Testnet (26). - Pass
transactionHashfrom the value returned byburnUSDCabove.
TypeScript
async function retrieveAttestation(transactionHash: string) {
console.log("Retrieving attestation...");
const url = `https://iris-api-sandbox.circle.com/v2/messages/${ARC_TESTNET_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 and forward USDC on Stellar
ThesubmitSorobanTx helper builds, signs, submits, and confirms a Soroban
contract transaction.TypeScript
async function submitSorobanTx(
server: rpc.Server,
contractId: string,
method: string,
args: xdr.ScVal[],
) {
const account = await server.getAccount(stellarKeypair.publicKey());
const contract = new Contract(contractId);
const tx = new TransactionBuilder(account, {
fee: "10000000",
networkPassphrase: STELLAR_NETWORK_PASSPHRASE,
})
.addOperation(contract.call(method, ...args))
.setTimeout(120)
.build();
const simulated = await server.simulateTransaction(tx);
if (rpc.Api.isSimulationError(simulated)) {
throw new Error(`Simulation failed: ${JSON.stringify(simulated)}`);
}
const prepared = rpc.assembleTransaction(tx, simulated).build();
prepared.sign(stellarKeypair);
const sendResult = await server.sendTransaction(prepared);
if (sendResult.status === "ERROR") {
throw new Error(`Send failed: ${JSON.stringify(sendResult)}`);
}
let getResult = await server.getTransaction(sendResult.hash);
while (getResult.status === "NOT_FOUND") {
await new Promise((resolve) => setTimeout(resolve, 2000));
getResult = await server.getTransaction(sendResult.hash);
}
if (getResult.status !== "SUCCESS") {
throw new Error(`Transaction failed: ${JSON.stringify(getResult)}`);
}
return sendResult.hash;
}
submitSorobanTx helper to call mint_and_forward on the Stellar CCTP
Forwarder. This verifies the CCTP message and attestation, mints USDC through
the TokenMessengerMinter, and forwards it to the recipient encoded in the hook
data:TypeScript
async function mintAndForwardOnStellar(attestation: AttestationMessage) {
console.log("Minting and forwarding USDC on Stellar...");
const server = new rpc.Server(STELLAR_RPC_URL);
const messageBytes = Buffer.from(
attestation.message.replace("0x", ""),
"hex",
);
const attestationBytes = Buffer.from(
attestation.attestation.replace("0x", ""),
"hex",
);
const txHash = await submitSorobanTx(
server,
STELLAR_CCTP_FORWARDER,
"mint_and_forward",
[xdr.ScVal.scvBytes(messageBytes), xdr.ScVal.scvBytes(attestationBytes)],
);
console.log(`mint_and_forward Tx: ${txHash}`);
}
Step 4: Full script
Create anindex.ts file in your project directory and paste the full script
below so you can run the flow from one file.index.ts
import {
Contract,
Keypair,
StrKey,
rpc,
TransactionBuilder,
xdr,
} from "@stellar/stellar-sdk";
import {
createPublicClient,
createWalletClient,
encodeFunctionData,
http,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { arcTestnet } from "viem/chains";
interface AttestationMessage {
message: string;
attestation: string;
status: string;
}
interface AttestationResponse {
messages: AttestationMessage[];
}
// ============ Configuration Constants ============
// Contract Addresses
const ARC_USDC = "0x3600000000000000000000000000000000000000";
const ARC_TOKEN_MESSENGER = "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA";
const STELLAR_CCTP_FORWARDER =
"CA66Q2WFBND6V4UEB7RD4SAXSVIWMD6RA4X3U32ELVFGXV5PJK4T4VSZ";
// Transfer Parameters
const AMOUNT = 1_000_000n; // 1 USDC (1 USDC = 1,000,000 subunits)
const MAX_FEE = 500n; // 0.0005 USDC (500 subunits)
// Chain-specific Parameters
const ARC_TESTNET_DOMAIN = 26; // Source domain ID for Arc Testnet
const STELLAR_DOMAIN = 27; // Destination domain ID for Stellar
// Stellar Soroban Configuration
const STELLAR_RPC_URL = "https://soroban-testnet.stellar.org";
const STELLAR_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015";
// Set up wallet clients
const evmAccount = privateKeyToAccount(
process.env.EVM_PRIVATE_KEY as `0x${string}`,
);
const arcWalletClient = createWalletClient({
chain: arcTestnet,
transport: http(),
account: evmAccount,
});
const arcPublicClient = createPublicClient({
chain: arcTestnet,
transport: http(),
});
// Hook Data — encodes the forward recipient for the CCTP Forwarder contract
function contractStrkeyToBytes32(strkey: string): `0x${string}` {
if (!StrKey.isValidContract(strkey)) {
throw new Error(`Invalid contract strkey: ${strkey}`);
}
return `0x${Buffer.from(StrKey.decodeContract(strkey)).toString("hex")}`;
}
function buildCctpForwarderHookData(
forwardRecipientStrkey: string,
): `0x${string}` {
const isValid =
StrKey.isValidEd25519PublicKey(forwardRecipientStrkey) ||
StrKey.isValidContract(forwardRecipientStrkey) ||
StrKey.isValidMed25519PublicKey(forwardRecipientStrkey);
if (!isValid) {
throw new Error(
`Invalid forward recipient: ${forwardRecipientStrkey} (expected G..., C..., or M... address)`,
);
}
const recipientBytes = Buffer.from(forwardRecipientStrkey, "utf8");
const hookData = Buffer.alloc(32 + recipientBytes.length);
hookData.writeUInt32BE(0, 24);
hookData.writeUInt32BE(recipientBytes.length, 28);
recipientBytes.copy(hookData, 32);
return `0x${hookData.toString("hex")}`;
}
const stellarKeypair = Keypair.fromSecret(
process.env.STELLAR_SECRET_KEY as string,
);
// Falls back to the Stellar public key derived from STELLAR_SECRET_KEY
// when FORWARD_RECIPIENT is unset or empty.
const forwardRecipient =
process.env.FORWARD_RECIPIENT || stellarKeypair.publicKey();
const hookData = buildCctpForwarderHookData(forwardRecipient);
// ============ CCTP Flow Functions ============
async function approveUSDC() {
console.log("Approving USDC spend on Arc...");
const approveTx = await arcWalletClient.sendTransaction({
to: ARC_USDC as `0x${string}`,
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: [ARC_TOKEN_MESSENGER as `0x${string}`, AMOUNT],
}),
});
await arcPublicClient.waitForTransactionReceipt({ hash: approveTx });
console.log(`Approve Tx: ${approveTx}`);
}
async function burnUSDC() {
console.log("Burning USDC on Arc (with hook)...");
const cctpForwarderBytes32 = contractStrkeyToBytes32(STELLAR_CCTP_FORWARDER);
const burnTx = await arcWalletClient.sendTransaction({
to: ARC_TOKEN_MESSENGER as `0x${string}`,
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: [
AMOUNT,
STELLAR_DOMAIN,
cctpForwarderBytes32, // mintRecipient = Stellar CCTP Forwarder
ARC_USDC as `0x${string}`,
cctpForwarderBytes32, // destinationCaller = Stellar CCTP Forwarder
MAX_FEE,
2000, // Standard Transfer finality threshold
hookData,
],
}),
});
await arcPublicClient.waitForTransactionReceipt({ hash: burnTx });
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/${ARC_TESTNET_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 submitSorobanTx(
server: rpc.Server,
contractId: string,
method: string,
args: xdr.ScVal[],
) {
const account = await server.getAccount(stellarKeypair.publicKey());
const contract = new Contract(contractId);
const tx = new TransactionBuilder(account, {
fee: "10000000",
networkPassphrase: STELLAR_NETWORK_PASSPHRASE,
})
.addOperation(contract.call(method, ...args))
.setTimeout(120)
.build();
const simulated = await server.simulateTransaction(tx);
if (rpc.Api.isSimulationError(simulated)) {
throw new Error(`Simulation failed: ${JSON.stringify(simulated)}`);
}
const prepared = rpc.assembleTransaction(tx, simulated).build();
prepared.sign(stellarKeypair);
const sendResult = await server.sendTransaction(prepared);
if (sendResult.status === "ERROR") {
throw new Error(`Send failed: ${JSON.stringify(sendResult)}`);
}
let getResult = await server.getTransaction(sendResult.hash);
while (getResult.status === "NOT_FOUND") {
await new Promise((resolve) => setTimeout(resolve, 2000));
getResult = await server.getTransaction(sendResult.hash);
}
if (getResult.status !== "SUCCESS") {
throw new Error(`Transaction failed: ${JSON.stringify(getResult)}`);
}
return sendResult.hash;
}
async function mintAndForwardOnStellar(attestation: AttestationMessage) {
console.log("Minting and forwarding USDC on Stellar...");
const server = new rpc.Server(STELLAR_RPC_URL);
const messageBytes = Buffer.from(
attestation.message.replace("0x", ""),
"hex",
);
const attestationBytes = Buffer.from(
attestation.attestation.replace("0x", ""),
"hex",
);
const txHash = await submitSorobanTx(
server,
STELLAR_CCTP_FORWARDER,
"mint_and_forward",
[xdr.ScVal.scvBytes(messageBytes), xdr.ScVal.scvBytes(attestationBytes)],
);
console.log(`mint_and_forward Tx: ${txHash}`);
}
// ============ Main Execution ============
async function main() {
await approveUSDC();
const burnTx = await burnUSDC();
const attestation = await retrieveAttestation(burnTx);
await mintAndForwardOnStellar(attestation);
console.log("USDC transfer from Arc to Stellar completed!");
}
main().catch(console.error);
Step 5: Test the script
Run the following command to execute the script:Shell
npm run start
Shell
Approving USDC spend on Arc...
Approve Tx: 0x65d6504333ac76cf952975dad29d4a31d8de28c59d7c97ee8ac5d0c360c0e70c
Burning USDC on Arc (with hook)...
Burn Tx: 0x73e1eb9224ca5778be763b4e8afc11cfa63e7ae3caa8b2748ce73f4f3d07181a
Retrieving attestation...
Waiting for attestation...
Attestation retrieved successfully!
Minting and forwarding USDC on Stellar...
mint_and_forward Tx: <stellar-transaction-hash>
USDC transfer from Arc to Stellar completed!
Waiting for attestation..., allow at
least five minutes before investigating.Rate limit: The attestation service rate limit is 35 requests per second. If
you exceed this limit, the service blocks all API requests for the next 5
minutes and returns an HTTP 429 (Too Many Requests) response.