Skip to main content
This guide shows how to withdraw USDC from a HyperCore spot or perp balance to an external EVM blockchain (such as Arbitrum, Ethereum, or Base) using the HyperCore API. Withdrawals from HyperCore to EVM chains default to the Fast Transfer method, due to the fast finality of HyperEVM. The withdrawal process:
  1. Debits your HyperCore balance (spot or perp)
  2. Routes through HyperEVM where USDC is burned via CCTP
  3. CCTP attests to the burn and mints on the destination chain
  4. If automatic forwarding is enabled, the recipient receives funds directly
Note: Withdrawals include a HyperCore fee and may include a CCTP forwarding fee. Ensure your withdrawal amount exceeds these fees.

Important considerations

Keep these things in mind when withdrawing USDC from HyperCore to EVM chains:
  • Data field: If the data field is empty, the CoreDepositWallet automatically sets a default hook that enables automatic message forwarding on the destination blockchain, provided that the blockchain supports CCTP forwarding. If the data field is not empty, its contents are passed to the CCTP protocol as the value of the hookData field.
  • Destination caller: The CCTP destinationCaller is always set to the zero address. Passing your own hook data means that anyone can receive the message on the destination blockchain.
  • Withdrawal fees: In addition to the maxFee charged by the HyperCore blockchain, an additional fixed forwarding fee may be charged by CCTP if automatic forwarding is enabled. The forwarding fee amount depends on the destination blockchain and can be viewed by querying the CoreDepositWallet smart contract. Initially, the fee for forwarding to Arbitrum is 0.2 USDC. If the withdrawal includes custom hook data, the forwarding fee is not set and users have to receive the message on the destination blockchain themselves.
  • Minimum withdrawal amount: If the withdrawal amount is less than the required forwarding fee, the transaction on HyperEVM reverts. Make sure the withdrawal amount is larger than the fees.

Prerequisites

Before you start, you should have:
  • Installed Node.js and npm on your development machine
  • A wallet with USDC balance on HyperCore (spot or perp)
  • Your wallet’s private key available
  • Created a new Node project and have the following dependencies installed:
    • ethers

Steps

Use the following steps to withdraw USDC from HyperCore to an EVM blockchain.

Step 1. Construct the sendToEvmWithData action

Create a sendToEvmWithData action object with the following parameters:
  • type: sendToEvmWithData
  • hyperliquidChain: Mainnet (or Testnet for testnet)
  • signatureChainId: The blockchain ID used for signing (for example, "0xa4b1" for Arbitrum)
  • token: USDC
  • amount: The amount of USDC in HyperCore minor units (8 decimal places)
  • sourceDex: spot to withdraw from spot balance, or leave blank for perp balance
  • destinationRecipient: The recipient address on the destination blockchain
  • addressEncoding: hex for EVM chains or base58 for Solana
  • destinationChainId: The CCTP destination domain ID (for example, 3 for Arbitrum, 0 for Ethereum, 6 for Base)
  • gasLimit: The HyperCore transaction gas limit
  • data: CCTP hook data (use "0x" for automatic forwarding)
  • nonce: Current timestamp in milliseconds
const action = {
  type: "sendToEvmWithData",
  hyperliquidChain: "Mainnet",
  signatureChainId: "0xa4b1",
  token: "USDC",
  amount: "1000000000", // 10 USDC (8 decimals)
  sourceDex: "spot", // or "" for `perp`
  destinationRecipient: "0x1234567890123456789012345678901234567890",
  addressEncoding: "hex",
  destinationChainId: 3, // Arbitrum
  gasLimit: 200000,
  data: "0x", // Enable automatic forwarding
  nonce: Date.now(),
};

Step 2. Sign the action using EIP-712

Sign the action using the EIP-712 typed data signing standard. The signature proves that you authorize this withdrawal. The signing domain should include:
  • name: "HyperliquidTransaction:SendToEvmWithData"
  • version: "1"
  • chainId: The chain ID from signatureChainId (as a number)
  • verifyingContract: "0x0000000000000000000000000000000000000000"
import { ethers } from "ethers";

