实用工具

OpenZeppelin Contracts 提供了大量可以在你的项目中使用的实用工具。有关完整列表,请查看 API 参考。 以下是一些更受欢迎的工具。

密码学

链上检查签名

从高层次来看,签名是一组密码学算法,允许 _签名者 _ 证明自己是 _ 私钥 _ 的所有者,该私钥用于授权一条信息 (通常是交易或 UserOperation)。EVM 原生支持椭圆曲线数字签名算法 (ECDSA),使用 secp256k1 曲线,但也支持其他签名算法,如 P256 和 RSA。

以太坊签名 (secp256k1)

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;
}
正确进行签名验证并非易事:请确保你已完整阅读并理解 MessageHashUtilsECDSA 的文档。

P256 签名 (secp256r1)

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

RSA 是一种公钥密码系统,因公司和政府公钥基础设施 (PKIs) 和 DNSSEC 而普及。

此密码系统包括使用作为 2 个大素数的乘积的私钥。通过对其哈希值(通常为 SHA256)应用模幂运算来对消息进行签名,其中指数和模数都构成签名者的公钥。

RSA 签名以其密钥大小而闻名,因此效率低于椭圆曲线签名。密钥大小与具有相同安全级别的 ECDSA 密钥相比很大。使用纯 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 或唯一标识符。

签名验证

SignatureChecker 库为验证来自不同来源的签名提供了一个统一的接口。它无缝支持:

  • 来自外部拥有的帐户 (EOA) 的 ECDSA 签名

  • 来自智能合约钱包(如 Argent 和 Safe Wallet)的 ERC-1271 签名

  • 来自没有自己的以太坊地址的密钥的 ERC-7913 签名

这允许开发人员编写一次签名验证代码,并在所有这些不同的签名类型中使用它。

基本签名验证

对于支持 EOA 和 ERC-1271 合同的标准签名验证:

using SignatureChecker for address;

function _verifySignature(address signer, bytes32 hash, bytes memory signature) internal view returns (bool) {
    return SignatureChecker.isValidSignatureNow(signer, hash, signature);
}

该库会自动检测签名者是 EOA 还是合约,并使用适当的验证方法。

ERC-1271 合约签名

对于实现 ERC-1271 的智能合约钱包,您可以显式使用:

function _verifyContractSignature(address signer, bytes32 hash, bytes memory signature) internal view returns (bool) {
    return SignatureChecker.isValidERC1271SignatureNow(signer, hash, signature);
}

ERC-7913 扩展签名

ERC-7913 扩展了签名验证,以支持没有自己的以太坊地址的密钥。这对于集成非以太坊加密曲线、硬件设备或其他身份系统非常有用。

签名者表示为 bytes 对象,该对象连接验证者地址和密钥:verifier || key

function _verifyERC7913Signature(bytes memory signer, bytes32 hash, bytes memory signature) internal view returns (bool) {
    return SignatureChecker.isValidSignatureNow(signer, hash, signature);
}

验证过程如下:

  • 如果 signer.length < 20:验证失败

  • 如果 signer.length == 20:使用标准签名检查完成验证

  • 否则:使用 ERC-7913 验证者完成验证

批量验证

用于一次验证多个 ERC-7913 签名:

function _verifyMultipleSignatures(
    bytes32 hash,
    bytes[] memory signers,
    bytes[] memory signatures
) internal view returns (bool) {
    return SignatureChecker.areValidSignaturesNow(hash, signers, signatures);
}

此函数将拒绝包含重复签名者的输入。建议按 keccak256 哈希值对签名者进行排序,以最大限度地降低 Gas 成本。

这种统一的方法允许智能合约接受来自任何支持源的签名,而无需为每种类型实现不同的验证逻辑。

验证 Merkle 证明

开发人员可以在链下构建 Merkle 树,从而可以通过使用 Merkle 证明来验证元素(叶子)是否属于集合的一部分。这种技术广泛用于创建白名单(例如,用于空投)和其他高级用例。

OpenZeppelin Contracts 提供了一个 JavaScript 库,用于在链下构建树和生成证明。

MerkleProof 提供:

对于链上 Merkle 树,请参见 MerkleTree 库。

自省

在 Solidity 中,了解合约是否支持您想要使用的接口通常很有帮助。ERC-165 是一个有助于进行运行时接口检测的标准。合约提供帮助程序,可用于在您的合约中实现 ERC-165 和查询其他合约:

