Nautilus Trader Lighter 签名交易 Oracle:以闭源签名器为基准的 L2 交易字节级等价验证方案

原创2026-09-10 11:37:331,749 阅读
文章标签:金融科技

Nautilus Trader Lighter 签名交易 Oracle:以闭源签名器为基准的 L2 交易字节级等价验证方案

本篇技术指南聚焦 nautilus_trader 仓库中 Lighter 适配器(crates/adapters/lighter)的签名交易 Oracle(signing tx oracle)机制。它通过加载 lighter-python SDK 自带的闭源签名器动态库,对交易关键型 L2 交易类型运行确定性输入,并将权威的 tx_info、tx_hash、签名字节与公钥字节固化为 JSON fixture,供 Rust 端测试断言与官方签名器输出的字节级等价性。读完本文,你将掌握这套"以闭源 SDK 为行为基准(oracle)"的测试方法论、fixture 的数据结构、生成脚本的运行方式,以及 Rust 侧如何借助 Poseidon2 哈希与 ecgfp5 曲线 Schnorr 签名完成字节等价、验签与自签回环三层验证。

为什么需要 Oracle:三层签章正确性门禁

Lighter 是一个 L2 交易所,其交易签名栈由三层构成,nautilus_trader 对每一层设置了对应的验证门禁(gate):

  • 第一层(底层密码学原语):Goldilocks 域(p = 2^64 - 2^32 + 1)、quintic 扩展域 Fp5、ecgfp5 曲线、Poseidon2 哈希、Schnorr 签名的行为,由仓库内开源的 Go 参考实现(elliottech/poseidon_crypto 等)生成的 fixture 向量验证;
  • 第二层(本文主题,SDK oracle 门禁):官方编译的闭源签名器在此被当作行为基准(oracle)检查,确保我们的哈希组装、域元素编码与签名布局和官方产物一致;
  • 第三层(最终门禁):对测试网(testnet)的实盘往返测试(live round-trip testing),验证 sequencer 真正接受什么。

从源码结构看,这一分层在 signing/mod.rs 的模块说明中有明确表述:ecgfp5 曲线与 Poseidon2 哈希的参考向量固化为 fixtures,而官方 lighter-go / lighter-python SDK 封装的是闭源编译签名器,测试网往返测试是最终正确性门禁。本文描述的 Oracle 正是中间这一关键一环。

Oracle 的构成:生成脚本 + JSON fixture + Rust 测试

Oracle 由三部分组成,分别位于仓库的以下位置:

组成 路径 作用
生成脚本 crates/adapters/lighter/tests/oracle-py/generate_oracle.py 通过 ctypes 加载闭源签名器 .so,驱动其对确定性输入签名并输出 fixture
交易 fixture crates/adapters/lighter/test_data/signing_tx_oracle.json 固化 tx_info / tx_hash / sig 等权威输出
认证令牌 fixture crates/adapters/lighter/test_data/signing_auth_token_oracle.json 固化 REST/WS 认证令牌的签名输出
Rust 消费方 crates/adapters/lighter/src/signing/tx/encode.rs #[cfg(test)] mod tests 加载 fixture 并断言字节等价
认证令牌消费方 crates/adapters/lighter/src/signing/auth_token.rs 用同一摘要重算并验证 oracle 令牌

生成脚本使用 include_str! 在编译期把 fixture 直接嵌入测试二进制:

const ORACLE_JSON: &str = include_str!(concat!(
    env!("CARGO_MANIFEST_DIR"),
    "/test_data/signing_tx_oracle.json",
));

这意味着 fixture 一旦被修改,测试即随之生效,无需任何运行时文件读取,天然保证了可复现与可审计。

向量形状(Vector Shape):每个条目固化什么

每个 fixture 条目遵循 README 中给出的 JSON 结构,字段语义如下:

{
  "kind":          "create_order",
  "chain_id":      300,
  "sk":            "<hex 40 bytes>",
  "account_index": 12345,
  "api_key_index": 5,
  "expired_at":    1777804107504,
  "nonce":         42,
  "fields":        {"market_index": 0, "...": "..."},
  "tx_type":       14,
  "tx_info":       "<json string emitted by the signer>",
  "tx_hash":       "<hex 40 bytes; the signed message hash>",
  "sig":           "<hex 80 bytes; s_le || e_le>"
}

