API key
API key 是从主钱包通过一次性签名派生出的委托 ECDSA 签名凭据。通过 /account/link 链上注册后,API key 可以代替主钱包签 session challenge,避免每次弹 MetaMask —— 这正是 bot、脚本、indexer 想要的形态。同一主钱包下可以用 label 区分多把 API key,每把都能单独限定操作范围、单独撤销。
- 在「更多」菜单打开 API Keys 页面,给 API key 起个名字(如 trading-bot-1),并勾选这把 key 可以执行的操作。
- 在 MetaMask 里签一次派生消息——浏览器在本地派生出 API key,链上只收到签名。
- 复制弹窗里显示的 API key。只显示一次,链上不存。丢失就只能重新生成新的。
- 在脚本里把这把 API key 作为 signer,开 session 时把**主钱包地址(0xABC)**作为 userAddress 传上去。链上会从签名恢复地址,匹配到 authorized_signers 里的 API key 记录就放行。
- 随时可在 API Keys 页面或调 POST /account/unlink(需要主钱包的新签名)撤销。
下面是不依赖 SDK 的最小端到端示例。如果用 TypeScript SDK,下单只需一行 await session.order(...)——见下文 SDK 章节。
Python — 用 API key 下一个限价单
Section titled “Python — 用 API key 下一个限价单”import requestsfrom eth_account import Accountfrom eth_utils import keccak
USER = "0xABC..." # 主钱包,也是 session 上的 userAddressAPI_KEY = "0x..." # API key(创建弹窗只显示一次)BASE = "https://api.bitnxs.com"
# 1. 为主钱包请求一个 challenge。chal = requests.post(f"{BASE}/session/start", json={"user": USER}).json()
# 2. 用 API key **直接对 keccak256(challenge_bytes) 签名**。# 链端用裸 secp256k1 ECDSA 在 keccak hash 上做恢复 ——# **不能用 eth_sign / personal_sign / EIP-191**。那些会在前面加# "\x19Ethereum Signed Message:\n" 前缀,hash 完全不同,链端会# 返回 signature-address-mismatch。sig = Account.unsafe_sign_hash( keccak(chal["challenge"].encode()), private_key=API_KEY,)
ses = requests.post(f"{BASE}/session/complete", json={ "challengeId": chal["challengeId"], "signature": { "r": hex(sig.r), "s": hex(sig.s), "v": sig.v - 27, # eth_account 返回 27/28;链端要 {0,1} },}).json()SID = ses["sessionId"]
# 3. 下单——后续请求不再签名,sessionId 就是凭证。print(requests.post(f"{BASE}/session/order", json={ "sessionId": SID, "requestId": 10001, # 幂等 ID(可选,非 0) "asset": 1, # 数字 asset id,从 /info { type: "meta" } 查 "isBuy": True, "limitPx": "65000.00", "sz": "0.01", "reduceOnly": False, "orderType": {"type": "limit", "tif": "Gtc"},}).json())
# 4. 读接口不需要 session,/info 公开。print(requests.post(f"{BASE}/info", json={ "type": "clearinghouseState", "user": USER,}).json())每把 API key 在创建时携带四个布尔标志。未授权的操作会在进入撮合管道之前被链端直接拒绝——用于在 API key 泄露时缩小影响面。主钱包直接签名的 session(不走 API key)总是拥有完全权限。
| Flag | Covers |
|---|---|
place_orders | POST /session/order — 限价 / 市价 / Post-only / IOC / FOK 等所有下单 |
cancel_orders | POST /session/cancel + /session/modify — 改单本质是「撤+重下」,共用这一位 |
change_leverage | POST /session/leverage — 调整每个资产的杠杆和全仓/逐仓模式 |
set_triggers | POST /session/trigger + /session/cancel_trigger — 止盈止损触发单 |
调用频率限制
Section titled “调用频率限制”生产级交易循环
Section titled “生产级交易循环”真实 bot 在最小示例基础上还要三件事:(1) 在 1 小时 session TTL 到期前自动重连;(2) 用 requestId 做幂等,让网络重试永远不会重复下单;(3) 用 WebSocket 订阅 executionReports,秒级拿到成交事件,不用轮询 /info。下面这段脚本三个都做了。
import asyncio, json, time, requestsimport websocketsfrom eth_account import Accountfrom eth_utils import keccak
USER, API_KEY = "0xABC...", "0x..."BASE = "https://api.bitnxs.com"WS = BASE.replace("http", "ws") + "/ws"
# ─── session 助手:开一次,1 小时 TTL 到前自动续 ───────_sid, _sid_exp = None, 0def _open_session(): chal = requests.post(f"{BASE}/session/start", json={"user": USER}).json() sig = Account.unsafe_sign_hash(keccak(chal["challenge"].encode()), private_key=API_KEY) ses = requests.post(f"{BASE}/session/complete", json={ "challengeId": chal["challengeId"], "signature": {"r": hex(sig.r), "s": hex(sig.s), "v": sig.v - 27}, }).json() if ses.get("status") != "ok": raise RuntimeError(f"session/complete failed: {ses}") return ses["sessionId"], time.time() + 3500 # 留 ~100 秒缓冲
def session_id(): global _sid, _sid_exp if not _sid or time.time() >= _sid_exp: _sid, _sid_exp = _open_session() return _sid
# ─── 幂等下单 ─────────────────────────────────────────# 单调递增的 requestId — 重试安全。链端按 (sessionId, requestId)# 缓存结果,相同 ID 永远返回同一笔,绝不重复成交。_req_seq = int(time.time() * 1000)def next_req_id(): global _req_seq _req_seq += 1 return _req_seq
def place(asset, is_buy, px, sz, req_id=None): return requests.post(f"{BASE}/session/order", json={ "sessionId": session_id(), "requestId": req_id or next_req_id(), "asset": asset, "isBuy": is_buy, "limitPx": str(px), "sz": str(sz), "reduceOnly": False, "orderType": {"type": "limit", "tif": "Gtc"}, }).json()
# ─── WebSocket 成交流 ── 秒级事件驱动 ──────────────────async def watch_fills(): async with websockets.connect(WS) as ws: await ws.send(json.dumps({ "method": "subscribe", "subscription": {"type": "executionReports", "user": USER}, })) async for raw in ws: msg = json.loads(raw) if msg.get("channel") == "subscription": for ev in msg.get("data", []): print("成交 requestId=%s sz=%s" % (ev.get("requestId"), ev.get("filledSz")))
async def main(): asyncio.create_task(watch_fills()) print(place(asset=1, is_buy=True, px=65000, sz=0.01)) await asyncio.sleep(60)
asyncio.run(main())/account/* 与 /session/* 失败时统一返回 { status: "err", response: "<code>" }(默认 HTTP 400,特殊标注除外)。写客户端时最常遇到的错误码:
| Code | Meaning | Fix |
|---|---|---|
invalid-signature | r/s/v 无法解析为合法 secp256k1 签名 | 确保 r、s 是 0x 开头的 32 字节 hex,v ∈ {0,1} |
signature-address-mismatch | 签名恢复出的地址既不是主钱包也不是任何已注册的 API key | API key 不对,或误用了 EIP-191/personal_sign 而不是裸 keccak 签名 |
signer-derivation-mismatch | 仅 /account/link:声明的 signer ≠ keccak256(r‖s‖v_norm) 派生地址 | 派生时 v 必须用 {27,28}(非 {0,1}) |
invalid-challenge-id | challengeId 不存在或已被消费 | Challenge 一次性,调 /session/start 拿新的 |
challenge-expired | Challenge 超过 60 秒 | 重新走 /session/start |
session-not-found | sessionId 过期、已撤销或从未存在 | 重新握手开 session(上面脚本会自动做) |
api-key-permission-denied | API key 没开本次操作的权限位 | 重新创建 API key 并开启对应权限,如 cancel_orders |
signer-already-linked-to-different-user | /account/link:该 API key 地址已绑给其他钱包 | 换一个 label,让 MetaMask 派生出新的 API key |
rate-limited (HTTP 429) | 该签名地址 token bucket 用尽(50 burst / 20 rps) | 退避约 50 ms 再重试 — 每秒补 20 个 token |
oracle-not-ready | 节点启动时 oracle 初始价格还没就绪 | 先 GET /ready 确认;过几秒再试 |
insufficient-margin | 订单在 admission preflight 阶段不满足保证金 | 充值、减仓位、或调整 limit 价格 |
invalid-asset | 本节点不认识这个 asset id | 从 /info { type: “meta” } 拉当前 universe |
撤销 API key
Section titled “撤销 API key”只有主钱包可以撤销 API key —— 设计上 API key 泄露后,攻击者无法用泄露的 key 自己注销自己。主钱包对 keccak256("nexus-delegate-unlink:" + user + ":" + signer) 签名;成功后 API key 从 authorized_signers 移除,正在用它的 session 在下次请求时返回 session-not-found。
import requestsfrom eth_account import Accountfrom eth_utils import keccak
WALLET_KEY = "0x..." # 主钱包私钥(不是 API key)USER = "0xABC..." # 主钱包地址,小写 0x + 40 hexAPI_KEY_ADDR = "0xXYZ..." # 要撤销的 API key 地址(在 /info userApiKeys 里看)BASE = ""
msg = f"nexus-delegate-unlink:{USER.lower()}:{API_KEY_ADDR.lower()}"sig = Account.unsafe_sign_hash(keccak(msg.encode()), private_key=WALLET_KEY)
print(requests.post(f"{BASE}/account/unlink", json={ "user": USER, "signer": API_KEY_ADDR, "revocationSig": {"r": hex(sig.r), "s": hex(sig.s), "v": sig.v - 27},}).json())# → { "status": "ok", "user": "0xABC...", "signer": "0xXYZ..." }