Robinhood Chain is now live on Chainstack! Deploy reliable nodes for tokenized stocks today.    Start building
  • Agents
  • Pricing

Hyperliquid agent wallets and nonce state machine

Created Jul 29, 2026 Updated Jul 29, 2026
Article cover for the Hyperliquid Agent Wallets and Nonce State Machine deep-dive, showing an abstract teal graphic of an agent-authority signature envelope on a gradient background

In Part 4, we built and signed a HIP-3 deployment action. We passed the action to the SDK, received a signature, and continued into the market lifecycle.

That was enough for deployment. But it left the most important part of the authorization path hidden.

The signing helper sits on the most fragile boundary in a Hyperliquid integration.

An API wallet can sign without owning the funds. A sub-account can receive an order without having a private key. The vaultAddress is not an unsigned routing hint. A nonce is not simply the previous Ethereum nonce plus one. Even two Python dictionaries containing the same fields can produce different signatures.

In this deep dive, we will strip away the helper and follow one order from a Python dictionary to Msgpack bytes, from those bytes to a Keccak hash, and from that hash to the EIP-712 signature HyperCore verifies.

Along the way, we will make four things explicit:

  • who actually signs an agent-wallet order
  • which account the signed action is allowed to mutate
  • why serialization is part of the security boundary
  • how Hyperliquid’s nonce model supports concurrent trading without becoming replayable

By the end, the signature will no longer look like a magic {r, s, v} object. It will be a concrete authority request whose exact bytes we can reproduce, mutate, recover, and test.

The Signature Helper Is Hiding an Authority System

Before looking at the bytes, we need to separate identities that are easy to collapse into the word “wallet.”

Four addresses can participate in one order

  • Master account: owns the funds and account configuration.
  • Signing key: produces the signature. It can belong to the master or to an approved API wallet.
  • Recovered signer: the address HyperCore derives from the signature and the payload it reconstructs.
  • Target account: the user, sub-account, or vault whose state the action attempts to change.

For an order signed directly by the master, these identities mostly collapse:

master account = signing key = recovered signer = target account

An API wallet separates custody from execution:

master account
    | 
    approves API wallet
            | 
            API wallet signs an L1 action
                    |
                    HyperCore recovers the API-wallet address
                            | 
                            authorization maps it back to the master

A sub-account adds one more target:

master account
    |
    approves API wallet
            | 
            API wallet signs
                    | 
                    vaultAddress selects the sub-account

The official documentation uses API wallet and agent wallet for the same concept. The agent is a signer, not the account whose balances we query. Account-state queries still use the master or sub-account address.

The API page makes that terminology explicit. Each row below is an approved agent: a name, a public signing address, an expiration time, and a control for revoking the authorization.

The Hyperliquid API page listing approved API wallets, also known as agent wallets.

If we query balances with the agent address, HyperCore usually returns an empty account. The agent holds authority, not the trading state.

“Which wallet placed this order?” is not precise enough. We need to ask which key signed, which address was recovered, which master approved it, and which signed target received the action.

Creating a key is not the same as approving an agent

Generating a private key locally only gives us a key pair:

agent = Account.create()
print(agent.address)

It does not create any authorization relationship inside HyperCore.

The master must approve that address with an approveAgent action. After the approval is accepted, the agent can sign supported L1 actions for the master. The agent still does not become the owner of the account.

The lifecycle is:

generate a new key
    -> derive the agent address
    -> master signs approveAgent
    -> submit the authorization
    -> read the authorization back
    -> agent signs L1 actions
    -> rotate to another new key

The frontend shows both sides of that boundary at once. Hyperliquid displays the generated API-wallet address, while the connected master wallet receives a typed-data signature request whose primary type is HyperliquidTransaction:ApproveAgent. The private key belongs only to the new agent and must be saved before authorization. It is redacted in the screenshot below.

Hyperliquid Authorize API Wallet dialog on the left showing wallet name my_api_wallet_4, generated agent address, and Days Valid set to MAX; on the right the connected master wallet displays an EIP-712 signature request with primary type HyperliquidTransaction:ApproveAgent

Now we can look at the two signatures involved in that flow.

Hyperliquid Uses Two Signing Schemes

Agent approval and agent execution are signed as different messages.

The master authorizes an API wallet with the readable, user-signed HyperliquidTransaction:ApproveAgent EIP-712 message. Orders, cancels, and many other exchange operations then use the compressed L1 signing scheme.

An L1 action follows this path:

action dictionary
    | Msgpack
action bytes
    + nonce
    + vault-address marker and optional address
    + optional expiration marker and timestamp
    | Keccak-256
connectionId
    |
{ source, connectionId }
    | EIP-712
{ r, s, v }

The small EIP-712 object at the end is called a phantom Agent. It is not the API-wallet authorization record. It is the typed envelope used to sign the hash of the L1 action.

A Hyperliquid L1 signature does not authorize a JSON object. It authorizes one exact byte sequence.

Setup once

The examples use Python 3.11+ and version 0.24.0 of the official Python SDK:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install "hyperliquid-python-sdk==0.24.0"

Each program below is complete. Save the code under the filename shown in its run command and execute it from the same folder.

Complete example: sign and recover both messages

The first program contains the whole path. It creates deterministic fixture keys, signs the master’s approveAgent message, signs an L1 order with the agent, and recovers both signers locally.

The keys are deliberately public test fixtures. Never fund them.

"""Sign and recover two Hyperliquid messages without submitting anything.

The private keys below are public deterministic fixtures. Never fund them.
"""

import copy
import json

from eth_account import Account
from hyperliquid.utils.signing import (
    action_hash,
    construct_phantom_agent,
    recover_agent_or_user_from_l1_action,
    recover_user_from_user_signed_action,
    sign_agent,
    sign_l1_action,
)


MASTER_KEY = "0x" + "11" * 32
AGENT_KEY = "0x" + "22" * 32
NONCE = 1_900_000_000_000

APPROVE_AGENT_TYPES = [
    {"name": "hyperliquidChain", "type": "string"},
    {"name": "agentAddress", "type": "address"},
    {"name": "agentName", "type": "string"},
    {"name": "nonce", "type": "uint64"},
]

master = Account.from_key(MASTER_KEY)
agent = Account.from_key(AGENT_KEY)

# The master authorizes the agent with a user-signed EIP-712 action.
approve_action = {
    "type": "approveAgent",
    "agentAddress": agent.address,
    "agentName": "part5-demo",
    "nonce": NONCE,
}
approve_signature = sign_agent(master, approve_action, is_mainnet=False)
recovered_master = recover_user_from_user_signed_action(
    copy.deepcopy(approve_action),
    approve_signature,
    APPROVE_AGENT_TYPES,
    "HyperliquidTransaction:ApproveAgent",
    is_mainnet=False,
)

# The agent signs an L1 order through the phantom Agent envelope.
order_action = {
    "type": "order",
    "orders": [
        {
            "a": 0,
            "b": True,
            "p": "100000",
            "s": "0.001",
            "r": False,
            "t": {"limit": {"tif": "Alo"}},
        }
    ],
    "grouping": "na",
}
connection_id = action_hash(
    order_action,
    vault_address=None,
    nonce=NONCE,
    expires_after=None,
)
phantom_agent = construct_phantom_agent(connection_id, is_mainnet=False)
order_signature = sign_l1_action(
    agent,
    order_action,
    active_pool=None,
    nonce=NONCE,
    expires_after=None,
    is_mainnet=False,
)
mainnet_signature = sign_l1_action(
    agent,
    order_action,
    active_pool=None,
    nonce=NONCE,
    expires_after=None,
    is_mainnet=True,
)
recovered_agent = recover_agent_or_user_from_l1_action(
    order_action,
    order_signature,
    active_pool=None,
    nonce=NONCE,
    expires_after=None,
    is_mainnet=False,
)

print(
    json.dumps(
        {
            "warning": "PUBLIC TEST KEYS — NEVER FUND",
            "approveAgent": {
                "master": master.address,
                "agent": agent.address,
                "recoveredSigner": recovered_master,
                "matchesMaster": recovered_master.lower() == master.address.lower(),
            },
            "l1Order": {
                "connectionId": "0x" + connection_id.hex(),
                "phantomSource": phantom_agent["source"],
                "recoveredSigner": recovered_agent,
                "matchesAgent": recovered_agent.lower() == agent.address.lower(),
                "networkSignaturesDiffer": order_signature != mainnet_signature,
            },
            "submitted": False,
        },
        indent=2,
    )
)

Run it:

python 01_sign_and_recover.py
Terminal output from 01_sign_and_recover.py showing matchesMaster true for the approveAgent recovery, matchesAgent true for the L1 order, phantomSource b confirming testnet envelope, networkSignaturesDiffer true, and submitted false

The output proves both authority transitions:

  • matchesMaster: true means the approval recovers the master
  • matchesAgent: true means the order recovers the agent
  • phantomSource: "b" identifies the testnet L1 signing envelope
  • networkSignaturesDiffer: true proves that changing the network source changes the signature
  • submitted: false confirms that the program stayed offline

The master did not sign the order. The agent did. HyperCore can recover that agent, look up its authorization, resolve the controlling master, and then validate the account targeted by the request.

Signed Fields That Look Like Metadata

The easiest signing bugs come from values that look unimportant to an application but are part of the signed preimage.

The connection ID commits to four values:

  1. the exact Msgpack serialization of the action
  2. the eight-byte big-endian nonce
  3. the optional target address
  4. the optional expiration timestamp

The next complete program changes one value at a time.

Complete example: mutate the signed preimage

"""Show which values change a Hyperliquid L1 action's connection ID."""

import json

from hyperliquid.utils.signing import action_hash


NONCE = 1_900_000_000_000
SUBACCOUNT_ADDRESS = "0x" + "33" * 20


def order_action(size: str = "0.001") -> dict:
    return {
        "type": "order",
        "orders": [
            {
                "a": 0,
                "b": True,
                "p": "100000",
                "s": size,
                "r": False,
                "t": {"limit": {"tif": "Alo"}},
            }
        ],
        "grouping": "na",
    }


def connection_id(
    action: dict,
    vault_address: str | None = None,
    expires_after: int | None = None,
) -> str:
    value = action_hash(
        action,
        vault_address=vault_address,
        nonce=NONCE,
        expires_after=expires_after,
    )
    return "0x" + value.hex()


canonical_action = order_action()
reordered_action = {
    "grouping": canonical_action["grouping"],
    "orders": canonical_action["orders"],
    "type": canonical_action["type"],
}

results = {
    "canonical": connection_id(canonical_action),
    "withSubaccountTarget": connection_id(
        canonical_action,
        vault_address=SUBACCOUNT_ADDRESS,
    ),
    "withExpiresAfter": connection_id(
        canonical_action,
        expires_after=NONCE + 30_000,
    ),
    "differentFieldOrder": connection_id(reordered_action),
    "sizeString0.0010": connection_id(order_action(size="0.0010")),
}

print(json.dumps(results, indent=2))
print("allConnectionIdsDifferent =", len(set(results.values())) == len(results))

Run it:

python 02_signed_preimage_mutations.py
Terminal output from 02_signed_preimage_mutations.py ending with allConnectionIdsDifferent equal to True, proving that field reorder, subaccount target, expiresAfter, and decimal-string formatting each produce a different signed commitment

The final line is:

allConnectionIdsDifferent = True

Every mutation created a new commitment.

Why each mutation matters

The reordered dictionary contains the same key-value relationships as the canonical dictionary. But Msgpack serializes map entries in insertion order, so it produces different bytes.

The same rule applies to decimal strings. "0.001" and "0.0010" may describe the same mathematical number, but they are different signed strings.

This is why signing code should not pass an action through arbitrary JSON transformations. Sorting fields, removing trailing zeroes, inserting defaults, or converting strings to floating-point values can silently change the signature domain.

The withSubaccountTarget result demonstrates that vaultAddress is not attached after signing. It is part of the commitment. An intermediary cannot redirect a valid master-account order to a sub-account or vault.

The withExpiresAfter result proves that the optional expiration is also signed. The API documentation adds two practical constraints:

  • user-signed actions such as a Core USDC transfer do not support expiresAfter
  • a stale expiresAfter rejection consumes five times the usual address-based rate limit

Expiration is a safety mechanism, but it is not a free cancellation mechanism.

Mainnet and testnet behave slightly differently. The connection ID is unchanged, but the phantom-agent source changes from "b" on testnet to "a" on mainnet. The first complete program proved that the resulting signatures are different.

Why local recovery can still mislead us

Recovering the expected address locally is necessary, but it is not sufficient.

If the recovery code repeats the same serialization bug as the signing code, both functions can agree with each other while disagreeing with HyperCore.

HyperCore reconstructs the protocol payload independently. When it reconstructs different bytes, it can recover an unexpected address and return an error such as:

L1 error: User or API Wallet 0x... does not exist.

The address can change when the malformed input changes, making the error look random.

The correct comparison is:

our serialized bytes
    versus
official SDK serialized bytes
    versus
the action and metadata actually submitted

Signer recovery tests the final cryptography. It does not prove that we signed the protocol’s intended message.

Nonces Are a Concurrency Primitive

Ethereum requires the next sequential transaction nonce for an address. That model is a poor fit for an order book where multiple workers may create orders and cancels concurrently.

One delayed request should not block every later request from the same signer.

Hyperliquid instead stores the 100 highest nonces per signer. A new nonce must:

  1. not have been used before
  2. be larger than the smallest nonce retained in the set
  3. fall inside the permitted window around the block timestamp

The documented window is:

(T - 2 days, T + 1 day)

where T is the block’s Unix timestamp in milliseconds.

This permits requests to arrive out of order while still preventing replay. But it moves the concurrency problem into the client.

Every worker using the same private key shares one nonce set, even when the actions target different sub-accounts:

agent key A -> master order
agent key A -> sub-account order
agent key A -> vault cancel

all three consume nonces from agent key A

The nonce belongs to the signer, not to the target account.

Why a millisecond timestamp is not enough

Using int(time.time() * 1000) directly fails when two workers sign during the same millisecond.

The third standalone program gives six workers the same clock value. It first uses the timestamp directly and then repeats the experiment with a locked monotonic allocator.

"""Compare timestamp-only nonces with a locked monotonic allocator."""

import json
import threading
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Callable


FIXED_TIME_MS = 1_900_000_000_000
WORKERS = 6


@dataclass
class MonotonicNonceAllocator:
    clock_ms: Callable[[], int]
    _last: int = 0
    _lock: threading.Lock = field(default_factory=threading.Lock)

    def next(self) -> int:
        with self._lock:
            now = self.clock_ms()
            self._last = max(now, self._last + 1)
            return self._last


def fixed_clock() -> int:
    return FIXED_TIME_MS


def run_workers(allocate: Callable[[], int]) -> list[int]:
    with ThreadPoolExecutor(max_workers=WORKERS) as executor:
        return list(executor.map(lambda _: allocate(), range(WORKERS)))


unsafe = run_workers(fixed_clock)
allocator = MonotonicNonceAllocator(fixed_clock)
safe = run_workers(allocator.next)

print(
    json.dumps(
        {
            "unsafeTimestampOnly": unsafe,
            "unsafeUniqueCount": len(set(unsafe)),
            "monotonicAllocatorSorted": sorted(safe),
            "safeUniqueCount": len(set(safe)),
        },
        indent=2,
    )
)

Run it:

python 03_nonce_allocator.py
Terminal output from 03_nonce_allocator.py showing six timestamp-only workers producing identical nonce 1900000000000 with unsafeUniqueCount 1, while the monotonic allocator produced six unique nonces from 1900000000000 to 1900000000005

All six timestamp-only workers produced the same nonce, so unsafeUniqueCount is 1. The monotonic allocator produced six unique values even though every worker observed the same clock tick.

For multiple processes or machines, an in-memory lock is not enough. We need one of three designs:

  • a shared atomic counter
  • a dedicated signing service
  • a separate API wallet for each execution process

The official documentation recommends using separate agent keys for separate processes. Because nonce state is tracked per signer, each agent becomes its own concurrency domain.

The agent-pruning replay trap

The most surprising rule appears during key rotation.

An API wallet and its nonce state may be pruned when:

  1. the agent is deregistered
  2. the agent expires
  3. the account that registered it no longer has funds

If the old nonce set is removed, previously consumed nonces may no longer be remembered. Re-registering the same agent address could make old signed actions replayable.

Rotate an agent by generating a completely new private key. Never reactivate an old agent address.

Key rotation is not only secret replacement. It is nonce-domain replacement.

Where an AI Agent Belongs

Once we separate intent from authority, the safe place for an AI agent becomes much clearer.

A production system should not distribute an agent private key to every strategy process:

market-data workers
    |
strategy or AI agent
    | unsigned intent
deterministic risk policy
    | validated Hyperliquid action
signer + nonce allocator
    | signed payload
submission worker
    |
HyperCore
    |
state reconciliation

The AI layer may propose an intent:

{
  "intent": "place_limit_order",
  "coin": "ETH",
  "side": "buy",
  "size": "0.01",
  "limitPrice": "1800",
  "timeInForce": "Alo"
}

It should not:

  • hold the private key
  • choose the final nonce
  • build arbitrary signed bytes
  • select an unrestricted target account
  • submit actions that bypass deterministic checks

The policy layer should validate the allowed action types, accounts, assets, order size, price deviation, reduceOnly requirements, expiration bounds, and network.

Only then should the signing service construct the canonical action, allocate a nonce, and sign it.

The model proposes intent. Deterministic code grants authority.

This boundary does not make the strategy correct. It limits what a wrong, compromised, or hallucinating strategy is able to authorize.

Summary

Hyperliquid’s agent-wallet model separates custody from high-frequency execution.

The master authorizes an agent through a readable user-signed action. The agent then signs compressed L1 actions without requiring the master wallet to approve every order. Sub-accounts and vaults remain keyless targets, selected through an address that is itself committed into the signature.

That flexibility depends on exact serialization.

Field order matters. Decimal-string representation matters. The target address matters. Expiration matters. The network source matters. Nonces belong to the signer rather than the target. Removing an agent can prune nonce history, which is why an old agent address must never be reused.

The way we should think about that is not:

Waller signs JSON

It is:

An exact byte sequence defines an authority request

Once we can see and test that byte sequence, we can build the next layer safely: a production execution agent with WebSocket state reconciliation, deterministic risk checks, atomic nonces, idempotent client order IDs and order batching.

The infrastructure side of that boundary

The signing model in this article looks like a single-machine concern. The production system that uses it isn’t.

The diagram earlier — market-data workers, strategy layer, deterministic risk policy, signer service, submission worker, state reconciliation — has infrastructure requirements at every layer:

  • Market-data workers need WebSocket streams for fills, order-book snapshots, and user events. HTTP polling misses fills and accumulates stale position data — the exact conditions that turn a well-designed signer into a source of duplicate orders.
  • Signer service and nonce allocator need private RPC with predictable latency. Two workers racing on the same signer key against a rate-limited public endpoint produce nonce collisions, not just retries.
  • Submission worker needs stable connectivity because a submitted-but-not-acknowledged action forces the reconciliation layer to guess whether the order landed. Guessing wrong is how strategies produce duplicate positions.
  • State reconciliation needs archive access for authoritative fill and position history — the source of truth when WebSocket streams reconnect.

