Circle CCTP Explained: Moving Native USDC Across Chains, and In and Out of Hyperliquid

Photo of Thomas CosiallsThomas Cosialls

Moving a dollar from one blockchain to another used to mean trusting somebody's vault. Classic bridges lock your USDC on chain A and mint an IOU on chain B, and those vaults have been the most profitable target in crypto: Ronin, Wormhole and Nomad alone lost more than a billion dollars between them. Liquidity-pool bridges avoid the IOU, but only move as much as their pools hold, at a price that moves with the pool.

Circle's Cross-Chain Transfer Protocol (CCTP) takes the vault out of the picture. Because Circle issues USDC, it can do what no third-party bridge can: burn USDC on the source chain and mint the same native USDC on the destination. No wrapped token, no pool, no slippage.

This article covers CCTP in two halves. First the protocol itself: what it is, how burn-attest-mint works, Fast vs Standard transfers, hooks, and the chains it runs on. Then the case we care most about lately: depositing USDC into Hyperliquid and withdrawing it back, with TypeScript code, the fee math, and the traps that can strand funds for good.

What is CCTP?

CCTP is a permissionless onchain utility operated by Circle. Anyone can call its contracts: there is no API key, no whitelisting, and no account to open. It moves USDC between blockchains by destroying it on one side and recreating it on the other, so the total USDC supply never changes and the token you receive is the canonical USDC of the destination chain.

The first version launched in 2023. CCTP V2, released in March 2025, added the two features that make it useful for real-time applications: Fast Transfer (settlement in seconds instead of waiting for finality) and Hooks (metadata that triggers logic on the destination). V2 is now the canonical version; V1 is legacy and only still needed for a couple of chains (Noble and Sui). CCTP has also been extended to EURC and registered third-party tokens, but this article sticks to USDC.

Lock-and-mint bridge vs CCTP burn-and-mint: a bridge vault locks USDC and mints a wrapped IOU, while CCTP burns USDC on chain A and Circle's attestation lets native USDC be minted on chain B
A lock-and-mint bridge leaves a honeypot behind. CCTP leaves nothing: supply moves, it does not pile up.

Here is how CCTP compares with the two families of bridges it replaces for USDC:

Lock-and-mint bridgeLiquidity-pool bridgeCCTP
What you receivea wrapped token (USDC.e, axlUSDC...)native USDC, from a poolnative USDC, freshly minted
Capacitywhatever the vault backspool depth, can run dryunlimited in Standard mode
Price1:1, but the IOU can depegslippage when pools are unbalancedexactly 1:1, minus a known fee
Trusted partybridge validators or multisigbridge operators and LPsCircle's attestation service
Funds at rest to attackthe vaultthe poolsnone

CCTP is not trustless: the attestation service is run by Circle. But if you hold USDC, you already trust Circle to honor it, so CCTP does not add a new trusted party to your stack. The contracts were audited by ChainSecurity and OtterSec.

The key concept: burn, attest, mint

Every CCTP transfer, whatever the chains involved, follows the same three-beat rhythm.

CCTP message lifecycle: the app calls depositForBurn on the source chain, Circle Iris observes the burn and signs an attestation, the app fetches it from the API and calls receiveMessage on the destination chain, which mints USDC
The CCTP lifecycle. With the Forwarding Service, steps 5 to 7 happen without you.

Four components make it work:

  • TokenMessengerV2, the entry point on each chain. You call depositForBurn (or depositForBurnWithHook) on it. On most EVM chains it lives at the same address, 0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d.
  • TokenMinterV2, which actually burns USDC on the source chain and mints it on the destination.
  • MessageTransmitterV2, a generic message-passing layer. It emits the MessageSent event on the source chain and, on the destination, verifies the attestation in receiveMessage before letting the mint happen.
  • Iris, Circle's offchain attestation service. It watches every burn, waits for the level of finality the message asks for, and signs the message. Its API lives at iris-api.circle.com.

So the lifecycle reads: you burn on the source chain, which emits a message (steps 1-2); Iris waits for the required finality and signs it (steps 3-4); you fetch the signed message from GET /v2/messages/{sourceDomain}?transactionHash=... (steps 5-6); and you submit it to receiveMessage on the destination, which checks the signature, marks the nonce as used, and mints USDC to the recipient (steps 7-8).

