← All articles

How to build a Hyperliquid trading bot without keeping the private key on your server

Let a Lit Action sign the bot's orders with a Lit wallet whose key stays in a secure enclave. Your main Hyperliquid account approves that wallet as an API wallet, and the action checks every order against limits written into its code. Here is the code.

Lit Protocol

To run a Hyperliquid trading bot without keeping a private key on your server, have a Lit Action sign the bot's orders with a Lit wallet. The wallet's key is created inside a secure enclave and stays there. Your main Hyperliquid account approves the wallet's address as an API wallet. A Lit Action, which is a JavaScript function that runs in the same enclave, checks every order against limits written into its code before it signs. Your server holds a Lit usage key that can only run that action with that wallet.

Why an API wallet

Hyperliquid lets your main account approve a separate API wallet to sign on its behalf. The API wallet holds no funds of its own, but it can trade the funds in your main account. Hyperliquid tracks nonces separately for each API wallet, and you can give each one an expiry of up to 180 days. So the bot only needs the API wallet's key, and your main key can stay on a machine you trust.

Create the Lit wallet

Create an account at the Lit dashboard by clicking Create an account. Then create a wallet. The response is the wallet's address, which is also the address you will approve on Hyperliquid.

LIT=https://api.chipotle.litprotocol.com/core/v1
curl -s -X POST "$LIT/create_wallet" -H "X-Api-Key: $ACCOUNT_KEY"
# {"wallet_address":"0x..."}

The action

This action signs a single BTC limit order, and only up to 0.01 BTC per order.

// hl-bot.js
import { encode } from "@msgpack/msgpack@3.1.2";

const PKP = "0xYourLitWallet...";  // the wallet you created above
const IS_MAINNET = false;          // start on testnet
const BTC = IS_MAINNET ? 0 : 3;    // asset indexes differ between networks; check meta.universe
const MAX_SIZE = 0.01;             // the most BTC in one order
const isDecimal = v => typeof v === "string" && /^\d+(\.\d+)?$/.test(v);

async function main({ order, nonce }) {
  if (!Number.isSafeInteger(nonce) || nonce <= 0) throw Error("invalid nonce");
  if (order.a !== BTC) return { signed: false, reason: "market not allowed" };
  if (!isDecimal(order.p) || !isDecimal(order.s)) return { signed: false, reason: "price and size must be decimal strings" };
  if (!(Number(order.s) > 0 && Number(order.s) <= MAX_SIZE)) return { signed: false, reason: "size not allowed" };
  if (typeof order.b !== "boolean" || typeof order.r !== "boolean") return { signed: false, reason: "b and r must be true or false" };
  if (!["Gtc", "Ioc", "Alo"].includes(order.t?.limit?.tif)) return { signed: false, reason: "only limit orders" };

  // Field order matters, because Hyperliquid hashes the msgpack encoding of the action.
  const o = { a: order.a, b: order.b, p: order.p, s: order.s, r: order.r, t: { limit: { tif: order.t.limit.tif } } };
  const action = { type: "order", orders: [o], grouping: "na" };

  const suffix = new Uint8Array(9); // nonce as uint64, then a null vault marker
  new DataView(suffix.buffer).setBigUint64(0, BigInt(nonce), false);
  const connectionId = ethers.utils.keccak256(ethers.utils.concat([encode(action), suffix]));

  const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId: PKP }));
  const sig = ethers.utils.splitSignature(await wallet._signTypedData(
    { name: "Exchange", version: "1", chainId: 1337, verifyingContract: ethers.constants.AddressZero },
    { Agent: [{ name: "source", type: "string" }, { name: "connectionId", type: "bytes32" }] },
    { source: IS_MAINNET ? "a" : "b", connectionId },
  ));
  return { signed: true, action, nonce, signature: { r: sig.r, s: sig.s, v: sig.v } };
}

The signing steps follow Hyperliquid's official Python SDK. We checked that this action produces the same signature as the SDK for the same key, order, and nonce. The limits are constants in the code, so the bot's server cannot change them.

The action asks for the key of your Lit wallet, and Lit only hands it over when the wallet and this action's code are in the same group in your account. Put both in a group, and make a usage key that can only run that group. The post on adding wallets to your app with an API shows each of those calls. New permissions take a short time to start working, so retry the first call until it succeeds. Do not turn on All actions permitted for that group, because then any code your usage key sends could use the wallet.

