Hyperliquid Withdrawals

Withdraw from Hyperliquid (HyperCore) to any Across-supported chain via a gasless multi-signature flow.

HyperCore is Hyperliquid's margin and state matching engine. To move funds off it the user signs user signs EIP-712 typed signatures, which is submitted onchain by Across's relayers. Across handles the HyperCore → HyperEVM lift, the bridge, and any destination swap.

API key with swap-gasless permission required. Get an API key + Integrator ID.

Endpoints

MethodPathPurpose
GET/swap/gaslessQuote — returns the steps the user must sign
POST/gasless/submitSubmit signed steps; relayer takes over from here
GET/deposit/status?depositId=...&originChainId=1337Track the withdrawal to terminal status

One flow, one response shape

Every withdrawal uses the same flow and the same quote shape. What differs per route is (a) the steps in swapTxns[] and (b) the settlement mechanism, reported in steps.bridge.provider:

steps.bridge.providerWhenSteps emittedNotes
sponsored-cctpUSDC → another chainhypercore-transfer + gasless-authUSDC always settles via Circle CCTP (sponsored). Scales to large sizes.
acrossnon-USDC / non-CCTP bridgeshypercore-transfer + gasless-authAcross intent/relayer network. Liquidity-capped — oversized amounts return 400 AMOUNT_TOO_HIGH.
hypercore-passthroughsame-token → HyperEVM (999)hypercore-transferNo relayer leg.
hypercore-usdclass-transfersame-chain SPOT ↔ PERPSusdclass-transferHyperCore-internal move.

Before You Integrate — gotchas that will trip you up, read these first:

  • Decimals trap. HyperCore USDC = 8 decimals. HyperEVM USDC and standard EVM USDC = 6 decimals. The amount query parameter is in input-token decimals — passing a 6-decimal value when the input lives on HyperCore will under-bridge by 100×.
  • Refunds land on HyperEVM, not HyperCore. refundToken.chainId on bridging quotes is always 999. Set refundOnOrigin: true on bridging routes and surface the destination explicitly in your UI — users won't expect refunds to come back to HyperEVM.

Supported Origin Tokens

SymbolinputToken valueDecimalsBalance lives in
USDC-spot0x20000000000000000000000000000000000000008HL spot
USDC-perps0x21000000000000000000000000000000000000008HL perps margin
HYPE0x22222222222222222222222222222222222222228HL spot
USDT (USDT-SPOT)0x200000000000000000000000000000000000010C8HL spot

The 0x2… addresses are HyperCore sentinels — they are not HyperEVM ERC-20s. Destinations: any EVM chain Across supports.

End-to-end

Quote

GET /swap/gasless
  ?inputToken=0x2100000000000000000000000000000000000000   # USDC-PERPS
  &originChainId=1337                                       # HyperCore
  &outputToken=0x0000000000000000000000000000000000000000   # native ETH
  &destinationChainId=8453                                  # Base
  &amount=100000000                                         # 1 USDC (8 decimals on HC)
  &depositor=<user EOA>
  &recipient=<destination addr>
  &integratorId=<your id>

Returns depositId, swapTxns[], swapTx (echoed periphery payload — {} on single-step routes), quoteExpiryTimestamp, expectedOutputAmount, minOutputAmount, and fees (the submission fee is at fees.submission — there is no top-level submissionFees). Also approvalTxns (always null), and informational checks / stepssteps.bridge.provider is the settlement mechanism; steps.originSwap / steps.destinationSwap are null when there's no swap leg. swapTxns is always present.

Submission fee — the amount (in the input token) paid to the submitter that broadcasts your signed permit on-chain in the gasless flow, reimbursing their gas plus margin.

It's part of the signed witness (submissionFees: { amount, recipient }), so the user authorizes the exact fee and payee when signing — no separate gas payment from the user.

Sign every step

Iterate swapTxns and dispatch by step.ecosystem. The user signs every step — missing signatures are rejected at submit. Please note that even if a single signature is unsigned or signed incorrectly, the transaction will not go through.

const sigs: Record<string, string> = {};
for (const step of quote.swapTxns) {
  if (step.ecosystem === 'hypercore') {
    sigs[step.stepId] = await hlSign(step.typedData);   // HL wallet
  } else if (step.ecosystem === 'evm-gasless') {
    sigs[step.stepId] = await evmSign(step.typedData); // EVM EOA — EIP-712
  }
}