A few design details matter as soon as you write code against it:

  • Domains, not chain IDs. CCTP identifies chains by its own domain numbers: Ethereum is 0, Arbitrum 3, Base 6, HyperEVM 19. They have nothing to do with EVM chain IDs.
  • Addresses are bytes32. CCTP spans EVM and non-EVM chains, so recipients are 32-byte values. An EVM address is simply left-padded with zeros.
  • Each nonce mints once. The destination records every message nonce it has processed, which makes replaying an attestation impossible.
  • Attestations expire. A burn message carries an expiration block about 24 hours out. An expired burn is not lost: POST /v2/reattest/{nonce} gets you a fresh attestation.

The fields you will actually set live in the message header and the burn message body:

FieldPurpose
destinationDomainCCTP domain of the destination chain
mintRecipientwho receives the minted USDC on the destination
destinationCallerwho may call receiveMessage; zero means anyone
maxFeethe highest fee you accept, in USDC subunits; the actual fee is recorded as feeExecuted
minFinalityThreshold1000 for Fast Transfer, 2000 for Standard
hookDataarbitrary bytes carried to the destination

Fast Transfer vs Standard Transfer

The finality threshold is the main dial in CCTP. On Ethereum and its rollups, a transaction is included in a block within seconds, but it only becomes irreversible once the Ethereum block that carries it is finalized, about 65 blocks or 15 to 19 minutes later. Iris can sign at either point.

Timeline comparing Fast and Standard transfers: Fast attests after one or two blocks in about 8 to 20 seconds while debiting the Fast Transfer allowance until hard finality; Standard waits 15 to 19 minutes for Ethereum finality and is free
Fast Transfer trades a small fee for skipping the finality wait. On chains that finalize in seconds, Standard is already fast.
  • Standard Transfer (minFinalityThreshold: 2000) waits for hard finality. It is free on every chain today, and slow wherever finality is slow: 15 to 19 minutes from Ethereum, Arbitrum, Base or OP Mainnet, 2 to 4 hours from Starknet, and 6 to 32 hours from Linea.
  • Fast Transfer (minFinalityThreshold: 1000) is attested as soon as the burn is confirmed, typically in 8 to 20 seconds. Circle carries the reorg risk in the meantime, backed by a global Fast Transfer allowance: each fast burn debits the allowance, and the amount is credited back once the burn reaches hard finality. If the allowance ever runs dry, you either wait or fall back to Standard. You can check the remaining allowance at GET /v2/fastBurn/USDC/allowance; it held tens of millions of USDC when we wrote this.

Fast Transfer fees are charged in basis points of the amount and deducted when the USDC is minted:

Source chainFast TransferFast feeStandard Transfer
Ethereum~20 s1 bps~15-19 min
Arbitrum~8 s1.4 bps~15-19 min
Base~8 s1.3 bps~15-19 min
Solana~8 s1 bps~25 s
Linea~8 s13 bps~6-32 hours
HyperEVMn/an/a~5 s

Chains marked n/a do not offer Fast Transfer as a source because their Standard Transfer is already that fast. Fees change over time, so never hardcode them: read them from GET /v2/burn/USDC/fees/{source}/{destination} and set maxFee from the response with a small buffer. A maxFee below the current fee can quietly turn a Fast Transfer into a Standard one.

Hooks and the Forwarding Service

Hooks are the hookData bytes attached to a burn. CCTP does not execute them: it carries them to the destination untouched, and whatever contract receives the message decides what to do with them. That is deliberate. It keeps the core protocol small, and lets integrators build "bridge and then do X" flows (deposit into a vault, open a position, pay an invoice) on their own terms.

The most useful hook is one Circle reads itself. Start the hook data with the magic bytes cctp-forward and Circle's Forwarding Service takes over steps 5 to 7 of the lifecycle: it fetches the attestation and submits the mint on the destination chain, paying the destination gas. A CCTP transfer becomes a single transaction on the source chain, with no relayer to run and no gas token to hold on the destination.

forward-hook.ts
// "cctp-forward" magic + version 0 + empty payload: just forward, nothing else
const forwardHookData = '0x636374702d666f72776172640000000000000000000000000000000000000000'

The service charges a fee that covers destination gas plus a service fee, deducted from the transfer (or paid upfront on the source chain on supported routes, if the recipient must receive the exact amount). The service fee is $0.05 on most routes, with dedicated prices for Hyperliquid that we detail below.

