Skip to main content

Overview

This guide shows how to use Magic’s Express Server Wallet to trade perpetual futures on Hyperliquid. The @nktkas/hyperliquid SDK accepts any viem-compatible account, so your server creates a custom account that delegates signTypedData to the TEE — the private key never leaves the enclave.

Prerequisites

Before starting, ensure you have:
  1. A Magic Secret Key — from your Magic Dashboard
  2. An OIDC Provider ID — configured for your auth provider (setup guide)
  3. A user JWT from your authentication provider
  4. USDC on Arbitrum One and ETH for gas (to activate the wallet on Hyperliquid mainnet — required even for testnet)

How It Works

  1. Your server authenticates the user and obtains a JWT
  2. JWT is sent to Magic’s TEE to get (or create) the user’s EOA
  3. Your app deposits at least $5 USDC from Arbitrum to activate the wallet on Hyperliquid
  4. For testnet: claim 1,000 mock USDC via the faucet API
  5. A custom viem account delegates signTypedData calls to the TEE
  6. The @nktkas/hyperliquid ExchangeClient uses this account for order signing
  7. Orders, cancellations, and leverage updates are signed and submitted to Hyperliquid
Hyperliquid uses EIP-712 typed data signatures for all exchange actions. The @nktkas/hyperliquid SDK handles nonces and signature construction automatically — your TEE account only needs to implement signTypedData.

TEE Request Helper

All TEE calls use the same authentication headers. Create a reusable helper for your server-side code.
TypeScript
const TEE_BASE = "https://tee.express.magiclabs.com";

async function teeRequest<T>(path: string, jwt: string, body: object): Promise<T> {
  const res = await fetch(`${TEE_BASE}${path}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${jwt}`,
      "X-Magic-Secret-Key": "YOUR_SECRET_KEY",
      "X-OIDC-Provider-ID": "YOUR_OIDC_PROVIDER_ID",
      "X-Magic-Chain": "ETH",
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`TEE error: ${res.status}`);
  return res.json();
}

Get or Create a Wallet

Fetch the user’s wallet address. If one doesn’t exist, it will be created automatically.
TypeScript
const { public_address: eoaAddress } = await teeRequest<{ public_address: string }>(
  "/v1/wallet", jwt, {}
);

Creating a TEE-Backed Account

The Hyperliquid SDK expects a wallet with a signTypedData method. Create a custom viem account that delegates signing to the TEE.
TypeScript
import { toAccount } from "viem/accounts";
import { hashTypedData, hashMessage, serializeSignature, type Address, type Hex } from "viem";

function createTeeAccount(jwt: string, address: Address) {
  return toAccount({
    address,
    signMessage: async ({ message }) => {
      const hash = typeof message === "string"
        ? hashMessage(message)
        : hashMessage({ raw: message as Hex });
      return signHashViaTee(jwt, hash);
    },
    signTransaction: async () => {
      throw new Error("Use signAndSend for transactions");
    },
    signTypedData: async ({ domain, types, primaryType, message }) => {
      const hash = hashTypedData({ domain, types, primaryType, message });
      return signHashViaTee(jwt, hash);
    },
  });
}

async function signHashViaTee(jwt: string, hash: Hex): Promise<Hex> {
  const { r, s, v } = await teeRequest<{ r: string; s: string; v: string }>(
    "/v1/wallet/sign/data", jwt, { raw_data_hash: hash, chain: "ETH" }
  );
  return serializeSignature({ r: r as Hex, s: s as Hex, yParity: Number(v) - 27 });
}

const teeAccount = createTeeAccount(jwt, eoaAddress as Address);

Setting Up Hyperliquid Clients

Install dependencies and initialize the SDK with the TEE-backed account.
npm install @nktkas/hyperliquid viem
TypeScript
import { ExchangeClient, InfoClient, HttpTransport } from "@nktkas/hyperliquid";

// Testnet transport
const transport = new HttpTransport({ isTestnet: true });

// Read-only client for market data (no wallet needed)
const info = new InfoClient({ transport });

// Trading client (uses TEE-backed account for signing)
const exchange = new ExchangeClient({ transport, wallet: teeAccount });

Depositing USDC

