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

> ## Agent Instructions
> Before reading these docs, install the relevant Circle Skill to get pre-loaded context — skills are LLM-optimized and cover architecture decisions, correct flows, and common pitfalls. In Claude Code: run /plugin marketplace add circlefin/skills then /plugin install circle-skills@circle. With Vercel Skills CLI: run npx skills add circlefin/skills. Available skills: use-usdc https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-usdc/SKILL.md (balances, transfers, approvals on EVM and Solana); EURC has no skill — see https://developers.circle.com/stablecoins/what-is-eurc.md; bridge-stablecoin https://github.com/circlefin/skills/blob/master/plugins/circle/skills/bridge-stablecoin/SKILL.md (crosschain transfers via CCTP, USDC, EURC, and third-party assets); use-arc https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-arc/SKILL.md (Arc chain config, contract deployment, USDC bridging — Arc docs: https://docs.arc.io/llms.txt); use-circle-wallets https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-circle-wallets/SKILL.md (choose wallet type); use-developer-controlled-wallets https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-developer-controlled-wallets/SKILL.md (custodial wallets for payouts and treasury); use-user-controlled-wallets https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-user-controlled-wallets/SKILL.md (embedded wallets with Web2 login); use-modular-wallets https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-modular-wallets/SKILL.md (smart contract wallets with passkeys and ERC-4337); use-gateway https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-gateway/SKILL.md (unified USDC balance, nanopayments); use-smart-contract-platform https://github.com/circlefin/skills/blob/master/plugins/circle/skills/use-smart-contract-platform/SKILL.md (deploy, interact, monitor contracts). Pair any skill with the Circle MCP server for live SDK signatures, contract addresses, and chain IDs: {"mcpServers":{"circle":{"url":"https://api.circle.com/v1/codegen/mcp"}}}.
> Read the product overview first. Each product has an overview explaining when and why to use it. Read it before quickstarts.
> Pick the right wallet type. Developer-controlled (you hold keys), user-controlled (end user holds keys via PIN/social login), or modular (smart contract wallets with passkeys). See https://developers.circle.com/wallets.md.
> Use CCTP V2 for crosschain transfers. CCTP natively transfers USDC and EURC via burn-and-mint, and supports permissionless wrapping of third-party assets via lock-and-mint. V1 is legacy; always use V2 unless V1 is specified or the chain requires it (Noble, Sui, and Aptos still require V1). Migration guide: https://developers.circle.com/cctp/migration-from-v1-to-v2.md.
> Use Bridge Kit for frontend bridging. Wraps CCTP with a simpler SDK for user-facing apps. Use CCTP directly for backend transfers.
> Gas Station vs Paymaster. Gas Station sponsors gas for Circle Wallet transactions. Paymaster lets users pay gas in USDC. Different use cases — read both overviews.
> Gateway Nanopayments for sub-cent payments. Gasless USDC micropayments down to $0.000001 via x402 and batched settlement. For pay-per-request APIs, AI agent payments, streaming.
> Look up USDC addresses per chain. Never hardcode — use https://developers.circle.com/stablecoins/usdc-contract-addresses.md.
> Prefer SDKs over raw API calls. Node.js and Python SDKs handle auth, retries, and errors.
> API key required. Bearer token in Authorization header. Testnet and mainnet use separate keys and may use different base URLs depending on the product.
> Set up webhooks when available. Most operations are async. Webhooks deliver transaction confirmations and state changes.
> When calling list endpoints, paginate using pageSize and pageAfter until no nextPageAfter cursor is returned—stopping at the first page silently misses records.
> Building an AI agent? Start with the Agent Stack—Circle CLI, agent wallets, and nanopayments built for autonomous use cases: https://developers.circle.com/agent-stack.md.

# Transfer USDC between Sui and Arc

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

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

