Skip to main content

What We’re Building

A React (Next.js) app that connects Dynamic’s MPC wallets to Circle Gateway infrastructure, allowing users to:
  • Deposit USDC into Circle Gateway across multiple domains
  • Transfer USDC cross-chain between Sepolia, Base Sepolia, and Arc Testnet
  • View USDC balances across all domains in a unified interface
  • Sign EIP-712 messages for secure cross-chain attestations
If you want to take a quick look at the final code, check out the GitHub repository.

Key Components

  • Dynamic MPC Wallets - Embedded, non-custodial wallets with seamless auth
  • Circle Gateway - Cross-chain infrastructure for USDC transfers between domains
  • EIP-712 Signing - Secure typed data signing for attestation messages

Supported Networks

This guide demonstrates Circle Gateway integration with Circle Arc testnet, along with Sepolia (Domain 0) and Base Sepolia (Domain 6). Circle Arc is a new Layer-1 blockchain network designed as an Economic Operating System for the internet, featuring predictable dollar-based fees, sub-second finality, and EVM compatibility.

How It Works

Cross-Chain Transfer Flow

Circle Gateway uses a burn-and-mint mechanism for cross-chain USDC transfers:
  1. Deposit - Users deposit USDC into the Gateway Wallet contract on the source chain
  2. Burn Intent - When transferring, the app constructs a burn intent specifying the source domain, destination domain, amount, and recipient
  3. EIP-712 Signing - The burn intent is signed using EIP-712 typed data signing, which provides cryptographic proof of the user’s intent
  4. Attestation - The signed burn intent is submitted to Circle Gateway API, which validates the signature and returns an attestation
  5. Mint - The app switches to the destination chain and calls the Gateway Minter contract with the attestation to mint USDC

EIP-712 Typed Data

EIP-712 provides a standard way to sign structured data. The burn intent includes:
  • Transfer Specification - Source and destination domains, contracts, tokens, and amounts
  • Burn Intent - Maximum block height, maximum fee, and the transfer spec
  • Domain Separator - Ensures signatures are chain-specific and cannot be replayed
This secure signing mechanism ensures that cross-chain transfers are authorized by the wallet owner and cannot be tampered with.

Building the Application

Project Setup

Start by creating a new Dynamic project with React, Viem, Wagmi, and Ethereum support:
npx create-dynamic-app@latest gateway-dynamic-app --framework nextjs --library viem --wagmi true --chains ethereum --pm npm
cd gateway-dynamic-app

Configure Dynamic Environment

Create a .env.local file with your Dynamic environment ID:
.env.local
NEXT_PUBLIC_DYNAMIC_ENV_ID=your-environment-id-here
You can find your Environment ID in the Dynamic dashboard under Developer Settings → SDK & API Keys.

Add Chain Configuration

Create src/lib/chains.ts to define the Circle Gateway domain IDs and contract addresses. This file contains all the chain-specific configuration needed for the Gateway integration.
src/lib/chains.ts
export const DOMAIN = {
  sepolia: 0,
  baseSepolia: 6,
  arcTestnet: 26,
} as const;

export const CONTRACTS = {
  gatewayWallet: "0x0077777d7EBA4688BDeF3E311b846F25870A19B9",
  gatewayMinter: "0x0022222ABE238Cc2C7Bb1f21003F0a260052475B",
  usdc: {
    sepolia: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
    baseSepolia: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
    arcTestnet: "0x3600000000000000000000000000000000000000",
  },
} as const;
This defines the three testnet domains and their USDC contract addresses, plus the shared Gateway contracts.

Update Wagmi Configuration

Update src/lib/wagmi.ts to include all three chains. This configures wagmi with the RPC endpoints for each chain, enabling the app to interact with Sepolia, Base Sepolia, and Arc Testnet networks.
src/lib/wagmi.ts
import { createConfig, http } from "wagmi";
import { arcTestnet, baseSepolia, sepolia } from "wagmi/chains";

const chains = [sepolia, baseSepolia, arcTestnet] as const;

export const config = createConfig({
  chains,
  multiInjectedProviderDiscovery: false,
  ssr: true,
  transports: {
    [sepolia.id]: http(),
    [baseSepolia.id]: http("https://sepolia-preconf.base.org"),
    [arcTestnet.id]: http("https://rpc.testnet.arc.network"),
  },
});

declare module "wagmi" {
  interface Register {
    config: typeof config;
  }
}
This configures wagmi with all three chains and their RPC endpoints.

