本文深入探讨了以太坊虚拟机(EVM)上常用的合约部署模式,包括用于逻辑升级的UUPS代理,用于标准化和可追踪部署的工厂模式,以及用于gas高效复制的最小代理(克隆)。文章通过代码示例详细解释了这些模式的原理和应用,并区分了简单合约、代理和克隆。
在前一部分中,我们揭开了合约如何从原始初始化代码到运行时字节码,真正在 EVM 区块链上部署的面纱,甚至还介绍了 CREATE 和 CREATE2 地址背后的数学原理。 这为我们奠定了基础:合约最初是如何产生的。
但是,如果你在以太坊上构建超过一个周末,你就会在实际应用中看到一些其他的东西:合约看起来与你从 Remix 或 Hardhat 或 Foundry 编写和部署的合约不太一样。 相反,你会遇到代理、克隆、工厂,甚至具有多个移动部件的“钻石”合约。 这些不是边缘情况,它们为 DeFi 中一些最大的协议提供动力。
这篇文章是关于理解这些模式的。 我们将了解它们存在的原因、它们在底层如何工作,以及当你偶然在浏览器中遇到它们时如何识别它们。 具体来说,我们将介绍:
1. UUPS 代理 (EIP-1822)
2. 工厂
3. 最小代理 (EIP-1167)
到最后,你将拥有一个心理工具箱来识别和推理这些模式,并有信心知道你是在与 vanilla 合约、克隆还是隐藏升级路径的代理进行对话。
透明代理 很好,但它们有一个额外的负担:你必须维护代理合约和升级管理逻辑。 UUPS (通用可升级代理标准) 颠倒了这种设计。
UUPS 代理不是让代理合约负责升级,而是将该责任推送到实现合约中。 代理本身只是一个“哑转发器”,它委托所有调用。 实现包含一个特殊函数(通常称为 proxiableUUID
或 upgradeTo
),它知道如何升级到新版本。 这使得代理非常小且可重用,而每个实现都可以定义自己的规则来确定谁可以升级。
Logic Contract 的地址存储在定义的存储位置 keccak256("PROXIABLE")=0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7
。
主要区别:
UUPSUpgradeable
mixin)。UUPSLogicContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;
// 代理
contract Proxiable {
// 存储中的代码位置是 keccak256("PROXIABLE") = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"
function updateCodeAddress(address newAddress) internal {
require(
bytes32(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7) == Proxiable(newAddress).proxiableUUID(),
"不兼容"
);
assembly { // solium-disable-line
sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, newAddress)
}
}
function proxiableUUID() public pure returns (bytes32) {
return 0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7;
}
}
// 控制只有所有者才能进行更改
contract Owned {
address owner;
function setOwner(address _owner) internal {
owner = _owner;
}
modifier onlyOwner() {
require(msg.sender == owner, "只有所有者才能执行此操作");
_;
}
}
contract LibraryLockDataLayout {
bool public initialized = false;
}
// 锁定机制。
contract LibraryLock is LibraryLockDataLayout {
// 确保一旦部署Logic Contract,任何人都无法操纵它。
// PARITY WALLET HACK PREVENTION
modifier delegatedOnly() {
require(initialized == true, "库已锁定。不允许直接'调用'");
_;
}
function initialize() internal {
initialized = true;
}
}
contract ERC20DataLayout is LibraryLockDataLayout {
uint256 public totalSupply;
mapping(address=>uint256) public tokens;
}
contract MyToken is Owned, ERC20DataLayout, Proxiable, LibraryLock {
function constructor1(uint256 _initialSupply) public {
totalSupply = _initialSupply;
tokens[msg.sender] = _initialSupply;
initialize();
setOwner(msg.sender);
}
function updateCode(address newCode) public onlyOwner delegatedOnly {
updateCodeAddress(newCode);
}
function transfer(address to, uint256 amount) public delegatedOnly {
require(tokens[msg.sender] >= amount, "资金不足,无法转账");
tokens[to] += amount;
tokens[msg.sender] -= amount;
}
}
注意: 这里添加了很多预防措施,主合约是继承 Proxiable 的 MyToken。 其他继承可以保护合约免受各种可能的攻击。
UUPSProxy.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.12;
/*
* 非常简化的 UUPS 模式。
* - 代理存储状态并委托调用。
* - 实现持有升级逻辑。
*/
// ---------------- 代理 ----------------
contract UUPSProxy {
// 存储中的代码位置是 keccak256("PROXIABLE") = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"
constructor(bytes memory constructData, address contractLogic) {
// 保存代码地址
assembly {
sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, contractLogic)
}
// 调用构造函数
(bool success, ) = contractLogic.delegatecall(constructData);
require(success, "构造失败");
}
// 此回退实际上将调用逻辑合约
// 因为带有数据字节的每个函数都会到达这里
fallback() external payable {
assembly {
// 加载逻辑合约地址
let contractLogic := sload(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7)
calldatacopy(0x0, 0x0, calldatasize())
// 使用数据字节调用逻辑合约
let success := delegatecall(sub(gas(), 10000), contractLogic, 0x0, calldatasize(), 0, 0)
let retSz := returndatasize()
returndatacopy(0, 0, retSz)
switch success
case 0 {
revert(0, retSz)
}
default {
return(0, retSz)
}
}
}
}
让我们启动节点:
anvil
并从不同的终端部署两个合约
// 部署合约逻辑
forge create src/UUPSLogicContract.sol:MyToken --rpc-url localhost:8545 --private-key <YOUR-ANVIL-PRIVATE-KEY> --broadcast
// 预期输出,类似于:
// [⠊] 正在编译...
// [⠒] 正在使用 Solc 0.8.30 编译 1 个文件
// [⠆] Solc 0.8.30 在 69.50 毫秒内完成
// 编译器运行成功!
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0x5FbDB2315678afecb367f032d93F642f64180aa3
// 交易哈希:0xfd7f81e7a54dba55a1a6399e465ed9ff67dc0d95a3f2fbbf85542fd7b0ffdf81
// 部署 UUPS 代理
INIT_DATA=$(cast calldata "constructor1(uint256)" 1000000)
forge create src/UUPSProxy.sol:UUPSProxy --rpc-url localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --broadcast --constructor-args "$INIT_DATA" 0x5FbDB2315678afecb367f032d93F642f64180aa3
// 预期输出,类似于:
// [⠊] 正在编译...
// 没有文件更改,跳过编译
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
// 交易哈希:0x60c04bf75c647a9c5bcd73155c8906675247ccf4f25fab20bf76e1b5a4ef129e
现在让我们通过代理调用 MyToken 合约上的自动生成的 getter:
// 请注意 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 是代理地址
cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 "totalSupply()(uint256)" --rpc-url http://localhost:8545
到目前为止,我们已经了解了合约如何部署以及代理如何帮助升级。 但你将在生产 EVM 代码中看到的另一种模式:工厂。
工厂只是一个知道如何部署其他合约的合约。 你无需手动部署每个新合约,而是与单个工厂合约交互,该合约:
CREATE
或 CREATE2
)可以将其视为合约的装配线。 你向工厂提供参数,它会返回一个新实例。
几乎每个主要协议都使用工厂,因为它们带来了:
CREATE2
,工厂甚至可以在实例存在之前预先计算其实例的地址(非常适合“反事实”钱包或确定性池地址)。basicfactory.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Counter {
address public owner;
uint256 public value;
constructor(address _owner, uint256 start) {
owner = _owner;
value = start;
}
function inc() external {
require(msg.sender == owner, "不是所有者");
value += 1;
}
}
contract CounterFactory {
event CounterCreated(address indexed counter, address indexed owner, uint256 start);
function createCounter(address owner_, uint256 start_) external returns (address addr) {
for (uint256 i = 0; i < 5; i++) {
Counter c = new Counter(owner_, start_);
addr = address(c);
emit CounterCreated(addr, owner_, start_);
}
}
}
设置节点:
anvil
部署合约并调用它
// 部署工厂合约
forge create src/basicfactory.sol:CounterFactory \
--rpc-url localhost:8545 \
--private-key <YOUR-ANVIL-PK>
// 预期输出:
// [⠊] 正在编译...
// [⠒] 正在使用 Solc 0.8.30 编译 1 个文件
// [⠢] Solc 0.8.30 在 52.99 毫秒内完成
// 编译器运行成功!
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0x610178dA211FEF7D417bC0e6FeD39F05609AD788
// 交易哈希:0x5c25685d15aacde7128a648d48dcd8b975ffc6e20aac9605bce2f582125b6437
// 调用工厂
cast send 0x610178dA211FEF7D417bC0e6FeD39F05609AD788 \
"createCounter(address,uint256)(address)" \
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 42 \
--rpc-url localhost:8545 --private-key <YOUR-ANVIL-PK>
// 在此调用后预期的日志,其中包含每个发出的事件的主题
// 在那里你会找到合约地址
https://learnblockchain.cn/docs/eips/EIPS/eip-1167
注意: 一个真实的例子可以是一个 dex pair,其中每个流动性对都是一个克隆,拥有自己的储备,但都共享相同的对逻辑。
minimalProxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/* ---------------------------------- */
/* 1) Implementation (shared logic) */
/* ---------------------------------- */
contract Counter {
address public owner;
uint256 public value;
bool private _initialized;
// 在部署后通过克隆调用
function initialize(address _owner, uint256 start) external {
require(!_initialized, "已初始化");
_initialized = true;
owner = _owner;
value = start;
}
function inc() external {
require(msg.sender == owner, "不是所有者");
unchecked { value += 1; }
}
// 可选:阻止初始化逻辑合约本身
constructor() {
_initialized = true;
}
}
/* ---------------------------------- */
/* 2) Minimal Clone Factory */
/* ---------------------------------- */
contract CounterCloneFactory {
event CloneCreated(address indexed clone, address indexed owner, uint256 start, bytes32 salt);
/* ---- Internal helpers: build creation code for the clone ---- */
// 克隆创建代码 = 返回 45 字节运行时的短序言
function _cloneCreationCode(address impl) internal pure returns (bytes memory code) {
// creation: 3d602d80600a3d3981f3 -> return(next 0x37 bytes)
// runtime: 363d3d373d3d3d363d73 <impl> 5af43d82803e903d91602b57fd5bf3
code = abi.encodePacked(
hex"3d602d80600a3d3981f3",
hex"363d3d373d3d3d363d73",
impl,
hex"5af43d82803e903d91602b57fd5bf3"
);
}
/* ---- Deploy a clone with CREATE ---- */
function createClone(address implementation, address owner_, uint256 start_)
external
returns (address clone)
{
bytes memory code = _cloneCreationCode(implementation);
assembly {
clone := create(0, add(code, 0x20), mload(code))
if iszero(clone) { revert(0, 0) }
}
// 立即初始化(构造函数不通过克隆运行)
(bool ok, ) = clone.call(abi.encodeWithSignature("initialize(address,uint256)", owner_, start_));
require(ok, "init failed");
emit CloneCreated(clone, owner_, start_, bytes32(0));
}
/* ---- Deploy a deterministic clone with CREATE2 ---- */
function createCloneDeterministic(address implementation, address owner_, uint256 start_, bytes32 salt)
external
returns (address clone)
{
bytes memory code = _cloneCreationCode(implementation);
assembly {
clone := create2(0, add(code, 0x20), mload(code), salt)
if iszero(clone) { revert(0, 0) }
}
(bool ok, ) = clone.call(abi.encodeWithSignature("initialize(address,uint256)", owner_, start_));
require(ok, "init failed");
emit CloneCreated(clone, owner_, start_, salt);
}
}
让我们部署它并运行一些链上函数:
启动节点:
anvil
部署合约:
// 共享逻辑的部署
forge create src/minimalProxy.sol:Counter \
--rpc-url localhost:8545 \
--private-key <YOUR-ANVIL-PK> --broadcast
// 预期输出:
// [⠊] 正在编译...
// 没有文件更改,跳过编译
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0x5FC8d32690cc91D4c39d9d3abcBD16989F875707
//交易哈希:0x43f37888e5c370cc1a398f1891cea860a80815fdf59716d4e2d8adb198b5edb8
// 最小代理工厂的部署
forge create src/minimalProxy.sol:CounterCloneFactory \
--rpc-url localhost:8545 \
--private-key <YOUR-ANVIL-PK> --broadcast
// 预期输出:
// [⠊] 正在编译...
// 没有文件更改,跳过编译
// 部署者:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到:0x0165878A594ca255338adfa4d48449f69242Eb8F
// 交易哈希:0x6ddac050694baf16fc7bcfde90dcf0516bd1777b6a1b8ea23fd8019bc20391b2
创建一些克隆:
// 部署第一个实例
cast send 0x0165878A594ca255338adfa4d48449f69242Eb8F \
"createClone(address,address,uint256)(address)" \
0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 <YOUR-ANVIL-PK-ADDR> 100 \
--rpc-url localhost:8545 \
--private-key <YOUR-ANVIL-PK>
// 成功后,期望以下日志:
// logs [\
// {"address":"0x0165878a594ca255338adfa4d48449f69242eb8f",\
// "topics":["0x6823f533242a9c540bd8ab230da11a7199723745abb9d484732bb57b4f34d4d1",\
// "0x0000000000000000000000003b02ff1e626ed7a8fd6ec5299e2c54e1421b626b",\
// "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"],\
// "data":"0x00000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000000",\
// "blockHash":"0xe7748e4f28f2492642e9f64e7252fd40e85079e5211523b7b5b35c82adceff69","blockNumber":"0x8","blockTimestamp":"0x68b92822","transactionHash":"0xe48d780a8268235887019e74d62e09a7e09164aaa37bdea60a2c0aa97f81d00c",\
// "transactionIndex":"0x0",\
// "logIndex":"0x0",\
// "removed":false}]
//
// 从日志中我们可以看到地址是:0x3b02ff1e626ed7a8fd6ec5299e2c54e1421b626b
// 部署第二个实例
cast send 0x0165878A594ca255338adfa4d48449f69242Eb8F \
"createClone(address,address,uint256)(address)" \
0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 <YOUR-ANVIL-PK-ADDR> 100 \
--rpc-url localhost:8545 \
--private-key <YOUR-ANVIL-PK>
// 成功后,期望有此日志:
// [{"address":"0x0165878a594ca255338adfa4d48449f69242eb8f",\
// "topics":["0x6823f533242a9c540bd8ab230da11a7199723745abb9d484732bb57b4f34d4d1",\
// "0x000000000000000000000000ba12646cc07adbe43f8bd25d83fb628d29c8a762" ....\
//\
// 从日志中我们可以看到地址是:0xba12646cc07adbe43f8bd25d83fb628d29c8a762\
让我们进行一些读取和写入:
// 读取第一个代理的值
cast call 0x3b02ff1e626ed7a8fd6ec5299e2c54e1421b626b "value()(uint256)" --rpc-url localhost:8545
// 预期输出:100
// 读取第一个代理的值
cast call 0xba12646cc07adbe43f8bd25d83fb628d29c8a762 "value()(uint256)" --rpc-url localhost:8545
// 预期输出:100
// 在第一个合约上进行写操作
cast send 0x3b02ff1e626ed7a8fd6ec5299e2c54e1421b626b "inc()" \\
--rpc-url http://localhost:8545 \\
--private-key <YOUR-ANVIL-PK>
// 读取第一个代理的值
cast call 0x3b02ff1e626ed7a8fd6ec5299e2c54e1421b626b "value()(uint256)" --rpc-url localhost:8545
// 预期输出:101
// 读取第一个代理的值
cast call 0xba12646cc07adbe43f8bd25d83fb628d29c8a762 "value()(uint256)" --rpc-url localhost:8545
// 预期输出:100
注意: 正如我们所见,每个代理都持有自己的存储,同时指向一些共享逻辑。 我们在这里使用了工厂模式结合代理。
简单的 CounterFactory
示例工作正常:每次调用 createCounter
都会部署一个完整的 Counter
合约。 但这里有一个很大的权衡:gas 成本。 每次你在 EVM 上部署合约时,你都需要支付与你放在链上的字节码大小成正比的 gas 费用。
Counter
可能有几 KB 的字节码。这既昂贵又浪费:代码永远不会改变,它只是重复而已。
在这篇文章中,我们探讨了 EVM 中三个最重要的部署模式:用于可升级逻辑的 UUPS 代理,用于标准化和可跟踪部署的工厂,以及用于 gas 高效复制的 最小代理(克隆)。 这些模式为当今的大部分 DeFi 提供动力,理解它们可以帮助你发现你是在与简单的合约、代理还是克隆进行交互。 在本系列的下一部分中,我们将更进一步,了解 Diamond 代理模式 (EIP-2535),该模式支持具有多个方面的模块化合约。
- 原文链接: medium.com/@andrey_obruc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!