关键字段说明:

  • kind:交易种类判别符,测试侧据此筛选向量,可选值为 create_order、cancel_order、modify_order、cancel_all_orders、update_leverage、approve_integrator;
  • chain_id:固定为 300(testnet,见脚本中 CHAIN_ID_TESTNET = 300);
  • sk:40 字节(80 个 hex 字符)的确定性私钥,所有向量共用。脚本注释说明该字节串"arbitrary but non-trivial",保证底层标量的每个 limb 都取非零值,从而充分覆盖标量运算路径;
  • account_index / api_key_index / nonce / expired_at:签名上下文(TxContext)的四个要素;
  • fields:该交易类型的业务字段(订单参数、integrator 归因、杠杆参数等);
  • tx_type:交易类型判别符,与脚本中 TX_TYPE_L2_* 常量对应;
  • tx_info:签名器吐出的权威 JSON 字符串(含 base64 编码的 Sig);
  • tx_hash:40 字节小端编码的签名消息哈希,即 sequencer 侧回显的 tx_hash;
  • sig:80 字节签名,按 s_le || e_le 布局。

在 Rust 测试的 OracleVector 反序列化结构中(见 encode.rs),expired_at 和 nonce 独立于 fields 单独解析。一个值得注意的细节:expired_at 是由脚本从签名器返回的 tx_info_decoded["ExpiredAt"] 回读并写入 fixture 的——因为闭源签名器在 FFI 内部会以墙钟时间自动填充该字段,Rust 侧必须读回它才能重建相同的哈希预映像(见 build_vector 的注释)。这保证了"输入被完整固化",是字节等价断言成立的前提。

如何运行:克隆上游 SDK 并执行生成脚本

README 给出完整运行方式(适用于 Linux):

git clone --depth 1 https://github.com/elliottech/lighter-python.git /tmp/lighter-python
cd crates/adapters/lighter/tests/oracle-py
python3 generate_oracle.py \
    --signer /tmp/lighter-python/lighter/signers/lighter-signer-linux-amd64.so \
    --out ../../test_data/signing_tx_oracle.json \
    --auth-out ../../test_data/signing_auth_token_oracle.json

各参数说明:

  • --signer(必填):闭源签名器共享库路径。该 .so 随 lighter-python 包分发;macOS 上应改用 lighter-signer-darwin-arm64.dylib(脚本 help 文本也列出了 {so,dylib,dll} 三种形态);
  • --out(必填):L2 交易 Oracle fixture 的输出路径;
  • --auth-out(可选):设置后,脚本还会驱动签名器的 CreateAuthToken,对固定的 (deadline, account_index, api_key_index) 三元组签名,并把签好的 REST/WS 认证令牌写入该 JSON 文件。Rust 侧在 auth_token.rs 中加载它,对同一消息重算摘要并验证令牌内嵌签名。

运行前提与可复现保证:

  • 脚本以固定私钥(fixed_private_key())初始化签名器,并合成固定的 ExpiredAt,因此连续多次运行会产出字节一致的 tx_hash 与 tx_info(除 Sig 外);
  • Sig 本身是非确定性的:上游签名器每次调用都会抽取新的随机 nonce k。所以 fixture 固化的是输入和结果 tx_hash 以支持确定性字节等价,而签名字节仅作为"验签侧往返"(verify-side round trip)的见证;
  • 该程序已提交入库以保障可复现性,但它不属于 crate 的构建产物——不会被编译进 crate,只在签名器版本升级或新增交易类型时由人工手动运行。

生成脚本还固定了上游版本指纹以增强可追溯性:

UPSTREAM_VERSION = "1.1.2"
UPSTREAM_REVISION = "6957dd8a1b36894ca9580be0d51de30aeea3bd4a"

这些信息连同许可证声明(Apache-2.0,SDK 仓库;编译签名器以二进制形式分发)被写入 fixture 的 metadata 块。

