使用ERC-8211进行基于跨链和谓词的模拟

ERC-8211引入的predicate原语允许用户在交易尚未有效时签名跨链操作,但现有模拟工具(如eth_call)无法处理这种未来状态。文章提出通过state override和存储槽检测技术来模拟post-bridge世界:先使用tracing调用发现token余额的存储槽,再构造override map,使模拟器能准确预测跨链交易的gas、输出和潜在失败。该技术使predicate-gated交易从“凭希望签名”变为“基于预报签名”,显著改善用户体验和可靠性。

基于谓词的跨链模拟与 ERC-8211

签署尚未生效的内容

ERC-8211 引入了一种旧的账户抽象标准无法表达的原语:谓词(predicate)。谓词是函数调用上的一个门控——它不执行、不转移、不改变状态,只是一个检查点。它只验证一个条件。当条件成立时,批次继续前进,否则回滚。

这就是为什么一笔交易能够在生效之前就被签署

典型的用例是跨桥后的操作。用户签署一个批次,内容为:“在目标链上,一旦我的 USDC 余额超过 1000,就将其兑换为 ETH 并存入借贷市场。” 在签署时,目标链上的 USDC 余额为零。兑换会回滚,存入会回滚,谓词之后的每一步都会回滚。按照传统标准,这个批次是无效的。

中继者持有签名,并在桥接交付资金后提交该批次。谓词从 false 变为 true。下游的所有操作都在用户签署时尚不存在的状态下执行。一次签名,多条链,意图与执行之间存在任意的延迟。

谓词很简洁。它们将过去需要一系列签名、可信中继者和乐观猜测的操作,压缩为一个带有链上强制执行门控的单一签名对象。但它们引入了一个传统批次中不存在的问题:你无法用现有的工具模拟它们。

为什么 eth_call 不行

当用户看到一个报价并决定是否签署时,他们实际需要知道的是具体信息:这次操作会成功吗?成本是多少?我会得到什么?

对于普通的 userOp,答案来自对捆绑器上模拟入口点的 eth_call。节点分叉当前状态,重放操作,并报告结果。Gas、回滚状态、回滚原因——在签署前都可以获得。

对于受谓词门控的跨链 userOp,这个协议在第一个检查点就失效了。谓词评估的是尚不存在的状态。目标链上的余额为零,因为尚未桥接任何内容。谓词失败。模拟器报告“谓词失败”,而用户已经知道了这一点。他们无法了解自己的兑换是否会滑点、借贷市场是否暂停、Gas 估算是否合理,或者最终会得到多少目标代币。

在当前状态下没有答案,因为问题涉及未来的状态。模拟器需要被欺骗——令人信服且精确地——关于它运行所处的世界。

状态覆盖作为未来世界

eth_call 接受一个状态覆盖映射。你提供一组 (地址, 槽位, 值) 三元组,eth_call 会在调用期间假装这些槽位持有这些值。追踪内部的每次读取都会返回覆盖后的值;这些读取下游的所有计算都基于这个合成世界。

概念上的做法是使用覆盖在内存中构建桥接后的世界:假装用户已经在目标链上持有桥接代币,然后询问模拟器会发生什么。如果答案是“一切成功,你收到 0.42 ETH,Gas 为 X”,用户就可以放心签署。如果答案是“兑换因滑点回滚”,用户在报价时就能看到真实的失败模式,无需支付任何桥接费用,也无需等待 30 分钟。

问题出在覆盖的粒度上。eth_call 覆盖的是存储槽位,而非视图函数。你不能直接覆盖 balanceOf(holder);EVM 没有覆盖函数的概念。你覆盖的是支持它的槽位,而 balanceOf——只是读取该槽位并格式化——会返回你写入的值。

所以问题变成了:哪个槽位?

存储槽位问题

