Skip to main content
This guide shows how to transfer USDC from Arbitrum to HyperCore using the CctpExtension contract.
Note: Fast Transfers from Arbitrum to HyperEVM have no fees, however there is a small forwarding fee for transfers to HyperCore. Fast Transfer is the default for transfers from Arbitrum to HyperEVM.

Prerequisites

If you want to run the example code, you should have:
  • Installed Node.js and npm on your development machine
  • Created a testnet wallet on Arbitrum and have the private key available
  • Funded your testnet wallet with USDC
  • Created a new Node project and have the following dependencies installed:
    • ethers

Steps

Use the following steps to transfer USDC from Arbitrum to HyperCore.

Step 1. Get CCTP fees from the API

Query the CCTP API for the fees for transferring USDC from Arbitrum to HyperCore. This value is passed to the maxFee parameter in the batchDepositForBurnWithAuth transaction. The following is an example request to the CCTP using source domain 3 (Arbitrum) and destination domain 19 (HyperEVM):
curl --request GET \
  --url 'https://{endpoint}.circle.com/v2/burn/USDC/fees/3/19?forward=true' \
  --header 'Content-Type: application/json'
Response:
[
  {
    "finalityThreshold": 1000, // fast transfer
    "minimumFee": 1, // bps
    "forwardFee": {
      "low": 500000, // 0.50 USDC In USDC minor units
      "med": 500000, // Low, med, high will be the same static fee for now, this schema supports future dynamic fees
      "high": 500000
    }
  },
  {
    "finalityThreshold": 2000, // standard transfer
    "minimumFee": 0,
    "forwardFee": {
      "low": 500000, // In USDC minor units
      "med": 500000,
      "high": 500000
    }
  }
]

Step 2. Calculate the USDC amounts minus fees

There is no fee to deposit USDC from Arbitrum to HyperEVM, but there is a small forwarding fee for the transfer to HyperCore. The forwarding fee is 0.50 USC (0_500_000 subunits). For a 10 USDC transfer from Arbitrum to HyperCore, the total fee is 0.50 USDC.

Step 3. Sign a ReceiveWithAuthorization transaction on the USDC contract

Create a ReceiveWithAuthorization transaction for the USDC contract with the following parameters:
  • from: Your wallet address
  • to: The CctpExtension contract address
  • value: The amount of USDC to transfer
  • validAfter: The timestamp after which the transaction is valid
  • validBefore: The timestamp before which the transaction is valid
  • nonce: A random nonce
Sign the hash of the transaction with your private key, and derive the v, r, s values. Broadcast the transaction to the blockchain.

Step 4. Sign and broadcast a batchDepositForBurnWithAuth transaction on the CctpExtension contract

Create a batchDepositForBurnWithAuth transaction for the CctpExtension contract with the following parameters:
  • destinationDomain: 19 (HyperEVM)
  • mintRecipient: The CctpForwarder contract address on HyperEVM
  • destinationCaller: The CctpForwarder contract address on HyperEVM
  • maxFee: 0_500_000 (from step 2)
  • minFinalityThreshold: 1000 (Fast Transfer)
  • hookData: The hook data to call the CctpForwarder contract on HyperEVM
The hookData is the data to execute the forwarder to HyperCore. The following is an example of the hook data:
/* Forward Hook Data format:
 * Field                        Bytes      Type       Index
 * magicBytes                   24         bytes24    0     ASCII prefix "cctp-forward", followed by padding
 * version                      4          uint32     24
 * dataLength                   4          uint32     28
 * hyperCoreMintRecipient       20         address    32    EVM address - optional, included if requesting a deposit to HyperCore
 * hyperCoreDestinationDex      4          uint32     52    The destinationDexId on HyperCore (0 for perp and uint32.max for spot)
 */
