恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
WTF-Solidity 实战:EIP712 类型化数据签名——从 EIP712Storage 合约理解链下签名与链上验证
首页
资讯中心
/
WTF-Solidity 实战:EIP712 类型化数据签名——从 EIP712Storage 合约理解链下签名与链上验证
WTF-Solidity 实战:EIP712 类型化数据签名——从 EIP712Storage 合约理解链下签名与链上验证
发布时间:2026/9/14 19:59:25
WTF-Solidity 实战EIP712 类型化数据签名——从 EIP712Storage 合约理解链下签名与链上验证【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity本讲是 WTF-Solidity 极简入门教程的第 52 讲围绕 EIP712 类型化数据签名展开。相比上一讲介绍的 EIP191 personal sign 签名EIP712 在签名时向用户展示结构化原始数据让用户确认所签内容后再签名既安全又直观。读完本文你将掌握 EIP712 的完整使用流程在链下前端/脚本用signTypedData()对类型化数据签名在链上合约用DOMAIN_SEPARATOR、TYPEHASH与 OpenZeppelinECDSA库验证签名并更新状态变量并能在 Remix 与本地 HTML 页面中完整复现整个流程。EIP712 是什么从 EIP191 到类型化签名以太坊早期的 EIP191 签名标准对应钱包的personal_sign/eth_sign可以给任意一段消息签名但它有一个明显的短板当签名的数据结构比较复杂时钱包只向用户展示一串难以阅读的十六进制哈希用户根本无法确认自己到底签了什么容易在不知情的情况下签署恶意内容。EIP712 类型化数据签名EIP-712: Typed data signing and verification解决了这个问题当支持 EIP712 的 DApp 发起签名请求时钱包会把签名消息的原始字段逐一展示出来例如 Permitting 的owner、spender、value、nonce、deadline用户核对无误后再签名。EIP712 在链下签名、链上验证签名过程不消耗 Gas同时它把签什么变得可读、可审计因此被 MetaMask、Uniswap 代币对、DAI 稳定币以及 ERC-2612permit等场景广泛采用。在 WTF-Solidity 仓库中第 52 讲 EIP712Storage.sol 是一个最简实现合约持有一个状态变量number只有持有者通过 EIP712 签名授权后调用者才能修改它。下面我们围绕这个例子完整走一遍。EIP712 的三个核心构件在进入代码之前先理解 EIP712 的编码骨架它由三部分构成EIP712Domain域分隔信息包含nameDApp 名称、version版本一般约定为1、chainId链 ID和verifyingContract验证签名的合约地址。这些信息会在签名时展示给用户并确保只有特定链上的特定合约才能验证该签名从而防止跨链、跨合约的签名重放。类型哈希TYPEHASH对自定义数据类型结构的字符串声明做keccak256哈希例如keccak256(Storage(address spender,uint256 number))。它保证链下签名与链上验证所引用的数据结构完全一致。DOMAIN_SEPARATOR与消息摘要digestDOMAIN_SEPARATOR是EIP712Domain四要素与EIP712DOMAIN_TYPEHASH编码哈希后的唯一值最终被签名的digest由\x19\x01前缀、DOMAIN_SEPARATOR与结构化数据哈希拼接后再次keccak256得到。整个编码在 OpenZeppelin 的 EIP712.sol 中有标准实现_buildDomainSeparator()内部执行keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this)))_hashTypedDataV4(structHash)则通过 MessageHashUtils.toTypedDataHash 计算keccak256(abi.encodePacked(\x19\x01, domainSeparator, structHash))。我们的教学合约为了极简没有直接继承该库而是手写了一遍同样的逻辑便于理解原理。链下签名使用 ethers.js v6 的 signTypedDataEIP712 应用的第一步是链下签名通常在前端页面或脚本中进行。以 eip712storage.html 为例签名流程分为四步。第 1 步构造domain域信息。EIP712Domain包含四个字段其结构声明如下EIP712Domain: [ { name: name, type: string }, { name: version, type: string }, { name: chainId, type: uint256 }, { name: verifyingContract, type: address }, ]实际传入的参数要与部署的合约完全对应const domain { name: EIP712Storage, version: 1, chainId: 1, verifyingContract: 0xf8e81D47203A594245E36C48e151709F0C19fBe8, };注意chainId必须与合约部署的链一致verifyingContract必须填部署后的合约地址否则签名无法通过链上验证。第 2 步自定义签名数据类型types。根据业务场景定义结构字段名、类型与顺序必须和链上合约严格一致。在EIP712Storage例子中定义了Storage类型spenderaddress允许修改变量的调用者和numberuint256修改后的目标值const types { Storage: [ { name: spender, type: address }, { name: number, type: uint256 }, ], };第 3 步构造message待签名的结构化数据const message { spender: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4, number: 100, };第 4 步调用钱包的signTypedData()签名本仓库使用 ethers v6// 获得provider const provider new ethers.BrowserProvider(window.ethereum) // 获得signer后调用signTypedData方法进行eip712签名 const signature await signer.signTypedData(domain, types, message); console.log(Signature:, signature);签名成功时MetaMask 会弹出类型化签名确认窗口直观展示Spender与Number等字段用户确认后返回 65 字节的r,s,v签名链上验证EIP712Storage 合约逐段解析链下签名完成后链上合约负责验证。完整的合约源码位于 EIP712Storage.sol下面逐段解析其核心逻辑。状态变量与类型哈希合约有 5 个状态变量EIP712DOMAIN_TYPEHASHEIP712Domain的类型哈希常量STORAGE_TYPEHASHStorage的类型哈希常量DOMAIN_SEPARATOR每个 DApp域唯一的值由EIP712DOMAIN_TYPEHASH与 name、version、chainId、verifyingContract 编码哈希得到在constructor()中初始化number被存储的值可由permitStore()修改owner合约所有者constructor()中初始化为部署者permitStore()用它校验签名者身份。// SPDX-License-Identifier: MIT // By 0xAA pragma solidity ^0.8.0; import openzeppelin/contracts/utils/cryptography/ECDSA.sol; contract EIP712Storage { using ECDSA for bytes32; bytes32 private constant EIP712DOMAIN_TYPEHASH keccak256(EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)); bytes32 private constant STORAGE_TYPEHASH keccak256(Storage(address spender,uint256 number)); bytes32 private DOMAIN_SEPARATOR; uint256 number; address owner;构造函数初始化 DOMAIN_SEPARATOR 与 ownerconstructor(){ DOMAIN_SEPARATOR keccak256(abi.encode( EIP712DOMAIN_TYPEHASH, // type hash keccak256(bytes(EIP712Storage)), // name keccak256(bytes(1)), // version block.chainid, // chain id address(this) // contract address )); owner msg.sender; }这里有两个细节值得注意其一string类型在abi.encode之前必须先用keccak256(bytes(...))做哈希动态类型无法直接参与abi.encode的定长编码其二chainId使用block.chainid、合约地址使用address(this)动态获取避免硬编码。这与 OpenZeppelin EIP712.sol 中_buildDomainSeparator()的做法完全一致。permitStore签名拆解、digest 拼接与验证permitStore()是核心函数它验证 EIP712 签名通过后将number更新为_num/** * dev Store value in variable */ function permitStore(uint256 _num, bytes memory _signature) public { // 检查签名长度65是标准r,s,v签名的长度 require(_signature.length 65, invalid signature length); bytes32 r; bytes32 s; uint8 v; // 目前只能用assembly (内联汇编)来从签名中获得r,s,v的值 assembly { /* 前32 bytes存储签名的长度 (动态数组存储规则) add(sig, 32) sig的指针 32 等效为略过signature的前32 bytes mload(p) 载入从内存地址p起始的接下来32 bytes数据 */ // 读取长度数据后的32 bytes r : mload(add(_signature, 0x20)) // 读取之后的32 bytes s : mload(add(_signature, 0x40)) // 读取最后一个byte v : byte(0, mload(add(_signature, 0x60))) } // 获取签名消息hash bytes32 digest keccak256(abi.encodePacked( \x19\x01, DOMAIN_SEPARATOR, keccak256(abi.encode(STORAGE_TYPEHASH, msg.sender, _num)) )); address signer digest.recover(v, r, s); // 恢复签名者 require(signer owner, EIP712Storage: Invalid signature); // 检查签名 // 修改状态变量 number _num; }这段代码分四步完成验证长度检查标准r,s,v签名为 65 字节r 32 字节 s 32 字节 v 1 字节先做require校验防止畸形输入。内联汇编拆解签名由于 EVM 没有内置的字节数组按偏移取段的便捷指令r/s/v需要通过assembly从bytes memory中读取——前 32 字节是动态数组的长度字段因此r从add(_signature, 0x20)开始读 32 字节s从0x40偏移读v取0x60偏移处的最后一个字节。OpenZeppelin 的 ECDSA.sol 中tryRecover采用了完全相同的汇编手法。拼接 digest\x19\x01是 EIP712 规范要求的固定前缀随后拼接DOMAIN_SEPARATOR与keccak256(abi.encode(STORAGE_TYPEHASH, msg.sender, _num))。注意这里第三部分用的是msg.sender而不是签名的spender字段——链上以实际调用者为准同时abi.encode定长编码会补零对齐而不是abi.encodePacked保证编码结果与链下 ethers 的signTypedData一致。ECDSA 恢复签名者并校验digest.recover(v, r, s)借助 OpenZeppelinECDSA库恢复出签名者地址再与owner比对。在 ECDSA.sol 的实现中tryRecover还会额外校验s值必须落在 secp256k1 曲线的下半区间、v必须为 27 或 28以抵御签名延展性malleability攻击。retrieve读取状态变量/** * dev Return value * return value of number */ function retrieve() public view returns (uint256){ return number; }完整部署复现下面是在 Remix MetaMask 环境下完整复现一遍的步骤。第 1 步部署合约。在 Remix 中编译并部署EIP712Storage合约部署者钱包即owner。第 2 步启动本地签名页面。运行仓库中的 eip712storage.html。由于浏览器内容安全策略Content Security Policy的限制MetaMask 无法通过file://协议与 DApp 通信因此需要用静态文件服务器托管该页面。在包含eip712storage.html的目录下执行npm install -g http-server http-server然后浏览器访问http://127.0.0.1:8080把页面的Contract Address改为刚部署的EIP712Storage合约地址依次点击Connect MetaMask和Sign Permit完成签名。签名必须使用部署合约的钱包例如 Remix 测试钱包public_key: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 private_key: 503f38a9c967ed597e47fe25643985f032b072db8075426a92110f82df48dfcb第 3 步调用permitStore()。将第 2 步得到的_num与签名填入参数调用后修改number。第 4 步调用retrieve()验证。观察返回值number已成功更新为目标值。如果签名无效例如签名者不是ownerpermitStore()会以EIP712Storage: Invalid signature回滚。举一反三EIP712 在 ERC-2612 Permit 中的标准应用掌握了 EIP712 的手写实现后再看真实协议的标准用法会更容易理解。在仓库的 ERC20Permit.sol 中OpenZeppelin 的ERC20Permit合约直接继承了 EIP712.sol 抽象合约将 EIP712 封装为可直接复用的基础设施contract ERC20Permit is ERC20, IERC20Permit, EIP712 { mapping(address uint) private _nonces; bytes32 private constant _PERMIT_TYPEHASH keccak256(Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)); constructor(string memory name, string memory symbol) EIP712(name, 1) ERC20(name, symbol){} function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public virtual override { require(block.timestamp deadline, ERC20Permit: expired deadline); bytes32 structHash keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline)); bytes32 hash _hashTypedDataV4(structHash); address signer ECDSA.recover(hash, v, r, s); require(signer owner, ERC20Permit: invalid signature); _approve(owner, spender, value); }对比可见permit与我们手写的permitStore逻辑同构都用_hashTypedDataV4()内部即toTypedDataHash(DOMAIN_SEPARATOR, structHash)拼出 digest都用ECDSA.recover恢复签名者并与owner比对。差别在于 ERC-2612 额外引入了nonce防止签名重放、deadline防止过期签名这也是生产级 EIP712 应用普遍需要补上的两重防护——本讲的教学合约为了聚焦 EIP712 原理刻意省略了它们在实际项目中使用时建议参考ERC20Permit的写法加入 nonce 与 deadline 机制。总结这一讲我们以EIP712Storage为例完整介绍了 EIP712 类型化数据签名与 EIP191 对比EIP712 在签名时向用户展示结构化原始数据用户核实无误后才签名安全性与可审计性远超裸哈希签名链下签名通过 ethers v6 的signTypedData(domain, types, message)完成domain约束链与合约types/message定义被签的业务数据链上验证合约侧用EIP712DOMAIN_TYPEHASH、STORAGE_TYPEHASH与DOMAIN_SEPARATOR拼出digest再用 OpenZeppelinECDSA.recover恢复签名者并与owner比对生产实践以仓库中的 ERC20Permit.sol 为例展示了 nonce 防重放、deadline 防过期的标准加固方式。EIP712 目前已成为以太坊生态中 DApp 签名授权的事实标准在 MetaMask、Uniswap 代币对、DAI 稳定币等场景均有使用值得每一位 Solidity 开发者熟练掌握。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考