async function signSendToEvmWithDataAction(
  action: any,
  privateKey: string,
): Promise<{ r: string; s: string; v: number }> {
  const wallet = new ethers.Wallet(privateKey);

  // Convert chainId from hex to number
  const chainId = parseInt(action.signatureChainId, 16);

  // EIP-712 domain
  const domain = {
    name: "HyperliquidTransaction:SendToEvmWithData",
    version: "1",
    chainId: chainId,
    verifyingContract: "0x0000000000000000000000000000000000000000",
  };

  // EIP-712 types
  const types = {
    SendToEvmWithData: [
      { name: "hyperliquidChain", type: "string" },
      { name: "token", type: "string" },
      { name: "amount", type: "string" },
      { name: "sourceDex", type: "string" },
      { name: "destinationRecipient", type: "string" },
      { name: "addressEncoding", type: "string" },
      { name: "destinationChainId", type: "uint32" },
      { name: "gasLimit", type: "uint64" },
      { name: "data", type: "bytes" },
      { name: "time", type: "uint64" },
    ],
  };

  // Message to sign
  const value = {
    hyperliquidChain: action.hyperliquidChain,
    token: action.token,
    amount: action.amount,
    sourceDex: action.sourceDex,
    destinationRecipient: action.destinationRecipient,
    addressEncoding: action.addressEncoding,
    destinationChainId: action.destinationChainId,
    gasLimit: action.gasLimit,
    data: action.data,
    time: action.nonce,
  };

  // Sign the typed data
  const signature = await wallet.signTypedData(domain, types, value);

  // Split signature into r, s, v components
  const sig = ethers.Signature.from(signature);

  return {
    r: sig.r,
    s: sig.s,
    v: sig.v,
  };
}

Step 3. Submit the signed action to the exchange API

Call the exchange endpoint with the action, nonce, and signature.
async function submitSendToEvmWithData(
  action: any,
  signature: { r: string; s: string; v: number },
) {
  const response = await fetch("https://api.hyperliquid.xyz/exchange", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      action: action,
      nonce: action.nonce,
      signature: signature,
    }),
  });

  const data = await response.json();

  if (data.status === "ok") {
    console.log("Withdrawal successful:", data);
    return data;
  } else {
    throw new Error(`Withdrawal failed: ${JSON.stringify(data)}`);
  }
}

Full example code

The following is a complete example of how to withdraw USDC from HyperCore to an external EVM blockchain.
#!/usr/bin/env node

/**
 * Script: Withdraw USDC from HyperCore to External EVM Chain
 * - Constructs a SendToEvmWithData action
 * - Signs the action using EIP-712
 * - Submits the signed action to the HyperCore API
 *
 * Requires: npm i ethers
 * Ethers v6
 */

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

// -------- Configuration --------
const CONFIG = {
  // Private key for signing
  PRIVATE_KEY: process.env.PRIVATE_KEY || "YOUR_PRIVATE_KEY_HERE",

  // HyperCore API endpoint
  API_ENDPOINT:
    process.env.API_ENDPOINT || "https://api.hyperliquid-testnet.xyz/exchange",

  // Chain configuration
  HYPERLIQUID_CHAIN: process.env.HYPERLIQUID_CHAIN || "Testnet", // or "Mainnet"
  SIGNATURE_CHAIN_ID: process.env.SIGNATURE_CHAIN_ID || "0xa4b1", // Arbitrum chain ID for signing

  // Withdrawal parameters
  AMOUNT_USDC: process.env.AMOUNT_USDC || "10", // Amount in USDC (HyperCore uses 8 decimals)
  SOURCE_DEX: process.env.SOURCE_DEX || "", // "" for perp, "spot" for spot
  DESTINATION_RECIPIENT:
    process.env.DESTINATION_RECIPIENT ||
    "0x1234567890123456789012345678901234567890", // Recipient address on destination chain
  ADDRESS_ENCODING: process.env.ADDRESS_ENCODING || "hex", // "hex" for EVM, "base58" for Solana
  DESTINATION_CHAIN_ID: Number(process.env.DESTINATION_CHAIN_ID || 3), // 3 = Arbitrum, 0 = Ethereum, 6 = Base
  GAS_LIMIT: Number(process.env.GAS_LIMIT || 200000),
  HOOK_DATA: process.env.HOOK_DATA || "0x", // "0x" for automatic forwarding
};

// -------- Helper Functions --------

/**
 * Convert USDC amount to HyperCore minor units (8 decimals)
 */
function toHyperCoreUnits(amountStr) {
  // HyperCore uses 8 decimals
  const amount = parseFloat(amountStr);
  return Math.floor(amount * 100000000).toString();
}

/**
 * Sign SendToEvmWithData action using EIP-712
 */
