智能批处理SDK:基于ERC-8211无需编写Solidity
Biconomy 发布了 Smart Batching SDK,基于 ERC-8211 智能批处理标准,允许开发者在以太坊上编写可编程的原子性批量交易。
静态批次问题
我们今天拥有的每一种批量交易模型——ERC-4337 用户操作、EIP-5792 钱包发送调用,以及大多数账户抽象“执行器”模式——都有一个共同的限制:
每个参数都是静态的。签名时冻结,对执行时链的状态一无所知。
这对于简单的流程来说还行。一旦你的批次在步骤之间有任何数据依赖关系,它就会崩溃。
任何 DeFi 团队都遇到过的一些例子:
- 交换 → 提供。 你将 USDC 兑换成 WETH,然后将 WETH 提供给 Aave。交换返回
0.04951WETH,但下一个调用硬编码为0.05。回滚。 - 金库提现 → 重新部署。 份额率在执行前一个区块发生变化。硬编码的金额不再匹配。回滚。
- 桥接 → 使用。 桥接费用是非确定性的。你不知道另一端会收到什么。要么回滚,要么留下零头。
- 借款循环。 每次迭代依赖于上一次。你无法将其表示为静态批次。
- 归集操作。 当“全部”取决于你还不知道的 Gas 费用时,“发送全部”是不可能的。
- MEV。 无法在调用之间断言你得到的价格就是你想要的价格。
今天的解决方法都不好:部署一个自定义的 Solidity 路由器,运行一个链下模拟器并重新签名,或者接受回滚和退款作为用户体验的一部分。这些都无法扩展,并且都将复杂性推给了本不应该编写单一用途 Solidity 的产品团队。
ERC-8211 简述
ERC-8211 — 智能批处理 — 是一个标准跟踪草案(创建于 2026-02-11),引入了一种可编程的批次编码。它不是将参数值在签名时冻结,而是每个参数声明它应在执行时如何在链上解析以及它必须满足的约束条件。该批次成为一个小型、确定性的程序,读取链状态,验证它,并将解析后的值路由到目标调用。
三个基本要素支撑了整个标准:
- 运行时参数注入 — 在调用实际运行时解析值的获取器(
RAW_BYTES、STATIC_CALL、BALANCE)。 - 内联前置/后置断言 — 门控执行的谓词(
EQ、GTE、LTE、IN、带符号的变体SIGNED_GTE/SIGNED_LTE以及逻辑OR)。任何一个失败,整个批次都会回滚。(带符号的谓词和OR将在本周的版本中推出——请参见示例 16 和 17。) - 共享存储上下文 — 一个存储合约,允许步骤 N 的输出成为步骤 N+1 的输入,甚至在用户操作之间。
如果你想要完整的规范,权威参考是 erc8211.com。
进入智能批处理 SDK
这个标准很优雅。但编码——获取器标签、谓词选择器、调用数据路由指令——不是你想手动编写的内容。
智能批处理 SDK 是开发者界面。它是一个基于 Viem 的 TypeScript 库,将意图式代码编译成任何兼容 ERC-8211 的 executeComposable 函数所期望的 ComposableExecution[] 负载。你编写应该发生什么;SDK 发出编码后的程序。
SDK 仅是一个编码器。这才是重点。
这是关于 SDK 最重要的架构事实,值得直白地说:
智能批处理 SDK 是一个调用数据编码器。它生成针对
executeComposable函数选择器的字节。该函数位于何处与 SDK 无关。
SDK 对此不关心:
executeComposable是否直接实现在智能账户上。- 它是否作为 ERC-7579 执行器模块安装。
- 它是否是一个 ERC-6900 插件执行函数。
- 是否通过 ERC-7702 从 EOA 委托过来。
- 它是否位于任何账户都可以调用的单例合约上。
- 它是由打包器、中继器、MEE、多重签名、会话密钥还是在 MetaMask 中签名的人调用。
SDK 获取你对流程的声明式描述,并输出两个结果:
batch.toCalls()— 一个ComposableCall[]数组。当你的执行客户端(例如 MEE / 模块化执行环境,或任何直接接受调用数组的抽象层)为你处理编码时使用。batch.toCalldata()— 完整的批次编码为executeComposable(...)调用数据,准备好放入UserOperation.callData或任何低级调用中。当你通过打包器(如 ZeroDev、Alchemy、Pimlico 或任何 ERC-4337 打包器)直接控制交易时使用。
就是这样。这就是整个调用面。SDK 不部署合约、不签名任何东西、不发送任何东西,也不对你的账户有任何了解。它负责编码。你发送字节。
这就是为什么 SDK 同样适用于智能账户 dApp、使用 7702 委托的 EOA、中继服务,甚至是一个希望在运行时构建可组合批次并转发它的合约。唯一的要求是_在某个下游_有一个实现 ERC-8211 executeComposable 接口的函数来接收调用数据。
核心 API 界面
理解 SDK 最简单的方式是四个模块:
| 模块 | 它提供给你的 |
|---|---|
| Batch | createComposableBatch(publicClient, accountAddress) — 流畅构建器,加上 add()、toCalls()、toCalldata() |
| Token | ERC-20 辅助工具 — runtimeBalance()、runtimeAllowance()、transfer、approve、check |
| Contract | 通用合约读/写 — runtimeValue()、捕获、任意调用 |
| Storage | 命名空间化的跨调用存储 — 捕获返回值并在之后注入 |
你实际会用到的关键方法:
runtimeBalance()— “使用执行时该账户持有的任何值。”runtimeAllowance()— “使用执行时已批准的任何值。”runtimeValue()— “在执行时执行此读取并使用结果。”check({ constraints })— “在继续之前,断言这是真的。”约束运算符包括eq、gte、lte、in、带符号的变体signedGte/signedLte,以及用于分支谓词的逻辑or。capture— “记住这个返回值,以便后续步骤使用。”
其他所有内容都由这些组合而成。
安装
npm install smart-batching viem
或者使用 Bun(SDK 内部使用 Bun):
bun add smart-batching viem
按用例导览
本文的其余部分是示例。每个示例都是真实的模式,这些模式在静态批次中很痛苦或不可能实现,而使用 SDK 则轻而易举。
1. 带有前置/后置守卫的安全 ERC-20 转账
可组合批次的“Hello World”:先断言余额,转账,再断言接收方余额。
import { createComposableBatch } from 'smart-batching';
import { parseUnits } from 'viem';
const batch = createComposableBatch(publicClient, scaAddress);
const usdc = batch.erc20Token('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48');
const amount = parseUnits('10', 6);
batch.add([\
usdc.check({\
functionName: 'balanceOf',\
args: [scaAddress],\
constraints: [{ gte: amount }],\
}),\
usdc.write({\
functionName: 'transfer',\
args: [recipient, amount],\
}),\
usdc.check({\
functionName: 'balanceOf',\
args: [recipient],\
constraints: [{ gte: amount }],\
}),\
]);
const calldata = await batch.toCalldata();
// 放入 UserOperation.callData
约束不是在链下检查的。它是编码批次的一部分。如果在签名和执行之间有任何导致前置条件不成立的情况,整个批次会在转账触发之前原子性地回滚。
2. 使用 runtimeBalance() 的交换 -> 供应
这是经典的例子,也是证明 SDK 价值的核心用例。
const usdc = batch.erc20Token(USDC);
const weth = batch.erc20Token(WETH);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
const aave = batch.contract(AAVE_POOL, aavePoolAbi);
batch.add([\
usdc.write({\
functionName: 'approve',\
args: [SWAP_ROUTER, parseUnits('1000', 6)],\
}),\
\
router.write({\
functionName: 'swapExactTokensForTokens',\
args: [parseUnits('1000', 6), 0n, [USDC, WETH], scaAddress, deadline],\
}),\
\
// 滑点保护:断言我们至少得到 0.49 WETH 返回
weth.check({\
functionName: 'balanceOf',\
args: [scaAddress],\
constraints: [{ gte: parseUnits('0.49', 18) }],\
}),\
\
// 为我们现在持有的 *确切* 数量批准 Aave
weth.write({\
functionName: 'approve',\
args: [AAVE_POOL, weth.runtimeBalance()],\
}),\
\
// 供应 *确切* 的数量,在链上解析
aave.write({\
functionName: 'supply',\
args: [WETH, weth.runtimeBalance(), scaAddress, 0],\
}),\
]);
const calldata = await batch.toCalldata();
没有链下模拟器。没有“交换、等待、重新签名、供应”的两步用户体验。不会因为 0.0001 WETH 的差异而回滚。一次签名,一次原子执行,并且批次内建了滑点保护。
3. 无零头的全额余额归集
使用静态批次,“发送我所有的”实际上很困难,因为你无法在签名时知道“所有”是什么意思——Gas 费用可能变了,一个零头空投可能到了,一个计息代币可能重新分红了。使用 runtimeBalance(),一次调用即可:
const usdc = batch.erc20Token(USDC);
batch.add([\
usdc.write({\
functionName: 'transfer',\
args: [coldWallet, usdc.runtimeBalance()],\
}),\
]);
转账的金额是执行时账户持有的数量。零灰尘。不需要模拟器。想要归集一个代币列表?
const tokens = [USDC, USDT, DAI, WETH].map(addr => batch.erc20Token(addr));
batch.add(
tokens.map(t =>
t.write({
functionName: 'transfer',
args: [coldWallet, t.runtimeBalance()],
}),
),
);
一次签名的批次,完整的金库归集,任何代币都没有剩余零头。
4. 带有 Gas 储备的原生 ETH 归集
发送原生 ETH 更棘手,因为你需要 Gas 来发送交易,所以你想要转移“余额减去储备金”。SDK 中没有可组合的数学原语——你无法在编码内部减去数值。任何链上算术都必须来自能够在执行时进行数学计算的地方:
- 一个小的自定义 Reader 合约,返回计算后的值,通过
runtimeValue()解析。这是正确的方法,因为输入(实时余额)在执行之前是未知的。 - 在签名之前在 TypeScript 中自己计算的值——只有当每个输入都是预先知道时才可行,而运行时余额不是这种情况。
因此,对于 Gas 预留的归集,将 balance − reserve 计算推送到一个 Reader 合约:
const eth = batch.nativeToken();
const reader = batch.contract(GAS_RESERVE_READER, gasReserveReaderAbi);
const reserveForGas = parseEther('0.001');
batch.add([\
// 如果我们甚至连储备金都不够,尽早退出
eth.check({\
constraints: [{ gte: reserveForGas }],\
}),\
eth.transfer({\
to: coldWallet,\
// SDK 中没有数学原语——“balance - reserve”计算存在
// 在一个小的自定义 Reader 合约中,并在执行时解析。
value: reader.runtimeValue({\
functionName: 'balanceMinusReserve',\
args: [scaAddress, reserveForGas],\
}),\
}),\
]);
Reader 合约是几行 Solidity(return account.balance - reserve;),并且在你编码的每个 Gas 预留归集中都可以重用。SDK 保持纯粹的编码器;算术保持链上。
5. 批准并清理
在一个批次中设置额度、使用它、然后将其重置为零——这是协议常见的模式,希望最小化额度生命周期:
const usdc = batch.erc20Token(USDC);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
const swapAmount = parseUnits('500', 6);
batch.add([\
usdc.write({\
functionName: 'approve',\
args: [SWAP_ROUTER, swapAmount],\
}),\
router.write({\
functionName: 'swapExactTokensForTokens',\
args: [swapAmount, minOut, [USDC, WETH], scaAddress, deadline],\
}),\
// 防御性:在重置前断言额度已完全消耗
usdc.check({\
functionName: 'allowance',\
args: [scaAddress, SWAP_ROUTER],\
constraints: [{ lte: 0n }],\
}),\
usdc.write({\
functionName: 'approve',\
args: [SWAP_ROUTER, 0n],\
}),\
]);
如果没有可组合批次,你需要要么一个自定义的 Solidity 助手,要么接受额度在签名交易之间持续存在——这是一个真正的攻击面。
6. 金库存款 / 跨份额率波动重新部署
ERC-4626 金库以每个区块都可能变化的比率将份额转换为资产。你在区块 N 签名;用户操作可能在区块 N+3 执行。使用静态数量,你要么过度赎回(回滚),要么赎回不足(留下资金)。
const vault = batch.contract(VAULT, erc4626Abi);
const newVault = batch.contract(NEW_VAULT, erc4626Abi);
const asset = batch.erc20Token(USDC);
batch.add([\
// 赎回我们所有的份额,无论它们转换成什么
vault.write({\
functionName: 'redeem',\
args: [vault.runtimeBalance({ from: scaAddress }), scaAddress, scaAddress],\
}),\
\
// 确保我们至少得到预期的最低输出
asset.check({\
functionName: 'balanceOf',\
args: [scaAddress],\
constraints: [{ gte: minExpectedAssets }],\
}),\
\
// 批准并存入 *我们收到的确切数量*
asset.write({\
functionName: 'approve',\
args: [NEW_VAULT, asset.runtimeBalance()],\
}),\
newVault.write({\
functionName: 'deposit',\
args: [asset.runtimeBalance(), scaAddress],\
}),\
]);
金库迁移、收益轮换和金库重新平衡不再是多交易任务。
7. 跨链执行,谓词门控
ERC-8211 在跨链用户体验上的杀手锏:目标批次可以等待一个谓词。中继器通过 eth_call 模拟并在条件通过时立即提交。
// === 源链(Base):将 USDC 桥接到以太坊 ===
const baseBatch = createComposableBatch(basePublicClient, scaAddress);
const usdcBase = baseBatch.erc20Token(USDC_BASE);
const bridge = baseBatch.contract(BRIDGE_ROUTER, bridgeAbi);
baseBatch.add([\
usdcBase.write({\
functionName: 'approve',\
args: [BRIDGE_ROUTER, usdcBase.runtimeBalance()],\
}),\
bridge.write({\
functionName: 'bridge',\
args: [USDC_BASE, ETHEREUM_CHAIN_ID, scaAddress, usdcBase.runtimeBalance()],\
}),\
]);
// === 目标链(以太坊):等待资金,然后供应给 Aave ===
const ethBatch = createComposableBatch(ethPublicClient, scaAddress);
const usdcEth = ethBatch.erc20Token(USDC_ETH);
const aave = ethBatch.contract(AAVE_POOL, aavePoolAbi);
ethBatch.add([\
// 中继器轮询这个谓词。仅当余额 ≥ 阈值时才提交交易。
usdcEth.check({\
functionName: 'balanceOf',\
args: [scaAddress],\
constraints: [{ gte: minBridgedAmount }],\
}),\
usdcEth.write({\
functionName: 'approve',\
args: [AAVE_POOL, usdcEth.runtimeBalance()],\
}),\
aave.write({\
functionName: 'supply',\
args: [USDC_ETH, usdcEth.runtimeBalance(), scaAddress, 0],\
}),\
]);
const baseCalldata = await baseBatch.toCalldata();
const ethCalldata = await ethBatch.toCalldata();
// 两者都由用户的账户在同一个默克尔根下签名。
目标批次与桥无关。原生桥、Across、ERC-7683、LayerZero——谓词只是等待余额到达。用户看到一次签名和“你的资金即将到达。”
8. 杠杆循环
递归借款 / 交换 / 供应,其中每次迭代的金额从上一次推导得出。静态批次无法表达这一点,除非在签名时展开,这意味着要在链下模拟整个路径——这很脆弱。
const aave = batch.contract(AAVE_POOL, aavePoolAbi);
const usdc = batch.erc20Token(USDC);
const weth = batch.erc20Token(WETH);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
const initialDeposit = parseUnits('1000', 6);
batch.add([\
usdc.write({ functionName: 'approve', args: [AAVE_POOL, initialDeposit] }),\
aave.write({ functionName: 'supply', args: [USDC, initialDeposit, scaAddress, 0] }),\
\
// 以 70% LTV 借用相当于存款的 WETH
aave.write({\
functionName: 'borrow',\
args: [WETH, borrowAmountForCycle1, 2, 0, scaAddress],\
}),\
\
// 使用 *运行时* WETH 余额将借来的 WETH 交换回 USDC
weth.write({ functionName: 'approve', args: [SWAP_ROUTER, weth.runtimeBalance()] }),\
router.write({\
functionName: 'swapExactTokensForTokens',\
args: [weth.runtimeBalance(), 0n, [WETH, USDC], scaAddress, deadline],\
}),\
\
// 重新供应我们现在拥有的任何 USDC
usdc.write({ functionName: 'approve', args: [AAVE_POOL, usdc.runtimeBalance()] }),\
aave.write({\
functionName: 'supply',\
args: [USDC, usdc.runtimeBalance(), scaAddress, 0],\
}),\
\
// 健康因子保护:断言我们没有意外过度杠杆
aave.check({\
functionName: 'getUserAccountData',\
args: [scaAddress],\
// 伪代码——真实形状取决于元组约束支持
constraints: [{ healthFactor: { gte: parseUnits('1.5', 18) } }],\
}),\
]);
每次迭代的金额在执行时解析。最后的健康因子检查是安全网。
9. 使用 runtimeValue() 的条件流程
“在执行时选择更高收益的协议”——这种决策目前需要一个链下守护者。
const aaveOracle = batch.contract(AAVE_RATE_ORACLE, oracleAbi);
const compoundOracle = batch.contract(COMPOUND_RATE_ORACLE, oracleAbi);
const usdc = batch.erc20Token(USDC);
const aave = batch.contract(AAVE_POOL, aavePoolAbi);
// 前提条件:仅当 Aave 的利率在执行时 ≥ Compound 的利率时才继续
batch.add([\
aaveOracle.check({\
functionName: 'getSupplyRate',\
args: [USDC],\
constraints: [{\
gte: compoundOracle.runtimeValue({ functionName: 'getSupplyRate', args: [USDC] }),\
}],\
}),\
\
usdc.write({ functionName: 'approve', args: [AAVE_POOL, usdc.runtimeBalance()] }),\
aave.write({\
functionName: 'supply',\
args: [USDC, usdc.runtimeBalance(), scaAddress, 0],\
}),\
]);
如果在执行时 Aave 的利率低于 Compound,批次回滚。将两个这样的批次放在一个多路复用器后面,你就拥有了自动化的协议路由,无需任何链下基础设施。
10. 存储:跨调用捕获返回值
有时你需要在步骤 N+1 中使用步骤 N 的_返回值_(不仅仅是余额变化)。使用 capture。存储键是唯一的 bigint——通常是 Unix 时间戳——getStorageKey() 为你生成一个新的:
const storage = batch.storage();
const swapKey = storage.getStorageKey();
batch.add([\
router.write({\
functionName: 'swapExactTokensForTokens',\
args: [parseUnits('1000', 6), 0n, [USDC, WETH], scaAddress, deadline],\
// 函数返回 uint256[] 的金额;捕获最后一个(输出金额)。
capture: { type: 'execResult', storageKey: swapKey, path: 'amounts[1]' },\
}),\
\
// 在后续调用中使用捕获的值
aave.write({\
functionName: 'supply',\
args: [WETH, storage.runtimeValue({ storageKey: swapKey }), scaAddress, 0],\
}),\
]);
当值不反映为代币余额时,这很重要——例如,从铸造中返回的 NFT ID、来自永续合约协议的头寸 ID 或任何不透明的Handle。
11. 存储:用于跨批次上下文的显式写入
存储是按账户命名空间的,并且跨用户操作持久存在。这意味着你今天签名的批次可以读取你昨天签名的批次写入的状态。因为两个批次必须就同一个槽达成一致,所以在这里使用一个稳定的自定义 bigint 键,而不是用 getStorageKey() 生成一个新的:
const storage = batch.storage();
const limitKey = 1n; // 任何应用选择的常量 bigint,由两个批次共享
// 一次性设置批次
await storage.write({ storageKey: limitKey, value: parseUnits('500', 6) });
// 每个后续支付批次
batch.add([\
usdc.check({\
functionName: 'balanceOf',\
args: [scaAddress],\
// 将运行时余额变化与持久化的限制进行比较
constraints: [{ lte: storage.runtimeValue({ storageKey: limitKey }) }],\
}),\
usdc.write({\
functionName: 'transfer',\
args: [recipient, paymentAmount],\
}),\
]);
持久化的链上上下文,无需自定义合约。
12. 订阅 / 定期支付
支付当前的发票金额(在链上读取),由用户签名的上限限制:
const invoice = batch.contract(INVOICE_CONTRACT, invoiceAbi);
const usdc = batch.erc20Token(USDC);
const userCap = parseUnits('100', 6);
batch.add([\
// 应支付金额的读取在执行时进行;受用户上限的限制。
usdc.write({\
functionName: 'transfer',\
args: [\
MERCHANT,\
invoice.runtimeValue({\
functionName: 'amountDue',\
args: [scaAddress],\
constraints: [{ lte: userCap }],\
}),\
],\
}),\
]);
如果发票超过上限,批次回滚——没有意外收费。如果低于上限,精确支付,不会多付。
13. 具有限定滑点的 MEV 保护型交换
不仅仅是在交换级别设置 minAmountOut,而是在调用之间断言价格范围——这是三明治机器人套利的那种东西:
const usdc = batch.erc20Token(USDC);
const weth = batch.erc20Token(WETH);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
const oracle = batch.contract(CHAINLINK_ETH_USD, oracleAbi);
batch.add([\
// 断言链上预言机价格在预期的范围内
oracle.check({\
functionName: 'latestAnswer',\
args: [],\
constraints: [\
{ gte: minOraclePrice },\
{ lte: maxOraclePrice },\
],\
}),\
\
usdc.write({ functionName: 'approve', args: [SWAP_ROUTER, parseUnits('1000', 6)] }),\
router.write({\
functionName: 'swapExactTokensForTokens',\
args: [parseUnits('1000', 6), minOutFromSwap, [USDC, WETH], scaAddress, deadline],\
}),\
\
// 后置条件:断言我们收到的在预期范围内
weth.check({\
functionName: 'balanceOf',\
args: [scaAddress],\
constraints: [\
{ gte: expectedMinWeth },\
{ lte: expectedMaxWeth },\
],\
}),\
]);
三明治攻击依赖于将用户的交易推向极端输出的能力。lte 后置条件使它们无利可图:它们无法在触发断言并使整个批次回滚的情况下提取价值。
14. 多代币金库重新平衡到目标权重
卖出一篮子代币,买入另一篮子代币,每个部分的金额在执行时推导:
const sellTokens = [USDT, DAI];
const buyToken = batch.erc20Token(USDC);
const calls = sellTokens.flatMap(addr => {
const t = batch.erc20Token(addr);
return [\
t.write({ functionName: 'approve', args: [SWAP_ROUTER, t.runtimeBalance()] }),\
router.write({\
functionName: 'swapExactTokensForTokens',\
args: [t.runtimeBalance(), 0n, [addr, USDC], scaAddress, deadline],\
}),\
];
});
batch.add([\
...calls,\
buyToken.check({\
functionName: 'balanceOf',\
args: [scaAddress],\
constraints: [{ gte: minTotalUsdcAfter }],\
}),\
]);
最终断言了总的 USDC 下限——只要最终头寸满足约束,部分填充无关紧要。
15. 紧急退出 / 止损
现在签名,仅在价格条件触发时执行:
const oracle = batch.contract(CHAINLINK_ETH_USD, oracleAbi);
const weth = batch.erc20Token(WETH);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
batch.add([\
// 谓词:仅当 ETH/USD < stopLossPrice 时执行
oracle.check({\
functionName: 'latestAnswer',\
args: [],\
constraints: [{ lte: stopLossPrice }],\
}),\
\
weth.write({ functionName: 'approve', args: [SWAP_ROUTER, weth.runtimeBalance()] }),\
router.write({\
functionName: 'swapExactTokensForTokens',\
args: [weth.runtimeBalance(), minOut, [WETH, USDC], scaAddress, deadline],\
}),\
]);
中继器在每个区块通过 eth_call 模拟该条件。谓词通过的那一刻交易提交。无需守护者合约、无需自定义 Solidity、无需存款锁定架构。
16. 有符号比较:PnL 门控的头寸关闭
本周版本的新增内容。 永续合约和保证金协议将未实现 PnL 等值暴露为 int256——它们可以为负。无符号的 gte/lte 运算符比较原始字,因此一个负的 int256(在二进制补码形式中是一个巨大的数字)可能会意外地满足无符号的 gte。signedGte / signedLte 将该值解释为有符号整数,因此比较在零边界上是正确的。
const perps = batch.contract(PERPS_CLEARINGHOUSE, perpsAbi);
batch.add([\
// PnL 是 int256,可能为负。仅在 PnL ≥ +500 USDC 时止盈。
perps.check({\
functionName: 'getUnrealizedPnl',\
args: [scaAddress, positionId],\
constraints: [{ signedGte: parseUnits('500', 6) }],\
}),\
perps.write({\
functionName: 'closePosition',\
args: [positionId],\
}),\
]);
同样的原语也可以用于有符号止损——{ signedLte: parseUnits('-200', 6) } 一旦损失至少达到 200 USDC 就关闭。任何读取或断言有符号数量(PnL、资金费率 delta、净头寸规模)的内容都应使用有符号运算符而不是无符号的。
17. OR 流程:一个谓词中组合的止损 / 止盈
本周版本的新增内容。 单个 check 上的多个约束默认是 AND 关系——每个分支都必须成立。or 允许一个谓词在_任意_分支成立时通过。标准用例是一个单个签名的批次,它根据止损或止盈退出头寸,以市场先达到的为准:
const oracle = batch.contract(CHAINLINK_ETH_USD, oracleAbi);
const weth = batch.erc20Token(WETH);
const router = batch.contract(SWAP_ROUTER, swapRouterAbi);
batch.add([\
// 如果价格 ≤ stopLoss 或者价格 ≥ takeProfit,则触发
oracle.check({\
functionName: 'latestAnswer',\
args: [],\
constraints: [{\
or: [\
{ lte: stopLossPrice },\
{ gte: takeProfitPrice },\
],\
}],\
}),\
\
weth.write({ functionName: 'approve', args: [SWAP_ROUTER, weth.runtimeBalance()] }),\
router.write({\
functionName: 'swapExactTokensForTokens',\
args: [weth.runtimeBalance(), minOut, [WETH, USDC], scaAddress, deadline],\
}),\
]);
一次签名,一个中继器轮询谓词,退出发生在价格先达到的任何一个区间——无需签名和管理两个独立的订单。or 也与有符号运算符组合,因此基于 PnL 的 bracket({ or: [{ signedLte: stopPnl }, { signedGte: targetPnl }] })可以一行完成。
两种输出模式——以及为什么两者都存在
这值得单独一节,因为选择错误的是最常见的集成错误。
batch.toCalls(): Promise<ComposableCall[]>
返回一个 ComposableCall 对象数组——批次中每个条目的结构化表示。当你的执行客户端直接接受调用数组时使用:
- 内部处理批处理/编码的模块化执行环境(MEE)。
- 高级抽象(Biconomy 自己的 SDK 客户端,类似的包装层)接受“一系列要做的事情”并处理其余部分。
- 你自己的脚本,你想在编码前检查结构化的批次。
const calls = await batch.toCalls();
await mee.execute({ calls });
batch.toCalldata(): Promise<Hex>
返回编码为 executeComposable(...) 调用数据的完整批次——一个准备好的十六进制字符串,可以放入任何低级调用中。当你直接控制交易时使用:
- ERC-4337 打包器(ZeroDev、Alchemy、Pimlico、Stackup、Voltaire),在那里你构建自己的
UserOperation。 - ERC-7702 委托,你在构建一个源自 EOA 的交易。
- 直接的合约调用——多重签名通过
Safe.execTransaction(...)、中继器的包装合约或任何你可以放置字节的地方。
const callData = await batch.toCalldata();
// ERC-4337 路径
const userOp = {
sender: scaAddress,
callData,
// ...nonce, gas, signature
};
await bundlerClient.sendUserOperation({ userOp });
// 或者任何合约级别的调用
await walletClient.sendTransaction({
to: scaAddress,
data: callData,
});
// 或者一个 Safe 交易
await safe.execTransaction({
to: scaAddress,
data: callData,
// ...
});
相同的编码调用数据在所有三种情况下都有效,因为它针对的是 executeComposable 选择器——SDK 不关心该函数在哪里。
executeComposable 函数实际在哪里
由于 SDK 与选择器无关,这里是该选择器可以实现的位置菜单,以及每种情况的集成故事:
ERC-7579 执行器模块。 最常见的路径。在 7579 账户上安装 Biconomy(或任何)ERC-8211 执行器模块。该模块通过标准执行器接口暴露 executeComposable。你的 toCalldata() 输出是 data 字段;账户通过其标准的执行流程路由到模块。
ERC-6900 插件。 相同的想法,不同的账户标准。该插件将 executeComposable 暴露为插件执行函数。账户分派给它。
ERC-7702 委托。 EOA 通过 7702 委托给一个实现合约。如果该实现包含 executeComposable,SDK 的调用数据直接工作——无需智能账户部署,只需要一个带有 7702 授权的 EOA。
原生账户继承。 如果你提供一个直接继承 IComposableExecution 的智能账户,SDK 的调用数据就是你的账户的 callData。搞定。
单例路由器。 部署一个任何调用者都可以调用的单个 executeComposable 合约——适用于中继器架构,其中中继器在托管中持有资金并代表用户运行可组合批次。SDK 的调用数据成为调用负载。
自定义执行器合约。 正在构建一些奇特的东西?只要它用 ERC-8211 ABI 实现了 executeComposable 选择器,SDK 的输出就能工作。
SDK 本身从不询问你正在使用哪一个。它只是发出调用数据。
SDK 解锁的用例(速查表)
上面的例子涵盖了大多数这些。快速索引:
- 交换级联 — 动态交换后的精确输出使用。
- 金库迁移 / 收益轮换 — 对份额率容忍。
- 跨链重新平衡 — 桥无关,一次签名。
- 杠杆循环 — 递归头寸构建与健康因子保护。
- 无零头转账 — 全额余额归集,不超额估计。
- MEV 保护 — 调用之间的内联价格范围断言。
- 条件式 DeFi 流程 — 基于链上读取的协议路由。
- 订阅 — 支付当前发票,由签名上限限制。
- 止损 / 触发式退出 — 谓词门控执行,中继器驱动。
- 有符号值门控 — 通过
signedGte/signedLte实现 PnL 感知退出和任何int256断言。 - OR 条件 — 单个谓词中的止损/止盈 bracket。
- 多代币重新平衡 — 带有最终状态约束的一篮子卖出/买入。
- 额度卫生 — 在一个原子批次中设置、使用、重置。
- 持久化链上上下文 — 基于存储的限制、计数器、ID。
谁应该关注
智能账户 / 钱包团队。 ERC-8211 是下一个可组合性原语。安装这个模块可以让你的每个用户访问上述所有功能,而智能批处理 SDK 是你的 dApp 合作伙伴将集成的工具。
DeFi 协议团队。 停止为每个多步骤产品流程提供定制的 Solidity 路由器。用 TypeScript 表达流程,让用户的账户执行它。新产品,新批次——没有新的审计。
聚合器和意图求解器。 ERC-8211 是求解器输出的自然执行层。求解器决定路由;可组合批次强制执行边界。
dApp 开发者。 如果你的用户体验曾经是“批准,等待,然后再次点击”,或者“如果价格变动我们会退款你”,这就是修复它的原语。一次签名,一次原子流程,滑点保护。
高级用户 / 金库 / DAO 运营。 多步骤金库操作——归集、重新平衡、归属——变成了可签名的原子操作,而不是多交易 saga。
AI 代理开发者。 一个带有边界和断言签署可组合批次的代理,比一个带着希望签署静态批次的代理更有意义。约束成为代理的安全护栏。
SDK 不做什么
值得明确说明,因为这在第一次阅读时会让人们困惑:
- 它不是智能账户。 你自己提供账户(或 EOA + 7702),该账户可以访问
executeComposable实现。 - 它不是打包器 / 支付主控。 输出是
ComposableCall[]或executeComposable调用数据——将其输入到你现有的 4337 堆栈中。 - 它不是求解器。 它编码你描述的流程;它不为你决定路由。将其与 Bungee、LiFi、1inch 或你自己的求解器组合使用。
- 它不签名。 签名发生在上面的层——钱包、会话密钥、代理、多重签名。
- 它不部署合约。 新流程不需要新合约。
- 它不做可组合数学。 没有算术原语——将任何链上数学推送到一个小的自定义 Reader 合约(通过
runtimeValue()解析),或者在签名前当输入已知时在 TypeScript 中预计算。
它是编码和开发者体验层。刻意限制范围,刻意保持可组合性。
开始使用
SDK 在 GitHub 上:bcnmy/smart-batching-sdk。它是 TypeScript,基于 Viem,Bun 管理,Vitest 测试。对于任何已经使用 Viem 的人来说都很习惯。
集成的形状:
npm install smart-batching viem。- 确保你目标智能账户(或 EOA + 7702 委托)有一个可访问的 ERC-8211
executeComposable实现。 - 用
createComposableBatch(...).add([...]).toCalldata()(或.toCalls())管道替换你静态的calls[]数组。 - 在你目前过度估计并祈祷的每个地方使用
runtimeBalance()、runtimeAllowance()、runtimeValue()和check()。
当你的一个“这个流程需要两个交易和一个后端任务”的特性折叠成一个签名的批次时,你就知道它工作正常了。
为什么这不仅仅对 DeFi 重要
静态批次给一个账户在一次签名中可以表达的内容设置了一个上限。解除这个上限会改变很多类别:
- 钱包用户体验。 “一次签名,做五件事”不再是营销口号,而是字面意思。
- 跨链应用。 用户签名一个根;谓词处理编排。桥变成一个实现细节。
- AI 代理。 有边界、有断言门控的执行是安全和不安全自主性之间的区别。
- 游戏经济。 “购买物品、装备、转移”,每一步之间都有链上状态检查。
- 订阅和定期支付。 支付确切所欠金额,由签名上限限制。
- 金库自动化。 多重签名友好的可组合批次取代定制的运营脚本。
ERC-8211 是——悄无声息地——自 4337 以来在以太坊上出现的更重要的人体工程学形状的标准之一。智能批处理 SDK 使其在今天对产品团队成为现实,而无需编写一行 Solidity。
资源
- SDK 源码: https://github.com/bcnmy/smart-batching-sdk
- NPM 包: https://www.npmjs.com/package/@biconomy/smart-batching
- ERC-8211 参考: erc8211.com
- 规范讨论: Ethereum Magicians — 关于约束机制、存储合约设计和集成模式的开放反馈
如果你用它做了一些有趣的东西,我们想看看。
- 原文链接: blog.biconomy.io/smart-b...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~