代理部署由两个合约组成:代理,负责委托每次调用;以及实现,包含逻辑。两者都必须分别验证。对于任何遵循 ERC-1967 的代理,Tenderly 浏览器都会读取标准的实现存储槽,因此一旦两个合约都被验证,对代理地址的调用就会使用实现的 ABI 进行解码。
本指南在 Virtual Environment 上将 UUPS 实现(CounterV1)部署在 ERC1967Proxy 之后,在一次 forge script 运行中验证两者,将实现升级到 CounterV2,并涵盖对未验证部署的代理进行重新验证。对于 Hardhat 的等效方法,请参见验证代理合约。
开始之前
- 完成部署和验证合约中的设置:一个 Virtual Environment、一个已注资的部署者地址以及一个包含
cbor_metadata = true 和 bytecode_hash = "ipfs" 的 foundry.toml。
- 设置环境变量:
export TENDERLY_VIRTUAL_TESTNET_RPC=... # your Virtual Environment RPC URL
export TENDERLY_VERIFIER_URL=$TENDERLY_VIRTUAL_TESTNET_RPC/verify
export PRIVATE_KEY=... # deployer private key
- 与基础库一起安装 OpenZeppelin 的可升级合约:
forge install OpenZeppelin/openzeppelin-contracts --no-git --shallow
forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-git --shallow
- 将两个 remappings 添加到
remappings.txt:
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/
forge-std/=lib/forge-std/src/
这两个库担任不同的角色:openzeppelin-contracts 提供代理(ERC1967Proxy),而 openzeppelin-contracts-upgradeable 提供实现的构建块(Initializable、UUPSUpgradeable、OwnableUpgradeable)。可升级版本使用初始化函数替代构造函数,因为当状态位于代理之后时,构造函数不会运行。
实现合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract CounterV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public number;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address owner_, uint256 initialNumber) external initializer {
__Ownable_init(owner_);
number = initialNumber;
}
function increment() external {
number += 1;
}
function setNumber(uint256 newNumber) external {
number = newNumber;
}
function version() external pure virtual returns (string memory) {
return "v1";
}
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
此合约中对代理模式重要的三个部分:
constructor() { _disableInitializers(); } 锁定实现。只有代理应该持有状态;此构造函数使得对实现地址直接调用 initialize 会 revert。
initialize(...) 替代构造函数。代理在部署代理的同一笔交易中对它进行一次 delegatecall。
_authorizeUpgrade(...) 是必需的 UUPS 权限钩子。每次 upgradeToAndCall 时都会运行;如果它 revert,升级就会被阻止。
部署脚本
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console} from "forge-std/Script.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {CounterV1} from "../src/CounterV1.sol";
contract DeployProxyScript is Script {
function run() public {
address owner = vm.addr(vm.envUint("PRIVATE_KEY"));
uint256 initialNumber = 42;
vm.startBroadcast();
// 1. Deploy the implementation.
CounterV1 implementation = new CounterV1();
// 2. Deploy the proxy, calling initialize(owner, 42) in the same tx.
bytes memory initData = abi.encodeCall(CounterV1.initialize, (owner, initialNumber));
ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData);
vm.stopBroadcast();
console.log("Implementation:", address(implementation));
console.log("Proxy :", address(proxy));
console.log("Owner :", owner);
}
}
该脚本部署逻辑合约,使用 abi.encodeCall 构造 initialize calldata(它在编译时对参数进行类型检查,与 abi.encodeWithSignature 不同),然后部署代理。代理的构造函数将实现地址存储在 ERC-1967 实现槽中,并对其进行 delegatecall initData,原子性地初始化代理的存储。
部署并验证两个合约
代理和实现是一个脚本中的两个合约,因此命令与任何多合约部署使用的 forge script 形式相同:
forge script script/DeployProxy.s.sol:DeployProxyScript \
--rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC \
--private-key $PRIVATE_KEY \
--broadcast --slow \
--verify \
--verifier custom \
--verifier-url $TENDERLY_VERIFIER_URL
预期的输出末尾:
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
##
Start verification for (2) contracts
Submitting verification for [src/CounterV1.sol:CounterV1] 0xeDa7...
Response: `OK`
Details: `Pass - Verified`
Submitting verification for [lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy] 0xb22e...
Response: `OK`
Details: `Pass - Verified`
All (2) contracts were verified!
注意第二次提交的路径:代理被验证为 lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy,即来自您 lib/ 树中的 OpenZeppelin 合约,而不是 src/ 中的合约。Foundry 会自动解析此路径,因为脚本的广播日志记录了每一个 new 及其字节码。
确认代理链接
当代理和实现都通过验证时,浏览器会通过代理解码调用。要确认代理正在委托,请直接读取 ERC-1967 实现槽:
PROXY=<your-proxy-address>
IMPL_SLOT=0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
cast storage $PROXY $IMPL_SLOT --rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC
# 0x000000000000000000000000<implementation address, 20 bytes>
然后通过代理调用实现的一个函数:
cast call $PROXY "number()(uint256)" --rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC
# 42
在 Tenderly Dashboard 中,代理的地址页面会显示已验证的 ERC1967Proxy 源代码、带有已链接实现地址的代理标签,以及包含实现函数的 ABI 视图。调用 proxy.increment() 或 proxy.setNumber(...) 的交易在调用跟踪中会使用实现的函数名和参数标签进行解码。
如果浏览器显示代理,但没有使用实现的 ABI 解码调用,最常见的原因是两个合约中只有一个被验证。请对缺失的那个重新运行 forge verify-contract。
升级实现
UUPS 升级通过继承自 UUPSUpgradeable 的 upgradeToAndCall(newImpl, callData) 发起。添加一个 V2:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {CounterV1} from "./CounterV1.sol";
contract CounterV2 is CounterV1 {
function decrement() external {
number -= 1;
}
function version() external pure override returns (string memory) {
return "v2";
}
}
编写 V2 时的存储布局约束:
- 继承自 V1,或原样复制 V1 的存储布局。存储槽是位置相关的;在现有变量之前添加新的状态变量会破坏代理的状态。
- 新的状态变量放在 V1 现有变量之后,永远不要放在它们之间。
- 不要更改现有变量的类型或重新排序声明。
script/UpgradeProxy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console} from "forge-std/Script.sol";
import {CounterV1} from "../src/CounterV1.sol";
import {CounterV2} from "../src/CounterV2.sol";
contract UpgradeProxyScript is Script {
function run() public {
address proxy = vm.envAddress("PROXY");
vm.startBroadcast();
CounterV2 newImplementation = new CounterV2();
CounterV1(proxy).upgradeToAndCall(address(newImplementation), bytes(""));
vm.stopBroadcast();
console.log("New implementation:", address(newImplementation));
console.log("Proxy upgraded: ", proxy);
}
}
使用相同的 verify 标志运行它:
PROXY=<your-proxy-address> \
forge script script/UpgradeProxy.s.sol:UpgradeProxyScript \
--rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC \
--private-key $PRIVATE_KEY \
--broadcast --slow \
--verify \
--verifier custom \
--verifier-url $TENDERLY_VERIFIER_URL
只有 CounterV2 被部署和验证(输出报告 (1) contracts)。代理地址不变;只有它的 ERC-1967 槽发生变化。脚本返回后:
cast storage $PROXY $IMPL_SLOT --rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC
# 0x000000000000000000000000<CounterV2 address>
cast call $PROXY "version()(string)" --rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC
# "v2"
cast call $PROXY "number()(uint256)" --rpc-url $TENDERLY_VIRTUAL_TESTNET_RPC
# 42 <-- state preserved; only the logic changed
浏览器中的代理地址现在按照 CounterV2 的 ABI 进行解码。旧的 CounterV1 实现在其原地址上仍然处于已验证状态,但不再是活动的实现。
验证已经部署的代理
如果您在部署时没有使用 --verify,请为每个合约分别运行 forge verify-contract。
实现是通过 new CounterV1() 部署的,没有构造函数参数,因此不需要 --constructor-args 标志:
forge verify-contract <IMPL_ADDRESS> src/CounterV1.sol:CounterV1 \
--verifier custom \
--verifier-url $TENDERLY_VERIFIER_URL \
--watch
代理需要部署时使用的完全相同的构造函数参数,且经过 ABI 编码:
# ABI-encode: constructor(address implementation, bytes initData)
INIT_DATA=$(cast calldata "initialize(address,uint256)" <OWNER_ADDRESS> 42)
forge verify-contract <PROXY_ADDRESS> \
lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy \
--verifier custom \
--verifier-url $TENDERLY_VERIFIER_URL \
--constructor-args $(cast abi-encode "constructor(address,bytes)" <IMPL_ADDRESS> $INIT_DATA) \
--watch
需要正确处理的三个细节:
- 合约路径使用
lib/ 位置,而不是 src/。代理是 OpenZeppelin 的代码,因此验证器要匹配的源代码位于 lib/openzeppelin-contracts/... 中。forge script --verify 会自动解析此路径;forge verify-contract 不会。
initData 必须与部署时的 initData 完全匹配。 即使部署后状态发生变化,构造函数参数依然是代理字节码构造时使用的原始 initialize(owner, 42) calldata。
- 代理的构造函数签名是
(address, bytes),forge verify-contract 期望已经过 ABI 编码的形式,这正是 cast abi-encode 生成的。
后续步骤
- 部署和验证合约:在 Virtual Environment 上的基础 Foundry 与 Hardhat 验证流程。
- 使用 Foundry 验证智能合约:通过 Tenderly 的验证 API 在公共网络上验证合约。
- 验证代理合约:针对 UUPS、Transparent 和 Beacon 代理的 Hardhat 插件流程。