contract MyContract {
    using ERC165Checker for address;

    bytes4 private InterfaceId_ERC721 = 0x80ac58cd;

    /**
     * @dev 将 ERC-721 令牌从此合约转移给其他人
     */
    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 uint256using 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 提供了这些库来增强数据结构的管理:

Enumerable* 结构与映射类似,因为它们以恒定的时间存储和删除元素,并且不允许重复条目,但它们也支持 _ 枚举 _,这意味着您可以轻松地在链上和链下查询所有存储的条目。

构建 Merkle 树

构建链上 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);
    // 存储新根。
}

该库还支持自定义哈希函数,这些函数可以作为额外的参数传递给 pushsetup 函数。

使用自定义哈希函数是一项敏感的操作。设置后,它要求对推送到树的每个新值保持使用相同的哈希函数,以避免损坏树。因此,最好在您的实施合约中保持您的哈希函数静态,如下所示:

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 字节的块,每个块称为 _ 插槽 _,并且只要这些值不超过其大小,就可以将多个值一起保存。存储的这些属性允许一种称为 _ 打包 _ 的技术,该技术包括将值一起放置在单个存储插槽中,以减少与读取和写入多个插槽而不是仅一个插槽相关的成本。

通常,开发人员使用将值放置在一起的结构来打包值,以便它们更好地适应存储。但是,这种方法需要从 calldata 或内存中加载这样的结构。虽然有时是必要的,但在单个插槽中打包值并将其视为打包值而不涉及 calldata 或内存可能很有用。

Packing 库是一组用于打包适合 32 字节的值的实用程序。该库包括 3 个主要功能:

  • 打包 2 个 bytesXX

  • bytesYY 中提取打包的 bytesXX

  • bytesYY 中替换打包的 bytesXX

使用这些原语,可以构建自定义函数来创建自定义打包类型。例如,假设您需要将 20 字节的 addressbytes4 选择器和 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 库通过用户定义的值类型 (UDVTs) 支持瞬时存储,它支持与 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

Base64 util 允许您将 bytes32 数据转换为其 Base64 string 表示形式。

这对于为 ERC-721ERC-1155 构建 URL 安全的 tokenURI 特别有用。该库提供了一种巧妙的方法来提供 URL 安全的 Data URI 兼容字符串,以提供链上数据结构。

这是一个通过使用 ERC-721 的 Base64 数据 URI 发送 JSON 元数据的示例:

Unresolved include directive in modules/ROOT/pages/utilities.adoc - include::api:example$utilities/Base64NFT.sol[]

Multicall

Multicall 抽象合约带有一个 multicall 函数,该函数将多个调用捆绑到单个外部调用中。使用它,外部帐户可以执行包含多个函数调用的原子操作。这不仅对 EOA 在单个交易中进行多个调用有用,而且是在后面的调用失败时恢复先前调用的方法。

考虑一下这个虚拟合约:

Unresolved include directive in modules/ROOT/pages/utilities.adoc - include::api:example$utilities/Multicall.sol[]

这是如何使用 Ethers.js 调用 multicall 函数,允许在单个交易中调用 foobar

// scripts/foobar.js

const instance = await ethers.deployContract("Box");

await instance.multicall([
    instance.interface.encodeFunctionData("foo"),
    instance.interface.encodeFunctionData("bar")
]);

内存

Memory 库为需要细粒度内存管理的高级用例提供了函数。一个常见的用例是避免在执行循环中分配内存的重复操作时产生不必要的内存扩展成本。考虑以下示例:

function processMultipleItems(uint256[] memory items) internal {
  for (uint256 i = 0; i < items.length; i++) {
    bytes memory tempData = abi.encode(items[i], block.timestamp);
    // 处理 tempData...
  }
}

请注意,每次迭代都会为 tempData 分配新的内存,导致内存不断扩展。可以通过重置迭代之间的内存指针来优化这一点:

function processMultipleItems(uint256[] memory items) internal {
  Memory.Pointer ptr = Memory.getFreeMemoryPointer(); // 缓存指针
  for (uint256 i = 0; i < items.length; i++) {
    bytes memory tempData = abi.encode(items[i], block.timestamp);
    // 处理 tempData...
    Memory.setFreeMemoryPointer(ptr); // 重置指针以供重用
  }
}

