Розробка системи автоматичного деплоя на несколько сетей
Проблема виникає на третьому чи четвертому деплоі одного й того ж протоколу в різні сети: хтось задеплоив на Arbitrum з іншим значенням константи, на Base використовувалася інша версія OpenZeppelin, адреси прокси не збереглися нормально, і тепер невизначено що де задеплоено. Multichain auto-deploy вирішує саме це — відтворюваність та трекінг деплоев.
Детерміновані адреси через CREATE2
Головна вимога до мультичейн деплоя: одинакові адреси на всіх сетях. Це спрощує користувацький досвід, документацію та кросс-чейн інтеграції.
CREATE2 дозволяє обчислити адресу контракту до деплоя:
address = keccak256(0xff ++ deployerAddress ++ salt ++ keccak256(bytecode))[12:]
Якщо deployer має одинаковий адресу на всіх EVM-сетях (через Nick's Factory або власний deployer через deterministicDeploy), байткод одинаків, salt одинаків — адреса буде одинакова скрізь.
Foundry підтримує це нативно:
// deploy script
function run() external {
bytes32 salt = keccak256("MyProtocol_v1.0.0");
vm.broadcast();
address proxy = factory.deployProxy(
address(implementation),
salt,
abi.encodeCall(MyContract.initialize, (owner, params))
);
console.log("Deployed at:", proxy);
console.log("Chain:", block.chainid);
}
# Деплой на несколько сетей
forge script script/Deploy.s.sol --rpc-url arbitrum --broadcast
forge script script/Deploy.s.sol --rpc-url base --broadcast
forge script script/Deploy.s.sol --rpc-url optimism --broadcast
Проблема: якщо байткод відрізняється між сетями (наприклад, hardcoded chainId або address), CREATE2-адреса буде іншою. Потрібно уникати compile-time константних адрес у байткоді.
Структура деплой-системи
Конфігурація сетей
Єдиний джерело істини для всіх сетей в deploy.config.ts:
export interface NetworkConfig {
chainId: number;
rpcUrl: string;
deployer: string; // адрес деплоера
gasPrice?: bigint; // override для сетей з нестабільним gas
confirmations: number; // скільки блоків чекати
verifier?: "etherscan" | "blockscout" | "none";
verifierUrl?: string;
nativeCurrency: string;
contracts: {
// адреси залежних контрактів у цій сети
usdc?: string;
weth?: string;
uniswapRouter?: string;
};
}
export const networks: Record<string, NetworkConfig> = {
arbitrum: {
chainId: 42161,
rpcUrl: process.env.ARBITRUM_RPC!,
deployer: DEPLOYER_ADDRESS,
confirmations: 1,
verifier: "etherscan",
nativeCurrency: "ETH",
contracts: {
usdc: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
weth: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
},
},
base: {
chainId: 8453,
rpcUrl: process.env.BASE_RPC!,
deployer: DEPLOYER_ADDRESS,
confirmations: 1,
verifier: "blockscout",
verifierUrl: "https://base.blockscout.com/api",
nativeCurrency: "ETH",
contracts: {
usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
weth: "0x4200000000000000000000000000000000000006",
},
},
};
Artifacts та управління станом
Після кожного деплоя потрібно зберегти адреси контрактів. Foundry зберігає в broadcast/ директорію, але це неудобно для multichain трекінгу.
Кращий підхід — генерувати deployments.json:
interface DeploymentRecord {
network: string;
chainId: number;
contracts: Record<string, {
address: string;
implementationAddress?: string;
abi: string; // IPFS хеш або шлях
deployedAt: number; // block number
txHash: string;
version: string; // semver
}>;
deployedBy: string;
timestamp: string;
}
Цей файл коммітиться в репозиторій — єдиний джерело істини про адреси. CI/CD обновляє його після кожного деплоя.
Деплой скрипт з retry та verification
import { createPublicClient, createWalletClient, http } from "viem";
async function deployWithRetry(
network: NetworkConfig,
contractName: string,
deployFn: () => Promise<`0x${string}`>,
maxRetries = 3
): Promise<`0x${string}`> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const address = await deployFn();
// Чекаємо підтверджень
const client = createPublicClient({ transport: http(network.rpcUrl) });
await client.waitForTransactionReceipt({
hash: address,
confirmations: network.confirmations,
});
console.log(`✓ ${contractName} on ${network.chainId}: ${address}`);
return address;
} catch (err) {
if (attempt === maxRetries - 1) throw err;
console.log(`Retry ${attempt + 1}/${maxRetries}: ${err.message}`);
await sleep(2000 * (attempt + 1));
}
}
throw new Error("unreachable");
}
Автоматична верифікація контрактів
Після деплоя контракти потрібно верифікувати на block explorer — обов'язково для протоколів, які хочуть довіри користувачів.
Foundry:
forge verify-contract \
--chain-id 42161 \
--num-of-optimizations 200 \
--compiler-version v0.8.24 \
$CONTRACT_ADDRESS \
src/MyContract.sol:MyContract \
--etherscan-api-key $ARBITRUM_ETHERSCAN_KEY
Для Blockscout (Base, деякі L2):
forge verify-contract \
--verifier blockscout \
--verifier-url https://base.blockscout.com/api \
$CONTRACT_ADDRESS \
src/MyContract.sol:MyContract
Автоматизація в скрипті: верифікувати одразу після деплоя, не чекати окремого кроку. Зберегти статус верифікації в deployments.json.
CI/CD pipeline
GitHub Actions workflow
name: Deploy Protocol
on:
push:
tags:
- "v*" # деплой при тегіруванні релізу
jobs:
deploy:
runs-on: ubuntu-latest
strategy:
matrix:
network: [arbitrum, base, optimism]
max-parallel: 1 # послідовно, щоб не було race conditions у deployments.json
steps:
- uses: actions/checkout@v4
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Run tests
run: forge test --fork-url ${{ secrets.MAINNET_RPC }}
- name: Deploy to ${{ matrix.network }}
env:
DEPLOYER_PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }}
RPC_URL: ${{ secrets[format('{0}_RPC', matrix.network)] }}
run: |
forge script script/Deploy.s.sol \
--rpc-url $RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY \
--broadcast \
--verify
- name: Update deployments.json
run: node scripts/update-deployments.js ${{ matrix.network }}
- name: Commit deployments
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: update deployments for ${{ matrix.network }} @ ${{ github.ref_name }}"
file_pattern: deployments.json
Управління приватними ключами в CI
Ніколи не зберігайте приватний ключ деплоера в CI secrets як hex-строку без додаткового захисту. Краще:
- AWS KMS + custom signer: ключ в KMS, signing через API. Утечка CI secrets не компрометує ключ.
- Hardware wallet через Frame + WalletConnect: деплой з ручним підтвердженням для mainnet.
- Окремий деплой-гаманець з мінімальним балансом (лише на газ), не пов'язаний з treasury.
Мультичейн адресація в протоколі
Якщо контракти взаємодіють між сетями (cross-chain вызови), потрібно управляти адресами peer-контрактів:
contract CrossChainRegistry {
mapping(uint256 => address) public peers; // chainId => peer address
function setPeer(uint256 chainId, address peer) external onlyOwner {
peers[chainId] = peer;
emit PeerSet(chainId, peer);
}
function _validateCrossChainSender(uint256 srcChainId, address sender) internal view {
require(peers[srcChainId] != address(0), "Unknown chain");
require(peers[srcChainId] == sender, "Invalid peer");
}
}
Якщо використовується LayerZero або Wormhole — вони надають власні registry механізми, але базова логіка та ж.
Моніторинг задеплоєних контрактів
Після деплоя на 5+ сетей потрібно мониторити всі інстанси:
// Агрегований моніторинг через события
const deployments = require("./deployments.json");
for (const [network, data] of Object.entries(deployments)) {
const client = createPublicClient({ transport: http(data.rpcUrl) });
// Слідимо за критичними событіями на всіх сетях
client.watchContractEvent({
address: data.contracts.core.address,
abi: CORE_ABI,
eventName: "EmergencyPause",
onLogs: (logs) => alertOps(`PAUSE on ${network}`, logs),
});
}
Стек та інструменти
| Компонент | Інструмент |
|---|---|
| Compile & test | Foundry (forge, cast, anvil) |
| Deploy scripts | TypeScript + viem або Foundry scripts |
| State tracking | deployments.json у git |
| CI/CD | GitHub Actions / GitLab CI |
| Key management | AWS KMS або Ledger для mainnet |
| Verification | Etherscan API / Blockscout API |
| Monitoring | Tenderly + власні алерти |
Розробка системи деплоя: 1–2 тижні для EVM-сумісних сетей. Додавання non-EVM сетей (Solana, TON) потребує окремого toolchain та значно складніше.