Create Gateway Utilities

Create src/lib/gateway.ts to handle Circle Gateway API calls and EIP-712 signing. This file contains all the API communication logic for interacting with the Circle Gateway service. It provides functions to build burn intents, create EIP-712 typed data structures for signing, and handle address conversions required for cross-chain transfers. The GatewayAPI object provides methods for making HTTP requests to the Circle Gateway API, while the burnIntent and burnIntentTypedData functions construct the structured data needed for EIP-712 message signing, which is required for secure cross-chain attestations.
src/lib/gateway.ts
import { pad, maxUint256, zeroAddress, bytesToHex } from "viem";

const baseUrl = "https://gateway-api-testnet.circle.com/v1";

export const GatewayAPI = {
  async get(path: string) {
    return fetch(baseUrl + path).then((r) => r.json());
  },
  async post(path: string, body: unknown) {
    return fetch(baseUrl + path, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body, (_k, v) =>
        typeof v === "bigint" ? v.toString() : v
      ),
    }).then((r) => r.json());
  },
};

const domain = { name: "GatewayWallet", version: "1" } as const;
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" },
];

const addressToBytes32 = (address: string) =>
  pad(address.toLowerCase() as `0x${string}`, { size: 32 });

export function burnIntent({
  account,
  from,
  to,
  amount,
  recipient,
}: {
  account: string;
  from: {
    domain: number;
    gatewayWallet: { address: `0x${string}` };
    usdc: { address: `0x${string}` };
  };
  to: {
    domain: number;
    gatewayMinter: { address: `0x${string}` };
    usdc: { address: `0x${string}` };
  };
  amount: number;
  recipient?: string;
}) {
  return {
    maxBlockHeight: maxUint256,
    maxFee: BigInt(2010000),
    spec: {
      version: 1,
      sourceDomain: from.domain,
      destinationDomain: to.domain,
      sourceContract: from.gatewayWallet.address,
      destinationContract: to.gatewayMinter.address,
      sourceToken: from.usdc.address,
      destinationToken: to.usdc.address,
      sourceDepositor: account,
      destinationRecipient: recipient || account,
      sourceSigner: account,
      destinationCaller: zeroAddress,
      value: BigInt(Math.floor(amount * 1e6)),
      salt: bytesToHex(crypto.getRandomValues(new Uint8Array(32))),
      hookData: "0x",
    },
  } as const;
}

export function burnIntentTypedData(intent: ReturnType<typeof burnIntent>) {
  const spec = intent.spec;
  return {
    types: { EIP712Domain, TransferSpec, BurnIntent },
    domain,
    primaryType: "BurnIntent" as const,
    message: {
      ...intent,
      spec: {
        ...spec,
        sourceContract: addressToBytes32(spec.sourceContract),
        destinationContract: addressToBytes32(spec.destinationContract),
        sourceToken: addressToBytes32(spec.sourceToken),
        destinationToken: addressToBytes32(spec.destinationToken),
        sourceDepositor: addressToBytes32(spec.sourceDepositor),
        destinationRecipient: addressToBytes32(spec.destinationRecipient),
        sourceSigner: addressToBytes32(spec.sourceSigner),
        destinationCaller: addressToBytes32(spec.destinationCaller || zeroAddress),
      },
    },
  } as const;
}
This handles Circle Gateway API calls and creates EIP-712 typed data for signing cross-chain transfers.

Build Main Gateway Component

Create src/components/GatewayApp.tsx for the main interface. This component serves as the central hub for the Gateway integration, displaying USDC balances across all three domains and providing access to deposit and transfer functionality. The component:
  • Fetches balances - Retrieves USDC balances from Circle Gateway API for all supported domains
  • Displays unified view - Shows balances across Sepolia, Base Sepolia, and Arc Testnet in a single interface
  • Manages state - Handles loading states and error handling for API calls
  • Integrates forms - Includes deposit and transfer forms for user interactions
src/components/GatewayApp.tsx
"use client";

import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import DynamicButton from "@/components/dynamic/dynamic-button";
import { useDynamicContext, useIsLoggedIn } from "@dynamic-labs/sdk-react-core";
import TransferForm from "@/components/TransferForm";
import DepositForm from "@/components/DepositForm";
import { DOMAIN } from "@/lib/chains";

type Balance = { domain: number; balance: string };

const GATEWAY_API_BASE_URL = "https://gateway-api-testnet.circle.com/v1";

