本文介绍了以太坊智能合约升级的常用模式:透明代理(Transparent Proxy,EIP-1967)。文章解释了代理合约如何通过 delegatecall 将调用转发到可替换的实现合约,从而在保持合约地址不变的情况下实现逻辑升级。文章还通过 Foundry 演示了代理合约的部署、升级和状态保持的过程,并强调了 EIP-1967 标准化存储槽位的重要性。
一旦你理解了合约如何上链,下一个重要的问题是它们如何在部署后保持有用。
以太坊合约被设计为不可变的,一旦字节码存在,就无法更改。这对于信任来说很好,但对于现实来说很痛苦:错误会发生,功能会演变,治理规则会改变。
为了解决这个问题,开发者使用 代理,即永远不会移动的最小合约,而它们的逻辑可以在底层被替换。
它们保持相同的地址,保留所有用户状态,并使用
delegatecall将执行路由到一个单独的实现合约。在这篇文章中,我们将分解最常见的代理模式:透明代理 (EIP-1967),并确切地了解它在实践中是如何工作的:
1.
delegatecall如何让逻辑存在于其他地方,同时状态保持在本地2. 为什么 EIP-1967 标准化了管理和实现存储槽
3. 如何使用 Foundry 在链上部署、升级和检查代理
最后,你将了解像 Aave 和 OpenZeppelin 这样的协议如何在不中断用户的情况下升级合约,以及如何安全地做到这一点。
智能合约在设计上是不可变的。一旦部署,它们的字节码就无法更改。这种不变性对于信任来说很好,但对于真正的协议来说很痛苦:错误会发生,标准会演变,治理规则会改变。如何在不要求每个用户迁移到新地址的情况下“升级”逻辑?
答案是 代理。永远不会更改其地址,但将调用转发到可以交换的实现的合约。
代理是一个精简的合约,它:
delegatecall)转发到另一个合约,称为 实现。因为 delegatecall 在调用者的上下文中执行,所有的存储都保存在代理中,而逻辑存在于实现中。交换实现 → 你就“升级”了合约,但用户保持与同一地址交互。
问题: 合约是不可变的,但实际系统需要修复和新功能。
解决方案: 透明代理 保留一个公共地址,并将用户调用转发到一个可交换的实现。它使用来自
EIP-1967 的固定存储槽,以便工具和审计可以准确地知道管理/实现的位置。] executes the logic code in the caller’s storage (the proxy). The implementation’s constructor ran earlier in its own context.
owner/number,但是通过代理读取将返回默认值,直到你初始化代理的存储。让我们深入研究有趣的部分:
让我们像之前在不同终端中做的那样,使用 anvil 启动本地链
anvil
逻辑合约部署:
forge create src/StorageV1.sol:StorageV1 --rpc-url http://localhost:8545 --private-key <ANVIL-的-私钥> --broadcast
// 输出将会是:
// [⠊] 正在编译...
// 没有文件更改,跳过编译
// 部署者: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到: 0x5FbDB2315678afecb367f032d93F642f64180aa3
// 交易哈希: 0x35b7d8d461c60c3759ab08a733c2f66e5fd8e22656ca5e158b37eecec605a80e
透明代理部署:
// 之前部署的合约
IMPL=0x5FbDB2315678afecb367f032d93F642f64180aa3
// ADMIN 是 anvil 提供的第二个地址,你可以使用任何其他的 admin
// 为了简单起见,我们将使用这个地址
ADMIN=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
## 构建完整的 calldata (selector + args)
## 逻辑合约的 "constructor"
INIT_DATA=$(cast calldata "initialize(uint256,string)" 42 "some-owner")
// --private-key - 在这种情况下是先前提供的 ADMIN 的私钥
// 但是可以使用任何其他的部署者
forge create src/TransparentProxy1967.sol:TransparentProxy1967 \
--rpc-url http://127.0.0.1:8545 \
--private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \
--broadcast \
--constructor-args $IMPL $ADMIN "$INIT_DATA"
// 输出应该像这样:
// [⠊] 正在编译...
// 没有文件更改,跳过编译
// 部署者: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
// 部署到: 0x8464135c8F25Da09e49BC8782676a84730C318bC
// 交易哈希: 0x389ad3d17e7d8fe850c9325247cd3bd6d9c7bcd59757a73246656d56fb9425c3
让我们将透明代理合约用作Users:
## 通过代理读取 (使用 delegatecall)
cast call 0x8464135c8F25Da09e49BC8782676a84730C318bC "number()(uint256)" --rpc-url http://127.0.0.1:8545
cast call 0x8464135c8F25Da09e49BC8782676a84730C318bC "ownerName()(string)" --rpc-url http://127.0.0.1:8545
// 输出应该像:
// 42
// "some-owner"
## 通过代理写入
cast send 0x8464135c8F25Da09e49BC8782676a84730C318bC "setNumber(uint256)" 77 --private-key <你的-私钥> --rpc-url http://127.0.0.1:8545
## 检查它是否工作
cast call 0x8464135c8F25Da09e49BC8782676a84730C318bC "number()(uint256)" --rpc-url http://127.0.0.1:8545
// 预期输出: 77
让我们证明地址不能回退 (使用逻辑合约):
## 这会恢复: "Transparent: admin cannot fallback"
cast call 0x8464135c8F25Da09e49BC8782676a84730C318bC "number()(uint256)" --from 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --rpc-url http://127.0.0.1:8545
// 预期输出:
// 服务器返回了一个错误响应: 错误代码 3: 执行已恢复: Transparent: admin cannot fallback...
让我们检查透明代理插槽:
## admin 插槽 (最后 20 个字节)
cast storage 0x8464135c8F25Da09e49BC8782676a84730C318bC 0xB53127684A568B3173AE13B9F8A6016E243E63B6E8EE1178D6A717850B5D6103 --rpc-url http://127.0.0.1:8545
## 逻辑合约地址插槽 (最后 20 个字节)
cast storage 0x8464135c8F25Da09e49BC8782676a84730C318bC 0x360894A13BA1A3210667C828492DB98DCA3E2076CC3735A920A3CA505D382BBC --rpc-url http://127.0.0.1:8545
// 预期输出:
// 0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8
// 0x0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa3
让我们升级到一个新的实现 StorageV2,其中添加了函数,相同的存储前缀)
让我们部署第二个合约:
forge create src/StorageV2.sol:StorageV2 --rpc-url http://127.0.0.1:8545 --private-key <YOUR-PRIVATE-KEY> --broadcast
// 输出应该像
// [⠊] 正在编译...
// 没有文件更改,跳过编译
// 部署者: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
// 部署到: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9
// 交易哈希: 0x80fd23c7145fff6853e17439b1e4d1332b41526a411dfaa842a4f985d55f936c
将透明代理指向新创建的合约:
cast send 0x8464135c8F25Da09e49BC8782676a84730C318bC "upgradeTo(address)" 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 --from 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --rpc-url http://127.0.0.1:8545 --private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
现在你可以重新运行存储,看到逻辑合约地址已经改变。
ProxyAdmin 合约)。代理让合约保持一个公共地址,同时在幕后交换逻辑。透明代理 (EIP-1967) 通过 delegatecall 将用户调用路由到一个实现,将所有状态保存在代理中,并公开一个单独的管理平面用于升级。你展示了三个标准插槽(实现/管理/信标),为什么构造函数不起作用(使用 initialize),以及一个完整的 Foundry 演练:部署 V1,使用 init calldata 部署代理,通过代理读取/写入,检查插槽,然后升级到 V2 并确认状态连续性。
- 原文链接: medium.com/@andrey_obruc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!