生成脚本内部:ctypes FFI 绑定与确定性输入设计

generate_oracle.py 的核心机制是 ctypes 动态加载签名器共享库,并镜像其 FFI 结构体与函数签名。

FFI 结构体镜像

脚本用两个 ctypes.Structure 精确对应签名器的 C ABI:

  • SignedTxResponse:(txType: u8, txInfo: ptr, txHash: ptr, messageToSign: ptr, err: ptr),是 Sign* 系列函数的返回结构;
  • StrOrErr:(str: ptr, err: ptr),是 CreateAuthToken 的返回结构。

所有 Go 分配的 C 字符串通过 take_str 读取并以 lib.Free(ptr) 释放,避免内存泄漏。setup_lib 为每个函数逐一绑定 argtypes / restype,包括 CreateClient、SignCreateOrder、SignCancelOrder、SignModifyOrder、SignCancelAllOrders、SignUpdateLeverage、SignApproveIntegrator、CreateAuthToken。

覆盖的交易类型与判别符

脚本顶部以常量形式固化了与 lighter-go 一致的 tx 类型判别符:

常量 值 说明
TX_TYPE_L2_CREATE_ORDER 14 创建订单
TX_TYPE_L2_CANCEL_ORDER 15 取消订单
TX_TYPE_L2_CANCEL_ALL_ORDERS 16 取消全部订单
TX_TYPE_L2_MODIFY_ORDER 17 修改订单
TX_TYPE_L2_UPDATE_LEVERAGE 20 更新杠杆
TX_TYPE_L2_APPROVE_INTEGRATOR 45 批准 integrator

每个 gen_* 函数在解码响应后都会用 decode_expected 校验返回的 tx_type 是否与预期一致,防止签名器行为漂移。

确定性输入矩阵(nonce 0–16)

build_tx_vectors 以严格递增的 nonce 顺序构造了一组高覆盖的向量集,从源码可以看出其刻意覆盖的分支:

  • CreateOrder 基准向量(nonce=0):限价 GTT 卖单,0.1 ETH @ 4050 USDC(base_amount=1000,price=405000,均以 tick 计),is_ask=True;
  • 带 integrator 归因的 CreateOrder(nonce=1):integrator_account_index=723813、integrator_taker_fee=250、integrator_maker_fee=100,覆盖非空 L2TxAttributes 的聚合哈希路径;
  • CancelOrder(nonce=2):按 client_order_index 取消;
  • ModifyOrder(nonce=3):改量与改价;
  • ApproveIntegrator(nonce=4):非交易关键路径;
  • 买入 + reduce-only 的 CreateOrder(nonce=5):覆盖 is_ask=False 与 reduce_only=True;
  • skip_nonce=1 的 CancelOrder(nonce=6):覆盖 cancel 哈希上的属性聚合分支及 {"4":1} JSON 形态;
  • skip_nonce=1 的 ApproveIntegrator(nonce=7):同一门禁在 approve 路径上;
  • 带 integrator 归因的 ModifyOrder(nonce=8):覆盖 modify 上的聚合哈希分支(基准 modify 向量 integrator 槽全零,会短路到 body 摘要);
  • 四种条件单(nonce=9–12):Stop loss(type=2)、Stop loss limit(type=3)、Take profit(type=4)、Take profit limit(type=5),其中市价触发类要求 IOC(time_in_force=0),限价触发类使用 GTT(time_in_force=1),均携带非零 trigger price;
  • 账户级立即取消 CancelAllOrders(nonce=13):cancel_all_market_index=255 是上游的 nil 哨兵值,与 Rust 端发出的账户级 payload 一致;
  • UpdateLeverage(nonce=14):initial_margin_fraction=500(5%,即 20 倍杠杆)、margin_mode=1(isolated);
  • 生产形态 integrator 归因(nonce=15–16):仅使用 integrator 账户索引、费用覆盖为零的部分映射形态,分别覆盖 create 与 modify。

认证令牌 Oracle 的向量设计

build_auth_vectors 生成令牌向量,固定一组 deadline 让预映像中出现不同的 ASCII 片段:

fixed_deadlines = [
    1_700_000_000, 1_777_809_907, 1_999_999_999, 1_111_111_111, 1_234_567_890,
]

每个 deadline 在 (api_key_index=5, account_index=12345) 的种子槽位上签名;此外额外注册 api_key_index=0 的客户端(CreateClient 对每个 (api_key, account) 组合是必需的),在 deadline=1888888888 上再生成一条向量,以覆盖标量第二 limb 路径。

Rust 侧的三重断言:字节等价、验签、自签回环

在 signing/tx/encode.rs 的 #[cfg(test)] mod tests 中,每个 oracle_tx_hash_matches_* 测试对每类向量依次执行三个断言,README 对此有明确描述:

1. assert_hash_matches:tx_hash 字节等价

用 fixture 中的 fields 与上下文重建强类型的 *TxInfo,调用 compute_tx_hash(tx, chain_id),断言输出与 fixture 中的 tx_hash 十六进制字符串完全一致。哈希组装是确定性的。

fn assert_hash_matches<T: LighterTx>(tx: &T, v: &OracleVector) {
    let got = compute_tx_hash(tx, v.chain_id);
    assert_eq!(bytes_to_hex(&got), v.tx_hash, "{}: tx_hash diverged", v.kind);
}

这背后是 encode.rs 中 compute_tx_hash_fp5 的双分支管线:

  • 把 [chain_id, tx_type, nonce, expired_at, account_index, api_key_index, ...body] 组装为域元素预映像,经 hash_to_quintic_extension 得到单一 Fp5 摘要;
  • 若 L2TxAttributes 非空,再把 (type, value) 对序列哈希成第二个 Fp5,对 body_digest || attributes_digest 再做一次 Poseidon2(hash_two_to_quintic);属性为空则短路为 body 摘要;
  • 最终 Fp5 编码为 40 字节规范小端,即签名消息哈希与交易所侧的 tx_hash。

注意 L2TxAttributes 的规范化顺序(type 1–4 升序:integrator 账户索引、taker 费、maker 费、skip_nonce,见 types.rs)以及 body || attributes 的聚合顺序,都是与 sequencer 字节等价的承重设计,直接来自上游 txtypes.L2TxAttributes.AggregateTxHash。

2. assert_oracle_sig_verifies:上游签名被我们的验签接受

从 fixture 的 sk 派生公钥,用我们自己的 compute_tx_hash 重算哈希,断言 pk.verify(hashed, &sig) 通过。这一断言的作用是:把关我们的域/曲线/哈希/Schnorr 解码器与闭源签名器输出的一致性——如果域元素编码或 Poseidon2 参数与官方不一致,验签必然失败。

3. assert_round_trip_sign:同一私钥下的自签回环

用同样的 sk、显式 k(从 sk 字节派生并异或 0x01 保证非零且规范)对同一哈希签名,断言:

  • 自签产生的 tx_hash 仍与 fixture 字节等价;
  • 自签签名能被我们自己的 verify 接受。

由于上游每次抽取新 k,签名器的 Sig 与我们的 Sig 不会字节相同,但两者都是同一哈希的合法签名。该断言证明了"即使不用 oracle 的随机签名,我们的显式 k 签名路径也自洽"。

tx_info 的字节级 JSON 等价(模 Sig)

除哈希与验签外,测试还断言 wire JSON 的字节等价:create_order_json_byte_equals_oracle_modulo_sig、modify_order_json_byte_equals_oracle_modulo_sig、cancel_all_orders_json_byte_equals_oracle_modulo_sig、update_leverage_json_byte_equals_oracle_modulo_sig、approve_integrator_json_byte_equals_oracle_modulo_sig 等测试用 redact_sig 将随机 Sig 替换为占位符后逐字节比较 JSON 字符串。这验证了 encode.rs 中 TxInfoJson 渲染器与上游 Go marshaling 的字段顺序一致(如 AccountIndex/ApiKeyIndex 前置、ExpiredAt/Nonce 后置、空属性输出 null、skip_nonce 输出 {"4":1}),这是 sequencer 在 sendTx 上期望的精确形态。