async function gatewayPost(path: string, body: unknown) {
  const res = await fetch(GATEWAY_API_BASE_URL + path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body, (_k, v) =>
      typeof v === "bigint" ? v.toString() : v
    ),
  });
  return res.json();
}

async function gatewayBalances(
  token: string,
  depositor: string,
  domains?: number[]
) {
  if (!domains)
    domains = [DOMAIN.sepolia, DOMAIN.baseSepolia, DOMAIN.arcTestnet];
  return gatewayPost("/balances", {
    token,
    sources: domains.map((domain) => ({ depositor, domain })),
  });
}

export default function GatewayApp() {
  const isLoggedIn = useIsLoggedIn();
  const { primaryWallet, sdkHasLoaded } = useDynamicContext();
  const address = primaryWallet?.address;
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [balances, setBalances] = useState<Balance[]>([]);

  useEffect(() => {
    if (!sdkHasLoaded || !address) return;
    const run = async () => {
      setLoading(true);
      setError(null);
      try {
        const resp = await gatewayBalances("USDC", address, [
          DOMAIN.sepolia,
          DOMAIN.baseSepolia,
          DOMAIN.arcTestnet,
        ]);
        setBalances(resp.balances || []);
      } catch {
        setError("Failed to load balances");
      } finally {
        setLoading(false);
      }
    };
    run();
  }, [sdkHasLoaded, address]);

  return (
    <div className="w-full max-w-xl space-y-4">
      <Card>
        <CardHeader>
          <CardTitle>Circle Gateway (Testnet)</CardTitle>
        </CardHeader>
        <CardContent className="space-y-3">
          {!isLoggedIn ? (
            <DynamicButton />
          ) : (
            <>
              <div className="text-sm text-muted-foreground break-all">
                Address: {address}
              </div>
              <div className="space-y-2">
                <div className="font-medium">USDC Balances</div>
                <div className="text-sm text-muted-foreground">
                  Base Sepolia (domain 6), Sepolia (domain 0), Arc Testnet
                  (domain 26)
                </div>
                {loading ? (
                  <div>Loading balances...</div>
                ) : error ? (
                  <div className="text-red-500">{error}</div>
                ) : (
                  <div className="grid grid-cols-1 gap-2">
                    <div className="flex items-center justify-between rounded-md border p-3">
                      <span>Domain 0 (Sepolia)</span>
                      <span>
                        {balances.find((b) => b.domain === 0)?.balance ?? "0"}{" "}
                        USDC
                      </span>
                    </div>
                    <div className="flex items-center justify-between rounded-md border p-3">
                      <span>Domain 6 (Base Sepolia)</span>
                      <span>
                        {balances.find((b) => b.domain === 6)?.balance ?? "0"}{" "}
                        USDC
                      </span>
                    </div>
                    <div className="flex items-center justify-between rounded-md border p-3">
                      <span>Domain 26 (Arc Testnet)</span>
                      <span>
                        {balances.find((b) => b.domain === 26)?.balance ?? "0"}{" "}
                        USDC
                      </span>
                    </div>
                  </div>
                )}
              </div>

              <div className="pt-2 space-y-2">
                <DepositForm />
                <TransferForm />
              </div>
            </>
          )}
        </CardContent>
      </Card>
    </div>
  );
}
This component fetches and displays USDC balances across all three domains, plus the deposit and transfer forms.

Create Deposit Form

Create src/components/DepositForm.tsx for depositing USDC. This component handles the two-step deposit process required by Circle Gateway:
  1. Approve Gateway Wallet - Grants the Gateway Wallet contract permission to spend the user’s USDC
  2. Deposit to Gateway - Transfers USDC into the Gateway Wallet contract on the selected chain
The component automatically switches to the correct chain if needed and waits for transaction confirmations before proceeding to the next step.
src/components/DepositForm.tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
  useAccount,
  useChainId,
  usePublicClient,
  useSwitchChain,
  useWriteContract,
} from "wagmi";
import { CONTRACTS } from "@/lib/chains";
import { arcTestnet, baseSepolia, sepolia } from "wagmi/chains";
import { parseUnits } from "viem";

