@locker-protocol/agent-wallet-hyperliquid-signer
v2.5.0
Published
Your private key is nowhere: not on our servers, not on the agent's machine, not with anyone. The agent trades. It holds nothing. Hyperliquid signing, orders and account reads for agents whose keys stay in Locker Vault.
Maintainers
Readme
@locker-protocol/agent-wallet-hyperliquid-signer
Your private key is nowhere: not on our servers, not on the agent's machine, not with anyone.
The agent trades. It holds nothing.
- Where the key is: the account's private key stays in Locker Vault, an offline app on a phone or tablet. This package never sees it.
- Who can withdraw: only the vault, by a QR code a person scans and signs. Hyperliquid refuses the agent key this package signs orders with every withdrawal and every send; the one movement of funds it accepts from that key is a deposit into a Hyperliquid vault (measured on mainnet, 2026-09-28).
- What goes where: requests go to
api.hyperliquid.xyzonly. No server of ours, no account, no telemetry.
Documentation: doc.lockerprotocol.com/agent-wallet: install, the setup in steps, how it trades and every command. Website: hyperagentictrader.com.
Hyperliquid signing, order building and account reads for programs and AI agents that trade perps while the keys stay offline. It is the engine under the lpa command (@locker-protocol/agent-wallet-hyperliquid-trader), usable on its own.
This page is the complete reference: how it works, a full example, then every export with its signature.
Contents
How it works
Two keys, two paths. The agent key trades from your program; everything that moves funds or grants a right goes to the phone.
your program
|
| reads: markets, books, candles, accounts,
| positions, orders (public, no key) ------------------------> api.hyperliquid.xyz/info
|
| trading: placeHlOrder, setHlPositionTpsl, cancelHlOrder,
| closeHlPosition
| signed with the AGENT key, through the key store
| you install (useAgentKeyStore) ------------------------> api.hyperliquid.xyz/exchange
|
| funds and rights: buildApproveAgentPayload,
| buildWithdrawTypedPayload, buildSendAssetTypedPayload ...
| plain EIP-712 --> @locker-protocol/agent-wallet-vault --> QR --> Locker Vault
| (phone signs)
| the signature --> userSignedExchangeBody --> postHlExchange --> /exchange
|
| deposit: buildDepositCall --> a USDC transfer the vault signs --> Arbitrum One
|
| live: createHlStream -----------------------------------> wss://api.hyperliquid.xyz/wsNothing here holds the account's key: approving an agent, withdrawing, sending and moving margin between dexes are payloads for the vault. The agent key is made here, sealed under a password when stored, and handed to the signing path through a key store that refuses any other account's orders, an expired agent, and (optionally) any action outside a list you give it.
What an agent key can and cannot do
An agent key is approved once by the vault and expires on its own. Measured on Hyperliquid mainnet on 2026-09-28, with an approved agent key:
| Action | Signed by the agent key |
|---|---|
| Place, modify and cancel orders, set leverage | accepted |
| withdraw3, usdSend, spotSend, sendAsset, usdClassTransfer | refused |
| approveAgent (approve another agent), approveBuilderFee | refused |
| vaultTransfer (deposit the account into a vault) | accepted |
So a stolen agent key cannot withdraw, but it can lose money by trading or by depositing into a vault run by someone else. Use a dedicated account holding only what you are ready to risk, and sign a closed list of trading actions (memoryAgentKeyStore's actions option, below). The Locker CLI does exactly that.
Install
npm install @locker-protocol/agent-wallet-hyperliquid-signerNode 22 or later. ESM and CommonJS, with TypeScript declarations. One dependency: ethers.
A full example
import {
getHlMarkets, getHlL2Book, meetsMinOrderUsd,
createAgentKey, sealAgentKey, openAgentKey, buildApproveAgentPayload,
memoryAgentKeyStore, useAgentKeyStore, probeAgentApproved,
placeHlOrder, getHlPositions, setHlPositionTpsl, getHlOpenOrders, cancelHlOrder, closeHlPosition,
} from '@locker-protocol/agent-wallet-hyperliquid-signer';
const account = '0x3f5CE5FBFe3E9af3971dD833D26bA9b5C936f0bE'; // the vault account
// 1. Read the market: no key.
const eth = (await getHlMarkets()).find((m) => m.name === 'ETH')!;
const book = await getHlL2Book('ETH', 5); // { bids, asks, time }, best level first
console.log(eth.markPx, book.bids[0].px, book.asks[0].px);
console.log(meetsMinOrderUsd(Number(eth.markPx), 0.03)); // true: above Hyperliquid's $10
// 2. A new agent key, sealed for storage, and its approval for the vault to sign.
const agent = createAgentKey({ base: 'my-bot', validityMs: 7 * 86_400_000 });
const sealed = await sealAgentKey({ privateKey: agent.privateKey, user: account, validUntil: agent.validUntil, password });
const approval = buildApproveAgentPayload(agent.address, agent.agentName, Date.now());
// ... show `approval` to Locker Vault with @locker-protocol/agent-wallet-vault, post its answer
// with userSignedExchangeBody + postHlExchange, then check Hyperliquid lists the agent:
console.log(await probeAgentApproved(account, agent.address)); // true, false, or null (cannot tell)
// 3. Install the key: orders for `account` only, trading actions only.
const store = memoryAgentKeyStore(Date.now, {
actions: ['order', 'cancel', 'cancelByCloid', 'modify', 'batchModify', 'updateLeverage', 'updateIsolatedMargin', 'scheduleCancel', 'setReferrer'],
});
store.add({ user: account, privateKey: (await openAgentKey(sealed, password))!, validUntil: agent.validUntil });
useAgentKeyStore(store);
// 4. Trade.
const r = await placeHlOrder({ user: account, coin: 'ETH', isBuy: true, size: '0.03', leverage: 3, takeProfitPx: '2750', stopLossPx: '2600' });
console.log(r); // { oid: 77738308, filled: true, filledSz: 0.03, avgPx: 2702.4 }
console.log(await getHlPositions(account)); // [{ coin: 'ETH', szi: '0.03', entryPx: '2668.9', ... }]
await setHlPositionTpsl({ user: account, coin: 'ETH', takeProfitPx: '2800' });
for (const o of await getHlOpenOrders(account)) await cancelHlOrder({ user: account, coin: o.coin, oid: o.oid });
await closeHlPosition({ user: account, coin: 'ETH' }); // 1: positions closed
// 5. Done: zero the key in memory.
store.clear();
useAgentKeyStore(null);Reference
Every read goes to https://api.hyperliquid.xyz/info and is memoized for a few seconds (5 s for books and rosters, longer for lists that only grow); invalidateHlInfoMemo() drops the memo, and the order functions call it after they act. Prices, sizes and balances come back as Hyperliquid's decimal strings, never floats, unless the name says otherwise. A market on a HIP-3 dex is named dex:TICKER (xyz:GOLD); the main dex is ''.
Markets and books
| Export | Signature | What it does |
|---|---|---|
| getHlMarkets | (dex?: string) => Promise<HlMarket[]> | the roster of one dex with live context: name, dex, markPx, midPx (null when halted), oraclePx, prevDayPx, funding (hourly), openInterest, dayNtlVlm, maxLeverage, szDecimals |
| getHlMarketsMerged | () => Promise<HlMarket[]> | the main dex and every HIP-3 dex in one list; one builder dex failing never sinks the rest |
| getHlMarketsFor | (coins: string[]) => Promise<HlMarket[]> | the markets of exactly the dexes these coins live on |
| getPerpDexs | () => Promise<HlPerpDex[]> | the HIP-3 dexes: name, fullName, assetOffset |
| getHlL2Book | (coin: string, depth = 8) => Promise<HlBook> | the book around the spread: bids and asks as { px, sz, n }, best first, and time; a halted or one-sided book is refused |
| getHlCandles | (coin: string, interval: string, count = 60) => Promise<HlCandle[]> | the last candles, { t, o, h, l, c, v }; interval is 1m, 5m, 15m, 1h, 4h or 1d |
| getHlMid | (coin: string) => Promise<number> | the mid price |
| getHlAssetInfo | (coin: string) => Promise<HlAssetInfo> | what the order wire needs: assetIndex, szDecimals, maxLeverage, onlyIsolated, marginTiers |
| getHlDexCollateralToken | (dex: string) => Promise<number> | the token a dex margins in; 0 is USDC |
| coinDex | (coin: string) => string | the dex a coin routes to: coinDex('xyz:GOLD') is 'xyz', coinDex('BTC') is '' |
| infoQuery | (body: object, ttlMs?: number) => Promise<unknown> | any /info request, memoized |
| invalidateHlInfoMemo | () => void | forget every memoized answer |
const gold = await getHlMarkets('xyz'); // names come back as 'xyz:GOLD', ...
const candles = await getHlCandles('BTC', '1h', 24); // the last day of hourly candles
const info = await getHlAssetInfo('ETH'); // { assetIndex: 1, szDecimals: 4, maxLeverage: 25, ... }Accounts
Public reads of any address.
| Export | Signature | What it does |
|---|---|---|
| getHlAccount | (address: string, dex?: string) => Promise<HlAccount> | equity, withdrawable, unrealizedPnl (number), positionCount, unified |
| getHlPositions | (user: string, dex?: string) => Promise<HlPositionRow[]> | open positions of one dex: coin, szi (signed), entryPx, positionValue, unrealizedPnl, liquidationPx, leverage, marginUsed |
| getHlPositionsAll | (user: string) => Promise<HlPositionRow[]> | the main dex and every funded HIP-3 dex |
| getHlOpenOrders | (user: string, dex?: string) => Promise<HlOpenOrderRow[]> | open orders of one dex, triggers included: coin, oid, side (B or A), limitPx, sz, orderType, isTrigger, triggerPx, isPositionTpsl |
| getHlOpenOrdersAll | (user: string) => Promise<HlOpenOrderRow[]> | across every funded dex |
| getHlSpotBalances | (address: string) => Promise<HlSpotBalance[]> | spot balances: coin, token, total, hold, available |
| getHlPnl | (address: string) => Promise<HlPnlWindows> | perps PnL over day, week, month, allTime (a window Hyperliquid does not return is absent) |
| parseHlPortfolioPnl | (body: unknown) => HlPnlWindows | the same, from a portfolio answer you already have |
| getHlUserFees | (address: string) => Promise<HlUserFees> | the account's own takerRate and makerRate, or null |
| noteFundedDex | (user: string, dex: string) => Promise<void> | remember at once that a dex was just funded, so its positions are read |
| clearFundedDexCache | () => void | forget every funded-dex set |
| subDecimalStrings | (a: string, b: string) => string | exact subtraction of two non-negative decimal strings |
const a = await getHlAccount(account); // { equity: '55.78', withdrawable: '55.78', ... }
const everywhere = await getHlPositionsAll(account);
const fees = await getHlUserFees(account); // { takerRate: 0.00045, makerRate: 0.00015 }Agent keys
| Export | Signature | What it does |
|---|---|---|
| createAgentKey | ({ base, validityMs, now? }) => NewAgentKey | a fresh random key: address, privateKey, agentName (<base> valid_until <ms>), validUntil; validityMs is capped at Hyperliquid's ceiling |
| agentNameFor | (base: string, validUntil: number) => string | the name an agent is approved under |
| parseAgentValidUntil | (agentName: string) => number | the expiry a name carries, or null |
| sealAgentKey | ({ privateKey, user, validUntil, password }) => Promise<SealedAgentKey> | the key encrypted for storage, bound to one account and dated; a password is required |
| openAgentKey | (sealed: SealedAgentKey, password: string) => Promise<string> | the private key back, or null for a wrong password; a record that does not decrypt to its own address is refused |
| needsReseal | (sealed: SealedAgentKey) => boolean | the record was sealed with fewer PBKDF2 rounds than today's: seal it again while the password is in hand |
| memoryAgentKeyStore | (now?, { actions? }) => AgentKeyStore & { add, clear } | a key store in memory, one agent per account; refuses another account's orders, an expired agent, and any action outside actions when given; clear() zeroes the keys |
| useAgentKeyStore | (store: AgentKeyStore \| null) => void | installs the store every order signs through (null removes it) |
| withAgentKey | (user, fn, action?) => Promise<T> | runs fn with the agent key of user, through the installed store |
| probeAgentApproved | (user: string, agentAddress: string) => Promise<boolean> | is the agent approved for the account: true, false, or null when Hyperliquid cannot tell |
| AGENT_NAME_BASE_MAX | 16 | the longest base Hyperliquid takes for a name |
| AGENT_MAX_VALIDITY_MS | number | Hyperliquid's ceiling on an agent's validity |
| PBKDF2_ITERATIONS | 600000 | the rounds that wrap a sealed key |
const key = createAgentKey({ base: 'lpa', validityMs: 30 * 86_400_000 });
const sealed = await sealAgentKey({ privateKey: key.privateKey, user: account, validUntil: key.validUntil, password });
// store `sealed` as JSON; later:
const privateKey = await openAgentKey(sealed, password); // null: wrong passwordOrders
Signed with the agent key of user through the installed store, and posted. Each throws Hyperliquid's refusal verbatim.
| Export | Signature | What it does |
|---|---|---|
| placeHlOrder | (input: HlOrderInput) => Promise<HlOrderResult> | one order, and its TP/SL when given; answers { oid?, filled?, filledSz?, avgPx?, tpslError? }: oid, the order on Hyperliquid, whether it rests on the book or executed at once, filledSz and avgPx when it executed at once (as Hyperliquid answers them) |
| setHlPositionTpsl | ({ user, coin, takeProfitPx?, stopLossPx? }) => Promise<void> | TP/SL on a position already open; refused when nothing is open |
| cancelHlOrder | ({ user, coin, oid }) => Promise<void> | cancels an order |
| closeHlPosition | ({ user, coin?, slippage? }) => Promise<number> | market-closes one position, or every position of every funded dex without coin; answers how many |
| setHlReferrer | (user: string) => Promise<void> | sets the referral code once; an account already referred keeps its referrer |
| postL1 | (user: string, action: object) => Promise<unknown> | signs any L1 action with the agent key and posts it |
| orderWire, orderAction | | the wire of one order and of a batch, as Hyperliquid hashes them |
| nextNonce | () => number | a millisecond nonce, strictly increasing in this process |
| MIN_ORDER_USD | 10 | Hyperliquid's minimum notional |
| meetsMinOrderUsd | (px: number, size: number) => boolean | is the order worth the minimum, at the price it signs with |
HlOrderInput:
| Field | Type | Meaning |
|---|---|---|
| user | string | the account |
| coin | string | the market |
| isBuy | boolean | long (buy) or short (sell) |
| size | string | in the base asset |
| limitPx | string | a limit price; absent, a market order at the mid plus or minus slippage (IOC) |
| tif | 'Gtc', 'Ioc' or 'Alo' | the validity of a limit: Gtc (the default) rests until it fills or is cancelled, Ioc fills what it can at once and the rest is cancelled, Alo is posted to the book only and refused by Hyperliquid if it would fill at once; refused without limitPx |
| slippage | number | for a market order, as a fraction; default 0.03 |
| leverage | number | set on the market before the order (capped at its maximum); Hyperliquid sets it for the whole market, a position open on it included |
| marginMode | 'cross' or 'isolated' | the margin that leverage is set in; absent, isolated on a market that takes no other and cross elsewhere; cross on a market that takes isolated only is refused before anything is signed; it goes with leverage |
| reduceOnly | boolean | only reduce a position |
| takeProfitPx, stopLossPx | string | TP/SL triggers placed with the order |
await placeHlOrder({ user: account, coin: 'BTC', isBuy: false, size: '0.001', limitPx: '84000' });
await placeHlOrder({ user: account, coin: 'xyz:GOLD', isBuy: true, size: '0.01', leverage: 2, slippage: 0.01 });
await closeHlPosition({ user: account }); // every position, every funded dexPrices and sizes
Hyperliquid refuses off-tick prices and sizes. These round the way it requires.
| Export | Signature | What it does |
|---|---|---|
| roundPx | (px: number, szDecimals: number) => number | 5 significant figures, then at most 6 - szDecimals decimals |
| roundLimitPx | (px: number, szDecimals: number, isBuy: boolean) => number | a limit price the wire takes, rounded toward the user; NaN for an input that is not a positive number |
| hlPriceDecimals | (px: number, szDecimals: number) => number | how many decimals a price may carry |
| roundSize | (sz: number, szDecimals: number) => number | a size rounded down, never up |
| slippagePrice | (mid: number, isBuy: boolean, slippage: number, szDecimals: number) => number | the price of a market order |
| floatToWire | (x: number) => string | a number as the wire writes it |
roundPx(2669.4567, 4); // 2669.5
slippagePrice(2669.5, true, 0.03, 4); // 2749.6Signing L1 actions
What postL1 does under the hood, for actions you build yourself.
| Export | Signature | What it does |
|---|---|---|
| signL1Action | (agentPrivKey, action, vaultAddress, nonce, expiresAfter, isMainnet = true) => HlSig | signs an action with the agent key; deterministic, no network |
| actionHash | (action, vaultAddress, nonce, expiresAfter) => string | the hash Hyperliquid signs over |
| msgpackEncode | (action: object) => Uint8Array | the msgpack encoding, key order kept |
Payloads the vault signs
Plain EIP-712 payloads, { domain, types, primaryType, message }, signed on Arbitrum's chain id. Show them to Locker Vault with @locker-protocol/agent-wallet-vault, then post the answer.
| Export | Signature | What the vault signs |
|---|---|---|
| buildApproveAgentPayload | (agentAddress, agentName, nonce) | lets an agent key trade for the account; approving another address under the same name replaces the agent |
| buildApproveBuilderFeePayload | (builder, maxFeeRate, nonce) | lets orders carry a builder fee up to that rate |
| buildWithdrawTypedPayload | (destination, amount, time) | a withdrawal to Arbitrum (withdraw3) |
| buildSpotSendTypedPayload | (destination, token, amount, time) | a spot send |
| buildSendAssetTypedPayload | (destination, sourceDex, destinationDex, amount, nonce) | USDC margin between dexes; '' is the main dex |
| userSignedExchangeBody | (payload, signature, signer) => body | the /exchange body; the signature must recover to signer, or it is refused here |
| postHlExchange | (payload: object) => Promise<unknown> | posts to /exchange; Hyperliquid's refusal is thrown verbatim |
| ARBITRUM_CHAIN_ID | '0xa4b1' | the signatureChainId of these payloads |
import { buildSignRequest, DataType } from '@locker-protocol/agent-wallet-vault';
import { hexlify, toUtf8Bytes } from 'ethers';
const payload = buildWithdrawTypedPayload(account, '25', Date.now());
const req = buildSignRequest({
signDataHex: hexlify(toUtf8Bytes(JSON.stringify(payload))), // the typed data, as JSON bytes
dataType: DataType.typedData,
chainId: payload.domain.chainId,
address: account,
origin: 'My bot',
});
// ... req.frames on screen, the phone signs, read the answer (see the vault package) ...
await postHlExchange(userSignedExchangeBody(payload, signature, account));Deposits and withdrawals
| Export | Signature | What it does |
|---|---|---|
| buildDepositCall | (amount: string) => { chainId, to, data, value } | the deposit: USDC.transfer(HL_BRIDGE, amount) on Arbitrum One, for the vault to sign as a transaction; under the bridge minimum it is refused |
| parseUsdcAmount | (amount: string) => bigint | a USDC amount on Arbitrum (6 decimals) |
| parseCoreUsdcAmount | (amount: string) => string | a USDC amount as Hyperliquid takes it, normalized; what is signed is what is shown |
| checkWithdrawAmount | (amount: string, withdrawable: string) => string | above the fee, within what is withdrawable, normalized |
| HL_BRIDGE | 0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7 | the Hyperliquid bridge on Arbitrum |
| USDC_ARBITRUM | 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 | native USDC on Arbitrum One |
| MIN_DEPOSIT_USDC | 5 | below this, the bridge loses the deposit |
| WITHDRAW_FEE_USDC | 1 | Hyperliquid's flat withdrawal fee |
| ARBITRUM_USDC_DECIMALS, CORE_USDC_DECIMALS | 6, 8 | USDC decimals on Arbitrum and on Hyperliquid |
const call = buildDepositCall('20'); // { chainId: '0xa4b1', to: USDC_ARBITRUM, data: '0xa9059cbb...', value: '0x0' }
// build an EIP-1559 transaction from it (nonce, gas), have the vault sign it, send it to an Arbitrum nodeBuilder fee and referral
| Export | What it is |
|---|---|
| HL_BUILDER_ADDRESS | Locker's builder address |
| HL_BUILDER_MAX_FEE_RATE | '0.05%', the rate approveBuilderFee names |
| HL_BUILDER_FEE_TENTHS_BP | 50, the same in tenths of a basis point |
| HL_BUILDER_FEE_FRACTION | 0.0005, the same as a fraction of the notional |
| HL_REFERRAL_CODE | LOCKERPROTOCOL |
| hlFeeRail() | the builder rail, or null when unarmed |
| feeRiderFor(rail, approvedTenthsBp) | the fee an order carries given the account's approval: none without it |
| builderParam(user) | the fee an order of this account carries now, or null |
| builderCollectible() | can the builder collect fees now |
| getHlFeeStatus(user) | { armed, collectible, approved, referralCode, maxFeeRate } |
Live data
A WebSocket to wss://api.hyperliquid.xyz/ws that reconnects on its own and routes each message to its subscribers.
| Export | Signature | What it does |
|---|---|---|
| createHlStream | (opts?) => HlStream | the stream: subscribe(sub, onData) returns an unsubscribe function; onStatus(fn), status() (connecting, open, closed), close() |
| HL_WS_URL | 'wss://api.hyperliquid.xyz/ws' | |
| parseMids | (data) => Map<string, number> | allMids: perp mids by name |
| parseAssetCtx | (data) => HlLiveCtx | activeAssetCtx: mark, mid, oracle, funding, open interest, volume |
| parseCandle | (data) => HlLiveCandle | candle |
| parseBook | (data) => HlLiveBook | l2Book |
| parseTrades | (data) => HlLiveTrade[] | trades |
| parseUserFills | (data) => { user, snapshot, fills } | userFills: an account's executions, the first message being a snapshot |
| subscriptionKey, messageKey | | the routing keys of a subscription and of a message |
Subscriptions: { type: 'allMids', dex? }, { type: 'activeAssetCtx', coin }, { type: 'candle', coin, interval }, { type: 'l2Book', coin }, { type: 'trades', coin }, { type: 'userFills', user }. Options: url, retryMs, maxRetryMs, pingMs, maxMessageBytes, socket (a WebSocket factory, for tests).
const stream = createHlStream();
const stop = stream.subscribe({ type: 'l2Book', coin: 'ETH' }, (data) => {
const book = parseBook(data);
console.log(book.bids[0]?.px, book.asks[0]?.px);
});
stream.subscribe({ type: 'trades', coin: 'ETH' }, (data) => console.log(parseTrades(data)));
// later
stop();
stream.close();The userFills subscription sends a snapshot of recent executions when it opens and nothing for the time a dropped socket was down. What a feed missed is read back over REST:
| Export | Signature | What it does |
|---|---|---|
| getHlFillsSince | (user, from) => Promise<{ fills, complete }> | the executions of user from from (ms, included) on, oldest first, each once, read in pages of 2,000; complete is false when the reading stopped at HL_FILLS_MAX_PAGES pages. Never memoized. An account very active keeps only its recent history readable at Hyperliquid (about 21 hours for the busiest trader tried, on 2026-09-30) |
| HL_FILLS_PAGE, HL_FILLS_MAX_PAGES | 2000, 5 | the size of a page, and the most pages one reading asks (each weighs about 100 of the 1,200 an IP may spend on /info in a minute) |
const { fills, complete } = await getHlFillsSince('0x5b5d51203a0f9079f8aeb098a6523a13f298c060', Date.now() - 3_600_000);
console.log(fills.length, complete);Fees
Orders carry a builder fee of 0.05 % to Locker once the account has approved it, and never otherwise: without the approval, orders go out without the fee, they are never blocked. The referral code LOCKERPROTOCOL is set once when the agent is activated; an account that already has a referrer keeps it.
Licence
LOCKER PROTOCOL PROPRIETARY NON-COMMERCIAL LICENSE. See LICENSE.
