概述
可以前往我的博客获得更好的阅读体验。
正如我们在上篇博客结尾时所述,本文主要依靠openzeppelin
库介绍代理合约的编写。
本文主要介绍的代理类型如下:
- EIP-1967
- EIP-1538
- EIP-2535
由于本文依赖于Openzeppelin/openzeppelin-contracts
进行介绍EIP标准,所以请读者使用以下命令在项目内安装对应的库:
forge install Openzeppelin/openzeppelin-contracts
你可以在github仓库内找到本文所使用的全部代码。
因为此文主要使用openzeppelin
编写智能合约,建议阅读以下文章:
在openzeppelin
的文档中,一般称逻辑合约的英文为implementation contract
而不是logic contract
EIP-1967
本合约是上篇介绍的EIP-1822 UUPS
的进一步标准化版本,读者可以在这里找到ERC文档。该标准被etherscan
等区块链浏览器支持,可以提供完整的代理合约展示和交互功能。你可以前往USDC合约查看情况。如下图:
### 基本标准
此标准文档与EIP-1822
文档大有不同。由于EIP-1822
较为古老,在其文档中仍存在大量的解释性内容由于解释合约运行的原理。但在EIP-1967
中,由于其创建时间较晚,合约代理的基本原理已被智能合约开发者所熟知,所以在EIP-1967
的文档中没有介绍代理合约的基本原理,主要是对存储槽、事件进行了标准化和解释。在本节内容中,我们将介绍EIP-1822
的基本标准和指定这些标准的原因。
首先被定义的就是逻辑合约(Logic Contract)的地址,在EIP-1822
中我们一般采用keccak256("PROXIABLE")
值,即0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7
,该值其实可以有开发者自行决定。但在EIP-1967
中,为了方便区块链浏览器的访问,该地址被标准化为keccak256('eip1967.proxy.implementation') - 1
,即0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
。
你可以在lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol
第21行中找到此地址槽的定义:
bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
在此处我们选择keccak256('eip1967.proxy.implementation') - 1
的原因是为了避免潜在的攻击。如果想阅读以下的讨论,请先行阅读Understanding Ethereum Smart Contract Storage中的mapping
部分。
为了方便读者理解以下内容,我将进行一次对某智能合约的假想攻击。假设当前存在一份使用keccak256("PROXIABLE")
的值作为逻辑合约存储槽的代理合约。且作为攻击者的我们通过阅读逻辑合约源代码发现在逻辑合约内存在mapping
数据结构。通过阅读Understanding Ethereum Smart Contract Storage,我们已知在合约内mapping
数据结构存储在keccak256(key, slot)
的地址内,且key
和slot
拼接方式已知。显然,我们可以阅读代理合约的代码或存储的状态变量得到slot
的值,一般而言我们也可以通过交互合约操作key
的值。如果满足上述条件,我们可以构造一种特殊的key
和slot
的拼接使其值等于PROXIABLE
实现将逻辑合约存储槽写入特定的value
。在代理合约内一般仅存在fallback
等函数,一旦逻辑合约地址被改变,则资金无法转移。这是极其严重的事故。但在EIP-1967
中。使用了keccak256('eip1967.proxy.implementation') - 1
导致无法在简单地使用mapping
的keccak256(key, slot)
存储槽进行占用。除非你可以将keccak256('eip1967.proxy.implementation') - 1
转换为keccak256(x)
的形式。但基于哈希函数的不可逆性,我们无法计算出x
的值,导致无法构造攻击用的(slot, key)
。
当然,该标准与EIP-1822
仍存在一点不同,就是逻辑合约(Logic Contract)的地址可以为空,但前提是存在信标代理(Beacon contract)的地址存储槽不为空。我们将在下一段介绍信标代理。
同时,标准也规定了每次升级合约应给出Upgraded
的event
。见lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol
第33行:
event Upgraded(address indexed implementation);
此处indexed
的属性用于检索event
日志,由于在此处我们仅涉及合约编写,所以我们不在此阐述其作用。
EIP-1967
也加入了一个我们过去没有给出的合约类型——信标代理(Beacon Contract)。ERC标准规定信标代理的地址存储在bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)
中,其值为0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50
。
我们可以在lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol
中的第142行查到以下代码:
bytes32 internal constant _BEACON_SLOT = 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50;
信标代理的作用是同一逻辑合约可以实现多个代理合约共同代理。在这里给出一种情况,如果你开发了一个NFT发布合约希望为以尝鲜为目的的客户服务。但为了体现项目的区块链属性,你决定让用户可以获得智能合约地址等信息。如果使用一般的架构,你需要为每一个用户部署一份相同NFT合约。这将消耗大量的gas
费,而且这些部署的NFT合约逻辑完全类似。作为开发者的我们可以考虑使用信标代理的架构,即开发一个通用逻辑的NFT合约,使用信标代理架构为其实现多个代理合约。这样部署NFT合约的费用降低为了部署一个逻辑简单的代理合约的gas
费。当然如果你的项目中存在高净值用户需要复杂的NFT逻辑,你可以为其进行单独部署合约,然后改变代理合约内的地址存储槽内的信息实现合约升级。未来,此项目可能作为我们的实战内容出现在我的博客中。
上述方案可以称为BeaconProxy
,此方法的基本原理是修改逻辑合约地址的获得。在以往的模式中,我们将逻辑合约地址存储在代理合约内部,但在BeaconProxy
方案中,我们将逻辑合约地址单独放置在一个智能合约(下称此合约为存储合约)内,要求代理合约在每次调用逻辑合约时先去读取存储合约内的逻辑合约地址。下面给出test/EIP1967/EIP1967.t.sol
中testInit()
栈调用:
Traces:
[20126] ContractTest::testInit()
├─ [13293] BeaconProxy::name()
│ ├─ [2308] UpgradeableBeacon::implementation() [staticcall]
│ │ └─ ← NFTImplementation: [0xce71065d4017f316ec606fe4422e11eb2c47c246]
│ ├─ [3191] NFTImplementation::name() [delegatecall]
│ │ └─ ← "TEST"
│ └─ ← "TEST"
└─ ← ()
为了方便读者与常规调用进行对比,我们在此给出test/EIP1822/EIP1822.t.sol
中testInit()
:
Traces:
[12752] ContractTest::testInit()
├─ [7131] Proxy::totalSupply()
│ ├─ [2318] NumberStorage::totalSupply() [delegatecall]
│ │ └─ ← 1000
│ └─ ← 1000
└─ ← ()
由给出的栈调用,我们可以明显看到BeaconProxy
在调用真正的合约NFTImplementation
前调用了UpgradeableBeacon
获取了合约地址,而在常规方法中,则是直接调用了指定的逻辑合约NumberStorage
,而没有在调用逻辑合约前进行获取地址的操作。当我们需要升级智能合约时,我们首先升级逻辑合约,再升级存储合约,这样依赖于存储合约的所有代理合约都可以同步升级。
当然,正如升级合约会触发事件,升级信标代理也会触发以下事件(可见lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol
第147行):
event BeaconUpgraded(address indexed beacon);
同时,也规定了信标代理内必须存在以下函数(在lib/openzeppelin-contracts/contracts/proxy/beacon/IBeacon.sol
实现了该接口):
function implementation() returns (address)
接口中不对函数进行定义,仅会指明该函数的存在,而由继承该接口的合约实现。当我们需要调用其他合约时,可以选择仅导入对方合约的接口,避免合约体积增大而导致gas费上升,具体的实战案例可以参考WTF solidity。在此合约中具体实现可以参考
lib/openzeppelin-contracts/contracts/proxy/beacon/UpgradeableBeacon.sol
第35行。
EIP-1967
也规定了合约拥有者的地址存储位置(Admin address),该存储操位于bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)
,即0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
,可在lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol
的第106行找到定义。同时也规定了改变此存储槽中的内容必须触发下述事件:
event AdminChanged(address previousAdmin, address newAdmin);
在lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sol
第111行进行了定义。
最终我们对上文内容进行总结。下表为EIP-1967
规定的存储槽列表:
存储槽位置 | 存储槽名称 | 作用 |
---|---|---|
bytes32(uint256(keccak256(‘eip1967.proxy.implementation’)) - 1) | Logic contract address | 存储逻辑合约地址 |
bytes32(uint256(keccak256(‘eip1967.proxy.beacon’)) - 1) | Beacon contract address | 存储信标代理合约地址 |
bytes32(uint256(keccak256(‘eip1967.proxy.admin’)) - 1) | Admin address | 存储代理合约拥有者的地址 |
下表为EIP-1967
规定的事件列表:
事件名称 | 事件代码 | 触发条件 |
---|---|---|
Upgraded | event Upgraded(address indexed implementation); |
逻辑合约地址升级 |
BeaconUpgraded | event BeaconUpgraded(address indexed beacon); |
信标代理合约升级 |
AdminChanged | event AdminChanged(address previousAdmin, address newAdmin); |
合约拥有者改变 |
Openzeppelin架构
openzeppelin
ERC1967合约的整体UML图如下: