身份驗證¶
網路與相容性
| 資源 | 值 |
|---|---|
| API 基礎 URL | https://api.testnet.sera.cx/api/v1 |
| 鏈 | Sepolia(chainId 11155111) |
| Sera 合約 | 0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e |
| Vault 合約 | 0x3c7945840bAE0d7e7f3824Ebccef1962629250F0 |
| SOR 合約 | 0x83c1368110B640A729f3810De5FBe94b99aa5668 |
簽名原語。 所有交易型變更都是針對 Sera domain 的 EIP-712 類型化資料簽名。走 permit 路徑的充值使用 ERC-2612 Permit 擴充功能 — USDC、EURC、EURT 以及多數現代穩定幣支援,但並非所有 ERC-20 都支援;簽名前請呼叫 GET /permit/metadata 檢查。API key 管理使用 EIP-712 ManageApiKey payload。
已驗證客戶端。 Python eth_account >= 0.10 + requests;TypeScript ethers v6(signer.signTypedData)。已驗證支援 EIP-712 類型化資料的瀏覽器錢包:MetaMask、Rabby、Frame、Coinbase Wallet、Trust、Rainbow。Safe 多簽透過 EIP-1271 支援(訊息於鏈上驗證,而非透過 ecrecover)。
地址大小寫。 讀取類端點(/balances、/orders、/fills)將 owner_address 視為大小寫敏感 — 請傳入小寫形式。EIP-712 簽名 payload 接受 EIP-55 校驗和地址。
Sera 使用兩種身份驗證模式:
- API Key:用於帳戶讀取端點與交易建構輔助端點。
- EIP-712 簽名:用於交易、取消、提款與 API Key 管理。
實際使用時,公共工具類端點包括:
GET /health、GET /system/time、GET /tokens、GET /markets與GET /configPOST /swap/quote與POST /verify-signature
需要 API Key 的讀取與建構類端點包括:
GET /orders、GET /orders/{order_id}、GET /fills、GET /fills/{order_id}與GET /balancesGET /permit/metadata- 各類交易建構端點,例如
POST /approve、POST /deposit、POST /tx/send、POST /transfer與POST /transfer/send
準備¶
本頁面的每個程式碼片段都假定下述 import 與常數。挑一種語言後在全頁面重複使用。
import json, time
import requests
from eth_account import Account
from eth_account.messages import encode_typed_data
API = "https://api.testnet.sera.cx/api/v1"
PRIVATE_KEY = "0x...你的錢包私鑰..."
WALLET = Account.from_key(PRIVATE_KEY).address
DOMAIN = {
"name": "Sera",
"version": "1",
"chainId": 11155111,
"verifyingContract": "0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e",
}
import { Wallet, TypedDataDomain, ZeroAddress } from "ethers";
const API = "https://api.testnet.sera.cx/api/v1";
const PRIVATE_KEY = "0x...你的錢包私鑰...";
const signer = new Wallet(PRIVATE_KEY);
const WALLET = signer.address;
const DOMAIN: TypedDataDomain = {
name: "Sera",
version: "1",
chainId: 11155111,
verifyingContract: "0x83475A1bD98a8DC2DCd507A747e4DC85da241D6e",
};
API Key¶
API Key 用於私有查詢、交易輔助接口與自身撤銷,不替代交易或簽名撤單所需的錢包簽名。憑證格式如下:
端點摘要:
| 方法 | 路徑 | 簽名位置 |
|---|---|---|
POST | /api-keys | 請求主體中的 EIP-712 載荷(action=create) |
GET 或 POST | /api-keys 或 /api-keys/list | 查詢參數或請求主體中的 EIP-712 載荷(action=list) |
DELETE 或 POST | /api-keys 或 /api-keys/revoke | 查詢參數或請求主體中的 EIP-712 載荷(action=revoke_<api_key>) |
POST | /api-keys/revoke-all | 請求主體中的 EIP-712 載荷(action=revoke_all) |
POST | /api-keys/self-revoke | 使用待撤銷 Key 自身的 Bearer 憑證 |
POST | /api-keys/verify | 無(待驗證的 Key 在請求主體中) |
建立 API Key¶
API Key 透過簽署 EIP-712 ManageApiKey 訊息來建立。
MANAGE_API_KEY_TYPES = {
"ManageApiKey": [
{"name": "owner", "type": "address"},
{"name": "action", "type": "string"},
{"name": "timestamp", "type": "uint256"},
]
}
timestamp = int(time.time())
message = {"owner": WALLET, "action": "create", "timestamp": timestamp}
signable = encode_typed_data(DOMAIN, MANAGE_API_KEY_TYPES, message)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
signature = "0x" + signature.removeprefix("0x")
r = requests.post(
f"{API}/api-keys",
headers={"Content-Type": "application/json"},
data=json.dumps({
"owner_address": WALLET,
"action": "create",
"timestamp": timestamp,
"signature": "0x" + signature.removeprefix("0x"),
"label": "Trading bot",
}),
timeout=10,
)
api_key, api_secret = (lambda b: (b["api_key"], b["api_secret"]))(r.json())
const MANAGE_API_KEY_TYPES = {
ManageApiKey: [
{ name: "owner", type: "address" },
{ name: "action", type: "string" },
{ name: "timestamp", type: "uint256" },
],
};
const timestamp = Math.floor(Date.now() / 1000);
const signature = await signer.signTypedData(DOMAIN, MANAGE_API_KEY_TYPES, {
owner: WALLET, action: "create", timestamp,
});
const response = await fetch(`${API}/api-keys`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
owner_address: WALLET,
action: "create",
timestamp,
signature,
label: "Trading bot",
}),
});
const { api_key, api_secret } = await response.json();
說明:
- 簽名中的時間戳必須在伺服器時間前後 5 分鐘內。
- 一個錢包最多可同時擁有 10 個活躍 API Key。
api_secret只會回傳一次,必須自行安全保存。
列出 API Key¶
timestamp = int(time.time())
message = {"owner": WALLET, "action": "list", "timestamp": timestamp}
signable = encode_typed_data(DOMAIN, MANAGE_API_KEY_TYPES, message)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
signature = "0x" + signature.removeprefix("0x")
keys = requests.get(
f"{API}/api-keys",
params={
"owner_address": WALLET,
"action": "list",
"timestamp": timestamp,
"signature": "0x" + signature.removeprefix("0x"),
},
timeout=10,
).json()
const timestamp = Math.floor(Date.now() / 1000);
const signature = await signer.signTypedData(DOMAIN, MANAGE_API_KEY_TYPES, {
owner: WALLET, action: "list", timestamp,
});
const url = new URL(`${API}/api-keys`);
url.searchParams.set("owner_address", WALLET);
url.searchParams.set("action", "list");
url.searchParams.set("timestamp", String(timestamp));
url.searchParams.set("signature", signature);
const keys = await fetch(url).then(r => r.json());
如果客戶端更適合使用 JSON 請求主體而不是簽名查詢參數,也可以呼叫 POST /api-keys/list,並在請求主體中提交相同的 owner_address、action、timestamp 與 signature 欄位。
撤銷 API Key¶
api_key_to_revoke = "sera_..."
action = f"revoke_{api_key_to_revoke}"
timestamp = int(time.time())
signable = encode_typed_data(
DOMAIN, MANAGE_API_KEY_TYPES,
{"owner": WALLET, "action": action, "timestamp": timestamp},
)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
signature = "0x" + signature.removeprefix("0x")
requests.delete(
f"{API}/api-keys",
params={
"owner_address": WALLET,
"api_key": api_key_to_revoke,
"action": action,
"timestamp": timestamp,
"signature": "0x" + signature.removeprefix("0x"),
},
timeout=10,
)
const apiKeyToRevoke = "sera_...";
const action = `revoke_${apiKeyToRevoke}`;
const timestamp = Math.floor(Date.now() / 1000);
const signature = await signer.signTypedData(DOMAIN, MANAGE_API_KEY_TYPES, {
owner: WALLET, action, timestamp,
});
const url = new URL(`${API}/api-keys`);
url.searchParams.set("owner_address", WALLET);
url.searchParams.set("api_key", apiKeyToRevoke);
url.searchParams.set("action", action);
url.searchParams.set("timestamp", String(timestamp));
url.searchParams.set("signature", signature);
await fetch(url, { method: "DELETE" });
如果客戶端更適合使用 JSON 請求主體而不是簽名查詢參數,也可以呼叫 POST /api-keys/revoke,並在請求主體中提交相同欄位以及 api_key。
批次撤銷所有 API key¶
以單一簽章撤銷該錢包下所有有效的 API key。action 是固定字串 revoke_all。適合在憑證疑似外洩、設備遺失或錢包輪替流程中使用 — 一次錢包彈窗代替 N 次簽名。
timestamp = int(time.time())
signable = encode_typed_data(
DOMAIN, MANAGE_API_KEY_TYPES,
{"owner": WALLET, "action": "revoke_all", "timestamp": timestamp},
)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
signature = "0x" + signature.removeprefix("0x")
r = requests.post(
f"{API}/api-keys/revoke-all",
headers={"Content-Type": "application/json"},
data=json.dumps({
"owner_address": WALLET,
"action": "revoke_all",
"timestamp": timestamp,
"signature": "0x" + signature.removeprefix("0x"),
}),
timeout=10,
)
# 200: {"status":"ok","revoked_api_keys":["sera_...","sera_..."],"count":2}
# 200: {"status":"ok","revoked_api_keys":[],"count":0} # 無有效 Key
# 409: 簽名重放(已使用過)— 用新的 timestamp 重新簽名
const timestamp = Math.floor(Date.now() / 1000);
const signature = await signer.signTypedData(DOMAIN, MANAGE_API_KEY_TYPES, {
owner: WALLET, action: "revoke_all", timestamp,
});
const res = await fetch(`${API}/api-keys/revoke-all`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
owner_address: WALLET,
action: "revoke_all",
timestamp,
signature,
}),
});
// 200: { "status": "ok", "revoked_api_keys": ["sera_...", "sera_..."], "count": 2 }
// 200: { "status": "ok", "revoked_api_keys": [], "count": 0 } // 無有效 Key
// 409: 簽名重放(已使用過)— 用新的 timestamp 重新簽名
自撤銷(Bearer 驗證)¶
無需錢包簽名即可撤銷當前正在使用的 API key。使用 Authorization: Bearer {api_key}:{api_secret} 進行驗證;請求主體中的 api_key 必須等於 Bearer 的 api_key(只能撤銷目前登入使用的 key — 撤銷同一錢包底下其它 key 仍需以 DELETE /api-keys 加錢包簽名)。
驗證 API key¶
驗證 api_key/api_secret 配對是否有效,且不消耗速率限制、不產生副作用。成功時回傳所屬錢包地址。
r = requests.post(
f"{API}/api-keys/verify",
headers={"Content-Type": "application/json"},
data=json.dumps({"api_key": api_key, "api_secret": api_secret}),
timeout=10,
)
# 200: {"valid": True, "owner_address": "0x..."}
# 401: {"detail": "Invalid api_key or api_secret"}
# 503: {"detail": "Service temporarily unavailable; please retry"}(驗證後端不可達)
const res = await fetch(`${API}/api-keys/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key, api_secret }),
});
// 200: { "valid": true, "owner_address": "0x..." }
// 401: { "detail": "Invalid api_key or api_secret" }
// 503: { "detail": "Service temporarily unavailable; please retry" }(驗證後端不可達)
API Key 欄位與回傳結果¶
POST /api-keys 還接受可選的 label(去除兩側空格,最多 64 字符)。成功創建回傳 HTTP 200,包含 api_key、僅回傳一次的明文 api_secret、可爲空的 label 與字串 create_time。
列表回應爲 { "keys": [...] },每項包含整數 id、owner_address、api_key、可爲空的 label 和 create_time,不回傳 api_secret。單個撤銷和自身撤銷回傳 { "status": "ok" };不存在或已撤銷的 key 可回傳 404。撤銷全部回傳 status、revoked_api_keys、count,數量爲零也可成功。
創建和撤銷全部會以 409 拒絕重複使用已簽名的 (owner, action, timestamp);僅改變簽名編碼不會產生新的授權。創建回應不明確時,先列出 key 再決定是否再次創建;丟失的明文 secret 不能通過列表恢復。超過 key 數量上限也回傳 409,需要先撤銷已有 key。
EIP-712 Domain¶
公共 API 會依 Sepolia 上的 Sera 合約 domain 驗證簽名(已在 準備 中定義):
請透過 GET /config 取得目前部署的 chain_id、sera_address、vault_address 與 sor_address,不要將這些值硬編碼在客戶端中。
到期時間規則¶
所有已簽名的訂單請求都必須攜帶 expiration,而兌換報價請求也使用同一套有界未來時間戳來產生後續簽署用的 Intent。
expiration必須嚴格大於目前時間。expiration最多只能比目前伺服器時間晚 365 天減去 300 秒的時鐘偏差保護。- 缺失、為 0、已過期或過遠的值都會在進入撮合或結算前直接被拒絕。
請使用 GET /system/time 推導這些時間戳,並預留一點客戶端緩衝,不要在邊界時間上簽名。
訂單請求中的 UUID 綁定¶
限價單現在同時攜帶兩個關聯識別碼:
order_id:人類可讀的 UUID4 字串。uuid_int:嵌入鏈上簽名Order.uuid欄位的十進位 uint256。
API 會驗證這兩個識別碼是否編碼的是同一筆訂單。請先從 GET /health 讀取目前 executor_id,然後按以下組合布局產生 uuid_int:
獨立限價單¶
一般限價單的規則:
leg_id = 0group_id = order_id的前 112 位
function uuidStringToBigInt(uuid: string): bigint {
return BigInt(`0x${uuid.replace(/-/g, "")}`);
}
function encodeStandaloneUuid(orderId: string, executorId: number): string {
const raw = uuidStringToBigInt(orderId);
const group = raw >> 16n;
return ((BigInt(executorId) << 252n) | (raw << 124n) | (group << 12n)).toString();
}
有效範例:
{
"order_id": "00000000-0000-4000-8000-000000000001",
"uuid_int": "6427948336465191935941739505432058208337171677044006212075520"
}
虛擬流動性批次¶
對於 VL 批次:
- 所有同組訂單共享同一個
group_id group_id來自 order 0leg_id依序為0, 1, 2, ...
function encodeVlUuid(
orderId: string, executorId: number,
legId: number, groupOrderId: string,
): string {
const raw = uuidStringToBigInt(orderId);
const group = uuidStringToBigInt(groupOrderId) >> 16n;
return ((BigInt(executorId) << 252n) | (raw << 124n) | (group << 12n) | BigInt(legId)).toString();
}
訂單簽名¶
鏈上簽名的 Order 結構如下:
ORDER_TYPES = {
"Order": [
{"name": "user", "type": "address"},
{"name": "expiration", "type": "uint48"},
{"name": "feeBps", "type": "uint48"},
{"name": "recipient", "type": "address"},
{"name": "fromToken", "type": "address"},
{"name": "toToken", "type": "address"},
{"name": "fromAmount", "type": "uint256"},
{"name": "toAmount", "type": "uint256"},
{"name": "initialDepositAmount", "type": "uint256"},
{"name": "uuid", "type": "uint256"},
]
}
const ORDER_TYPES = {
Order: [
{ name: "user", type: "address" },
{ name: "expiration", type: "uint48" },
{ name: "feeBps", type: "uint48" },
{ name: "recipient", type: "address" },
{ name: "fromToken", type: "address" },
{ name: "toToken", type: "address" },
{ name: "fromAmount", type: "uint256" },
{ name: "toAmount", type: "uint256" },
{ name: "initialDepositAmount", type: "uint256" },
{ name: "uuid", type: "uint256" },
],
};
在 POST /orders 請求中,使用交易對識別欄位,而不是直接的「支出/接收」欄位:
from_address是市場的基礎代幣地址。to_address是市場的計價代幣地址。bid用to_address買入from_address。ask賣出from_address,換取to_address。
這些地址請透過 GET /tokens 取得,顯示用交易對標籤請透過 GET /markets 取得。
範例:
health_response = requests.get(f"{API}/health", timeout=10)
health_response.raise_for_status()
order_id = str(_uuid.uuid4())
uuid_int = encode_standalone_uuid(order_id, int(health_response.json()["executor_id"]))
market_response = requests.get(f"{API}/markets", timeout=10)
market_response.raise_for_status()
market = next(m for m in market_response.json()["markets"]
if {m["base_symbol"], m["quote_symbol"]} == {"USDC", "EURC"})
signed_order_request = {
"owner_address": WALLET, "side": "bid", "amount": "1000", "price": "1",
"from_address": market["base_address"], "to_address": market["quote_address"],
"order_id": order_id, "uuid_int": str(uuid_int),
"expiration": int(time.time()) + 3600,
}
preview_response = requests.post(f"{API}/orders/preview",
json={**signed_order_request, "order_type": "limit"}, timeout=10)
preview_response.raise_for_status()
preview = preview_response.json()
signed_order_request.update(amount=preview["normalized_amount"], price=preview["normalized_price"])
order_data = preview["eip712_order"]
signable = encode_typed_data(DOMAIN, preview["eip712_types"], order_data)
raw_signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
order_signature = "0x" + raw_signature.removeprefix("0x")
const healthResponse = await fetch(`${API}/health`);
if (!healthResponse.ok) throw new Error(`health: ${healthResponse.status}`);
const orderId = crypto.randomUUID();
const uuidInt = encodeStandaloneUuid(orderId, (await healthResponse.json()).executor_id);
const marketsResponse = await fetch(`${API}/markets`);
if (!marketsResponse.ok) throw new Error(`markets: ${marketsResponse.status}`);
const market = (await marketsResponse.json()).markets.find((m: any) =>
[m.base_symbol, m.quote_symbol].sort().join("/") === "EURC/USDC");
if (!market) throw new Error("No active USDC/EURC market");
const signedOrderRequest = {
owner_address: WALLET, side: "bid", amount: "1000", price: "1",
from_address: market.base_address, to_address: market.quote_address,
order_id: orderId, uuid_int: uuidInt.toString(),
expiration: Math.floor(Date.now() / 1000) + 3600,
};
const previewResponse = await fetch(`${API}/orders/preview`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...signedOrderRequest, order_type: "limit" }),
});
if (!previewResponse.ok) throw new Error(`preview: ${previewResponse.status}`);
const preview = await previewResponse.json();
signedOrderRequest.amount = preview.normalized_amount;
signedOrderRequest.price = preview.normalized_price;
const orderData = preview.eip712_order;
const orderSignature = await signer.signTypedData(DOMAIN, preview.eip712_types, orderData);
兌換的 Intent 簽名¶
POST /swap/quote 會回傳 route_params。請依回傳結果原樣簽名。
INTENT_TYPES = {
"Intent": [
{"name": "taker", "type": "address"},
{"name": "inputToken", "type": "address"},
{"name": "outputToken", "type": "address"},
{"name": "maxInputAmount", "type": "uint256"},
{"name": "minOutputAmount", "type": "uint256"},
{"name": "recipient", "type": "address"},
{"name": "initialDepositAmount", "type": "uint256"},
{"name": "uuid", "type": "uint256"},
{"name": "deadline", "type": "uint48"},
]
}
signable = encode_typed_data(DOMAIN, INTENT_TYPES, quote["route_params"])
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
signature = "0x" + signature.removeprefix("0x")
const INTENT_TYPES = {
Intent: [
{ name: "taker", type: "address" },
{ name: "inputToken", type: "address" },
{ name: "outputToken", type: "address" },
{ name: "maxInputAmount", type: "uint256" },
{ name: "minOutputAmount", type: "uint256" },
{ name: "recipient", type: "address" },
{ name: "initialDepositAmount", type: "uint256" },
{ name: "uuid", type: "uint256" },
{ name: "deadline", type: "uint48" },
],
};
const signature = await signer.signTypedData(DOMAIN, INTENT_TYPES, quote.route_params);
取消簽名¶
CancelOrder¶
CancelOrder.orderId 使用的是組合 uuid_int,不是 UUID 字串。
CANCEL_ORDER_TYPES = {
"CancelOrder": [
{"name": "owner", "type": "address"},
{"name": "orderId", "type": "uint256"},
]
}
signable = encode_typed_data(
DOMAIN, CANCEL_ORDER_TYPES,
{"owner": WALLET, "orderId": int(uuid_int)},
)
signature = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
signature = "0x" + signature.removeprefix("0x")
CancelVLBatch¶
提款簽名¶
提交前驗證簽名¶
const response = await fetch(`${API}/verify-signature`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...signedOrderRequest, signature: orderSignature }),
});
if (!response.ok) throw new Error(`verify: ${response.status}`);
console.log((await response.json()).valid);
時鐘同步¶
請使用 GET /system/time 計算 expiration 與 deadline,使用 GET /health 讀取產生 uuid_int 所需的最新 executor_id,並使用 GET /config 取得最新的 EIP-712 合約地址。
CancelAll¶
POST /orders/cancel-all 同時要求 API Key 和此簽名消息。請使用 GET /config 回傳的當前 Sera EIP-712 域。
import secrets
from eth_account import Account
from eth_account.messages import encode_typed_data
CANCEL_ALL_TYPES = {
"CancelAll": [
{"name": "owner", "type": "address"},
{"name": "nonce", "type": "uint256"},
{"name": "deadline", "type": "uint256"},
]
}
nonce = secrets.randbits(256) or 1
deadline = int(time.time()) + 300
message = {"owner": WALLET, "nonce": nonce, "deadline": deadline}
signable = encode_typed_data(DOMAIN, CANCEL_ALL_TYPES, message)
signed = Account.from_key(PRIVATE_KEY).sign_message(signable).signature.hex()
signature = "0x" + signed.removeprefix("0x")
const CANCEL_ALL_TYPES = {
CancelAll: [
{ name: "owner", type: "address" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const nonce = BigInt("0x" + crypto.randomUUID().replace(/-/g, ""));
const deadline = Math.floor(Date.now() / 1000) + 300;
const signature = await signer.signTypedData(DOMAIN, CANCEL_ALL_TYPES, {
owner: WALLET,
nonce: BigInt(nonce),
deadline: BigInt(deadline),
});
每次獨立取消使用新的正 uint256 nonce。在請求體中傳入其十進制字串和整數 deadline,並在查詢參數中傳入一致的小寫所有者地址。重用已消費 nonce 回傳 409。單筆訂單與 VL 取消仍使用原有簽名類型。
普通下單可通過預覽取得 eip712_order、eip712_types 並原樣簽名。Exact Output 使用現有 Intent 類型,以及其自身回傳的 signing_domain 和已保存的 quote_uuid。
簽名編碼¶
下單、VL 下單、Swap/Exact Output 執行與 /verify-signature 請使用錢包回傳的標準 65 字節 ECDSA 簽名。下單、VL 下單、一般 Swap 執行與 /verify-signature 接受 65–8192 字節;Exact Output 執行接受 65–4096 字節。底層代碼雖可識別 64 字節 EIP-2098 簽名,上述意圖簽名欄位會拒絕它。Permit 欄位另行接受 64/65 字節編碼;Exact Output 的 Permit 簽名上限為 4096 字節。合約錢包簽名必須同時滿足所在路由的長度檢查和 ERC-1271 驗證。
Python 格式化簽名時,使用 removeprefix("0x") 僅移除字面前綴。lstrip("0x") 還會刪除開頭的零數字,可能破壞有效簽名;上面的示例會保留這些字節。