Hyperliquid runs on its own L1 chain — you can’t trade directly from an Arbitrum wallet. To fund your Hyperliquid account, send USDC to the bridge contract on Arbitrum. This is a standard ERC20 transfer — your Hyperliquid account is credited within ~1 minute. First, set up a public client and transaction helper for Arbitrum One:
TypeScript
import {
  createPublicClient, http, encodeFunctionData, parseUnits,
  serializeTransaction, keccak256, type TransactionSerializable,
} from "viem";
import { arbitrum } from "viem/chains";

const arbClient = createPublicClient({
  chain: arbitrum,
  transport: http("https://arb1.arbitrum.io/rpc"),
});

async function signAndSend(jwt: string, tx: TransactionSerializable) {
  const serialized = serializeTransaction(tx);
  const hash = keccak256(serialized);

  const { r, s, v } = await teeRequest<{ r: string; s: string; v: string }>(
    "/v1/wallet/sign/data", jwt, { raw_data_hash: hash, chain: "ETH" }
  );

  const yParity = Number(v) >= 27 ? Number(v) - 27 : Number(v);
  const signed = serializeTransaction(tx, { r: r as Hex, s: s as Hex, yParity });
  return await arbClient.sendRawTransaction({ serializedTransaction: signed });
}
Then deposit USDC to the bridge:
TypeScript
const BRIDGE_ADDRESS = "0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7";
const USDC_ADDRESS = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831";

const usdcAbi = [
  {
    name: "transfer",
    type: "function",
    inputs: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    outputs: [{ type: "bool" }],
    stateMutability: "nonpayable",
  },
] as const;

// Deposit 10 USDC into Hyperliquid
const amount = parseUnits("10", 6); // USDC has 6 decimals

const nonce = await arbClient.getTransactionCount({ address: eoaAddress as Address });
const txHash = await signAndSend(jwt, {
  to: USDC_ADDRESS as Address,
  data: encodeFunctionData({
    abi: usdcAbi,
    functionName: "transfer",
    args: [BRIDGE_ADDRESS, amount],
  }),
  chainId: arbitrum.id,
  nonce,
});

console.log("Deposit tx:", txHash);
// Funds arrive on Hyperliquid within ~1 minute
The minimum deposit is 5 USDC. Deposits below this amount are not credited and cannot be recovered.

Testnet Faucet

Once a wallet has been activated with a mainnet deposit of at least $5 USDC, you can claim 1,000 mock USDC on Hyperliquid testnet. This is a one-time claim per address.
TypeScript
const response = await fetch("https://api.hyperliquid-testnet.xyz/info", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    type: "claimDrip",
    user: eoaAddress,
  }),
});

const result = await response.json();
console.log("Faucet claim:", result);
After claiming, switch to testnet clients for development and testing.

Withdrawing USDC

To withdraw USDC from Hyperliquid back to Arbitrum, use the SDK’s withdraw3 method. The withdrawal is signed on Hyperliquid and processed by validators — no Arbitrum transaction is needed from the user.
TypeScript
// Withdraw 10 USDC back to Arbitrum
await exchange.withdraw3({
  destination: eoaAddress,
  amount: "10",
});

// Funds arrive on Arbitrum within ~3-4 minutes

Reading Market Data

Use the InfoClient to fetch prices, order books, and account state. These calls are read-only and don’t require signing.
TypeScript
// Get all mid prices
const mids = await info.allMids();
console.log("BTC:", mids["BTC"], "ETH:", mids["ETH"]);

// Get L2 order book for BTC
const book = await info.l2Book({ coin: "BTC", nSigFigs: 3 });
console.log("Best bid:", book.levels[0][0]); // { px, sz, n }
console.log("Best ask:", book.levels[1][0]);

// Get user's positions and margin
const state = await info.clearinghouseState({ user: eoaAddress });
console.log("Account value:", state.marginSummary.accountValue);
console.log("Positions:", state.assetPositions);

Placing Orders

The ExchangeClient handles nonce generation and EIP-712 signing automatically.

Limit Order

