Разработка скриптов миграции смарт-контрактов

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска DeFi-протоколов, NFT-маркетплейсов и криптобирж. Аудит безопасности, токеномика, интеграция с существующей инфраструктурой.
Показано 1 из 1 услугВсе 1306 услуг
Разработка скриптов миграции смарт-контрактов
Средняя
от 1 рабочего дня до 3 рабочих дней
Часто задаваемые вопросы
Направления блокчейн-разработки
Этапы блокчейн-разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1221
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1163
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    855
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1056
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    561
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    828

Разработка скриптов миграции смарт-контрактов

Смарт-контракты нельзя изменить после деплоя. Это не баг — это фундаментальное свойство блокчейна. Но данные, которые хранит контракт, и адрес, по которому пользователи с ним взаимодействуют, можно перенести. Миграция — это не просто «перезалить контракт», это операция, которая должна сохранить целостность 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 транзакций. Реальный подход:

  1. Snapshot off-chain: читаем всё состояние через RPC (ethers.js + event logs + direct storage reads)
  2. Merkle tree: строим дерево из всех адресов и балансов
  3. 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 и мультиподписью добавляет операционное время, но не разработческое.