Skip to main content
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 tsx

# 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 {
  createWalletClient,
  createPublicClient,
  http,
  encodeFunctionData,
  defineChain,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";

// Stellar Testnet Configuration
const STELLAR_RPC_URL = "https://soroban-testnet.stellar.org";
const STELLAR_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015";
const stellarKeypair = Keypair.fromSecret(process.env.STELLAR_SECRET_KEY!);
const STELLAR_TOKEN_MESSENGER_MINTER =
  "CDNG7HXAPBWICI2E3AUBP3YZWZELJLYSB6F5CC7WLDTLTHVM74SLRTHP";
const STELLAR_USDC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";

// Arc Testnet Configuration
const evmAccount = privateKeyToAccount(
  process.env.EVM_PRIVATE_KEY! as `0x${string}`,
);
const ARC_MESSAGE_TRANSMITTER = "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275";
const ARC_RPC_URL = "https://rpc.testnet.arc.network";

// 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)

// Blockchain-specific Parameters
const STELLAR_DOMAIN = 27; // Source domain ID for Stellar Testnet
const ARC_TESTNET_DOMAIN = 26; // Destination domain ID for Arc Testnet

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 arcChain = defineChain({
  id: 5042002,
  name: "Arc Testnet",
  nativeCurrency: { name: "USDC", symbol: "USDC", decimals: 18 },
  rpcUrls: { default: { http: [ARC_RPC_URL] } },
});

const arcWalletClient = createWalletClient({
  chain: arcChain,
  transport: http(ARC_RPC_URL),
  account: evmAccount,
});

const arcPublicClient = createPublicClient({
  chain: arcChain,
  transport: http(ARC_RPC_URL),
});

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 and burn USDC on Stellar

Approve the TokenMessengerMinterV2 contract to spend your USDC, then call deposit_for_burn to burn USDC on Stellar. You specify the following parameters:
  • 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 burnUSDCOnStellar() {
  console.log("Burning USDC on Stellar...");
  const server = new rpc.Server(STELLAR_RPC_URL);

  // Step 1: Approve the TokenMessengerMinter to spend USDC
  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(`  Approved: ${approveHash}`);

  // Step 2: Build the mint recipient (EVM address → zero-padded 32-byte ScVal)
  const cleaned = evmAccount.address.slice(2);
  const buf = Buffer.alloc(32);
  Buffer.from(cleaned, "hex").copy(buf, 12);
  const mintRecipient = xdr.ScVal.scvBytes(buf);

  // Step 3: Call deposit_for_burn on the TokenMessengerMinter
  const txHash = await submitSorobanTx(
    server,
    STELLAR_TOKEN_MESSENGER_MINTER,
    "deposit_for_burn",
    [
      new Address(stellarKeypair.publicKey()).toScVal(), // caller
      nativeToScVal(AMOUNT, { type: "i128" }), // amount
      nativeToScVal(ARC_TESTNET_DOMAIN, { type: "u32" }), // destination_domain
      mintRecipient, // mint_recipient
      new Address(STELLAR_USDC).toScVal(), // burn_token
      xdr.ScVal.scvBytes(Buffer.alloc(32)), // destination_caller (empty = any relayer)
      nativeToScVal(MAX_FEE, { type: "i128" }), // max_fee
      nativeToScVal(1000, { type: "u32" }), // min_finality_threshold (1000 = Fast Transfer)
    ],
  );

  console.log(`  Burn Tx: ${txHash}`);
  return txHash;
}
The submitSorobanTx helper assembles, simulates, signs, and submits a Soroban transaction:
TypeScript
async function submitSorobanTx(
  server: rpc.Server,
  contractId: string,
  method: string,
  args: xdr.ScVal[],
): Promise<string> {
  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((r) => setTimeout(r, 2000));
    getResult = await server.getTransaction(sendResult.hash);
  }

  if (getResult.status !== "SUCCESS") {
    throw new Error(`Transaction failed: ${JSON.stringify(getResult)}`);
  }

  return sendResult.hash;
}

3.2. 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 the srcDomain argument from the CCTP domain for Stellar (27).
  • Pass transactionHash from the value returned by submitSorobanTx within the burnUSDCOnStellar function above.
TypeScript
interface AttestationMessage {
  message: string;
  attestation: string;
  status: string;
}