Place a Good-Til-Canceled limit order at a specific price.
TypeScript
const result = await exchange.order({
  orders: [{
    a: 0,           // Asset index (0 = BTC)
    b: true,        // Buy (true = long, false = short)
    p: "95000",     // Limit price
    s: "0.01",      // Size in base currency
    r: false,       // Not reduce-only
    t: { limit: { tif: "Gtc" } },
  }],
  grouping: "na",
});

console.log("Order status:", result.response.data.statuses);
// [{ resting: { oid: 12345 } }]

Market Order

Hyperliquid doesn’t have a native market order type. Use an Immediate-or-Cancel (IOC) limit order with a slippage price.
TypeScript
// Get current price for slippage calculation
const mids = await info.allMids();
const currentPrice = parseFloat(mids["ETH"]);
const slippagePrice = (currentPrice * 1.01).toFixed(1); // +1% for buys

const result = await exchange.order({
  orders: [{
    a: 4,                    // Asset index (4 = ETH)
    b: true,                 // Buy
    p: slippagePrice,        // Max price willing to pay
    s: "0.1",                // Size
    r: false,
    t: { limit: { tif: "Ioc" } },
  }],
  grouping: "na",
});

console.log("Fill:", result.response.data.statuses);
// [{ filled: { totalSz: "0.1", avgPx: "3456.78" } }]
For market buys, set the slippage price above the current mid. For market sells, set it below. The unfilled portion of an IOC order is automatically canceled.

Canceling Orders

TypeScript
// Cancel by order ID
await exchange.cancel({
  cancels: [
    { a: 0, o: 12345 }, // Cancel order 12345 on BTC
  ],
});

// Cancel by client order ID
await exchange.cancelByCloid({
  cancels: [{ asset: 0, cloid: "0x0001" }],
});

Updating Leverage

TypeScript
await exchange.updateLeverage({
  asset: 0,         // BTC
  isCross: true,    // Cross-margin mode
  leverage: 10,
});

Switching to Production

To move from testnet to mainnet, remove the isTestnet flag from the transport. The deposit and withdrawal code already uses mainnet addresses.
TypeScript
// Mainnet transport (default)
const transport = new HttpTransport();

const info = new InfoClient({ transport });
const exchange = new ExchangeClient({ transport, wallet: teeAccount });
Asset indices can change between testnet and mainnet. In production, use info.meta() to look up the correct index for each asset dynamically rather than hardcoding values.

Key Dependencies

PackagePurpose
@nktkas/hyperliquidTypeScript SDK for Hyperliquid exchange API
viemEthereum client, account utilities, and typed data hashing

TEE Endpoints Used

EndpointPurpose
POST /v1/walletGet or create an EOA wallet
POST /v1/wallet/sign/dataSign a raw data hash (EIP-712 typed data hash)

Troubleshooting

Symptoms: Authentication errors when calling TEE endpoints.Solutions:
  • Verify the JWT token is valid and not expired
  • Check that X-Magic-Secret-Key matches your dashboard credentials
  • Ensure the OIDC Provider ID is correct
  • Confirm your domain is allowlisted in the Magic Dashboard
Symptoms: Hyperliquid rejects the order with a signature error.Solutions:
  • Verify the signHashViaTee function correctly reconstructs the signature from r, s, v components
  • Ensure the TEE account address matches the address that deposited USDC on Hyperliquid
  • Check that serializeSignature is imported from viem
Symptoms: Order rejected due to insufficient margin or balance.Solutions:
  • Deposit USDC to Hyperliquid using the bridge contract (see Depositing USDC above)
  • Check available margin with info.clearinghouseState({ user: address })
  • Ensure at least 5 USDC was deposited (minimum)
  • Reduce order size or increase leverage
Symptoms: Order rejected because the asset index is invalid.Solutions:
  • Use info.meta() to look up the correct asset index for each coin
  • Asset indices may differ between testnet and mainnet
  • Common indices: 0 = BTC, 4 = ETH (but always verify with meta())

Resources

Express API Docs

Learn about Magic’s Express Server Wallet API

Hyperliquid Documentation

Official Hyperliquid protocol documentation

@nktkas/hyperliquid SDK

TypeScript SDK with full API coverage

Hyperliquid Testnet

Testnet UI for deposits and testing