function encodeForwardHookData(
  hyperCoreMintRecipient,
  hyperCoreDestinationDex = 0,
) {
  // Header: 24 (magic) + 4 (version) + 4 (dataLength) = 32
  const headerLen = 32;
  // Payload when HyperCore deposit: 20 (address) + 4 (destinationDex) = 24
  const payloadLen = hyperCoreMintRecipient ? 24 : 0;
  const totalLen = headerLen + payloadLen;

  const hookDataBuffer = Buffer.alloc(totalLen);

  // magic ("cctp-forward") + version(0) + dataLength(0) as base
  const baseHookData =
    "0x636374702d666f72776172640000000000000000000000000000000000000000";
  hookDataBuffer.write(baseHookData.slice(2), 0, "hex");

  if (hyperCoreMintRecipient) {
    // Write dataLength = 24 (20 bytes address + 4 bytes destinationDex)
    hookDataBuffer.writeUInt32BE(24, 28);

    // Write HyperCore mint recipient at offset 32
    hookDataBuffer.write(hyperCoreMintRecipient.slice(2), 32, 20, "hex");

    // Write hyperCoreDestinationDex (uint32, BE) at offset 52
    const dex = Number(hyperCoreDestinationDex) >>> 0; // clamp to uint32
    hookDataBuffer.writeUInt32BE(dex, 52);
  }

  return hookDataBuffer;
}
Once the deposit transaction is confirmed, the USDC is minted on HyperEVM and automatically forwarded to your address on HyperCore. By default (when hyperCoreDestinationDex is 0), deposits credit the perps balance on HyperCore. To deposit to the spot balance, set hyperCoreDestinationDex to 4294967295 (uint32 max value).

Full example code

The following is a complete example of how to transfer USDC from Arbitrum to HyperCore.
#!/usr/bin/env node

/**
 * Script: Call CctpExtension.batchDepositForBurnWithAuth
 * - Generates EIP-3009 receiveWithAuthorization signature
 * - Executes a single or batched CCTP burn via the extension
 * - Supports Forwarder hook data to auto-forward to a final recipient
 *
 * Requires: npm i ethers
 * Ethers v6
 */

const { ethers } = require("ethers");
const crypto = require("crypto");

// -------- Configuration (env or inline) --------
const CONFIG = {
  RPC_URL: process.env.RPC_URL || "https://sepolia-rollup.arbitrum.io/rpc",
  PRIVATE_KEY: process.env.PRIVATE_KEY || "PRIVATE_KEY_HERE",

  // Contracts
  CCTP_EXTENSION:
    process.env.CCTP_EXTENSION || "0x8E4e3d0E95C1bEC4F3eC7F69aa48473E0Ab6eB8D",
  USDC_TOKEN:
    process.env.USDC_TOKEN || "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d",

  // Amounts (USDC has 6 decimals)
  TOTAL_AMOUNT_USDC: process.env.TOTAL_AMOUNT_USDC || "10", // total to authorize and burn
  BATCH_SIZE_USDC: process.env.BATCH_SIZE_USDC || "10", // per burn (must divide TOTAL exactly)

  // CCTP params
  DEST_DOMAIN: Number(process.env.DEST_DOMAIN || 19), // destination CCTP domain id
  DEST_MINT_RECIPIENT: (
    process.env.DEST_MINT_RECIPIENT ||
    "0x02e39ECb8368b41bF68FF99ff351aC9864e5E2a2"
  ).toLowerCase(), // CCTP Forwader Contract address
  DEST_CALLER:
    process.env.DEST_CALLER || "0x02e39ECb8368b41bF68FF99ff351aC9864e5E2a2", // CCTP Forwader Contract address
  MAX_FEE_USDC: process.env.MAX_FEE_USDC || "0.5", // max fee (in USDC units)
  MIN_FINALITY_THRESHOLD: Number(process.env.MIN_FINALITY_THRESHOLD || 1000),

  // EIP-3009 validity window (unix seconds)
  VALID_AFTER: Number(
    process.env.VALID_AFTER || Math.floor(Date.now() / 1000) - 3600,
  ),
  VALID_BEFORE: Number(
    process.env.VALID_BEFORE || Math.floor(Date.now() / 1000) + 3600,
  ),

  // Optional hook data (set to "0x" for none). If omitted and FORWARD_RECIPIENT is set, we will build forwarder hook data to forwad automatically from ARB -> HyperEVM -> HyperCore.
  HOOK_DATA_HEX: process.env.HOOK_DATA_HEX || "0x",

  // Optional: final recipient to forward to via Forwarder hook (EVM address on destination chain)
  FORWARD_RECIPIENT:
    process.env.FORWARD_RECIPIENT ||
    "0x74328769aD582E2eEA366936f80bb101c7ee5A55",
};

