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
Withdrawing using the Forwarding Service is only available for Arbitrum. If you are withdrawing to a different chain, you will need to fetch the attestation from the Circle API and mint the USDC on the destination chain yourself.Withdrawals include a HyperCore fee and (if using the Forwarding Service) a CCTP forwarding fee. Ensure your withdrawal amount exceeds combined fees depending on your transfer.

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 begin, ensure that you’ve:
  • Installed Node.js v22+
  • Prepared an EVM wallet with the private key available
  • Funded your HyperCore account with USDC in either spot or perp balance
  • Created a new Node project and installed dependencies:
    npm install ethers
    npm install -D tsx typescript @types/node
    
  • Created a .env file with required environment variables:
    PRIVATE_KEY=0x...
    DESTINATION_RECIPIENT=0x...  # Recipient address on destination chain
    

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 destination chain’s EVM chain ID in hexadecimal format (e.g., "0xa4b1" for Arbitrum, "0x1" for Ethereum). Must match the destination chain.
  • token: USDC
  • amount: The amount of USDC as a string (e.g., "10" for 10 USDC, "1.5" for 1.5 USDC)
  • sourceDex: "spot" to withdraw from spot balance, or "" 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: Gas limit for the transaction on the destination chain
  • data: CCTP hook data (use "0x" for automatic forwarding)
  • nonce: Current timestamp in milliseconds
TypeScript
// Example action payload for sendToEvmWithData
const action = {
  type: "sendToEvmWithData",
  hyperliquidChain: "Mainnet",
  signatureChainId: "0xa4b1", // Arbitrum chain ID used for signing
  token: "USDC",
  amount: "10", // 10 USDC
  sourceDex: "spot", // or "" for perp
  destinationRecipient: "0x1234567890123456789012345678901234567890",
  addressEncoding: "hex",
  destinationChainId: 3, // Arbitrum CCTP domain
  gasLimit: 200000,
  data: "0x", // "0x" enables automatic forwarding on the destination
  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: "HyperliquidSignTransaction"
  • version: "1"
  • chainId: The chain ID from signatureChainId (as a number)
  • verifyingContract: "0x0000000000000000000000000000000000000000"
TypeScript
import { Wallet, Signature } from "ethers";

// Sign the action using EIP-712
const wallet = new Wallet(privateKey);
const chainId = parseInt(signatureChainId, 16);

const domain = {
  name: "HyperliquidSignTransaction",
  version: "1",
  chainId,
  verifyingContract: "0x0000000000000000000000000000000000000000",
};

const types = {
  "HyperliquidTransaction: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: "nonce", type: "uint64" },
  ],
};

const message = {
  hyperliquidChain: "Mainnet",
  token: "USDC",
  amount: "10",
  sourceDex: "spot",
  destinationRecipient: "0x...",
  addressEncoding: "hex",
  destinationChainId: 3,
  gasLimit: BigInt(200000),
  data: "0x",
  nonce: BigInt(Date.now()),
};

const sigHex = await wallet.signTypedData(domain, types, message);
const sig = Signature.from(sigHex);
const signature = { 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.
TypeScript
const response = await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    action,
    nonce: timestamp,
    signature,
  }),
});

const result = await response.json();

if (response.status === 200 && result.status === "ok") {
  console.log("Withdrawal initiated successfully:", result);
} else {
  throw new Error(`Withdrawal failed: ${JSON.stringify(result)}`);
}

Full example code

The following is a complete example of how to withdraw USDC from HyperCore to an external EVM blockchain. By default, it withdraws 10 USDC from your perp balance to Arbitrum testnet with automatic forwarding enabled. For other destination chains, update destinationChainId (CCTP domain ID) and signatureChainId (destination chain’s EVM chain ID in hex) accordingly.
TypeScript
/**
 * Script: Withdraw USDC from HyperCore to EVM chain
 * - Signs EIP-712 sendToEvmWithData action
 * - Submits to Hyperliquid /exchange API
 */