Which chains does CCTP support?

CCTP V2 is live on around 30 blockchains, EVM and non-EVM alike. The ones you are most likely to use are Ethereum, Arbitrum, Base, OP Mainnet, Polygon PoS, Avalanche, Solana, Linea, Unichain and HyperEVM, the chain that connects CCTP to Hyperliquid. Every supported chain can receive transfers, testnets included, and Circle keeps the full list with domain IDs up to date.

CCTP on Hyperliquid: HyperEVM and HyperCore

Hyperliquid is one L1 with two execution environments. HyperCore is the native exchange engine: the perps and spot order books, margin, and account balances; it is where trading happens, and it is not an EVM. HyperEVM is a general-purpose EVM that runs on the same chain and consensus, with HYPE as gas. The two share state, which is what lets a contract on HyperEVM credit a trading balance on HyperCore.

Circle launched native USDC and CCTP V2 on HyperEVM (domain 19) on September 16, 2025, then enabled CCTP deposits and withdrawals for HyperCore in the following weeks. Before that, the canonical way in was Hyperliquid's own bridge contract on Arbitrum, secured by its validator set. With CCTP, every CCTP chain is a direct on-ramp.

Since CCTP cannot mint on a non-EVM engine, Circle added a few contracts around the standard protocol:

ContractChainRole
CctpForwarderHyperEVMreceives CCTP mints bound for HyperCore and forwards them
CoreDepositWalletHyperEVMholds the native USDC, credits HyperCore balances, burns on withdrawal
CctpExtensionArbitrumone-transaction deposits authorized by an EIP-3009 signature
CctpExtensionV2Arbitrumsponsored deposits: a relayer submits the burn and pays the gas
HyperCore CCTP contracts (mainnet)
CctpForwarder      HyperEVM   0xb21D281DEdb17AE5B501F6AA8256fe38C4e45757
CoreDepositWallet  HyperEVM   0x6B9E773128f453f5c2C60935Ee2DE2CBc5390A24
CctpExtension      Arbitrum   0xA95d9c1F655341597C94393fDdc30cf3c08E4fcE
CctpExtensionV2    Arbitrum   0x3289e443a95B28Bcedacc4B33C689b0C9b84ffAB

One subtlety to keep in mind: a HyperCore USDC balance is a protocol-level credit, not a token. The actual native USDC sits in the CoreDepositWallet contract on HyperEVM, backing those credits one to one, and a withdrawal is what redeems a credit back into real USDC.

Depositing USDC into Hyperliquid

HyperCore deposit flow: USDC is burned on any CCTP chain with a cctp-forward hook, Circle attests and the Forwarding Service mints on HyperEVM to the CctpForwarder, which calls CoreDepositWallet to credit the recipient's perps or spot balance on HyperCore
A deposit is a normal CCTP transfer to HyperEVM whose hook tells the CctpForwarder where to credit the funds on HyperCore.

A deposit is a standard CCTP transfer to HyperEVM with three specific settings:

  1. destinationDomain is 19 (HyperEVM).
  2. mintRecipient and destinationCaller are both the CctpForwarder address. The USDC is minted to the forwarder, and only the forwarder may complete the message.
  3. hookData tells the forwarder what to do: the cctp-forward header followed by the HyperCore recipient and the destination balance, 0 for perps or 4294967295 (the maximum uint32) for spot.

Circle's Forwarding Service then mints on HyperEVM, the forwarder deposits the USDC into CoreDepositWallet on the recipient's behalf, and the balance shows up on HyperCore.

Start by asking the fee API what the route costs. The hyperCoreDeposit=true flag prices the HyperCore forwarding:

curl 'https://iris-api.circle.com/v2/burn/USDC/fees/3/19?forward=true&hyperCoreDeposit=true'
response
[
  {
    "finalityThreshold": 1000,
    "minimumFee": 0,
    "forwardFee": { "low": 200000, "med": 200000, "high": 200000 }
  },
  {
    "finalityThreshold": 2000,
    "minimumFee": 0,
    "forwardFee": { "low": 200000, "med": 200000, "high": 200000 }
  }
]