// -------- ABIs (minimal) --------
const CCTP_EXTENSION_ABI = [
  {
    inputs: [
      {
        components: [
          { internalType: "uint256", name: "amount", type: "uint256" },
          { internalType: "uint256", name: "authValidAfter", type: "uint256" },
          { internalType: "uint256", name: "authValidBefore", type: "uint256" },
          { internalType: "bytes32", name: "authNonce", type: "bytes32" },
          { internalType: "uint8", name: "v", type: "uint8" },
          { internalType: "bytes32", name: "r", type: "bytes32" },
          { internalType: "bytes32", name: "s", type: "bytes32" },
        ],
        internalType: "struct ICctpExtension.ReceiveWithAuthorizationData",
        name: "_receiveWithAuthorizationData",
        type: "tuple",
      },
      {
        components: [
          { internalType: "uint256", name: "amount", type: "uint256" },
          { internalType: "uint32", name: "destinationDomain", type: "uint32" },
          { internalType: "bytes32", name: "mintRecipient", type: "bytes32" },
          {
            internalType: "bytes32",
            name: "destinationCaller",
            type: "bytes32",
          },
          { internalType: "uint256", name: "maxFee", type: "uint256" },
          {
            internalType: "uint32",
            name: "minFinalityThreshold",
            type: "uint32",
          },
          { internalType: "bytes", name: "hookData", type: "bytes" },
        ],
        internalType: "struct ICctpExtension.DepositForBurnWithHookData",
        name: "_depositForBurnData",
        type: "tuple",
      },
    ],
    name: "batchDepositForBurnWithAuth",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
];
const USDC_ABI = [
  {
    inputs: [],
    name: "DOMAIN_SEPARATOR",
    outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [{ internalType: "address", name: "account", type: "address" }],
    name: "balanceOf",
    outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [],
    name: "decimals",
    outputs: [{ internalType: "uint8", name: "", type: "uint8" }],
    stateMutability: "view",
    type: "function",
  },
];

// -------- EIP-3009 constants --------
const RECEIVE_WITH_AUTHORIZATION_TYPEHASH = ethers.keccak256(
  ethers.toUtf8Bytes(
    "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)",
  ),
);

// -------- Helpers --------
function toUSDC(amountStr) {
  return ethers.parseUnits(amountStr, 6);
}
function randomNonce32() {
  return ethers.hexlify(crypto.randomBytes(32));
}
function toBytes32Address(addr) {
  return ethers.zeroPadValue(ethers.getAddress(addr), 32);
}

// Build Forwarder hook data (CctpForwarderHookData spec):
// Bytes 0-23:  "cctp-forward" (right-padded with zeros)
// Bytes 24-27: uint32 version = 0 (big-endian)
// Bytes 28-31: uint32 length  = 24 (big-endian)
// Bytes 32-51: address forwardRecipient (20 bytes)
// Bytes 52-55: uint32 destinationId (big-endian)
function generateForwarderHookData(forwardRecipientAddress, destinationId = 0) {
  const magicText = "cctp-forward";
  const magicBytes = new Uint8Array(24);
  for (let i = 0; i < magicText.length; i++) {
    magicBytes[i] = magicText.charCodeAt(i);
  }
  const version = new Uint8Array([0, 0, 0, 0]);
  const length = new Uint8Array([0, 0, 0, 24]);
  const recipient = ethers.getBytes(ethers.getAddress(forwardRecipientAddress));
  const dest = new Uint8Array(4);
  // big-endian uint32
  dest[0] = (destinationId >>> 24) & 0xff;
  dest[1] = (destinationId >>> 16) & 0xff;
  dest[2] = (destinationId >>> 8) & 0xff;
  dest[3] = destinationId & 0xff;
  return ethers.concat([magicBytes, version, length, recipient, dest]);
}

async function getDomainSeparator(usdc) {
  try {
    return await usdc.DOMAIN_SEPARATOR();
  } catch {
    // Fallback build (commonly works for USDC v2-style)
    const domain = {
      name: "USD Coin",
      version: "2",
      chainId: await usdc.provider.getNetwork().then((n) => n.chainId),
      verifyingContract: await usdc.getAddress(),
    };
    return ethers._TypedDataEncoder.hashDomain(domain);
  }
}

async function signReceiveWithAuthorization({
  from,
  to,
  value,
  validAfter,
  validBefore,
  nonce,
  domainSeparator,
  privateKey,
}) {
  const structHash = ethers.keccak256(
    ethers.AbiCoder.defaultAbiCoder().encode(
      [
        "bytes32",
        "address",
        "address",
        "uint256",
        "uint256",
        "uint256",
        "bytes32",
      ],
      [
        RECEIVE_WITH_AUTHORIZATION_TYPEHASH,
        from,
        to,
        value,
        validAfter,
        validBefore,
        nonce,
      ],
    ),
  );
  const digest = ethers.keccak256(
    ethers.concat(["0x1901", domainSeparator, structHash]),
  );
  const signer = new ethers.SigningKey(privateKey);
  const sig = signer.sign(digest);
  return { v: sig.v, r: sig.r, s: sig.s };
}

// -------- Main --------
async function main() {
  const {
    RPC_URL,
    PRIVATE_KEY,
    CCTP_EXTENSION,
    USDC_TOKEN,
    TOTAL_AMOUNT_USDC,
    BATCH_SIZE_USDC,
    DEST_DOMAIN,
    DEST_MINT_RECIPIENT,
    DEST_CALLER,
    MAX_FEE_USDC,
    MIN_FINALITY_THRESHOLD,
    VALID_AFTER,
    VALID_BEFORE,
    HOOK_DATA_HEX,
    FORWARD_RECIPIENT,
  } = CONFIG;

  if (!ethers.isAddress(CCTP_EXTENSION))
    throw new Error("Invalid CCTP_EXTENSION");
  if (!ethers.isAddress(USDC_TOKEN)) throw new Error("Invalid USDC_TOKEN");
  if (!ethers.isAddress(DEST_MINT_RECIPIENT))
    throw new Error("Invalid DEST_MINT_RECIPIENT address");
  if (!PRIVATE_KEY || PRIVATE_KEY === "0xYourPrivateKey")
    throw new Error("Set PRIVATE_KEY");

  const provider = new ethers.JsonRpcProvider(RPC_URL);
  const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
  const from = wallet.address;

  const extension = new ethers.Contract(
    CCTP_EXTENSION,
    CCTP_EXTENSION_ABI,
    wallet,
  );
  const usdc = new ethers.Contract(USDC_TOKEN, USDC_ABI, provider);

  const totalAmount = toUSDC(TOTAL_AMOUNT_USDC);
  const batchSize = toUSDC(BATCH_SIZE_USDC);
  const maxFee = toUSDC(MAX_FEE_USDC);

  if (totalAmount === 0n) throw new Error("TOTAL_AMOUNT_USDC must be > 0");
  if (batchSize === 0n) throw new Error("BATCH_SIZE_USDC must be > 0");
  if (totalAmount % batchSize !== 0n)
    throw new Error("TOTAL_AMOUNT_USDC must be divisible by BATCH_SIZE_USDC");

  console.log("User:", from);
  console.log("Extension:", CCTP_EXTENSION);
  console.log("USDC:", USDC_TOKEN);
  console.log(
    "Total (USDC):",
    TOTAL_AMOUNT_USDC,
    "Batch Size (USDC):",
    BATCH_SIZE_USDC,
    "Batches:",
    totalAmount / batchSize,
  );
  console.log(
    "Dest Domain:",
    DEST_DOMAIN,
    "Mint Recipient:",
    DEST_MINT_RECIPIENT,
  );
  console.log(
    "Max Fee (USDC):",
    MAX_FEE_USDC,
    "Min Finality:",
    MIN_FINALITY_THRESHOLD,
  );

  // Compute hook data: prefer explicit HOOK_DATA_HEX; otherwise, if FORWARD_RECIPIENT is provided, build forwarder hook
  let hookData = HOOK_DATA_HEX;
  if (
    (!hookData || hookData === "0x") &&
    FORWARD_RECIPIENT &&
    FORWARD_RECIPIENT.length > 0
  ) {
    if (!ethers.isAddress(FORWARD_RECIPIENT))
      throw new Error("Invalid FORWARD_RECIPIENT");
    hookData = generateForwarderHookData(FORWARD_RECIPIENT);
    console.log(
      "Forwarder hook enabled -> Final recipient:",
      FORWARD_RECIPIENT,
    );
  }
  console.log("Hook Data:", hookData);

  // destinationCaller must be bytes32 per ICctpExtension.DepositForBurnWithHookData
  const destinationCallerBytes32 =
    DEST_CALLER && DEST_CALLER !== ethers.ZeroHash
      ? ethers.zeroPadValue(ethers.getAddress(DEST_CALLER), 32)
      : ethers.ZeroHash;
  console.log("Destination Caller (bytes32):", destinationCallerBytes32);

  // Check USDC balance
  const bal = await usdc.balanceOf(from);
  if (bal < totalAmount)
    throw new Error(
      `Insufficient USDC: have ${ethers.formatUnits(
        bal,
        6,
      )}, need ${TOTAL_AMOUNT_USDC}`,
    );

  const domainSeparator = await getDomainSeparator(usdc);
  const nonce = randomNonce32();

  // EIP-3009 sign
  const { v, r, s } = await signReceiveWithAuthorization({
    from,
    to: CCTP_EXTENSION,
    value: totalAmount,
    validAfter: VALID_AFTER,
    validBefore: VALID_BEFORE,
    nonce,
    domainSeparator,
    privateKey: PRIVATE_KEY,
  });

  // Structs
  const receiveWithAuth = {
    amount: totalAmount,
    authValidAfter: VALID_AFTER,
    authValidBefore: VALID_BEFORE,
    authNonce: nonce,
    v,
    r,
    s,
  };

  const burnData = {
    amount: batchSize,
    destinationDomain: DEST_DOMAIN,
    mintRecipient: toBytes32Address(DEST_MINT_RECIPIENT),
    destinationCaller: destinationCallerBytes32,
    maxFee: maxFee,
    minFinalityThreshold: MIN_FINALITY_THRESHOLD,
    hookData: hookData, // either explicit or forwarder hook
  };

  // Estimate and send
  const gas = await extension.batchDepositForBurnWithAuth.estimateGas(
    receiveWithAuth,
    burnData,
  );
  console.log("Estimated gas:", gas.toString());

  const tx = await extension.batchDepositForBurnWithAuth(
    receiveWithAuth,
    burnData,
    {
      gasLimit: (gas * 120n) / 100n, // +20%
    },
  );
  console.log("Tx hash:", tx.hash);

  const receipt = await tx.wait();
  console.log("Status:", receipt.status === 1 ? "SUCCESS" : "FAILED");
  console.log(
    "Block:",
    receipt.blockNumber,
    "Gas Used:",
    receipt.gasUsed.toString(),
  );
}

// Run
if (require.main === module) {
  main().catch((e) => {
    console.error("Error:", e.message);
    process.exit(1);
  });
}