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

# Quickstart: Create and transfer a unified USDC balance on EVM

> Create and transfer a unified USDC balance on EVM using Circle Gateway.

This guide walks you through the process of creating Unified Crosschain USDC
Balances on
[supported EVM chains](/gateway/references/supported-blockchains#testnet) using
Circle Gateway, and performing transfers from EVM to EVM and from Solana to EVM.

<Tip>
  **Use
  [Unified Balance Kit](https://www.npmjs.com/package/@circle-fin/unified-balance-kit)
  to simplify this integration.**

  This quickstart uses a manual Gateway integration. It is for learning or for
  developers who need direct control.

  To simplify, use Unified Balance Kit to deposit and spend USDC in just a few
  lines of code.
</Tip>

Select a tab below for the Circle Wallets or self-managed wallet path.

<Tabs>
  <Tab title="Circle Wallets">
    ## Prerequisites

    This quickstart uses Arc Testnet for deposits and the
    `Arc Testnet -> Base Sepolia` route for the EVM transfer example. You can adapt
    the same code to other supported EVM testnets later in the guide.

    Before you begin, ensure that you've:

    * Installed [Node.js v22.6+](https://nodejs.org/)
    * Obtained a
      [Circle API Key](/w3s/circle-developer-account#creating-an-api-key-for-developer-services)
      and [Entity Secret](/wallets/dev-controlled/register-entity-secret) from the
      [Circle Console](/w3s/circle-developer-account).
    * [Created Developer-Controlled Wallets](/wallets/dev-controlled/create-your-first-wallet)
      for the chains you want to test.
    * Funded your wallets with testnet tokens:
      * Get testnet USDC from the [Circle Faucet](https://faucet.circle.com/).
      * Get test native tokens from the
        [Console Faucet](https://console.circle.com/faucet).

    If you want to try the Solana to EVM transfer, ensure that you've:

    * Created a Solana Devnet Developer-Controlled Wallet to act as the source
      depositor
    * Completed the
      [deposit flow](/gateway/quickstarts/unified-balance-solana#step-3-deposit-into-a-unified-crosschain-balance-circle-wallets)
      from the
      [Solana quickstart](/gateway/quickstarts/unified-balance-solana#circle-wallets)
      first

    If you want to use the Direct Mint path for Solana to EVM, also create an EVM
    Developer-Controlled Wallet on the destination chain to submit the mint
    transaction.

    ### Add testnet funds to your wallet

    To interact with Gateway, you need test USDC and native tokens in your wallet on
    each chain you deposit from. In the default Arc Testnet deposit flow, the same
    USDC also covers transaction fees because Arc uses USDC as the native gas token.

    Use the [Circle Faucet](https://faucet.circle.com/) to get test USDC. If you
    have a [Circle Developer Console](https://console.circle.com) account, you can
    use the [Console Faucet](https://console.circle.com/faucet) to get testnet
    native tokens. In addition, the following faucets can also be used to fund your
    wallet with testnet native tokens:

    <Tabs>
      <Tab title="Arc">
        **Faucet:** [Arc Testnet](https://faucet.circle.com) (USDC + native tokens)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `arcTestnet`                                 |
        | USDC address | `0x3600000000000000000000000000000000000000` |
        | Domain ID    | `26`                                         |
      </Tab>

      <Tab title="Avalanche">
        **Faucet:** [Avalanche Fuji](https://core.app/tools/testnet-faucet)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `avalancheFuji`                              |
        | USDC address | `0x5425890298aed601595a70ab815c96711a31bc65` |
        | Domain ID    | `1`                                          |
      </Tab>

      <Tab title="Base">
        **Faucet:** [Base Sepolia](https://www.alchemy.com/faucets/base-sepolia)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `baseSepolia`                                |
        | USDC address | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` |
        | Domain ID    | `6`                                          |
      </Tab>

      <Tab title="Ethereum">
        **Faucet:** [Ethereum Sepolia](https://cloud.google.com/application/web3/faucet/ethereum/sepolia)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `sepolia`                                    |
        | USDC address | `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238` |
        | Domain ID    | `0`                                          |
      </Tab>

      <Tab title="Hyperliquid">
        **Faucet:** [Hyperliquid EVM Testnet](https://app.hyperliquid-testnet.xyz/drip)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `hyperliquidEvmTestnet`                      |
        | USDC address | `0x2B3370eE501B4a559b57D449569354196457D8Ab` |
        | Domain ID    | `19`                                         |
      </Tab>

      <Tab title="Sei">
        **Faucet:** [Sei Testnet](https://docs.sei.io/learn/faucet)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `seiTestnet`                                 |
        | USDC address | `0x4fCF1784B31630811181f670Aea7A7bEF803eaED` |
        | Domain ID    | `16`                                         |
      </Tab>

      <Tab title="Solana">
        **Faucet:** [Solana Devnet](https://faucet.solana.com/)

        | Property     | Value                                                   |
        | ------------ | ------------------------------------------------------- |
        | Chain name   | `solanaDevnet` (note that Solana is not EVM-compatible) |
        | USDC address | `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`          |
        | Domain ID    | `5`                                                     |
      </Tab>

      <Tab title="Sonic">
        **Faucet:** [Sonic Testnet](https://testnet.soniclabs.com/account)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `sonicTestnet`                               |
        | USDC address | `0x0BA304580ee7c9a980CF72e55f5Ed2E9fd30Bc51` |
        | Domain ID    | `13`                                         |
      </Tab>

      <Tab title="Worldchain">
        **Faucet:** [Worldchain Sepolia](https://www.l2faucet.com/world)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `worldchainSepolia`                          |
        | USDC address | `0x66145f38cBAC35Ca6F1Dfb4914dF98F1614aeA88` |
        | Domain ID    | `14`                                         |
      </Tab>
    </Tabs>

    ## Step 1. Set up your project

    ### 1.1. Create the project and install dependencies

    ```shell theme={null}
    # Set up your directory and initialize a Node.js project
    mkdir unified-gateway-balance-evm-circle-wallets
    cd unified-gateway-balance-evm-circle-wallets
    npm init -y

    # Set up module type and run scripts
    npm pkg set type=module
    npm pkg set scripts.deposit="node --env-file=.env deposit.ts"
    npm pkg set scripts.balances="node --env-file=.env balances.ts"
    npm pkg set scripts.transfer-from-evm="node --env-file=.env transfer-from-evm.ts"

    # Install runtime dependencies
    npm install @circle-fin/developer-controlled-wallets typescript

    # Install dev dependencies
    npm install --save-dev @types/node
    ```

    If you want to try the Solana to EVM transfer, add the run script and install
    these additional dependencies:

    ```shell theme={null}
    npm pkg set scripts.transfer-from-sol="node --env-file=.env transfer-from-sol.ts"
    npm pkg set overrides.bigint-buffer=npm:@trufflesuite/bigint-buffer@1.1.10
    npm install @solana/buffer-layout @solana/web3.js bs58
    ```

    ### 1.2. Configure TypeScript (optional)

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

    Create a `.env` file in the project directory:

    ```text .env theme={null}
    CIRCLE_API_KEY=YOUR_API_KEY
    CIRCLE_ENTITY_SECRET=YOUR_ENTITY_SECRET
    DEPOSITOR_ADDRESS=YOUR_SOURCE_WALLET_ADDRESS
    RECIPIENT_ADDRESS=YOUR_DESTINATION_WALLET_ADDRESS
    ```

    * `CIRCLE_API_KEY` is your Circle API key.
    * `CIRCLE_ENTITY_SECRET` is your Circle entity secret.
    * `DEPOSITOR_ADDRESS` is the source depositor wallet for the script you are
      running.
    * `RECIPIENT_ADDRESS` is the destination wallet. It is only required for
      transfer scripts.

    For `transfer-from-evm.ts`, both `DEPOSITOR_ADDRESS` and `RECIPIENT_ADDRESS` are
    EVM addresses.

    For `transfer-from-sol.ts`, `DEPOSITOR_ADDRESS` is a Solana address and
    `RECIPIENT_ADDRESS` is an EVM address.

    <Note>
      For the Circle Wallets Direct Mint path, `RECIPIENT_ADDRESS` must be a
      destination-chain Developer-Controlled Wallet address in your Circle account.
      The script uses `createContractExecutionTransaction()` to submit
      `gatewayMint()` from that wallet, so a regular external EVM address will not
      work.
    </Note>

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

    ## Step 2. Set up the configuration file

    The deposit and transfer scripts share this configuration file.

    ### 2.1. Create the configuration file

    ```shell theme={null}
    touch config.ts
    ```

    ### 2.2. Configure shared Gateway constants

    Add the shared Gateway constants used by the deposit and transfer scripts. To
    swap chains later, keep `chainConfig` as the shared map and update the
    `SOURCE_CHAIN` and `DEST_CHAIN` constants inside each script.

    ```ts config.ts expandable theme={null}
    import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";

    type WalletChain = "ARC-TESTNET" | "BASE-SEPOLIA";

    type EvmChainConfig = {
      chainName: string;
      usdc: string;
      domain: number;
      walletChain: WalletChain;
    };

    export const chainConfig = {
      arc: {
        chainName: "Arc Testnet",
        usdc: "0x3600000000000000000000000000000000000000",
        domain: 26,
        walletChain: "ARC-TESTNET",
      },
      base: {
        chainName: "Base Sepolia",
        usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
        domain: 6,
        walletChain: "BASE-SEPOLIA",
      },
    } as const satisfies Record<string, EvmChainConfig>;

    export type ChainKey = keyof typeof chainConfig;

    export const GATEWAY_API_BASE = "https://gateway-api-testnet.circle.com";
    export const GATEWAY_WALLET_ADDRESS =
      "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
    export const GATEWAY_MINTER_ADDRESS =
      "0x0022222ABE238Cc2C7Bb1f21003F0a260052475B";

    export const API_KEY = process.env.CIRCLE_API_KEY!;
    export const ENTITY_SECRET = process.env.CIRCLE_ENTITY_SECRET!;
    export const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;

    if (!API_KEY || !ENTITY_SECRET || !DEPOSITOR_ADDRESS) {
      console.error(
        "Missing required env vars: CIRCLE_API_KEY, CIRCLE_ENTITY_SECRET, DEPOSITOR_ADDRESS",
      );
      process.exit(1);
    }

    /* Circle Wallets Client */
    export const client = initiateDeveloperControlledWalletsClient({
      apiKey: API_KEY,
      entitySecret: ENTITY_SECRET,
    });

    export async function waitForTxCompletion(
      client: ReturnType<typeof initiateDeveloperControlledWalletsClient>,
      txId: string,
      label: string,
    ) {
      const terminalStates = new Set([
        "COMPLETE",
        "CONFIRMED",
        "FAILED",
        "DENIED",
        "CANCELLED",
      ]);

      process.stdout.write(`Waiting for ${label} (txId=${txId})\n`);

      while (true) {
        const { data } = await client.getTransaction({ id: txId });
        const state = data?.transaction?.state;

        process.stdout.write(".");

        if (state && terminalStates.has(state)) {
          process.stdout.write("\n");
          console.log(`${label} final state: ${state}`);

          if (state !== "COMPLETE" && state !== "CONFIRMED") {
            throw new Error(
              `${label} did not complete successfully (state=${state})`,
            );
          }
          return data.transaction;
        }
        await new Promise((resolve) => setTimeout(resolve, 3000));
      }
    }

    export function parseBalance(
      value: string | number | null | undefined,
    ): bigint {
      const str = String(value ?? "0");
      const [whole, decimal = ""] = str.split(".");
      const decimal6 = (decimal + "000000").slice(0, 6);
      return BigInt((whole || "0") + decimal6);
    }

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

    export function stringifyTypedData<T>(obj: T) {
      return JSON.stringify(obj, (_key, value) =>
        typeof value === "bigint" ? value.toString() : value,
      );
    }
    ```

    ## Step 3. Deposit into a unified crosschain balance (Circle Wallets)

    The deposit script deposits USDC into the Gateway Wallet on Arc Testnet. You can
    skip to the [full deposit script](#3-5-full-deposit-script-circle-wallets) if
    you prefer.

    <Warning>
      Do not transfer USDC directly to the Gateway Wallet contract with a standard
      ERC-20 transfer. You must call a Gateway deposit method or the USDC will not
      be credited to your unified balance.
    </Warning>

    ### 3.1. Create the deposit script

    ```shell theme={null}
    touch deposit.ts
    ```

    ### 3.2. Define constants and deposit amount

    Set the deposit amount near the top of the script. This example uses Arc Testnet
    as the source chain, and you can swap chains later by changing `SOURCE_CHAIN`.

    ```ts theme={null}
    const DEPOSIT_AMOUNT_USDC = "2";
    ```

    ### 3.3. Approve USDC spending and submit the deposit

    The script first approves the Gateway Wallet contract to spend USDC, then calls
    the Gateway `deposit(address,uint256)` method on the configured source chain.

    ```ts theme={null}
    const SOURCE_CHAIN = "arc" as const;
    const sourceConfig = chainConfig[SOURCE_CHAIN];

    const approveTx = await client.createContractExecutionTransaction({
      walletAddress: DEPOSITOR_ADDRESS,
      blockchain: sourceConfig.walletChain,
      contractAddress: sourceConfig.usdc,
      abiFunctionSignature: "approve(address,uint256)",
      abiParameters: [
        GATEWAY_WALLET_ADDRESS,
        parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
      ],
      fee: { type: "level", config: { feeLevel: "MEDIUM" } },
    });

    const depositTx = await client.createContractExecutionTransaction({
      walletAddress: DEPOSITOR_ADDRESS,
      blockchain: sourceConfig.walletChain,
      contractAddress: GATEWAY_WALLET_ADDRESS,
      abiFunctionSignature: "deposit(address,uint256)",
      abiParameters: [
        sourceConfig.usdc,
        parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
      ],
      fee: { type: "level", config: { feeLevel: "MEDIUM" } },
    });
    ```

    ### 3.4. Wait for Circle Wallet transaction to complete

    Circle Wallet contract execution is asynchronous. After each submit step, wait
    for transaction completion before proceeding to the next phase.

    ```ts theme={null}
    await waitForTxCompletion(client, approveTxId, "USDC approve");
    await waitForTxCompletion(client, depositTxId, "Gateway deposit");
    ```

    ### 3.5. Full deposit script (Circle Wallets)

    The script approves USDC spending and deposits into the Gateway Wallet on the
    configured source chain. Inline comments explain each stage.

    ```ts deposit.ts expandable theme={null}
    import {
      chainConfig,
      GATEWAY_WALLET_ADDRESS,
      DEPOSITOR_ADDRESS,
      client,
      waitForTxCompletion,
      parseBalance,
    } from "./config.js";

    const DEPOSIT_AMOUNT_USDC = "2";
    const SOURCE_CHAIN = "arc" as const;
    const sourceConfig = chainConfig[SOURCE_CHAIN];

    async function main() {
      console.log(`Using account: ${DEPOSITOR_ADDRESS}`);
      console.log(`Depositing on: ${sourceConfig.chainName}`);

      // [1] Approve the Gateway Wallet to spend USDC on the source chain.
      console.log(
        `Approving ${DEPOSIT_AMOUNT_USDC} USDC on ${sourceConfig.chainName}...`,
      );

      const approveTx = await client.createContractExecutionTransaction({
        walletAddress: DEPOSITOR_ADDRESS,
        blockchain: sourceConfig.walletChain,
        contractAddress: sourceConfig.usdc,
        abiFunctionSignature: "approve(address,uint256)",
        abiParameters: [
          GATEWAY_WALLET_ADDRESS,
          parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
        ],
        fee: { type: "level", config: { feeLevel: "MEDIUM" } },
      });

      const approveTxId = approveTx.data?.id;
      if (!approveTxId) throw new Error("Failed to create approve transaction");
      await waitForTxCompletion(client, approveTxId, "USDC approve");

      // [2] Call the Gateway deposit function on the source chain.
      console.log(`Depositing ${DEPOSIT_AMOUNT_USDC} USDC to Gateway Wallet`);

      const depositTx = await client.createContractExecutionTransaction({
        walletAddress: DEPOSITOR_ADDRESS,
        blockchain: sourceConfig.walletChain,
        contractAddress: GATEWAY_WALLET_ADDRESS,
        abiFunctionSignature: "deposit(address,uint256)",
        abiParameters: [
          sourceConfig.usdc,
          parseBalance(DEPOSIT_AMOUNT_USDC).toString(),
        ],
        fee: { type: "level", config: { feeLevel: "MEDIUM" } },
      });

      const depositTxId = depositTx.data?.id;
      if (!depositTxId) throw new Error("Failed to create deposit transaction");
      await waitForTxCompletion(client, depositTxId, "Gateway deposit");

      console.log(
        "\n==| Block confirmation may take up to 19 minutes for some chains |==",
      );
    }

    main().catch((error) => {
      console.error("\nError:", error);
      process.exit(1);
    });
    ```

    ### 3.6. Run the deposit script

    Run the script:

    ```shell theme={null}
    npm run deposit
    ```

    Wait for the
    [required number of block confirmations](https://developers.circle.com/gateway/references/supported-blockchains#required-block-confirmations).
    Once the deposit transactions are final, the total balance is the sum of all the
    USDC from deposit transactions across all supported chains that have reached
    finality.

    If you later adapt this deposit script to another EVM chain, update
    `SOURCE_CHAIN` to another supported entry in `chainConfig`.

    ### 3.7. Check the balances on the Gateway Wallet

    Create a new file called `balances.ts`, and add the following code. This script
    retrieves the USDC balances available from your Gateway Wallet for the
    `DEPOSITOR_ADDRESS` currently set in `.env`.

    ```ts balances.ts expandable theme={null}
    interface GatewayBalancesResponse {
      balances: Array<{
        domain: number;
        balance: string;
      }>;
    }

    const EVM_DOMAINS = {
      base: 6,
      arc: 26,
    };

    const SOLANA_DOMAINS = {
      solana: 5,
    };

    const DOMAINS = { ...EVM_DOMAINS, ...SOLANA_DOMAINS };

    const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;

    if (!DEPOSITOR_ADDRESS) {
      console.error("Missing required env var: DEPOSITOR_ADDRESS");
      process.exit(1);
    }

    const isEvmAddress = DEPOSITOR_ADDRESS.startsWith("0x");

    async function main() {
      console.log(`Depositor address: ${DEPOSITOR_ADDRESS}`);
      console.log(`Address type: ${isEvmAddress ? "EVM" : "Solana"}\n`);

      const activeDomains = isEvmAddress ? EVM_DOMAINS : SOLANA_DOMAINS;
      const domainIds = Object.values(activeDomains);
      const body = {
        token: "USDC",
        sources: domainIds.map((domain) => ({
          domain,
          depositor: DEPOSITOR_ADDRESS,
        })),
      };

      const res = await fetch(
        "https://gateway-api-testnet.circle.com/v1/balances",
        {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(body),
        },
      );

      const result = (await res.json()) as GatewayBalancesResponse;

      let total = 0;
      for (const balance of result.balances) {
        const chain =
          Object.keys(DOMAINS).find(
            (k) => DOMAINS[k as keyof typeof DOMAINS] === balance.domain,
          ) || `Domain ${balance.domain}`;
        const amount = parseFloat(balance.balance);
        console.log(`${chain}: ${amount.toFixed(6)} USDC`);
        total += amount;
      }

      console.log(`\nTotal: ${total.toFixed(6)} USDC`);
    }

    main().catch((error) => {
      console.error("\nError:", error);
      process.exit(1);
    });
    ```

    You can run it to check whether finality has been reached for recent
    transactions.

    ```shell theme={null}
    npm run balances
    ```

    <Tabs>
      <Tab title="Transfer from EVM">
        ## Step 4. Transfer USDC from EVM to EVM

        This step transfers USDC from your Arc Testnet Gateway balance to Base Sepolia.
        Both paths create and submit the burn intent from Arc Testnet first. Direct Mint
        then retrieves the Gateway attestation and calls `gatewayMint(...)` on Base
        Sepolia from your wallet, while the Forwarding Service lets Circle complete the
        destination mint for you.

        <Tabs>
          <Tab title="Direct Mint">
            ### 4.1. Create the EVM transfer script (Circle Wallets)

            You can skip to the
            [full transfer script](#4-7-full-evm-direct-mint-script-circle-wallets) if you
            prefer.

            ```shell theme={null}
            touch transfer-from-evm.ts
            ```

            ### 4.2. Define constants and types

            This script uses Arc Testnet as the source chain and Base Sepolia as the
            destination chain.

            This direct-mint flow signs the burn intent on Arc Testnet, requests the Gateway
            attestation, and then calls `gatewayMint(...)` on Base Sepolia from your wallet.

            ```ts theme={null}
            type GatewayTransferResponse = {
              attestation: string;
              signature: string;
            };

            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const MAX_FEE = 2_010000n;
            const MAX_UINT256_DEC = ((1n << 256n) - 1n).toString();
            ```

            ### 4.3. Add shared chain references

            The script uses the shared chain map from `config.ts`.

            ```ts theme={null}
            const SOURCE_CHAIN = "arc" as const;
            const DEST_CHAIN = "base" as const;

            const sourceConfig = chainConfig[SOURCE_CHAIN];
            const destinationConfig = chainConfig[DEST_CHAIN];
            ```

            ### 4.4. Create and sign the burn intent

            Create an EIP-712 burn intent for the Arc Testnet Gateway balance and sign it
            with the source Developer-Controlled Wallet.

            ```ts theme={null}
            const burnIntent = createBurnIntent({
              depositorAddress: DEPOSITOR_ADDRESS,
              recipientAddress: RECIPIENT_ADDRESS,
            });

            const typedData = burnIntentTypedData(burnIntent);

            const sigResp = await client.signTypedData({
              walletAddress: DEPOSITOR_ADDRESS,
              blockchain: sourceConfig.walletChain,
              data: stringifyTypedData(typedData),
            });
            ```

            ### 4.5. Request the Gateway attestation

            Send the signed burn intent to the Gateway API and validate that the response
            includes both the attestation and the operator signature needed for minting.

            ```ts theme={null}
            const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: stringifyTypedData(requests),
            });
            ```

            ### 4.6. Mint on Base Sepolia

            Once the Gateway API returns the attestation set, call
            `gatewayMint(bytes,bytes)` on Base Sepolia and wait for Circle Wallet
            transaction completion.

            ```ts theme={null}
            const tx = await client.createContractExecutionTransaction({
              walletAddress: RECIPIENT_ADDRESS,
              blockchain: destinationConfig.walletChain,
              contractAddress: GATEWAY_MINTER_ADDRESS,
              abiFunctionSignature: "gatewayMint(bytes,bytes)",
              abiParameters: [attestation, operatorSig],
              fee: { type: "level", config: { feeLevel: "MEDIUM" } },
            });

            await waitForTxCompletion(client, txId, "USDC mint");
            ```

            ### 4.7. Full EVM direct-mint script (Circle Wallets)

            The script builds and signs a burn intent for Arc Testnet, requests the Gateway
            attestation, and mints on Base Sepolia. Inline comments explain each stage.

            ```ts transfer-from-evm.ts expandable theme={null}
            import { randomBytes } from "node:crypto";
            import {
              chainConfig,
              GATEWAY_API_BASE,
              GATEWAY_WALLET_ADDRESS,
              GATEWAY_MINTER_ADDRESS,
              DEPOSITOR_ADDRESS,
              client,
              waitForTxCompletion,
              addressToBytes32,
              stringifyTypedData,
            } from "./config.js";

            type GatewayTransferResponse = {
              attestation: string;
              signature: string;
            };

            const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;

            if (!RECIPIENT_ADDRESS) {
              console.error("Missing required env var: RECIPIENT_ADDRESS");
              process.exit(1);
            }

            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const MAX_FEE = 2_010000n;
            const MAX_UINT256_DEC = ((1n << 256n) - 1n).toString();

            const SOURCE_CHAIN = "arc" as const;
            const DEST_CHAIN = "base" as const;

            const sourceConfig = chainConfig[SOURCE_CHAIN];
            const destinationConfig = chainConfig[DEST_CHAIN];

            const domain = { name: "GatewayWallet", version: "1" };

            const EIP712Domain = [
              { name: "name", type: "string" },
              { name: "version", type: "string" },
            ];

            const TransferSpec = [
              { name: "version", type: "uint32" },
              { name: "sourceDomain", type: "uint32" },
              { name: "destinationDomain", type: "uint32" },
              { name: "sourceContract", type: "bytes32" },
              { name: "destinationContract", type: "bytes32" },
              { name: "sourceToken", type: "bytes32" },
              { name: "destinationToken", type: "bytes32" },
              { name: "sourceDepositor", type: "bytes32" },
              { name: "destinationRecipient", type: "bytes32" },
              { name: "sourceSigner", type: "bytes32" },
              { name: "destinationCaller", type: "bytes32" },
              { name: "value", type: "uint256" },
              { name: "salt", type: "bytes32" },
              { name: "hookData", type: "bytes" },
            ];

            const BurnIntent = [
              { name: "maxBlockHeight", type: "uint256" },
              { name: "maxFee", type: "uint256" },
              { name: "spec", type: "TransferSpec" },
            ];

            function createBurnIntent(params: {
              depositorAddress: string;
              recipientAddress?: string;
            }) {
              const { depositorAddress, recipientAddress = depositorAddress } = params;

              return {
                maxBlockHeight: MAX_UINT256_DEC,
                maxFee: MAX_FEE,
                spec: {
                  version: 1,
                  sourceDomain: sourceConfig.domain,
                  destinationDomain: destinationConfig.domain,
                  sourceContract: GATEWAY_WALLET_ADDRESS,
                  destinationContract: GATEWAY_MINTER_ADDRESS,
                  sourceToken: sourceConfig.usdc,
                  destinationToken: destinationConfig.usdc,
                  sourceDepositor: depositorAddress,
                  destinationRecipient: recipientAddress,
                  sourceSigner: depositorAddress,
                  destinationCaller: "0x0000000000000000000000000000000000000000",
                  value: TRANSFER_VALUE,
                  salt: `0x${randomBytes(32).toString("hex")}`,
                  hookData: "0x",
                },
              };
            }

            function burnIntentTypedData(burnIntent: ReturnType<typeof createBurnIntent>) {
              return {
                types: { EIP712Domain, TransferSpec, BurnIntent },
                domain,
                primaryType: "BurnIntent",
                message: {
                  ...burnIntent,
                  spec: {
                    ...burnIntent.spec,
                    sourceContract: addressToBytes32(burnIntent.spec.sourceContract),
                    destinationContract: addressToBytes32(
                      burnIntent.spec.destinationContract,
                    ),
                    sourceToken: addressToBytes32(burnIntent.spec.sourceToken),
                    destinationToken: addressToBytes32(burnIntent.spec.destinationToken),
                    sourceDepositor: addressToBytes32(burnIntent.spec.sourceDepositor),
                    destinationRecipient: addressToBytes32(
                      burnIntent.spec.destinationRecipient,
                    ),
                    sourceSigner: addressToBytes32(burnIntent.spec.sourceSigner),
                    destinationCaller: addressToBytes32(burnIntent.spec.destinationCaller),
                  },
                },
              };
            }

            async function main() {
              console.log("Transferring 1 USDC from Arc Testnet to Base Sepolia");
              console.log(`Using wallet: ${DEPOSITOR_ADDRESS}`);
              console.log(`Recipient: ${RECIPIENT_ADDRESS}`);

              // [1] Create and sign the Arc Testnet burn intent.
              console.log("\n[1/3] Signing burn intent...");

              const burnIntent = createBurnIntent({
                depositorAddress: DEPOSITOR_ADDRESS,
                recipientAddress: RECIPIENT_ADDRESS,
              });

              const typedData = burnIntentTypedData(burnIntent);

              const sigResp = await client.signTypedData({
                walletAddress: DEPOSITOR_ADDRESS,
                blockchain: sourceConfig.walletChain,
                data: stringifyTypedData(typedData),
              });

              const signature = sigResp.data?.signature;
              if (!signature) throw new Error("Failed to sign burn intent");

              const requests = [{ burnIntent: typedData.message, signature }];

              // [2] Submit the burn intent to the Gateway API.
              console.log("[2/3] Submitting burn intent to Gateway API...");

              const transferResponse = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyTypedData(requests),
              });

              if (!transferResponse.ok) {
                throw new Error(await transferResponse.text());
              }

              const { attestation, signature: operatorSig } =
                (await transferResponse.json()) as GatewayTransferResponse;

              if (!attestation || !operatorSig) {
                throw new Error("Invalid Gateway API response");
              }

              // [3] Mint on Base Sepolia with the returned attestation set.
              console.log(`[3/3] Minting on ${destinationConfig.chainName}...`);

              const tx = await client.createContractExecutionTransaction({
                walletAddress: RECIPIENT_ADDRESS,
                blockchain: destinationConfig.walletChain,
                contractAddress: GATEWAY_MINTER_ADDRESS,
                abiFunctionSignature: "gatewayMint(bytes,bytes)",
                abiParameters: [attestation, operatorSig],
                fee: { type: "level", config: { feeLevel: "MEDIUM" } },
              });

              const txId = tx.data?.id;
              if (!txId) throw new Error("Failed to submit mint transaction");

              await waitForTxCompletion(client, txId, "USDC mint");

              console.log(
                `Transfer complete. 1 USDC minted on ${destinationConfig.chainName}.`,
              );
              console.log(`Mint transaction ID: ${txId}`);
            }

            main().catch((error) => {
              console.error("\nError:", error);
              process.exit(1);
            });
            ```

            ### 4.8. Run the EVM direct-mint script

            Run the script to burn from your Arc Testnet Gateway balance and call
            `gatewayMint(...)` on Base Sepolia.

            Confirm these values before running:

            * `DEPOSITOR_ADDRESS` is the source Arc Testnet wallet
            * `RECIPIENT_ADDRESS` is the destination Base Sepolia wallet
            * the source depositor has a Gateway balance on Arc Testnet
            * the destination wallet can submit the mint transaction on Base Sepolia

            <Note>
              Gateway fees are charged per burn intent and are based on the source
              blockchain you burn from. Choosing where to hold and burn Gateway balances can
              affect transfer costs. For fee details, see [Gateway
              Fees](/gateway/references/fees).
            </Note>

            ```shell theme={null}
            npm run transfer-from-evm
            ```

            When the transfer succeeds, the script logs the minted amount and mint
            transaction ID.
          </Tab>

          <Tab title="Forwarding Service">
            ### 4.1. Create the EVM transfer script

            You can skip to the
            [full transfer script](#4-7-full-evm-forwarding-script-circle-wallets) if you
            prefer.

            ```shell theme={null}
            touch transfer-from-evm.ts
            ```

            ### 4.2. Define constants and types

            This script uses Arc Testnet as the source chain and Base Sepolia as the
            destination chain.

            This forwarding flow signs the burn intent on Arc Testnet, submits it with
            forwarding enabled, and then lets Circle complete the destination mint on Base
            Sepolia.

            ```ts theme={null}
            type EstimateResponse = {
              body: Array<{
                burnIntent: {
                  maxFee: string;
                  maxBlockHeight: string;
                };
              }>;
              fees: {
                forwardingFee?: string;
                token: string;
              };
            };

            type TransferResponse = {
              transferId?: string;
            };

            type TransferStatusResponse = {
              status: string;
              transactionHash?: string;
              forwardingDetails?: {
                failureReason?: string;
              };
            };

            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const POLL_INTERVAL_MS = 5_000;
            const POLL_TIMEOUT_MS = 300_000;
            ```

            ### 4.3. Define the burn-intent shape

            The forwarding flow first estimates the burn-intent limits for the Arc Testnet
            to Base Sepolia route, then uses the same burn-intent shape for signing and
            submission.

            ```ts theme={null}
            type TransferSpecBytes32 = {
              version: number;
              sourceDomain: number;
              destinationDomain: number;
              sourceContract: `0x${string}`;
              destinationContract: `0x${string}`;
              sourceToken: `0x${string}`;
              destinationToken: `0x${string}`;
              sourceDepositor: `0x${string}`;
              destinationRecipient: `0x${string}`;
              sourceSigner: `0x${string}`;
              destinationCaller: `0x${string}`;
              value: bigint;
              salt: `0x${string}`;
              hookData: `0x${string}`;
            };
            ```

            ### 4.4. Estimate forwarding fees

            Before you submit the forwarding transfer, call the Gateway estimate endpoint
            with `enableForwarder=true`. The response returns the `maxFee` and
            `maxBlockHeight` values you must include in the signed burn intent.

            ```ts theme={null}
            const estimateResponse = await fetch(
              `${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
              {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyTypedData([{ spec }]),
              },
            );
            ```

            ### 4.5. Sign and submit the forwarding burn intent

            Sign the burn intent with the Arc Testnet Developer-Controlled Wallet, then
            submit it to the Gateway transfer endpoint with `enableForwarder=true`.

            ```ts theme={null}
            const sigResp = await client.signTypedData({
              walletAddress: DEPOSITOR_ADDRESS,
              blockchain: sourceConfig.walletChain,
              data: stringifyTypedData(typedData),
            });

            const response = await fetch(
              `${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
              {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyTypedData([{ burnIntent: typedData.message, signature }]),
              },
            );
            ```

            ### 4.6. Poll for forwarding completion

            The Forwarding Service completes the Base Sepolia mint for you. Poll the
            transfer status endpoint until the transfer is finalized or fails.

            ```ts theme={null}
            const pollRes = await fetch(`${GATEWAY_API_BASE}/v1/transfer/${transferId}`);
            ```

            ### 4.7. Full EVM forwarding script (Circle Wallets)

            The script estimates forwarding fees, signs the burn intent on Arc Testnet,
            submits it to the Forwarding Service, and polls until Circle completes the Base
            Sepolia mint. Inline comments explain each stage.

            ```ts transfer-from-evm.ts expandable theme={null}
            import { randomBytes } from "node:crypto";
            import {
              chainConfig,
              GATEWAY_API_BASE,
              GATEWAY_WALLET_ADDRESS,
              GATEWAY_MINTER_ADDRESS,
              DEPOSITOR_ADDRESS,
              client,
              addressToBytes32,
              stringifyTypedData,
            } from "./config.js";

            type EstimateResponse = {
              body: Array<{
                burnIntent: {
                  maxFee: string;
                  maxBlockHeight: string;
                };
              }>;
              fees: {
                forwardingFee?: string;
                token: string;
              };
            };

            type TransferResponse = {
              transferId?: string;
            };

            type TransferStatusResponse = {
              status: string;
              transactionHash?: string;
              forwardingDetails?: {
                failureReason?: string;
              };
            };

            type TransferSpecBytes32 = {
              version: number;
              sourceDomain: number;
              destinationDomain: number;
              sourceContract: `0x${string}`;
              destinationContract: `0x${string}`;
              sourceToken: `0x${string}`;
              destinationToken: `0x${string}`;
              sourceDepositor: `0x${string}`;
              destinationRecipient: `0x${string}`;
              sourceSigner: `0x${string}`;
              destinationCaller: `0x${string}`;
              value: bigint;
              salt: `0x${string}`;
              hookData: `0x${string}`;
            };

            const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;

            if (!RECIPIENT_ADDRESS) {
              console.error("Missing required env var: RECIPIENT_ADDRESS");
              process.exit(1);
            }

            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const POLL_INTERVAL_MS = 5_000;
            const POLL_TIMEOUT_MS = 300_000;

            const SOURCE_CHAIN = "arc" as const;
            const DEST_CHAIN = "base" as const;

            const sourceConfig = chainConfig[SOURCE_CHAIN];
            const destinationConfig = chainConfig[DEST_CHAIN];

            const domain = { name: "GatewayWallet", version: "1" };

            const EIP712Domain = [
              { name: "name", type: "string" },
              { name: "version", type: "string" },
            ];

            const TransferSpec = [
              { name: "version", type: "uint32" },
              { name: "sourceDomain", type: "uint32" },
              { name: "destinationDomain", type: "uint32" },
              { name: "sourceContract", type: "bytes32" },
              { name: "destinationContract", type: "bytes32" },
              { name: "sourceToken", type: "bytes32" },
              { name: "destinationToken", type: "bytes32" },
              { name: "sourceDepositor", type: "bytes32" },
              { name: "destinationRecipient", type: "bytes32" },
              { name: "sourceSigner", type: "bytes32" },
              { name: "destinationCaller", type: "bytes32" },
              { name: "value", type: "uint256" },
              { name: "salt", type: "bytes32" },
              { name: "hookData", type: "bytes" },
            ];

            const BurnIntent = [
              { name: "maxBlockHeight", type: "uint256" },
              { name: "maxFee", type: "uint256" },
              { name: "spec", type: "TransferSpec" },
            ];

            function createTransferSpec(): TransferSpecBytes32 {
              return {
                version: 1,
                sourceDomain: sourceConfig.domain,
                destinationDomain: destinationConfig.domain,
                sourceContract: addressToBytes32(GATEWAY_WALLET_ADDRESS),
                destinationContract: addressToBytes32(GATEWAY_MINTER_ADDRESS),
                sourceToken: addressToBytes32(sourceConfig.usdc),
                destinationToken: addressToBytes32(destinationConfig.usdc),
                sourceDepositor: addressToBytes32(DEPOSITOR_ADDRESS),
                destinationRecipient: addressToBytes32(RECIPIENT_ADDRESS),
                sourceSigner: addressToBytes32(DEPOSITOR_ADDRESS),
                destinationCaller: addressToBytes32(
                  "0x0000000000000000000000000000000000000000",
                ),
                value: TRANSFER_VALUE,
                salt: `0x${randomBytes(32).toString("hex")}`,
                hookData: "0x",
              };
            }

            async function main() {
              console.log("Forwarding 1 USDC from Arc Testnet to Base Sepolia");
              console.log(`Using wallet: ${DEPOSITOR_ADDRESS}`);
              console.log(`Recipient: ${RECIPIENT_ADDRESS}`);

              const spec = createTransferSpec();

              // [1] Estimate forwarding fee inputs for the burn intent.
              console.log("\n[1/3] Estimating forwarding fees...");

              const estimateResponse = await fetch(
                `${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
                {
                  method: "POST",
                  headers: { "Content-Type": "application/json" },
                  body: stringifyTypedData([{ spec }]),
                },
              );

              if (!estimateResponse.ok) {
                const text = await estimateResponse.text();
                throw new Error(`Estimate API error: ${estimateResponse.status} ${text}`);
              }

              const estimateResult = (await estimateResponse.json()) as EstimateResponse;
              const estimated = estimateResult.body[0]?.burnIntent;
              if (!estimated) throw new Error("Missing burnIntent estimate");

              const maxFee = BigInt(estimated.maxFee);
              const maxBlockHeight = BigInt(estimated.maxBlockHeight);
              const { fees } = estimateResult;

              if (fees.forwardingFee) {
                console.log(
                  `Forwarding fee: ${Number(fees.forwardingFee) / 1_000_000} ${fees.token}`,
                );
              }
              console.log(`Estimated maxFee: ${Number(maxFee) / 1_000_000} ${fees.token}`);

              // [2] Sign and submit the forwarding burn intent.
              console.log("\n[2/3] Signing and submitting burn intent...");

              const typedData = {
                types: { EIP712Domain, TransferSpec, BurnIntent },
                domain,
                primaryType: "BurnIntent" as const,
                message: { maxBlockHeight, maxFee, spec },
              };

              const sigResp = await client.signTypedData({
                walletAddress: DEPOSITOR_ADDRESS,
                blockchain: sourceConfig.walletChain,
                data: stringifyTypedData(typedData),
              });

              const signature = sigResp.data?.signature;
              if (!signature) throw new Error("Failed to sign burn intent");

              const response = await fetch(
                `${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
                {
                  method: "POST",
                  headers: { "Content-Type": "application/json" },
                  body: stringifyTypedData([{ burnIntent: typedData.message, signature }]),
                },
              );

              if (!response.ok) {
                const text = await response.text();
                throw new Error(`Gateway API error: ${response.status} ${text}`);
              }

              const json = (await response.json()) as TransferResponse;
              if (!json.transferId) throw new Error("Missing transferId in response");
              const transferId = json.transferId;
              console.log(`Transfer ID: ${transferId}`);

              // [3] Poll until the Forwarding Service completes the destination mint.
              console.log("\n[3/3] Polling for transfer completion...");

              const pollStart = Date.now();
              while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
                const pollRes = await fetch(
                  `${GATEWAY_API_BASE}/v1/transfer/${transferId}`,
                );

                if (!pollRes.ok) {
                  console.error(`Poll error: ${pollRes.status}`);
                  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
                  continue;
                }

                const details = (await pollRes.json()) as TransferStatusResponse;
                console.log(`Status: ${details.status}`);

                if (details.status === "finalized" || details.status === "confirmed") {
                  if (details.transactionHash) {
                    console.log(`Mint transaction hash: ${details.transactionHash}`);
                  }
                  break;
                }

                if (details.status === "failed") {
                  const reason = details.forwardingDetails?.failureReason ?? "unknown";
                  throw new Error(`Transfer failed: ${reason}`);
                }

                if (details.status === "expired") {
                  throw new Error("Transfer attestation expired before forwarding");
                }

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

              if (Date.now() - pollStart >= POLL_TIMEOUT_MS) {
                throw new Error("Polling timed out waiting for transfer completion");
              }

              console.log(
                `Transfer complete. 1 USDC forwarded to ${destinationConfig.chainName}.`,
              );
            }

            main().catch((error) => {
              console.error("\nError:", error);
              process.exit(1);
            });
            ```

            ### 4.8. Run the EVM forwarding script

            Run the script to burn from your Arc Testnet Gateway balance and let the
            Forwarding Service mint on Base Sepolia.

            Confirm these values before running:

            * `DEPOSITOR_ADDRESS` is the source Arc Testnet wallet
            * `RECIPIENT_ADDRESS` is the destination Base Sepolia wallet
            * the source depositor has a Gateway balance on Arc Testnet
            * no destination gas is required for this forwarding path

            <Note>
              Gateway fees are charged per burn intent and are based on the source
              blockchain you burn from. Choosing where to hold and burn Gateway balances can
              affect transfer costs. For fee details, see [Gateway
              Fees](/gateway/references/fees).
            </Note>

            ```shell theme={null}
            npm run transfer-from-evm
            ```

            When the transfer succeeds, the script logs the transfer ID, status, and
            destination mint transaction hash.
          </Tab>
        </Tabs>
      </Tab>

      <Tab title="Transfer from Solana">
        ## Step 4. Transfer USDC from Solana to EVM

        This step transfers USDC from your Solana Devnet Gateway balance to Arc Testnet.
        Both paths create and sign the Solana burn intent first. Direct Mint then
        requests the Gateway attestation and calls `gatewayMint(...)` on Arc Testnet
        from your wallet, while the Forwarding Service lets Circle complete the
        destination mint for you.

        <Tabs>
          <Tab title="Direct Mint">
            ### 4.1. Create the Solana to EVM transfer script

            You can skip to the
            [full transfer script](#4-7-full-solana-direct-mint-script-circle-wallets) if
            you prefer.

            ```shell theme={null}
            touch transfer-from-sol.ts
            ```

            ### 4.2. Define constants and types

            This script uses Arc Testnet as the destination chain.

            This direct-mint flow signs the burn intent on Solana Devnet, requests the
            Gateway attestation, and then calls `gatewayMint(...)` on Arc Testnet from your
            wallet.

            ```ts theme={null}
            const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
            const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
            const SOLANA_DOMAIN = 5;
            ```

            ### 4.3. Add helper functions

            The helper layer encodes the Solana burn intent into the binary layout expected
            by Gateway, then normalizes addresses and payloads for the API request.

            ```ts theme={null}
            function solanaAddressToBytes32(address: string): string {
              const decoded = Buffer.from(bs58.decode(address));
              return `0x${decoded.toString("hex")}`;
            }

            function evmAddressToBytes32(address: string): string {
              return "0x" + address.toLowerCase().replace(/^0x/, "").padStart(64, "0");
            }
            ```

            ### 4.4. Create and sign burn intent

            Build the Solana burn intent, prefix the encoded payload, and sign it with the
            source Solana Developer-Controlled Wallet.

            For Solana-origin transfers, the burn intent uses Solana-specific binary
            encoding and must be signed with the Solana signing-domain prefix. See the
            [Solana technical guide](/gateway/references/solana#signing) for the signing
            rules and message format details.

            ```ts theme={null}
            const burnIntent = createBurnIntent({
              sourceDepositor: DEPOSITOR_ADDRESS,
              destinationRecipient: RECIPIENT_ADDRESS,
            });

            const encoded = encodeBurnIntent(burnIntent);
            const prefixed = Buffer.concat([
              Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
              encoded,
            ]);

            const sigResult = await client.signMessage({
              walletAddress: DEPOSITOR_ADDRESS,
              blockchain: "SOL-DEVNET",
              encodedByHex: true,
              message: "0x" + prefixed.toString("hex"),
            });
            ```

            ### 4.5. Request attestation from Gateway API

            Submit the signed burn intent to the Gateway API and verify that it returns the
            attestation and operator signature required by the destination minter.

            ```ts theme={null}
            const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: stringifyTypedData(request),
            });
            ```

            ### 4.6. Mint on Arc Testnet

            Use the destination EVM Developer-Controlled Wallet to call
            `gatewayMint(bytes,bytes)` on the Gateway Minter contract, then wait for
            completion.

            ```ts theme={null}
            const tx = await client.createContractExecutionTransaction({
              walletAddress: RECIPIENT_ADDRESS,
              blockchain: destConfig.walletChain,
              contractAddress: GATEWAY_MINTER_ADDRESS,
              abiFunctionSignature: "gatewayMint(bytes,bytes)",
              abiParameters: [attestation, operatorSig],
              fee: { type: "level", config: { feeLevel: "MEDIUM" } },
            });

            await waitForTxCompletion(client, txId, "USDC mint");
            ```

            ### 4.7. Full Solana direct-mint script (Circle Wallets)

            The script signs a Solana burn intent, requests a Gateway attestation, and mints
            on Arc Testnet. Inline comments explain each stage.

            ```ts transfer-from-sol.ts expandable theme={null}
            import { randomBytes } from "node:crypto";
            import { PublicKey } from "@solana/web3.js";
            import { u32be, struct, blob, offset, Layout } from "@solana/buffer-layout";
            import bs58 from "bs58";
            import {
              chainConfig,
              GATEWAY_API_BASE,
              GATEWAY_MINTER_ADDRESS,
              client,
              waitForTxCompletion,
            } from "./config.js";

            const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;
            const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;

            if (!DEPOSITOR_ADDRESS || !RECIPIENT_ADDRESS) {
              console.error(
                "Missing required env vars: DEPOSITOR_ADDRESS, RECIPIENT_ADDRESS",
              );
              process.exit(1);
            }

            const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
            const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
            const SOLANA_DOMAIN = 5;
            const DEST_CHAIN = "arc" as const;

            const TRANSFER_AMOUNT = 0.1;
            const TRANSFER_VALUE = BigInt(Math.floor(TRANSFER_AMOUNT * 1e6));
            const MAX_FEE = 2_010000n;
            const MAX_UINT64 = 2n ** 64n - 1n;

            const TRANSFER_SPEC_MAGIC = 0xca85def7;
            const BURN_INTENT_MAGIC = 0x070afbc2;

            // Custom layout for Solana PublicKey values in the burn intent payload.
            class PublicKeyLayout extends Layout<PublicKey> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0): PublicKey {
                return new PublicKey(b.subarray(offset, offset + 32));
              }
              encode(src: PublicKey, b: Buffer, offset = 0): number {
                const pubkeyBuffer = src.toBuffer();
                pubkeyBuffer.copy(b, offset);
                return 32;
              }
            }

            const publicKey = (property: string) => new PublicKeyLayout(property);

            // Custom layout for 256-bit unsigned integers.
            class UInt256BE extends Layout<bigint> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0) {
                const buffer = b.subarray(offset, offset + 32);
                return buffer.readBigUInt64BE(24);
              }
              encode(src: bigint, b: Buffer, offset = 0) {
                const buffer = Buffer.alloc(32);
                buffer.writeBigUInt64BE(BigInt(src), 24);
                buffer.copy(b, offset);
                return 32;
              }
            }

            const uint256be = (property: string) => new UInt256BE(property);

            const BurnIntentLayout = struct([
              u32be("magic"),
              uint256be("maxBlockHeight"),
              uint256be("maxFee"),
              u32be("transferSpecLength"),
              struct(
                [
                  u32be("magic"),
                  u32be("version"),
                  u32be("sourceDomain"),
                  u32be("destinationDomain"),
                  publicKey("sourceContract"),
                  publicKey("destinationContract"),
                  publicKey("sourceToken"),
                  publicKey("destinationToken"),
                  publicKey("sourceDepositor"),
                  publicKey("destinationRecipient"),
                  publicKey("sourceSigner"),
                  publicKey("destinationCaller"),
                  uint256be("value"),
                  blob(32, "salt"),
                  u32be("hookDataLength"),
                  blob(offset(u32be(), -4), "hookData"),
                ] as any,
                "spec",
              ),
            ] as any);

            function createBurnIntent(params: {
              sourceDepositor: string;
              destinationRecipient: string;
            }) {
              const { sourceDepositor, destinationRecipient } = params;
              const destConfig = chainConfig[DEST_CHAIN];

              return {
                maxBlockHeight: MAX_UINT64,
                maxFee: MAX_FEE,
                spec: {
                  version: 1,
                  sourceDomain: SOLANA_DOMAIN,
                  destinationDomain: destConfig.domain,
                  sourceContract: solanaAddressToBytes32(SOLANA_GATEWAY_WALLET),
                  destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
                  sourceToken: solanaAddressToBytes32(SOLANA_USDC),
                  destinationToken: evmAddressToBytes32(destConfig.usdc),
                  sourceDepositor: solanaAddressToBytes32(sourceDepositor),
                  destinationRecipient: evmAddressToBytes32(destinationRecipient),
                  sourceSigner: solanaAddressToBytes32(sourceDepositor),
                  destinationCaller: evmAddressToBytes32(
                    "0x0000000000000000000000000000000000000000",
                  ),
                  value: TRANSFER_VALUE,
                  salt: "0x" + randomBytes(32).toString("hex"),
                  hookData: "0x",
                },
              };
            }

            // Encode the burn intent into the binary layout expected by Gateway.
            function encodeBurnIntent(bi: ReturnType<typeof createBurnIntent>): Buffer {
              const hookData = Buffer.from((bi.spec.hookData || "0x").slice(2), "hex");
              const prepared = {
                magic: BURN_INTENT_MAGIC,
                maxBlockHeight: bi.maxBlockHeight,
                maxFee: bi.maxFee,
                transferSpecLength: 340 + hookData.length,
                spec: {
                  magic: TRANSFER_SPEC_MAGIC,
                  version: bi.spec.version,
                  sourceDomain: bi.spec.sourceDomain,
                  destinationDomain: bi.spec.destinationDomain,
                  sourceContract: hexToPublicKey(bi.spec.sourceContract),
                  destinationContract: hexToPublicKey(bi.spec.destinationContract),
                  sourceToken: hexToPublicKey(bi.spec.sourceToken),
                  destinationToken: hexToPublicKey(bi.spec.destinationToken),
                  sourceDepositor: hexToPublicKey(bi.spec.sourceDepositor),
                  destinationRecipient: hexToPublicKey(bi.spec.destinationRecipient),
                  sourceSigner: hexToPublicKey(bi.spec.sourceSigner),
                  destinationCaller: hexToPublicKey(bi.spec.destinationCaller),
                  value: bi.spec.value,
                  salt: Buffer.from(bi.spec.salt.slice(2), "hex"),
                  hookDataLength: hookData.length,
                  hookData,
                },
              };
              const buffer = Buffer.alloc(72 + 340 + hookData.length);
              const bytesWritten = BurnIntentLayout.encode(prepared, buffer);
              return buffer.subarray(0, bytesWritten);
            }

            // Convert a Solana address to a 32-byte hex string.
            function solanaAddressToBytes32(address: string): string {
              const decoded = Buffer.from(bs58.decode(address));
              return `0x${decoded.toString("hex")}`;
            }

            // Convert an EVM address to a 32-byte hex string.
            function evmAddressToBytes32(address: string): string {
              return "0x" + address.toLowerCase().replace(/^0x/, "").padStart(64, "0");
            }

            // Convert a 32-byte hex string into a Solana PublicKey.
            function hexToPublicKey(hex: string): PublicKey {
              return new PublicKey(Buffer.from(hex.slice(2), "hex"));
            }

            // Serialize typed data while converting bigint values to strings.
            function stringifyTypedData<T>(obj: T) {
              return JSON.stringify(obj, (_key, value) =>
                typeof value === "bigint" ? value.toString() : value,
              );
            }

            async function main() {
              const destConfig = chainConfig[DEST_CHAIN];

              console.log(`Sender (Solana): ${DEPOSITOR_ADDRESS}`);
              console.log(`Recipient (EVM): ${RECIPIENT_ADDRESS}`);
              console.log(`Transferring from: Solana Devnet -> ${destConfig.chainName}`);

              console.log(
                `Creating burn intent from Solana Devnet -> ${destConfig.chainName}...`,
              );

              // [1] Create and sign the Solana burn intent.
              const burnIntent = createBurnIntent({
                sourceDepositor: DEPOSITOR_ADDRESS,
                destinationRecipient: RECIPIENT_ADDRESS,
              });

              const encoded = encodeBurnIntent(burnIntent);
              const prefixed = Buffer.concat([
                Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
                encoded,
              ]);

              const sigResult = await client.signMessage({
                walletAddress: DEPOSITOR_ADDRESS,
                blockchain: "SOL-DEVNET",
                encodedByHex: true,
                message: "0x" + prefixed.toString("hex"),
              });

              const burnIntentSignature = sigResult.data?.signature;
              if (!burnIntentSignature) throw new Error("Failed to sign burn intent");

              const formattedSignature = burnIntentSignature.startsWith("0x")
                ? burnIntentSignature
                : `0x${burnIntentSignature}`;

              const request = [{ burnIntent, signature: formattedSignature }];
              console.log("Signed burn intent.");

              // [2] Request the attestation set from Gateway API.
              const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyTypedData(request),
              });

              if (!response.ok) {
                console.error("Gateway API error status:", response.status);
                console.error(await response.text());
                throw new Error("Gateway API request failed");
              }

              const json = (await response.json()) as {
                attestation: string;
                signature: string;
              };
              console.log("Gateway API response:", JSON.stringify(json, null, 2));

              const attestation = json?.attestation;
              const operatorSig = json?.signature;

              if (!attestation || !operatorSig) {
                throw new Error("Missing attestation or signature in Gateway API response");
              }

              // [3] Mint on Arc Testnet with the returned attestation.
              console.log(`Minting funds on ${destConfig.chainName}...`);

              const tx = await client.createContractExecutionTransaction({
                walletAddress: RECIPIENT_ADDRESS,
                blockchain: destConfig.walletChain,
                contractAddress: GATEWAY_MINTER_ADDRESS,
                abiFunctionSignature: "gatewayMint(bytes,bytes)",
                abiParameters: [attestation, operatorSig],
                fee: { type: "level", config: { feeLevel: "MEDIUM" } },
              });

              const txId = tx.data?.id;
              if (!txId) throw new Error("Failed to submit mint transaction");
              console.log("Mint tx submitted:", txId);

              await waitForTxCompletion(client, txId, "USDC mint");

              console.log(`Minted ${Number(TRANSFER_VALUE) / 1_000_000} USDC`);
              console.log(`Mint transaction ID (${destConfig.walletChain}):`, txId);
            }

            main().catch((error) => {
              console.error("\nError:", error);
              process.exit(1);
            });
            ```

            ### 4.8. Run the Solana to EVM direct-mint script

            Run the script to burn from your Solana Devnet Gateway balance and call
            `gatewayMint(...)` on Arc Testnet.

            Confirm these values before running:

            * `DEPOSITOR_ADDRESS` is the source Solana Devnet wallet
            * `RECIPIENT_ADDRESS` is the destination Arc Testnet wallet
            * the source depositor has a Gateway balance on Solana Devnet
            * the destination Arc Testnet wallet can submit the mint transaction

            <Note>
              Gateway fees are charged per burn intent and are based on the source
              blockchain you burn from. Choosing where to hold and burn Gateway balances can
              affect transfer costs. For fee details, see [Gateway
              Fees](/gateway/references/fees).
            </Note>

            ```shell theme={null}
            npm run transfer-from-sol
            ```

            When the transfer succeeds, the script logs the minted amount and mint
            transaction ID.
          </Tab>

          <Tab title="Forwarding Service">
            ### 4.1. Create the Solana to EVM transfer script

            You can skip to the
            [full transfer script](#4-6-full-solana-forwarding-script-circle-wallets) if you
            prefer.

            ```shell theme={null}
            touch transfer-from-sol.ts
            ```

            ### 4.2. Define constants and types

            This script uses Arc Testnet as the destination chain.

            This forwarding flow signs the burn intent on Solana Devnet, submits it with
            forwarding enabled, and then lets Circle complete the destination mint on Arc
            Testnet.

            ```ts theme={null}
            type EstimateResponse = {
              body: Array<{
                burnIntent: {
                  maxFee: string;
                  maxBlockHeight: string;
                };
              }>;
              fees: {
                forwardingFee?: string;
                token: string;
              };
            };

            type TransferResponse = {
              transferId?: string;
            };

            type TransferStatusResponse = {
              status: string;
              transactionHash?: string;
              forwardingDetails?: {
                failureReason?: string;
              };
            };

            const TRANSFER_VALUE = 100_000n; // 0.1 USDC (6 decimals)
            const POLL_INTERVAL_MS = 5_000;
            const POLL_TIMEOUT_MS = 300_000;
            ```

            ### 4.3. Estimate forwarding fees

            Before you submit the forwarding transfer, call the Gateway estimate endpoint
            with `enableForwarder=true`. The response returns the `maxFee` and
            `maxBlockHeight` values you must include in the signed burn intent.

            ```ts theme={null}
            const estimateResponse = await fetch(
              `${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
              {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyTypedData([{ spec }]),
              },
            );
            ```

            ### 4.4. Sign and submit the forwarding burn intent

            Create the Solana burn intent with the estimated values, prefix the encoded
            payload, and sign it with the source Solana Developer-Controlled Wallet. Then
            submit it to the Gateway transfer endpoint with `enableForwarder=true`.

            For Solana-origin transfers, the burn intent uses Solana-specific binary
            encoding and must be signed with the Solana signing-domain prefix. See the
            [Solana technical guide](/gateway/references/solana#signing) for the signing
            rules and message format details.

            ```ts theme={null}
            const sigResult = await client.signMessage({
              walletAddress: DEPOSITOR_ADDRESS,
              blockchain: "SOL-DEVNET",
              encodedByHex: true,
              message: "0x" + prefixed.toString("hex"),
            });

            const response = await fetch(
              `${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
              {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyTypedData([{ burnIntent, signature }]),
              },
            );
            ```

            ### 4.5. Poll for forwarding completion

            The Forwarding Service completes the Arc Testnet mint for you. Poll the transfer
            status endpoint until the transfer is finalized or fails.

            ```ts theme={null}
            const pollRes = await fetch(`${GATEWAY_API_BASE}/v1/transfer/${transferId}`);
            ```

            ### 4.6. Full Solana forwarding script (Circle Wallets)

            The script estimates forwarding fees, signs the burn intent on Solana Devnet,
            submits it to the Forwarding Service, and polls until Circle completes the Arc
            Testnet mint. Inline comments explain each stage.

            ```ts transfer-from-sol.ts expandable theme={null}
            import { randomBytes } from "node:crypto";
            import { PublicKey } from "@solana/web3.js";
            import { u32be, struct, blob, offset, Layout } from "@solana/buffer-layout";
            import bs58 from "bs58";
            import {
              chainConfig,
              GATEWAY_API_BASE,
              GATEWAY_MINTER_ADDRESS,
              client,
              stringifyTypedData,
            } from "./config.js";

            type EstimateResponse = {
              body: Array<{
                burnIntent: {
                  maxFee: string;
                  maxBlockHeight: string;
                };
              }>;
              fees: {
                forwardingFee?: string;
                token: string;
              };
            };

            type TransferResponse = {
              transferId?: string;
            };

            type TransferStatusResponse = {
              status: string;
              transactionHash?: string;
              forwardingDetails?: {
                failureReason?: string;
              };
            };

            const DEPOSITOR_ADDRESS = process.env.DEPOSITOR_ADDRESS!;
            const RECIPIENT_ADDRESS = process.env.RECIPIENT_ADDRESS!;

            if (!DEPOSITOR_ADDRESS || !RECIPIENT_ADDRESS) {
              console.error(
                "Missing required env vars: DEPOSITOR_ADDRESS, RECIPIENT_ADDRESS",
              );
              process.exit(1);
            }

            const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
            const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
            const SOLANA_DOMAIN = 5;
            const DEST_CHAIN = "arc" as const;

            const TRANSFER_VALUE = 100_000n; // 0.1 USDC (6 decimals)
            const POLL_INTERVAL_MS = 5_000;
            const POLL_TIMEOUT_MS = 300_000;

            const TRANSFER_SPEC_MAGIC = 0xca85def7;
            const BURN_INTENT_MAGIC = 0x070afbc2;

            class PublicKeyLayout extends Layout<PublicKey> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0): PublicKey {
                return new PublicKey(b.subarray(offset, offset + 32));
              }
              encode(src: PublicKey, b: Buffer, offset = 0): number {
                const pubkeyBuffer = src.toBuffer();
                pubkeyBuffer.copy(b, offset);
                return 32;
              }
            }

            const publicKey = (property: string) => new PublicKeyLayout(property);

            class UInt256BE extends Layout<bigint> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0) {
                const buffer = b.subarray(offset, offset + 32);
                return buffer.readBigUInt64BE(24);
              }
              encode(src: bigint, b: Buffer, offset = 0) {
                const buffer = Buffer.alloc(32);
                buffer.writeBigUInt64BE(BigInt(src), 24);
                buffer.copy(b, offset);
                return 32;
              }
            }

            const uint256be = (property: string) => new UInt256BE(property);

            const BurnIntentLayout = struct([
              u32be("magic"),
              uint256be("maxBlockHeight"),
              uint256be("maxFee"),
              u32be("transferSpecLength"),
              struct(
                [
                  u32be("magic"),
                  u32be("version"),
                  u32be("sourceDomain"),
                  u32be("destinationDomain"),
                  publicKey("sourceContract"),
                  publicKey("destinationContract"),
                  publicKey("sourceToken"),
                  publicKey("destinationToken"),
                  publicKey("sourceDepositor"),
                  publicKey("destinationRecipient"),
                  publicKey("sourceSigner"),
                  publicKey("destinationCaller"),
                  uint256be("value"),
                  blob(32, "salt"),
                  u32be("hookDataLength"),
                  blob(offset(u32be(), -4), "hookData"),
                ] as any,
                "spec",
              ),
            ] as any);

            function createTransferSpec(params: {
              sourceDepositor: string;
              destinationRecipient: string;
            }) {
              const { sourceDepositor, destinationRecipient } = params;
              const destConfig = chainConfig[DEST_CHAIN];

              return {
                version: 1,
                sourceDomain: SOLANA_DOMAIN,
                destinationDomain: destConfig.domain,
                sourceContract: solanaAddressToBytes32(SOLANA_GATEWAY_WALLET),
                destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
                sourceToken: solanaAddressToBytes32(SOLANA_USDC),
                destinationToken: evmAddressToBytes32(destConfig.usdc),
                sourceDepositor: solanaAddressToBytes32(sourceDepositor),
                destinationRecipient: evmAddressToBytes32(destinationRecipient),
                sourceSigner: solanaAddressToBytes32(sourceDepositor),
                destinationCaller: evmAddressToBytes32(
                  "0x0000000000000000000000000000000000000000",
                ),
                value: TRANSFER_VALUE,
                salt: `0x${randomBytes(32).toString("hex")}`,
                hookData: "0x",
              };
            }

            function encodeBurnIntent(bi: {
              maxBlockHeight: bigint;
              maxFee: bigint;
              spec: ReturnType<typeof createTransferSpec>;
            }): Buffer {
              const hookData = Buffer.from((bi.spec.hookData || "0x").slice(2), "hex");
              const prepared = {
                magic: BURN_INTENT_MAGIC,
                maxBlockHeight: bi.maxBlockHeight,
                maxFee: bi.maxFee,
                transferSpecLength: 340 + hookData.length,
                spec: {
                  magic: TRANSFER_SPEC_MAGIC,
                  version: bi.spec.version,
                  sourceDomain: bi.spec.sourceDomain,
                  destinationDomain: bi.spec.destinationDomain,
                  sourceContract: hexToPublicKey(bi.spec.sourceContract),
                  destinationContract: hexToPublicKey(bi.spec.destinationContract),
                  sourceToken: hexToPublicKey(bi.spec.sourceToken),
                  destinationToken: hexToPublicKey(bi.spec.destinationToken),
                  sourceDepositor: hexToPublicKey(bi.spec.sourceDepositor),
                  destinationRecipient: hexToPublicKey(bi.spec.destinationRecipient),
                  sourceSigner: hexToPublicKey(bi.spec.sourceSigner),
                  destinationCaller: hexToPublicKey(bi.spec.destinationCaller),
                  value: bi.spec.value,
                  salt: Buffer.from(bi.spec.salt.slice(2), "hex"),
                  hookDataLength: hookData.length,
                  hookData,
                },
              };
              const buffer = Buffer.alloc(72 + 340 + hookData.length);
              const bytesWritten = BurnIntentLayout.encode(prepared, buffer);
              return buffer.subarray(0, bytesWritten);
            }

            function solanaAddressToBytes32(address: string): string {
              const decoded = Buffer.from(bs58.decode(address));
              return `0x${decoded.toString("hex")}`;
            }

            function evmAddressToBytes32(address: string): string {
              return "0x" + address.toLowerCase().replace(/^0x/, "").padStart(64, "0");
            }

            function hexToPublicKey(hex: string): PublicKey {
              return new PublicKey(Buffer.from(hex.slice(2), "hex"));
            }

            async function main() {
              const destConfig = chainConfig[DEST_CHAIN];

              console.log(
                `Forwarding 0.1 USDC from Solana Devnet to ${destConfig.chainName}`,
              );
              console.log(`Using wallet: ${DEPOSITOR_ADDRESS}`);
              console.log(`Recipient: ${RECIPIENT_ADDRESS}`);

              const spec = createTransferSpec({
                sourceDepositor: DEPOSITOR_ADDRESS,
                destinationRecipient: RECIPIENT_ADDRESS,
              });

              // [1] Estimate forwarding fee inputs for the burn intent.
              console.log("\n[1/3] Estimating forwarding fees...");

              const estimateResponse = await fetch(
                `${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
                {
                  method: "POST",
                  headers: { "Content-Type": "application/json" },
                  body: stringifyTypedData([{ spec }]),
                },
              );

              if (!estimateResponse.ok) {
                const text = await estimateResponse.text();
                throw new Error(`Estimate API error: ${estimateResponse.status} ${text}`);
              }

              const estimateResult = (await estimateResponse.json()) as EstimateResponse;
              const estimated = estimateResult.body[0]?.burnIntent;
              if (!estimated) throw new Error("Missing burnIntent estimate");

              const maxFee = BigInt(estimated.maxFee);
              const maxBlockHeight = BigInt(estimated.maxBlockHeight);
              const { fees } = estimateResult;

              if (fees.forwardingFee) {
                console.log(`Forwarding fee: ${fees.forwardingFee} ${fees.token}`);
              }
              console.log(`Estimated maxFee: ${Number(maxFee) / 1_000_000} ${fees.token}`);

              // [2] Create, sign, and submit the forwarding burn intent.
              console.log("\n[2/3] Signing and submitting burn intent...");

              const burnIntent = { maxBlockHeight, maxFee, spec };
              const encoded = encodeBurnIntent(burnIntent);
              const prefixed = Buffer.concat([
                Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
                encoded,
              ]);

              const sigResult = await client.signMessage({
                walletAddress: DEPOSITOR_ADDRESS,
                blockchain: "SOL-DEVNET",
                encodedByHex: true,
                message: "0x" + prefixed.toString("hex"),
              });

              const signature = sigResult.data?.signature;
              if (!signature) throw new Error("Failed to sign burn intent");

              const response = await fetch(
                `${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
                {
                  method: "POST",
                  headers: { "Content-Type": "application/json" },
                  body: stringifyTypedData([{ burnIntent, signature }]),
                },
              );

              if (!response.ok) {
                const text = await response.text();
                throw new Error(`Gateway API error: ${response.status} ${text}`);
              }

              const json = (await response.json()) as TransferResponse;
              if (!json.transferId) throw new Error("Missing transferId in response");
              const transferId = json.transferId;
              console.log(`Transfer ID: ${transferId}`);

              // [3] Poll until the Forwarding Service completes the destination mint.
              console.log("\n[3/3] Polling for transfer completion...");

              const pollStart = Date.now();
              while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
                const pollRes = await fetch(
                  `${GATEWAY_API_BASE}/v1/transfer/${transferId}`,
                );

                if (!pollRes.ok) {
                  console.error(`Poll error: ${pollRes.status}`);
                  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
                  continue;
                }

                const details = (await pollRes.json()) as TransferStatusResponse;
                console.log(`Status: ${details.status}`);

                if (details.status === "finalized" || details.status === "confirmed") {
                  if (details.transactionHash) {
                    console.log(`Mint transaction hash: ${details.transactionHash}`);
                  }
                  break;
                }

                if (details.status === "failed") {
                  const reason = details.forwardingDetails?.failureReason ?? "unknown";
                  throw new Error(`Transfer failed: ${reason}`);
                }

                if (details.status === "expired") {
                  throw new Error("Transfer attestation expired before forwarding");
                }

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

              if (Date.now() - pollStart >= POLL_TIMEOUT_MS) {
                throw new Error("Polling timed out waiting for transfer completion");
              }

              console.log(
                `Transfer complete. 0.1 USDC forwarded to ${destConfig.chainName}.`,
              );
            }

            main().catch((error) => {
              console.error("\nError:", error);
              process.exit(1);
            });
            ```

            ### 4.7. Run the Solana forwarding script

            Run the script to burn from your Solana Devnet Gateway balance and let the
            Forwarding Service mint on Arc Testnet.

            Confirm these values before running:

            * `DEPOSITOR_ADDRESS` is the source Solana Devnet wallet
            * `RECIPIENT_ADDRESS` is the destination Arc Testnet wallet
            * the source depositor has a Gateway balance on Solana Devnet
            * no destination gas is required for this forwarding path

            <Note>
              Gateway fees are charged per burn intent and are based on the source
              blockchain you burn from. Choosing where to hold and burn Gateway balances can
              affect transfer costs. For fee details, see [Gateway
              Fees](/gateway/references/fees).
            </Note>

            ```shell theme={null}
            npm run transfer-from-sol
            ```

            When the transfer succeeds, the script logs the transfer ID, status, and
            destination mint transaction hash.
          </Tab>
        </Tabs>
      </Tab>
    </Tabs>
  </Tab>

  <Tab title="Self-managed">
    ## Prerequisites

    This quickstart uses Arc Testnet for deposits and the
    `Arc Testnet -> Base Sepolia` route for the EVM transfer example. You can adapt
    the same code to other supported EVM testnets later in the guide.

    Before you begin, ensure that you've:

    * Installed [Node.js v22.6+](https://nodejs.org/)
    * Prepared an EVM testnet wallet with the private key available
      * Added the
        [supported Testnets](/gateway/references/supported-blockchains#testnet) of
        your choice to your wallet

    If you want to try the Solana to EVM transfer, ensure that you've:

    * Prepared a Solana Devnet wallet and exported its keypair as a JSON array
    * Completed
      [Step 3. Deposit into a unified crosschain balance](/gateway/quickstarts/unified-balance-solana#step-3-deposit-into-a-unified-crosschain-balance-self-managed)
      from the
      [Solana quickstart](/gateway/quickstarts/unified-balance-solana#self-managed)

    ### Add testnet funds to your wallet

    To interact with Gateway, you need test USDC and native tokens in your wallet on
    each chain you deposit from. In the default Arc Testnet deposit flow, the same
    USDC also covers transaction fees because Arc uses USDC as the native gas token.
    For direct-mint transfers, you also need the destination chain's gas token to
    call the Gateway Minter contract.

    Use the [Circle Faucet](https://faucet.circle.com/) to get testnet USDC. If you
    have a [Circle Developer Console](https://console.circle.com) account, you can
    use the [Console Faucet](https://console.circle.com/faucet) to get testnet
    native tokens. In addition, the following faucets can also be used to fund your
    wallet with testnet native tokens:

    <Tabs>
      <Tab title="Arc">
        **Faucet:** [Arc Testnet](https://faucet.circle.com) (USDC + native tokens)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `arcTestnet`                                 |
        | USDC address | `0x3600000000000000000000000000000000000000` |
        | Domain ID    | `26`                                         |
      </Tab>

      <Tab title="Avalanche">
        **Faucet:** [Avalanche Fuji](https://core.app/tools/testnet-faucet)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `avalancheFuji`                              |
        | USDC address | `0x5425890298aed601595a70ab815c96711a31bc65` |
        | Domain ID    | `1`                                          |
      </Tab>

      <Tab title="Base">
        **Faucet:** [Base Sepolia](https://www.alchemy.com/faucets/base-sepolia)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `baseSepolia`                                |
        | USDC address | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` |
        | Domain ID    | `6`                                          |
      </Tab>

      <Tab title="Ethereum">
        **Faucet:** [Ethereum Sepolia](https://cloud.google.com/application/web3/faucet/ethereum/sepolia)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `sepolia`                                    |
        | USDC address | `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238` |
        | Domain ID    | `0`                                          |
      </Tab>

      <Tab title="Hyperliquid">
        **Faucet:** [Hyperliquid EVM Testnet](https://app.hyperliquid-testnet.xyz/drip)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `hyperliquidEvmTestnet`                      |
        | USDC address | `0x2B3370eE501B4a559b57D449569354196457D8Ab` |
        | Domain ID    | `19`                                         |
      </Tab>

      <Tab title="Sei">
        **Faucet:** [Sei Testnet](https://docs.sei.io/learn/faucet)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `seiTestnet`                                 |
        | USDC address | `0x4fCF1784B31630811181f670Aea7A7bEF803eaED` |
        | Domain ID    | `16`                                         |
      </Tab>

      <Tab title="Solana">
        **Faucet:** [Solana Devnet](https://faucet.solana.com/)

        | Property     | Value                                                   |
        | ------------ | ------------------------------------------------------- |
        | Chain name   | `solanaDevnet` (note that Solana is not EVM-compatible) |
        | USDC address | `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`          |
        | Domain ID    | `5`                                                     |
      </Tab>

      <Tab title="Sonic">
        **Faucet:** [Sonic Testnet](https://testnet.soniclabs.com/account)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `sonicTestnet`                               |
        | USDC address | `0x0BA304580ee7c9a980CF72e55f5Ed2E9fd30Bc51` |
        | Domain ID    | `13`                                         |
      </Tab>

      <Tab title="Worldchain">
        **Faucet:** [Worldchain Sepolia](https://www.l2faucet.com/world)

        | Property     | Value                                        |
        | ------------ | -------------------------------------------- |
        | Chain name   | `worldchainSepolia`                          |
        | USDC address | `0x66145f38cBAC35Ca6F1Dfb4914dF98F1614aeA88` |
        | Domain ID    | `14`                                         |
      </Tab>
    </Tabs>

    ## Step 1. Set up your project

    ### 1.1. Create the project and install dependencies

    ```shell theme={null}
    # Set up your directory and initialize a Node.js project
    mkdir unified-gateway-balance-evm
    cd unified-gateway-balance-evm
    npm init -y

    # Set up module type and run scripts
    npm pkg set type=module
    npm pkg set scripts.deposit="node --env-file=.env deposit.ts"
    npm pkg set scripts.balances="node --env-file=.env balances.ts"
    npm pkg set scripts.transfer-from-evm="node --env-file=.env transfer-from-evm.ts"

    # Install runtime dependencies
    npm install viem typescript

    # Install dev dependencies
    npm install --save-dev @types/node
    ```

    If you want to try the Solana to EVM transfer, add the run script and install
    these additional dependencies:

    ```shell theme={null}
    npm pkg set scripts.transfer-from-sol="node --env-file=.env transfer-from-sol.ts"
    npm pkg set overrides.bigint-buffer=npm:@trufflesuite/bigint-buffer@1.1.10
    npm install @solana/buffer-layout @solana/web3.js bs58
    ```

    ### 1.2. Configure TypeScript (optional)

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

    Open `.env` in your editor and add:

    ```text theme={null}
    EVM_PRIVATE_KEY=YOUR_EVM_PRIVATE_KEY
    ```

    If you want to try the Solana to EVM transfer, also add:

    ```text theme={null}
    SOLANA_PRIVATE_KEYPAIR=YOUR_SOLANA_KEYPAIR_ARRAY
    ```

    * `EVM_PRIVATE_KEY` is the private key for the EVM wallet you use for the EVM
      side of the transfer.
    * `SOLANA_PRIVATE_KEYPAIR` is the Solana wallet keypair as a JSON array for the
      Solana side of the transfer.

    <Note>
      If your wallet exports a private key hash instead, you can use `bs58` to convert it:

      ```ts TypeScript theme={null}
      const bytes = bs58.decode("YOUR_PRIVATE_KEY_HASH");
      console.log(JSON.stringify(Array.from(bytes)));
      ```
    </Note>

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

    <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. Set up the configuration file

    This section covers the shared configuration file used by both the deposit and
    transfer scripts.

    ### 2.1. Create the configuration file

    ```shell theme={null}
    touch config.ts
    ```

    ### 2.2. Configure shared Gateway constants

    Add only the shared values used across the deposit and transfer scripts. This
    keeps `config.ts` focused on shared chain data without turning it into a generic
    chain-selector layer. To swap chains later, leave `chainConfig` as the shared
    map and change the `SOURCE_CHAIN` and `DEST_CHAIN` constants inside each script.

    ```ts config.ts expandable theme={null}
    import { type Address } from "viem";
    import { baseSepolia, arcTestnet } from "viem/chains";
    import { privateKeyToAccount } from "viem/accounts";

    /* Account Setup */
    if (!process.env.EVM_PRIVATE_KEY) {
      throw new Error("EVM_PRIVATE_KEY not set in environment");
    }
    export const account = privateKeyToAccount(
      process.env.EVM_PRIVATE_KEY as `0x${string}`,
    );

    /* Gateway Contract Addresses */
    export const GATEWAY_WALLET_ADDRESS: Address =
      "0x0077777d7EBA4688BDeF3E311b846F25870A19B9";
    export const GATEWAY_MINTER_ADDRESS: Address =
      "0x0022222ABE238Cc2C7Bb1f21003F0a260052475B";

    export const GATEWAY_API_BASE = "https://gateway-api-testnet.circle.com";

    export const chainConfig = {
      arc: {
        chain: arcTestnet,
        usdcAddress: "0x3600000000000000000000000000000000000000" as Address,
        domainId: 26,
      },
      base: {
        chain: baseSepolia,
        usdcAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as Address,
        domainId: 6,
      },
    } as const;

    export type ChainKey = keyof typeof chainConfig;

    export function stringifyBigInts<T>(obj: T) {
      return JSON.stringify(obj, (_key, value) =>
        typeof value === "bigint" ? value.toString() : value,
      );
    }
    ```

    ## Step 3. Deposit into a unified crosschain balance (self-managed)

    This section explains the deposit script that deposits USDC into the Gateway
    Wallet contract on Arc Testnet. You can skip to the
    [full deposit script](#3-5-full-deposit-script-self-managed) if you prefer.

    ### 3.1. Create the script file

    ```shell theme={null}
    touch deposit.ts
    ```

    ### 3.2. Define constants and ABI

    ```ts deposit.ts theme={null}
    const DEPOSIT_AMOUNT = 2_000000n; // 2 USDC (6 decimals)

    // Gateway Wallet ABI (minimal - only deposit function)
    const gatewayWalletAbi = [
      {
        type: "function",
        name: "deposit",
        inputs: [
          { name: "token", type: "address" },
          { name: "value", type: "uint256" },
        ],
        outputs: [],
        stateMutability: "nonpayable",
      },
    ] as const;
    ```

    ### 3.3. Setup clients and check balances

    Set up the client and contracts for the chain, then verify sufficient USDC
    balance before depositing.

    ```ts deposit.ts theme={null}
    const SOURCE_CHAIN = "arc" as const;
    const sourceConfig = chainConfig[SOURCE_CHAIN];
    const chainName = sourceConfig.chain.name;

    const client = createPublicClient({
      chain: sourceConfig.chain,
      transport: http(),
    });

    // Get contract instances
    const usdcContract = getContract({
      address: sourceConfig.usdcAddress,
      abi: erc20Abi,
      client,
    });

    const gatewayWallet = getContract({
      address: GATEWAY_WALLET_ADDRESS,
      abi: gatewayWalletAbi,
      client,
    });

    // Check USDC balance
    const balance = await usdcContract.read.balanceOf([account.address]);
    console.log(`Current balance: ${formatUnits(balance, 6)} USDC`);

    if (balance < DEPOSIT_AMOUNT) {
      throw new Error(
        "Insufficient USDC balance. Please top up at https://faucet.circle.com",
      );
    }
    ```

    ### 3.4. Approve and deposit USDC

    The main logic performs two key actions:

    * Approve USDC transfers: It calls the `approve` method on the USDC contract to
      allow the Gateway Wallet contract to transfer USDC from your wallet.
    * Deposit USDC into Gateway: After receiving the approval transaction hash, it
      calls the `deposit` method on the Gateway Wallet contract.

    ```ts deposit.ts theme={null}
    // [1] Approve Gateway Wallet to spend USDC
    console.log(
      `Approving ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC on ${chainName}...`,
    );
    const approvalTx = await usdcContract.write.approve(
      [GATEWAY_WALLET_ADDRESS, DEPOSIT_AMOUNT],
      { account },
    );
    await client.waitForTransactionReceipt({ hash: approvalTx });
    console.log(`Approved on ${chainName}: ${approvalTx}`);

    // [2] Deposit USDC into Gateway Wallet
    console.log(
      `Depositing ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC to Gateway Wallet`,
    );
    const depositTx = await gatewayWallet.write.deposit(
      [sourceConfig.usdcAddress, DEPOSIT_AMOUNT],
      { account },
    );
    await client.waitForTransactionReceipt({ hash: depositTx });
    console.log(`Done on ${chainName}. Deposit tx: ${depositTx}`);
    ```

    ### 3.5. Full deposit script (Self-managed)

    The complete deposit script checks the source-chain USDC balance, then deposits
    funds into the Gateway Wallet contract. The script includes inline comments to
    explain each stage.

    ```ts deposit.ts expandable theme={null}
    import {
      createPublicClient,
      getContract,
      http,
      erc20Abi,
      formatUnits,
    } from "viem";
    import { account, chainConfig, GATEWAY_WALLET_ADDRESS } from "./config.js";

    const DEPOSIT_AMOUNT = 2_000000n; // 2 USDC (6 decimals)
    const SOURCE_CHAIN = "arc" as const;

    // Gateway Wallet ABI (minimal - only deposit function)
    const gatewayWalletAbi = [
      {
        type: "function",
        name: "deposit",
        inputs: [
          { name: "token", type: "address" },
          { name: "value", type: "uint256" },
        ],
        outputs: [],
        stateMutability: "nonpayable",
      },
    ] as const;

    async function main() {
      const sourceConfig = chainConfig[SOURCE_CHAIN];

      console.log(`Using account: ${account.address}\n`);
      console.log(`Depositing on: ${sourceConfig.chain.name}\n`);

      // Create the client for the source chain.
      const client = createPublicClient({
        chain: sourceConfig.chain,
        transport: http(),
      });

      // Get the USDC and Gateway Wallet contracts.
      const usdcContract = getContract({
        address: sourceConfig.usdcAddress,
        abi: erc20Abi,
        client,
      });

      const gatewayWallet = getContract({
        address: GATEWAY_WALLET_ADDRESS,
        abi: gatewayWalletAbi,
        client,
      });

      // [1] Check the current source-chain USDC balance.
      const balance = await usdcContract.read.balanceOf([account.address]);
      console.log(`Current balance: ${formatUnits(balance, 6)} USDC`);

      if (balance < DEPOSIT_AMOUNT) {
        throw new Error(
          "Insufficient USDC balance. Please top up at https://faucet.circle.com",
        );
      }

      // [2] Approve the Gateway Wallet to spend USDC.
      console.log(
        `Approving ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC on ${sourceConfig.chain.name}...`,
      );
      const approvalTx = await usdcContract.write.approve(
        [GATEWAY_WALLET_ADDRESS, DEPOSIT_AMOUNT],
        { account },
      );
      await client.waitForTransactionReceipt({ hash: approvalTx });
      console.log(`Approved on ${sourceConfig.chain.name}: ${approvalTx}`);

      // [3] Deposit USDC into the source-chain Gateway Wallet.
      console.log(
        `Depositing ${formatUnits(DEPOSIT_AMOUNT, 6)} USDC to Gateway Wallet`,
      );
      const depositTx = await gatewayWallet.write.deposit(
        [sourceConfig.usdcAddress, DEPOSIT_AMOUNT],
        { account },
      );
      await client.waitForTransactionReceipt({ hash: depositTx });
      console.log(`Done on ${sourceConfig.chain.name}. Deposit tx: ${depositTx}`);
    }

    main().catch((error) => {
      console.error("\nError:", error);
      process.exit(1);
    });
    ```

    ### 3.6. Run the script to create a crosschain balance

    Run the deposit script to make the source-chain deposit.

    ```shell theme={null}
    npm run deposit
    ```

    Wait for the
    [required number of block confirmations](https://developers.circle.com/gateway/references/supported-blockchains#required-block-confirmations).
    Once the deposit transactions are final, the total balance is the sum of all the
    USDC from deposit transactions across all supported chains that have reached
    finality. Note that for certain chains, finality may take up to 20 minutes to be
    reached.

    To adapt this deposit script later, update `SOURCE_CHAIN` to another supported
    entry in `chainConfig`.

    ### 3.7. Check the balances on the Gateway Wallet

    Create a new file called `balances.ts`, and add the following code. This script
    retrieves the USDC balances available from your Gateway Wallet across the
    supported testnet routes in `chainConfig`.

    ```ts balances.ts expandable theme={null}
    import { privateKeyToAccount } from "viem/accounts";

    if (!process.env.EVM_PRIVATE_KEY) {
      throw new Error("Missing EVM_PRIVATE_KEY in environment");
    }

    const DOMAINS = {
      baseSepolia: 6,
      arcTestnet: 26,
    };

    async function main() {
      const account = privateKeyToAccount(
        process.env.EVM_PRIVATE_KEY as `0x${string}`,
      );
      const depositor = account.address;

      console.log(`Depositor address: ${depositor}\n`);

      const body = {
        token: "USDC",
        sources: Object.entries(DOMAINS).map(([_, domain]) => ({
          domain,
          depositor,
        })),
      };

      const res = await fetch(
        "https://gateway-api-testnet.circle.com/v1/balances",
        {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(body),
        },
      );

      const result = await res.json();

      let total = 0;
      for (const balance of result.balances) {
        const chain =
          Object.keys(DOMAINS).find(
            (key) => DOMAINS[key as keyof typeof DOMAINS] === balance.domain,
          ) || `Domain ${balance.domain}`;
        const amount = parseFloat(balance.balance);
        console.log(`${chain}: ${amount.toFixed(6)} USDC`);
        total += amount;
      }

      console.log(`\nTotal: ${total.toFixed(6)} USDC`);
    }

    main().catch((error) => {
      console.error("\nError:", error);
      process.exit(1);
    });
    ```

    You can run it to check whether finality has been reached for recent
    transactions.

    ```shell theme={null}
    npm run balances
    ```

    <Tabs>
      <Tab title="Transfer from EVM">
        ## Step 4. Transfer USDC from EVM to EVM

        This step transfers USDC from your Arc Testnet Gateway balance to Base Sepolia.
        Both paths create and submit the burn intent from Arc Testnet first. Direct Mint
        then retrieves the Gateway attestation and calls `gatewayMint(...)` on Base
        Sepolia from your wallet, while the Forwarding Service lets Circle complete the
        destination mint for you.

        <Tabs>
          <Tab title="Direct Mint">
            ### 4.1. Create the script file (Self-managed)

            You can skip to the
            [full transfer script](#4-7-full-evm-transfer-script-self-managed) if you
            prefer.

            ```shell theme={null}
            touch transfer-from-evm.ts
            ```

            ### 4.2. Define constants and types

            This script uses Arc Testnet as the source chain and Base Sepolia as the
            destination chain.

            ```ts transfer-from-evm.ts theme={null}
            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const MAX_FEE = 2_010000n;

            // EIP-712 Domain and Types for Gateway burn intents
            const domain = { name: "GatewayWallet", version: "1" };

            const EIP712Domain = [
              { name: "name", type: "string" },
              { name: "version", type: "string" },
            ] as const;

            const TransferSpec = [
              { name: "version", type: "uint32" },
              { name: "sourceDomain", type: "uint32" },
              { name: "destinationDomain", type: "uint32" },
              { name: "sourceContract", type: "bytes32" },
              { name: "destinationContract", type: "bytes32" },
              { name: "sourceToken", type: "bytes32" },
              { name: "destinationToken", type: "bytes32" },
              { name: "sourceDepositor", type: "bytes32" },
              { name: "destinationRecipient", type: "bytes32" },
              { name: "sourceSigner", type: "bytes32" },
              { name: "destinationCaller", type: "bytes32" },
              { name: "value", type: "uint256" },
              { name: "salt", type: "bytes32" },
              { name: "hookData", type: "bytes" },
            ] as const;

            const BurnIntent = [
              { name: "maxBlockHeight", type: "uint256" },
              { name: "maxFee", type: "uint256" },
              { name: "spec", type: "TransferSpec" },
            ] as const;

            const gatewayMinterAbi = [
              {
                type: "function",
                name: "gatewayMint",
                inputs: [
                  { name: "attestationPayload", type: "bytes" },
                  { name: "signature", type: "bytes" },
                ],
                outputs: [],
                stateMutability: "nonpayable",
              },
            ] as const;
            ```

            ### 4.3. Add helper functions

            ```ts transfer-from-evm.ts theme={null}
            // Create a burn intent for crosschain transfer
            const SOURCE_CHAIN = "arc" as const;
            const DEST_CHAIN = "base" as const;

            function createBurnIntent(params: {
              depositorAddress: string;
              recipientAddress?: string;
            }) {
              const { depositorAddress, recipientAddress = depositorAddress } = params;
              const sourceConfig = chainConfig[SOURCE_CHAIN];
              const destConfig = chainConfig[DEST_CHAIN];

              return {
                maxBlockHeight: maxUint256,
                maxFee: MAX_FEE,
                spec: {
                  version: 1,
                  sourceDomain: sourceConfig.domainId,
                  destinationDomain: destConfig.domainId,
                  sourceContract: GATEWAY_WALLET_ADDRESS,
                  destinationContract: GATEWAY_MINTER_ADDRESS,
                  sourceToken: sourceConfig.usdcAddress,
                  destinationToken: destConfig.usdcAddress,
                  sourceDepositor: depositorAddress,
                  destinationRecipient: recipientAddress,
                  sourceSigner: depositorAddress,
                  destinationCaller: zeroAddress,
                  value: TRANSFER_VALUE,
                  salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
                  hookData: "0x" as Hex,
                },
              };
            }

            // Create EIP-712 typed data for signing
            function burnIntentTypedData(burnIntent: ReturnType<typeof createBurnIntent>) {
              return {
                types: { EIP712Domain, TransferSpec, BurnIntent },
                domain,
                primaryType: "BurnIntent" as const,
                message: {
                  ...burnIntent,
                  spec: {
                    ...burnIntent.spec,
                    sourceContract: addressToBytes32(burnIntent.spec.sourceContract),
                    destinationContract: addressToBytes32(
                      burnIntent.spec.destinationContract,
                    ),
                    sourceToken: addressToBytes32(burnIntent.spec.sourceToken),
                    destinationToken: addressToBytes32(burnIntent.spec.destinationToken),
                    sourceDepositor: addressToBytes32(burnIntent.spec.sourceDepositor),
                    destinationRecipient: addressToBytes32(
                      burnIntent.spec.destinationRecipient,
                    ),
                    sourceSigner: addressToBytes32(burnIntent.spec.sourceSigner),
                    destinationCaller: addressToBytes32(burnIntent.spec.destinationCaller),
                  },
                },
              };
            }

            // Convert address to bytes32
            function addressToBytes32(address: string): Hex {
              return pad(address.toLowerCase() as Hex, { size: 32 });
            }
            ```

            ### 4.4. Create and sign burn intents

            ```ts transfer-from-evm.ts theme={null}
            const intent = createBurnIntent({
              depositorAddress: account.address,
            });

            const typedData = burnIntentTypedData(intent);
            const signature = await account.signTypedData(typedData);
            const requests = [{ burnIntent: typedData.message, signature }];
            console.log("Signed burn intent.");
            ```

            ### 4.5. Request attestation from Gateway API

            ```ts transfer-from-evm.ts theme={null}
            const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: stringifyBigInts(requests),
            });

            if (!response.ok) {
              const text = await response.text();
              throw new Error(`Gateway API error: ${response.status} ${text}`);
            }

            const json = await response.json();
            console.log("Gateway API response:", JSON.stringify(json, null, 2));

            const attestation = json?.attestation;
            const operatorSig = json?.signature;

            if (!attestation || !operatorSig) {
              throw new Error("Missing attestation or signature in response");
            }
            ```

            ### 4.6. Mint on Base Sepolia

            ```ts transfer-from-evm.ts theme={null}
            const destConfig = chainConfig[DEST_CHAIN];

            const destClient = createPublicClient({
              chain: destConfig.chain,
              transport: http(),
            });

            const walletClient = createWalletClient({
              account,
              chain: destConfig.chain,
              transport: http(),
            });

            const destinationGatewayMinterContract = getContract({
              address: GATEWAY_MINTER_ADDRESS,
              abi: gatewayMinterAbi,
              client: { public: destClient, wallet: walletClient },
            });

            console.log(`Minting funds on ${destConfig.chain.name}...`);
            const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
              [attestation, operatorSig],
              { account },
            );

            await destClient.waitForTransactionReceipt({ hash: mintTx });

            const totalMinted = BigInt(requests.length) * TRANSFER_VALUE;
            console.log(`Minted ${formatUnits(totalMinted, 6)} USDC`);
            console.log(`Mint transaction hash (${destConfig.chain.name}):`, mintTx);
            ```

            ### 4.7. Full EVM transfer script (Self-managed)

            The complete transfer script creates and signs a burn intent for Arc Testnet,
            submits it to the Gateway API for attestation, and mints USDC on Base Sepolia.
            The script includes inline comments to explain each stage.

            ```ts transfer-from-evm.ts expandable theme={null}
            import {
              createPublicClient,
              createWalletClient,
              getContract,
              http,
              pad,
              zeroAddress,
              maxUint256,
              formatUnits,
              type Hex,
            } from "viem";
            import { randomBytes } from "node:crypto";
            import {
              account,
              chainConfig,
              GATEWAY_API_BASE,
              GATEWAY_WALLET_ADDRESS,
              GATEWAY_MINTER_ADDRESS,
              stringifyBigInts,
            } from "./config.js";

            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const MAX_FEE = 2_010000n;
            const SOURCE_CHAIN = "arc" as const;
            const DEST_CHAIN = "base" as const;

            // EIP-712 Domain and Types for Gateway burn intents
            const domain = { name: "GatewayWallet", version: "1" };

            const EIP712Domain = [
              { name: "name", type: "string" },
              { name: "version", type: "string" },
            ] as const;

            const TransferSpec = [
              { name: "version", type: "uint32" },
              { name: "sourceDomain", type: "uint32" },
              { name: "destinationDomain", type: "uint32" },
              { name: "sourceContract", type: "bytes32" },
              { name: "destinationContract", type: "bytes32" },
              { name: "sourceToken", type: "bytes32" },
              { name: "destinationToken", type: "bytes32" },
              { name: "sourceDepositor", type: "bytes32" },
              { name: "destinationRecipient", type: "bytes32" },
              { name: "sourceSigner", type: "bytes32" },
              { name: "destinationCaller", type: "bytes32" },
              { name: "value", type: "uint256" },
              { name: "salt", type: "bytes32" },
              { name: "hookData", type: "bytes" },
            ] as const;

            const BurnIntent = [
              { name: "maxBlockHeight", type: "uint256" },
              { name: "maxFee", type: "uint256" },
              { name: "spec", type: "TransferSpec" },
            ] as const;

            const gatewayMinterAbi = [
              {
                type: "function",
                name: "gatewayMint",
                inputs: [
                  { name: "attestationPayload", type: "bytes" },
                  { name: "signature", type: "bytes" },
                ],
                outputs: [],
                stateMutability: "nonpayable",
              },
            ] as const;

            async function main() {
              const sourceConfig = chainConfig[SOURCE_CHAIN];
              const destConfig = chainConfig[DEST_CHAIN];

              console.log(`Using account: ${account.address}`);
              console.log(
                `Transferring from: ${sourceConfig.chain.name} -> ${destConfig.chain.name}`,
              );

              // [1] Create and sign the Arc Testnet burn intent.
              console.log(
                `Creating burn intent from ${sourceConfig.chain.name} -> ${destConfig.chain.name}...`,
              );

              const burnIntent = {
                maxBlockHeight: maxUint256,
                maxFee: MAX_FEE,
                spec: {
                  version: 1,
                  sourceDomain: sourceConfig.domainId,
                  destinationDomain: destConfig.domainId,
                  sourceContract: pad(GATEWAY_WALLET_ADDRESS.toLowerCase() as Hex, {
                    size: 32,
                  }),
                  destinationContract: pad(GATEWAY_MINTER_ADDRESS.toLowerCase() as Hex, {
                    size: 32,
                  }),
                  sourceToken: pad(sourceConfig.usdcAddress.toLowerCase() as Hex, {
                    size: 32,
                  }),
                  destinationToken: pad(destConfig.usdcAddress.toLowerCase() as Hex, {
                    size: 32,
                  }),
                  sourceDepositor: pad(account.address.toLowerCase() as Hex, { size: 32 }),
                  destinationRecipient: pad(account.address.toLowerCase() as Hex, {
                    size: 32,
                  }),
                  sourceSigner: pad(account.address.toLowerCase() as Hex, { size: 32 }),
                  destinationCaller: pad(zeroAddress as Hex, { size: 32 }),
                  value: TRANSFER_VALUE,
                  salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
                  hookData: "0x" as Hex,
                },
              };

              const typedData = {
                types: { EIP712Domain, TransferSpec, BurnIntent },
                domain,
                primaryType: "BurnIntent" as const,
                message: burnIntent,
              };
              const signature = await account.signTypedData(typedData);
              const requests = [{ burnIntent: typedData.message, signature }];
              console.log("Signed burn intent.");

              // [2] Request attestation from Gateway API
              const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyBigInts(requests),
              });

              if (!response.ok) {
                const text = await response.text();
                throw new Error(`Gateway API error: ${response.status} ${text}`);
              }

              const json = await response.json();
              console.log("Gateway API response:", JSON.stringify(json, null, 2));

              const attestation = json?.attestation;
              const operatorSig = json?.signature;

              if (!attestation || !operatorSig) {
                throw new Error("Missing attestation or signature in response");
              }

              // [3] Mint on destination chain
              const destClient = createPublicClient({
                chain: destConfig.chain,
                transport: http(),
              });

              const walletClient = createWalletClient({
                account,
                chain: destConfig.chain,
                transport: http(),
              });

              const destinationGatewayMinterContract = getContract({
                address: GATEWAY_MINTER_ADDRESS,
                abi: gatewayMinterAbi,
                client: { public: destClient, wallet: walletClient },
              });

              console.log(`Minting funds on ${destConfig.chain.name}...`);
              const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
                [attestation, operatorSig],
                { account },
              );

              await destClient.waitForTransactionReceipt({ hash: mintTx });

              const totalMinted = TRANSFER_VALUE;
              console.log(`Minted ${formatUnits(totalMinted, 6)} USDC`);
              console.log(`Mint transaction hash (${destConfig.chain.name}):`, mintTx);
            }

            main().catch((error) => {
              console.error("\nError:", error);
              process.exit(1);
            });
            ```

            ### 4.8. Run the EVM direct-mint script

            Run the script to burn from your Arc Testnet Gateway balance and call
            `gatewayMint(...)` on Base Sepolia.

            Confirm these values before running:

            * `EVM_PRIVATE_KEY` controls the source Arc Testnet account
            * the source account has a Gateway balance on Arc Testnet
            * the destination account has enough Base Sepolia ETH to submit the mint
              transaction

            <Note>
              Gateway fees are charged per burn intent and are based on the source
              blockchain you burn from. Choosing where to hold and burn Gateway balances can
              affect transfer costs. For fee details, see [Gateway
              Fees](/gateway/references/fees).
            </Note>

            ```shell theme={null}
            npm run transfer-from-evm
            ```

            When the transfer succeeds, the script logs the minted amount and mint
            transaction hash.
          </Tab>

          <Tab title="Forwarding Service">
            ### 4.1. Create the script file

            You can skip to the
            [full transfer script](#4-7-full-evm-forwarding-script-self-managed) if you
            prefer.

            ```shell theme={null}
            touch transfer-from-evm.ts
            ```

            ### 4.2. Define constants and types

            This script uses Arc Testnet as the source chain and Base Sepolia as the
            destination chain.

            This forwarding flow signs the burn intent on Arc Testnet, submits it with
            forwarding enabled, and then lets Circle complete the destination mint on Base
            Sepolia.

            ```ts transfer-from-evm.ts theme={null}
            type EstimateResponse = {
              body: Array<{
                burnIntent: {
                  maxFee: string;
                  maxBlockHeight: string;
                };
              }>;
              fees: {
                forwardingFee?: string;
                token: string;
              };
            };

            type TransferResponse = {
              transferId?: string;
            };

            type TransferStatusResponse = {
              status: string;
              transactionHash?: string;
              forwardingDetails?: {
                failureReason?: string;
              };
            };

            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const POLL_INTERVAL_MS = 5_000;
            const POLL_TIMEOUT_MS = 300_000;

            // EIP-712 Domain and Types for Gateway burn intents
            const domain = { name: "GatewayWallet", version: "1" };

            const EIP712Domain = [
              { name: "name", type: "string" },
              { name: "version", type: "string" },
            ] as const;

            const TransferSpec = [
              { name: "version", type: "uint32" },
              { name: "sourceDomain", type: "uint32" },
              { name: "destinationDomain", type: "uint32" },
              { name: "sourceContract", type: "bytes32" },
              { name: "destinationContract", type: "bytes32" },
              { name: "sourceToken", type: "bytes32" },
              { name: "destinationToken", type: "bytes32" },
              { name: "sourceDepositor", type: "bytes32" },
              { name: "destinationRecipient", type: "bytes32" },
              { name: "sourceSigner", type: "bytes32" },
              { name: "destinationCaller", type: "bytes32" },
              { name: "value", type: "uint256" },
              { name: "salt", type: "bytes32" },
              { name: "hookData", type: "bytes" },
            ] as const;

            const BurnIntent = [
              { name: "maxBlockHeight", type: "uint256" },
              { name: "maxFee", type: "uint256" },
              { name: "spec", type: "TransferSpec" },
            ] as const;
            ```

            ### 4.3. Define the burn-intent shape

            The forwarding flow first estimates the burn-intent limits for the Arc Testnet
            to Base Sepolia route, then uses the same burn-intent shape for signing and
            submission.

            ```ts transfer-from-evm.ts theme={null}
            type TransferSpecBytes32 = {
              version: number;
              sourceDomain: number;
              destinationDomain: number;
              sourceContract: `0x${string}`;
              destinationContract: `0x${string}`;
              sourceToken: `0x${string}`;
              destinationToken: `0x${string}`;
              sourceDepositor: `0x${string}`;
              destinationRecipient: `0x${string}`;
              sourceSigner: `0x${string}`;
              destinationCaller: `0x${string}`;
              value: bigint;
              salt: `0x${string}`;
              hookData: `0x${string}`;
            };
            ```

            Use a small helper layer to build the bytes32 transfer spec and normalize EVM
            addresses for the Gateway payload.

            ```ts transfer-from-evm.ts theme={null}
            const SOURCE_CHAIN = "arc" as const;
            const DEST_CHAIN = "base" as const;

            function createTransferSpec(): TransferSpecBytes32 {
              const sourceConfig = chainConfig[SOURCE_CHAIN];
              const destConfig = chainConfig[DEST_CHAIN];

              return {
                version: 1,
                sourceDomain: sourceConfig.domainId,
                destinationDomain: destConfig.domainId,
                sourceContract: addressToBytes32(GATEWAY_WALLET_ADDRESS),
                destinationContract: addressToBytes32(GATEWAY_MINTER_ADDRESS),
                sourceToken: addressToBytes32(sourceConfig.usdcAddress),
                destinationToken: addressToBytes32(destConfig.usdcAddress),
                sourceDepositor: addressToBytes32(account.address),
                destinationRecipient: addressToBytes32(account.address),
                sourceSigner: addressToBytes32(account.address),
                destinationCaller: addressToBytes32(zeroAddress),
                value: TRANSFER_VALUE,
                salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
                hookData: "0x",
              };
            }

            function addressToBytes32(address: string): Hex {
              return pad(address.toLowerCase() as Hex, { size: 32 });
            }
            ```

            ### 4.4. Estimate forwarding fees

            Before you submit the forwarding transfer, call the Gateway estimate endpoint
            with `enableForwarder=true`. The response returns the `maxFee` and
            `maxBlockHeight` values you must include in the signed burn intent.

            ```ts transfer-from-evm.ts theme={null}
            const estimateResponse = await fetch(
              `${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
              {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyBigInts([{ spec }]),
              },
            );
            ```

            ### 4.5. Sign and submit the forwarding burn intent

            Sign the burn intent with the Arc Testnet self-managed wallet, then submit it to
            the Gateway transfer endpoint with `enableForwarder=true`.

            ```ts transfer-from-evm.ts theme={null}
            const typedData = {
              types: { EIP712Domain, TransferSpec, BurnIntent },
              domain,
              primaryType: "BurnIntent" as const,
              message: { maxBlockHeight, maxFee, spec },
            };

            const signature = await account.signTypedData(typedData);

            const response = await fetch(
              `${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
              {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyBigInts([{ burnIntent: typedData.message, signature }]),
              },
            );
            ```

            ### 4.6. Poll for forwarding completion

            The Forwarding Service completes the Base Sepolia mint for you. Poll the
            transfer status endpoint until the transfer is finalized or fails.

            ```ts transfer-from-evm.ts theme={null}
            const pollRes = await fetch(`${GATEWAY_API_BASE}/v1/transfer/${transferId}`);
            ```

            ### 4.7. Full EVM forwarding script (Self-managed)

            The script estimates forwarding fees, signs the burn intent on Arc Testnet,
            submits it to the Forwarding Service, and polls until Circle completes the Base
            Sepolia mint. Inline comments explain each stage.

            ```ts transfer-from-evm.ts expandable theme={null}
            import { pad, zeroAddress, formatUnits, type Hex } from "viem";
            import { randomBytes } from "node:crypto";
            import {
              account,
              chainConfig,
              GATEWAY_API_BASE,
              GATEWAY_WALLET_ADDRESS,
              GATEWAY_MINTER_ADDRESS,
              stringifyBigInts,
            } from "./config.js";

            type EstimateResponse = {
              body: Array<{
                burnIntent: {
                  maxFee: string;
                  maxBlockHeight: string;
                };
              }>;
              fees: {
                forwardingFee?: string;
                token: string;
              };
            };

            type TransferResponse = {
              transferId?: string;
            };

            type TransferStatusResponse = {
              status: string;
              transactionHash?: string;
              forwardingDetails?: {
                failureReason?: string;
              };
            };

            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const POLL_INTERVAL_MS = 5_000;
            const POLL_TIMEOUT_MS = 300_000;
            const SOURCE_CHAIN = "arc" as const;
            const DEST_CHAIN = "base" as const;

            // EIP-712 Domain and Types for Gateway burn intents
            const domain = { name: "GatewayWallet", version: "1" };

            const EIP712Domain = [
              { name: "name", type: "string" },
              { name: "version", type: "string" },
            ] as const;

            const TransferSpec = [
              { name: "version", type: "uint32" },
              { name: "sourceDomain", type: "uint32" },
              { name: "destinationDomain", type: "uint32" },
              { name: "sourceContract", type: "bytes32" },
              { name: "destinationContract", type: "bytes32" },
              { name: "sourceToken", type: "bytes32" },
              { name: "destinationToken", type: "bytes32" },
              { name: "sourceDepositor", type: "bytes32" },
              { name: "destinationRecipient", type: "bytes32" },
              { name: "sourceSigner", type: "bytes32" },
              { name: "destinationCaller", type: "bytes32" },
              { name: "value", type: "uint256" },
              { name: "salt", type: "bytes32" },
              { name: "hookData", type: "bytes" },
            ] as const;

            const BurnIntent = [
              { name: "maxBlockHeight", type: "uint256" },
              { name: "maxFee", type: "uint256" },
              { name: "spec", type: "TransferSpec" },
            ] as const;

            async function main() {
              const sourceConfig = chainConfig[SOURCE_CHAIN];
              const destConfig = chainConfig[DEST_CHAIN];

              console.log(`Using account: ${account.address}`);
              console.log(
                `Forwarding from: ${sourceConfig.chain.name} -> ${destConfig.chain.name}`,
              );

              const spec = {
                version: 1,
                sourceDomain: sourceConfig.domainId,
                destinationDomain: destConfig.domainId,
                sourceContract: pad(GATEWAY_WALLET_ADDRESS.toLowerCase() as Hex, {
                  size: 32,
                }),
                destinationContract: pad(GATEWAY_MINTER_ADDRESS.toLowerCase() as Hex, {
                  size: 32,
                }),
                sourceToken: pad(sourceConfig.usdcAddress.toLowerCase() as Hex, {
                  size: 32,
                }),
                destinationToken: pad(destConfig.usdcAddress.toLowerCase() as Hex, {
                  size: 32,
                }),
                sourceDepositor: pad(account.address.toLowerCase() as Hex, { size: 32 }),
                destinationRecipient: pad(account.address.toLowerCase() as Hex, {
                  size: 32,
                }),
                sourceSigner: pad(account.address.toLowerCase() as Hex, { size: 32 }),
                destinationCaller: pad(zeroAddress as Hex, { size: 32 }),
                value: TRANSFER_VALUE,
                salt: ("0x" + randomBytes(32).toString("hex")) as Hex,
                hookData: "0x" as Hex,
              };

              // [1] Estimate forwarding fee inputs for the burn intent.
              console.log("\n[1/3] Estimating forwarding fees...");

              const estimateResponse = await fetch(
                `${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
                {
                  method: "POST",
                  headers: { "Content-Type": "application/json" },
                  body: stringifyBigInts([{ spec }]),
                },
              );

              if (!estimateResponse.ok) {
                const text = await estimateResponse.text();
                throw new Error(`Estimate API error: ${estimateResponse.status} ${text}`);
              }

              const estimateResult = (await estimateResponse.json()) as EstimateResponse;
              const estimated = estimateResult.body[0]?.burnIntent;
              if (!estimated) throw new Error("Missing burnIntent estimate");

              const maxFee = BigInt(estimated.maxFee);
              const maxBlockHeight = BigInt(estimated.maxBlockHeight);
              const { fees } = estimateResult;

              if (fees.forwardingFee) {
                console.log(
                  `Forwarding fee: ${Number(fees.forwardingFee) / 1_000_000} ${fees.token}`,
                );
              }
              console.log(`Estimated maxFee: ${formatUnits(maxFee, 6)} ${fees.token}`);

              // [2] Sign and submit the forwarding burn intent.
              console.log("\n[2/3] Signing and submitting burn intent...");

              const typedData = {
                types: { EIP712Domain, TransferSpec, BurnIntent },
                domain,
                primaryType: "BurnIntent" as const,
                message: { maxBlockHeight, maxFee, spec },
              };

              const signature = await account.signTypedData(typedData);

              const response = await fetch(
                `${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
                {
                  method: "POST",
                  headers: { "Content-Type": "application/json" },
                  body: stringifyBigInts([{ burnIntent: typedData.message, signature }]),
                },
              );

              if (!response.ok) {
                const text = await response.text();
                throw new Error(`Gateway API error: ${response.status} ${text}`);
              }

              const json = (await response.json()) as TransferResponse;
              if (!json.transferId) throw new Error("Missing transferId in response");
              const transferId = json.transferId;
              console.log(`Transfer ID: ${transferId}`);

              // [3] Poll until the Forwarding Service completes the destination mint.
              console.log("\n[3/3] Polling for transfer completion...");

              const pollStart = Date.now();
              while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
                const pollRes = await fetch(
                  `${GATEWAY_API_BASE}/v1/transfer/${transferId}`,
                );

                if (!pollRes.ok) {
                  console.error(`Poll error: ${pollRes.status}`);
                  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
                  continue;
                }

                const details = (await pollRes.json()) as TransferStatusResponse;
                console.log(`Status: ${details.status}`);

                if (details.status === "finalized" || details.status === "confirmed") {
                  if (details.transactionHash) {
                    console.log(`Mint transaction hash: ${details.transactionHash}`);
                  }
                  break;
                }

                if (details.status === "failed") {
                  const reason = details.forwardingDetails?.failureReason ?? "unknown";
                  throw new Error(`Transfer failed: ${reason}`);
                }

                if (details.status === "expired") {
                  throw new Error("Transfer attestation expired before forwarding");
                }

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

              if (Date.now() - pollStart >= POLL_TIMEOUT_MS) {
                throw new Error("Polling timed out waiting for transfer completion");
              }

              console.log(
                `Transfer complete. ${formatUnits(TRANSFER_VALUE, 6)} USDC forwarded to ${destConfig.chain.name}.`,
              );
            }

            main().catch((error) => {
              console.error("\nError:", error);
              process.exit(1);
            });
            ```

            ### 4.8. Run the EVM forwarding script

            Run the script to burn from your Arc Testnet Gateway balance and let the
            Forwarding Service mint on Base Sepolia.

            Confirm these values before running:

            * `EVM_PRIVATE_KEY` controls the source Arc Testnet account
            * the source account has a Gateway balance on Arc Testnet
            * no destination gas is required for this forwarding path

            <Note>
              Gateway fees are charged per burn intent and are based on the source
              blockchain you burn from. Choosing where to hold and burn Gateway balances can
              affect transfer costs. For fee details, see [Gateway
              Fees](/gateway/references/fees).
            </Note>

            ```shell theme={null}
            npm run transfer-from-evm
            ```

            When the transfer succeeds, the script logs the transfer ID, status, and
            destination mint transaction hash.
          </Tab>
        </Tabs>
      </Tab>

      <Tab title="Transfer from Solana">
        ## Step 4. Transfer USDC from Solana to EVM

        This step transfers USDC from your Solana Devnet Gateway balance to Arc Testnet.
        Both paths create and sign the Solana burn intent first. Direct Mint then
        requests the Gateway attestation and calls `gatewayMint(...)` on Arc Testnet
        from your wallet, while the Forwarding Service lets Circle complete the
        destination mint for you.

        <Tabs>
          <Tab title="Direct Mint">
            ### 4.1. Create the script file

            You can skip to the
            [full transfer script](#4-7-full-solana-transfer-script-self-managed) if you
            prefer.

            ```shell theme={null}
            touch transfer-from-sol.ts
            ```

            ### 4.2. Define constants and types

            This script uses Arc Testnet as the destination chain.

            This direct-mint flow signs the burn intent on Solana Devnet, requests the
            Gateway attestation, and then calls `gatewayMint(...)` on Arc Testnet from your
            wallet.

            ```ts transfer-from-sol.ts theme={null}
            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const MAX_FEE = 2_010000n;
            const MAX_UINT64 = 2n ** 64n - 1n;

            const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
            const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
            const SOLANA_DOMAIN = 5;

            const TRANSFER_SPEC_MAGIC = 0xca85def7;
            const BURN_INTENT_MAGIC = 0x070afbc2;

            // Custom layout for Solana PublicKey values in the burn intent payload.
            class PublicKeyLayout extends Layout<PublicKey> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0): PublicKey {
                return new PublicKey(b.subarray(offset, offset + 32));
              }
              encode(src: PublicKey, b: Buffer, offset = 0): number {
                const pubkeyBuffer = src.toBuffer();
                pubkeyBuffer.copy(b, offset);
                return 32;
              }
            }

            const publicKey = (property: string) => new PublicKeyLayout(property);

            // Custom layout for 256-bit unsigned integers.
            class UInt256BE extends Layout<bigint> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0) {
                const buffer = b.subarray(offset, offset + 32);
                return buffer.readBigUInt64BE(24);
              }
              encode(src: bigint, b: Buffer, offset = 0) {
                const buffer = Buffer.alloc(32);
                buffer.writeBigUInt64BE(BigInt(src), 24);
                buffer.copy(b, offset);
                return 32;
              }
            }

            const uint256be = (property: string) => new UInt256BE(property);

            const BurnIntentLayout = struct([
              u32be("magic"),
              uint256be("maxBlockHeight"),
              uint256be("maxFee"),
              u32be("transferSpecLength"),
              struct(
                [
                  u32be("magic"),
                  u32be("version"),
                  u32be("sourceDomain"),
                  u32be("destinationDomain"),
                  publicKey("sourceContract"),
                  publicKey("destinationContract"),
                  publicKey("sourceToken"),
                  publicKey("destinationToken"),
                  publicKey("sourceDepositor"),
                  publicKey("destinationRecipient"),
                  publicKey("sourceSigner"),
                  publicKey("destinationCaller"),
                  uint256be("value"),
                  blob(32, "salt"),
                  u32be("hookDataLength"),
                  blob(offset(u32be(), -4), "hookData"),
                ] as any,
                "spec",
              ),
            ] as any);

            const gatewayMinterAbi = [
              {
                type: "function",
                name: "gatewayMint",
                inputs: [
                  { name: "attestationPayload", type: "bytes" },
                  { name: "signature", type: "bytes" },
                ],
                outputs: [],
                stateMutability: "nonpayable",
              },
            ] as const;
            ```

            ### 4.3. Add helper functions

            The helper layer encodes the Solana burn intent into the binary layout expected
            by Gateway, then normalizes addresses and payloads for the API request.

            ```ts transfer-from-sol.ts theme={null}
            function encodeBurnIntent(bi: {
              maxBlockHeight: bigint;
              maxFee: bigint;
              spec: {
                version: number;
                sourceDomain: number;
                destinationDomain: number;
                sourceContract: string;
                destinationContract: string;
                sourceToken: string;
                destinationToken: string;
                sourceDepositor: string;
                destinationRecipient: string;
                sourceSigner: string;
                destinationCaller: string;
                value: bigint;
                salt: string;
                hookData: string;
              };
            }): Buffer {
              const hookData = Buffer.from((bi.spec.hookData || "0x").slice(2), "hex");
              const prepared = {
                magic: BURN_INTENT_MAGIC,
                maxBlockHeight: bi.maxBlockHeight,
                maxFee: bi.maxFee,
                transferSpecLength: 340 + hookData.length,
                spec: {
                  magic: TRANSFER_SPEC_MAGIC,
                  version: bi.spec.version,
                  sourceDomain: bi.spec.sourceDomain,
                  destinationDomain: bi.spec.destinationDomain,
                  sourceContract: hexToPublicKey(bi.spec.sourceContract),
                  destinationContract: hexToPublicKey(bi.spec.destinationContract),
                  sourceToken: hexToPublicKey(bi.spec.sourceToken),
                  destinationToken: hexToPublicKey(bi.spec.destinationToken),
                  sourceDepositor: hexToPublicKey(bi.spec.sourceDepositor),
                  destinationRecipient: hexToPublicKey(bi.spec.destinationRecipient),
                  sourceSigner: hexToPublicKey(bi.spec.sourceSigner),
                  destinationCaller: hexToPublicKey(bi.spec.destinationCaller),
                  value: bi.spec.value,
                  salt: Buffer.from(bi.spec.salt.slice(2), "hex"),
                  hookDataLength: hookData.length,
                  hookData,
                },
              };
              const buffer = Buffer.alloc(72 + 340 + hookData.length);
              const bytesWritten = BurnIntentLayout.encode(prepared, buffer);
              return buffer.subarray(0, bytesWritten);
            }

            function signBurnIntent(
              keypair: Keypair,
              payload: Parameters<typeof encodeBurnIntent>[0],
            ): string {
              const encoded = encodeBurnIntent(payload);
              const prefixed = Buffer.concat([
                Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
                encoded,
              ]);
              const privateKey = crypto.createPrivateKey({
                key: Buffer.concat([
                  Buffer.from("302e020100300506032b657004220420", "hex"),
                  Buffer.from(keypair.secretKey.slice(0, 32)),
                ]),
                format: "der",
                type: "pkcs8",
              });
              return `0x${crypto.sign(null, prefixed, privateKey).toString("hex")}`;
            }

            function solanaAddressToBytes32(address: string): string {
              const decoded = Buffer.from(bs58.decode(address));
              return `0x${decoded.toString("hex")}`;
            }

            function evmAddressToBytes32(address: string): string {
              return pad(address.toLowerCase() as Hex, { size: 32 });
            }

            function hexToPublicKey(hexString: string): PublicKey {
              const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
              return new PublicKey(Buffer.from(cleanHex, "hex"));
            }
            ```

            ### 4.4. Create and sign burn intent

            Build the Solana burn intent, prefix the encoded payload, and sign it with the
            source Solana self-managed wallet.

            For Solana-origin transfers, the burn intent uses Solana-specific binary
            encoding and must be signed with the Solana signing-domain prefix. See the
            [Solana technical guide](/gateway/references/solana#signing) for the signing
            rules and message format details.

            ```ts transfer-from-sol.ts theme={null}
            const destConfig = chainConfig[DEST_CHAIN];

            const burnIntent = {
              maxBlockHeight: MAX_UINT64,
              maxFee: MAX_FEE,
              spec: {
                version: 1,
                sourceDomain: SOLANA_DOMAIN,
                destinationDomain: destConfig.domainId,
                sourceContract: solanaAddressToBytes32(SOLANA_GATEWAY_WALLET),
                destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
                sourceToken: solanaAddressToBytes32(SOLANA_USDC),
                destinationToken: evmAddressToBytes32(destConfig.usdcAddress),
                sourceDepositor: solanaAddressToBytes32(solanaKeypair.publicKey.toBase58()),
                destinationRecipient: evmAddressToBytes32(account.address),
                sourceSigner: solanaAddressToBytes32(solanaKeypair.publicKey.toBase58()),
                destinationCaller: evmAddressToBytes32(zeroAddress),
                value: TRANSFER_VALUE,
                salt: `0x${randomBytes(32).toString("hex")}`,
                hookData: "0x",
              },
            };

            const signature = signBurnIntent(solanaKeypair, burnIntent);
            const request = [{ burnIntent, signature }];
            ```

            ### 4.5. Request attestation from Gateway API

            ```ts transfer-from-sol.ts theme={null}
            const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: stringifyBigInts(request),
            });
            ```

            ### 4.6. Mint on Arc Testnet

            Use the destination EVM self-managed wallet to call `gatewayMint(bytes,bytes)`
            on Arc Testnet, then wait for completion.

            ```ts transfer-from-sol.ts theme={null}
            const destConfig = chainConfig[DEST_CHAIN];

            const destClient = createPublicClient({
              chain: destConfig.chain,
              transport: http(),
            });

            const walletClient = createWalletClient({
              account,
              chain: destConfig.chain,
              transport: http(),
            });

            const destinationGatewayMinterContract = getContract({
              address: GATEWAY_MINTER_ADDRESS,
              abi: gatewayMinterAbi,
              client: { public: destClient, wallet: walletClient },
            });

            const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
              [attestation, operatorSig],
              { account },
            );
            ```

            ### 4.7. Full Solana transfer script (Self-managed)

            The complete transfer script signs a Solana burn intent, requests a Gateway
            attestation, and mints on Arc Testnet. Inline comments explain each stage.

            ```ts transfer-from-sol.ts expandable theme={null}
            import { randomBytes } from "node:crypto";
            import * as crypto from "crypto";
            import {
              createPublicClient,
              createWalletClient,
              getContract,
              http,
              pad,
              zeroAddress,
              formatUnits,
              type Hex,
            } from "viem";
            import { Keypair, PublicKey } from "@solana/web3.js";
            import { u32be, struct, blob, offset, Layout } from "@solana/buffer-layout";
            import bs58 from "bs58";
            import {
              account,
              chainConfig,
              GATEWAY_API_BASE,
              GATEWAY_MINTER_ADDRESS,
              stringifyBigInts,
            } from "./config.js";

            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const MAX_FEE = 2_010000n;
            const MAX_UINT64 = 2n ** 64n - 1n;

            const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
            const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
            const SOLANA_DOMAIN = 5;
            const DEST_CHAIN = "arc" as const;

            const TRANSFER_SPEC_MAGIC = 0xca85def7;
            const BURN_INTENT_MAGIC = 0x070afbc2;

            // Custom layout for Solana PublicKey values in the burn intent payload.
            class PublicKeyLayout extends Layout<PublicKey> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0): PublicKey {
                return new PublicKey(b.subarray(offset, offset + 32));
              }
              encode(src: PublicKey, b: Buffer, offset = 0): number {
                const pubkeyBuffer = src.toBuffer();
                pubkeyBuffer.copy(b, offset);
                return 32;
              }
            }

            const publicKey = (property: string) => new PublicKeyLayout(property);

            // Custom layout for 256-bit unsigned integers.
            class UInt256BE extends Layout<bigint> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0) {
                const buffer = b.subarray(offset, offset + 32);
                return buffer.readBigUInt64BE(24);
              }
              encode(src: bigint, b: Buffer, offset = 0) {
                const buffer = Buffer.alloc(32);
                buffer.writeBigUInt64BE(BigInt(src), 24);
                buffer.copy(b, offset);
                return 32;
              }
            }

            const uint256be = (property: string) => new UInt256BE(property);

            const BurnIntentLayout = struct([
              u32be("magic"),
              uint256be("maxBlockHeight"),
              uint256be("maxFee"),
              u32be("transferSpecLength"),
              struct(
                [
                  u32be("magic"),
                  u32be("version"),
                  u32be("sourceDomain"),
                  u32be("destinationDomain"),
                  publicKey("sourceContract"),
                  publicKey("destinationContract"),
                  publicKey("sourceToken"),
                  publicKey("destinationToken"),
                  publicKey("sourceDepositor"),
                  publicKey("destinationRecipient"),
                  publicKey("sourceSigner"),
                  publicKey("destinationCaller"),
                  uint256be("value"),
                  blob(32, "salt"),
                  u32be("hookDataLength"),
                  blob(offset(u32be(), -4), "hookData"),
                ] as any,
                "spec",
              ),
            ] as any);

            const gatewayMinterAbi = [
              {
                type: "function",
                name: "gatewayMint",
                inputs: [
                  { name: "attestationPayload", type: "bytes" },
                  { name: "signature", type: "bytes" },
                ],
                outputs: [],
                stateMutability: "nonpayable",
              },
            ] as const;

            function encodeBurnIntent(bi: {
              maxBlockHeight: bigint;
              maxFee: bigint;
              spec: {
                version: number;
                sourceDomain: number;
                destinationDomain: number;
                sourceContract: string;
                destinationContract: string;
                sourceToken: string;
                destinationToken: string;
                sourceDepositor: string;
                destinationRecipient: string;
                sourceSigner: string;
                destinationCaller: string;
                value: bigint;
                salt: string;
                hookData: string;
              };
            }): Buffer {
              const hookData = Buffer.from((bi.spec.hookData || "0x").slice(2), "hex");
              const prepared = {
                magic: BURN_INTENT_MAGIC,
                maxBlockHeight: bi.maxBlockHeight,
                maxFee: bi.maxFee,
                transferSpecLength: 340 + hookData.length,
                spec: {
                  magic: TRANSFER_SPEC_MAGIC,
                  version: bi.spec.version,
                  sourceDomain: bi.spec.sourceDomain,
                  destinationDomain: bi.spec.destinationDomain,
                  sourceContract: hexToPublicKey(bi.spec.sourceContract),
                  destinationContract: hexToPublicKey(bi.spec.destinationContract),
                  sourceToken: hexToPublicKey(bi.spec.sourceToken),
                  destinationToken: hexToPublicKey(bi.spec.destinationToken),
                  sourceDepositor: hexToPublicKey(bi.spec.sourceDepositor),
                  destinationRecipient: hexToPublicKey(bi.spec.destinationRecipient),
                  sourceSigner: hexToPublicKey(bi.spec.sourceSigner),
                  destinationCaller: hexToPublicKey(bi.spec.destinationCaller),
                  value: bi.spec.value,
                  salt: Buffer.from(bi.spec.salt.slice(2), "hex"),
                  hookDataLength: hookData.length,
                  hookData,
                },
              };
              const buffer = Buffer.alloc(72 + 340 + hookData.length);
              const bytesWritten = BurnIntentLayout.encode(prepared, buffer);
              return buffer.subarray(0, bytesWritten);
            }

            function signBurnIntent(
              keypair: Keypair,
              payload: Parameters<typeof encodeBurnIntent>[0],
            ): string {
              const encoded = encodeBurnIntent(payload);
              const prefixed = Buffer.concat([
                Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
                encoded,
              ]);
              const privateKey = crypto.createPrivateKey({
                key: Buffer.concat([
                  Buffer.from("302e020100300506032b657004220420", "hex"),
                  Buffer.from(keypair.secretKey.slice(0, 32)),
                ]),
                format: "der",
                type: "pkcs8",
              });
              return `0x${crypto.sign(null, prefixed, privateKey).toString("hex")}`;
            }

            function solanaAddressToBytes32(address: string): string {
              const decoded = Buffer.from(bs58.decode(address));
              return `0x${decoded.toString("hex")}`;
            }

            function evmAddressToBytes32(address: string): string {
              return pad(address.toLowerCase() as Hex, { size: 32 });
            }

            function hexToPublicKey(hexString: string): PublicKey {
              const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
              return new PublicKey(Buffer.from(cleanHex, "hex"));
            }

            async function main() {
              const destConfig = chainConfig[DEST_CHAIN];

              if (!process.env.SOLANA_PRIVATE_KEYPAIR) {
                throw new Error("SOLANA_PRIVATE_KEYPAIR not set");
              }

              const solanaKeypair = Keypair.fromSecretKey(
                Uint8Array.from(JSON.parse(process.env.SOLANA_PRIVATE_KEYPAIR)),
              );

              console.log(`Sender (Solana): ${solanaKeypair.publicKey.toBase58()}`);
              console.log(`Recipient (EVM): ${account.address}`);
              console.log(`Transferring from: Solana Devnet -> ${destConfig.chain.name}`);

              // [1] Create and sign the Solana burn intent.
              console.log(
                `Creating burn intent from Solana Devnet -> ${destConfig.chain.name}...`,
              );

              const burnIntent = {
                maxBlockHeight: MAX_UINT64,
                maxFee: MAX_FEE,
                spec: {
                  version: 1,
                  sourceDomain: SOLANA_DOMAIN,
                  destinationDomain: destConfig.domainId,
                  sourceContract: solanaAddressToBytes32(SOLANA_GATEWAY_WALLET),
                  destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
                  sourceToken: solanaAddressToBytes32(SOLANA_USDC),
                  destinationToken: evmAddressToBytes32(destConfig.usdcAddress),
                  sourceDepositor: solanaAddressToBytes32(
                    solanaKeypair.publicKey.toBase58(),
                  ),
                  destinationRecipient: evmAddressToBytes32(account.address),
                  sourceSigner: solanaAddressToBytes32(solanaKeypair.publicKey.toBase58()),
                  destinationCaller: evmAddressToBytes32(zeroAddress),
                  value: TRANSFER_VALUE,
                  salt: "0x" + randomBytes(32).toString("hex"),
                  hookData: "0x",
                },
              };

              const signature = signBurnIntent(solanaKeypair, burnIntent);
              const request = [{ burnIntent, signature }];
              console.log("Signed burn intent.");

              // [2] Request the attestation set from Gateway API.
              const response = await fetch(`${GATEWAY_API_BASE}/v1/transfer`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyBigInts(request),
              });

              if (!response.ok) {
                const text = await response.text();
                throw new Error(`Gateway API error: ${response.status} ${text}`);
              }

              const json = await response.json();
              console.log("Gateway API response:", JSON.stringify(json, null, 2));

              const attestation = json?.attestation;
              const operatorSig = json?.signature;

              if (!attestation || !operatorSig) {
                throw new Error("Missing attestation or signature in response");
              }

              // [3] Mint on Arc Testnet with the returned attestation set.
              console.log(`Minting funds on ${destConfig.chain.name}...`);

              const destClient = createPublicClient({
                chain: destConfig.chain,
                transport: http(),
              });

              const walletClient = createWalletClient({
                account,
                chain: destConfig.chain,
                transport: http(),
              });

              const destinationGatewayMinterContract = getContract({
                address: GATEWAY_MINTER_ADDRESS,
                abi: gatewayMinterAbi,
                client: { public: destClient, wallet: walletClient },
              });

              const mintTx = await destinationGatewayMinterContract.write.gatewayMint(
                [attestation, operatorSig],
                { account },
              );

              await destClient.waitForTransactionReceipt({ hash: mintTx });

              console.log(`Minted ${formatUnits(TRANSFER_VALUE, 6)} USDC`);
              console.log(`Mint transaction hash (${destConfig.chain.name}):`, mintTx);
            }

            main().catch((error) => {
              console.error("\nError:", error);
              process.exit(1);
            });
            ```

            ### 4.8. Run the Solana to EVM direct-mint script

            Run the script to burn from your Solana Devnet Gateway balance and call
            `gatewayMint(...)` on Arc Testnet.

            Confirm these values before running:

            * `SOLANA_PRIVATE_KEYPAIR` controls the source Solana Devnet wallet
            * `EVM_PRIVATE_KEY` controls the destination Arc Testnet wallet
            * the source Solana wallet has a Gateway balance on Solana Devnet
            * the destination Arc Testnet wallet can submit the mint transaction

            <Note>
              Gateway fees are charged per burn intent and are based on the source
              blockchain you burn from. Choosing where to hold and burn Gateway balances can
              affect transfer costs. For fee details, see [Gateway
              Fees](/gateway/references/fees).
            </Note>

            ```shell theme={null}
            npm run transfer-from-sol
            ```

            When the transfer succeeds, the script logs the minted amount and mint
            transaction hash.
          </Tab>

          <Tab title="Forwarding Service">
            ### 4.1. Create the script file

            You can skip to the
            [full transfer script](#4-7-full-solana-forwarding-script-self-managed) if you
            prefer.

            ```shell theme={null}
            touch transfer-from-sol.ts
            ```

            ### 4.2. Define constants and types

            This script uses Arc Testnet as the destination chain.

            This forwarding flow signs the burn intent on Solana Devnet, submits it with
            forwarding enabled, and then lets Circle complete the destination mint on Arc
            Testnet.

            ```ts transfer-from-sol.ts theme={null}
            type EstimateResponse = {
              body: Array<{
                burnIntent: {
                  maxFee: string;
                  maxBlockHeight: string;
                };
              }>;
              fees: {
                forwardingFee?: string;
                token: string;
              };
            };

            type TransferResponse = {
              transferId?: string;
            };

            type TransferStatusResponse = {
              status: string;
              transactionHash?: string;
              forwardingDetails?: {
                failureReason?: string;
              };
            };

            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const POLL_INTERVAL_MS = 5_000;
            const POLL_TIMEOUT_MS = 300_000;

            const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
            const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
            const SOLANA_DOMAIN = 5;

            const TRANSFER_SPEC_MAGIC = 0xca85def7;
            const BURN_INTENT_MAGIC = 0x070afbc2;

            const domain = { name: "GatewayWallet", version: "1" };

            const EIP712Domain = [
              { name: "name", type: "string" },
              { name: "version", type: "string" },
            ] as const;

            const TransferSpec = [
              { name: "version", type: "uint32" },
              { name: "sourceDomain", type: "uint32" },
              { name: "destinationDomain", type: "uint32" },
              { name: "sourceContract", type: "bytes32" },
              { name: "destinationContract", type: "bytes32" },
              { name: "sourceToken", type: "bytes32" },
              { name: "destinationToken", type: "bytes32" },
              { name: "sourceDepositor", type: "bytes32" },
              { name: "destinationRecipient", type: "bytes32" },
              { name: "sourceSigner", type: "bytes32" },
              { name: "destinationCaller", type: "bytes32" },
              { name: "value", type: "uint256" },
              { name: "salt", type: "bytes32" },
              { name: "hookData", type: "bytes" },
            ] as const;

            const BurnIntent = [
              { name: "maxBlockHeight", type: "uint256" },
              { name: "maxFee", type: "uint256" },
              { name: "spec", type: "TransferSpec" },
            ] as const;

            class PublicKeyLayout extends Layout<PublicKey> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0): PublicKey {
                return new PublicKey(b.subarray(offset, offset + 32));
              }
              encode(src: PublicKey, b: Buffer, offset = 0): number {
                const pubkeyBuffer = src.toBuffer();
                pubkeyBuffer.copy(b, offset);
                return 32;
              }
            }

            const publicKey = (property: string) => new PublicKeyLayout(property);

            class UInt256BE extends Layout<bigint> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0) {
                const buffer = b.subarray(offset, offset + 32);
                return buffer.readBigUInt64BE(24);
              }
              encode(src: bigint, b: Buffer, offset = 0) {
                const buffer = Buffer.alloc(32);
                buffer.writeBigUInt64BE(BigInt(src), 24);
                buffer.copy(b, offset);
                return 32;
              }
            }

            const uint256be = (property: string) => new UInt256BE(property);

            const BurnIntentLayout = struct([
              u32be("magic"),
              uint256be("maxBlockHeight"),
              uint256be("maxFee"),
              u32be("transferSpecLength"),
              struct(
                [
                  u32be("magic"),
                  u32be("version"),
                  u32be("sourceDomain"),
                  u32be("destinationDomain"),
                  publicKey("sourceContract"),
                  publicKey("destinationContract"),
                  publicKey("sourceToken"),
                  publicKey("destinationToken"),
                  publicKey("sourceDepositor"),
                  publicKey("destinationRecipient"),
                  publicKey("sourceSigner"),
                  publicKey("destinationCaller"),
                  uint256be("value"),
                  blob(32, "salt"),
                  u32be("hookDataLength"),
                  blob(offset(u32be(), -4), "hookData"),
                ] as any,
                "spec",
              ),
            ] as any);
            ```

            ### 4.3. Add helper functions

            The helper layer encodes the Solana burn intent into the binary layout expected
            by Gateway, then normalizes addresses and payloads for the API request.

            ```ts transfer-from-sol.ts theme={null}
            function encodeBurnIntent(bi: {
              maxBlockHeight: bigint;
              maxFee: bigint;
              spec: {
                version: number;
                sourceDomain: number;
                destinationDomain: number;
                sourceContract: string;
                destinationContract: string;
                sourceToken: string;
                destinationToken: string;
                sourceDepositor: string;
                destinationRecipient: string;
                sourceSigner: string;
                destinationCaller: string;
                value: bigint;
                salt: string;
                hookData: string;
              };
            }): Buffer {
              const hookData = Buffer.from((bi.spec.hookData || "0x").slice(2), "hex");
              const prepared = {
                magic: BURN_INTENT_MAGIC,
                maxBlockHeight: bi.maxBlockHeight,
                maxFee: bi.maxFee,
                transferSpecLength: 340 + hookData.length,
                spec: {
                  magic: TRANSFER_SPEC_MAGIC,
                  version: bi.spec.version,
                  sourceDomain: bi.spec.sourceDomain,
                  destinationDomain: bi.spec.destinationDomain,
                  sourceContract: hexToPublicKey(bi.spec.sourceContract),
                  destinationContract: hexToPublicKey(bi.spec.destinationContract),
                  sourceToken: hexToPublicKey(bi.spec.sourceToken),
                  destinationToken: hexToPublicKey(bi.spec.destinationToken),
                  sourceDepositor: hexToPublicKey(bi.spec.sourceDepositor),
                  destinationRecipient: hexToPublicKey(bi.spec.destinationRecipient),
                  sourceSigner: hexToPublicKey(bi.spec.sourceSigner),
                  destinationCaller: hexToPublicKey(bi.spec.destinationCaller),
                  value: bi.spec.value,
                  salt: Buffer.from(bi.spec.salt.slice(2), "hex"),
                  hookDataLength: hookData.length,
                  hookData,
                },
              };
              const buffer = Buffer.alloc(72 + 340 + hookData.length);
              const bytesWritten = BurnIntentLayout.encode(prepared, buffer);
              return buffer.subarray(0, bytesWritten);
            }

            function signBurnIntent(
              keypair: Keypair,
              payload: Parameters<typeof encodeBurnIntent>[0],
            ): string {
              const encoded = encodeBurnIntent(payload);
              const prefixed = Buffer.concat([
                Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
                encoded,
              ]);
              const privateKey = crypto.createPrivateKey({
                key: Buffer.concat([
                  Buffer.from("302e020100300506032b657004220420", "hex"),
                  Buffer.from(keypair.secretKey.slice(0, 32)),
                ]),
                format: "der",
                type: "pkcs8",
              });
              return `0x${crypto.sign(null, prefixed, privateKey).toString("hex")}`;
            }

            function solanaAddressToBytes32(address: string): string {
              const decoded = Buffer.from(bs58.decode(address));
              return `0x${decoded.toString("hex")}`;
            }

            function evmAddressToBytes32(address: string): string {
              return pad(address.toLowerCase() as Hex, { size: 32 });
            }

            function hexToPublicKey(hexString: string): PublicKey {
              const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
              return new PublicKey(Buffer.from(cleanHex, "hex"));
            }
            ```

            ### 4.4. Estimate forwarding fees

            Before you submit the forwarding transfer, call the Gateway estimate endpoint
            with `enableForwarder=true`. The response returns the `maxFee` and
            `maxBlockHeight` values you must include in the signed burn intent.

            ```ts transfer-from-sol.ts theme={null}
            const estimateResponse = await fetch(
              `${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
              {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyBigInts([{ spec }]),
              },
            );
            ```

            ### 4.5. Sign and submit the forwarding burn intent

            Create the Solana burn intent with the estimated values, prefix the encoded
            payload, and sign it with the source Solana self-managed wallet. Then submit it
            to the Gateway transfer endpoint with `enableForwarder=true`.

            For Solana-origin transfers, the burn intent uses Solana-specific binary
            encoding and must be signed with the Solana signing-domain prefix. See the
            [Solana technical guide](/gateway/references/solana#signing) for the signing
            rules and message format details.

            ```ts transfer-from-sol.ts theme={null}
            const burnIntent = { maxBlockHeight, maxFee, spec };
            const signature = signBurnIntent(solanaKeypair, burnIntent);

            const response = await fetch(
              `${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
              {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: stringifyBigInts([{ burnIntent, signature }]),
              },
            );
            ```

            ### 4.6. Poll for forwarding completion

            The Forwarding Service completes the Arc Testnet mint for you. Poll the transfer
            status endpoint until the transfer is finalized or fails.

            ```ts transfer-from-sol.ts theme={null}
            const pollRes = await fetch(`${GATEWAY_API_BASE}/v1/transfer/${transferId}`);
            ```

            ### 4.7. Full Solana forwarding script (Self-managed)

            The script estimates forwarding fees, signs the burn intent on Solana Devnet,
            submits it to the Forwarding Service, and polls until Circle completes the Arc
            Testnet mint. Inline comments explain each stage.

            ```ts transfer-from-sol.ts expandable theme={null}
            import { randomBytes } from "node:crypto";
            import * as crypto from "crypto";
            import { pad, zeroAddress, formatUnits, type Hex } from "viem";
            import { Keypair, PublicKey } from "@solana/web3.js";
            import { u32be, struct, blob, offset, Layout } from "@solana/buffer-layout";
            import bs58 from "bs58";
            import {
              account,
              chainConfig,
              GATEWAY_API_BASE,
              GATEWAY_MINTER_ADDRESS,
              stringifyBigInts,
            } from "./config.js";

            type EstimateResponse = {
              body: Array<{
                burnIntent: {
                  maxFee: string;
                  maxBlockHeight: string;
                };
              }>;
              fees: {
                forwardingFee?: string;
                token: string;
              };
            };

            type TransferResponse = {
              transferId?: string;
            };

            type TransferStatusResponse = {
              status: string;
              transactionHash?: string;
              forwardingDetails?: {
                failureReason?: string;
              };
            };

            const TRANSFER_VALUE = 1_000000n; // 1 USDC (6 decimals)
            const POLL_INTERVAL_MS = 5_000;
            const POLL_TIMEOUT_MS = 300_000;

            const SOLANA_GATEWAY_WALLET = "GATEwdfmYNELfp5wDmmR6noSr2vHnAfBPMm2PvCzX5vu";
            const SOLANA_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU";
            const SOLANA_DOMAIN = 5;
            const DEST_CHAIN = "arc" as const;

            const TRANSFER_SPEC_MAGIC = 0xca85def7;
            const BURN_INTENT_MAGIC = 0x070afbc2;

            class PublicKeyLayout extends Layout<PublicKey> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0): PublicKey {
                return new PublicKey(b.subarray(offset, offset + 32));
              }
              encode(src: PublicKey, b: Buffer, offset = 0): number {
                const pubkeyBuffer = src.toBuffer();
                pubkeyBuffer.copy(b, offset);
                return 32;
              }
            }

            const publicKey = (property: string) => new PublicKeyLayout(property);

            class UInt256BE extends Layout<bigint> {
              constructor(property: string) {
                super(32, property);
              }
              decode(b: Buffer, offset = 0) {
                const buffer = b.subarray(offset, offset + 32);
                return buffer.readBigUInt64BE(24);
              }
              encode(src: bigint, b: Buffer, offset = 0) {
                const buffer = Buffer.alloc(32);
                buffer.writeBigUInt64BE(BigInt(src), 24);
                buffer.copy(b, offset);
                return 32;
              }
            }

            const uint256be = (property: string) => new UInt256BE(property);

            const BurnIntentLayout = struct([
              u32be("magic"),
              uint256be("maxBlockHeight"),
              uint256be("maxFee"),
              u32be("transferSpecLength"),
              struct(
                [
                  u32be("magic"),
                  u32be("version"),
                  u32be("sourceDomain"),
                  u32be("destinationDomain"),
                  publicKey("sourceContract"),
                  publicKey("destinationContract"),
                  publicKey("sourceToken"),
                  publicKey("destinationToken"),
                  publicKey("sourceDepositor"),
                  publicKey("destinationRecipient"),
                  publicKey("sourceSigner"),
                  publicKey("destinationCaller"),
                  uint256be("value"),
                  blob(32, "salt"),
                  u32be("hookDataLength"),
                  blob(offset(u32be(), -4), "hookData"),
                ] as any,
                "spec",
              ),
            ] as any);

            const domain = { name: "GatewayWallet", version: "1" };

            const EIP712Domain = [
              { name: "name", type: "string" },
              { name: "version", type: "string" },
            ] as const;

            const TransferSpec = [
              { name: "version", type: "uint32" },
              { name: "sourceDomain", type: "uint32" },
              { name: "destinationDomain", type: "uint32" },
              { name: "sourceContract", type: "bytes32" },
              { name: "destinationContract", type: "bytes32" },
              { name: "sourceToken", type: "bytes32" },
              { name: "destinationToken", type: "bytes32" },
              { name: "sourceDepositor", type: "bytes32" },
              { name: "destinationRecipient", type: "bytes32" },
              { name: "sourceSigner", type: "bytes32" },
              { name: "destinationCaller", type: "bytes32" },
              { name: "value", type: "uint256" },
              { name: "salt", type: "bytes32" },
              { name: "hookData", type: "bytes" },
            ] as const;

            const BurnIntent = [
              { name: "maxBlockHeight", type: "uint256" },
              { name: "maxFee", type: "uint256" },
              { name: "spec", type: "TransferSpec" },
            ] as const;

            function encodeBurnIntent(bi: {
              maxBlockHeight: bigint;
              maxFee: bigint;
              spec: {
                version: number;
                sourceDomain: number;
                destinationDomain: number;
                sourceContract: string;
                destinationContract: string;
                sourceToken: string;
                destinationToken: string;
                sourceDepositor: string;
                destinationRecipient: string;
                sourceSigner: string;
                destinationCaller: string;
                value: bigint;
                salt: string;
                hookData: string;
              };
            }): Buffer {
              const hookData = Buffer.from((bi.spec.hookData || "0x").slice(2), "hex");
              const prepared = {
                magic: BURN_INTENT_MAGIC,
                maxBlockHeight: bi.maxBlockHeight,
                maxFee: bi.maxFee,
                transferSpecLength: 340 + hookData.length,
                spec: {
                  magic: TRANSFER_SPEC_MAGIC,
                  version: bi.spec.version,
                  sourceDomain: bi.spec.sourceDomain,
                  destinationDomain: bi.spec.destinationDomain,
                  sourceContract: hexToPublicKey(bi.spec.sourceContract),
                  destinationContract: hexToPublicKey(bi.spec.destinationContract),
                  sourceToken: hexToPublicKey(bi.spec.sourceToken),
                  destinationToken: hexToPublicKey(bi.spec.destinationToken),
                  sourceDepositor: hexToPublicKey(bi.spec.sourceDepositor),
                  destinationRecipient: hexToPublicKey(bi.spec.destinationRecipient),
                  sourceSigner: hexToPublicKey(bi.spec.sourceSigner),
                  destinationCaller: hexToPublicKey(bi.spec.destinationCaller),
                  value: bi.spec.value,
                  salt: Buffer.from(bi.spec.salt.slice(2), "hex"),
                  hookDataLength: hookData.length,
                  hookData,
                },
              };
              const buffer = Buffer.alloc(72 + 340 + hookData.length);
              const bytesWritten = BurnIntentLayout.encode(prepared, buffer);
              return buffer.subarray(0, bytesWritten);
            }

            function signBurnIntent(
              keypair: Keypair,
              payload: Parameters<typeof encodeBurnIntent>[0],
            ): string {
              const encoded = encodeBurnIntent(payload);
              const prefixed = Buffer.concat([
                Buffer.from([0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
                encoded,
              ]);
              const privateKey = crypto.createPrivateKey({
                key: Buffer.concat([
                  Buffer.from("302e020100300506032b657004220420", "hex"),
                  Buffer.from(keypair.secretKey.slice(0, 32)),
                ]),
                format: "der",
                type: "pkcs8",
              });
              return `0x${crypto.sign(null, prefixed, privateKey).toString("hex")}`;
            }

            function solanaAddressToBytes32(address: string): string {
              const decoded = Buffer.from(bs58.decode(address));
              return `0x${decoded.toString("hex")}`;
            }

            function evmAddressToBytes32(address: string): string {
              return pad(address.toLowerCase() as Hex, { size: 32 });
            }

            function hexToPublicKey(hexString: string): PublicKey {
              const cleanHex = hexString.startsWith("0x") ? hexString.slice(2) : hexString;
              return new PublicKey(Buffer.from(cleanHex, "hex"));
            }

            async function main() {
              const destConfig = chainConfig[DEST_CHAIN];

              if (!process.env.SOLANA_PRIVATE_KEYPAIR) {
                throw new Error("SOLANA_PRIVATE_KEYPAIR not set");
              }

              const solanaKeypair = Keypair.fromSecretKey(
                Uint8Array.from(JSON.parse(process.env.SOLANA_PRIVATE_KEYPAIR)),
              );

              console.log(`Sender (Solana): ${solanaKeypair.publicKey.toBase58()}`);
              console.log(`Recipient (EVM): ${account.address}`);
              console.log(`Forwarding from: Solana Devnet -> ${destConfig.chain.name}`);

              const spec = {
                version: 1,
                sourceDomain: SOLANA_DOMAIN,
                destinationDomain: destConfig.domainId,
                sourceContract: solanaAddressToBytes32(SOLANA_GATEWAY_WALLET),
                destinationContract: evmAddressToBytes32(GATEWAY_MINTER_ADDRESS),
                sourceToken: solanaAddressToBytes32(SOLANA_USDC),
                destinationToken: evmAddressToBytes32(destConfig.usdcAddress),
                sourceDepositor: solanaAddressToBytes32(solanaKeypair.publicKey.toBase58()),
                destinationRecipient: evmAddressToBytes32(account.address),
                sourceSigner: solanaAddressToBytes32(solanaKeypair.publicKey.toBase58()),
                destinationCaller: evmAddressToBytes32(zeroAddress),
                value: TRANSFER_VALUE,
                salt: `0x${randomBytes(32).toString("hex")}`,
                hookData: "0x",
              };

              // [1] Estimate forwarding fee inputs for the burn intent.
              console.log("\n[1/3] Estimating forwarding fees...");

              const estimateResponse = await fetch(
                `${GATEWAY_API_BASE}/v1/estimate?enableForwarder=true`,
                {
                  method: "POST",
                  headers: { "Content-Type": "application/json" },
                  body: stringifyBigInts([{ spec }]),
                },
              );

              if (!estimateResponse.ok) {
                const text = await estimateResponse.text();
                throw new Error(`Estimate API error: ${estimateResponse.status} ${text}`);
              }

              const estimateResult = (await estimateResponse.json()) as EstimateResponse;
              const estimated = estimateResult.body[0]?.burnIntent;
              if (!estimated) throw new Error("Missing burnIntent estimate");

              const maxFee = BigInt(estimated.maxFee);
              const maxBlockHeight = BigInt(estimated.maxBlockHeight);
              const { fees } = estimateResult;

              if (fees.forwardingFee) {
                console.log(`Forwarding fee: ${fees.forwardingFee} ${fees.token}`);
              }
              console.log(`Estimated maxFee: ${formatUnits(maxFee, 6)} ${fees.token}`);

              // [2] Create, sign, and submit the forwarding burn intent.
              console.log("\n[2/3] Signing and submitting burn intent...");

              const burnIntent = { maxBlockHeight, maxFee, spec };
              const signature = signBurnIntent(solanaKeypair, burnIntent);

              const response = await fetch(
                `${GATEWAY_API_BASE}/v1/transfer?enableForwarder=true`,
                {
                  method: "POST",
                  headers: { "Content-Type": "application/json" },
                  body: stringifyBigInts([{ burnIntent, signature }]),
                },
              );

              if (!response.ok) {
                const text = await response.text();
                throw new Error(`Gateway API error: ${response.status} ${text}`);
              }

              const json = (await response.json()) as TransferResponse;
              if (!json.transferId) throw new Error("Missing transferId in response");
              const transferId = json.transferId;
              console.log(`Transfer ID: ${transferId}`);

              // [3] Poll until the Forwarding Service completes the destination mint.
              console.log("\n[3/3] Polling for transfer completion...");

              const pollStart = Date.now();
              while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
                const pollRes = await fetch(
                  `${GATEWAY_API_BASE}/v1/transfer/${transferId}`,
                );

                if (!pollRes.ok) {
                  console.error(`Poll error: ${pollRes.status}`);
                  await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
                  continue;
                }

                const details = (await pollRes.json()) as TransferStatusResponse;
                console.log(`Status: ${details.status}`);

                if (details.status === "finalized" || details.status === "confirmed") {
                  if (details.transactionHash) {
                    console.log(`Mint transaction hash: ${details.transactionHash}`);
                  }
                  break;
                }

                if (details.status === "failed") {
                  const reason = details.forwardingDetails?.failureReason ?? "unknown";
                  throw new Error(`Transfer failed: ${reason}`);
                }

                if (details.status === "expired") {
                  throw new Error("Transfer attestation expired before forwarding");
                }

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

              if (Date.now() - pollStart >= POLL_TIMEOUT_MS) {
                throw new Error("Polling timed out waiting for transfer completion");
              }

              console.log(
                `Transfer complete. ${formatUnits(TRANSFER_VALUE, 6)} USDC forwarded to ${destConfig.chain.name}.`,
              );
            }

            main().catch((error) => {
              console.error("\nError:", error);
              process.exit(1);
            });
            ```

            ### 4.8. Run the Solana forwarding script

            Run the script to burn from your Solana Devnet Gateway balance and let the
            Forwarding Service mint on Arc Testnet.

            Confirm these values before running:

            * `SOLANA_PRIVATE_KEYPAIR` controls the source Solana Devnet wallet
            * `EVM_PRIVATE_KEY` controls the destination Arc Testnet wallet
            * the source Solana wallet has a Gateway balance on Solana Devnet
            * no destination gas is required for this forwarding path

            <Note>
              Gateway fees are charged per burn intent and are based on the source
              blockchain you burn from. Choosing where to hold and burn Gateway balances can
              affect transfer costs. For fee details, see [Gateway
              Fees](/gateway/references/fees).
            </Note>

            ```shell theme={null}
            npm run transfer-from-sol
            ```

            When the transfer succeeds, the script logs the transfer ID, status, and
            destination mint transaction hash.
          </Tab>
        </Tabs>
      </Tab>
    </Tabs>
  </Tab>
</Tabs>
