#30348·ccxt

[hyperliquid] Concurrent signed actions reuse a nonce and get rejected as duplicates

Author: las83Created Sep 10, 2026Updated Sep 14, 2026
Labelsbug

Operating System

Linux (arm64 and x86_64; not OS-specific)

Programming Language

Python

CCXT Version

4.5.78

Description

Description

Hyperliquid rejects a nonce a signer has already used. Every signed action in hyperliquid takes its nonce straight from the clock:

typescript
const nonce = this.milliseconds ();

There are 20 such reads in ts/src/hyperliquid.ts on master. Nothing dedupes them, and the read-then-sign block contains no await, so two signed actions issued back to back run as consecutive synchronous blocks. Signing is faster than the clock ticks, so they take the same millisecond and the venue refuses the second with:

{"status":"err","response":"Invalid nonce: duplicate nonce 1788895523507"}

This surfaces as a bare ExchangeError — there is no 'Invalid nonce' entry in hyperliquid's exceptions.broad.

Reproduction

No network and no real credentials: it stops at the signed request builder.

python
import collections, time
import ccxt

SYMBOL = "ETH/USDC:USDC"
ex = ccxt.hyperliquid({
    "walletAddress": "0x0000000000000000000000000000000000000001",
    "privateKey": "0x" + "11" * 32,          # throwaway key, nothing is sent
})
market = {
    "id": SYMBOL, "symbol": SYMBOL, "base": "ETH", "quote": "USDC", "settle": "USDC",
    "baseId": "1", "type": "swap", "swap": True, "spot": False, "contract": True,
    "active": True, "linear": True, "inverse": False, "contractSize": 1,
    "precision": {"amount": 0.0001, "price": 0.1}, "limits": {}, "info": {},
}
ex.markets, ex.markets_by_id = {SYMBOL: market}, {SYMBOL: [market]}

order = {"symbol": SYMBOL, "type": "limit", "side": "buy",
         "amount": 0.03, "price": 2400.0, "params": {"reduceOnly": True}}

start = time.perf_counter()
nonces = [ex.create_orders_request([order], {})["nonce"] for _ in range(20)]
elapsed_ms = (time.perf_counter() - start) * 1000

dupes = {n: c for n, c in collections.Counter(nonces).items() if c > 1}
print(f"ccxt {ccxt.__version__}")
print(f"20 signed requests built in {elapsed_ms:.1f} ms ({elapsed_ms / 20:.2f} ms each)")
print(f"unique nonces: {len(set(nonces))} of 20")
print(f"reused nonces: {dupes}")

Output (three consecutive runs, x86_64):

20 signed requests built in 21.5 ms (1.07 ms each)   unique nonces: 13 of 20
20 signed requests built in 16.7 ms (0.84 ms each)   unique nonces: 10 of 20
20 signed requests built in 16.0 ms (0.80 ms each)   unique nonces: 10 of 20

Affected methods

All the trading operations, via the shared builders:

  • createOrdersRequest (createOrder / createOrders)
  • cancelOrdersRequest (cancelOrder / cancelOrders), cancelOrdersForSymbols
  • editOrdersRequest (editOrder)
  • setLeverage, setMarginMode, modifyMarginHelper

Both transports. ts/src/pro/hyperliquid.ts reuses the same builders — createOrdersWs calls createOrdersRequest, editOrderWs calls editOrdersRequest, cancelOrdersWs calls cancelOrdersRequest — so the WS trading path shares the defect and the same nonce space as REST.

Anything that issues two signed actions concurrently on one signer hits this. The case that bit us is placing a stop-loss and a take-profit together after a fill: a rejected bracket leg leaves a position without full protection.

Note on scope

Hyperliquid tracks nonce reuse per signer, including across the subaccounts that key operates — so this is not avoidable by splitting accounts if they share an API wallet.

Suggested fix

Two remedies already exist in ccxt for this exact class, and neither is available on hyperliquid:

  1. In-library uniqueness. #30260 (fix(nado): order nonce entropy) adds random low bits, with the comment "otherwise two orders created during the same millisecond would collide on the same nonce and get rejected". That works because nado's nonce reserves 20 low bits. Hyperliquid's nonce is a plain millisecond integer with no spare bits, so entropy does not transfer — the equivalent would be a per-instance monotonic counter, nonce = max (this.milliseconds (), lastNonce + 1), which is what Hyperliquid's own Python SDK does.

  2. A caller-supplied nonce. In #28073 ([Lighter] Nonce errors when sending orders asynchronously) the advice was to pass the nonce in params, and lighter supports it (handleOptionAndParams (params, 'createOrder', 'nonce'), documented as @param {int} [params.nonce]). hyperliquid has no such path — the nonce is read unconditionally and params is never consulted for it.