async function signSendToEvmWithDataAction(action, privateKey) {
  const wallet = new ethers.Wallet(privateKey);

  // Convert chainId from hex to number
  const chainId = parseInt(action.signatureChainId, 16);

  // EIP-712 domain
  const domain = {
    name: "HyperliquidTransaction:SendToEvmWithData",
    version: "1",
    chainId: chainId,
    verifyingContract: "0x0000000000000000000000000000000000000000",
  };

  // EIP-712 types
  const types = {
    SendToEvmWithData: [
      { name: "hyperliquidChain", type: "string" },
      { name: "token", type: "string" },
      { name: "amount", type: "string" },
      { name: "sourceDex", type: "string" },
      { name: "destinationRecipient", type: "string" },
      { name: "addressEncoding", type: "string" },
      { name: "destinationChainId", type: "uint32" },
      { name: "gasLimit", type: "uint64" },
      { name: "data", type: "bytes" },
      { name: "time", type: "uint64" },
    ],
  };

  // Message to sign
  const value = {
    hyperliquidChain: action.hyperliquidChain,
    token: action.token,
    amount: action.amount,
    sourceDex: action.sourceDex,
    destinationRecipient: action.destinationRecipient,
    addressEncoding: action.addressEncoding,
    destinationChainId: action.destinationChainId,
    gasLimit: action.gasLimit,
    data: action.data,
    time: action.nonce,
  };

  // Sign the typed data
  const signature = await wallet.signTypedData(domain, types, value);

  // Split signature into r, s, v components
  const sig = ethers.Signature.from(signature);

  return {
    r: sig.r,
    s: sig.s,
    v: sig.v,
  };
}

/**
 * Submit signed action to HyperCore exchange API
 */
async function submitSendToEvmWithData(apiEndpoint, action, signature) {
  const response = await fetch(apiEndpoint, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      action: action,
      nonce: action.nonce,
      signature: signature,
    }),
  });

  const data = await response.json();

  if (data.status === "ok") {
    return data;
  } else {
    throw new Error(`Withdrawal failed: ${JSON.stringify(data)}`);
  }
}

/**
 * Get chain name from CCTP domain ID
 */
function getChainName(domainId) {
  const chains = {
    0: "Ethereum",
    3: "Arbitrum",
    6: "Base",
    1: "Avalanche",
    2: "OP Mainnet",
    7: "Polygon PoS",
  };
  return chains[domainId] || `Chain ${domainId}`;
}

// -------- Main --------
async function main() {
  const {
    PRIVATE_KEY,
    API_ENDPOINT,
    HYPERLIQUID_CHAIN,
    SIGNATURE_CHAIN_ID,
    AMOUNT_USDC,
    SOURCE_DEX,
    DESTINATION_RECIPIENT,
    ADDRESS_ENCODING,
    DESTINATION_CHAIN_ID,
    GAS_LIMIT,
    HOOK_DATA,
  } = CONFIG;

  if (!PRIVATE_KEY || PRIVATE_KEY === "YOUR_PRIVATE_KEY_HERE") {
    throw new Error("Set PRIVATE_KEY environment variable");
  }

  if (
    !DESTINATION_RECIPIENT ||
    DESTINATION_RECIPIENT === "0x1234567890123456789012345678901234567890"
  ) {
    console.warn(
      "⚠️  Warning: Using default recipient address. Set DESTINATION_RECIPIENT to your address.",
    );
  }

  const wallet = new ethers.Wallet(PRIVATE_KEY);
  const userAddress = wallet.address;

  console.log(
    `Withdrawing USDC from HyperCore to ${getChainName(DESTINATION_CHAIN_ID)}`,
  );
  console.log("User Address:", userAddress);
  console.log("Amount:", AMOUNT_USDC, "USDC");
  console.log("Source DEX:", SOURCE_DEX || "perp");
  console.log("Destination Chain ID:", DESTINATION_CHAIN_ID);
  console.log("Destination Recipient:", DESTINATION_RECIPIENT);
  console.log("API Endpoint:", API_ENDPOINT);

  // Convert amount to HyperCore minor units (8 decimals)
  const amount = toHyperCoreUnits(AMOUNT_USDC);

  // Construct the sendToEvmWithData action
  const action = {
    type: "sendToEvmWithData",
    hyperliquidChain: HYPERLIQUID_CHAIN,
    signatureChainId: SIGNATURE_CHAIN_ID,
    token: "USDC",
    amount: amount,
    sourceDex: SOURCE_DEX,
    destinationRecipient: DESTINATION_RECIPIENT,
    addressEncoding: ADDRESS_ENCODING,
    destinationChainId: DESTINATION_CHAIN_ID,
    gasLimit: GAS_LIMIT,
    data: HOOK_DATA,
    time: Date.now(),
  };

  console.log("\nAction:", JSON.stringify(action, null, 2));

  // Sign the action
  console.log("\nSigning action...");
  const signature = await signSendToEvmWithDataAction(action, PRIVATE_KEY);
  console.log("Signature:", signature);

  // Submit the action
  console.log("\nSubmitting withdrawal...");
  const result = await submitSendToEvmWithData(API_ENDPOINT, action, signature);

  console.log("\n✅ Withdrawal successful!");
  console.log("Result:", JSON.stringify(result, null, 2));

  if (HOOK_DATA === "0x") {
    console.log(
      "\n📝 Note: Automatic forwarding is enabled. The recipient will receive funds directly on the destination chain.",
    );
  } else {
    console.log(
      "\n📝 Note: Custom hook data provided. You may need to manually complete the message on the destination chain.",
    );
  }
}

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