如何在 OpenZeppelin Contracts ERC-4626 金库中给存取款添加费用并保持 preview 函数合规?
【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts
如果你的 ERC-4626 金库要对 deposit/mint 收取入场费、对 withdraw/redeem 收取出场费,直接的困难不在收费逻辑本身,而在于 EIP-4626 对四个 preview 函数有明确的 MUST 约束:改了存取款路径却不同步改 preview,就会出现预览值与实际成交值不一致,金库既不合规也会误导集成方。OpenZeppelin Contracts 的官方 ERC-4626 指南(erc4626.adoc)专门有一节 "Custom behavior: Adding fees to the vault",给出了合规要求和一个可直接参考的费用金库示例(ERC4626Fees.sol),本文按这条路径说明怎么实现、怎么配置费率、以及仓库里官方测试是如何核对结果的。
先弄清 preview 函数必须满足的合规约束
IERC4626 的接口注释对四个 preview 函数都写死了费用语义:
previewDeposit/previewMint:MUST be inclusive of deposit fees(返回值必须已包含入金费用);previewWithdraw/previewRedeem:MUST be inclusive of withdrawal fees(返回值必须已包含出金费用)。
指南正文把这两条约束落到具体调用语义上:
- 调用
deposit(100, receiver)时,调用方必须恰好支付 100 个底层资产(含费),receiver 得到的 shares 数必须与previewDeposit(100)的返回值一致; previewMint必须把用户要在 shares 成本之外额外支付的费用算进去;- 取款方向,用户给的数值应对应他实际收到的资产,费用要加进
previewWithdraw报出的 shares; - 相应地,
Deposit事件应包含用户支付的资产数(含费),Withdraw事件应包含用户烧毁的 shares 数(含费)与用户实际收到的资产数(扣费后)。
这个设计的后果是:Deposit和Withdraw事件各自描述了两个汇率,"Buy-in" 与 "Exit" 价差就是金库收取的费用。
官方示例金库:ERC4626Fees
指南给出的示例是一个继承 ERC4626 的抽象合约(源码),费率用基点(basis point)表示,_BASIS_POINT_SCALE = 1e4。它的关键设计说明写在合约注释里:
- 费用以资产(assets)而非 shares 计——费用按存入/取出的资产额计算,而不是按铸造/赎回的 shares 额计算。注释明确说这是一个 opinionated 的设计决定,集成时要留意;
- 合约标注:未经审计(not been audited),不应视为 production ready,使用需谨慎。
需要覆盖的函数分三组。
四个 preview override,把费用算进预览值:
/// @dev Preview taking an entry fee on deposit. See {IERC4626-previewDeposit}. function previewDeposit(uint256 assets) public view virtual override returns (uint256) { uint256 fee = _feeOnTotal(assets, _entryFeeBasisPoints()); return super.previewDeposit(assets - fee); } /// @dev Preview adding an entry fee on mint. See {IERC4626-previewMint}. function previewMint(uint256 shares) public view virtual override returns (uint256) { uint256 assets = super.previewMint(shares); return assets + _feeOnRaw(assets, _entryFeeBasisPoints()); } /// @dev Preview adding an exit fee on withdrawal. See {IERC4626-previewWithdraw}. function previewWithdraw(uint256 assets) public view virtual override returns (uint256) { uint256 fee = _feeOnRaw(assets, _exitFeeBasisPoints()); return super.previewWithdraw(assets + fee); } /// @dev Preview taking an exit fee on redeem. See {IERC4626-previewRedeem}. function previewRedeem(uint256 shares) public view virtual override returns (uint256) { uint256 assets = super.previewRedeem(shares); return assets - _feeOnTotal(assets, _exitFeeBasisPoints()); }两个内部 hook,在真正动资产的位置把费用转走。这里覆盖的是内部函数而不是公共函数,这与 ERC4626 的注释一致:修改存取款行为应覆盖_deposit/_withdraw,覆盖公共函数可能导致 deposit 与 mint、withdraw 与 redeem 之间行为不一致。
/// @dev Send entry fee to {_entryFeeRecipient}. See {ERC4626-_deposit}. function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual override { uint256 fee = _feeOnTotal(assets, _entryFeeBasisPoints()); address recipient = _entryFeeRecipient(); super._deposit(caller, receiver, assets, shares); if (fee > 0 && recipient != address(this)) { SafeERC20.safeTransfer(IERC20(asset()), recipient, fee); } } /// @dev Send exit fee to {_exitFeeRecipient}. See {ERC4626-_withdraw}. function _withdraw( address caller, address receiver, address owner, uint256 assets, uint256 shares ) internal virtual override { uint256 fee = _feeOnRaw(assets, _exitFeeBasisPoints()); address recipient = _exitFeeRecipient(); super._withdraw(caller, receiver, owner, assets, shares); if (fee > 0 && recipient != address(this)) { SafeERC20.safeTransfer(IERC20(asset()), recipient, fee); } }两个细节决定了 preview 和实际转账能对得上:
deposit的assets参数本来就含费,所以取费用_feeOnTotal——从一个"已含费"的总额里抽出费用部分;mint的目标是得到确定数量的 shares,费用要额外加在上面,所以用_feeOnRaw——在"尚不含费"的金额上计算要加收的费用。withdraw / redeem 同理对应_feeOnRaw/_feeOnTotal。源码注释说明了各自的适用操作:_feeOnRaw用于 mint / withdraw,_feeOnTotal用于 deposit / redeem;- 当
recipient == address(this)(费用接收方就是金库自己)时不做 transfer,费用留在金库内;只有fee > 0且接收方不是金库时才转出去。
四个配置函数,默认值都是"不收费",按你的产品替换即可(下面的注释为源码原样保留):
function _entryFeeBasisPoints() internal view virtual returns (uint256) { return 0; // replace with e.g. 100 for 1% } function _exitFeeBasisPoints() internal view virtual returns (uint256) { return 0; // replace with e.g. 100 for 1% } function _entryFeeRecipient() internal view virtual returns (address) { return address(0); // replace with e.g. a treasury address } function _exitFeeRecipient() internal view virtual returns (address) { return address(0); // replace with e.g. a treasury address }如果你希望费率在部署时确定而不是写死在代码里,仓库里已有现成的做法:ERC4646FeesMock.sol 通过构造函数接收四个参数(entry/exit 的基点与接收方),存成immutable状态并 override 上述四个函数返回这些值。注意这个文件放在 mocks 目录下且文件名与内容不一致(文件叫 ERC4646FeesMock,合约叫ERC4626FeesMock),把它当作部署形态的参考而非直接引用的生产合约。
用官方测试核对 preview 与事件是否符合预期
仓库自带的测试 ERC4626.test.js 中有专门的ERC4626Fees章节,部署ERC4626FeesMock(即上面的 mock 形态)并按 5% 费率(feeBasisPoints = 500n)验证。测试用的示例数值(文档示例):
const feeBasisPoints = 500n; // 5% const valueWithoutFees = 10_000n; const fees = (valueWithoutFees * feeBasisPoints) / 10_000n; const valueWithFees = valueWithoutFees + fees;入金侧(entry fee = 500 基点,exit fee = 0)的断言:
previewDeposit(valueWithFees)等于valueWithoutFees——付 10,500 资产(含 500 费用),换 10,000 份 shares;previewMint(valueWithoutFees)等于valueWithFees——想要 10,000 份 shares,要付 10,500 资产;- 交易后资产 token 余额变化:用户
-valueWithFees,金库+valueWithoutFees,fee recipient+fees;shares 余额:recipient+valueWithoutFees; - 事件参数:
Deposit(holder, recipient, valueWithFees, valueWithoutFees)——事件里资产数是含费的 10,500,shares 数是 10,000,与指南中 "Deposit 事件描述含费资产数" 的要求一致。
出金侧(exit fee = 500 基点,entry fee = 0)的断言:
previewRedeem(valueWithFees)等于valueWithoutFees——烧 10,500 份 shares,实际收到 10,000 资产;previewWithdraw(valueWithoutFees)等于valueWithFees——想拿 10,000 资产,需要烧 10,500 份 shares;- 余额变化:金库
-valueWithFees,recipient+valueWithoutFees,fee recipient+fees。
这组断言就是"preview 合规"的可核对标准:preview 返回值与同一交易实际发生的转账、铸造、烧毁金额以及Deposit/Withdraw事件参数一一对应,四个方向(deposit、mint、withdraw、redeem)全部成立。
边界与限制
- 示例合约注释明确写着:This contract has not been audited and shouldn't be considered production ready。直接把它当生产合约用之前需要自行审计;
- 费用以资产计的语义是示例合约 opinionated 的选择,如果你的费用应按 shares 计,需要按同样的 preview / 内部 hook 结构自行重写,不能只改配置函数;
_feeOnRaw/_feeOnTotal都使用向上取整(Math.Rounding.Ceil),即费用永远至少按整数单位向上收,方向对用户略不利,集成时要意识到这一点;- 费用接收方默认为
address(0)且未做校验,替换成真实 treasury 地址是接入时的必改项; - 空金库的 inflation attack(通胀攻击)风险与费用无关,
_decimalsOffset的默认虚拟偏移机制仍在生效,相关分析见指南的 Inflation attack 一节。
下一步
如果只看了本文还不够,完整背景(金库汇率模型、通胀攻击的数学推导、费用与事件语义的完整论述)都在 ERC-4626 指南;实现细节可以对照 ERC4626.sol 中deposit/mint/withdraw/redeem如何调用 preview 与内部函数来读,覆盖点与调用链在源码注释里有说明。
【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考