The public Hyperliquid endpoint is rate-limited to 100 requests per minute per IP. That is fine for signing tutorials and not close to fine for running any of the four layers above in production.

Chainstack runs private Hyperliquid RPC endpoints that remove that ceiling. You get both HyperEVM paths (/evm for standard EVM traffic, /nanoreth when you need system transactions), plus /info and /exchange for the action flows this article walks through, WebSocket streams for the reconciliation layer, and archive access for fill history. Testnet nodes and a HYPE faucet are available for building and stress-testing the signer service before it touches mainnet funds.

FAQ

Does an agent wallet hold any funds or account state?

No. An agent wallet is a signing key with authority delegated by a master through an approveAgent action. If you query balances with the agent address, HyperCore usually returns an empty account. Funds, positions, sub-accounts, and account configuration all live on the master. The agent holds authorization to sign specific L1 action types on the master’s behalf — nothing else.

Can I rotate an agent by re-registering the same address?

No. Re-registering a previously deregistered agent address is unsafe. When an agent is deregistered, expires, or its registering account runs out of funds, HyperCore may prune the agent’s nonce state. If the old nonces are no longer remembered, previously consumed nonces become replayable. Always rotate by generating a completely new private key. An old agent address must never be reused.

Why does reordering fields in an action dictionary change the signature?

Because Hyperliquid signs the Msgpack serialization of the action, not the semantic content. Msgpack serializes map entries in insertion order. Two Python dictionaries with the same keys but different insertion order produce different bytes, different Keccak hashes, and different signatures. This is why signing code should not pass an action through arbitrary JSON transformations, sorting, or default-injection before signing.

Can multiple workers share one agent wallet without nonce collisions?

Within a single process, yes — with a locked monotonic allocator over int(time.time() * 1000). Across processes or machines, an in-memory lock isn’t enough. Two workers observing the same clock tick will produce identical nonces. The Hyperliquid documentation recommends a separate agent wallet per execution process; each agent becomes its own concurrency domain because nonce state is tracked per signer, not per master.

What’s the difference between vaultAddress and the recovered signer?

The recovered signer is derived from the signature — HyperCore recomputes the connectionId from the action bytes and recovers the address that signed it. vaultAddress is a separate field committed into the signed preimage that selects which account (master, sub-account, or vault) the action targets. An agent’s signature does not implicitly authorize its own sub-accounts; vaultAddress is signed, not attached after the fact, so an intermediary cannot redirect a valid order to a different target.

Does expiresAfter give me a free way to cancel an order?

No. expiresAfter is a safety mechanism, not a cancellation primitive. A stale expiresAfter rejection consumes five times the usual address-based rate limit, so relying on expiration to “cancel” orders will get your signer rate-limited fast. It also isn’t supported on all action types — user-signed actions like Core USDC transfers reject it. Use explicit cancel actions for cancellation and reserve expiresAfter for defense against replay of stale intent.

The Hyperliquid series

HIP context

Practical infrastructure

SHARE THIS ARTICLE
Trust 530x281 logo

Trust yourself—final part of the trust trilogy

In part 2 of the Trust Trilogy, I ended with the promise of larger ecosystems and markets made possible through a simple mind-shift, where collaboration is the norm and the blockchain is the default trusted execution environment.

Chainstack Avatar@3x logo
Chainstack
Jul 9
Customer Stories

SMARTy Pay

Automating infrastructure network operations with databases and the blockchain application.

Saakuru Labs

Saakuru Labs seamlessly transitions businesses from Web2 to Web3 with a 4X infrastructure ROI using Chainstack Global Node.

Linear

Linear pioneers DeFi with Chainstack Subgraphs for robust multi-chain scalability, and synthetic asset trading.