实用工具 - OpenZeppelin 文档

本文档介绍了OpenZeppelin Contracts库中提供的各种实用工具,包括密码学(签名验证,包括ECDSA、P256和RSA)、Merkle证明验证、接口自省(ERC-165)、数学运算、数据结构(如BitMaps、EnumerableSet、MerkleTree等)、数据打包、底层存储槽操作(StorageSlot)、Base64编码以及多重调用(Multicall)等功能。

实用工具

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

密码学

链上检查签名

从高层次来看,签名是一组密码学算法,允许签名者证明自己是用于授权一段信息(通常是交易或 UserOperation)的私钥的所有者。EVM 原生支持使用 secp256k1 曲线的椭圆曲线数字签名算法(ECDSA),但也支持其他签名算法,如 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 是一种公钥密码系统,在公司和政府的公钥基础设施(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 树,这允许通过使用 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 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 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 字节的块的形式存在,每个块被称为一个 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

Base64 实用程序允许你将 bytes32 数据转换为其 Base64 string 表示形式。

这对于为 ERC-721ERC-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 抽象合约带有一个 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 函数的方法,允许在单个交易中调用 foobar

// scripts/foobar.js

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

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

← Governance

Subgraphs →

  • 原文链接: docs.openzeppelin.com/co...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
OpenZeppelin
OpenZeppelin
江湖只有他的大名,没有他的介绍。