OpenZeppelin Contracts EIP712 变更解析:移除 name/version 存储回退,锁定 31 字节 immutable 边界
2026/9/10 20:35:29 网站建设 项目流程

OpenZeppelin Contracts EIP712 变更解析:移除 name/version 存储回退,锁定 31 字节 immutable 边界

【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts

本文以.changeset/eip712-drop-string-fallback.md所记录的变更为主线,结合仓库中 EIP712 实现、ShortStrings 库 与对应测试用例,系统讲解 OpenZeppelin Contracts 中 EIP-712 域名(domain)参数name/version从"短字符串优先、超长走存储回退"到"仅允许 ShortString、超长直接回退"的行为变更,说明其背后的 EVM 字长约束、不可变变量(immutable)在代理/克隆场景下的作用,以及开发者在升级到新版本时需要执行的迁移检查。

一、变更速览:一次影响所有 EIP712 派生合约的 breaking change

.changeset/eip712-drop-string-fallback.md是 OpenZeppelin Contracts 仓库中由 Changesets 工具管理的版本变更记录(release note),它声明了本次变更的版本影响等级为minor(对应 OpenZeppelin 的语义化版本约定,此类变更在openzeppelin-solidity包中以 minor 版本发布),并将变更全文同步登记在 CHANGELOG.md 中(对应条目编号 #6631)。

变更的核心内容可以用一句话概括:

EIP712:为长name/version值移除存储回退(storage fallback)。两个参数现在都必须能装进一个ShortString(至多 31 字节),否则构造函数将以ShortStrings.StringTooLong错误回退。将域名完全存储在不可变变量中,可以确保当合约在未调用 initializer 的代理(proxy)或克隆(clone)之后使用时,域名(以及下游的ERC7739验证)保持一致。

这意味着一项长期存在的"兜底机制"被正式移除,nameversion的合法长度从此有了硬性上限,且该约束从部署(构造)阶段就开始强制生效。

二、背景:OpenZeppelin 的 EIP-712 域名是如何构造与缓存的

2.1 域名分隔符的构造

EIP-712(eth_signTypedDataV4采用的编码版本,即源码注释中所说的 "v4")要求签名方先计算一个域名分隔符(domain separator)。OpenZeppelin 的 EIP712.sol 在构造阶段将其固化:

bytes32 private constant TYPE_HASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); constructor(string memory name, string memory version) { _name = name.toShortString(); _version = version.toShortString(); _hashedName = keccak256(bytes(name)); _hashedVersion = keccak256(bytes(version)); _cachedChainId = block.chainid; _cachedDomainSeparator = _buildDomainSeparator(); _cachedThis = address(this); }

从源码结构看(EIP712.sol),构造函数做了四件事:

  1. nameversion编码为ShortString存入不可变变量_name_version
  2. 计算并缓存_hashedName_hashedVersion
  3. 将当前链 ID、当前合约地址、构造时计算出的域名分隔符分别缓存到_cachedChainId_cachedThis_cachedDomainSeparator
  4. 所有这些缓存都以immutable修饰——它们被内联进合约字节码,在部署瞬间确定,此后永不可变。

运行时读取域名时,_domainSeparatorV4 只在"当前地址与缓存地址一致、当前链 ID 与缓存链 ID 一致"时复用缓存的_cachedDomainSeparator,否则基于实时值重新计算,从而在链分叉(chain fork)场景下自动抵御重放攻击:

function _domainSeparatorV4() internal view returns (bytes32) { if (address(this) == _cachedThis && block.chainid == _cachedChainId) { return _cachedDomainSeparator; } else { return _buildDomainSeparator(); } } function _buildDomainSeparator() private view returns (bytes32) { return keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this))); }

_hashTypedDataV4(structHash)则借助 MessageHashUtils.toTypedDataHash 将结构哈希与域名分隔符合并,得到最终可交由ECDSA.recover验证的摘要。

2.2 使用 EIP712 的典型合约

EIP712是抽象基类,仓库中大量"签名友好型"合约继承自它,本次变更因此波及面较广。从源码中可确认的继承关系包括:

  • ERC20Permit.sol:ERC20Permit is ERC20, IERC20Permit, EIP712, Nonces,即 EIP-2612 授权签名;
  • Governor.sol:Governor is Context, ERC165, EIP712, Nonces, ...,治理提案与投票签名;
  • Votes.sol:Votes is Context, EIP712, Nonces, IERC5805,代币投票权委托签名;
  • ERC2771Forwarder.sol:元交易转发器,constructor(string memory name) EIP712(name, "1")
  • draft-ERC3009.sol:EIP-3009 转账授权;
  • draft-ERC7739.sol 与 PaymasterSigner.sol:智能合约账户签名验证与 Paymaster 签名者。