evm-gasless chainId gotcha. Sign at step.typedData.domain.chainId (always 999 / HyperEVM), NOT the outer step.chainId. If you use a single EVM wallet for both ecosystems, make sure it is on chain 999 before signing every step. Wrong-chain signatures fail server-side verifyTypedData.

Submit

POST /gasless/submit
{
  "swapTx":             <quote.swapTx>,
  "swapTxns":           <quote.swapTxns>,
  "signaturesByStepId": { "hypercore-transfer": "0x…", "gasless-auth": "0x…" }
}

Returns { depositId, messageId, id } (messageId may be null). Submit is gated in two stages: (1) every signature must recover to the depositor (else 400 "Invalid signature: unable to verify signature"); (2) the integrator key must be eligible for gasless submission (else 403). It is accepted optimistically — a 200 is returned before on-chain execution, so an underfunded withdrawal still returns 200 and later surfaces as deposit-failed.

Track status

Poll /deposit/status with the depositId and originChainId=1337:

GET /deposit/status?depositId=<id>&originChainId=1337
StatusMeaning
deposit-pendingHyperCore → HyperEVM lift in progress
deposit-failedLift failed (e.g. insufficient HyperCore balance); funds not moved (terminal)
filledDestination tokens delivered (terminal)
expired / refundedTerminal failure (refunds land on HyperEVM)

Gas Fees

HyperCore → HyperEVM mirror moves cost a small HyperEVM gas fee.

  • USDC / USDT origin — fee is paid in-kind or auto-deducted in HYPE. Users don't need to think about it.
  • HYPE / non-stablecoin origin — users must hold HYPE on HyperCore to cover gas.

This lift cost is reported at fees.submission on every quote (a flat amount in the submission token, independent of size). fees.total / fees.totalMax cover any protocol/relayer spread — 0 for same-asset USDC routes.

Caveats

  1. Decimals trap. HC tokens = 8 decimals; standard EVM USDC = 6. Re-check before passing amount.
  2. HL nonces are wallet-unique. If your client races two quotes, sign + submit them in order — HL will reject a later nonce that arrives first as stale.
  3. Quote expiry is short (typically 60–120s). After quoteExpiryTimestamp, re-quote; submitting a stale quote returns a witness-binding error.
  4. Don't mutate swapTx or swapTxns between quote and submit. The submit endpoint re-validates byte-for-byte; any drift returns a deep-equal error.
  5. USDC always settles via CCTP (steps.bridge.provider: sponsored-cctp) — it is not a separate flow. Always trust fees.submission.amount from the quote rather than computing the lift fee yourself.
  6. across (Intents) routes are liquidity-capped. Oversized amounts return 400 AMOUNT_TOO_HIGH with the max in the message. CCTP (USDC) routes are not capped and scale to large sizes.
  7. recipientdepositor is fine — but the depositor EOA must own the HC funds and sign every step.

All validation errors return 400 InvalidParamError with a param field telling you which input to fix. Most "re-quote" errors are TOCTOU freshness checks — retrying the same body will keep failing.

Minimal Example: HC USDC-PERPS → Base ETH

const quote = await fetch(`${BASE}/swap/gasless?inputToken=0x2100…&…`).then(r => r.json());

const sigs: Record<string, string> = {};
for (const step of quote.swapTxns) {
  sigs[step.stepId] = step.ecosystem === 'hypercore'
    ? await hlSign(step.typedData)
    : await evmSign(step.typedData);
}

const { depositId } = await fetch(`${BASE}/gasless/submit`, {
  method: 'POST',
  body: JSON.stringify({
    swapTx: quote.swapTx,
    swapTxns: quote.swapTxns,
    signaturesByStepId: sigs,
  }),
}).then(r => r.json());

while (true) {
  const { status } = await fetch(
    `${BASE}/deposit/status?depositId=${depositId}&originChainId=1337`,
  ).then(r => r.json());
  if (['filled', 'expired', 'refunded', 'deposit-failed'].includes(status)) break;
  await new Promise(r => setTimeout(r, 2000));
}

Good to Know

References

On this page