interface AttestationResponse {
  messages: AttestationMessage[];
}

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.3. 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}`,
      ],
    }),
  });

  const receipt = await arcPublicClient.waitForTransactionReceipt({ hash });
  if (receipt.status !== "success") {
    throw new Error(`receiveMessage failed: ${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 {
  createWalletClient,
  createPublicClient,
  http,
  encodeFunctionData,
  defineChain,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";

// Stellar Testnet Configuration
const STELLAR_RPC_URL = "https://soroban-testnet.stellar.org";
const STELLAR_NETWORK_PASSPHRASE = "Test SDF Network ; September 2015";
const stellarKeypair = Keypair.fromSecret(process.env.STELLAR_SECRET_KEY!);
const STELLAR_TOKEN_MESSENGER_MINTER =
  "CDNG7HXAPBWICI2E3AUBP3YZWZELJLYSB6F5CC7WLDTLTHVM74SLRTHP";
const STELLAR_USDC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";

// Arc Testnet Configuration
const evmAccount = privateKeyToAccount(
  process.env.EVM_PRIVATE_KEY! as `0x${string}`,
);
const ARC_MESSAGE_TRANSMITTER = "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275";
const ARC_RPC_URL = "https://rpc.testnet.arc.network";

// 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)

// Blockchain-specific Parameters
const STELLAR_DOMAIN = 27; // Source domain ID for Stellar Testnet
const ARC_TESTNET_DOMAIN = 26; // Destination domain ID for Arc Testnet

const arcChain = defineChain({
  id: 5042002,
  name: "Arc Testnet",
  nativeCurrency: { name: "USDC", symbol: "USDC", decimals: 18 },
  rpcUrls: { default: { http: [ARC_RPC_URL] } },
});

const arcWalletClient = createWalletClient({
  chain: arcChain,
  transport: http(ARC_RPC_URL),
  account: evmAccount,
});

const arcPublicClient = createPublicClient({
  chain: arcChain,
  transport: http(ARC_RPC_URL),
});

async function submitSorobanTx(
  server: rpc.Server,
  contractId: string,
  method: string,
  args: xdr.ScVal[],
): Promise<string> {
  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((r) => setTimeout(r, 2000));
    getResult = await server.getTransaction(sendResult.hash);
  }

  if (getResult.status !== "SUCCESS") {
    throw new Error(`Transaction failed: ${JSON.stringify(getResult)}`);
  }

  return sendResult.hash;
}

async function burnUSDCOnStellar() {
  console.log("Burning USDC on Stellar...");
  const server = new rpc.Server(STELLAR_RPC_URL);

  // Step 1: Approve the TokenMessengerMinter to spend USDC
  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(`  Approved: ${approveHash}`);

  // Step 2: Build the mint recipient (EVM address → zero-padded 32-byte ScVal)
  const cleaned = evmAccount.address.slice(2);
  const buf = Buffer.alloc(32);
  Buffer.from(cleaned, "hex").copy(buf, 12);
  const mintRecipient = xdr.ScVal.scvBytes(buf);

  // Step 3: Call deposit_for_burn on the TokenMessengerMinter
  const txHash = await submitSorobanTx(
    server,
    STELLAR_TOKEN_MESSENGER_MINTER,
    "deposit_for_burn",
    [
      new Address(stellarKeypair.publicKey()).toScVal(), // caller
      nativeToScVal(AMOUNT, { type: "i128" }), // amount
      nativeToScVal(ARC_TESTNET_DOMAIN, { type: "u32" }), // destination_domain
      mintRecipient, // mint_recipient
      new Address(STELLAR_USDC).toScVal(), // burn_token
      xdr.ScVal.scvBytes(Buffer.alloc(32)), // destination_caller (empty = any relayer)
      nativeToScVal(MAX_FEE, { type: "i128" }), // max_fee
      nativeToScVal(1000, { type: "u32" }), // min_finality_threshold (1000 = Fast Transfer)
    ],
  );

  console.log(`  Burn Tx: ${txHash}`);
  return txHash;
}

interface AttestationMessage {
  message: string;
  attestation: string;
  status: string;
}

interface AttestationResponse {
  messages: AttestationMessage[];
}

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}`,
      ],
    }),
  });

  const receipt = await arcPublicClient.waitForTransactionReceipt({ hash });
  if (receipt.status !== "success") {
    throw new Error(`receiveMessage failed: ${hash}`);
  }
  console.log(`  Mint Tx: ${hash}`);
}

async function main() {
  const burnTxHash = await burnUSDCOnStellar();
  const attestation = await retrieveAttestation(burnTxHash);
  await mintUSDCOnArc(attestation);
  console.log("USDC transfer from Stellar Testnet to Arc Testnet 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
Burning USDC on Stellar...
  Approved: <stellar-transaction-hash>
  Burn Tx: <stellar-transaction-hash>
Retrieving attestation...
Attestation retrieved successfully!
Minting USDC on Arc Testnet...
  Mint Tx: 0x...
USDC transfer from Stellar Testnet to Arc Testnet 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.