[hyperliquid] Concurrent signed actions reuse a nonce and get rejected as duplicates
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:
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.
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 20Affected methods
All the trading operations, via the shared builders:
createOrdersRequest(createOrder/createOrders)cancelOrdersRequest(cancelOrder/cancelOrders),cancelOrdersForSymbolseditOrdersRequest(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:
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.A caller-supplied nonce. In #28073 (
[Lighter] Nonce errors when sending orders asynchronously) the advice was to pass the nonce inparams, andlightersupports it (handleOptionAndParams (params, 'createOrder', 'nonce'), documented as@param {int} [params.nonce]).hyperliquidhas no such path — the nonce is read unconditionally andparamsis never consulted for it.
Source: ccxt/ccxt