跳转到内容
去交易

API key

API key 是从主钱包通过一次性签名派生出的委托 ECDSA 签名凭据。通过 /account/link 链上注册后,API key 可以代替主钱包签 session challenge,避免每次弹 MetaMask —— 这正是 bot、脚本、indexer 想要的形态。同一主钱包下可以用 label 区分多把 API key,每把都能单独限定操作范围、单独撤销。

  1. 在「更多」菜单打开 API Keys 页面,给 API key 起个名字(如 trading-bot-1),并勾选这把 key 可以执行的操作。
  2. 在 MetaMask 里签一次派生消息——浏览器在本地派生出 API key,链上只收到签名。
  3. 复制弹窗里显示的 API key。只显示一次,链上不存。丢失就只能重新生成新的。
  4. 在脚本里把这把 API key 作为 signer,开 session 时把**主钱包地址(0xABC)**作为 userAddress 传上去。链上会从签名恢复地址,匹配到 authorized_signers 里的 API key 记录就放行。
  5. 随时可在 API Keys 页面或调 POST /account/unlink(需要主钱包的新签名)撤销。

下面是不依赖 SDK 的最小端到端示例。如果用 TypeScript SDK,下单只需一行 await session.order(...)——见下文 SDK 章节。

import requests
from eth_account import Account
from eth_utils import keccak
USER = "0xABC..." # 主钱包,也是 session 上的 userAddress
API_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)总是拥有完全权限。

FlagCovers
place_ordersPOST /session/order — 限价 / 市价 / Post-only / IOC / FOK 等所有下单
cancel_ordersPOST /session/cancel + /session/modify — 改单本质是「撤+重下」,共用这一位
change_leveragePOST /session/leverage — 调整每个资产的杠杆和全仓/逐仓模式
set_triggersPOST /session/trigger + /session/cancel_trigger — 止盈止损触发单

真实 bot 在最小示例基础上还要三件事:(1) 在 1 小时 session TTL 到期前自动重连;(2) 用 requestId 做幂等,让网络重试永远不会重复下单;(3) 用 WebSocket 订阅 executionReports,秒级拿到成交事件,不用轮询 /info。下面这段脚本三个都做了。

import asyncio, json, time, requests
import websockets
from eth_account import Account
from 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, 0
def _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,特殊标注除外)。写客户端时最常遇到的错误码:

CodeMeaningFix
invalid-signaturer/s/v 无法解析为合法 secp256k1 签名确保 r、s 是 0x 开头的 32 字节 hex,v ∈ {0,1}
signature-address-mismatch签名恢复出的地址既不是主钱包也不是任何已注册的 API keyAPI key 不对,或误用了 EIP-191/personal_sign 而不是裸 keccak 签名
signer-derivation-mismatch仅 /account/link:声明的 signer ≠ keccak256(r‖s‖v_norm) 派生地址派生时 v 必须用 {27,28}(非 {0,1})
invalid-challenge-idchallengeId 不存在或已被消费Challenge 一次性,调 /session/start 拿新的
challenge-expiredChallenge 超过 60 秒重新走 /session/start
session-not-foundsessionId 过期、已撤销或从未存在重新握手开 session(上面脚本会自动做)
api-key-permission-deniedAPI 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 —— 设计上 API key 泄露后,攻击者无法用泄露的 key 自己注销自己。主钱包对 keccak256("nexus-delegate-unlink:" + user + ":" + signer) 签名;成功后 API key 从 authorized_signers 移除,正在用它的 session 在下次请求时返回 session-not-found。

import requests
from eth_account import Account
from eth_utils import keccak
WALLET_KEY = "0x..." # 主钱包私钥(不是 API key)
USER = "0xABC..." # 主钱包地址,小写 0x + 40 hex
API_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..." }