A baited hook under a magnifying lens

FIELD NOTE 01 ·

Free to quote. Costly to swap.

模拟低费率,成交最高收 14%

Three pools on Robinhood Chain showed near-zero fees in simulations, then charged up to 14% on real swaps. We traced the fee changes, the liquidity owners, and the code used to recognize a simulation.

Robinhood Chain 上有三个池子,模拟时几乎不收手续费,真实成交最高抽到 14%。它们靠读取交易的 gas price 识别模拟环境:探测时报低费率,上链后换高费率。

3
Confirmed bait pools
已确认的钓鱼池
Robinhood Chain
Robinhood Chain
6,419
USDG in estimated fees
估算手续费(USDG)
1,003 swaps
1,003 笔交换
14%
Highest traced fee
链上实测最高费率
Near zero in simulation
模拟时接近零

What happened

Our AgentSwap solver checked a swap and got a green light. The real transaction failed with InsufficientOutput, after spending roughly one million gas. Five of six sampled failures had the same cause: the pool charged more than the simulation showed. (Replay evidence)

The trick was simple. A Uniswap V4 hook can set a pool's fee and read the transaction's gas price. In our zero-gas-price calls, these hooks returned a low fee. With a real transaction's gas price, they returned a higher one. Replaying the same transaction in both settings reproduced the mismatch. (Fee probes)

HookSimulation feeReal transaction fee
H1 · 0x0d83…e8800–0.20%14%
H2 · 0x62d4…40800%1.8947–4.90%, depending on the hop
H3 · 0x349c…80c4 · control0%0%

A router that trusts the cheap quote may choose the pool. The higher fee then reduces the output. If the user's minimum is no longer met, the swap reverts; if it still passes, the swap completes at the worse price.

The same hooks return different fees when the gas price changes.
The same hooks return different fees when the gas price changes.

source: fee_step.csv

How we caught it

Run the same swap twice, changing only the context. Pin the block, calldata, balances, and caller. Compare a zero-gas-price call with the real transaction's fee settings. Then compare debug_traceCall results, following the first changed return value down to beforeSwap. Its third return word contains the LP fee override.

For the wider scan, we first select dynamic-fee pools with beforeSwap enabled. We probe each selected hook in both fee settings; when it needs a complete swap, we use the V4 Quoter. An error or empty pool gets no safety verdict. This finds candidates, but does not test every way a hook can recognize a simulator.

The September 5 follow-up exposed that limit. Our actual simulation wrapper omitted from, making tx.origin zero. Adding realistic fees alone still produced the inflated quote. Only the intended wallet plus realistic fees reproduced the mined transaction's short output. We tested all four zero/real sender-and-fee combinations at the same historical block. (Four-way replay, method and limits)

How the hook spots a simulation

The follow-up traced another Robinhood hook, 0xd752…0080, through a fee controller to implementation 0x1181…f46dd. The detector lived in that implementation. Looking only at the hook's code would have missed it.

It recognizes the simulator's defaults. At block 54,676,220, the implementation combined these checks with OR: zero gas price, zero transaction origin, Foundry's default test address, zero base fee, a zero value from opcode 0x44 (difficulty/PREVRANDAO), and a stored gas threshold. On the observed path, any match cleared the calculated fee. (Bytecode, disassembly, and executed trace)

PoolManager → hook → fee controller → implementation

Recognized simulation fingerprint → LP fee becomes 0%
No fingerprint matched           → calculated LP fee applies

Foundry's default address is hard-coded in the detector. With realistic gas price, direct controller probes returned:

Transaction originLP fee
Zero address0%
Foundry default0%
Broadcasting wallet2.4391%
Independent nonzero control2.4391%

The result held at four gas limits. The returned fee word changed from 0x400000 to 0x405f47: the same override flag, plus 24,391 fee units. This is how a simulated swap gets a real—but misleading—low-fee result. No quote-response tampering is needed. (Probe results)

This is reconstructed bytecode behavior; we did not obtain the Solidity source. A fixed-fee branch also exists, so the detector is not necessarily active in every configuration. And fixing the context is not a future-price guarantee: advancing only the timestamp by one second changed the fee from 2.4391% to 4.8953%. (Context probes)

How much it cost

In the September 4 snapshot, three confirmed bait pools processed 1,003 swaps with about 238,334 USDG in USDG-side volume. Their estimated fees totalled 6,419 USDG. These are historical measurements, not live totals. (Pool totals)

The estimate multiplies each swap's USDG-side amount by its recorded fee rate. It is not a measurement of fees withdrawn. The 17 distinct callers are contract addresses, often routers—not a count of individual users.

We found the pools in a census of 21,661 dynamic-fee pools and 2,203 hooks. Only four incident-related hooks were tested on Robinhood Chain; three showed the mismatch. Untested hooks have no verdict. (Browse the census)

Estimated fees accumulated across the three bait pools.
Estimated fees accumulated across the three bait pools.

source: cumulative_bait_fees.csv

Who collected the fees

The extra charge went to liquidity providers through normal Uniswap LP fee accounting. We found no hook claim transfers in the 993 swap transactions checked. (Accounting checks, mechanism evidence)

Each pool's observed liquidity traced to one owner. Across the three pools there were two owners: 0x22d5…2527 and 0x9924…9699. For two hooks, deployment and pool creation traced to those same owners. The third hook's deployment could not be established. (Ownership evidence)

USDG withdrawals are visible, but they mix deposited capital with fees. We cannot label the entire withdrawal as profit. (Withdrawal records)

It also appeared on other chains

Ethereum had one matching hook, with roughly 136 USDC in observed volume. Arbitrum had a cluster of six hooks that quoted 0.3% at zero gas price; higher-gas-price probes returned 50–53.3%. Some results depended on the probe context. (Cross-chain results, hook details)

We found no mismatch among the hooks we could test on Unichain, Optimism, or Avalanche. Some calls failed or pools lacked liquidity, so those hooks remain undecided. Base and Polygon scans were incomplete in this snapshot; BNB was not scanned.

This technique has been reported before. Our contribution is tracing this cluster from the quote mismatch to the fee records and liquidity owners. Prior reports and sources give the background.

What this means for users

AgentSwap uses intents. Users sign an order with a minimum output and an expiry; our solver submits the fill. In these failed fills, settlement reverted because it could not deliver the signed minimum. Users lost no principal from those failures; our solver paid the gas. The signed minimum protects an agreed floor, not the best available price.

A direct aggregator swap can still succeed at a worse price. Excessive slippage tolerance sets a low minimum output. A hook can quote a low fee, charge more onchain, and leave enough output to pass that minimum. The swap completes and the user receives fewer tokens. If output falls below the minimum, the swap reverts and the sender pays gas.

As a meta-aggregator, AgentSwap also checks routes supplied by upstream aggregators. In this investigation, we performed dedicated detection and marked malicious hooks that upstream aggregators had not identified and filtered out.

For solvers, use the intended wallet, realistic transaction fees, and faithful block fields through the actual simulation wrapper. Quarantine routes with confirmed simulation-dependent fees; a second simulation with the same defaults adds no protection. Keep minimum-output checks, and avoid repeating an identical failed route. (Detection method)

Check the evidence

Open the pool explorer to compare quoted and real-context fees, inspect individual pools, or run a fresh probe. The article and census use a fixed snapshot; a live probe may differ.

The research notes contain the method and limits. Download the fee data, swap records, or cross-chain summary to check the numbers.


发生了什么

我们的 solver(负责撮合并提交成交的程序)先模拟了一笔交换,结果可以成交;真实上链却因 InsufficientOutput 失败,白花约一百万 gas。抽查的六笔失败交易,五笔是同一原因:实际手续费比模拟高。(失败重放数据

手法不复杂。Uniswap V4 的钩子(hook)既能设置池子手续费,也能读取交易的 gas price:为零时报低费率,用真实交易的 gas price 就报高费率。用两种环境重放同一笔交易,即可复现差异。(费率探针数据

钩子模拟时的费率真实交易的费率
H1 · 0x0d83…e8800–0.20%14%
H2 · 0x62d4…40800%1.8947–4.90%(各跳费率不同)
H3 · 0x349c…80c4 · 对照0%0%

路由器信了便宜报价就会选中这个池子。手续费抬高后,到手的币变少:低于用户设定的最低接收量就回滚,仍达标的按更差价格成交。

同一批钩子,gas price 一变,返回的手续费就变。
同一批钩子,gas price 一变,返回的手续费就变。

source: fee_step.csv

我们是怎么发现的

同一笔交换跑两遍,只改费用环境。 区块、状态、calldata、余额、调用者全部固定,一次零 gas price,一次用真实交易的费用设置。对比两份 debug_traceCall,沿第一个变化的返回值追到 beforeSwap 的第三个返回值(32 字节)——LP 手续费覆盖值就在这里。

批量扫描先筛出"动态费率且启用 beforeSwap"的池子做候选,再逐个用两种费用环境探测;需要完整交换才能测的钩子改用 V4 Quoter。调用报错或没有流动性,都只能算无法定论,不等于安全。这只是初筛,覆盖不了识别模拟器的所有手段。

9 月 5 日的复查暴露了盲区:我们的模拟脚本漏填 fromtx.origin 因此为零。只补真实 gas price,报价依旧虚高;真实钱包加真实 gas price,才复现出链上实际拿到的较低输出。四种"零/真实发送者 × 零/真实费用"组合,都在同一历史区块测过。(四组重放数据方法与局限

钩子怎么识别模拟

这次追的是另一个 Robinhood 钩子 0xd752…0080,不在 9 月 4 日确认的三个池子之列。它把费率计算委托给控制器合约,控制器再 DELEGATECALL 到实现合约 0x1181…f46dd;识别逻辑就藏在这一层,只看钩子代码会漏掉。

它认的是模拟器的默认值。 在区块 54,676,220,实现合约用 OR 串联六项检查:

- gas price 为零 - tx.origin 为零 - tx.origin 等于 Foundry 默认测试地址(硬编码) - base fee 为零 - 操作码 0x44(difficulty/PREVRANDAO)读数为零 - 内置的 gas 阈值检查

观察到的执行路径上,命中任意一项,算出的 LP 手续费即清零。(字节码、反汇编与执行轨迹

PoolManager → 钩子 → 费率控制器 → 实现合约

命中模拟器特征 → LP 手续费清零
一项都没命中   → 按算出的费率收取

固定真实 gas price,只换交易发起者,直接调用控制器:

交易发起者(tx.origin)LP 手续费
零地址0%
Foundry 默认地址0%
广播交易的真实钱包2.4391%
独立非零对照地址2.4391%

四档 gas 上限下结果一致。返回值从 0x400000 变为 0x405f47:覆盖标志位不变,多出 24,391 个费率单位,即 2.4391%。注意,模拟时手续费是真的变低了——无需伪造 RPC 返回或篡改报价,模拟器自己会算出一个好看却误导的数字。这里测的是池子的 LP 手续费,与交易 gas 定价是两回事。(探针原始数据

以上行为是从字节码和执行轨迹还原的,我们没有拿到 Solidity 源码。合约里还有固定费率分支,并非每种配置都会执行这条检测路径。模拟环境再逼真,也锁不住未来的费率:只把时间戳拨快一秒,费率就从 2.4391% 变为 4.8953%。(环境变量对照

收了多少钱

按 9 月 4 日的快照,三个已确认的钓鱼池共成交 1,003 笔交换,USDG 侧交易量约 238,334 USDG,估算手续费合计 6,419 USDG。这是历史快照,不是实时累计。(池子统计数据

估算方法是逐笔把 USDG 侧成交额乘以该笔记录的费率再加总,不等于已提走的手续费。17 个不同调用地址是合约地址,不少是路由器,不代表 17 个用户。

普查覆盖 21,661 个动态费率池2,203 个钩子;在 Robinhood Chain 上,只测了与本次事件相关的 4 个钩子,其中 3 个出现上述差异。没测过的钩子没有结论。(浏览普查数据

三个钓鱼池的估算手续费累计曲线。
三个钓鱼池的估算手续费累计曲线。

source: cumulative_bait_fees.csv

钱进了谁的手里

多收的部分通过 Uniswap 常规的 LP 手续费记账,归给了流动性提供者。核查的 993 笔交换交易里,没有发现钩子发起 claim 转账的记录。(记账核查机制证据

每个池子观察到的流动性都指向同一个所有者;三个池子加起来只有两个:0x22d5…25270x9924…9699。其中两个钩子,从部署到建池都能追到这两个地址;第三个钩子的部署者未能确认。(所有权证据

链上确实能看到 USDG 被提走,但提款里本金和手续费混在一起,不能把整笔提款都算作利润。(提款记录

其他链上也有

Ethereum 上有一个特征相符的钩子,观察到的交易量约 136 USDC。Arbitrum 上有六个同类钩子,零 gas price 时报 0.3%;提高 gas price 后,探针返回 50–53.3%。部分结果依赖于探测时的环境。(跨链汇总钩子明细

Unichain、Optimism、Avalanche 上能完成测试的钩子,都没有发现费率差异;但有些调用失败、有些池子没有流动性,只能存疑。这份快照里,Base 和 Polygon 的扫描未完成,BNB 未扫描。

这种手法早有前例。我们的工作,是把这组池子从报价差异追到手续费记录和流动性所有者。背景见前人报告与资料

对用户意味着什么

AgentSwap 采用 intent 模式:用户签署最低接收量与过期时间,由 solver 提交交易。在本次调查的手续费陷阱事件中,未达到签署最低接收量的交易在链上回滚,用户本金没有因此受损,失败交易的 gas 由 solver 承担。intent 保护的是用户签署的下限,而不是市场最优价格。

同一笔兑换若直接走聚合器,过大的滑点容忍度会把最低接收量放得很低。恶意 hook 在模拟中报出偏低手续费,在真实执行中收取高额 LP 费;扣费后到手数量仍高于放宽后的下限时,交易照样成功,用户少拿币,本该到手的输出被手续费吃掉;只有跌破下限才会回滚,gas 由发起方承担。

AgentSwap 作为 meta 聚合器,在本次事件中已完成对上游聚合器未能识别、过滤的恶意 hook 的专项识别和标记。在此之外,我们建议 solver:用真实发起方、贴近实际的 gas 定价与区块上下文复现执行,隔离已被证实的陷阱,保留最低接收量检查,并且不再重复相同的失败路径,详见检测方法

自己核实

打开池子浏览器,可以对比模拟与真实环境下的费率、逐池查看,或重新跑一次探针。文章和普查基于固定快照,实时探测结果可能不同。

方法与局限写在研究笔记里。欢迎下载费率数据交换记录跨链汇总,自行核对。

Look inside the pools.

浏览资金池数据

Search 21,661 pools. Compare fees and inspect the evidence behind each verdict.

搜索 21,661 个池子,对比费率,查看每个结论背后的证据。

Open pool explorer ↗打开池子浏览器 ↗

Method & data

方法与数据

Research notes ↗

For a fresh check, open a pool and run its live probe. Each pool also includes commands you can copy into a terminal.

打开资金池即可实时检查,或把命令复制到终端运行。