Skip to main content

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.

Use CCTP to transfer USDC with Stellar Testnet as the source or destination.
On Stellar, USDC precision and address encoding differ from other CCTP-supported blockchains. Before you integrate beyond these examples, read CCTP on Stellar.
Pick the tab that matches the direction of your transfer.
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 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.
Create a tsconfig.json file:
npx tsc --init
Then, update the tsconfig.json file:
cat <<'EOF' > tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["node"]
  }
}
EOF

1.3. Configure environment variables

1

Create an empty .env file

From the project directory:
Shell
touch .env
2

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_KEY is the Stellar secret key (S...) used to sign Soroban transactions on Stellar Testnet.
  • EVM_PRIVATE_KEY is the private key for the EVM wallet you use on Arc Testnet.
3

Ignore the environment file in Git

Add .env to .gitignore:
Shell
echo ".env" >> .gitignore
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 using viem. 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

The submitSorobanTx 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 the TokenMessengerMinterV2 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

Call deposit_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/messages API endpoint to retrieve the attestation.
  • Pass STELLAR_DOMAIN for the sourceDomain path parameter, using the CCTP domain for Stellar Testnet (27).
  • Pass transactionHash from the value returned by submitSorobanTx within the burnUSDC function 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 the receiveMessage 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 an index.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
When the transfer finishes, the console logs a completion message and the relevant transaction hashes. Successful output looks similar to the following:
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!
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 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.