这样,每次迭代中为 tempData 分配的内存都会被重用,从而显著降低了处理多个项目时内存扩展的成本。

仅在仔细确认它们是必需的后才使用这些函数。默认情况下,Solidity 安全地处理内存。在不了解内存布局和安全性的情况下使用此库可能很危险。有关详细信息,请参阅 内存布局内存安全 文档。

历史区块哈希

Blockhash 为 L2 协议开发人员提供了对超过以太坊原生 256 区块限制的历史区块哈希的扩展访问权限。通过利用 EIP-2935 的历史存储合约,该库能够访问过去多达 8,191 个区块的区块哈希,使其对于 L2 欺诈证明和状态验证系统非常有价值。

该库无缝地结合了本机 BLOCKHASH 操作码对最近区块 (≤256) 的访问与 EIP-2935 历史存储查询对较旧区块 (257-8,191) 的访问。它通过为未来区块或超出历史窗口的区块返回零,从而以优雅的方式处理边缘情况,从而与 EVM 的行为相匹配。该实现使用高效的 Gas 汇编静态调用历史存储合约。

contract L1Inbox {
    using Blockhash for uint256;

    function verifyBlockHash(uint256 blockNumber, bytes32 expectedHash) public view returns (bool) {
        return blockNumber.blockHash() == expectedHash;
    }
}
在 EIP-2935 激活后,需要 8,191 个区块才能完全填充历史存储。在此之前,只有自 Fork 区块以来的区块哈希可用。

时间

Time 库提供了以类型安全的方式操作与时间相关的对象的辅助程序。它使用 uint48 作为时间点,使用 uint32 作为持续时间,有助于降低 Gas 成本,同时提供足够的精度。

它的主要功能之一是 Delay 类型,它表示一个持续时间,该持续时间可以在将来到达指定时间点自动更改其值,同时保持延迟保证。例如,当减少延迟值(例如,从 7 天减少到 1 天)时,更改仅在旧延迟和新延迟之间的差值(即 6 天)或最小回退期后生效,以防止获得管理员访问权限的攻击者立即减少安全超时并执行敏感操作。这对于需要强制执行时间锁定期的治理和安全机制特别有用。

考虑以下使用和安全更新 Delay 的示例:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;

import {Time} from "contracts/utils/types/Time.sol";

contract MyDelayedContract {
    using Time for *;

    Time.Delay private _delay;

    constructor() {
        _delay = Time.toDelay(3 days);
    }

    function schedule(bytes32 operationId) external {
        // 获取当前的 `_delay` 值,如果在生效前生效,则尊重任何待处理的延迟更改
        uint32 currentDelay = _delay.get();
        uint48 executionTime = Time.timestamp() + currentDelay;

        // ... 在 `executionTime` 安排操作
    }

    function execute(bytes32 operationId) external {
        uint48 executionTime = getExecutionTime(operationId);
        require(executionTime > 0, "Operation not scheduled");
        require(Time.timestamp() >= executionTime, "Delay not elapsed yet");

        // ... 执行操作
    }

    // 使用 `Time` 的安全机制更新延迟
    function updateDelay(uint32 newDelay) external {
        (Time.Delay updatedDelay, uint48 effect) = _delay.withUpdate(
            newDelay,    // 新的延迟值
            5 days       // 如果缩短延迟,则最小回退
        );

        _delay = updatedDelay;

        // ... 发出事件
    }

    // 获取完整的延迟详细信息,包括待处理的更改
    function getDelayDetails() external view returns (
        uint32 currentValue, // 当前延迟值
        uint32 pendingValue, // 待处理的延迟值
        uint48 effectTime    // 待处理的延迟更改生效的时间点
    ) {
        return _delay.getFull();
    }
}

此模式在 OpenZeppelin 的AccessManager 中广泛用于实现安全的基于时间的访问控制。例如,当更改管理员延迟时:

// 来自 AccessManager.sol
function _setTargetAdminDelay(address target, uint32 newDelay) internal virtual {
    uint48 effect;
    (_targets[target].adminDelay, effect) = _targets[target].adminDelay.withUpdate(
        newDelay,
        minSetback()
    );

    emit TargetAdminDelayUpdated(target, newDelay, effect);
}