OpenZeppelin Contracts 4.x 文档
OpenZeppelin Contracts 4.x 文档
本文是对 OpenZeppelin Contracts 4.x 文档(截止2022.9.14)的部分翻译。
Overview
OpenZeppelin Contracts 是建立在经过社区审查的代码的坚实基础之上的用于安全智能合约开发的库。当前有 2.x、3.x 和 4.x 三个版本。
其包含有:
- ERC20 和 ERC721 等标准的实施
- 灵活的基于角色的许可方案
- 可重用的 Solidity 组件,用于构建自定义合约和复杂的去中心化系统
OpenZeppelin Contracts 具有稳定的 API,这意味着合约在升级到新的次版本(minor version)时不会意外中断。
使用 npm
即可安装:
安装后,在项目中导入库中的合约即可使用:
如果不熟悉智能合约开发,可以前往 Developing Smart Contracts 来了解有关创建新项目和变异合约的信息。
为了保证系统安全,应该始终按照原样使用已安装的代码,既不要从在线资源复制粘贴,也不要执行修改。该库被设计为仅您使用的合约和函数会被部署,因此您无需担心它会不必要地增加 gas 成本。
官网的 guides 上给出了几个常见的用例和良好实践。以下文章提供了很好的阅读背景,但应注意,随着生态系统中工具继续快速发展,一些引用的工具已经发生了变化。
- The Hitchhiker’s Guide to Smart Contracts in Ethereum 帮助您了解可用于智能合约开发的各种工具,并帮助您设置环境。
- A Gentle Introduction to Ethereum Programming, Part 1 提供了非常有用的介绍性信息,包括来自以太坊平台的许多基本概念。
- Designing the architecture for your Ethereum application 讨论了如何更好地构建应用程序及其与现实世界的关系。
合约向导 页面提供的交互式生成器可以帮助引导您的合约来了解 OpenZeppelin Contracts 中提供的组件。使用交互式生成器,可以根据多种需求来生成定制化的合约,比如可以选择 FEATURES、ACCESS CONTROL、UPGRADEABILITY 等特性。生成的合约可以使用 Hardhat 或 Truffle 等工具编译部署。
Extending Contracts
大多数的 OpenZeppelin Contracts 被期望通过继承(inheritance)来使用:通过继承库中的合约来编写自己的合约。
常见的是 is
语法,比如 contract MyToken is ERC20
。
NOTE:与 contract
不同,Solidity library
不是继承而是依赖于 using for
语法。Openzeppelin Contracts 有一些 library
,大部分都在 Utils 目录中。
Overriding
继承通常用于将父合约的功能添加到自己的合约中,但这并不是它所能做的全部。还可以使用 overrides 来改变父合约某些部分的行为。
例如,假设想要更改 AccessControl 以便不再调用 revokeRole,可以使用 overrides 来实现:
然后旧的 revokeRole 就被替换了,任何对它的调用都会立即 revert。我们无法从合约从删除(remove)该函数,但 revert 所有的调用就足够了。
Calling super
有时想要 extend 父合约的行为,而不是将其更改为其他行为,就需要用到 super
。
super
关键字允许您调用父合约中定义的函数,即使它被覆盖(overridden)。该机制可用于向函数添加额外的检查、发出事件或以其他方式添加您认为合适的功能。
TIP:有关 overrides 如何工作的信息,请参阅 Solidity 官方文档。
下面是 AccessControl
的修改版本,其中 revokeRole
不能用于 revoke DEFAULT_ADMIN_ROLE:
最后的 super.revokeRole
语句将调用 AccessControl
的原始版本的 revokeRole
,如果没有 override 那么相同的代码会运行。
NOTE:从 v3.0.0 开始,view
函数在 OpenZeppelin 中不是 virtual
,因此不能被覆盖。我们正在考虑在即将发布的版本中取消此限制。
Using Hooks
有时,为了扩展父合约,需要覆盖多个相关函数,这会导致代码重复并增加 bug 的可能性。
例如,考虑以 IERC721Receiver
的方式实现安全的 ERC20
transfer。您可能认为覆盖 transfer
和 transferFrom
就足够了,但是 _transfer
和 _mint
呢?为了避免你不得不处理这些细节,我们引入了钩子(hooks)。
Hooks 只是在某些操作发生之前或之后调用的函数。它们提供了一个集中的点来钩入(hook into)和扩展原始行为。
以下是在 ERC20
中使用 _beforeTokenTransfer
实现 IERC721Receiver
模式的方法:
以这种方式使用钩子会使代码更干净、更安全,而不必依赖对父合约内部的深入了解。
NOTE:Hooks 是 OpenZeppelin Contracts v3.0.0 的新功能,我们渴望了解您打算如何使用它们!到目前为止,唯一可用的钩子是 _beforeTransferHook
,在所有的ERC20
, ERC721
, ERC777
和 ERC1155
中。
Rules of Hooks
为了防止在编写使用钩子的代码出现问题,您应该遵循一些准则。它们非常简单,但请确保您遵循它们:
- 每当您覆盖(override)父合约的钩子时,将
virtual
属性重新应用于钩子。这将允许子合约向钩子添加更多的功能。 - 总是在你的覆盖中使用
super
调用父合约的钩子。这将确保调用继承树中的所有钩子:像ERC20Pausable
这样的合约依赖于这种行为。
Using with Upgrades
如果您的合约要部署可升级,例如使用 OpenZeppelin Upgrades Plugins,您将需要使用 OpenZeppelin Contracts 的可升级变体。
此变体可作为名为 @openzeppelin/contracts-upgradeable
的单独包提供,该软件包托管在仓库 OpenZeppelin/openzeppelin-contracts-upgradeable 中。
它遵循 编写可升级合约 的所有规则:构造函数替换为初始化函数,状态变量在初始化函数中初始化,我们还检查次要版本之间的存储不兼容性。
Overview
使用 npm
命令安装:
这个包复制了主 OpenZeppelin Contracts 包结构,但每个文件和合约都有后缀 Upgradeable
。
构造函数被内部初始化函数替换,遵循命名约定 __{ContractName}_init
。由于这些是 internal,因此您必须始终定义自己的公共初始化函数(public initializer function) 并调用您扩展的合约的父合约初始化程序。
CAUTION:与多重继承一起使用需要特别注意。请参阅下面标题为多重继承的部分。
Further Notes
Multiple Inheritance
初始化函数不像构造函数那样被编译器线性化。因此,每个 __{ContractName}_init
函数都将线性化调用嵌入到了所有父合约初始化函数中。因此,调用其中两个 init
函数可能会初始化同一个合约两次。
每个合约中的 __{ContractName}_init_unchained
函数是初始化函数减去对父合约初始化函数的调用,可用于避免双重初始化问题,但不建议手动执行此操作。我们希望能够在 Upgrades Plugins 的未来版本中对此进行安全检查。
Storage Gaps
您可能会注意到每个合约都包含一个名为 __gap
的状态变量。这是在可升级合约中放置的存储(sotrage)中的空的保留空间。它允许我们在未来自由添加新的状态变量,而不会影响与现有部署的存储兼容性。
简单地添加一个状态变量是不安全的,因为它会“向下移动(shifts down)”继承链中下面的所有状态变量。这使得存储布局不兼容,如 Writing Upgradeable Contracts 中所述。计算 __gap
数组的大小,以便合约使用的存储量加起来总是相同的数字(在本例中为 50 storage slots)。
Access Control
访问控制——即“谁被允许做这件事”—— 在智能合约的世界中非常重要。你的合约的访问控制可以控制谁可以铸造代币、对提案进行投票、冻结转账和许多其他事情。因此,了解您如何实现它至关重要,以免其他人窃取您的整个系统。
Ownership and Ownable
最常见和最基本的访问控制形式是所有权(ownership)的概念:有一个账户是合约的 owner
,可以对其执行管理任务。这种方法对于只有一个管理用户的合约来说是完全合理的。
OpenZeppelin Contracts 提供 Ownable
用于在您的合约中实现所有权。
默认情况下,Ownable 合约的 owner
是部署它的帐户,这通常正是您想要的。
Ownable 还可以让您:
transferOwnership
从所有者账户到新账户,以及renounceOwnership
让所有者放弃此管理特权,这是集中管理初始阶段结束后的常见模式。
WARNING:完全删除所有者将意味着受 onlyOwner
保护的管理任务将不再可调用!
请注意,一个合约也可以是另一个合约的所有者!例如,这为使用 Gnosis Safe、Aragon DAO 或您创建的完全自定义合约打开了大门。
通过这种方式,您可以使用可组合性(composability)为您的合约添加额外的访问控制复杂性层。例如,您可以使用由项目负责人运行的 2-of-3 多重签名,而不是将单个常规以太坊账户(外部拥有的账户,或 EOA)作为所有者。该领域的知名项目,例如 MakerDAO,使用与此类似的系统。
Role-Based Access Control
虽然 ownership
的简单性对于简单的系统或快速原型设计很有用,但通常需要不同级别的授权。您可能希望帐户有权禁止用户进入系统,但不能创建新代币。Role-Based Access Control (RBAC) 在这方面提供了灵活性。
本质上,我们将定义多个角色,每个角色都允许执行不同的操作集。例如,一个帐户可能具有“moderator”、“minter”或“admin”角色,然后您将检查这些角色,而不是简单地使用 onlyOwner
。可以通过 onlyRole
修饰符强制执行此检查。另外,您将能够定义如何向帐户授予角色、撤销角色等规则。
大多数软件使用基于角色的访问控制系统:一些用户是普通用户,一些可能是主管(supervisor)或经理(manager),还有一些通常具有管理权限。
Using AccessControl
OpenZeppelin Contracts 为实现基于角色的访问控制提供了 AccessControl
。它的用法很简单:对于您要定义的每个角色,您将创建一个新的角色标识符(role identifier),用于授予(grant)、撤销(revoke)和检查(check)帐户是否具有该角色。
这是一个在 ERC20
token 中使用 AccessControl
来定义“minter”角色的简单示例,该角色允许拥有它的帐户创建新的 token:
NOTE:在您的系统上使用 AccessControl
或复制粘贴本指南中的示例之前,请确保您完全了解其工作原理。
虽然清晰明确,但这并不是我们使用 Ownable
无法实现的。事实上,AccessControl
的亮点在于需要细粒度权限的场景,这可以通过定义多个角色来实现。
让我们通过定义一个“burner”角色来扩充我们的 ERC20 代币示例,该角色允许帐户销毁代币,并使用 onlyRole
修饰符:
多干净!通过这种方式拆分关注点,可以实现比使用更简单的 ownership 方法进行访问控制更细化的权限级别。限制系统的每个组件能够执行的操作被称为 最小权限原则,并且是一种良好的安全实践。请注意,如果需要,每个帐户可能仍具有多个角色。
Granting and Revoking Roles
上面的 ERC20 token 示例使用 _setupRole
,这是一个 internal
函数,在以编程方式分配角色时(例如在构造期间)很有用。但是,如果我们稍后想将“minter”角色授予其他帐户怎么办?
默认情况下,具有角色的帐户无法从其他帐户授予或撤销它:拥有角色所做的只是使 hasRole
检查通过。要动态授予和撤销角色,您需要角色管理员(role’s admin)的帮助。
每个角色都有一个关联的管理员角色,该角色授予调用 grantRole
和 revokeRole
函数的权限。如果调用帐户具有相应的管理员角色,则可以使用这些来授予或撤销角色。多个角色可能具有相同的管理员角色,以便于管理。一个角色的管理员甚至可以是同一个角色本身,这将导致具有该角色的帐户也能够授予和撤销它。
这种机制可用于创建类似于组织结构图的复杂许可结构,但它也提供了一种管理简单应用程序的简单方法。 AccessControl
包含一个特殊角色,称为 DEFAULT_ADMIN_ROLE
,它充当所有角色的默认管理员角色。具有此角色的帐户将能够管理任何其他角色,除非 _setRoleAdmin
用于选择新的管理员角色。
让我们看一下 ERC20 代币示例,这一次利用了默认管理员角色:
请注意,与前面的示例不同,没有帐户被授予“minter”或“burner”角色。但是,由于这些角色的管理员角色是默认管理员角色,并且该角色已授予 msg.sender
,因此同一帐户可以调用 grantRole
授予铸币或销毁权限,也可以调用 revokeRole
将其删除。
动态角色分配通常是理想的属性,例如在对参与者的信任可能随时间变化的系统中。它还可用于支持诸如 KYC 之类的用例,其中角色承担者的列表可能事先不知道,或者包含在单个交易中可能过于昂贵。
Querying Privileged Accounts
由于帐户可能会动态授予和撤销角色,因此并不总是可以确定哪些帐户拥有特定角色。这很重要,因为它可以证明系统的某些属性,例如管理帐户是多重签名或 DAO,或者某个角色已从所有用户中删除,从而有效地禁用任何相关功能。
在底层,AccessControl
使用 EnumerableSet
,这是 Solidity mapping
类型的一个更强大的变体,它允许键枚举。 getRoleMemberCount
可用于检索具有特定角色的帐户的数量,然后可以调用 getRoleMember
来获取每个帐户的地址。
Delayed operation
访问控制对于防止未经授权访问关键函数来说至关重要。这些函数可用于铸造代币、冻结转账或执行完全改变智能合约逻辑的升级。虽然 Ownable
和 AccessControl
可以防止未经授权的访问,但它们并没有解决行为不端的管理员攻击他们自己的系统以损害其用户的问题。
这是 TimelockController
正在解决的问题。
TimelockController
是由提议者(proposer)和执行者(executor)管理的代理。当设置为智能合约的所有者(owner)/管理员(admin)/控制者(controller)时,它确保提议者下令的任何维护操作都会受到延迟。这种延迟保护了智能合约的用户,让他们有时间审查维护操作并在他们认为这样做最符合他们利益的情况下退出系统。
Using TimelockController
默认情况下,部署了 TimelockController
的地址获得对时间锁的管理权限。此角色授予指定提议者、执行者和其他管理员的权利。
配置 TimelockController
的第一步是至少分配一个提议者和一个执行者。这些可以在构建期间或以后由具有管理员角色的任何人分配。这些角色不是独占的,这意味着一个帐户可以同时拥有这两个角色。
角色由 AccessControl
接口管理,每个角色的 bytes32
值可通过 ADMIN_ROLE
、PROPOSER_ROLE
和 EXECUTOR_ROLE
常量访问。
在 AccessControl
之上构建了一个附加特性:将执行者角色赋予 address(0)
可以在时间锁到期后向任何人开放执行提案的权限。此功能虽然有用,但应谨慎使用。
此时,同时分配了提议者和执行者,时间锁就可以执行操作了。
可选的下一步是部署者放弃其管理权限并让时间锁自行管理。如果部署者决定这样做,所有进一步的维护,包括分配新的提议者/调度者或更改时间锁持续时间都必须遵循时间锁工作流程。这将时间锁的治理与附加到时间锁的合约的治理联系起来,并强制延迟时间锁维护操作。
WARNING:如果部署者放弃管理权限以支持 timelock 本身,则分配新的提议者或执行者将需要 timelocked 操作。这意味着,如果负责这两个角色中的任何一个的账户不可用,那么整个合约(以及它控制的任何合约)都会被无限期锁定。
通过分配提议者和执行者角色以及负责其自身管理的时间锁,您现在可以将任何合约的所有权/控制权转移到时间锁。
TIP:推荐的配置是将这两个角色授予安全治理合约(例如 DAO 或多重签名),并将执行者角色授予负责帮助维护操作的人员持有的一些 EOA。这些钱包无法接管时间锁,但它们可以帮助简化工作流程。
Minimum delay
TimelockController
执行的操作不受固定延迟的影响,而是最小延迟。一些主要更新可能需要更长的延迟。例如,如果仅仅几天的延迟可能足以让用户审核铸币操作,那么在安排智能合约升级时使用几周甚至几个月的延迟是有意义的。
可以通过调用 updateDelay
函数来更新最小延迟(可通过 getMinDelay
方法访问)。请记住,只有时间锁本身才能访问此功能,这意味着此维护操作必须通过时间锁本身。
Tokens
啊,“代币”:区块链最强大也是最容易被误解的工具。
代币是区块链中某物的表示。这可以是金钱、时间、服务、公司股份、虚拟宠物,任何东西。通过将事物表示为代币,我们可以允许智能合约与它们交互、交换、创建或销毁它们。
But First, Coffee a Primer on Token Contracts
围绕代币的大部分混淆来自于两个概念的混淆:代币合约(token contracts)和实际代币(token)。
代币合约只是一个以太坊智能合约。 “发送代币”实际上意味着“调用某人编写和部署的智能合约上的方法”。归根结底,代币合约只不过是地址到余额的映射,加上一些从这些余额中加减的方法。
正是这些余额代表了代币本身。当代币合约中的余额不为零时,某人“拥有代币”。That’s it!这些余额可以被视为金钱、游戏中的经验值、所有权契约或投票权,并且这些代币中的每一个都将存储在不同的代币合约中。
Different Kinds of Tokens
请注意,拥有两个投票权和两个所有权契约之间有很大的区别:每个投票都等于所有其他投票,但房子通常不是!这称为可替代性(fungibility)。可替代商品(Fungible goods)是等价且可互换的,例如以太币、法定货币和投票权。不可替代的(Non-fungible)商品是独一无二的,就像所有权契约或收藏品一样。
简而言之,在处理不可替代的资产(例如您的房子)时,您关心的是您拥有哪些资产,而在可替代资产(例如您的银行账户对账单)中,重要的是您拥有多少。
Standards
尽管代币的概念很简单,但它们在实现中具有各种复杂性。因为以太坊中的一切都只是一个智能合约,并且没有关于智能合约必须做什么的规则,社区已经开发了各种标准(称为 EIP 或 ERC)来记录合约如何与其他合约互操作。
您可能听说过 ERC20 或 ERC721 代币标准,这就是您来这里的原因。前往我们的专业指南了解更多信息:
- ERC20:可替代资产最广泛的代币标准,尽管在某种程度上受到其简单性的限制。
- ERC721:不可替代代币的实际解决方案,通常用于收藏品和游戏。
- ERC777:更丰富的可替代代币标准,支持新的用例并建立在过去的学习基础上。向后兼容 ERC20。
- ERC1155:一种新的多代币标准,允许单个合约代表多个可替代和不可替代的代币,以及批量操作以提高gas效率。
ERC20
ERC721
ERC777
ERC1155
Governance
CrossChain
Utilities
OpenZeppelin Contracts 提供了大量有用的实用程序,您可以在项目中使用它们。这里有一些比较流行的。
Cryptography
Checking Signatures On-Chain
ECDSA
提供了恢复和管理以太坊账户 ECDSA 签名的功能。这些通常是通过 web3.eth.sign
生成的,是一个 65 字节的数组(Solidity 中的 bytes
类型),排列方式如下:[[v (1)], [r (32)], [s (32)]]
。
可以使用 ECDSA.recover
恢复数据签名者,并将其地址进行比较以验证签名。大多数钱包会对数据进行哈希签名并添加前缀“\x19Ethereum Signed Message:\n”,因此在尝试恢复以太坊签名消息哈希的签名者时,您需要使用 toEthSignedMessageHash
。
WARNING:正确进行签名验证并非易事:确保您完全阅读并理解 ECDSA
的文档。
Verifying Merkle Proofs
MerkleProof
提供:
verify
- 可以证明某个值是 Merkle tree 的一部分。multiProofVerify
- 可以证明多个值是 Merkle 树的一部分。
Introspection
在 Solidity 中,了解合约是否支持您想要使用的接口通常很有帮助。 ERC165 是一个有助于进行运行时接口检测的标准。合约为在你的合约中实现 ERC165 和查询其他合约提供了帮助:
IERC165
- 这是定义supportsInterface
的 ERC165 接口。实现 ERC165 时,您将遵守此接口。ERC165
- 如果您想使用合约存储中的查找表支持接口检测,请继承此合约。您可以使用_registerInterface(bytes4)
注册接口:查看示例用法作为 ERC721 实现的一部分。ERC165Checker
- ERC165Checker 简化了检查合约是否支持您关心的接口的过程。- 包括使用 ERC165Checker 作为地址;
myAddress._supportsInterface(bytes4)
myAddress._supportsAllInterfaces(bytes4[])
Math
OpenZeppelin Contracts 提供的最流行的数学相关库是 SafeMath
,它提供了保护您的合约免受上溢和下溢的数学函数。
通过 using SafeMath for uint256;
包含合约;然后调用函数:
myNumber.add(otherNumber)
myNumber.sub(otherNumber)
myNumber.div(otherNumber)
myNumber.mul(otherNumber)
myNumber.mod(otherNumber)
Payment
想要在多人之间分摊一些付款?也许你有一个应用程序,将 30% 的艺术品购买发送给原始创作者,将 70% 的利润发送给当前所有者;您可以使用 PaymentSplitter
构建它!
在 Solidity 中,盲目地向账户汇款存在一些安全问题,因为它允许账户执行任意代码。您可以在 Ethereum Smart Contract Best Practices 网站上阅读这些安全问题。解决重入和停滞问题的方法之一是,您可以使用 PullPayment
,而不是立即将 Ether 发送到需要它的帐户,它提供了一个 _asyncTransfer
函数,用于向某物汇款并要求他们稍后再 withdrawPayments()
。
如果你想托管一些资金,请查看 Escrow
和 ConditionalEscrow
以管理一些托管 Ether 的释放。
Collections
如果您需要比 Solidity 的原生数组和映射更强大的集合支持,请查看 EnumerableSet
和 EnumerableMap
。它们类似于映射,因为它们在恒定时间内存储和删除元素并且不允许重复条目,但它们还支持枚举(enumeration),这意味着您可以轻松地查询链上和链下的所有存储条目。
Misc
想检查地址是否为合约?使用 Address
和 Address.isContract()
。
想要跟踪一些每次需要另一个数字时递增 1 的数字?查看 Counters
。这对很多事情都很有用,例如创建增量标识符,如 ERC721 指南 中所示。
Base64
Base64
实用程序允许您将 bytes32
数据转换为其 Base64 string
表示。
这对于为 ERC721
或 ERC1155
构建 URL 安全的 tokenURI 特别有用。该库提供了一种巧妙的方式来提供符合 URL 安全的 Data URI 兼容字符串以提供链上数据结构。
考虑这是一个使用 ERC721 通过 Base64 Data URI 发送 JSON 元数据的示例:
Multicall
Multicall 抽象合约带有一个 multicall
函数,该函数将多个调用捆绑在一个外部调用中。有了它,外部帐户可以执行包含多个函数调用的原子操作。这不仅对 EOA 在单个交易中进行多次调用很有用,它也是一种在后续调用失败时恢复先前调用的方法。
考虑这个虚拟合约:
这是使用 Truffle 调用 multicall
函数的方法,允许在单个交易中调用 foo
和bar
:
API
OpenZeppelin Docs API 文档提供了库中所有 合约(Contract)、库(Library)、接口(Interface) 的 修饰符(Modifier)、函数(Function) 和 事件(Events) 等的用法和 API 说明,以供检索查阅。
可以在顶部的搜索栏中检索关键字,搜索栏会在整个文档中查找匹配的对应关键字。