ERC-20 标准化了接口,而不是布局。两个具有相同公共接口的代币,其余额可能存储在不同的位置。

  • 标准的 OpenZeppelin 代币将 _balances 放在槽位 0。
  • USDC 的 FiatTokenV2 将余额放在槽位 9。
  • 使用 Vyper 编译的代币对映射键的哈希顺序与 Solidity 相反(keccak256(槽位, 持有者) 而不是 keccak256(持有者, 槽位))。
  • 钻石模式的代币通过从命名空间位置解析存储的切面来路由读取——而不是合约的主存储。
  • 有些代币将存储完全委托给另一个合约。你调用 balanceOf 的地址并不是你需要覆盖其存储的地址。
  • 自定义实现则根据作者的选择来做任何事。

没有链上注册表将代币映射到槽位。合约本身也不知道——存储布局是编译时的决定,嵌入在字节码中,不是可内省的运行时元数据。你不能询问代币它的余额存储在哪里。

天真的方法——尝试槽位 0,然后 1,然后 2,最后回退到硬编码的已知偏移列表——会静默失败。错误的槽位意味着覆盖写入了一个不会被读取的位置。模拟器针对原始(零)余额运行。谓词因与之前相同的原因失败。模拟器报告与没有覆盖时相同的无用答案。更糟的是,失败看起来与合法的谓词失败一模一样,因此用户无法区分“模拟器找不到槽位”和“你的批次真的无法工作”。

要可靠地覆盖余额,槽位必须被发现,而不是猜测。

槽位检测如何工作

可靠的方法是将问题反转。不是猜测余额存储在哪里并检查,而是观察实际合约读取它。

步骤如下:

  1. 选择一个具有已知非零余额的持有者地址。
  2. 使用 eth_call 的追踪变体(受支持节点上的 debug_traceCall 系列方法)对实际合约调用 balanceOf(holder),并记录 SLOAD 操作码。
  3. 遍历追踪。函数执行的每次存储读取都会被捕获:哪个合约、哪个槽位、哪个值。
  4. 找到返回值与函数返回结果匹配的读取。
  5. 该槽位——更准确地说,该合约-槽位对——就是余额存储的位置。

这样做可行,因为 EVM 是完全确定性的,并且通过追踪完全可观察。无论字节码采取什么路径来产生答案,该路径上的每次存储读取都在追踪中。正确的槽位是其值流入返回结果的槽位。

这个机制描述起来很简单,但实现起来很棘手,因为真实的代币不会只读取一个槽位。

过滤噪声

对一个可升级代币的 balanceOf 调用,在获取余额之前通常会执行多次读取:

  • 代理读取其实现槽位(例如 EIP-1967 的预留位置)以知道要委托调用哪个逻辑合约。
  • 实现可能在服务调用之前读取初始化标志暂停标志
  • 有些代币在返回余额之前会读取持有者的黑名单状态
  • 钻石代理读取切面路由表以将调用分派给正确的切面。

这些在追踪中都是 SLOAD。它们都不是余额槽位。必须将它们过滤掉——要么通过识别已知的槽位(EIP-1967 实现槽位是固定的,黑名单标志通常是布尔值),要么通过将返回值与读取值匹配(余额槽位是其 SLOAD 精确返回余额的那个槽位)。

对布局进行分类

一旦确定了候选槽位,它几乎从来都不是你实际要覆盖的槽位。代币余额存在于映射中,而 Solidity 中的映射存储在 keccak256(abi.encode(holder, baseSlot))——你发现的槽位是基础槽位,每个持有者的槽位是从它推导出来的。Vyper 反转了哈希顺序。有些代币使用嵌套映射(mapping(address => mapping(uint => uint))),你需要两个推导层次。

因此,检测不仅产生一个槽位编号,还产生一个布局描述符:基础槽位、哈希约定(Solidity 与 Vyper)、持有存储的合约地址(对于委托存储的代币,这不是代币合约)。在覆盖时,描述符和持有者地址结合成一个具体的 (合约, 槽位) 对。

零余额陷阱

如果测试持有者余额为零,启发式方法会失败。任何合约中的许多槽位读取时都返回零;你无法区分哪个零是“那个”余额。

解决方法是永远不要对零持有者运行检测。要么选择一个你知道有正余额的持有者(已知的鲸鱼、持有大量资金的合约),要么执行引导:通过覆盖将哨兵值写入猜测的槽位,运行追踪,查看返回的余额是否变为与哨兵匹配。如果是,则猜测正确。如果不是,尝试下一个候选者。这会将检测转换为不动点搜索,但由于候选集来自追踪而非盲目猜测,因此范围可以紧密限定。

