自动逆向工程Hyperliquid风险引擎——借助智能体辅助
本文通过逆向工程分析了Hyperliquid去中心化永续合约交易所的风险引擎实现,揭示了其清算与自动去杠杆(ADL)机制。
永续合约
定义
永续合约是一种对价格的押注,通常带有杠杆,以稳定币结算,且没有到期日。它是交易所账本中的一行,以保证金为后盾,其中一方的收益就是同一账簿中另一方的亏损。
这跟逆向工程有什么关系呢?
Hyperliquid
Hyperliquid 是一个去中心化永续合约交易所。它运行在自己的 L1 上,公开发布供任何人运行,但代码是闭源的。订单簿和风险引擎位于链上,因此位于 L1 节点二进制文件内,处于用户和他们共同押注的共享资产负债表之间。
节点面临着一个非常困难的问题:当杠杆使得每笔交易中的一方可能亏损超过其保证金覆盖范围时,节点必须保持账目平衡。
实现一个正确的算法来解决这个问题,正是保持交易所偿付能力的关键。这也是为什么从安全角度来看,深入研究实际实现非常引人入胜。
除此之外,hl-node 二进制文件还提供了额外的技术乐趣。它是用 Rust 编写的,而 Rust 以其难以逆向工程而闻名。
带着一些好奇心、大量的推理和正确的工具,我们将深入探讨重构后的代码,以便任何人都能跟上,同时也让逆向工程师有所收获。
价值百万美元的问题
假设一个糟糕的交易者在 Hyperliquid 上开了一个 50 倍 杠杆的头寸。交易所如何避免因他们最终的失败而亏损?
当市场对他们不利时,他们首先会遇到传统的清算。他们的头寸会自动以相反的挂单方向挂在市场上。每当头寸的权益低于维持保证金时就会发生这种情况,这在 compute_margin_requirement_by_mode() 函数中有明确说明。
从最简单的情况开始,一个独立(isolated)头寸。相关函数是 compute_account_value_for_direction()。
int64_t sub_555556bb4d20(int64_t arg1, int64_t arg2, int64_t arg3, int64_t arg4, int64_t arg5, int64_t arg6)
struct OptionUnitQty* compute_account_value_for_direction(struct OptionUnitQty* result, struct UserState* user_state, uint8_t is_isolated, uint64_t asset_idx, uint32_t margin_mode, void* oracle_data)
重要的部分是参数列表。该函数接收一个 UserState、一个独立或全仓选择器、一个资产索引、一个保证金模式以及预言机数据。
函数体根据 is_isolated 进行分支。全仓保证金(Cross margin)走账户范围路径,而独立保证金(Isolated margin)则继续进入单一头寸计算。
struct OptionUnitQty* compute_account_value_for_direction(struct OptionUnitQty* result, struct UserState* user_state, uint8_t is_isolated, uint64_t asset_idx, uint32_t margin_mode, void* oracle_data)
{
// 4 行折叠
int128_t var_138;
struct OptionUnitQty lhs;
int64_t rax, rcx, rdx;
bool cond;
if (!(is_isolated & 1))
{
// 全仓保证金计算账户范围价值和缺口。
compute_user_margin_and_shortfall(&var_138, user_state, oracle_data, nullptr);
return result;
}
// 独立保证金使用该资产的保证金和自身盈亏。
}
重要的细节是,独立权益是局限于头寸本身的。它是独立保证金加上头寸自身的盈亏。由此,阈值简化为以下表达式。
$$ \text{liq_equity} = \frac{|\text{notional}|}{2 \cdot \text{leverage}} $$
如果没人愿意买入这个头寸怎么办?
上一节中的市价单只是一个普通订单,它仍然需要有人在订单簿的另一边来成交。如果订单簿很薄,或者波动足够剧烈以至于所有被动挂单都被扫光,那么只有部分(或全部)会成交,权益可能会继续下降。
当这种情况发生时,交易所基本上剩下一个需要摆脱的“烫手山芋”。它应该如何处理这个债务呢?
风险引擎
现在我们回到风险引擎。为了防止所有这些糟糕的交易者将系统推向崩溃,我们需要一些自动的方法来预防和清除坏账。
Hyperliquid 文档 解释说,低于 2/3 维持保证金(maintenance margin)的头寸会被后备清算到 HLP 清算人金库。即使这样也无法保持账簿偿付能力时,自动去杠杆(auto-deleveraging,ADL)会通过对立交易者来平仓。这到底是什么意思?
为了更好地了解这里发生了什么,我们转向二进制文件本身并追踪实际逻辑。一个简单的测试网同步给了我们很多上下文。在节点的 ABCI 状态中,我们可以看到名为 Clearinghouse 的相当复杂的结构,这是一个包含所有清算相关信息的结构体,嵌套在更大的 Exchange 数据结构中。这是它在运行时序列化为 MessagePack 时可能包含的内容示例(下载示例转储,234 MB)。
这些数据对我们和智能体都很有用。
那么,风险引擎何时实际检查头寸呢?我们可以通过从 Clearinghouse 中已知字段字符串开始,使用交叉引用来回答这个问题。经过一些重命名后,调用路径如下所示:
我们有一个处理所有清算所操作的大函数,我们称之为 clearinghouse_adl_orchestrator()。它在每个区块结束时由顶层方法(间接在其他地方调用,很可能是一个 trait 方法)调用,我们称之为 exchange_end_block()。因此,在每个区块结束时,我们会进行一些检查。
以 only_isolated 分支为例,它直接将某些资产快速追踪到去杠杆:
1 · 原始反编译结果 2 · 命名和类型化 3 · 惯用 Rust
int64_t var_28 = *(arg1 + 8);
if (*(arg2 + (var_28 << 5) + 0x18) != 0) {
if (LOGGER_ONCE_CELL != 3)
once_cell_get_or_init(&LOGGER_ONCE_CELL, 0, &var_a0, &data_55555878bb08);
int64_t rax_11 = rust_alloc(0x34);
__builtin_strncpy(rax_11, "immediately auto-deleveraging only_isolated position", 0x34);
// ...追加条目...
}
int64_t entry_asset_idx = *(ph1_entry_ptr + 8);
if (clearinghouse->asset_count <= entry_asset_idx)
panic_index_out_of_bounds(entry_asset_idx, clearinghouse->asset_count);
if (*(clearinghouse->asset_array_ptr + (entry_asset_idx << 5) + 0x18)) // asset.strict_isolated != 0
{
if (LOGGER_ONCE_CELL != 3)
once_cell_get_or_init(&LOGGER_ONCE_CELL, 0, &scratch_opt, &data_55555878bb08);
int64_t rax_11 = rust_alloc(0x34);
__builtin_strncpy(rax_11, "immediately auto-deleveraging only_isolated position", 0x34);
// ...追加到延迟队列...
}
if asset_strict_isolated(ch, *asset_idx) {
fill_ctx.log_lines.push(String::from("immediately auto-deleveraging only_isolated position"));
deferred_cross_queue.push((*counterparty_addr, *asset_idx));
}
这个分支给了我们第一个 ADL 快捷方式。严格独立(strict isolated)资产绕过正常的聚合缺口门控,直接进入 ADL 队列。
由此,ADL 路径有一个清晰的四阶段形状:
// 98 行折叠
use std::collections::{BTreeMap, HashMap};
pub struct OptionUnitQty {
pub tag: u64,
pub unit: u64,
pub value: i64,
}
pub struct UserState {
pub acct_value: OptionUnitQty,
pub margin_used: OptionUnitQty,
pub positions: BTreeMap<u64, [u64; 17]>,
pub funding_state: OptionUnitQty,
}
pub struct AssetConfig {
pub coin: String,
pub strict_isolated: bool,
pub spot_only: bool,
pub max_leverage: u16,
pub asset_flags: u32,
}
pub struct OraclePrice {
pub mark: u64,
pub oracle: u64,
pub mid: u64,
pub funding_rate: u64,
pub spot: u64,
pub last_trade: u64,
pub min_size: u64,
pub tick_size: u64,
pub szi: u64,
pub usd_value: u64,
pub interest: u64,
pub volume: u64,
}
pub struct OracleData {
pub prices: Vec<OraclePrice>,
pub recent_oi: BTreeMap<u64, OptionUnitQty>,
}
pub struct AdlFillTracker {
pub log_lines: Vec<String>,
pub pending_fills: Vec<u64>,
pub fill_list_idx: u64,
pub asset_to_fills: String,
pub block_time_us: u64,
}
pub struct Clearinghouse {
pub total_net_deposit: OptionUnitQty,
pub total_non_bridge_deposit: OptionUnitQty,
pub adl_shortfall_remaining: OptionUnitQty,
pub bridge2_withdraw_fee: OptionUnitQty,
pub default_user_state: UserState,
pub lookup_asset_ref: u64,
pub assets: &'static [AssetConfig],
pub oracle_prices: Vec<OraclePrice>,
pub oracle_funding: OptionUnitQty,
pub oracle_misc: OptionUnitQty,
pub margin_tables: BTreeMap<u64, u64>,
pub user_states: BTreeMap<[u8; 20], UserState>,
pub vault_states: HashMap<[u8; 20], u64>,
pub spot_balances: HashMap<[u8; 20], u64>,
pub spot_meta: HashMap<u64, u64>,
pub builder_fees: BTreeMap<u64, u64>,
pub asset_recent_oi: BTreeMap<u64, u64>,
pub perform_auto_deleveraging: bool,
}
fn ouq_value(quote: &OptionUnitQty) -> i64 { quote.value }
fn account_value_for_direction(_user: &UserState, _asset_idx: u64, _oracle: &OracleData) -> OptionUnitQty {
OptionUnitQty { tag: 0, unit: 0, value: 0 }
}
fn margin_and_shortfall(_user: &UserState, _oracle: &OracleData) -> OptionUnitQty {
OptionUnitQty { tag: 0, unit: 0, value: 0 }
}
fn build_counterparty_fills(_ch: &Clearinghouse, _ctx: &mut AdlFillTracker, _oracle: &mut OracleData, _addr: &[u8; 20], _asset_idx: u64) -> Vec<u8> {
Vec::new()
}
fn process_counterparty_fill(_ch: &Clearinghouse, _ctx: &mut AdlFillTracker, _oracle: &mut OracleData, _asset_idx: u64, _direction: u8, _byte: u8) -> i64 {
0
}
fn lookup_user_state<'a>(ch: &'a Clearinghouse, counterparty_addr: &[u8; 20]) -> &'a UserState {
ch.user_states.get(counterparty_addr).unwrap_or(&ch.default_user_state)
}
fn asset_strict_isolated(ch: &Clearinghouse, asset_idx: u64) -> bool {
ch.assets.get(asset_idx as usize).map(|a| a.strict_isolated).unwrap_or(false)
}
pub fn clearinghouse_adl_orchestrator(ch: &Clearinghouse, fill_ctx: &mut AdlFillTracker, oracle: &mut OracleData, candidates: &BTreeMap<[u8; 20], u64>) -> BTreeMap<(u64, u8), Vec<u8>> {
let mut result: BTreeMap<(u64, u8), Vec<u8>> = BTreeMap::new();
let mut total_shortfall_value: i64 = 0;
let mut deferred_cross_queue: Vec<([u8; 20], u64)> = Vec::new();
let mut pointer = candidates.iter();
while let Some((counterparty_addr, asset_idx)) = pointer.next() {
let user_state = lookup_user_state(ch, counterparty_addr);
let user_shortfall = account_value_for_direction(user_state, *asset_idx, oracle);
let value = ouq_value(&user_shortfall);
if value > 0 {
panic!("Bug! ADL candidate account value was not negative");
}
if asset_strict_isolated(ch, *asset_idx) {
fill_ctx.log_lines.push(String::from("immediately auto-deleveraging only_isolated position"));
deferred_cross_queue.push((*counterparty_addr, *asset_idx));
} else {
total_shortfall_value = total_shortfall_value.wrapping_sub(value);
}
}
let remaining_shortfall = ch.adl_shortfall_remaining.value;
let adl_was_triggered = total_shortfall_value >= remaining_shortfall;
if adl_was_triggered {
deferred_cross_queue.clear();
let mut pointer_1 = candidates.iter();
while let Some((counterparty_addr, asset_idx)) = pointer_1.next() {
deferred_cross_queue.push((*counterparty_addr, *asset_idx));
}
}
let mut counterparty_array: Vec<[u8; 20]> = Vec::new();
let mut pointer_2 = deferred_cross_queue.iter();
while let Some(&(counterparty_addr, asset_idx)) = pointer_2.next() {
let user_state = lookup_user_state(ch, &counterparty_addr);
let cpty_abs_position = margin_and_shortfall(user_state, oracle);
let _ = cpty_abs_position;
counterparty_array.push(counterparty_addr);
let counterparty_info = build_counterparty_fills(ch, fill_ctx, oracle, &counterparty_addr, asset_idx);
result.insert((asset_idx, 0u8), counterparty_info);
}
let counterparty_count = counterparty_array.len();
let _ = counterparty_count;
let mut pointer_3 = result.iter_mut();
while let Some((&(asset_idx, expected_kind), counterparty_info)) = pointer_3.next() {
let length = counterparty_info.len();
let mut i: usize = 0;
while i < length {
let cpty_addr_bytes = counterparty_info[i];
let fill_delta = process_counterparty_fill(ch, fill_ctx, oracle, asset_idx, expected_kind, cpty_addr_bytes);
let _ = fill_delta;
i = i.wrapping_add(1);
}
}
if !ch.perform_auto_deleveraging {
result.clear();
}
result
}
ADL 是一种应急措施。与清算不同,它不总是可操作的。在这个视图中,该标志作为最后的守卫。除非设置了 clearinghouse.perform_auto_deleveraging,否则结果会在返回前被清空。
if !ch.perform_auto_deleveraging {
result.clear();
}
result
}
注意(执行门控)
底层的反编译在这里更精确。在二进制视图下,perform_auto_deleveraging 门控了第 4 阶段内的实际强制平仓调用。
if (!clearinghouse_1->perform_auto_deleveraging)
goto label_555556ac74ce;
r9_6 = adl_process_counterparty_position(clearinghouse_1,
asset_idx_1, &entry_addr_raw, &fill_delta,
&insolvent_user_addr, r9_5);
所以,把最终的 result.clear() 读作一个不执行模式的紧凑模型,而不是二进制文件强制执行该标志的字面位置。
最重要的是,要使其发挥作用,必须确实需要它。根据 Clearinghouse,这意味着什么?
阈值条件
清算和 ADL 之间的自然联系是 shortfall(缺口),它衡量一个头寸,甚至是整个系统 (!),处于负值多少。
如果我们能够及时清算每个头寸,让愿意承担债务的买家接手,就不会有系统性缺口。只有当这种情况不成立时,ADL 才会被触发。
谁处于亏损状态?
为了确定这个缺口(或坏账总和)[^1],我们处理一个亏损头寸的有用树,它在每个区块结束时构建,并传递给 clearinghouse_adl_orchestrator(),起始于所有用户头寸,这些头寸存储在 clearinghouse.user_states 中,正如我们从序列化中看到的那样。
adl_init_user_position_iterators() 基本上将其转换为一个 Iter,然后在 build_adl_candidate_set() 内部,每个条目根据 AdlIterEntry 字段的值被计为全仓或独立头寸。
struct AdlIterContext {
uint8_t* direction_ptr;
struct OracleState* oracle_ptr;
};
注意,在存储中,Isolated 头寸在位置迭代器中拥有自己独立的 Entry,而 Cross 头寸有一个共同的 Entry,该条目将分布在几个永续合约上。我们将看到更多用于处理这两种头寸的分支。
while (true)
{
int64_t shortfall_tag = entry_cursor->margin_type;
if (shortfall_tag != 2)
{
int64_t asset_idx = entry_cursor->asset_idx;
}
}
最终,我们得到了一个亏损用户的迭代器。第 1 阶段的分类循环只保留账户价值为非正数的条目,这些条目是根据之前的阈值在区块结束时无法清算的条目。正数值会触发 panic,因为一个有偿付能力的账户永远不应该进入 ADL。
在遍历用户之后,如果总损失小于一个预定义的常数(在测试网状态下硬编码为 500 万美元),我们将豁免全仓头寸并记录“不执行自动去杠杆,因为 shortfall={} 是可接受的。”
注意,这个保险基金并不适用于每个资产。虽然这在任何地方都没有记录,但被称为 only_isolated(或在 MessagePack 转储中为 strict_isolated)的市场会被添加到一个单独的队列 deferred_queue 中,无论系统的 total_shortfall 如何,都会触发 ADL。这就是我们之前经历过的第 1 阶段中的 strict_isolated 分支。
非常有趣的是,一些具有此标志的资产,至少在测试网上,包括 HYPE(以及其他相关代币,如 ZRO,以及来自 2025 年 3 月事件 的 JELLYJELLY)。Hyperliquid 的历史元数据非常难以获取,所以在考虑主网时请持保留态度。[^2]
我们如何摆脱债务?
现在我们知道我们什么时候有缺口。在这种情况下该怎么办?这就是不同的风险引擎做出不同设计选择的地方。Hyperliquid 选择应用基于队列的 ADL 系统,这意味着他们强制关闭一些盈利的头寸,以清除亏损交易者的债务。
因此,如果债务无法克服,deferred_queue 会被 build_adl_candidate_set() 标记的所有用户覆盖。否则我们保留它,只有来自前一个循环的处于亏损状态的 strict_isolated 头寸的持有者才会被考虑进行 ADL。这个分支是第 2 阶段的门控:
let remaining_shortfall = ch.adl_shortfall_remaining.value;
let adl_was_triggered = total_shortfall_value >= remaining_shortfall;
if adl_was_triggered {
deferred_cross_queue.clear();
let mut pointer_1 = candidates.iter();
while let Some((counterparty_addr, asset_idx)) = pointer_1.next() {
deferred_cross_queue.push((*counterparty_addr, *asset_idx));
}
}
对于每个亏损头寸,第 3 阶段通过恢复的 lookup_user_state() 辅助函数在 clearinghouse.user_states[position.user] 上进行 B 树查找,并将用户推入一个 Vec。你会惊讶于这样一个简单语句的编译结果有多长。这只是 push 操作本身,包括增长检查:
if (cpty_vec_count == cpty_vec_cap)
OPTION_UNIT_QTY_NONE_2 = vec_grow_0x28(&cpty_vec_cap);
int64_t rcx_11 = cpty_vec_count * 5;
*(cpty_vec_ptr + (rcx_11 << 3) + 0x10) = scratch_opt.set.height;
OPTION_UNIT_QTY_NONE_2 = scratch_opt.tag;
*(&OPTION_UNIT_QTY_NONE_2 + 8) = scratch_opt.set.root_ptr;
*(cpty_vec_ptr + (rcx_11 << 3)) = OPTION_UNIT_QTY_NONE_2;
*(cpty_vec_ptr + (rcx_11 << 3) + 0x18) = deferred_entry_tag;
*(cpty_vec_ptr + (rcx_11 << 3) + 0x20) = entry_addr_raw_1;
cpty_vec_count += 1;
全仓头寸的逻辑更复杂,因为我们基本上要问的是如何将一个破产用户的总缺口分摊到其各个头寸上,以便我们能够吸收每个头寸的正确比例。自然的划分是按其占总全仓名义价值的份额来加权每个头寸:
$$ \begin{aligned} \text{position_weight} &= \frac{\text{position_notional}}{\text{total_cross_notional}} \ \text{position_adl_amount} &= \text{user_shortfall} \cdot \text{position_weight} \end{aligned} $$
在上面四个阶段的草图中,这种比例分配由 build_counterparty_fills() 存根表示:
fn build_counterparty_fills(_ch: &Clearinghouse, _ctx: &mut AdlFillTracker, _oracle: &mut OracleData, _addr: &[u8; 20], _asset_idx: u64) -> Vec<u8> {
Vec::new()
}
真实版本涵盖了全仓保证金头寸中持有的所有 (asset_idx, direction),并根据上面的公式对每个进行加权。
对于独立头寸,计算很简单,我们直接将单个头寸及其缺口写入 adl_output B 树,这是此转换对独立和全仓头寸的输出,包含 position_id 和 position_shortfall。
之后,我们可以最终遍历包含 (position_id, cut) 的 B 树,这确切地告诉我们每个头寸需要关闭多少,以便稍后从一个盈利头寸中去杠杆。
clearinghouse_adl_orchestrator() 的最后阶段遍历它,并为每个键构建一个对手方数组,最初包括所有持有头寸的用户,根据每个资产的 ADL 排名分数进行排序。[^3]
如何选择(排序)头寸进行去杠杆?
我们对谁进行去杠杆?
这个问题对于永续合约平台的偿付能力和公平性至关重要。为什么偿付能力是优先事项很明显。
但公平性是什么意思?通俗地说,我们可以说账户的相对财富不应受到去杠杆的影响。
更正式地说,公平性可以有多种相关定义。关于一种处理方法,请参阅这篇论文。我们想要证明 HL 中使用的算法在其实现中并非公理上公平,如论文命题 6.1 所定义。
那么,Hyperliquid 公平吗?它总是有偿付能力吗?
ADL 分数计算
compute_adl_ranking_score() 处的分数函数是回答这个问题的核心。它很可能是作为某些 20 字节地址表示的 Ord 实现来定义的。一个 Vec 保存了可能的对手方用于成交,该 trait 被排序例程使用,这些例程根据每个亏损头寸的排名分数对它们进行排序。
这些排序例程是由编译器生成的。由于 Rust 二进制文件默认在 .comment 中包含大量元数据,我们甚至可能手动利用这一点来恢复库函数的确切源码。
让我们看看这如何关联回 clearinghouse_adl_orchestrator():
排序本身是由编译器生成的,所以没有一个单独的手写调用点可以指向。编排器将其对手方交给这些例程,这些例程在每次比较时回调 compute_adl_ranking_score()。
比率 1:有效杠杆
定义(有效杠杆)
$$ \text{effective leverage} = \frac{|\text{notional}|}{\text{account value}} $$
在恢复的分数函数中,第一个比率是头寸名义价值的绝对值除以其账户价值,这是用户承担的风险倍数。
let abs_notional = (signed_notional_product as f64).abs();
let account_value = result_2.account_value_unit.qty as f64;
let ratio1_margin_ratio = (abs_notional / account_value).max(1e-8);
注意(操作数检查)
在 label_555556abb73f 附近的底层反编译注释将此比率标记为 account_value / abs_notional,但原始指令操作数将其确定为另一种方式。分子使用无符号 u64 → f64 SIMD 转换模式,这与 abs_notional 匹配。分母使用有符号 cvtsi2sd,这与 account_value 匹配。最终的 divsd 计算分子除以分母。
比率 2:利润比率
定义(利润比率)
$$ \text{profit ratio} = \frac{\max(\text{pnl},, 0)}{\text{entry notional}} $$
第二个比率将头寸的盈亏(PNL)限制为非负,除以其入场名义价值,告诉我们用户的押注结果如何。[^4]
let result_4 = (signed_notional_product + entry_notional_1).max(0) as f64;
let result = ratio1_margin_ratio * (result_4 / (entry_notional_4 as f64)).max(1e-8);
存在限制以避免舍入问题。两个比率都被限制在最小 1e-8,以免相互抵消。最终的 ADL 排名分数是 effective_leverage * profit_ratio,这与恢复的 Rust 代码和原始操作数检查一致。
直觉
我们寻找的是风险高且盈利的头寸!
部分和全部 ADL
最后,注意去杠杆也可以是部分的,但即使如此,它也按照这个分数定义的顺序进行。
在这里,我们根据 ADL 是全部还是部分进行分支,在前一种情况下关闭头寸。在二进制视图中,该决策比较对手方的头寸大小和剩余需要填充的缺口:
if (cpty_pos_size <= fill_remaining_check_2)
{
// 完全填充会弹出已消耗的对手方并获取整个头寸。
cpty_ranking_count -= 1;
// ...
fill_delta.value = cpty_pos_abs_szi;
}
因此,本质上,clearinghouse_adl_orchestrator() 的后半部分归结为第 4 阶段,该阶段按资产和方向执行填充:
let mut pointer_3 = result.iter_mut();
while let Some((&(asset_idx, expected_kind), counterparty_info)) = pointer_3.next() {
let length = counterparty_info.len();
let mut i: usize = 0;
while i < length {
let cpty_addr_bytes = counterparty_info[i];
let fill_delta = process_counterparty_fill(ch, fill_ctx, oracle, asset_idx, expected_kind, cpty_addr_bytes);
let _ = fill_delta;
i = i.wrapping_add(1);
}
}
上述执行门控的注意事项适用于此调用。[^5] 二进制文件仅在设置了 perform_auto_deleveraging 时执行强制平仓操作。
注意,fill.pos_szi 是破产用户的完整头寸大小,而不仅仅是头寸缺口。这是理解 ADL 财务影响的关键。
分支
这里还有很多可以探索的。我们可以尝试向前迈出一步,根据我们目前所学推测一些财务结论,或者我们可以退后一步,对使这一切成为可能的工具进行一些内省。
鉴于这两个方向的极端对立性,作者决定让读者选择自己的冒险。
财务结论
公平性、收入、偿付能力三难困境
与前面提到的论文 §2.1 命题 2.5 中提出的三难困境相一致,让我们尝试对每个轴进行具体估计:
- 偿付能力衡量平台是否能够支付所有交易者。
- 公平性使用命题 3.4 的公理公平性定义。
- 收入衡量去杠杆后幸存的总赢家 PNL 的比例。
让我们借用论文中的这些定义。
符号
$$ \begin{aligned} P_n &:= \text{标记价格} \ I &:= \text{保险基金} \ c_i &:= \text{抵押品} = \frac{\text{名义价值}}{\text{杠杆}} \ \pi_i &= s_i \cdot (P_n - p_i) \ e_i &= c_i + \pi_i \ w_i &= \pi_i^+ \ x_i &:= \text{从赢家 } i \text{ 处没收的美元} \ h_i &= \frac{x_i}{w_i} \ H_T &= \sum_i x_i \ D_T &= \sum_i \max(-e_i,, 0) \ U_T &= \sum_i w_i \ R_t &= \max(D_T - I - H_T,, 0) \end{aligned} $$
这里,$P_n$ 是来自不同来源的标记价格,$I$ 是保险基金,大致相当于测试网 HL 上的 500 万,$\pi_i$ 是多头的 PNL,空头取反,$w_i$ 是 PNL 的正数部分。
让我们具体定义三难困境。
- 偿付能力 是 $1 - \frac{R_t}{D_T}$。它衡量实际覆盖了多少总坏账。$S=1$ 表示完全偿付($R_t=0$),而 $S=0$ 表示没有任何恢复。
- 公平性 是 $1 - \text{Gini}(h_i)$,其中 $h_i = x_i / w_i$,对于每个 $w_i > 0$ 的赢家。它衡量减记负担在盈利交易者之间的分布均匀程度。$F=1$ 意味着每个人损失相同比例的 PNL。$F \approx 0$ 意味着少数人被清零,而大多数人未受影响。
- 收入 是 $U_T^\pi / U_T$。这里 $U_T^\pi$ 是减记能力,或总正 PNL,经过策略 $\pi$ 之后。
我们现在可以为 Hyperliquid 和 Percolator(Anatoly Yakovenko 开发的一种新的基于按比例分配的永续合约引擎,github.com/aeyakovenko/percolator)模拟这些值。
我们固定一个先验价格路径来模拟两种情况。第一种是暴跌后复苏,第二种是暴跌后接着同样严重的第二次暴跌。这是去杠杆最常见的原因,但相反方向的变动也可能触发相同的机制。
我们可以预测的一个结果是,根据这个评分,Percolator 是完全公平的,因为每个人的 $h_i$ 都相同。因此,基尼系数必须为 0。
我们忽略了去杠杆造成的后验价格影响(阅读那篇论文中的 10 月 10 日暴跌分析)。全仓杠杆也很有趣,因为它在跨交易资产之间引入了更多相关性,而在 Percolator 中,每个资产(slab)都有一个风险引擎。
Hyperliquid 还有一个有趣的注意事项,正如我们之前展示的,它使用一个条件保险基金。我们目前只模拟一个资产,但这会在多个资产以独立或全仓模式交易时影响方程。
模拟
让我们考虑以下价格情景:
使用 Python 重新实现运行这两个系统的模拟,得出以下结果:
Percolator 更“乐观”
关键区别在于头寸发生了什么。ADL 永久关闭它们。如果市场继续朝着对你有利的方向移动,那就算你倒霉,你已经出局,必须以新价格重新入场。Percolator 只减少你可以提取的资金,但头寸保持开放。如果条件改善,Residual 上升,$h$ 爬回接近 1,你无需做任何事就能拿回你的 PNL。
我们可以在一个轧空场景(60% 空头市场,价格上涨 10%)中看到这一点。HL 的队列关闭了 27 个盈利多头中的 8 个——如果涨势继续,这些交易者将得到零。在 Percolator 下,所有 27 个都保持其全部头寸(只有可提取的利润被统一减记减少),并且都参与后续的上涨。注意,HL 幸存的头寸是全部规模,因此它们每个头寸会单独获得更多收益,但代价是更少的交易者能够参与。
Percolator 对杠杆无感
我们可以证明 Hyperliquid 恰恰相反,正如我们追踪了 ADL 算法的实现,它不成比例地针对更高杠杆。
公平性
同一篇论文中提出的一个观点是 Hyperliquid 是“反公平的”。节点实现按分数降序选择头寸,关闭它们,并保持其他一切不变。
该论文已经在命题 8.1 中说明,像 HL 这样的基于队列的算法能更快地达到偿付能力。我们可以通过观察 ADL 的触发方式看到这一点。在 clearinghouse_adl_orchestrator() 中,我们首先针对表现最好的交易者,完全关闭最差的头寸。
智能体在逆向工程中的优势与局限
总的来说,除了非常直接的任务外,大部分工作都是由底层模型完成的。将模型过于推向一组固定的可能结论,通常会使它在搜索问题上表现更差。
Hook(Hooks)
工具通过Hook来检查智能体工作非常出色。智能体对不确定的问题(如类型恢复)提出解决方案,然后 Hook 检查该类型是否具有与汇编级别实际使用的兼容偏移量和嵌套指针。我们在重构的 Rust 代码片段中对数据流应用了类似的更简单检查。
同时,在可读但未经验证的输出与技术上根据 Hook 通过但不可读的输出之间存在一条非常细的线。通常,Hook 用时间和可读性来换取每个阶段可检查的具体属性,例如 Rust 样本是否编译并使用与反汇编一一映射的变量。
规模
要吸取的第二个教训是,智能体在大规模任务上确实表现出色。更新的模型可以比最优秀的逆向工程师掌握更多上下文,因此使用它们进行批量循环任务(如重命名许多函数)是最优选择。
话虽如此,智能体也远比最优秀的逆向工程师过于自信。在智能体拥有可“写入”反编译器视图的工具的设置中,错误的猜测会在恢复阶段累积。工具层通过将失败的猜测强制返回分析来提供帮助,这为智能体提供了更多数据,然后再尝试更困难的问题。
接下来是什么?
这绝不是一个严格的科学结论,而是一些现场笔记。智能体及其工具的公开源代码可以在这个 GitHub 仓库中找到。
分析过的二进制文件可作为 hl-node(53 MB)获取。
结语
虽然如果说在 LLM 时代一切都可以被破解和研究是令人兴奋的,但仍然存在一些需要弥合的差距。
旗舰模型让我们完成了 60% 的工作,但仍有大量的手动工作需要完成。我们仍然需要记录 Rust 编译器中的模式,将这些知识提炼到智能体和工具中,并理解程序背后的“为什么”,而不仅仅是技术性的“是什么”。
尽管如此,毫无疑问 LLM 极大地加速了该领域的进展,而将一群装备精良的智能体投向一堆丑陋的机器代码,以提取有意义且最重要的是正确的表示和源代码的梦想正在接近。
我们希望成为那个未来的一部分,届时即使复杂且混淆的系统也能一目了然地得到验证。
脚注
-
compute_user_margin_and_shortfall()是一个普遍用于记账的函数。在其主分支中,我们遍历一个 B 树(注意btree_cursor抽象表达式中的重复偏移量0x748)的每个用户头寸,根据全仓/独立头寸进行分支(以了解要考虑的名义价值)。↩ -
Hyperliquid 的一些历史数据可以通过
s3://hl-mainnet-node-data/explorer_blocksS3 存储桶检索。↩ -
ADL 代码中的一些循环迭代看起来非常低效。例如,我们似乎对每个需要吸收的头寸进行排序并得出排序后的对手方。↩
-
ADL 在失败时会使节点崩溃。具体来说,如果我们未能完成对任何人的所有填充,循环中的断言会失败。↩
- 原文链接: osec.io/blog/hyperliquid...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~