From Arbitrum, the route has a special deal: Fast Transfer is free (minimumFee: 0) and forwarding is a flat 0.20 USDC (200000 subunits, since USDC has 6 decimals). Deposit 10 USDC and 9.80 lands on HyperCore within seconds. From other chains you pay the source chain's Fast Transfer fee plus a forwarding fee that includes HyperEVM gas, around 0.25 USDC from Base or Ethereum at the time of writing.

Shared helpers: hook data and fee quote

The scripts below use viem and run directly with Node.js 22.6+ (npm install viem, then node --env-file=.env <script>.ts). First, the pieces every deposit needs:

hypercore.ts
import { concat, pad, stringToHex, toHex, type Address, type Hex } from 'viem'

export const IRIS_API = 'https://iris-api.circle.com'
export const HYPEREVM_DOMAIN = 19
export const FAST_TRANSFER = 1000

// CctpForwarder on HyperEVM: mints from CCTP land here, then get credited on HyperCore
export const CCTP_FORWARDER: Address = '0xb21D281DEdb17AE5B501F6AA8256fe38C4e45757'

export const PERPS_DEX = 0
export const SPOT_DEX = 0xffffffff // type(uint32).max

/**
 * Forwarding hook read by CctpForwarder (version 0):
 *   bytes 0-23   "cctp-forward" magic, right-padded with zeros
 *   bytes 24-27  version = 0
 *   bytes 28-31  payload length = 24
 *   bytes 32-51  HyperCore recipient
 *   bytes 52-55  destination dex (0 = perps, uint32 max = spot)
 */
export function encodeHyperCoreHook(recipient: Address, dex: number = PERPS_DEX): Hex {
  return concat([
    stringToHex('cctp-forward', { size: 24 }),
    toHex(0, { size: 4 }),
    toHex(24, { size: 4 }),
    recipient,
    toHex(dex, { size: 4 }),
  ])
}

export const toBytes32 = (address: Address): Hex => pad(address, { size: 32 })

type FeeQuote = {
  finalityThreshold: number
  minimumFee: number // basis points of the amount
  forwardFee: { low: number; med: number; high: number } // USDC subunits
}

/** maxFee for a Fast Transfer to HyperCore: protocol fee (+20% buffer) plus the forwarding fee. */
export async function quoteMaxFee(sourceDomain: number, amount: bigint): Promise<bigint> {
  const url = `${IRIS_API}/v2/burn/USDC/fees/${sourceDomain}/${HYPEREVM_DOMAIN}?forward=true&hyperCoreDeposit=true`
  const res = await fetch(url)
  if (!res.ok) throw new Error(`Fee quote failed with HTTP ${res.status}`)

  const quotes = (await res.json()) as FeeQuote[]
  const fast = quotes.find((q) => q.finalityThreshold === FAST_TRANSFER)
  if (!fast) throw new Error('No Fast Transfer quote for this route')

  const protocolFee = (amount * BigInt(Math.round(fast.minimumFee * 100))) / 1_000_000n
  return (protocolFee * 120n) / 100n + BigInt(fast.forwardFee.med)
}

The encoder produces exactly the 56 bytes the forwarder expects; we checked its output byte for byte against Circle's reference implementation. The fee helper converts basis points into USDC subunits (the * 100 keeps fractional rates like 1.3 bps exact in integer math), adds a 20% buffer on the protocol fee as Circle recommends, then adds the forwarding fee. low, med and high trade cost against inclusion speed on the destination; on the Arbitrum route all three are the same flat 0.20 USDC.

Deposit from Arbitrum in one transaction

On Arbitrum, the CctpExtension contract accepts an EIP-3009 ReceiveWithAuthorization signature instead of an ERC-20 approval, so the whole deposit is one signature plus one transaction:

deposit-arbitrum.ts
import {
  createPublicClient,
  createWalletClient,
  http,
  parseAbi,
  parseSignature,
  parseUnits,
  toHex,
  type Address,
} from 'viem'
import { arbitrum } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'
import {
  CCTP_FORWARDER,
  FAST_TRANSFER,
  HYPEREVM_DOMAIN,
  PERPS_DEX,
  encodeHyperCoreHook,
  quoteMaxFee,
  toBytes32,
} from './hypercore.ts'

const CCTP_EXTENSION: Address = '0xA95d9c1F655341597C94393fDdc30cf3c08E4fcE'
const USDC: Address = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'
const ARBITRUM_DOMAIN = 3

const extensionAbi = parseAbi([
  'struct ReceiveWithAuthorizationData { uint256 amount; uint256 authValidAfter; uint256 authValidBefore; bytes32 authNonce; uint8 v; bytes32 r; bytes32 s; }',
  'struct DepositForBurnData { uint256 amount; uint32 destinationDomain; bytes32 mintRecipient; bytes32 destinationCaller; uint256 maxFee; uint32 minFinalityThreshold; bytes hookData; }',
  'function batchDepositForBurnWithAuth(ReceiveWithAuthorizationData _receiveWithAuthorizationData, DepositForBurnData _depositForBurnData)',
])

const privateKey = process.env.PRIVATE_KEY as `0x${string}` | undefined
if (!privateKey) throw new Error('PRIVATE_KEY not configured')

const account = privateKeyToAccount(privateKey)
const publicClient = createPublicClient({ chain: arbitrum, transport: http() })
const walletClient = createWalletClient({ account, chain: arbitrum, transport: http() })

const amount = parseUnits('10', 6) // 10 USDC
const maxFee = await quoteMaxFee(ARBITRUM_DOMAIN, amount)

// 1. Authorize the extension to pull the USDC: an offchain EIP-3009 signature, no approve tx
const validAfter = 0n
const validBefore = BigInt(Math.floor(Date.now() / 1000) + 3600)
const nonce = toHex(crypto.getRandomValues(new Uint8Array(32)))

const signature = await walletClient.signTypedData({
  domain: { name: 'USD Coin', version: '2', chainId: arbitrum.id, verifyingContract: USDC },
  types: {
    ReceiveWithAuthorization: [
      { name: 'from', type: 'address' },
      { name: 'to', type: 'address' },
      { name: 'value', type: 'uint256' },
      { name: 'validAfter', type: 'uint256' },
      { name: 'validBefore', type: 'uint256' },
      { name: 'nonce', type: 'bytes32' },
    ],
  },
  primaryType: 'ReceiveWithAuthorization',
  message: {
    from: account.address,
    to: CCTP_EXTENSION,
    value: amount,
    validAfter,
    validBefore,
    nonce,
  },
})
const { r, s, yParity } = parseSignature(signature)

// 2. Burn on Arbitrum, mint to the CctpForwarder on HyperEVM, credit our HyperCore perps balance
const hash = await walletClient.writeContract({
  address: CCTP_EXTENSION,
  abi: extensionAbi,
  functionName: 'batchDepositForBurnWithAuth',
  args: [
    {
      amount,
      authValidAfter: validAfter,
      authValidBefore: validBefore,
      authNonce: nonce,
      v: yParity + 27,
      r,
      s,
    },
    {
      amount,
      destinationDomain: HYPEREVM_DOMAIN,
      mintRecipient: toBytes32(CCTP_FORWARDER), // must be the forwarder
      destinationCaller: toBytes32(CCTP_FORWARDER), // must be the forwarder
      maxFee,
      minFinalityThreshold: FAST_TRANSFER,
      hookData: encodeHyperCoreHook(account.address, PERPS_DEX),
    },
  ],
})

const receipt = await publicClient.waitForTransactionReceipt({ hash })
console.log(`Burn ${receipt.status} on Arbitrum: ${hash}`)

The wallet only needs USDC and a little ETH for gas on Arbitrum; nothing on Hyperliquid. The recipient passed to encodeHyperCoreHook can be any address, which is how an app credits a user's HyperCore account from a treasury wallet. For users who hold USDC but no ETH, CctpExtensionV2 goes one step further: the user only signs, and a relayer submits the burn and pays the gas.

A few seconds after the receipt, the funds are on HyperCore. Hyperliquid's public info API shows the perps balance:

curl -s https://api.hyperliquid.xyz/info \ -H 'Content-Type: application/json' \ -d '{"type":"clearinghouseState","user":"0xYourAddress"}'

Use "type":"spotClearinghouseState" for the spot balance. To track the transfer itself, GET /v2/messages/3?transactionHash=0x... on the Iris API returns the message and its attestation status.

Deposit from any other chain

Every other CCTP chain goes through the standard TokenMessengerV2: approve it to spend the USDC, then call depositForBurnWithHook with the same forwarder settings. This time, we credit the spot balance from Base:

deposit-base.ts
import { createPublicClient, createWalletClient, http, parseAbi, parseUnits, type Address } from 'viem'
import { base } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'
import {
  CCTP_FORWARDER,
  FAST_TRANSFER,
  HYPEREVM_DOMAIN,
  SPOT_DEX,
  encodeHyperCoreHook,
  quoteMaxFee,
  toBytes32,
} from './hypercore.ts'

const TOKEN_MESSENGER_V2: Address = '0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d' // same on most EVM chains
const USDC: Address = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' // USDC on Base
const BASE_DOMAIN = 6

const abi = parseAbi([
  'function approve(address spender, uint256 amount) returns (bool)',
  'function depositForBurnWithHook(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 minFinalityThreshold, bytes hookData)',
])

const privateKey = process.env.PRIVATE_KEY as `0x${string}` | undefined
if (!privateKey) throw new Error('PRIVATE_KEY not configured')

const account = privateKeyToAccount(privateKey)
const publicClient = createPublicClient({ chain: base, transport: http() })
const walletClient = createWalletClient({ account, chain: base, transport: http() })

const amount = parseUnits('10', 6)
const maxFee = await quoteMaxFee(BASE_DOMAIN, amount)

const approval = await walletClient.writeContract({
  address: USDC,
  abi,
  functionName: 'approve',
  args: [TOKEN_MESSENGER_V2, amount],
})
await publicClient.waitForTransactionReceipt({ hash: approval })

const hash = await walletClient.writeContract({
  address: TOKEN_MESSENGER_V2,
  abi,
  functionName: 'depositForBurnWithHook',
  args: [
    amount,
    HYPEREVM_DOMAIN,
    toBytes32(CCTP_FORWARDER),
    USDC,
    toBytes32(CCTP_FORWARDER),
    maxFee,
    FAST_TRANSFER,
    encodeHyperCoreHook(account.address, SPOT_DEX), // this time, credit the spot balance
  ],
})
console.log(`Burn submitted on Base: ${hash}`)

Only the chain, the USDC address and the source domain change from one EVM chain to the next. Solana works the same way conceptually, with its own CCTP programs.

Already holding USDC on HyperEVM?

Then you do not need CCTP at all. Approve the CoreDepositWallet and call one of its deposit functions: deposit(amount, dex) credits the caller, depositFor(recipient, amount, dex) credits someone else, and depositWithAuth takes an EIP-3009 signature instead of an approval.

# Approve, then deposit 100 USDC (6 decimals) to the perps balance (dex 0) cast send $USDC_HYPEREVM "approve(address,uint256)" 0x6B9E773128f453f5c2C60935Ee2DE2CBc5390A24 100000000 \ --rpc-url $HYPEREVM_RPC --private-key $PRIVATE_KEY cast send 0x6B9E773128f453f5c2C60935Ee2DE2CBc5390A24 "deposit(uint256,uint32)" 100000000 0 \ --rpc-url $HYPEREVM_RPC --private-key $PRIVATE_KEY

Never move USDC into CoreDepositWallet with a plain transfer: it does not trigger a deposit, and the tokens are stuck for good.

Withdrawing USDC from Hyperliquid

HyperCore withdrawal flow: the user signs a sendToEvmWithData action posted to the Hyperliquid exchange API, HyperCore debits the balance, CoreDepositWallet on HyperEVM burns the USDC through CCTP, Circle attests and the Forwarding Service mints native USDC to the recipient on the destination chain
A withdrawal starts as a signed HyperCore action and ends as a CCTP mint on the destination chain.

A withdrawal runs the same path in reverse, but it does not start with an EVM transaction. You sign a sendToEvmWithData action with EIP-712 and post it to Hyperliquid's /exchange endpoint. HyperCore debits your balance, CoreDepositWallet burns the matching USDC through CCTP on HyperEVM, Iris attests (HyperEVM finalizes in about five seconds, so withdrawals are fast by default), and the Forwarding Service mints native USDC to your recipient on the destination chain. You sign one message, and hold no HYPE and no destination gas.

The action takes these fields:

FieldValue
sourceDex'' to withdraw from perps, 'spot' from spot
destinationRecipientrecipient address on the destination chain
addressEncoding'hex' for EVM chains, 'base58' for Solana
destinationChainIdthe CCTP domain, not the EVM chain id: 3 Arbitrum, 0 Ethereum, 6 Base, 5 Solana
gasLimitgas budget for execution on the destination
data'0x' for automatic forwarding; custom bytes become the CCTP hookData
signatureChainIdthe chain id used in the EIP-712 domain, in hex
noncecurrent timestamp in milliseconds

Here is the whole withdrawal, again with viem:

withdraw.ts
import { parseSignature } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'

const privateKey = process.env.PRIVATE_KEY as `0x${string}` | undefined
if (!privateKey) throw new Error('PRIVATE_KEY not configured')

const account = privateKeyToAccount(privateKey)
const nonce = Date.now()

const message = {
  hyperliquidChain: 'Mainnet',
  token: 'USDC',
  amount: '10', // human-readable USDC, as a string
  sourceDex: '', // '' = perps balance, 'spot' = spot balance
  destinationRecipient: account.address, // receive on our own address
  addressEncoding: 'hex', // 'base58' for a Solana recipient
  destinationChainId: 3, // a CCTP domain, not an EVM chain id: 3 = Arbitrum
  gasLimit: 200_000n,
  data: '0x', // empty = let the Forwarding Service mint on the destination for us
  nonce: BigInt(nonce),
} as const

const signature = await account.signTypedData({
  domain: {
    name: 'HyperliquidSignTransaction',
    version: '1',
    chainId: 42161, // must match signatureChainId below
    verifyingContract: '0x0000000000000000000000000000000000000000',
  },
  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' },
    ],
  },
  primaryType: 'HyperliquidTransaction:SendToEvmWithData',
  message,
})
const { r, s, yParity } = parseSignature(signature)

const res = await fetch('https://api.hyperliquid.xyz/exchange', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    action: {
      type: 'sendToEvmWithData',
      signatureChainId: '0xa4b1', // 42161 in hex
      ...message,
      gasLimit: Number(message.gasLimit),
      nonce,
    },
    nonce,
    signature: { r, s, v: yParity + 27 },
  }),
})

const result = await res.json()
if (!res.ok || result.status !== 'ok') {
  throw new Error(`Withdrawal rejected: ${JSON.stringify(result)}`)
}
console.log('Withdrawal accepted by HyperCore:', result)

The EIP-712 message and the JSON action carry the same values; the only differences are encoding (bigint for the signed uint64 fields, plain numbers in JSON) and the two fields that only exist in the action, type and signatureChainId. To target another chain, change destinationChainId to its CCTP domain; to use testnet, switch hyperliquidChain to 'Testnet' and post to api.hyperliquid-testnet.xyz.

Withdrawals carry two fees: a HyperCore-side fee and, with forwarding, the Forwarding Service fee, which depends on the destination: 0.20 USDC to most chains, 1.20 USDC to Ethereum and 0.50 USDC to Solana. The exact forwarding fee can be read from the CoreDepositWallet contract. If the amount does not cover the fees, the burn on HyperEVM reverts.

Two more details. The CCTP destinationCaller is always the zero address on withdrawals, so anyone can complete the mint. And if you put your own bytes in data instead of 0x, they become the CCTP hook data, forwarding is switched off, and completing the mint on the destination becomes your job (or anyone's).

Fees at a glance

As of September 2026, for a 10 USDC transfer:

RouteCCTP protocol feeForwarding fee
Arbitrum to HyperCore (Fast)00.20 USDC flat
Base to HyperCore (Fast)1.3 bps~0.25 USDC, quoted live
Ethereum to HyperCore (Fast)1 bps~0.25 USDC, quoted live
Any chain to HyperCore (Standard)0same as Fast, but minutes instead of seconds
HyperCore to Arbitrum, Base and most chains00.20 USDC, plus the HyperCore fee
HyperCore to Ethereum01.20 USDC, plus the HyperCore fee
HyperCore to Solana00.50 USDC, plus the HyperCore fee

New HyperCore accounts also pay a one-time 1 USDC activation fee, charged by Hyperliquid (not Circle) on the account's first outbound action.

Gotchas that can cost you funds

Most CCTP mistakes are loud: an expired authorization or a missing approval reverts on the source chain, and nothing moves. The Hyperliquid integration adds a few silent ones.

  • The forwarder must be both mintRecipient and destinationCaller. Set either one to anything else on a HyperCore deposit and the USDC is minted somewhere that cannot deposit it. Circle's docs are explicit: those funds cannot be recovered. Keep these values as constants, never as user input.
  • No plain transfers to CoreDepositWallet. Only its deposit functions credit HyperCore. A direct transfer strands the tokens.
  • The activation fee bites on the way out, not in. Deposits of any size succeed, even below 1 USDC. But a new account's first withdrawal or transfer needs at least 1 USDC for the activation fee, on top of the withdrawal fees, and fails otherwise. If your system creates accounts programmatically, you can pre-activate them.
  • Perps or spot is decided by the hook. 0 credits perps, 4294967295 credits spot, and any other value falls back to spot. A trading bot that expects margin in perps will not find it in spot.
  • A thin maxFee downgrades silently. If maxFee cannot cover both the Fast Transfer fee and forwarding, CCTP keeps the forwarding and drops to a Standard Transfer: from Arbitrum that is 15 to 19 minutes instead of 8 seconds. The same happens if the Fast Transfer allowance is exhausted.
  • Testnet has its own rules. A HyperCore testnet recipient must already exist on mainnet, can receive at most 1,000 testnet USDC, and transfers to unknown addresses fail silently. Check an address first with Hyperliquid's userRole info request.
  • Respect the Iris rate limit. The API allows 40 requests per second; exceed it and you are blocked for five minutes. Poll attestations every few seconds, not in a tight loop.

Frequently asked questions

Is CCTP a bridge? Functionally, yes: it moves value between chains. Structurally, no: nothing is locked and nothing is wrapped. USDC is burned on one side and minted on the other by its own issuer, so there is no pool or vault to drain.

How long does a deposit into Hyperliquid take? With Fast Transfer from Arbitrum or Base, about 8 seconds for the attestation, plus the forwarding transaction on HyperEVM. With Standard Transfer from an Ethereum rollup, 15 to 19 minutes. Withdrawals are fast by default because HyperEVM finalizes in seconds.

Can I deposit straight into my spot balance? Yes. Set the last four bytes of the hook data to 4294967295 (the maximum uint32) instead of 0.

Is the USDC on HyperCore "real" USDC? HyperCore balances are protocol-level credits. Each one is backed by native USDC held in the CoreDepositWallet contract on HyperEVM, and a withdrawal redeems it for native USDC on any CCTP chain.

Do I need HYPE or gas on Hyperliquid? No. A deposit costs gas on the source chain only (and even that can be sponsored through CctpExtensionV2 on Arbitrum). A withdrawal is a signed HyperCore action with fees paid in USDC.

Where this fits

CCTP turned USDC from a token that lives on many chains into one balance that moves between them in seconds, at a known price, without a bridge to trust. For Hyperliquid, it means capital can flow between any major chain and the order book without detours, and without anyone clicking through a bridge UI.

This article grew out of a client project where we have been using CCTP to bridge USDC between EVM chains and Hyperliquid.

At Etherwave Labs, we build on EVM chains and Hyperliquid, and we are open to helping you with anything in that space: trading robots and automated strategies, delta-neutral strategies (for example hedging spot or liquidity positions with Hyperliquid perps), and onchain automation in general, from cross-chain treasury rebalancing with CCTP to keepers and position management. You can see how we approach automation on our liquidity management automation service page, read our primer on order books vs AMMs, and if you have a strategy or a flow to automate, talk to us.

More articles

Cover image for Circle Nanopayments Explained: Gas-Free USDC Payments Down to $0.000001

Circle Nanopayments Explained: Gas-Free USDC Payments Down to $0.000001

How Circle Nanopayments uses Gateway's batched settlement to make sub-cent USDC payments economical, with a full Node.js integration tutorial: gate an Express API behind a $0.0001 price and pay it from an AI agent, gas-free on both sides.

Read more
Cover image for Gate an API Endpoint with x402: Accept USDC Payments on Your Node.js Server

Gate an API Endpoint with x402: Accept USDC Payments on Your Node.js Server

A full, hands-on tutorial to monetize an Express API with the x402 payment protocol: gate an endpoint behind a USDC price on Base, then pay for it programmatically from JavaScript and Python clients.

Read more

Ready to take your project to the next level?

Contact us today to discuss how we can help you achieve your goals in the blockchain space.