跳转到主要内容
代理部署由两个合约组成:代理,负责委托每次调用;以及实现,包含逻辑。两者都必须分别验证。对于任何遵循 ERC-1967 的代理,Tenderly 浏览器都会读取标准的实现存储槽,因此一旦两个合约都被验证,对代理地址的调用就会使用实现的 ABI 进行解码。 本指南在 Virtual Environment 上将 UUPS 实现(CounterV1)部署在 ERC1967Proxy 之后,在一次 forge script 运行中验证两者,将实现升级到 CounterV2,并涵盖对未验证部署的代理进行重新验证。对于 Hardhat 的等效方法,请参见验证代理合约

开始之前

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
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 提供实现的构建块(InitializableUUPSUpgradeableOwnableUpgradeable)。可升级版本使用初始化函数替代构造函数,因为当状态位于代理之后时,构造函数不会运行。

实现合约

src/CounterV1.sol
// 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 {}
}
此合约中对代理模式重要的三个部分:
  1. constructor() { _disableInitializers(); } 锁定实现。只有代理应该持有状态;此构造函数使得对实现地址直接调用 initialize 会 revert。
  2. initialize(...) 替代构造函数。代理在部署代理的同一笔交易中对它进行一次 delegatecall。
  3. _authorizeUpgrade(...) 是必需的 UUPS 权限钩子。每次 upgradeToAndCall 时都会运行;如果它 revert,升级就会被阻止。

部署脚本

script/DeployProxy.s.sol
// 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 形式相同:
showLineNumbers
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 实现槽:
showLineNumbers
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 升级通过继承自 UUPSUpgradeableupgradeToAndCall(newImpl, callData) 发起。添加一个 V2:
src/CounterV2.sol
// 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 标志运行它:
showLineNumbers
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 槽发生变化。脚本返回后:
showLineNumbers
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 标志:
showLineNumbers
forge verify-contract <IMPL_ADDRESS> src/CounterV1.sol:CounterV1 \
  --verifier       custom \
  --verifier-url   $TENDERLY_VERIFIER_URL \
  --watch
代理需要部署时使用的完全相同的构造函数参数,且经过 ABI 编码:
showLineNumbers
# 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
需要正确处理的三个细节:
  1. 合约路径使用 lib/ 位置,而不是 src/。代理是 OpenZeppelin 的代码,因此验证器要匹配的源代码位于 lib/openzeppelin-contracts/... 中。forge script --verify 会自动解析此路径;forge verify-contract 不会。
  2. initData 必须与部署时的 initData 完全匹配。 即使部署后状态发生变化,构造函数参数依然是代理字节码构造时使用的原始 initialize(owner, 42) calldata。
  3. 代理的构造函数签名是 (address, bytes)forge verify-contract 期望已经过 ABI 编码的形式,这正是 cast abi-encode 生成的。

后续步骤