覆盖完整性守卫测试

fixture 之外,测试还包含若干"元断言",确保向量集合本身不过期:

  • oracle_covers_conditional_create_order_type:断言 fixture 中存在 type 2/3/4/5 四种条件单向量;
  • oracle_covers_production_integrator_attributes:断言 create/modify 存在 integrator 账户索引为 723813、费用为零的生产形态向量。

认证令牌(Auth Token)Oracle 的验证路径

Lighter 交易所对长连接客户端以"ASCII 消息上的 Schnorr 签名"做认证,签名字符串原样放入 REST 的 Authorization 头与 WebSocket 订阅握手。认证令牌管线(与 Go 参考 ConstructAuthToken 一致,见 auth_token.rs):

  1. 渲染 message = "{deadline}:{account_index}:{api_key_index}";
  2. 将 ASCII 字节按 8 字节小端分块(末块补零),每块解码为规范 Goldilocks 元素 Fp;
  3. 对 [Fp] 运行 hash_to_quintic_extension 得到单一 Fp5 摘要;
  4. 用 (sk, k) 走标准 Schnorr 绑定签名;
  5. 拼接 "{message}:{hex(sig)}",其中 sig 是规范 80 字节 s_le || e_le 布局的小写十六进制。

注意认证令牌的哈希路径与交易 compute_tx_hash 不同:令牌把消息当作不透明 ASCII 按原始小端字节打包进 limb,而交易把每个字段编码为 Fp 域整数。

Rust 侧测试 oracle_auth_tokens_verify_against_our_hash(auth_token.rs)对 signing_auth_token_oracle.json 的每个向量:

  • 断言令牌前缀 message 与 auth_token_message(deadline, account_index, api_key_index) 重建值一致;
  • 用 hash_auth_message 重算摘要;
  • 在派生公钥下断言内嵌签名验证通过。

令牌向量的 metadata.note 也明确说明:"Sig 非确定性(随机 k);Rust 侧在派生公钥下验证每个 oracle 令牌,而不是断言签名字节相等。"

与底层密码学模块的关系

Oracle 验证的是"哈希组装 + 域编码 + 签名布局"与官方一致性,而底层原语本身的正确性由更底层的 fixture 向量保证——test_data 目录中并列存放着:

这些向量与 fuzz_pornin_diff_* 差分测试(对照 Pornin 的 MIT 许可参考实现逐字节断言代数运算)共同构成第一层门禁,Oracle 则在其之上构成第二层,形成"底层原语可证正确 → 上层组装与官方一致 → 最终 sequencer 实盘接受"的完整验证链。

何时需要重新生成 fixture

README 明确指出该程序不构建进 crate,仅在两种情况下人工运行:

  1. 上游签名器版本升级:UPSTREAM_VERSION / UPSTREAM_REVISION 指纹变化后,应重新生成 fixture 并提交,确保 Rust 测试始终对最新签名器行为做字节等价断言;
  2. 新增交易类型:当 Lighter 增加新的 L2 交易类型时,需要在脚本中新增 gen_* 函数与 TX_TYPE_L2_* 判别符,并补充对应的确定性输入向量。

由于 Sig 随机,重新生成的 fixture 中 sig 字段会变化,但 tx_hash 与 tx_info(模 Sig)必须保持稳定——若重新生成后 tx_hash 漂移,说明上游签名器行为已变,需要同步排查 Rust 端编码实现。

小结

Lighter 签名交易 Oracle 是 nautilus_trader 在"闭源依赖可信化"上的一个典型工程实践:无法审计官方签名器源码,就用确定性输入把它变成可复现的测试基准。通过 generate_oracle.py 生成的 signing_tx_oracle.json 与 signing_auth_token_oracle.json,Rust 侧得以对每种交易类型执行"哈希字节等价 + 上游签名验签 + 显式 k 自签回环 + wire JSON 字节等价(模 Sig)"的全套断言,最终以"字段顺序锁定、属性聚合承重、上游版本指纹化"的方式,把 L2 交易签名的正确性牢牢锚定在可复现、可审计的 fixture 之上。

登录后查看全文
nautilus_trader