代理与可升级性 - 透明代理 (EIP-1967)

本文介绍了以太坊智能合约升级的常用模式:透明代理(Transparent Proxy,EIP-1967)。文章解释了代理合约如何通过 delegatecall 将调用转发到可替换的实现合约,从而在保持合约地址不变的情况下实现逻辑升级。文章还通过 Foundry 演示了代理合约的部署、升级和状态保持的过程,并强调了 EIP-1967 标准化存储槽位的重要性。

代理和可升级性 - 透明代理 (EIP-1967)

一旦你理解了合约如何上链,下一个重要的问题是它们如何在部署后保持有用

以太坊合约被设计为不可变的,一旦字节码存在,就无法更改。这对于信任来说很好,但对于现实来说很痛苦:错误会发生,功能会演变,治理规则会改变。

为了解决这个问题,开发者使用 代理,即永远不会移动的最小合约,而它们的逻辑可以在底层被替换。

它们保持相同的地址,保留所有用户状态,并使用 delegatecall 将执行路由到一个单独的实现合约。

在这篇文章中,我们将分解最常见的代理模式:透明代理 (EIP-1967),并确切地了解它在实践中是如何工作的:

1. delegatecall 如何让逻辑存在于其他地方,同时状态保持在本地

2. 为什么 EIP-1967 标准化了管理和实现存储槽

3. 如何使用 Foundry 在链上部署、升级和检查代理

最后,你将了解像 Aave 和 OpenZeppelin 这样的协议如何在不中断用户的情况下升级合约,以及如何安全地做到这一点。

代理和可升级性

智能合约在设计上是不可变的。一旦部署,它们的字节码就无法更改。这种不变性对于信任来说很好,但对于真正的协议来说很痛苦:错误会发生,标准会演变,治理规则会改变。如何在不要求每个用户迁移到新地址的情况下“升级”逻辑?

答案是 代理。永远不会更改其地址,但将调用转发到可以交换的实现的合约。

核心思想

代理是一个精简的合约,它:

  1. 接收一个调用。
  2. 将其(通过 delegatecall)转发到另一个合约,称为 实现
  3. 将结果返回给用户。

因为 delegatecall 在调用者的上下文中执行,所有的存储都保存在代理中,而逻辑存在于实现中。交换实现 → 你就“升级”了合约,但用户保持与同一地址交互。

透明代理 (EIP-1967)

问题: 合约是不可变的,但实际系统需要修复和新功能。

解决方案: 透明代理 保留一个公共地址,并将用户调用转发到一个可交换的实现。它使用来自

EIP-1967 的固定存储槽,以便工具和审计可以准确地知道管理/实现的位置。] executes the logic code in the caller’s storage (the proxy). The implementation’s constructor ran earlier in its own context.

  • 构造函数不能重新运行: 一旦实现(逻辑合约)被部署,它的构造函数就消失了。你不能稍后“通过 delegatecall 构造”。
  • 你将通过代理看到零: 你的构造函数可能已经在逻辑地址上设置了 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 标准化,这使得检查和审计可预测。
  • 在逻辑合约中使用 初始化器;构造函数不能通过代理运行。

总结

代理让合约保持一个公共地址,同时在幕后交换逻辑。透明代理 (EIP-1967) 通过 delegatecall 将用户调用路由到一个实现,将所有状态保存在代理中,并公开一个单独的管理平面用于升级。你展示了三个标准插槽(实现/管理/信标),为什么构造函数不起作用(使用 initialize),以及一个完整的 Foundry 演练:部署 V1,使用 init calldata 部署代理,通过代理读取/写入,检查插槽,然后升级到 V2 并确认状态连续性。

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

0 条评论

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