由于name通常直接使用代币名称或协议名,一旦某个项目给 ERC20 取了超过 31 字节的名字(例如0x风格的超长描述性名称),在旧版本中可以正常部署,新版本下构造将直接失败——这正是本次变更需要重点评估的破坏点。

三、31 字节上限的由来:ShortString 的 EVM 字长编码

要理解"至多 31 字节"这个限制,需要看 ShortStrings.sol 的实现。其核心是一个 Solidity 用户定义值类型:

type ShortString is bytes32;

一个 EVM 字(word)恰好是 32 字节。ShortStrings库的编码策略是把字符串内容(≤31 字节)与长度信息(1 字节)打包进同一个 32 字节的字中,从而让短字符串可以整体作为immutable变量存在,无需占用任何存储槽:

function toShortString(string memory str) internal pure returns (ShortString) { bytes memory bstr = bytes(str); if (bstr.length > 0x1f) { // 0x1f = 31 revert StringTooLong(str); } return ShortString.wrap(bytes32(uint256(bytes32(bstr)) | bstr.length)); }

解码方向则从低字节取出长度、从高 31 字节取出内容(toString 通过 memory-safe 内联汇编完成):

function byteLength(ShortString sstr) internal pure returns (uint256) { uint256 result = uint256(ShortString.unwrap(sstr)) & 0xFF; if (result > 0x1f) { revert InvalidShortString(); } return result; }

两个值得注意的细节:

  • 上限是 31 字节而非 31 个字符toShortString检查的是bytes(str).length,即 UTF-8 编码后的字节长度。一个中文字符可能占 3 字节,因此"看起来很短"的字符串也可能触发StringTooLong。这一点与ShortStrings.byteLength的文档警告一致("the UTF-8 encoding of a single character can span over multiple bytes")。
  • 错误信息带参数。test/utils/ShortStrings.test.js 覆盖了 0、1、16、31、32、64、1024 字节等边界,其中 32 字节及以上全部断言回退StringTooLong;对应的 Foundry 测试 test/utils/ShortStrings.t.sol 也使用abi.encodeWithSelector(ShortStrings.StringTooLong.selector, input)精确匹配错误参数。

四、被移除的旧行为:storage fallback 长什么样

在本次变更之前,EIP712对长name/version的处理方式是"短则内联、长则落盘",底层机制就是 ShortStrings.sol 中至今仍然保留的 fallback 函数对:

bytes32 private constant FALLBACK_SENTINEL = 0x0000...00FF; function toShortStringWithFallback(string memory value, string storage store) internal returns (ShortString) { if (bytes(value).length < 0x20) { // 长度 < 32 字节,走短路径 return toShortString(value); } else { StorageSlot.getStringSlot(store).value = value; // 否则写入存储 return ShortString.wrap(FALLBACK_SENTINEL); // 并以哨兵值标记"在存储里" } } function toStringWithFallback(ShortString value, string storage store) internal pure returns (string memory) { if (ShortString.unwrap(value) != FALLBACK_SENTINEL) { return toString(value); } else { return store; // 从存储槽读回完整字符串 } }

旧版EIP712正是用这套机制:_name/_versionShortString,另配_nameFallback/_versionFallback两个存储字符串变量承接超长值。这意味着长域名会真实占用合约存储槽。

本次变更将其删除后,在 EIP712.sol 中可以看到两个 fallback 槽位被显式标记为弃用并保留

// IMPORTANT: Deprecated. Kept to preserve the storage layout of inheriting contracts used as an // implementation behind a proxy. // slither-disable-next-line constable-states string private _nameFallback; string private _versionFallback;

保留这两个声明纯粹是为了维持存储布局稳定(storage layout compatibility),避免在升级场景下破坏已继承合约的存储槽位布局;它们不再参与任何读写逻辑。

五、为什么要改:immutable 与代理/克隆场景的一致性

这是本次变更最核心的设计动机。changeset 原文明确写道:

Storing the domain exclusively in immutables keeps the domain (and downstreamERC7739verification) consistent when the contract is used behind a proxy or clone without an initializer.

其逻辑链条可以拆解如下:

  1. immutable 在字节码中固化immutable变量的值在部署(construct-time)写入合约字节码,因此"实现合约"(implementation)与所有克隆(clone)共享同一份字节码时,自然共享同一组 immutable 值。EIP-1167 克隆不执行构造函数,克隆的 immutable 值直接继承自被克隆的实现——这正是"无需 initializer 也保持一致"的机制来源。

  2. 域名的verifyingContract部分是动态的:虽然_hashedName_hashedVersion等是 immutable,但_buildDomainSeparator中的address(this)block.chainid是运行时读取的,所以 test/utils/cryptography/EIP712.test.js 中的 "adjusts when behind proxy" 用例验证了:克隆到新地址后,通过eip712Domain()重建出的域名会自动带上克隆自身的地址,域名分隔符也随之正确变化。

  3. 旧 fallback 的隐患:一旦长字符串落到存储槽,其值属于"存储状态"。对于未运行 initializer 的代理/克隆,存储区是空的、不可预期的,读取_nameFallback会得到未初始化数据,导致name/version在实现地址与代理地址之间不一致,进而污染域名分隔符和签名摘要。

  4. 对 ERC-7739 的下游影响ERC7739及其工具库 draft-ERC7739Utils.sol 在验证嵌套签名时会引用应用合约的_domainSeparatorV4(源码中称为appSeparator)。如果应用域名因 fallback 存储而漂移,嵌套签名验证将无法复现相同的摘要,签名校验即失败。将所有域名成分收敛为 immutable 后,"任何地址的克隆,域名都等于构造时的名字/版本/链 ID/自身地址"这一不变量在任何部署形态下都成立。

  5. Gas 与冷存储读取:源码注释也指出,升级版(upgradeable)合约中缓存值与实现合约地址绑定,_domainSeparatorV4在代理场景总会走重建路径;由于全部成分都在字节码里,重建比访问冷存储槽更便宜。

六、新行为与迁移检查清单

6.1 新行为的精确语义

变更后 EIP712.sol 构造函数的强制约束为:

  • bytes(name).length <= 31,否则回退ShortStrings.StringTooLong(name)
  • bytes(version).length <= 31,否则回退ShortStrings.StringTooLong(version)
  • 回退发生在部署阶段(构造调用),部署交易将以自定义错误(custom error)形式失败,链上不会留下半成品合约。

测试 test/utils/cryptography/EIP712.test.js 的 "with long name and version" 一节完整覆盖了这条路径:32 个A作为name、32 个B作为version,分别断言部署回退StringTooLong且错误参数与超长字符串一致。

6.2 迁移检查清单

对正在使用或计划升级到包含此变更版本的开发者,建议按以下步骤自查:

  1. 枚举所有继承EIP712的合约ERC20Permit/ERC20Votesname通常透传代币名)、GovernorGovernorVotes系列、Votes/ERC721VotesERC2771ForwarderERC3009ERC7739PaymasterSigner等(可在仓库中通过搜索is EIP712/EIP712(快速定位全部派生点,参考上文第二节列出的文件清单)。
  2. 核对传入构造函数的字符串bytes(name).lengthbytes(version).length均需 ≤ 31。注意按字节长度而非字符数核算,UTF-8 多字节字符会占用更多空间;版本号如"1""0.8.24"通常安全,代币全名是最常见的高危项。
  3. 处理超长场景:若确需长名称,可改用简写名称作为域名name(域名nameERC20.symbol()等元数据无关,只影响签名域),或将项目迁移到自定义域名实现。
  4. 回归验证:部署后通过eip712Domain()(EIP-5267 接口,见 IERC5267.sol)核对name/version返回值;对代理/克隆部署形态,额外验证域名中的verifyingContract与当前地址一致。
  5. 升级注意:若已有线上合约以代理形式部署且其实现包含旧版EIP712,升级实现时须保持相同的name/version,否则域名将改变、存量签名全部失效(这一点在 CHANGELOG.md 的历史条目中也有明确告诫)。

七、总结

本次EIP712变更是一次"以确定性换灵活性"的设计收敛:通过移除长name/version的存储回退,OpenZeppelin 将域名构造的每一个输入都固化进字节码(immutable),换来的是在代理、克隆、无 initializer 部署等任何形态下完全可复现的域名分隔符,并为 ERC-7739 嵌套签名验证提供了稳定基石;代价则是name/version必须满足ShortString的 31 字节上限,否则在构造阶段即以StringTooLong回退。对于链上签名协议而言,签名摘要的可复现性是正确性的生命线——这正是本次变更值得所有依赖EIP712的项目在升级时逐项核对的原因。

【免费下载链接】openzeppelin-contractsOpenZeppelin Contracts is a library for secure smart contract development.项目地址: https://gitcode.com/GitHub_Trending/op/openzeppelin-contracts

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询