Перейти к основному содержанию
Развёртывание прокси состоит из двух контрактов: прокси, который делегирует каждый вызов, и имплементации, содержащей логику. Оба должны быть верифицированы отдельно. Для любого прокси, следующего ERC-1967, обозреватель Tenderly считывает стандартный слот хранилища имплементации, так что после верификации обоих контрактов вызовы к адресу прокси декодируются с помощью ABI имплементации. Это руководство деплоит UUPS-имплементацию (CounterV1) за ERC1967Proxy в Virtual Environment, верифицирует обе за один запуск forge script, обновляет имплементацию до CounterV2 и рассказывает, как повторно верифицировать прокси, задеплоенный без верификации. Для эквивалента в Hardhat см. Verifying Proxy Contracts.

Прежде чем начать

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
  • Установите upgradeable-контракты OpenZeppelin рядом с базовой библиотекой:
forge install OpenZeppelin/openzeppelin-contracts --no-git --shallow
forge install OpenZeppelin/openzeppelin-contracts-upgradeable --no-git --shallow
  • Добавьте оба remapping в 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 — строительные блоки имплементации (Initializable, UUPSUpgradeable, OwnableUpgradeable). Upgradeable-версии заменяют конструкторы функциями инициализации, потому что конструкторы не выполняются, когда состояние живёт за прокси.

Контракт имплементации

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, апгрейд блокируется.

Deploy-скрипт

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);
    }
}
Скрипт деплоит контракт логики, собирает calldata для initialize с помощью abi.encodeCall (который проверяет типы аргументов на этапе компиляции, в отличие от 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, контракт OpenZeppelin из вашего дерева lib/, а не контракт в src/. Foundry разрешает это автоматически, потому что broadcast log скрипта записывает каждое 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(...), декодируются в call trace с именами функций и меток параметров имплементации.
Если обозреватель показывает прокси, но не декодирует вызовы с помощью ABI имплементации, самая частая причина — что верифицирован только один из двух контрактов. Перезапустите forge verify-contract на том, который отсутствует.

Апгрейд имплементации

Апгрейды UUPS выполняются через upgradeToAndCall(newImpl, callData), унаследованный от UUPSUpgradeable. Добавьте 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);
    }
}
Запустите его с теми же флагами верификации:
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
Адрес прокси в обозревателе теперь декодируется по ABI CounterV2. Старая имплементация 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 во время деплоя. Даже если состояние изменилось после развёртывания, аргумент конструктора — это оригинальная calldata initialize(owner, 42), с которой был сконструирован байткод прокси.
  3. Сигнатура конструктора прокси — (address, bytes), а forge verify-contract ожидает уже ABI-кодированную форму, которую и производит cast abi-encode.

Дальнейшие шаги