Разработка скриптов миграции смарт-контрактов
Смарт-контракты нельзя изменить после деплоя. Это не баг — это фундаментальное свойство блокчейна. Но данные, которые хранит контракт, и адрес, по которому пользователи с ним взаимодействуют, можно перенести. Миграция — это не просто «перезалить контракт», это операция, которая должна сохранить целостность state, не нарушить работу интегрированных протоколов и дать возможность откатиться, если что-то пошло не так.
Два принципиально разных сценария миграции
1. Proxy upgrade: меняем логику, сохраняем адрес и storage
Если контракт развёрнут через UUPS (EIP-1822) или Transparent Proxy (EIP-1967) паттерн — апгрейд технически прост: деплоим новую implementation, вызываем upgradeTo(newImpl). Но дьявол в storage layout.
Storage collision — главная угроза proxy-апгрейдов. Переменные в Solidity занимают слоты по порядку объявления. Если в версии V1 слот 0 — это address owner, а в V2 ты добавил новую переменную перед owner, слот 0 теперь будет читаться как новая переменная. Данные не теряются физически, но интерпретируются неверно.
Пример реального грабля: контракт V1:
contract StakingV1 {
address public owner; // slot 0
uint256 public totalStaked; // slot 1
}
Контракт V2 с неправильным добавлением:
contract StakingV2 {
uint256 public version; // slot 0 — КОНФЛИКТ с owner!
address public owner; // slot 1 — КОНФЛИКТ с totalStaked!
uint256 public totalStaked; // slot 2
}
После апгрейда owner вернёт первые 20 байт числа totalStaked из старого storage. Это критическая ошибка.
Правильный паттерн для OpenZeppelin upgradeable контрактов — никогда не переставлять существующие переменные, только добавлять новые в конец, и использовать @openzeppelin/upgrades-core для проверки совместимости:
npx @openzeppelin/upgrades-core validate artifacts/ --unsafeAllowCustomTypes
Для UUPS паттерна мы также используем storage gaps — зарезервированные слоты в базовых контрактах:
uint256[50] private __gap; // резерв на будущие переменные
2. Полная миграция: деплоим новый контракт, переносим данные
Иногда proxy невозможен (контракт изначально не проектировался upgradeable) или нежелателен (слишком большой технический долг в архитектуре). Тогда нужна data migration: считать все данные из старого контракта и записать в новый.
Это дорого по газу. На контракте с 10 000 пользователей и mapping(address => UserData) прямая миграция on-chain означает 10 000 транзакций. Реальный подход:
- Snapshot off-chain: читаем всё состояние через RPC (ethers.js + event logs + direct storage reads)
- Merkle tree: строим дерево из всех адресов и балансов
- Lazy migration: в новом контракте пользователь сам забирает свои данные, предоставляя merkle proof
mapping(address => bool) public migrated;
bytes32 public merkleRoot; // root от снапшота старого контракта
function claimMigration(uint256 amount, bytes32[] calldata proof) external {
require(!migrated[msg.sender], "Already migrated");
bytes32 leaf = keccak256(abi.encode(msg.sender, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
migrated[msg.sender] = true;
_mint(msg.sender, amount); // или другой трансфер
}
Merkle root публикуется после снапшота. Пользователи мигрируют самостоятельно — расходы на газ распределены между ними.
Скрипты миграции: структура и инструменты
Foundry script для proxy upgrade
// script/Upgrade.s.sol
contract UpgradeScript is Script {
function run() external {
address proxyAddress = vm.envAddress("PROXY_ADDRESS");
vm.startBroadcast();
StakingV2 newImpl = new StakingV2();
UUPSUpgradeable(proxyAddress).upgradeToAndCall(
address(newImpl),
abi.encodeCall(StakingV2.initializeV2, (newParam))
);
vm.stopBroadcast();
// Верификация после апгрейда
StakingV2 proxy = StakingV2(proxyAddress);
require(proxy.version() == 2, "Upgrade failed");
}
}
Запуск с dry-run перед mainnet:
forge script script/Upgrade.s.sol --fork-url $MAINNET_RPC --broadcast false
Snapshot скрипт на TypeScript
import { ethers } from "ethers";
async function snapshot(contractAddress: string, fromBlock: number) {
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const contract = new ethers.Contract(contractAddress, abi, provider);
// Читаем Transfer события для ERC-20 балансов
const filter = contract.filters.Transfer();
const events = await contract.queryFilter(filter, fromBlock, "latest");
const balances = new Map<string, bigint>();
for (const event of events) {
// аккумулируем balance changes
}
return Object.fromEntries(balances);
}
Для крупных контрактов — разбиваем на блочные чанки по 10 000 блоков. Infura и Alchemy ограничивают getLogs запросы по диапазону.
Управление версиями и откат
Каждый апгрейд тегируем в git: v2.0.0-upgrade-2024-03. Храним адрес старой implementation — в UUPS паттерне откат технически возможен через повторный upgradeToAndCall к предыдущей implementation, если storage совместим.
Для критических апгрейдов используем timelock: TimelockController из OpenZeppelin с задержкой 24-48 часов. За это время сообщество может проверить транзакцию апгрейда и среагировать на нежелательные изменения.
Процесс работы
Аудит текущего состояния. Анализируем storage layout, proxy паттерн (если есть), объём данных для миграции, зависимые протоколы (другие контракты, которые хранят адрес нашего контракта).
Проектирование стратегии. Выбираем proxy upgrade или full migration. Проектируем backward compatibility для интегрированных протоколов.
Разработка и тестирование. Fork-тесты mainnet через Foundry — симулируем апгрейд на реальном состоянии. Проверяем storage layout через @openzeppelin/upgrades-core. Тестируем rollback сценарий.
Деплой. Мультиподпись через Safe{Wallet} для production апгрейдов. Timelock если требует governance. Мониторинг через Tenderly Alerts первые 24 часа после апгрейда.
Ориентиры по срокам
Proxy upgrade скрипт с тестами на форке: 1-2 дня. Полная data migration с merkle tree и lazy claim механизмом: 2-5 дней в зависимости от объёма данных и сложности структур. Координация с timelock и мультиподписью добавляет операционное время, но не разработческое.