const erc20Abi = [
  {
    type: "function",
    name: "transfer",
    stateMutability: "nonpayable",
    inputs: [
      { name: "to", type: "address", internalType: "address" },
      { name: "amount", type: "uint256", internalType: "uint256" },
    ],
    outputs: [{ name: "", type: "bool", internalType: "bool" }],
  },
  {
    type: "function",
    name: "approve",
    stateMutability: "nonpayable",
    inputs: [
      { name: "spender", type: "address", internalType: "address" },
      { name: "amount", type: "uint256", internalType: "uint256" },
    ],
    outputs: [{ name: "", type: "bool", internalType: "bool" }],
  },
];

const gatewayWalletAbi = [
  {
    type: "function",
    name: "deposit",
    stateMutability: "nonpayable",
    inputs: [
      { name: "token", type: "address", internalType: "address" },
      { name: "amount", type: "uint256", internalType: "uint256" },
    ],
    outputs: [],
  },
];

type ChainKey = "sepolia" | "baseSepolia" | "arcTestnet";
type ChainId = typeof sepolia.id | typeof baseSepolia.id | typeof arcTestnet.id;

const CHAINS: { key: ChainKey; label: string; id: ChainId }[] = [
  { key: "sepolia", label: "Sepolia (domain 0)", id: sepolia.id },
  { key: "baseSepolia", label: "Base Sepolia (domain 6)", id: baseSepolia.id },
  { key: "arcTestnet", label: "Arc Testnet (domain 26)", id: arcTestnet.id },
];

export default function DepositForm() {
  const { address } = useAccount();
  const chainId = useChainId();
  const { switchChainAsync } = useSwitchChain();
  const { writeContractAsync } = useWriteContract();
  const publicClient = usePublicClient();
  const [amount, setAmount] = useState("1");
  const [source, setSource] = useState<ChainKey>("sepolia");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [txHash, setTxHash] = useState<string | null>(null);

  const onDeposit = async () => {
    if (!address) return;
    setLoading(true);
    setError(null);
    setTxHash(null);
    try {
      if (!amount || Number(amount) <= 0) {
        throw new Error("Enter a positive amount");
      }

      const selected = CHAINS.find((c) => c.key === source)!;
      if (chainId !== selected.id) {
        await switchChainAsync({ chainId: selected.id });
      }

      const tokenAddress = CONTRACTS.usdc[source] as `0x${string}`;
      const gatewayWallet = CONTRACTS.gatewayWallet as `0x${string}`;
      const value = parseUnits(amount || "0", 6);

      // 1) Approve GatewayWallet to spend USDC
      const approveHash = await writeContractAsync({
        address: tokenAddress,
        abi: erc20Abi,
        functionName: "approve",
        args: [gatewayWallet, value],
      });
      if (publicClient) {
        await publicClient.waitForTransactionReceipt({ hash: approveHash });
      }

      // 2) Call deposit on GatewayWallet
      const depositHash = await writeContractAsync({
        address: gatewayWallet,
        abi: gatewayWalletAbi,
        functionName: "deposit",
        args: [tokenAddress, value],
      });
      if (publicClient) {
        await publicClient.waitForTransactionReceipt({ hash: depositHash });
      }
      setTxHash(depositHash);
    } catch (e: unknown) {
      const msg = e instanceof Error ? e.message : "Failed to deposit";
      if (
        typeof msg === "string" &&
        msg.toLowerCase().includes("insufficient funds")
      ) {
        setError("Not enough native token to pay gas on the selected chain");
      } else {
        setError(msg);
      }
    } finally {
      setLoading(false);
    }
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>Deposit USDC to Gateway</CardTitle>
      </CardHeader>
      <CardContent className="space-y-3">
        <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
          <div className="sm:col-span-1">
            <label className="mb-1 block text-sm text-muted-foreground">
              Source chain
            </label>
            <select
              className="w-full rounded-md border bg-background px-3 py-2 text-sm"
              value={source}
              onChange={(e) => setSource(e.target.value as ChainKey)}
            >
              {CHAINS.map((c) => (
                <option key={c.key} value={c.key}>
                  {c.label}
                </option>
              ))}
            </select>
          </div>
          <div className="sm:col-span-1">
            <label className="mb-1 block text-sm text-muted-foreground">
              Amount (USDC)
            </label>
            <input
              type="number"
              min="0"
              step="0.01"
              value={amount}
              onChange={(e) => setAmount(e.target.value)}
              className="w-full rounded-md border bg-background px-3 py-2 text-sm"
              placeholder="Amount"
            />
          </div>
          <div className="sm:col-span-1 flex items-end">
            <Button
              onClick={onDeposit}
              disabled={loading || !address}
              className="w-full"
            >
              {loading ? "Depositing..." : "Deposit"}
            </Button>
          </div>
        </div>
        {error && <div className="text-sm text-red-500">{error}</div>}
        {txHash && (
          <div className="text-sm">
            Deposit tx: <span className="break-all">{txHash}</span>
          </div>
        )}
        <div className="text-xs text-muted-foreground">
          Approves and deposits USDC into the gateway wallet contract:{" "}
          {CONTRACTS.gatewayWallet}
        </div>
      </CardContent>
    </Card>
  );
}
This component handles the two-step deposit process required by Circle Gateway. When a user wants to deposit USDC, they first need to approve the Gateway Wallet contract to spend their tokens, then call the deposit function to transfer the USDC into the gateway. The component automatically waits for the approval transaction to be confirmed before proceeding with the deposit. Users can select which chain they want to deposit on (Sepolia, Base Sepolia, or Arc Testnet), and the component will switch to that network if needed. It validates that the amount is positive before starting. Once the deposit succeeds, the transaction hash is displayed so users can track it on the blockchain explorer. This makes the USDC available in the Gateway Wallet contract, ready for cross-chain transfers.

