Upgradeable — UUPS Proxy
Как работает UUPS Proxy в Solidity: тонкий ERC-1967 proxy, upgradeTo в implementation и защита _authorizeUpgrade.
UUPS переносит upgrade-логику в implementation
UUPS Proxy — это upgradeable-паттерн, где proxy остаётся тонким: он хранит implementation address и делает delegatecall.
В отличие от Transparent Proxy, функция upgrade находится в implementation. Поэтому каждая новая версия logic-контракта должна сама уметь безопасно обновлять implementation.
Пишем UUPS Proxy
Создайте файл contracts/UUPSProxyDemo.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract ERC1967Proxy {
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
error ZeroAddress();
error InitializationFailed();
constructor(address initialImplementation, bytes memory initData) payable {
if (initialImplementation == address(0)) {
revert ZeroAddress();
}
_setImplementation(initialImplementation);
if (initData.length > 0) {
(bool success, ) = initialImplementation.delegatecall(initData);
if (!success) {
revert InitializationFailed();
}
}
}
fallback() external payable {
_delegate(_implementation());
}
receive() external payable {
_delegate(_implementation());
}
function _implementation() private view returns (address implementationAddress) {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
implementationAddress := sload(slot)
}
}
function _setImplementation(address newImplementation) private {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
sstore(slot, newImplementation)
}
}
function _delegate(address implementationAddress) private {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), implementationAddress, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
}
interface IERC1822Proxiable {
function proxiableUUID() external view returns (bytes32);
}
abstract contract UUPSUpgradeable {
bytes32 internal constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
address private immutable self = address(this);
event Upgraded(address indexed implementation);
error FunctionMustBeCalledThroughDelegatecall();
error FunctionMustNotBeCalledThroughDelegatecall();
error ProxyImplementationMismatch(address currentImplementation);
error NotContract(address account);
error UnsupportedUUPSImplementation(address implementation);
error UnsupportedProxiableUUID(bytes32 slot);
modifier onlyProxy() {
if (address(this) == self) {
revert FunctionMustBeCalledThroughDelegatecall();
}
if (_getImplementation() != self) {
revert ProxyImplementationMismatch(_getImplementation());
}
_;
}
modifier notDelegated() {
if (address(this) != self) {
revert FunctionMustNotBeCalledThroughDelegatecall();
}
_;
}
function proxiableUUID() external view notDelegated returns (bytes32) {
return IMPLEMENTATION_SLOT;
}
function upgradeTo(address newImplementation) external onlyProxy {
_authorizeUpgrade(newImplementation);
_upgradeTo(newImplementation);
}
function _authorizeUpgrade(address newImplementation) internal virtual;
function _upgradeTo(address newImplementation) internal {
if (newImplementation.code.length == 0) {
revert NotContract(newImplementation);
}
try IERC1822Proxiable(newImplementation).proxiableUUID() returns (bytes32 slot) {
if (slot != IMPLEMENTATION_SLOT) {
revert UnsupportedProxiableUUID(slot);
}
} catch {
revert UnsupportedUUPSImplementation(newImplementation);
}
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
sstore(slot, newImplementation)
}
emit Upgraded(newImplementation);
}
function _getImplementation() internal view returns (address implementationAddress) {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
implementationAddress := sload(slot)
}
}
}
contract CounterUUPSV1 is UUPSUpgradeable {
uint256 public value;
address public owner;
bool public initialized;
event Initialized(address indexed owner);
event ValueChanged(uint256 newValue);
error AlreadyInitialized();
error OnlyOwner(address caller);
error ZeroAddress();
constructor() {
initialized = true;
}
function initialize(address initialOwner) external {
if (initialized) {
revert AlreadyInitialized();
}
if (initialOwner == address(0)) {
revert ZeroAddress();
}
owner = initialOwner;
initialized = true;
emit Initialized(initialOwner);
}
function increment() external {
value += 1;
emit ValueChanged(value);
}
function setValue(uint256 newValue) external {
if (msg.sender != owner) {
revert OnlyOwner(msg.sender);
}
value = newValue;
emit ValueChanged(newValue);
}
function _authorizeUpgrade(address) internal view override {
if (msg.sender != owner) {
revert OnlyOwner(msg.sender);
}
}
}
contract CounterUUPSV2 is CounterUUPSV1 {
function decrement() external {
value -= 1;
emit ValueChanged(value);
}
}Этот пример содержит тонкий proxy и две версии logic-контракта. Пользователь вызывает increment() через proxy, а owner вызывает upgradeTo(address) тоже через proxy.
Как это работает
ERC1967Proxy — минимальный proxy. Он хранит implementation в EIP-1967 slot и делегирует все вызовы в текущую implementation.
UUPSUpgradeable — базовая логика upgrade. Она знает, как записать новый implementation address в storage proxy.
upgradeTo(newImplementation) — функция находится в implementation, но вызывается через proxy. Из-за delegatecall она меняет implementation slot proxy.
onlyProxy() — не даёт вызвать upgradeTo напрямую на implementation address. Вызов должен идти через proxy и текущая implementation должна совпадать с self.
proxiableUUID() — возвращает slot, который использует implementation. Это проверка совместимости UUPS-контрактов.
_authorizeUpgrade(...) — место, где конкретный контракт решает, кто имеет право на upgrade. В примере это только owner.
Почему constructor отключает initialize
constructor() в CounterUUPSV1 выставляет initialized = true только в storage implementation-контракта.
Proxy storage от этого не меняется. Поэтому initialize(initialOwner) всё ещё можно выполнить через proxy, но нельзя захватить сам implementation-контракт прямым вызовом.
Это важно: незаблокированная implementation может стать источником ошибок, особенно если в ней есть опасные функции или upgrade-логика.
UUPS vs Transparent Proxy
В Transparent Proxy upgrade-функции живут в proxy. Implementation отвечает только за бизнес-логику.
В UUPS upgrade-функция живёт в implementation. Proxy меньше и дешевле, но новая implementation обязана сохранить UUPS-логику. Если обновиться на контракт без upgradeTo, дальнейшие upgrades могут стать невозможны.
Главный риск UUPS — неправильная _authorizeUpgrade. Если она пустая или проверяет не тот адрес, любой сможет заменить implementation.
Storage layout
Как и в Transparent Proxy, state хранится на proxy address. Поэтому новые версии должны сохранять старый layout:
contract CounterUUPSV1 {
uint256 public value; // slot 0
address public owner; // slot 1
bool public initialized; // slot 1, упакован рядом с owner
}CounterUUPSV2 добавляет только новую функцию. Если нужно добавить переменную, добавляйте её после старого layout.
Частые ошибки
Вызывать upgradeTo на implementation address → onlyProxy должен откатить такой вызов. Upgrade делают через proxy address.
Оставить _authorizeUpgrade без проверки → любой адрес сможет заменить implementation и забрать контракт.
Обновиться на implementation без UUPS-логики → proxy продолжит работать, но следующий upgrade может стать невозможным.
Забыть initialize → owner останется нулевым или proxy сможет инициализировать посторонний адрес.
Что дальше
- Upgradeable — Transparent Proxy — сравните UUPS с proxy, где upgrade-логика живёт в proxy
- Взаимодействие с Proxy контрактом — прочитайте implementation slot через ethers v6
- AccessControl — система ролей — вынесите право upgrade в отдельную роль или multisig