You can add more checks before the signature. A useful one is a price band, where the action fetches the mid price from Hyperliquid's info API and refuses orders far from it. Each extra fetch adds time, so add the checks that protect you most.

Approve the wallet on Hyperliquid

Approve the Lit wallet's address as an API wallet from your main account. Do this on a machine you trust, not on the bot's server. Hyperliquid's Python SDK has a helper that signs the approval for any address.

import time, eth_account
from hyperliquid.exchange import Exchange
from hyperliquid.utils import constants
from hyperliquid.utils.signing import sign_agent

main_wallet = eth_account.Account.from_key(MAIN_KEY)
exchange = Exchange(main_wallet, constants.TESTNET_API_URL)
nonce = int(time.time() * 1000)
action = {"type": "approveAgent", "agentAddress": "0xYourLitWallet...", "agentName": "lit-bot", "nonce": nonce}
signature = sign_agent(main_wallet, action, False)  # False means testnet
print(exchange._post_action(action, signature, nonce))

To set an expiry, add it to the name, as in "lit-bot valid_until 1798761600000", with a time up to 180 days ahead in milliseconds. An account can have one unnamed API wallet and three named ones. Do not approve an address again after you remove it or after it expires, because Hyperliquid warns that old signed actions can be replayed once its nonces are pruned. When the expiry comes, create a new Lit wallet, put its address in the action, and approve the new address.

Send orders from the bot

The bot asks Lit for a signature and sends the signed action to Hyperliquid's exchange endpoint. Hyperliquid recommends using the current time in milliseconds as the nonce. If the bot can send two orders in the same millisecond, keep a counter that never goes backward.

let lastNonce = 0;
const nextNonce = () => (lastNonce = Math.max(Date.now(), lastNonce + 1));

const nonce = nextNonce();
const order = { a: 3, b: true, p: "81000", s: "0.001", r: false, t: { limit: { tif: "Ioc" } } }; // BTC on testnet

const litRes = await fetch("https://api.chipotle.litprotocol.com/core/v1/lit_action", {
  method: "POST",
  headers: { "X-Api-Key": process.env.LIT_USAGE_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ code: HL_BOT_CODE, js_params: { order, nonce } }),
});
const lit = await litRes.json();
if (!litRes.ok) throw new Error(typeof lit === "string" ? lit : lit.message ?? JSON.stringify(lit));
if (!lit.response.signed) throw new Error(lit.response.reason);

const { action, signature } = lit.response;
const res = await fetch("https://api.hyperliquid-testnet.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action, nonce, signature, vaultAddress: null }),
}).then(r => r.json());
if (res.status !== "ok" || res.response?.data?.statuses?.[0]?.error) console.error(res);

HL_BOT_CODE is the source of hl-bot.js, read from the file, and the price should be near the current market. Hyperliquid reports order errors inside a normal response, which is why the last line checks the status. When you move to mainnet, set IS_MAINNET to true, approve the wallet on mainnet, and send to https://api.hyperliquid.xyz/exchange.

For take-profit and stop-loss, change the action to accept the entry order and two trigger orders, check the trigger fields too, and sign them as one order action with grouping normalTpsl.

Changing the bot

Because the key belongs to the Lit wallet and not to the code, the address stays the same when you change the action. Add the new version's CID to the group with add_action_to_group, and remove the old one with remove_action_from_group, which takes the hashed CID from list_actions. There is nothing to approve again on Hyperliquid until the API wallet expires.

What the setup protects

Your server never holds a key that can sign on Hyperliquid. It holds a Lit usage key that can run this action with this wallet. If someone steals the usage key, they can place orders that pass your limits until you delete the key, so keep the limits tight and the account's balance at a level you are comfortable trading. Lit's servers cache permissions for a short time, so a deleted usage key can keep working briefly after you delete it. Your main Hyperliquid key, which approves API wallets, stays off the server entirely.

In our signing latency benchmark on Lit's staging environment, a signed order came back in a median of about 140 milliseconds. That test signed with the action's own key rather than a Lit wallet, which skips the wallet permission check, so measure your own setup before you rely on a number.

Get started

To create an account, go to the Lit dashboard and click Create an account. The Lit Actions runtime and package imports are documented at developer.litprotocol.com/lit-actions.