为什么检测缓存效果好

检测结果稳定。代币的存储布局由其字节码固定;它不会漂移。一旦 (链ID, 代币地址) 解析为一个布局描述符,该结果在合约升级之前都是有效的——而大多数 ERC-20 合约永远不会升级。检测是昂贵的(每个代币需要一次完整追踪),但每个链上每个代币只需要一次。后续的模拟重用缓存的描述符,无需额外开销。

这就是该技术实用之处。如果检测必须在每次模拟时运行,延迟将主导报价流程。作为每个代币的一次性原语,它填充了长期缓存,从而消失在背景中。

覆盖解锁了什么

有了槽位检测,可以构建受谓词门控的跨链批次的覆盖映射。谓词依赖的每个代币余额都成为覆盖映射中的 (合约, 槽位, 值) 三元组。模拟器在应用了映射的情况下对目标链入口点运行 eth_call,桥接后的世界在调用期间显现。

三个以前不可能的事情成为可能。

跨链操作的 Gas 估算

在谓词门控之前,跨链操作的 Gas 无法以任何诚实的方式估算。目标链步骤无法模拟,因为前提状态不存在。集成者要么大幅高估(向用户收取最坏情况的执行费用),要么低估并让目标链操作在提交时失败。

有了覆盖,目标链步骤在一个合成的桥接后世界中运行,并产生反映实际执行路径的 Gas 数字:兑换走哪个分支、借贷存款执行多少次存储写入、谓词评估器消耗多少 calldata。估算与同链估算一样准确,因为从机制上讲,它就是对已编辑为看起来像未来的状态进行的同链估算。

预测输出

模拟不仅仅是成功或失败——它还会追踪。每次状态变化、每个事件、每个返回值都是可观察的。对于先兑换再存入的批次,模拟器可以精确报告用户将收到多少目标代币、存款将铸造多少借贷市场份额、每一步收取了多少费用。

以前这是不可能的。由于无法模拟目标步骤,集成者可以向用户显示跨链操作的输入,但不能显示其输出。报价是上限和下限,之间差距很大,或者是没有保证的点估计。有了谓词门控的模拟,报价变成了精确的预测:你将收到 0.4187 ETH,滑点后最低 0.4185,每条链上的 Gas 范围如此。 用户根据一个数字(而不是一个范围)进行签署。

在报价时暴露失败

第三个好处是负面情况。受谓词门控的批次可能因与谓词无关的原因而失败:目标市场可能暂停、兑换可能滑过容忍度、预言机可能过时、下游合约可能因无关原因回滚。如果没有基于覆盖的模拟,所有这些只有在提交后才会暴露——在桥接已经运行、中继者已经支付 Gas、用户已经等待之后。

有了覆盖,每个可达的失败都在报价时暴露。用户在签署前就能看到确切的回滚原因,追踪到违规调用。桥接永远不会运行。中继者永远不会在一个注定失败的批次上花费 Gas。用户永远不会等待一个可以提前知道的结果。

这总体实现了什么

谓词是表达未来条件执行的简洁原语。存储槽位检测是让谓词变得可模拟的不起眼的原语。它们共同将一种过去凭希望签署的交易类别转变为一种可以凭预测签署的交易类别——Gas 已估算、输出已预测、失败已提前暴露。

检测本身是一个伪装成布局问题的追踪问题:观察一次真实的读取,过滤噪声,对布局进行分类,推导每个持有者的槽位,缓存结果。每一步都是机械的。杠杆作用来自于组合它们。一旦你能可靠地覆盖一个余额,你就能覆盖任何谓词关心的任何状态。一旦你能覆盖状态,模拟器就不再是一个现在时工具,而成为一个未来时工具。一旦模拟是未来时,跨链和谓词门控的交易就不再是一场 UX 赌博。

这就是 ERC-8211 在实践中变得有用(而不仅仅是纸上谈兵)所需的转变。

  • 原文链接: blog.biconomy.io/cross-c...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Biconomy
Biconomy
江湖只有他的大名,没有他的介绍。