<Note>
  On Sui, CCTP V2 burn and mint are multi-step programmable transactions. Each
  flow calls `TokenMessengerMinterV2`, `MessageTransmitterV2`, and the
  `StablecoinHandler` in one transaction. Package and State object IDs differ from
  [CCTP V1 on Sui](/cctp/v1/sui-packages).

  These examples treat Sui USDC as a
  [SIP-58 address balance](https://docs.sui.io/onchain-finance/asset-custody/address-balances):
  burn redeems with `0x2::coin::redeem_funds`, and after mint a follow-up
  `0x2::coin::send_funds` folds the `public_transfer`'d Coin into the recipient
  address balance. Omit these two transactions if you're not using SIP-58
  balances.
</Note>

Pick the tab that matches the direction of your transfer.

<Tabs>
  <Tab title="Sui to Arc">
    This quickstart demonstrates how to transfer USDC from Sui Testnet to Arc
    Testnet using CCTP. You use the
    [`@mysten/sui`](https://www.npmjs.com/package/@mysten/sui) SDK to burn USDC on
    Sui, 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 Sui
    programmable 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 EVM blockchain](/cctp/concepts/supported-chains-and-domains).

    <Note>
      This script uses `SuiGrpcClient`, which requires a Sui **gRPC** endpoint (not
      JSON-RPC). The default fullnode URL below serves gRPC on port `443`.
    </Note>

    ## Prerequisites

    Before you begin this tutorial, ensure you have:

    * Installed [Node.js v22.6+](https://nodejs.org/)
    * Prepared a Sui Testnet wallet with the private key available (`suiprivkey1…`)
      * Funded your account with testnet SUI for transaction fees
      * Funded your account with Sui Testnet USDC from the
        [Circle Faucet](https://faucet.circle.com) as an
        [address balance](https://docs.sui.io/onchain-finance/asset-custody/address-balances)
        (SIP-58). If USDC is only held as Coin objects, deposit it first with
        `0x2::coin::send_funds`.
    * 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-sui-to-arc
    cd cctp-sui-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 @mysten/sui 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
    SUI_PRIVATE_KEY=YOUR_SUI_PRIVATE_KEY
    ```

    * `EVM_PRIVATE_KEY` is the private key for the EVM wallet used to receive USDC
      on Arc Testnet.
    * `SUI_PRIVATE_KEY` is the Sui Testnet private key (`suiprivkey1…`) used to sign
      the burn transaction.

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

    ### 2.1. Define configuration constants

    The script predefines the Sui CCTP V2 package and State object IDs, the Arc
    `MessageTransmitterV2` address, the transfer amount, and the CCTP domain IDs.

    ```ts TypeScript theme={null}
    import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
    import { SuiGrpcClient } from "@mysten/sui/grpc";
    import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
    import { Transaction } from "@mysten/sui/transactions";
    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 SUI_DOMAIN = 8; // Source domain ID for Sui Testnet
    const ARC_DOMAIN = 26; // Destination domain ID for Arc Testnet

    const ARC_MESSAGE_TRANSMITTER =
      "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275" as Address;

    const IRIS_API_URL = "https://iris-api-sandbox.circle.com";

    /** Sandbox Sui CCTP V2 package and State object IDs. */
    const CCTP_V2_CONFIG = {
      mtV2PackageId:
        "0xe9678cd42a81886e18e21361088071f275105636a4d3751e1d7f255211f73bbe",
      mtV2StateId:
        "0xfee3a2b47f9ef2de2405fc63d79194307945f8ea768815cfc46083bc20fbe6ed",
      tmmV2PackageId:
        "0x267d3c0cb776eace2840f27e4d33da9c6d952f9749403f1fe6579f4962ed3c64",
      tmmV2StateId:
        "0x72cb55cd14d01e6361386d6ea93eecda9fa1efc3c202b8dd69c1e4683e4c0ca0",
      handlerPackageId:
        "0xbe8479044396a45e07de2c7f14789c35b8338406eebc53b2527da56839a91561",
      handlerStateId:
        "0xfbd9c0517c4f0e1817445ee2be598806e3c392a465d7e3d773d1055f3f0eed32",
      usdcPackageId:
        "0xa1ec7fc00a6f40db9693ad1415d0c193ad3906494428cf252621037bd7117e29",
      treasuryId:
        "0x7170137d4a6431bf83351ac025baf462909bffe2877d87716374fb42b9629ebe",
    };
    const USDC_TYPE = `${CCTP_V2_CONFIG.usdcPackageId}::usdc::USDC`;

    const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals
    const MAX_FEE = 0n;
    const MIN_FINALITY_THRESHOLD = 2_000; // Standard Transfer
    ```

    `SUI_DOMAIN` is the source domain used when requesting the attestation. The
    destination domain for Arc Testnet is 26. Sui Testnet 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 Sui client uses
    `SuiGrpcClient` on Testnet and signs transactions with an Ed25519 keypair
    decoded from `SUI_PRIVATE_KEY`.

    ```ts TypeScript theme={null}
    const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
    const suiPrivateKey = process.env.SUI_PRIVATE_KEY;

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

    const account = privateKeyToAccount(evmPrivateKey as Hex);
    const walletClient = createWalletClient({
      account,
      chain: arcTestnet,
      transport: http(),
    });
    const publicClient = createPublicClient({
      chain: arcTestnet,
      transport: http(),
    });
    const suiClient = new SuiGrpcClient({
      network: "testnet",
      baseUrl: "https://fullnode.testnet.sui.io:443",
    });
    const suiSigner = Ed25519Keypair.fromSecretKey(
      decodeSuiPrivateKey(suiPrivateKey).secretKey,
    );

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

    ### 2.3. Format the Arc recipient for Sui

    Sui `deposit_for_burn` accepts the mint recipient as a 32-byte address. An EVM
    address is converted by left-padding it with zeros.

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

    ## Step 3: Implement the transfer logic

    This step implements the core transfer logic: burn on Sui, poll for an
    attestation, then mint on Arc. The Sui burn is a single programmable
    transaction: redeem from the SIP-58 address balance, then the CCTP Move calls.

    ### 3.1. Burn USDC on Sui

    Build a programmable transaction that:

    1. Calls `0x2::coin::redeem_funds` to unwrap the burn amount from the sender's
       [address balance](https://docs.sui.io/onchain-finance/asset-custody/address-balances)
       into a `Coin<USDC>`
    2. Calls `deposit_for_burn` on `TokenMessengerMinterV2`
    3. Calls `handler::burn` on the `StablecoinHandler`
    4. Calls `complete_burn` to emit the CCTP message

    You specify the following parameters for `deposit_for_burn`:

    * **Burn amount**: The amount of USDC to burn in Sui 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
    * **Destination caller**: The zero address, allowing any caller to submit the
      receive transaction on Arc
    * **Max fee**: The maximum [fee](/cctp/concepts/fees) allowed for the transfer
    * **Finality threshold**: `2000` for a
      [Standard Transfer](/cctp/concepts/finality-and-block-confirmations#standard-transfer-attestation-times)

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

      const tx = new Transaction();

      const [coin] = tx.moveCall({
        target: "0x2::coin::redeem_funds",
        typeArguments: [USDC_TYPE],
        arguments: [tx.withdrawal({ amount: AMOUNT, type: USDC_TYPE })],
      });

      const [burnReceipt, returnedCoin] = tx.moveCall({
        target: `${CCTP_V2_CONFIG.tmmV2PackageId}::deposit_for_burn::deposit_for_burn`,
        arguments: [
          coin,
          tx.pure.u32(ARC_DOMAIN),
          tx.pure.address(padAddressToBytes32(account.address)),
          tx.pure.address("0x0"),
          tx.pure.u256(MAX_FEE),
          tx.pure.u32(MIN_FINALITY_THRESHOLD),
          tx.pure.vector("u8", []),
          tx.object(CCTP_V2_CONFIG.tmmV2StateId),
        ],
        typeArguments: [USDC_TYPE],
      });

      const [completeBurnTicket] = tx.moveCall({
        target: `${CCTP_V2_CONFIG.handlerPackageId}::handler::burn`,
        arguments: [
          tx.object(CCTP_V2_CONFIG.handlerStateId),
          burnReceipt,
          returnedCoin,
          tx.object("0x403"), // Sui deny list
          tx.object(CCTP_V2_CONFIG.treasuryId),
        ],
      });

      tx.moveCall({
        target: `${CCTP_V2_CONFIG.tmmV2PackageId}::deposit_for_burn::complete_burn`,
        arguments: [
          completeBurnTicket,
          tx.object(CCTP_V2_CONFIG.tmmV2StateId),
          tx.object(CCTP_V2_CONFIG.mtV2StateId),
        ],
        typeArguments: [
          USDC_TYPE,
          `${CCTP_V2_CONFIG.handlerPackageId}::handler::Auth`,
        ],
      });

      const digest = await executeTransaction(tx);
      console.log(
        `Deposit-for-burn confirmed: https://suiscan.xyz/testnet/tx/${digest}`,
      );
      return digest;
    }
    ```

    ### 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-reference/cctp/all/get-messages-v2)
      API endpoint to retrieve the attestation.
    * Pass `SUI_DOMAIN` for the `sourceDomain` path parameter, using the
      [CCTP domain](/cctp/concepts/supported-chains-and-domains#domain-identifiers)
      for Sui (8).
    * Pass the Sui transaction digest returned by `burnUSDC` as `transactionHash`.
      Sui digests are base58 and must not include a `0x` prefix.

    ```ts TypeScript theme={null}
    async function retrieveAttestation(
      transactionHash: string,
    ): Promise<Attestation> {
      const url = `${IRIS_API_URL}/v2/messages/${SUI_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" &&
            result.message !== "0x"
          ) {
            return {
              message: result.message,
              attestation: result.attestation,
            };
          }
        }

        await new Promise((resolve) => setTimeout(resolve, 2_000));
      }
    }
    ```

    ### 3.3. 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("Receiving the message on Arc...");

      const receiveTransactionHash = await walletClient.writeContract({
        account,
        chain: arcTestnet,
        address: ARC_MESSAGE_TRANSMITTER,
        abi: messageTransmitterAbi,
        functionName: "receiveMessage",
        args: [message as Hex, attestation as Hex],
      });
      const receiveReceipt = await publicClient.waitForTransactionReceipt({
        hash: receiveTransactionHash,
      });
      if (receiveReceipt.status !== "success") {
        throw new Error(
          `EVM receive transaction reverted: ${receiveTransactionHash}`,
        );
      }
      console.log(`Receive 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 { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
    import { SuiGrpcClient } from "@mysten/sui/grpc";
    import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
    import { Transaction } from "@mysten/sui/transactions";
    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 SUI_DOMAIN = 8;
    const ARC_DOMAIN = 26;

    const ARC_MESSAGE_TRANSMITTER =
      "0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275" as Address;
    const IRIS_API_URL = "https://iris-api-sandbox.circle.com";

    const CCTP_V2_CONFIG = {
      mtV2PackageId:
        "0xe9678cd42a81886e18e21361088071f275105636a4d3751e1d7f255211f73bbe",
      mtV2StateId:
        "0xfee3a2b47f9ef2de2405fc63d79194307945f8ea768815cfc46083bc20fbe6ed",
      tmmV2PackageId:
        "0x267d3c0cb776eace2840f27e4d33da9c6d952f9749403f1fe6579f4962ed3c64",
      tmmV2StateId:
        "0x72cb55cd14d01e6361386d6ea93eecda9fa1efc3c202b8dd69c1e4683e4c0ca0",
      handlerPackageId:
        "0xbe8479044396a45e07de2c7f14789c35b8338406eebc53b2527da56839a91561",
      handlerStateId:
        "0xfbd9c0517c4f0e1817445ee2be598806e3c392a465d7e3d773d1055f3f0eed32",
      usdcPackageId:
        "0xa1ec7fc00a6f40db9693ad1415d0c193ad3906494428cf252621037bd7117e29",
      treasuryId:
        "0x7170137d4a6431bf83351ac025baf462909bffe2877d87716374fb42b9629ebe",
    };
    const USDC_TYPE = `${CCTP_V2_CONFIG.usdcPackageId}::usdc::USDC`;

    const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals
    const MAX_FEE = 0n;
    const MIN_FINALITY_THRESHOLD = 2_000; // Standard Transfer

    const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
    const suiPrivateKey = process.env.SUI_PRIVATE_KEY;

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

    const account = privateKeyToAccount(evmPrivateKey as Hex);
    const walletClient = createWalletClient({
      account,
      chain: arcTestnet,
      transport: http(),
    });
    const publicClient = createPublicClient({
      chain: arcTestnet,
      transport: http(),
    });
    const suiClient = new SuiGrpcClient({
      network: "testnet",
      baseUrl: "https://fullnode.testnet.sui.io:443",
    });
    const suiSigner = Ed25519Keypair.fromSecretKey(
      decodeSuiPrivateKey(suiPrivateKey).secretKey,
    );

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

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

    async function executeTransaction(transaction: Transaction) {
      const res = await suiClient.signAndExecuteTransaction({
        signer: suiSigner,
        transaction,
        include: { effects: true, events: true },
      });
      const body =
        (
          res as {
            Transaction?: {
              digest?: string;
              status?: { success?: boolean; error?: string | null };
            };
          }
        ).Transaction ??
        (
          res as {
            FailedTransaction?: {
              digest?: string;
              status?: { success?: boolean; error?: string | null };
            };
          }
        ).FailedTransaction ??
        {};
      const digest = body.digest ?? "";
      await suiClient.waitForTransaction({ digest });
      if (!body.status?.success) {
        console.dir(res, { depth: null, colors: true });
        throw new Error(`Transaction failed! ${body.status?.error ?? ""}`);
      }
      return digest;
    }

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

      const tx = new Transaction();

      const [coin] = tx.moveCall({
        target: "0x2::coin::redeem_funds",
        typeArguments: [USDC_TYPE],
        arguments: [tx.withdrawal({ amount: AMOUNT, type: USDC_TYPE })],
      });

      const [burnReceipt, returnedCoin] = tx.moveCall({
        target: `${CCTP_V2_CONFIG.tmmV2PackageId}::deposit_for_burn::deposit_for_burn`,
        arguments: [
          coin,
          tx.pure.u32(ARC_DOMAIN),
          tx.pure.address(padAddressToBytes32(account.address)),
          tx.pure.address("0x0"),
          tx.pure.u256(MAX_FEE),
          tx.pure.u32(MIN_FINALITY_THRESHOLD),
          tx.pure.vector("u8", []),
          tx.object(CCTP_V2_CONFIG.tmmV2StateId),
        ],
        typeArguments: [USDC_TYPE],
      });

      const [completeBurnTicket] = tx.moveCall({
        target: `${CCTP_V2_CONFIG.handlerPackageId}::handler::burn`,
        arguments: [
          tx.object(CCTP_V2_CONFIG.handlerStateId),
          burnReceipt,
          returnedCoin,
          tx.object("0x403"),
          tx.object(CCTP_V2_CONFIG.treasuryId),
        ],
      });

      tx.moveCall({
        target: `${CCTP_V2_CONFIG.tmmV2PackageId}::deposit_for_burn::complete_burn`,
        arguments: [
          completeBurnTicket,
          tx.object(CCTP_V2_CONFIG.tmmV2StateId),
          tx.object(CCTP_V2_CONFIG.mtV2StateId),
        ],
        typeArguments: [
          USDC_TYPE,
          `${CCTP_V2_CONFIG.handlerPackageId}::handler::Auth`,
        ],
      });

      const digest = await executeTransaction(tx);
      console.log(
        `Deposit-for-burn confirmed: https://suiscan.xyz/testnet/tx/${digest}`,
      );
      return digest;
    }

    async function retrieveAttestation(
      transactionHash: string,
    ): Promise<Attestation> {
      const url = `${IRIS_API_URL}/v2/messages/${SUI_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" &&
            result.message !== "0x"
          ) {
            return {
              message: result.message,
              attestation: result.attestation,
            };
          }
        }

        await new Promise((resolve) => setTimeout(resolve, 2_000));
      }
    }

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

      const receiveTransactionHash = await walletClient.writeContract({
        account,
        chain: arcTestnet,
        address: ARC_MESSAGE_TRANSMITTER,
        abi: messageTransmitterAbi,
        functionName: "receiveMessage",
        args: [message as Hex, attestation as Hex],
      });
      const receiveReceipt = await publicClient.waitForTransactionReceipt({
        hash: receiveTransactionHash,
      });
      if (receiveReceipt.status !== "success") {
        throw new Error(
          `EVM receive transaction reverted: ${receiveTransactionHash}`,
        );
      }
      console.log(`Receive confirmed: ${receiveTransactionHash}`);

      return receiveTransactionHash;
    }

    export default async function main() {
      const burnDigest = await burnUSDC();
      const attestation = await retrieveAttestation(burnDigest);
      console.log("Attestation received from Iris V2.");
      const receiveTransactionHash = await mintUSDC(attestation);
      console.log("USDC transfer completed.");

      return {
        suiTransactionDigest: burnDigest,
        evmTransactionHash: receiveTransactionHash,
      };
    }

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

    ## Step 5: Test the script

    Run the following command to execute the script:

    ```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 flow...
    Burning 1 USDC on Sui...
    Deposit-for-burn confirmed: https://suiscan.xyz/testnet/tx/<sui-transaction-digest>
    Polling Iris V2 for attestation: https://iris-api-sandbox.circle.com/v2/messages/8?transactionHash=<sui-transaction-digest>
    Attestation received from Iris V2.
    Receiving the message on Arc...
    Receive confirmed: 0x...
    USDC transfer 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 Sui">
    This quickstart demonstrates how to transfer USDC from Arc Testnet to Sui
    Testnet using CCTP. You use [`viem`](https://viem.sh/) to approve and burn USDC
    on Arc, and the [`@mysten/sui`](https://www.npmjs.com/package/@mysten/sui) SDK
    to receive and mint USDC on Sui. 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 Sui programmable transactions 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).

    <Note>
      This script uses `SuiGrpcClient`, which requires a Sui **gRPC** endpoint (not
      JSON-RPC). The default fullnode URL below serves gRPC on port `443`.
    </Note>

    ## 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 Sui Testnet wallet with the private key available (`suiprivkey1…`)
      * Funded your account with testnet SUI 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-sui
    cd cctp-arc-to-sui
    npm init -y

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

    npm install @mysten/sui 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
    SUI_PRIVATE_KEY=YOUR_SUI_PRIVATE_KEY
    ```

    * `EVM_PRIVATE_KEY` is the private key for the EVM wallet used to burn USDC on
      Arc Testnet.
    * `SUI_PRIVATE_KEY` is the Sui Testnet private key (`suiprivkey1…`) for the
      account that receives the minted USDC and signs the receive transaction.

    <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, finality settings, and wallet
    clients for Arc Testnet and Sui Testnet.

    ### 2.1. Define configuration constants

    The script predefines the Arc USDC and TokenMessengerV2 addresses, the Sui CCTP
    V2 package and State object IDs, and the CCTP domain IDs.

    ```ts TypeScript theme={null}
    import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
    import { SuiGrpcClient } from "@mysten/sui/grpc";
    import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
    import { Transaction } from "@mysten/sui/transactions";
    import { normalizeSuiAddress } from "@mysten/sui/utils";
    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 SUI_DOMAIN = 8; // Destination domain ID for Sui Testnet
    const ARC_DOMAIN = 26; // Source domain ID for Arc Testnet

    const ARC_USDC = "0x3600000000000000000000000000000000000000" as Address;
    const ARC_TOKEN_MESSENGER =
      "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA" as Address;

    const IRIS_API_URL = "https://iris-api-sandbox.circle.com";

    /** Sandbox Sui CCTP V2 package and State object IDs. */
    const CCTP_V2_CONFIG = {
      mtV2PackageId:
        "0xe9678cd42a81886e18e21361088071f275105636a4d3751e1d7f255211f73bbe",
      mtV2StateId:
        "0xfee3a2b47f9ef2de2405fc63d79194307945f8ea768815cfc46083bc20fbe6ed",
      tmmV2PackageId:
        "0x267d3c0cb776eace2840f27e4d33da9c6d952f9749403f1fe6579f4962ed3c64",
      tmmV2StateId:
        "0x72cb55cd14d01e6361386d6ea93eecda9fa1efc3c202b8dd69c1e4683e4c0ca0",
      handlerPackageId:
        "0xbe8479044396a45e07de2c7f14789c35b8338406eebc53b2527da56839a91561",
      handlerStateId:
        "0xfbd9c0517c4f0e1817445ee2be598806e3c392a465d7e3d773d1055f3f0eed32",
      usdcPackageId:
        "0xa1ec7fc00a6f40db9693ad1415d0c193ad3906494428cf252621037bd7117e29",
      treasuryId:
        "0x7170137d4a6431bf83351ac025baf462909bffe2877d87716374fb42b9629ebe",
    };
    const USDC_TYPE = `${CCTP_V2_CONFIG.usdcPackageId}::usdc::USDC`;

    const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals
    const MAX_FEE = 0n;
    const MIN_FINALITY_THRESHOLD = 2_000; // Standard Transfer
    ```

    `ARC_DOMAIN` is the source domain used when requesting the attestation. The
    destination domain for Sui Testnet is 8. This example uses a
    [Standard Transfer](/cctp/concepts/finality-and-block-confirmations#standard-transfer-attestation-times)
    (`minFinalityThreshold` of `2000`) with a maximum fee of zero.

    ### 2.2. Set up wallet clients

    The wallet clients use `viem` for Arc Testnet and `@mysten/sui` for Sui Testnet.
    The Sui keypair signs the programmable transaction that receives and mints USDC.

    ```ts TypeScript theme={null}
    const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
    const suiPrivateKey = process.env.SUI_PRIVATE_KEY;

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

    const account = privateKeyToAccount(evmPrivateKey as Hex);
    const walletClient = createWalletClient({
      account,
      chain: arcTestnet,
      transport: http(),
    });
    const publicClient = createPublicClient({
      chain: arcTestnet,
      transport: http(),
    });
    const suiClient = new SuiGrpcClient({
      network: "testnet",
      baseUrl: "https://fullnode.testnet.sui.io:443",
    });
    const suiSigner = Ed25519Keypair.fromSecretKey(
      decodeSuiPrivateKey(suiPrivateKey).secretKey,
    );

    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 Sui recipient for Arc

    `depositForBurn` accepts the destination recipient as `bytes32`. A Sui address
    is already a 32-byte value, so the script passes it as a hexadecimal value to
    `viem`.

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

    ## 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 Sui. After mint, a follow-up
    `0x2::coin::send_funds` folds the minted Coin into the recipient's
    [SIP-58 address balance](https://docs.sui.io/onchain-finance/asset-custody/address-balances).
    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 walletClient.writeContract({
        account,
        chain: arcTestnet,
        address: ARC_USDC,
        abi: usdcAbi,
        functionName: "approve",
        args: [ARC_TOKEN_MESSENGER, AMOUNT],
      });
      const approvalReceipt = await publicClient.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 (8 for Sui
      Testnet)
    * **Mint recipient**: The Sui account address that receives minted USDC
    * **Destination caller**: The zero address, allowing any caller to submit the
      receive transaction on Sui
    * **Burn token**: The Arc Testnet USDC address
    * **Max fee**: The maximum [fee](/cctp/concepts/fees) allowed for the transfer
    * **Finality threshold**: `2000` for a
      [Standard Transfer](/cctp/concepts/finality-and-block-confirmations#standard-transfer-attestation-times)

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

      const burnHash = await walletClient.writeContract({
        account,
        chain: arcTestnet,
        address: ARC_TOKEN_MESSENGER,
        abi: tokenMessengerV2Abi,
        functionName: "depositForBurn",
        args: [
          AMOUNT,
          SUI_DOMAIN,
          padAddressToBytes32(suiSigner.toSuiAddress()),
          ARC_USDC,
          `0x${"0".repeat(64)}` as Hex,
          MAX_FEE,
          MIN_FINALITY_THRESHOLD,
        ],
      });
      const burnReceipt = await publicClient.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_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 = `${IRIS_API_URL}/v2/messages/${ARC_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" &&
            result.message !== "0x"
          ) {
            return {
              message: result.message,
              attestation: result.attestation,
            };
          }
        }

        await new Promise((resolve) => setTimeout(resolve, 2_000));
      }
    }
    ```

    ### 3.4. Receive and mint USDC on Sui

    Build a programmable transaction that:

    1. Calls `receive_message` on `MessageTransmitterV2`
    2. Calls `prepare_mint` on `TokenMessengerMinterV2`
    3. Calls `handler::mint` on the `StablecoinHandler`
    4. Calls `complete_mint` to finish the mint

    CCTP mints a `Coin<USDC>` and `public_transfer`s it to the recipient. Folding
    that Coin into an address balance requires a separate transaction (step 3.5),
    because the recipient cannot sign `send_funds` inside the CCTP receive PTB.

    ```ts TypeScript theme={null}
    async function receiveMessage({ message, attestation }: Attestation) {
      console.log("Receiving the message on Sui...");

      const messageBytes = hexToBytes(message);
      const attestationBytes = hexToBytes(attestation);
      const tx = new Transaction();

      const [receipt] = tx.moveCall({
        target: `${CCTP_V2_CONFIG.mtV2PackageId}::receive_message::receive_message`,
        arguments: [
          tx.pure.vector("u8", Array.from(messageBytes)),
          tx.pure.vector("u8", Array.from(attestationBytes)),
          tx.object(CCTP_V2_CONFIG.mtV2StateId),
        ],
      });

      const [mintReceipt] = tx.moveCall({
        target: `${CCTP_V2_CONFIG.tmmV2PackageId}::handle_receive_message::prepare_mint`,
        arguments: [
          receipt,
          tx.object(CCTP_V2_CONFIG.tmmV2StateId),
          tx.object("0x6"), // Sui clock
        ],
        typeArguments: [USDC_TYPE],
      });

      const [completeMintTicket] = tx.moveCall({
        target: `${CCTP_V2_CONFIG.handlerPackageId}::handler::mint`,
        arguments: [
          tx.object(CCTP_V2_CONFIG.handlerStateId),
          mintReceipt,
          tx.object(CCTP_V2_CONFIG.tmmV2StateId),
          tx.object(CCTP_V2_CONFIG.treasuryId),
          tx.object("0x403"), // Sui deny list
        ],
      });

      tx.moveCall({
        target: `${CCTP_V2_CONFIG.tmmV2PackageId}::handle_receive_message::complete_mint`,
        arguments: [
          completeMintTicket,
          tx.object(CCTP_V2_CONFIG.tmmV2StateId),
          tx.object(CCTP_V2_CONFIG.mtV2StateId),
        ],
        typeArguments: [
          USDC_TYPE,
          `${CCTP_V2_CONFIG.handlerPackageId}::handler::Auth`,
        ],
      });

      const digest = await executeTransaction(tx);
      console.log(
        `Receive-message confirmed: https://suiscan.xyz/testnet/tx/${digest}`,
      );
      return digest;
    }
    ```

    ### 3.5. Fold minted USDC into an address balance

    After receive confirms, look up the created `Coin<USDC>` and call
    `0x2::coin::send_funds` so the recipient holds USDC as an address balance.

    ```ts TypeScript theme={null}
    async function foldUsdcIntoAddressBalance(
      coinObjectId: string,
      recipient: string,
    ) {
      console.log(
        "Folding minted USDC into address balance (SIP-58 send_funds)...",
      );

      const tx = new Transaction();
      tx.moveCall({
        target: "0x2::coin::send_funds",
        typeArguments: [USDC_TYPE],
        arguments: [tx.object(coinObjectId), tx.pure.address(recipient)],
      });

      const digest = await executeTransaction(tx);
      console.log(`send_funds confirmed: https://suiscan.xyz/testnet/tx/${digest}`);
      return digest;
    }

    async function findCreatedUsdcCoin(digest: string, owner: string) {
      const result = await suiClient.core.getTransaction({
        digest,
        include: { effects: true, objectTypes: true },
      });
      const tx =
        result.$kind === "Transaction"
          ? result.Transaction
          : result.FailedTransaction;
      if (!tx?.effects || !tx.objectTypes) {
        throw new Error(`Missing effects/objectTypes for ${digest}`);
      }

      const normalizedOwner = normalizeSuiAddress(owner);
      const created = tx.effects.changedObjects.filter((obj) => {
        if (obj.idOperation !== "Created") return false;
        const type = tx.objectTypes![obj.objectId] ?? "";
        if (!type.includes("::coin::Coin<") || !type.includes("::usdc::USDC")) {
          return false;
        }
        if (obj.outputOwner?.$kind !== "AddressOwner") return false;
        return (
          normalizeSuiAddress(obj.outputOwner.AddressOwner) === normalizedOwner
        );
      });

      if (created.length === 0) {
        throw new Error(
          `No Coin<USDC> created for ${owner} in https://suiscan.xyz/testnet/tx/${digest}`,
        );
      }
      return created[0].objectId;
    }
    ```

    ## 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 { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
    import { SuiGrpcClient } from "@mysten/sui/grpc";
    import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
    import { Transaction } from "@mysten/sui/transactions";
    import { normalizeSuiAddress } from "@mysten/sui/utils";
    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 SUI_DOMAIN = 8;
    const ARC_DOMAIN = 26;

    const ARC_USDC = "0x3600000000000000000000000000000000000000" as Address;
    const ARC_TOKEN_MESSENGER =
      "0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA" as Address;
    const IRIS_API_URL = "https://iris-api-sandbox.circle.com";

    const CCTP_V2_CONFIG = {
      mtV2PackageId:
        "0xe9678cd42a81886e18e21361088071f275105636a4d3751e1d7f255211f73bbe",
      mtV2StateId:
        "0xfee3a2b47f9ef2de2405fc63d79194307945f8ea768815cfc46083bc20fbe6ed",
      tmmV2PackageId:
        "0x267d3c0cb776eace2840f27e4d33da9c6d952f9749403f1fe6579f4962ed3c64",
      tmmV2StateId:
        "0x72cb55cd14d01e6361386d6ea93eecda9fa1efc3c202b8dd69c1e4683e4c0ca0",
      handlerPackageId:
        "0xbe8479044396a45e07de2c7f14789c35b8338406eebc53b2527da56839a91561",
      handlerStateId:
        "0xfbd9c0517c4f0e1817445ee2be598806e3c392a465d7e3d773d1055f3f0eed32",
      usdcPackageId:
        "0xa1ec7fc00a6f40db9693ad1415d0c193ad3906494428cf252621037bd7117e29",
      treasuryId:
        "0x7170137d4a6431bf83351ac025baf462909bffe2877d87716374fb42b9629ebe",
    };
    const USDC_TYPE = `${CCTP_V2_CONFIG.usdcPackageId}::usdc::USDC`;

    const AMOUNT = 1_000_000n; // 1 USDC, 6 decimals
    const MAX_FEE = 0n;
    const MIN_FINALITY_THRESHOLD = 2_000; // Standard Transfer

    const evmPrivateKey = process.env.EVM_PRIVATE_KEY;
    const suiPrivateKey = process.env.SUI_PRIVATE_KEY;

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

    const account = privateKeyToAccount(evmPrivateKey as Hex);
    const walletClient = createWalletClient({
      account,
      chain: arcTestnet,
      transport: http(),
    });
    const publicClient = createPublicClient({
      chain: arcTestnet,
      transport: http(),
    });
    const suiClient = new SuiGrpcClient({
      network: "testnet",
      baseUrl: "https://fullnode.testnet.sui.io:443",
    });
    const suiSigner = Ed25519Keypair.fromSecretKey(
      decodeSuiPrivateKey(suiPrivateKey).secretKey,
    );

    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 padAddressToBytes32(address: string): Hex {
      return `0x${address.replace(/^0x/, "").padStart(64, "0").toLowerCase()}` as Hex;
    }

    function hexToBytes(hex: string): Uint8Array {
      const normalized = hex.startsWith("0x") ? hex.slice(2) : hex;
      return Uint8Array.from(Buffer.from(normalized, "hex"));
    }

    async function executeTransaction(transaction: Transaction) {
      const res = await suiClient.signAndExecuteTransaction({
        signer: suiSigner,
        transaction,
        include: { effects: true, events: true },
      });
      const body =
        (
          res as {
            Transaction?: {
              digest?: string;
              status?: { success?: boolean; error?: string | null };
            };
          }
        ).Transaction ??
        (
          res as {
            FailedTransaction?: {
              digest?: string;
              status?: { success?: boolean; error?: string | null };
            };
          }
        ).FailedTransaction ??
        {};
      const digest = body.digest ?? "";
      await suiClient.waitForTransaction({ digest });
      if (!body.status?.success) {
        console.dir(res, { depth: null, colors: true });
        throw new Error(`Transaction failed! ${body.status?.error ?? ""}`);
      }
      return digest;
    }

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

      const approvalHash = await walletClient.writeContract({
        account,
        chain: arcTestnet,
        address: ARC_USDC,
        abi: usdcAbi,
        functionName: "approve",
        args: [ARC_TOKEN_MESSENGER, AMOUNT],
      });
      const approvalReceipt = await publicClient.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 burnHash = await walletClient.writeContract({
        account,
        chain: arcTestnet,
        address: ARC_TOKEN_MESSENGER,
        abi: tokenMessengerV2Abi,
        functionName: "depositForBurn",
        args: [
          AMOUNT,
          SUI_DOMAIN,
          padAddressToBytes32(suiSigner.toSuiAddress()),
          ARC_USDC,
          `0x${"0".repeat(64)}` as Hex,
          MAX_FEE,
          MIN_FINALITY_THRESHOLD,
        ],
      });
      const burnReceipt = await publicClient.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 = `${IRIS_API_URL}/v2/messages/${ARC_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" &&
            result.message !== "0x"
          ) {
            return {
              message: result.message,
              attestation: result.attestation,
            };
          }
        }

        await new Promise((resolve) => setTimeout(resolve, 2_000));
      }
    }

    async function receiveMessage({ message, attestation }: Attestation) {
      console.log("Receiving the message on Sui...");

      const messageBytes = hexToBytes(message);
      const attestationBytes = hexToBytes(attestation);
      const tx = new Transaction();

      const [receipt] = tx.moveCall({
        target: `${CCTP_V2_CONFIG.mtV2PackageId}::receive_message::receive_message`,
        arguments: [
          tx.pure.vector("u8", Array.from(messageBytes)),
          tx.pure.vector("u8", Array.from(attestationBytes)),
          tx.object(CCTP_V2_CONFIG.mtV2StateId),
        ],
      });

      const [mintReceipt] = tx.moveCall({
        target: `${CCTP_V2_CONFIG.tmmV2PackageId}::handle_receive_message::prepare_mint`,
        arguments: [
          receipt,
          tx.object(CCTP_V2_CONFIG.tmmV2StateId),
          tx.object("0x6"),
        ],
        typeArguments: [USDC_TYPE],
      });

      const [completeMintTicket] = tx.moveCall({
        target: `${CCTP_V2_CONFIG.handlerPackageId}::handler::mint`,
        arguments: [
          tx.object(CCTP_V2_CONFIG.handlerStateId),
          mintReceipt,
          tx.object(CCTP_V2_CONFIG.tmmV2StateId),
          tx.object(CCTP_V2_CONFIG.treasuryId),
          tx.object("0x403"),
        ],
      });

      tx.moveCall({
        target: `${CCTP_V2_CONFIG.tmmV2PackageId}::handle_receive_message::complete_mint`,
        arguments: [
          completeMintTicket,
          tx.object(CCTP_V2_CONFIG.tmmV2StateId),
          tx.object(CCTP_V2_CONFIG.mtV2StateId),
        ],
        typeArguments: [
          USDC_TYPE,
          `${CCTP_V2_CONFIG.handlerPackageId}::handler::Auth`,
        ],
      });

      const digest = await executeTransaction(tx);
      console.log(
        `Receive-message confirmed: https://suiscan.xyz/testnet/tx/${digest}`,
      );
      return digest;
    }

    async function foldUsdcIntoAddressBalance(
      coinObjectId: string,
      recipient: string,
    ) {
      console.log(
        "Folding minted USDC into address balance (SIP-58 send_funds)...",
      );

      const tx = new Transaction();
      tx.moveCall({
        target: "0x2::coin::send_funds",
        typeArguments: [USDC_TYPE],
        arguments: [tx.object(coinObjectId), tx.pure.address(recipient)],
      });

      const digest = await executeTransaction(tx);
      console.log(`send_funds confirmed: https://suiscan.xyz/testnet/tx/${digest}`);
      return digest;
    }

    async function findCreatedUsdcCoin(digest: string, owner: string) {
      const result = await suiClient.core.getTransaction({
        digest,
        include: { effects: true, objectTypes: true },
      });
      const tx =
        result.$kind === "Transaction"
          ? result.Transaction
          : result.FailedTransaction;
      if (!tx?.effects || !tx.objectTypes) {
        throw new Error(`Missing effects/objectTypes for ${digest}`);
      }

      const normalizedOwner = normalizeSuiAddress(owner);
      const created = tx.effects.changedObjects.filter((obj) => {
        if (obj.idOperation !== "Created") return false;
        const type = tx.objectTypes![obj.objectId] ?? "";
        if (!type.includes("::coin::Coin<") || !type.includes("::usdc::USDC")) {
          return false;
        }
        if (obj.outputOwner?.$kind !== "AddressOwner") return false;
        return (
          normalizeSuiAddress(obj.outputOwner.AddressOwner) === normalizedOwner
        );
      });

      if (created.length === 0) {
        throw new Error(
          `No Coin<USDC> created for ${owner} in https://suiscan.xyz/testnet/tx/${digest}`,
        );
      }
      return created[0].objectId;
    }

    export default async function main() {
      await approveUSDC();
      const burnTransactionHash = await burnUSDC();
      const attestation = await retrieveAttestation(burnTransactionHash);
      console.log("Attestation received from Iris V2.");
      const receiveDigest = await receiveMessage(attestation);
      const recipient = suiSigner.toSuiAddress();
      const mintedCoinId = await findCreatedUsdcCoin(receiveDigest, recipient);
      const foldDigest = await foldUsdcIntoAddressBalance(mintedCoinId, recipient);
      console.log("USDC transfer completed (address balance).");

      return {
        evmTransactionHash: burnTransactionHash,
        suiTransactionDigest: receiveDigest,
        foldDigest,
      };
    }

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

    ## Step 5: Test the script

    Run the following command to execute the script:

    ```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 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 received from Iris V2.
    Receiving the message on Sui...
    Receive-message confirmed: https://suiscan.xyz/testnet/tx/<sui-transaction-digest>
    Folding minted USDC into address balance (SIP-58 send_funds)...
    send_funds confirmed: https://suiscan.xyz/testnet/tx/<sui-fold-digest>
    USDC transfer completed (address balance).
    ```

    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>
