> ## 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.

# Transfer USDC between Starknet and Arc

> Build scripts to transfer USDC between Arc Testnet and Starknet Sepolia using CCTP

Transfer USDC between Starknet Sepolia and Arc Testnet using the CCTP
burn-attest-mint flow.

<Note>
  On Starknet, CCTP uses Cairo contracts and a combined `TokenMessengerMinterV2`.
  Before you integrate beyond these examples, read
  [CCTP Starknet contracts and interfaces](/cctp/references/starknet-contracts).
</Note>

Pick the tab that matches the direction of your transfer.

<Tabs>
  <Tab title="Starknet to Arc">
    This quickstart demonstrates how to transfer USDC from Starknet Sepolia to Arc
    Testnet using CCTP. You use
    [`starknet.js`](https://www.npmjs.com/package/starknet) to approve and burn USDC
    on Starknet, and [`viem`](https://viem.sh/) 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
    Starknet account contracts and basic EVM usage helps you follow and adapt the
    script. Examples use Arc Testnet as the destination, but you can use any
    [supported EVM blockchain](/cctp/concepts/supported-chains-and-domains).

    ## Prerequisites

    Before you begin this tutorial, ensure you have:

    * Installed [Node.js v22.6+](https://nodejs.org/)
    * Prepared a Starknet Sepolia account with the address and private key available
      * Funded your account with testnet STRK from the
        [Starknet faucet](https://faucet.starknet.io/) for transaction fees
      * Funded your account with Starknet Sepolia USDC from the
        [Circle Faucet](https://faucet.circle.com)
    * Prepared an EVM wallet with the private key available for Arc Testnet
      * Added the Arc Testnet network to your wallet
        ([network details](https://docs.arc.io/arc/references/connect-to-arc#wallet-setup))
      * Funded your wallet with Arc Testnet ETH for gas fees

    ## 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, initialize and set up a new Node.js project, and install
    the required dependencies:

    ```bash Shell theme={null}
    mkdir cctp-starknet-to-arc
    cd cctp-starknet-to-arc
    npm init -y

    npm pkg set type=module
    npm pkg set scripts.start="node --env-file=.env --import=tsx index.ts"

    npm install starknet viem

    npm install --save-dev tsx typescript @types/node
    ```

    ### 1.2. Initialize and configure the project

    <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. Configure environment variables

    Open `.env` in your editor and add:

    ```text theme={null}
    EVM_PRIVATE_KEY=YOUR_EVM_PRIVATE_KEY
    STARKNET_PRIVATE_KEY=YOUR_STARKNET_PRIVATE_KEY
    # STARKNET_RPC_URL=https://starknet-sepolia-rpc.publicnode.com
    ```

    * `EVM_PRIVATE_KEY` is the private key for the EVM wallet used to receive USDC
      on Arc Testnet.
    * `STARKNET_PRIVATE_KEY` is the private key for the Starknet Sepolia account
      used to sign the burn transaction.
    * `STARKNET_RPC_URL` is optional. The script defaults to a public Starknet
      Sepolia RPC endpoint.

    <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 Starknet and Arc Testnet parameters, then configure the wallet
    clients for each chain.

    ### 2.1. Define configuration constants

    The script predefines the USDC and CCTP contract addresses, transfer amount, and
    CCTP domain IDs. Replace `STARKNET_ACCOUNT_ADDRESS` with your Starknet Sepolia
    account address. Starknet account addresses are not derived from the private key
    alone, so you set the address as a constant.

    ```ts TypeScript theme={null}
    import { Account, Contract, RpcProvider } from "starknet";
    import {
      createPublicClient,
      createWalletClient,
      http,
      parseAbi,
      type Address,
      type Hex,
    } from "viem";
    import { privateKeyToAccount } from "viem/accounts";
    import { arcTestnet } from "viem/chains";

    type Attestation = {
      message: string;
      attestation: string;
    };

    const STARKNET_DOMAIN = 25; // Source domain ID for Starknet
    const ARC_TESTNET_DOMAIN = 26; // Destination domain ID for Arc Testnet

    const STARKNET_ACCOUNT_ADDRESS = "0xYOUR_STARKNET_ACCOUNT_ADDRESS";
    const STARKNET_USDC =
      "0x0512feAc6339Ff7889822cb5aA2a86C848e9D392bB0E3E237C008674feeD8343";
    const STARKNET_TOKEN_MESSENGER_MINTER =
      "0x04bDdE1E09a4B09a2F95d893D94a967b7717eB85A3f6dEcA8c080Ee01fBc3370";

    const ARC_MESSAGE_TRANSMITTER_V2 =
      "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275" as Address;

    const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals

    const DESTINATION_CALLER =
      "0x0000000000000000000000000000000000000000000000000000000000000000";
    ```

    `STARKNET_DOMAIN` is the source domain used when requesting the attestation. The
    destination domain for Arc Testnet is 26. Starknet Sepolia USDC uses 6 decimals,
    so `1_000_000` represents 1 USDC.

    ### 2.2. Set up wallet clients

    The EVM clients connect to Arc Testnet with `viem`. The Starknet client uses
    `starknet.js` with your account address and private key. The script loads the
    contract ABI from the network with `getClassAt`.

    ```ts TypeScript theme={null}
    const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
    const starknetPrivateKey = process.env.STARKNET_PRIVATE_KEY;
    const starknetRpcUrl =
      process.env.STARKNET_RPC_URL ?? "https://starknet-sepolia-rpc.publicnode.com";

    if (!evmPrivateKey || !starknetPrivateKey) {
      throw new Error("Set EVM_PRIVATE_KEY and STARKNET_PRIVATE_KEY in .env");
    }

    const evmAccount = privateKeyToAccount(evmPrivateKey as Hex);
    const arcWalletClient = createWalletClient({
      account: evmAccount,
      chain: arcTestnet,
      transport: http(),
    });
    const arcPublicClient = createPublicClient({
      chain: arcTestnet,
      transport: http(),
    });

    const starknetProvider = new RpcProvider({ nodeUrl: starknetRpcUrl });
    const starknetAccount = new Account({
      provider: starknetProvider,
      address: STARKNET_ACCOUNT_ADDRESS,
      signer: starknetPrivateKey,
    });

    const messageTransmitterAbi = parseAbi([
      "function receiveMessage(bytes message, bytes attestation) returns (bool success)",
    ]);

    async function getStarknetContract(address: string) {
      const { abi } = await starknetProvider.getClassAt(address);
      if (!abi) {
        throw new Error(`Failed to load ABI for ${address}`);
      }
      return new Contract({
        abi,
        address,
        providerOrAccount: starknetAccount,
      });
    }
    ```

    ### 2.3. Format the Arc recipient for Starknet

    `deposit_for_burn` accepts the mint recipient as a 32-byte felt. An EVM address
    is left-padded with zeros to 32 bytes.

    ```ts TypeScript theme={null}
    function toBytes32Address(address: string): string {
      return `0x${address.replace(/^0x/i, "").padStart(64, "0")}`;
    }

    function sanitizeTransactionHash(txHash: string): string {
      const hash = txHash.trim().toLowerCase();
      if (hash.length < 66) {
        return `0x${hash.replace(/^0x/i, "").padStart(64, "0")}`;
      }
      return hash;
    }
    ```

    ## Step 3: Implement the transfer logic

    This step implements the core transfer logic: approve and burn on Starknet, poll
    for an attestation, then mint on Arc.

    ### 3.1. Approve USDC on Starknet

    Grant approval for the
    [`TokenMessengerMinterV2` contract](/cctp/references/starknet-contracts) to
    withdraw USDC from your Starknet account. This allows the contract to burn USDC
    when you initiate the transfer.

    ```ts TypeScript theme={null}
    async function approveUSDC() {
      console.log("Approving Starknet USDC transfer...");

      const usdcContract = await getStarknetContract(STARKNET_USDC);
      const approveTx = await usdcContract.approve(
        STARKNET_TOKEN_MESSENGER_MINTER,
        AMOUNT,
      );
      const approveReceipt = await starknetProvider.waitForTransaction(
        approveTx.transaction_hash,
      );
      if (!approveReceipt.isSuccess()) {
        throw new Error(`USDC approval failed: ${approveTx.transaction_hash}`);
      }

      console.log(`Approval confirmed: ${approveTx.transaction_hash}`);
    }
    ```

    ### 3.2. Burn USDC on Starknet

    Call `deposit_for_burn` to burn USDC on Starknet. You specify the following
    parameters:

    * **Burn amount**: The amount of USDC to burn in Starknet subunits (6 decimals)
    * **Destination domain**: The target blockchain for minting USDC (26 for Arc
      Testnet)
    * **Mint recipient**: The EVM wallet address that receives minted USDC on Arc,
      left-padded to 32 bytes
    * **Burn token**: The Starknet Sepolia USDC address
    * **Destination caller**: The zero address, allowing any caller to submit the
      receive transaction on Arc
    * **Max fee** / **finality threshold**: This example uses Fast Transfer. For
      live fee values and allowance checks, see
      [Get the fee for your transfer](/cctp/howtos/get-transfer-fee) and
      [Get the fast transfer allowance](/cctp/howtos/get-fast-transfer-allowance).

    ```ts TypeScript theme={null}
    async function burnUSDC() {
      console.log(`Burning ${Number(AMOUNT) / 1_000_000} USDC on Starknet...`);

      const tokenMessengerMinter = await getStarknetContract(
        STARKNET_TOKEN_MESSENGER_MINTER,
      );
      const mintRecipient = toBytes32Address(evmAccount.address);

      const depositTx = await tokenMessengerMinter.invoke("deposit_for_burn", [
        AMOUNT,
        ARC_TESTNET_DOMAIN,
        mintRecipient,
        STARKNET_USDC,
        DESTINATION_CALLER,
        1500n, // maxFee — see Get the fee for your transfer
        1000, // minFinalityThreshold — Fast Transfer
      ]);

      const receipt = await starknetProvider.waitForTransaction(
        depositTx.transaction_hash,
      );
      if (!receipt.isSuccess()) {
        throw new Error(`Deposit-for-burn failed: ${depositTx.transaction_hash}`);
      }

      const txHash = sanitizeTransactionHash(depositTx.transaction_hash);
      console.log(`Deposit-for-burn confirmed: ${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-reference/cctp/all/get-messages-v2)
      API endpoint to retrieve the attestation.
    * Pass `STARKNET_DOMAIN` for the `sourceDomain` path parameter, using the
      [CCTP domain](/cctp/concepts/supported-chains-and-domains#domain-identifiers)
      for Starknet (25).
    * Pass the Starknet transaction hash returned by `burnUSDC` as
      `transactionHash`.

    ```ts TypeScript theme={null}
    async function retrieveAttestation(
      transactionHash: string,
    ): Promise<Attestation> {
      const url = `https://iris-api-sandbox.circle.com/v2/messages/${STARKNET_DOMAIN}?transactionHash=${transactionHash}`;
      console.log(`Polling Iris V2 for attestation: ${url}`);

      while (true) {
        const response = await fetch(url);
        if (!response.ok && response.status !== 404) {
          throw new Error(`Iris V2 returned HTTP ${response.status}`);
        }

        if (response.ok) {
          const data = (await response.json()) as {
            messages?: Array<{ message?: string; attestation?: string }>;
          };
          const result = data.messages?.[0];

          if (
            result?.message &&
            result.attestation &&
            result.attestation !== "PENDING"
          ) {
            console.log("Attestation retrieved successfully!");
            return {
              message: result.message,
              attestation: result.attestation,
            };
          }
        }

        console.log("Waiting for attestation...");
        await new Promise((resolve) => setTimeout(resolve, 2_000));
      }
    }
    ```

    ### 3.4. Mint USDC on Arc

    Call the
    [`receiveMessage` function](/cctp/references/contract-interfaces#receivemessage)
    from the [`MessageTransmitterV2` contract](/cctp/references/contract-addresses)
    deployed on Arc Testnet.

    * Pass the signed attestation and message bytes as parameters.
    * The contract verifies the attestation and mints USDC to the recipient encoded
      in the CCTP message.

    ```ts TypeScript theme={null}
    async function mintUSDC({ message, attestation }: Attestation) {
      console.log("Minting USDC on Arc...");

      const receiveTransactionHash = await arcWalletClient.writeContract({
        account: evmAccount,
        chain: arcTestnet,
        address: ARC_MESSAGE_TRANSMITTER_V2,
        abi: messageTransmitterAbi,
        functionName: "receiveMessage",
        args: [message as Hex, attestation as Hex],
      });
      const receiveReceipt = await arcPublicClient.waitForTransactionReceipt({
        hash: receiveTransactionHash,
      });
      if (receiveReceipt.status !== "success") {
        throw new Error(`Arc receiveMessage reverted: ${receiveTransactionHash}`);
      }

      console.log(`Mint confirmed: ${receiveTransactionHash}`);
      return receiveTransactionHash;
    }
    ```

    ## Step 4: Full script

    Create an `index.ts` file in your project directory and paste the full script
    following.

    ```ts index.ts expandable theme={null}
    import { resolve } from "node:path";
    import { pathToFileURL } from "node:url";
    import { Account, Contract, RpcProvider } from "starknet";
    import {
      createPublicClient,
      createWalletClient,
      http,
      parseAbi,
      type Address,
      type Hex,
    } from "viem";
    import { privateKeyToAccount } from "viem/accounts";
    import { arcTestnet } from "viem/chains";

    type Attestation = {
      message: string;
      attestation: string;
    };

    const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
    const starknetPrivateKey = process.env.STARKNET_PRIVATE_KEY;
    const starknetRpcUrl =
      process.env.STARKNET_RPC_URL ?? "https://starknet-sepolia-rpc.publicnode.com";

    if (!evmPrivateKey || !starknetPrivateKey) {
      throw new Error("Set EVM_PRIVATE_KEY and STARKNET_PRIVATE_KEY in .env");
    }

    const STARKNET_DOMAIN = 25;
    const ARC_TESTNET_DOMAIN = 26;

    const STARKNET_ACCOUNT_ADDRESS = "0xYOUR_STARKNET_ACCOUNT_ADDRESS";
    const STARKNET_USDC =
      "0x0512feAc6339Ff7889822cb5aA2a86C848e9D392bB0E3E237C008674feeD8343";
    const STARKNET_TOKEN_MESSENGER_MINTER =
      "0x04bDdE1E09a4B09a2F95d893D94a967b7717eB85A3f6dEcA8c080Ee01fBc3370";

    const ARC_MESSAGE_TRANSMITTER_V2 =
      "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275" as Address;

    const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals

    const DESTINATION_CALLER =
      "0x0000000000000000000000000000000000000000000000000000000000000000";

    const evmAccount = privateKeyToAccount(evmPrivateKey as Hex);
    const arcWalletClient = createWalletClient({
      account: evmAccount,
      chain: arcTestnet,
      transport: http(),
    });
    const arcPublicClient = createPublicClient({
      chain: arcTestnet,
      transport: http(),
    });

    const starknetProvider = new RpcProvider({ nodeUrl: starknetRpcUrl });
    const starknetAccount = new Account({
      provider: starknetProvider,
      address: STARKNET_ACCOUNT_ADDRESS,
      signer: starknetPrivateKey,
    });

    const messageTransmitterAbi = parseAbi([
      "function receiveMessage(bytes message, bytes attestation) returns (bool success)",
    ]);

    function toBytes32Address(address: string): string {
      return `0x${address.replace(/^0x/i, "").padStart(64, "0")}`;
    }

    function sanitizeTransactionHash(txHash: string): string {
      const hash = txHash.trim().toLowerCase();
      if (hash.length < 66) {
        return `0x${hash.replace(/^0x/i, "").padStart(64, "0")}`;
      }
      return hash;
    }

    async function getStarknetContract(address: string) {
      const { abi } = await starknetProvider.getClassAt(address);
      if (!abi) {
        throw new Error(`Failed to load ABI for ${address}`);
      }
      return new Contract({
        abi,
        address,
        providerOrAccount: starknetAccount,
      });
    }

    async function approveUSDC() {
      console.log("Approving Starknet USDC transfer...");

      const usdcContract = await getStarknetContract(STARKNET_USDC);
      const approveTx = await usdcContract.approve(
        STARKNET_TOKEN_MESSENGER_MINTER,
        AMOUNT,
      );
      const approveReceipt = await starknetProvider.waitForTransaction(
        approveTx.transaction_hash,
      );
      if (!approveReceipt.isSuccess()) {
        throw new Error(`USDC approval failed: ${approveTx.transaction_hash}`);
      }

      console.log(`Approval confirmed: ${approveTx.transaction_hash}`);
    }

    async function burnUSDC() {
      console.log(`Burning ${Number(AMOUNT) / 1_000_000} USDC on Starknet...`);

      const tokenMessengerMinter = await getStarknetContract(
        STARKNET_TOKEN_MESSENGER_MINTER,
      );
      const mintRecipient = toBytes32Address(evmAccount.address);

      const depositTx = await tokenMessengerMinter.invoke("deposit_for_burn", [
        AMOUNT,
        ARC_TESTNET_DOMAIN,
        mintRecipient,
        STARKNET_USDC,
        DESTINATION_CALLER,
        1500n, // maxFee — see Get the fee for your transfer
        1000, // minFinalityThreshold — Fast Transfer
      ]);

      const receipt = await starknetProvider.waitForTransaction(
        depositTx.transaction_hash,
      );
      if (!receipt.isSuccess()) {
        throw new Error(`Deposit-for-burn failed: ${depositTx.transaction_hash}`);
      }

      const txHash = sanitizeTransactionHash(depositTx.transaction_hash);
      console.log(`Deposit-for-burn confirmed: ${txHash}`);
      return txHash;
    }

    async function retrieveAttestation(
      transactionHash: string,
    ): Promise<Attestation> {
      const url = `https://iris-api-sandbox.circle.com/v2/messages/${STARKNET_DOMAIN}?transactionHash=${transactionHash}`;
      console.log(`Polling Iris V2 for attestation: ${url}`);

      while (true) {
        const response = await fetch(url);
        if (!response.ok && response.status !== 404) {
          throw new Error(`Iris V2 returned HTTP ${response.status}`);
        }

        if (response.ok) {
          const data = (await response.json()) as {
            messages?: Array<{ message?: string; attestation?: string }>;
          };
          const result = data.messages?.[0];

          if (
            result?.message &&
            result.attestation &&
            result.attestation !== "PENDING"
          ) {
            console.log("Attestation retrieved successfully!");
            return {
              message: result.message,
              attestation: result.attestation,
            };
          }
        }

        console.log("Waiting for attestation...");
        await new Promise((resolve) => setTimeout(resolve, 2_000));
      }
    }

    async function mintUSDC({ message, attestation }: Attestation) {
      console.log("Minting USDC on Arc...");

      const receiveTransactionHash = await arcWalletClient.writeContract({
        account: evmAccount,
        chain: arcTestnet,
        address: ARC_MESSAGE_TRANSMITTER_V2,
        abi: messageTransmitterAbi,
        functionName: "receiveMessage",
        args: [message as Hex, attestation as Hex],
      });
      const receiveReceipt = await arcPublicClient.waitForTransactionReceipt({
        hash: receiveTransactionHash,
      });
      if (receiveReceipt.status !== "success") {
        throw new Error(`Arc receiveMessage reverted: ${receiveTransactionHash}`);
      }

      console.log(`Mint confirmed: ${receiveTransactionHash}`);
      return receiveTransactionHash;
    }

    export default async function main() {
      await approveUSDC();
      const burnTransactionHash = await burnUSDC();
      const attestation = await retrieveAttestation(burnTransactionHash);
      const mintTransactionHash = await mintUSDC(attestation);
      console.log("USDC transfer from Starknet to Arc completed.");

      return {
        starknetTransactionHash: burnTransactionHash,
        evmTransactionHash: mintTransactionHash,
      };
    }

    if (
      process.argv[1] &&
      import.meta.url === pathToFileURL(resolve(process.argv[1])).href
    ) {
      console.log("Starting CCTP Starknet → Arc flow...");
      main().catch((error: unknown) => {
        console.error(error instanceof Error ? error.message : error);
        process.exitCode = 1;
      });
    }
    ```

    ## Step 5: Test the script

    Replace `STARKNET_ACCOUNT_ADDRESS` in `index.ts` with your Starknet Sepolia
    account address, then run:

    ```shell Shell theme={null}
    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:

    ```bash Shell theme={null}
    Starting CCTP Starknet → Arc flow...
    Approving Starknet USDC transfer...
    Approval confirmed: 0x...
    Burning 1 USDC on Starknet...
    Deposit-for-burn confirmed: 0x...
    Polling Iris V2 for attestation: https://iris-api-sandbox.circle.com/v2/messages/25?transactionHash=<starknet-transaction-hash>
    Attestation retrieved successfully!
    Minting USDC on Arc...
    Mint confirmed: 0x...
    USDC transfer from Starknet to Arc completed.
    ```

    Attestation polling can take several minutes depending on network conditions and
    the finality threshold you chose. The script retries every 2 seconds with no
    timeout, so allow the process to continue while Iris prepares the attestation.

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

  <Tab title="Arc to Starknet">
    This quickstart demonstrates how to transfer USDC from Arc Testnet to Starknet
    Sepolia using CCTP. You use [`viem`](https://viem.sh/) to approve and burn USDC
    on Arc, and [`starknet.js`](https://www.npmjs.com/package/starknet) to receive
    and mint USDC on Starknet. 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 Starknet account contracts helps you follow and adapt the script.
    Examples use Arc Testnet as the source, but you can use any
    [supported EVM blockchain](/cctp/concepts/supported-chains-and-domains).

    ## Prerequisites

    Before you begin this tutorial, ensure you have:

    * Installed [Node.js v22.6+](https://nodejs.org/)
    * Prepared an EVM wallet with the private key available for Arc Testnet
      * Added the Arc Testnet network to your wallet
        ([network details](https://docs.arc.io/arc/references/connect-to-arc#wallet-setup))
      * Funded your wallet with Arc Testnet USDC for the transfer amount from the
        [Circle Faucet](https://faucet.circle.com)
      * Funded your wallet with Arc Testnet ETH for gas fees
    * Prepared a Starknet Sepolia account with the address and private key available
      * Funded your account with testnet STRK from the
        [Starknet faucet](https://faucet.starknet.io/) for the receive transaction
        fee

    ## 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, initialize and set up a new Node.js project, and install
    the required dependencies:

    ```bash Shell theme={null}
    mkdir cctp-arc-to-starknet
    cd cctp-arc-to-starknet
    npm init -y

    npm pkg set type=module
    npm pkg set scripts.start="node --env-file=.env --import=tsx index.ts"

    npm install starknet viem

    npm install --save-dev tsx typescript @types/node
    ```

    ### 1.2. Initialize and configure the project

    <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. Configure environment variables

    Open `.env` in your editor and add:

    ```text theme={null}
    EVM_PRIVATE_KEY=YOUR_EVM_PRIVATE_KEY
    STARKNET_PRIVATE_KEY=YOUR_STARKNET_PRIVATE_KEY
    # STARKNET_RPC_URL=https://starknet-sepolia-rpc.publicnode.com
    ```

    * `EVM_PRIVATE_KEY` is the private key for the EVM wallet used to burn USDC on
      Arc Testnet.
    * `STARKNET_PRIVATE_KEY` is the private key for the Starknet Sepolia account
      that receives the minted USDC.
    * `STARKNET_RPC_URL` is optional. The script defaults to a public Starknet
      Sepolia RPC endpoint.

    <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 contract addresses, transfer amount, and wallet clients for Arc
    Testnet and Starknet Sepolia.

    ### 2.1. Define configuration constants

    The script predefines the Arc USDC and TokenMessengerV2 addresses, the Starknet
    MessageTransmitterV2 address, and the CCTP domain IDs. Replace
    `STARKNET_ACCOUNT_ADDRESS` with your Starknet Sepolia account address. Starknet
    account addresses are not derived from the private key alone, so you set the
    address as a constant.

    ```ts TypeScript theme={null}
    import {
      Account,
      CairoByteArray,
      CallData,
      Contract,
      RpcProvider,
    } from "starknet";
    import {
      createPublicClient,
      createWalletClient,
      http,
      parseAbi,
      type Address,
      type Hex,
    } from "viem";
    import { privateKeyToAccount } from "viem/accounts";
    import { arcTestnet } from "viem/chains";

    type Attestation = {
      message: string;
      attestation: string;
    };

    const STARKNET_DOMAIN = 25; // Destination domain ID for Starknet
    const ARC_TESTNET_DOMAIN = 26; // Source domain ID for Arc Testnet

    const STARKNET_ACCOUNT_ADDRESS = "0xYOUR_STARKNET_ACCOUNT_ADDRESS";

    const ARC_USDC_ADDRESS =
      "0x3600000000000000000000000000000000000000" as Address;
    const ARC_TOKEN_MESSENGER_V2 =
      "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA" as Address;

    const STARKNET_MESSAGE_TRANSMITTER =
      "0x04db7926C64f1f32a840F3Fa95cB551f3801a3600Bae87aF87807A54DCE12Fe8";

    const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals

    const DESTINATION_CALLER =
      "0x0000000000000000000000000000000000000000000000000000000000000000" as Hex;
    ```

    `ARC_TESTNET_DOMAIN` is the source domain used when requesting the attestation.
    The destination domain for Starknet is 25.

    ### 2.2. Set up wallet clients

    The wallet clients use `viem` for Arc Testnet and `starknet.js` for Starknet
    Sepolia. The Starknet account signs the transaction that calls
    `receive_message`.

    ```ts TypeScript theme={null}
    const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
    const starknetPrivateKey = process.env.STARKNET_PRIVATE_KEY;
    const starknetRpcUrl =
      process.env.STARKNET_RPC_URL ?? "https://starknet-sepolia-rpc.publicnode.com";

    if (!evmPrivateKey || !starknetPrivateKey) {
      throw new Error("Set EVM_PRIVATE_KEY and STARKNET_PRIVATE_KEY in .env");
    }

    const evmAccount = privateKeyToAccount(evmPrivateKey as Hex);
    const arcWalletClient = createWalletClient({
      account: evmAccount,
      chain: arcTestnet,
      transport: http(),
    });
    const arcPublicClient = createPublicClient({
      chain: arcTestnet,
      transport: http(),
    });

    const starknetProvider = new RpcProvider({ nodeUrl: starknetRpcUrl });
    const starknetAccount = new Account({
      provider: starknetProvider,
      address: STARKNET_ACCOUNT_ADDRESS,
      signer: starknetPrivateKey,
    });

    const usdcAbi = parseAbi([
      "function approve(address spender, uint256 amount) returns (bool)",
    ]);
    const tokenMessengerV2Abi = parseAbi([
      "function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold)",
    ]);
    ```

    ### 2.3. Format the Starknet recipient for Arc

    `depositForBurn` accepts the destination recipient as `bytes32`. The Starknet
    account address is left-padded with zeros to 32 bytes.

    ```ts TypeScript theme={null}
    function toBytes32Address(address: string): Hex {
      return `0x${address.replace(/^0x/i, "").padStart(64, "0")}`;
    }
    ```

    ## Step 3: Implement the transfer logic

    This step implements the core transfer logic: approve and burn on Arc, poll for
    an attestation, then receive and mint on Starknet. A successful run prints
    transaction hashes and a completion message in the console.

    ### 3.1. Approve USDC on Arc

    Grant approval for the
    [`TokenMessengerV2` contract](/cctp/references/contract-addresses) to withdraw
    USDC from your Arc wallet. This allows the contract to burn USDC when you
    initiate the transfer.

    ```ts TypeScript theme={null}
    async function approveUSDC() {
      console.log("Approving Arc USDC transfer...");

      const approvalHash = await arcWalletClient.writeContract({
        account: evmAccount,
        chain: arcTestnet,
        address: ARC_USDC_ADDRESS,
        abi: usdcAbi,
        functionName: "approve",
        args: [ARC_TOKEN_MESSENGER_V2, AMOUNT],
      });
      const approvalReceipt = await arcPublicClient.waitForTransactionReceipt({
        hash: approvalHash,
      });
      if (approvalReceipt.status !== "success") {
        throw new Error(`USDC approval reverted: ${approvalHash}`);
      }

      console.log(`Approval confirmed: ${approvalHash}`);
    }
    ```

    ### 3.2. Burn USDC on Arc

    Call `depositForBurn` to burn USDC on Arc. 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 (25 for
      Starknet)
    * **Mint recipient**: The Starknet account address that receives minted USDC,
      left-padded to 32 bytes
    * **Destination caller**: The zero address, allowing any caller to submit the
      receive transaction on Starknet
    * **Burn token**: The Arc Testnet USDC address
    * **Max fee** / **finality threshold**: This example uses Fast Transfer. For
      live fee values and allowance checks, see
      [Get the fee for your transfer](/cctp/howtos/get-transfer-fee) and
      [Get the fast transfer allowance](/cctp/howtos/get-fast-transfer-allowance).

    ```ts TypeScript theme={null}
    async function burnUSDC() {
      console.log(`Burning ${Number(AMOUNT) / 1_000_000} USDC on Arc...`);

      const mintRecipient = toBytes32Address(STARKNET_ACCOUNT_ADDRESS);
      const burnHash = await arcWalletClient.writeContract({
        account: evmAccount,
        chain: arcTestnet,
        address: ARC_TOKEN_MESSENGER_V2,
        abi: tokenMessengerV2Abi,
        functionName: "depositForBurn",
        args: [
          AMOUNT,
          STARKNET_DOMAIN,
          mintRecipient,
          ARC_USDC_ADDRESS,
          DESTINATION_CALLER,
          500n, // maxFee — see Get the fee for your transfer
          1000, // minFinalityThreshold — Fast Transfer
        ],
      });
      const burnReceipt = await arcPublicClient.waitForTransactionReceipt({
        hash: burnHash,
      });
      if (burnReceipt.status !== "success") {
        throw new Error(`Deposit-for-burn reverted: ${burnHash}`);
      }

      console.log(`Deposit-for-burn confirmed: ${burnHash}`);
      return burnHash;
    }
    ```

    ### 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 `ARC_TESTNET_DOMAIN` for the `sourceDomain` path parameter, using the
      [CCTP domain](/cctp/concepts/supported-chains-and-domains#domain-identifiers)
      for Arc Testnet (26).
    * Pass the Arc transaction hash returned by `burnUSDC` as `transactionHash`.

    ```ts TypeScript theme={null}
    async function retrieveAttestation(
      transactionHash: string,
    ): Promise<Attestation> {
      const url = `https://iris-api-sandbox.circle.com/v2/messages/${ARC_TESTNET_DOMAIN}?transactionHash=${transactionHash}`;
      console.log(`Polling Iris V2 for attestation: ${url}`);

      while (true) {
        const response = await fetch(url);
        if (!response.ok && response.status !== 404) {
          throw new Error(`Iris V2 returned HTTP ${response.status}`);
        }

        if (response.ok) {
          const data = (await response.json()) as {
            messages?: Array<{ message?: string; attestation?: string }>;
          };
          const result = data.messages?.[0];

          if (
            result?.message &&
            result.attestation &&
            result.attestation !== "PENDING"
          ) {
            console.log("Attestation retrieved successfully!");
            return {
              message: result.message,
              attestation: result.attestation,
            };
          }
        }

        console.log("Waiting for attestation...");
        await new Promise((resolve) => setTimeout(resolve, 2_000));
      }
    }
    ```

    ### 3.4. Receive and mint USDC on Starknet

    Call `receive_message` on the Starknet
    [`MessageTransmitterV2`](/cctp/references/starknet-contracts) with the message
    and attestation returned by Iris V2. Encode both values as Cairo byte arrays
    before compiling the calldata.

    ```ts TypeScript theme={null}
    async function mintUSDC({ message, attestation }: Attestation) {
      console.log("Minting USDC on Starknet...");

      const { abi } = await starknetProvider.getClassAt(
        STARKNET_MESSAGE_TRANSMITTER,
      );
      if (!abi) {
        throw new Error("Failed to load MessageTransmitter ABI");
      }

      const messageTransmitter = new Contract({
        abi,
        address: STARKNET_MESSAGE_TRANSMITTER,
        providerOrAccount: starknetAccount,
      });

      const messageBytes = new CairoByteArray(message);
      const attestationBytes = new CairoByteArray(attestation);
      const callData = CallData.compile([
        ...messageBytes.toApiRequest(),
        ...attestationBytes.toApiRequest(),
      ]);

      const receiveTx = await messageTransmitter.receive_message(callData);
      const receipt = await starknetProvider.waitForTransaction(
        receiveTx.transaction_hash,
      );
      if (!receipt.isSuccess()) {
        throw new Error(
          `Starknet receive_message failed: ${receiveTx.transaction_hash}`,
        );
      }

      console.log(`Mint confirmed: ${receiveTx.transaction_hash}`);
      return receiveTx.transaction_hash;
    }
    ```

    ## Step 4: Full script

    Create an `index.ts` file in your project directory and paste the full script
    following.

    ```ts index.ts expandable theme={null}
    import { resolve } from "node:path";
    import { pathToFileURL } from "node:url";
    import {
      Account,
      CairoByteArray,
      CallData,
      Contract,
      RpcProvider,
    } from "starknet";
    import {
      createPublicClient,
      createWalletClient,
      http,
      parseAbi,
      type Address,
      type Hex,
    } from "viem";
    import { privateKeyToAccount } from "viem/accounts";
    import { arcTestnet } from "viem/chains";

    type Attestation = {
      message: string;
      attestation: string;
    };

    const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
    const starknetPrivateKey = process.env.STARKNET_PRIVATE_KEY;
    const starknetRpcUrl =
      process.env.STARKNET_RPC_URL ?? "https://starknet-sepolia-rpc.publicnode.com";

    if (!evmPrivateKey || !starknetPrivateKey) {
      throw new Error("Set EVM_PRIVATE_KEY and STARKNET_PRIVATE_KEY in .env");
    }

    const STARKNET_DOMAIN = 25;
    const ARC_TESTNET_DOMAIN = 26;

    const STARKNET_ACCOUNT_ADDRESS = "0xYOUR_STARKNET_ACCOUNT_ADDRESS";

    const ARC_USDC_ADDRESS =
      "0x3600000000000000000000000000000000000000" as Address;
    const ARC_TOKEN_MESSENGER_V2 =
      "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA" as Address;

    const STARKNET_MESSAGE_TRANSMITTER =
      "0x04db7926C64f1f32a840F3Fa95cB551f3801a3600Bae87aF87807A54DCE12Fe8";

    const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals

    const DESTINATION_CALLER =
      "0x0000000000000000000000000000000000000000000000000000000000000000" as Hex;

    const evmAccount = privateKeyToAccount(evmPrivateKey as Hex);
    const arcWalletClient = createWalletClient({
      account: evmAccount,
      chain: arcTestnet,
      transport: http(),
    });
    const arcPublicClient = createPublicClient({
      chain: arcTestnet,
      transport: http(),
    });

    const starknetProvider = new RpcProvider({ nodeUrl: starknetRpcUrl });
    const starknetAccount = new Account({
      provider: starknetProvider,
      address: STARKNET_ACCOUNT_ADDRESS,
      signer: starknetPrivateKey,
    });

    const usdcAbi = parseAbi([
      "function approve(address spender, uint256 amount) returns (bool)",
    ]);
    const tokenMessengerV2Abi = parseAbi([
      "function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold)",
    ]);

    function toBytes32Address(address: string): Hex {
      return `0x${address.replace(/^0x/i, "").padStart(64, "0")}`;
    }

    async function approveUSDC() {
      console.log("Approving Arc USDC transfer...");

      const approvalHash = await arcWalletClient.writeContract({
        account: evmAccount,
        chain: arcTestnet,
        address: ARC_USDC_ADDRESS,
        abi: usdcAbi,
        functionName: "approve",
        args: [ARC_TOKEN_MESSENGER_V2, AMOUNT],
      });
      const approvalReceipt = await arcPublicClient.waitForTransactionReceipt({
        hash: approvalHash,
      });
      if (approvalReceipt.status !== "success") {
        throw new Error(`USDC approval reverted: ${approvalHash}`);
      }

      console.log(`Approval confirmed: ${approvalHash}`);
    }

    async function burnUSDC() {
      console.log(`Burning ${Number(AMOUNT) / 1_000_000} USDC on Arc...`);

      const mintRecipient = toBytes32Address(STARKNET_ACCOUNT_ADDRESS);
      const burnHash = await arcWalletClient.writeContract({
        account: evmAccount,
        chain: arcTestnet,
        address: ARC_TOKEN_MESSENGER_V2,
        abi: tokenMessengerV2Abi,
        functionName: "depositForBurn",
        args: [
          AMOUNT,
          STARKNET_DOMAIN,
          mintRecipient,
          ARC_USDC_ADDRESS,
          DESTINATION_CALLER,
          500n, // maxFee — see Get the fee for your transfer
          1000, // minFinalityThreshold — Fast Transfer
        ],
      });
      const burnReceipt = await arcPublicClient.waitForTransactionReceipt({
        hash: burnHash,
      });
      if (burnReceipt.status !== "success") {
        throw new Error(`Deposit-for-burn reverted: ${burnHash}`);
      }

      console.log(`Deposit-for-burn confirmed: ${burnHash}`);
      return burnHash;
    }

    async function retrieveAttestation(
      transactionHash: string,
    ): Promise<Attestation> {
      const url = `https://iris-api-sandbox.circle.com/v2/messages/${ARC_TESTNET_DOMAIN}?transactionHash=${transactionHash}`;
      console.log(`Polling Iris V2 for attestation: ${url}`);

      while (true) {
        const response = await fetch(url);
        if (!response.ok && response.status !== 404) {
          throw new Error(`Iris V2 returned HTTP ${response.status}`);
        }

        if (response.ok) {
          const data = (await response.json()) as {
            messages?: Array<{ message?: string; attestation?: string }>;
          };
          const result = data.messages?.[0];

          if (
            result?.message &&
            result.attestation &&
            result.attestation !== "PENDING"
          ) {
            console.log("Attestation retrieved successfully!");
            return {
              message: result.message,
              attestation: result.attestation,
            };
          }
        }

        console.log("Waiting for attestation...");
        await new Promise((resolve) => setTimeout(resolve, 2_000));
      }
    }

    async function mintUSDC({ message, attestation }: Attestation) {
      console.log("Minting USDC on Starknet...");

      const { abi } = await starknetProvider.getClassAt(
        STARKNET_MESSAGE_TRANSMITTER,
      );
      if (!abi) {
        throw new Error("Failed to load MessageTransmitter ABI");
      }

      const messageTransmitter = new Contract({
        abi,
        address: STARKNET_MESSAGE_TRANSMITTER,
        providerOrAccount: starknetAccount,
      });

      const messageBytes = new CairoByteArray(message);
      const attestationBytes = new CairoByteArray(attestation);
      const callData = CallData.compile([
        ...messageBytes.toApiRequest(),
        ...attestationBytes.toApiRequest(),
      ]);

      const receiveTx = await messageTransmitter.receive_message(callData);
      const receipt = await starknetProvider.waitForTransaction(
        receiveTx.transaction_hash,
      );
      if (!receipt.isSuccess()) {
        throw new Error(
          `Starknet receive_message failed: ${receiveTx.transaction_hash}`,
        );
      }

      console.log(`Mint confirmed: ${receiveTx.transaction_hash}`);
      return receiveTx.transaction_hash;
    }

    export default async function main() {
      await approveUSDC();
      const burnTransactionHash = await burnUSDC();
      const attestation = await retrieveAttestation(burnTransactionHash);
      const mintTransactionHash = await mintUSDC(attestation);
      console.log("USDC transfer from Arc to Starknet completed.");

      return {
        evmTransactionHash: burnTransactionHash,
        starknetTransactionHash: mintTransactionHash,
      };
    }

    if (
      process.argv[1] &&
      import.meta.url === pathToFileURL(resolve(process.argv[1])).href
    ) {
      console.log("Starting CCTP Arc → Starknet flow...");
      main().catch((error: unknown) => {
        console.error(error instanceof Error ? error.message : error);
        process.exitCode = 1;
      });
    }
    ```

    ## Step 5: Test the script

    Replace `STARKNET_ACCOUNT_ADDRESS` in `index.ts` with your Starknet Sepolia
    account address, then run:

    ```shell Shell theme={null}
    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:

    ```bash Shell theme={null}
    Starting CCTP Arc → Starknet flow...
    Approving Arc USDC transfer...
    Approval confirmed: 0x...
    Burning 1 USDC on Arc...
    Deposit-for-burn confirmed: 0x...
    Polling Iris V2 for attestation: https://iris-api-sandbox.circle.com/v2/messages/26?transactionHash=<arc-transaction-hash>
    Attestation retrieved successfully!
    Minting USDC on Starknet...
    Mint confirmed: 0x...
    USDC transfer from Arc to Starknet completed.
    ```

    Attestation polling can take several minutes depending on network conditions and
    the finality threshold you chose. The script retries every 2 seconds with no
    timeout, so allow the process to continue while Iris prepares the attestation.

    <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>
  </Tab>
</Tabs>
