本文档介绍了OpenZeppelin Contracts库中提供的各种实用工具,包括密码学(签名验证,包括ECDSA、P256和RSA)、Merkle证明验证、接口自省(ERC-165)、数学运算、数据结构(如BitMaps、EnumerableSet、MerkleTree等)、数据打包、底层存储槽操作(StorageSlot)、Base64编码以及多重调用(Multicall)等功能。
OpenZeppelin Contracts 提供了大量可在你的项目中使用的实用工具。有关完整列表,请查看 API 参考。 以下是一些更受欢迎的工具。
从高层次来看,签名是一组密码学算法,允许签名者证明自己是用于授权一段信息(通常是交易或 UserOperation
)的私钥的所有者。EVM 原生支持使用 secp256k1 曲线的椭圆曲线数字签名算法(ECDSA),但也支持其他签名算法,如 P256 和 RSA。
ECDSA
提供了用于恢复和管理以太坊账户 ECDSA 签名的函数。这些签名通常通过 web3.eth.sign
生成,并形成一个 65 字节的数组(在 Solidity 中是 bytes
类型),排列方式如下:[[v (1)], [r (32)], [s (32)]]
。
数据签名者可以使用 ECDSA.recover
恢复,并将其地址进行比较以验证签名。大多数钱包会对要签名的数据进行哈希处理,并添加前缀 \x19Ethereum Signed Message:\n
,因此在尝试恢复以太坊签名消息哈希的签名者时,你需要使用 toEthSignedMessageHash
。
using ECDSA for bytes32;
using MessageHashUtils for bytes32;
function _verify(bytes32 data, bytes memory signature, address account) internal pure returns (bool) {
return data
.toEthSignedMessageHash()
.recover(signature) == account;
}
正确进行签名验证并非易事:请确保你完整阅读并理解 MessageHashUtils 和 ECDSA 的文档。 |
P256,也称为 secp256r1,是最常用的签名方案之一。P256 签名由美国国家标准与技术研究院 (NIST) 制定,并在消费类硬件和软件中广泛使用。
这些签名与常规的以太坊签名 (secp256k1) 不同,因为它们使用不同的椭圆曲线来执行操作,但具有相似的安全保证。
using P256 for bytes32;
function _verify(
bytes32 data,
bytes32 r,
bytes32 s,
bytes32 qx,
bytes32 qy
) internal pure returns (bool) {
return data.verify(data, r, s, qx, qy);
}
默认情况下,verify
函数将尝试调用地址 0x100
上的 RIP-7212 预编译合约,如果不可用,将回退到 Solidity 中的实现。如果你知道预编译合约在你正在处理的链上可用,并且在将来你打算在任何其他链上使用相同的字节码,我们建议你使用 verifyNative
。如果对潜在的未来目标链的本机预编译合约 P256
的实施路线图有任何疑问,请考虑使用 verifySolidity
。
using P256 for bytes32;
function _verify(
bytes32 data,
bytes32 r,
bytes32 s,
bytes32 qx,
bytes32 qy
) internal pure returns (bool) {
// 将只调用 address(0x100) 上的预编译合约
return data.verifyNative(data, r, s, qx, qy);
}
P256 库只允许曲线较低阶的 s 值(即 s ⇐ N/2 )以防止可延展性。如果你的工具生成曲线两侧的签名,请考虑翻转 s 值以保持兼容性。 |
RSA 是一种公钥密码系统,在公司和政府的公钥基础设施(PKI)和 DNSSEC 中得到普及。
该密码系统包括使用两个大质数的乘积的私钥。消息通过对其哈希值(通常是 SHA256)应用模幂运算来签名,其中指数和模数都构成签名者的公钥。
由于密钥的大小(与具有相同安全级别的 ECDSA 密钥相比很大),RSA 签名不如椭圆曲线签名有效。使用纯 RSA 被认为是不安全的,这就是为什么该实现使用 RFC8017 中的 EMSA-PKCS1-v1_5
编码方法,以在签名中包含填充。
要使用 RSA 验证签名,你可以利用 RSA
库,该库公开了一种使用 PKCS 1.5 标准验证 RSA 的方法:
using RSA for bytes32;
function _verify(
bytes32 data,
bytes memory signature,
bytes memory e,
bytes memory n
) internal pure returns (bool) {
return data.pkcs1Sha256(signature, e, n);
}
始终使用至少 2048 位的密钥。此外,请注意,由于存在任意可选参数的可能性,PKCS#1 v1.5 允许重放。为防止重放攻击,请考虑在消息中包含链上 nonce 或唯一标识符。 |
开发人员可以在链下构建 Merkle 树,这允许通过使用 Merkle 证明来验证元素(叶子)是否属于某个集合。这种技术广泛用于创建白名单(例如,用于空投)和其他高级用例。
OpenZeppelin Contracts 提供了一个 JavaScript 库,用于在链下构建树和生成证明。 |
MerkleProof
提供:
multiProofVerify
- 可以证明多个值是 Merkle 树的一部分。
对于链上 Merkle 树,请参阅 MerkleTree
库。
在 Solidity 中,经常需要知道合约是否支持你要使用的接口。ERC-165 是一个有助于进行运行时接口检测的标准。合约为在你的合约中实现 ERC-165 和查询其他合约提供了辅助函数:
IERC165
— 这是 ERC-165 接口,它定义了 supportsInterface
。在实现 ERC-165 时,你将符合此接口。
ERC165
— 如果你想使用合约存储中的查找表来支持接口检测,请继承此合约。你可以使用 _registerInterface(bytes4)
注册接口:查看作为 ERC-721 实现一部分的示例用法。
ERC165Checker
— ERC165Checker 简化了检查合约是否支持你关心的接口的过程。
包含 using ERC165Checker for address;
contract MyContract {
using ERC165Checker for address;
bytes4 private InterfaceId_ERC721 = 0x80ac58cd;
/**
* @dev 将 ERC-721 token 从此合约转移给其他人
*/
function transferERC721(
address token,
address to,
uint256 tokenId
)
public
{
require(token.supportsInterface(InterfaceId_ERC721), "IS_NOT_721_TOKEN");
IERC721(token).transferFrom(address(this), to, tokenId);
}
}
虽然 Solidity 已经提供了数学运算符(即 +
、-
等),但 Contracts 包括 Math
;一组用于处理数学运算符的实用工具,支持额外的操作(例如,average
)和 SignedMath
;一个专门用于有符号数学运算的库。
使用 using Math for uint256
或 using SignedMath for int256
包含这些合约,然后在你的代码中使用它们的函数:
contract MyContract {
using Math for uint256;
using SignedMath for int256;
function tryOperations(uint256 a, uint256 b) internal pure {
(bool succeededAdd, uint256 resultAdd) = x.tryAdd(y);
(bool succeededSub, uint256 resultSub) = x.trySub(y);
(bool succeededMul, uint256 resultMul) = x.tryMul(y);
(bool succeededDiv, uint256 resultDiv) = x.tryDiv(y);
// ...
}
function unsignedAverage(int256 a, int256 b) {
int256 avg = a.average(b);
// ...
}
}
简单!
在使用可能需要强制转换的不同数据类型时,可以使用 SafeCast 进行类型转换,并添加溢出检查。 |
某些用例需要比 Solidity 本身提供的数组和映射更强大的数据结构。Contracts 提供了这些库来增强数据结构管理:
BitMaps
:在存储中存储打包的布尔值。
Checkpoints
:具有内置查找的检查点值。
DoubleEndedQueue
:在具有 pop()
和 queue()
常数时间操作的队列中存储项目。
EnumerableSet
:具有枚举功能的 集合。
EnumerableMap
:具有枚举功能的 mapping
变体。
MerkleTree
:具有辅助函数的链上 Merkle 树。
Enumerable*
结构类似于映射,因为它们以恒定时间存储和删除元素,并且不允许重复条目,但它们也支持枚举,这意味着你可以轻松地查询链上和链下的所有存储条目。
构建链上 Merkle 树允许开发人员以分散的方式跟踪根的历史记录。对于这些情况,MerkleTree
包含一个预定义的结构,其中包含操作树的函数(例如,推送值或重置树)。
Merkle 树不会故意跟踪根,以便开发人员可以选择他们的跟踪机制。在 Solidity 中设置和使用 Merkle 树非常简单,如下所示:
为了演示目的,函数在没有访问控制的情况下公开 |
using MerkleTree for MerkleTree.Bytes32PushTree;
MerkleTree.Bytes32PushTree private _tree;
function setup(uint8 _depth, bytes32 _zero) public /* onlyOwner */ {
root = _tree.setup(_depth, _zero);
}
function push(bytes32 leaf) public /* onlyOwner */ {
(uint256 leafIndex, bytes32 currentRoot) = _tree.push(leaf);
// 存储新根。
}
该库还支持自定义哈希函数,可以将其作为额外的参数传递给 push
和 setup
函数。
使用自定义哈希函数是一项敏感的操作。设置后,它要求为推送到树的每个新值保持使用相同的哈希函数,以避免损坏树。因此,最好在你的实现合约中保持你的哈希函数静态,如下所示:
using MerkleTree for MerkleTree.Bytes32PushTree;
MerkleTree.Bytes32PushTree private _tree;
function setup(uint8 _depth, bytes32 _zero) public /* onlyOwner */ {
root = _tree.setup(_depth, _zero, _hashFn);
}
function push(bytes32 leaf) public /* onlyOwner */ {
(uint256 leafIndex, bytes32 currentRoot) = _tree.push(leaf, _hashFn);
// 存储新根。
}
function _hashFn(bytes32 a, bytes32 b) internal view returns(bytes32) {
// 自定义哈希函数实现
// 保留为内部实现细节,以
// 保证始终使用相同的函数
}
二叉堆 是一种数据结构,它始终将最重要的元素存储在其峰值,并且可以用作优先级队列。
为了定义堆中什么是最重要的,这些堆通常采用比较器函数,该函数告诉二叉堆一个值是否比另一个值更相关。
OpenZeppelin Contracts 使用二叉堆的属性实现了堆数据结构。默认情况下,堆使用 lt
函数,但允许自定义其比较器。
使用自定义比较器时,建议包装你的函数,以避免错误地使用其他比较器函数的可能性:
function pop(Uint256Heap storage self) internal returns (uint256) {
return pop(self, Comparators.gt);
}
function insert(Uint256Heap storage self, uint256 value) internal {
insert(self, value, Comparators.gt);
}
function replace(Uint256Heap storage self, uint256 newValue) internal returns (uint256) {
return replace(self, newValue, Comparators.gt);
}
EVM 中的存储以 32 字节的块的形式存在,每个块被称为一个 slot,并且只要这些值不超过其大小,就可以将多个值一起保存。存储的这些属性允许一种称为 packing 的技术,该技术包括将值一起放置在单个存储槽上,以减少与读取和写入多个槽而不是仅一个槽相关的成本。
通常,开发人员使用结构体将值打包在一起,以便它们更好地适应存储。但是,此方法需要从 calldata 或内存中加载此类结构。虽然有时是必需的,但在单个槽中打包值并将其视为打包值而不涉及 calldata 或内存可能很有用。
Packing
库是一组用于打包适合 32 字节的值的实用工具。该库包括 3 个主要功能:
打包 2 个 bytesXX
值
从 bytesYY
中提取打包的 bytesXX
值
从 bytesYY
中替换打包的 bytesXX
值
使用这些原语,可以构建自定义函数来创建自定义打包类型。例如,假设你需要将一个 20 字节的 address
与一个 bytes4
选择器和一个 uint64
时间段打包在一起:
function _pack(address account, bytes4 selector, uint64 period) external pure returns (bytes32) {
bytes12 subpack = Packing.pack_4_8(selector, bytes8(period));
return Packing.pack_20_12(bytes20(account), subpack);
}
function _unpack(bytes32 pack) external pure returns (address, bytes4, uint64) {
return (
address(Packing.extract_32_20(pack, 0)),
Packing.extract_32_4(pack, 20),
uint64(Packing.extract_32_8(pack, 24))
);
}
Solidity 为合约中声明的每个变量分配一个存储指针。但是,在某些情况下,需要访问无法使用常规 Solidity 派生的存储指针。
对于这些情况,StorageSlot
库允许直接操作存储槽。
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
function _getImplementation() internal view returns (address) {
return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value;
}
function _setImplementation(address newImplementation) internal {
require(newImplementation.code.length > 0);
StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation;
}
TransientSlot
库通过用户定义的值类型(UDVT)支持瞬态存储,从而启用与 Solidity 中相同的值类型。
bytes32 internal constant _LOCK_SLOT = 0xf4678858b2b588224636b8522b729e7722d32fc491da849ed75b3fdf3c84f542;
function _getTransientLock() internal view returns (bool) {
return _LOCK_SLOT.asBoolean().tload();
}
function _setTransientLock(bool lock) internal {
_LOCK_SLOT.asBoolean().tstore(lock);
}
直接操作存储槽是一种高级实践。开发人员必须确保存储指针不与其他变量冲突。 |
直接写入存储槽的最常见用例之一是用于命名空间存储的 ERC-7201,该命名空间存储保证不会与 Solidity 派生的其他存储槽冲突。
用户可以使用 SlotDerivation
库来利用此标准。
using SlotDerivation for bytes32;
string private constant _NAMESPACE = "<namespace>" // eg. example.main
function erc7201Pointer() internal view returns (bytes32) {
return _NAMESPACE.erc7201Slot();
}
Base64
实用程序允许你将 bytes32
数据转换为其 Base64 string
表示形式。
这对于为 ERC-721
或 ERC-1155
构建 URL 安全的 tokenURIs 特别有用。该库提供了一种巧妙的方法来提供 URL 安全的 Data URI 兼容字符串,以提供链上数据结构。
这是一个通过使用 ERC-721 的 Base64 Data URI 发送 JSON 元数据的示例:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
contract Base64NFT is ERC721 {
using Strings for uint256;
constructor() ERC721("Base64NFT", "MTK") {}
// ...
function tokenURI(uint256 tokenId) public pure override returns (string memory) {
// Equivalent to:
// {
// "name": "Base64NFT #1",
// // Replace with extra ERC-721 Metadata properties
// }
// prettier-ignore
string memory dataURI = string.concat("{\"name\": \"Base64NFT #", tokenId.toString(), "\"}");
return string.concat("data:application/json;base64,", Base64.encode(bytes(dataURI)));
}
}
Multicall
抽象合约带有一个 multicall
函数,该函数将多个调用捆绑到单个外部调用中。有了它,外部账户可以执行包含多个函数调用的原子操作。这不仅对于 EOAs 在单个交易中进行多个调用很有用,而且也是如果后面的调用失败则恢复先前调用的方法。
考虑一下这个虚拟合约:
// contracts/Box.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol";
contract Box is Multicall {
function foo() public {
// ...
}
function bar() public {
// ...
}
}
这是使用 Ethers.js 调用 multicall
函数的方法,允许在单个交易中调用 foo
和 bar
:
// scripts/foobar.js
const instance = await ethers.deployContract("Box");
await instance.multicall([\
instance.interface.encodeFunctionData("foo"),\
instance.interface.encodeFunctionData("bar")\
]);
- 原文链接: docs.openzeppelin.com/co...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!