Create Transfer Form

src/components/TransferForm.tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useDynamicContext } from "@dynamic-labs/sdk-react-core";
import {
  useAccount,
  useChainId,
  useSwitchChain,
  useWriteContract,
  useWalletClient,
} from "wagmi";
import { CONTRACTS, DOMAIN } from "@/lib/chains";
import { arcTestnet, baseSepolia, sepolia } from "wagmi/chains";
import { GatewayAPI, burnIntent, burnIntentTypedData } from "@/lib/gateway";

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

export default function TransferForm() {
  const { address } = useAccount();
  const { primaryWallet } = useDynamicContext();
  const chainId = useChainId();
  const { switchChainAsync } = useSwitchChain();
  const { data: walletClient } = useWalletClient();
  const [amountEth, setAmountEth] = useState("1");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [txHash, setTxHash] = useState<string | null>(null);
  const { writeContractAsync } = useWriteContract();

  type ChainKey = "sepolia" | "baseSepolia" | "arcTestnet";
  type ChainId =
    | typeof sepolia.id
    | typeof baseSepolia.id
    | typeof arcTestnet.id;
  const CHAINS: {
    key: ChainKey;
    label: string;
    id: ChainId;
    domain: number;
  }[] = [
    {
      key: "sepolia",
      label: "Sepolia (domain 0)",
      id: sepolia.id,
      domain: DOMAIN.sepolia,
    },
    {
      key: "baseSepolia",
      label: "Base Sepolia (domain 6)",
      id: baseSepolia.id,
      domain: DOMAIN.baseSepolia,
    },
    {
      key: "arcTestnet",
      label: "Arc Testnet (domain 26)",
      id: arcTestnet.id,
      domain: DOMAIN.arcTestnet,
    },
  ];
  const [source, setSource] = useState<ChainKey>("sepolia");
  const [destination, setDestination] = useState<ChainKey>("baseSepolia");

  const onTransfer = async () => {
    if (!primaryWallet || !address) return;
    setLoading(true);
    setError(null);
    setTxHash(null);
    try {
      if (source === destination) {
        throw new Error("Source and destination must be different");
      }

      const src = CHAINS.find((c) => c.key === source)!;
      const dst = CHAINS.find((c) => c.key === destination)!;

      // Build burn intent for selected source → destination
      const intent = burnIntent({
        account: address,
        from: {
          domain: src.domain,
          gatewayWallet: { address: CONTRACTS.gatewayWallet as `0x${string}` },
          usdc: { address: CONTRACTS.usdc[source] as `0x${string}` },
        },
        to: {
          domain: dst.domain,
          gatewayMinter: { address: CONTRACTS.gatewayMinter as `0x${string}` },
          usdc: { address: CONTRACTS.usdc[destination] as `0x${string}` },
        },
        amount: parseFloat(amountEth),
        recipient: address,
      });

      const typedData = burnIntentTypedData(intent);
      if (!walletClient) throw new Error("No wallet client available");
      const signature = await walletClient.signTypedData({
        account: address as `0x${string}`,
        domain: typedData.domain as {
          name: string;
          version: string;
        },
        types: {
          TransferSpec: typedData.types.TransferSpec as Array<{
            name: string;
            type: string;
          }>,
          BurnIntent: typedData.types.BurnIntent as Array<{
            name: string;
            type: string;
          }>,
        },
        primaryType: typedData.primaryType as "BurnIntent",
        message: typedData.message as Record<string, unknown>,
      });

      const resp = await GatewayAPI.post("/transfer", [
        { burnIntent: typedData.message, signature },
      ]);
      if (resp.success === false)
        throw new Error(resp.message || "Gateway error");

      const { attestation, signature: attSig } = resp;

      if (chainId !== dst.id) {
        await switchChainAsync({ chainId: dst.id });
      }
      const hash = await writeContractAsync({
        address: CONTRACTS.gatewayMinter as `0x${string}`,
        abi: gatewayMinterAbi,
        functionName: "gatewayMint",
        args: [attestation, attSig],
      });
      setTxHash(hash);
    } catch (e: unknown) {
      const errMsg = e instanceof Error ? e.message : "Failed to transfer";
      setError(errMsg);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>Transfer USDC</CardTitle>
      </CardHeader>
      <CardContent className="space-y-3">
        <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
          <div className="sm:col-span-1">
            <label className="mb-1 block text-sm text-muted-foreground">
              Source chain
            </label>
            <select
              className="w-full rounded-md border bg-background px-3 py-2 text-sm"
              value={source}
              onChange={(e) => setSource(e.target.value as ChainKey)}
            >
              {CHAINS.map((c) => (
                <option key={c.key} value={c.key}>
                  {c.label}
                </option>
              ))}
            </select>
          </div>
          <div className="sm:col-span-1">
            <label className="mb-1 block text-sm text-muted-foreground">
              Destination chain
            </label>
            <select
              className="w-full rounded-md border bg-background px-3 py-2 text-sm"
              value={destination}
              onChange={(e) => setDestination(e.target.value as ChainKey)}
            >
              {CHAINS.map((c) => (
                <option key={c.key} value={c.key}>
                  {c.label}
                </option>
              ))}
            </select>
          </div>
          <div className="sm:col-span-1">
            <label className="mb-1 block text-sm text-muted-foreground">
              Amount (USDC)
            </label>
            <input
              type="number"
              min="0"
              step="0.01"
              value={amountEth}
              onChange={(e) => setAmountEth(e.target.value)}
              className="w-full rounded-md border bg-background px-3 py-2 text-sm"
              placeholder="Amount"
            />
          </div>
          <div className="sm:col-span-3 flex items-end">
            <Button
              onClick={onTransfer}
              disabled={loading || !address}
              className="w-full"
            >
              {loading ? "Transferring..." : "Transfer"}
            </Button>
          </div>
        </div>
        {error && <div className="text-sm text-red-500">{error}</div>}
        {txHash && (
          <div className="text-sm">
            Mint tx: <span className="break-all">{txHash}</span>
          </div>
        )}
      </CardContent>
    </Card>
  );
}
This component implements the complete cross-chain transfer flow using Circle Gateway’s burn-and-mint mechanism. It builds a burn intent specifying the source and destination domains, contract addresses, amount, and recipient. The intent is converted to EIP-712 typed data and signed by the wallet to authorize the transfer. The signed burn intent is sent to Circle Gateway’s API, which validates the signature and returns an attestation. The component then automatically switches to the destination chain and uses the attestation to mint USDC on the new chain.

Update Main Page

Update src/app/page.tsx to use the GatewayApp component. This is the entry point for the application that renders the main Gateway interface.
src/app/page.tsx
"use client";

import { PageLayout } from "@/components/ui/page-layout";
import GatewayApp from "@/components/GatewayApp";

export default function Main() {
  return (
    <PageLayout>
      <GatewayApp />
    </PageLayout>
  );
}

Enable Transaction Simulation

Dynamic’s transaction simulation shows detailed transaction previews before execution. To enable:
  1. Go to your Dynamic dashboard
  2. Navigate to Developer SettingsEmbedded WalletsDynamic
  3. Enable “Show Confirmation UI” and “Transaction Simulation” toggles
When enabled, users see a transaction preview when depositing or transferring USDC:

Configure CORS

Add http://localhost:3000 to CORS origins in your Dynamic dashboard under Developer Settings > CORS Origins.

Run the Application

Start the development server:
npm run dev
The application will be available at http://localhost:3000.

Conclusion

If you want to take a look at the full source code, check out the GitHub repository. This integration demonstrates how Dynamic’s MPC wallets connect to Circle Gateway for secure cross-chain USDC transfers using EIP-712 signing.

Additional Resources