import { Wallet, Signature } from "ethers";

// -------- Configuration --------
const config = {
  privateKey: process.env.PRIVATE_KEY as string,

  // Transfer parameters
  amount: process.env.AMOUNT || "10", // 10 USDC
  sourceDex: process.env.SOURCE_DEX || "", // "" for perp, "spot" for spot

  // Destination parameters
  destinationRecipient: process.env.DESTINATION_RECIPIENT as string,
  destinationChainId: Number(process.env.DESTINATION_CHAIN_ID || 3), // 3 = Arbitrum
  addressEncoding: process.env.ADDRESS_ENCODING || "hex",
  gasLimit: Number(process.env.GAS_LIMIT || 200000),
  data: process.env.DATA || "0x", // "0x" enables automatic forwarding

  // Hyperliquid environment
  isMainnet:
    String(process.env.HL_IS_MAINNET || "false").toLowerCase() === "true",
  signatureChainId: "0xa4b1", // Destination chain's EVM chain ID (hex) for EIP-712 signing
};

// -------- Main Function --------
async function main() {
  // Validate required parameters
  if (!config.privateKey) {
    throw new Error("Set PRIVATE_KEY");
  }
  if (!config.destinationRecipient) {
    throw new Error("Set DESTINATION_RECIPIENT");
  }

  const apiUrl = config.isMainnet
    ? "https://api.hyperliquid.xyz"
    : "https://api.hyperliquid-testnet.xyz";
  const hyperliquidChain = config.isMainnet ? "Mainnet" : "Testnet";
  const chainId = parseInt(config.signatureChainId, 16);
  const timestamp = Date.now();

  console.log("Withdrawing from HyperCore:", hyperliquidChain);
  console.log("Source balance:", config.sourceDex || "perp");
  console.log("Amount (USDC):", config.amount);
  console.log("Destination recipient:", config.destinationRecipient);
  console.log("Destination chain ID:", config.destinationChainId);
  console.log("Gas limit:", config.gasLimit);

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

  // EIP-712 Types
  const types = {
    "HyperliquidTransaction: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: "nonce", type: "uint64" },
    ],
  };

  // Message to sign
  const message = {
    hyperliquidChain,
    token: "USDC",
    amount: config.amount,
    sourceDex: config.sourceDex,
    destinationRecipient: config.destinationRecipient,
    addressEncoding: config.addressEncoding,
    destinationChainId: config.destinationChainId,
    gasLimit: BigInt(config.gasLimit),
    data: config.data,
    nonce: BigInt(timestamp),
  };

  // Sign the message using EIP-712
  const wallet = new Wallet(config.privateKey);
  const sigHex = await wallet.signTypedData(domain, types, message);
  const sig = Signature.from(sigHex);

  // Build action payload
  const action = {
    type: "sendToEvmWithData",
    hyperliquidChain,
    signatureChainId: config.signatureChainId,
    token: "USDC",
    amount: config.amount,
    sourceDex: config.sourceDex,
    destinationRecipient: config.destinationRecipient,
    addressEncoding: config.addressEncoding,
    destinationChainId: config.destinationChainId,
    gasLimit: config.gasLimit,
    data: config.data,
    nonce: timestamp,
  };

  // Submit to Hyperliquid exchange API
  const response = await fetch(`${apiUrl}/exchange`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      action,
      nonce: timestamp,
      signature: { r: sig.r, s: sig.s, v: sig.v },
    }),
  });

  const result = await response.json();

  console.log("\nStatus:", response.status);
  console.log("Response:", JSON.stringify(result, null, 2));

  if (response.status === 200 && result.status === "ok") {
    console.log("\nWithdrawal initiated successfully");
  } else {
    throw new Error(`Withdrawal failed: ${JSON.stringify(result)}`);
  }
}

// Run
main().catch((error) => {
  console.error("Error:", error.message);
  process.exit(1);
});
Run the script:
npx tsx script.ts