Skip to main content
This guide shows how to withdraw USDC from a HyperCore spot or perp balance to HyperEVM using the HyperCore API.
Note: You can only withdraw USDC from HyperCore to the same address on HyperEVM. It’s not possible to specify a different recipient address.

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

Steps

Use the following steps to withdraw USDC from HyperCore to HyperEVM.

Step 1. Construct the sendAsset action

Create a sendAsset action object with the following parameters:
  • type: sendAsset
  • hyperliquidChain: Mainnet (or Testnet for testnet)
  • signatureChainId: An EVM chain ID used for EIP-712 replay protection. Must match between signing and the action payload, but can be any valid chain ID (for example, "0xa4b1" for Arbitrum)
  • destination: The USDC token system address (0x2000000000000000000000000000000000000000)
  • sourceDex: "spot" to withdraw from spot balance, or "" for perp balance
  • destinationDex: "spot"
  • token: USDC
  • amount: The amount of USDC as a human-readable string (for example, "10" for 10 USDC)
  • fromSubAccount: Set to "" for main account, or the subaccount address
  • nonce: Current timestamp in milliseconds
TypeScript
const action = {
  type: "sendAsset",
  hyperliquidChain: "Testnet",
  signatureChainId: "0xa4b1", // EVM chain ID for EIP-712 replay protection
  destination: "0x2000000000000000000000000000000000000000",
  sourceDex: "", // "" for perp, "spot" for spot
  destinationDex: "spot",
  token: "USDC",
  amount: "10", // 10 USDC (human-readable)
  fromSubAccount: "",
  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";

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

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

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

  // EIP-712 types (must match Hyperliquid SDK's SEND_ASSET_SIGN_TYPES)
  const types = {
    "HyperliquidTransaction:SendAsset": [
      { name: "hyperliquidChain", type: "string" },
      { name: "destination", type: "string" },
      { name: "sourceDex", type: "string" },
      { name: "destinationDex", type: "string" },
      { name: "token", type: "string" },
      { name: "amount", type: "string" },
      { name: "fromSubAccount", type: "string" },
      { name: "nonce", type: "uint64" },
    ],
  };

  // Message to sign (only fields defined in EIP-712 types, not signatureChainId)
  const value = {
    hyperliquidChain: action.hyperliquidChain,
    destination: action.destination,
    sourceDex: action.sourceDex,
    destinationDex: action.destinationDex,
    token: action.token,
    amount: action.amount,
    fromSubAccount: action.fromSubAccount,
    nonce: BigInt(action.nonce),
  };

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

  // Split signature into r, s, v components
  const sig = 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.
TypeScript
async function submitSendAsset(
  action: any,
  signature: { r: string; s: string; v: number },
) {
  // Use https://api.hyperliquid.xyz for mainnet
  const response = await fetch("https://api.hyperliquid-testnet.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 HyperEVM. By default, it withdraws 10 USDC from your perp balance to HyperEVM testnet.
TypeScript
/**
 * Script: Withdraw USDC from HyperCore to HyperEVM
 * - Constructs a SendAsset action
 * - Signs the action using EIP-712
 * - Submits the signed action to the HyperCore 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 (human-readable)
  sourceDex: process.env.SOURCE_DEX || "", // "" for perp, "spot" for spot

  // Hyperliquid environment
  isMainnet:
    String(process.env.HL_IS_MAINNET || "false").toLowerCase() === "true",
};

// System address for USDC token on HyperCore
const USDC_SYSTEM_ADDRESS = "0x2000000000000000000000000000000000000000";

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

  const apiUrl = config.isMainnet
    ? "https://api.hyperliquid.xyz"
    : "https://api.hyperliquid-testnet.xyz";
  const hyperliquidChain = config.isMainnet ? "Mainnet" : "Testnet";
  const signingChainId = "0xa4b1"; // EVM chain ID for EIP-712 signing (any valid chain ID works)
  const chainId = parseInt(signingChainId, 16);
  const timestamp = Date.now();

  const wallet = new Wallet(config.privateKey);
  console.log("Withdrawing from HyperCore to HyperEVM:", hyperliquidChain);
  console.log("User Address:", wallet.address);
  console.log("Source balance:", config.sourceDex || "perp");
  console.log("Amount (USDC):", config.amount);

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

  // Build action for signing
  const actionForSigning = {
    hyperliquidChain,
    signatureChainId: signingChainId,
    destination: USDC_SYSTEM_ADDRESS,
    sourceDex: config.sourceDex,
    destinationDex: "spot",
    token: "USDC",
    amount: config.amount,
    fromSubAccount: "",
    nonce: timestamp,
  };

  // EIP-712 Types (must match Hyperliquid SDK's SEND_ASSET_SIGN_TYPES)
  const types = {
    "HyperliquidTransaction:SendAsset": [
      { name: "hyperliquidChain", type: "string" },
      { name: "destination", type: "string" },
      { name: "sourceDex", type: "string" },
      { name: "destinationDex", type: "string" },
      { name: "token", type: "string" },
      { name: "amount", type: "string" },
      { name: "fromSubAccount", type: "string" },
      { name: "nonce", type: "uint64" },
    ],
  };

  // Message to sign (only fields defined in EIP-712 types, not signatureChainId)
  const message = {
    hyperliquidChain: actionForSigning.hyperliquidChain,
    destination: actionForSigning.destination,
    sourceDex: actionForSigning.sourceDex,
    destinationDex: actionForSigning.destinationDex,
    token: actionForSigning.token,
    amount: actionForSigning.amount,
    fromSubAccount: actionForSigning.fromSubAccount,
    nonce: BigInt(actionForSigning.nonce),
  };

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

  // Build action payload for API (includes type and signatureChainId)
  const action: any = {
    type: "sendAsset",
    ...actionForSigning,
  };

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