Smart Contract Migration Scripts Development
Smart contracts can't be changed after deployment. This isn't a bug — it's a fundamental blockchain property. But the data a contract stores and the address where users interact with it can be migrated. Migration isn't just "redeploy the contract," it's an operation that must preserve state integrity, not break integrated protocol interactions, and allow rollback if something goes wrong.
Two Fundamentally Different Migration Scenarios
1. Proxy upgrade: change logic, preserve address and storage
If a contract is deployed via UUPS (EIP-1822) or Transparent Proxy (EIP-1967) pattern — an upgrade is technically simple: deploy new implementation, call upgradeTo(newImpl). But the devil is in storage layout.
Storage collision is the main threat of proxy upgrades. Variables in Solidity occupy slots in declaration order. If in version V1 slot 0 is address owner, but in V2 you added a new variable before owner, slot 0 now reads as the new variable. Data isn't physically lost, but interpreted incorrectly.
Real example: contract V1:
contract StakingV1 {
address public owner; // slot 0
uint256 public totalStaked; // slot 1
}
Contract V2 with incorrect addition:
contract StakingV2 {
uint256 public version; // slot 0 — CONFLICT with owner!
address public owner; // slot 1 — CONFLICT with totalStaked!
uint256 public totalStaked; // slot 2
}
After upgrade owner returns the first 20 bytes of totalStaked from old storage. Critical error.
Correct pattern for OpenZeppelin upgradeable contracts — never reorder existing variables, only add new ones at the end, and use @openzeppelin/upgrades-core to verify compatibility:
npx @openzeppelin/upgrades-core validate artifacts/ --unsafeAllowCustomTypes
For UUPS pattern we also use storage gaps — reserved slots in base contracts:
uint256[50] private __gap; // reserve for future variables
2. Full migration: deploy new contract, move data
Sometimes proxy is impossible (contract wasn't originally designed upgradeable) or undesirable (too much technical debt). Then you need data migration: read all data from the old contract and write to the new one.
This is expensive in gas. A contract with 10,000 users and mapping(address => UserData) direct migration on-chain means 10,000 transactions. Real approach:
- Snapshot off-chain: read all state via RPC (ethers.js + event logs + direct storage reads)
- Merkle tree: build tree from all addresses and balances
- Lazy migration: in the new contract, users claim their data themselves, providing merkle proof
mapping(address => bool) public migrated;
bytes32 public merkleRoot; // root from old contract snapshot
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); // or another transfer
}
Merkle root is published after snapshot. Users migrate themselves — gas costs distributed among them.
Migration Scripts: Structure and Tools
Foundry Script for 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();
// Verify after upgrade
StakingV2 proxy = StakingV2(proxyAddress);
require(proxy.version() == 2, "Upgrade failed");
}
}
Run with dry-run before mainnet:
forge script script/Upgrade.s.sol --fork-url $MAINNET_RPC --broadcast false
Snapshot Script in 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);
// Read Transfer events for ERC-20 balances
const filter = contract.filters.Transfer();
const events = await contract.queryFilter(filter, fromBlock, "latest");
const balances = new Map<string, bigint>();
for (const event of events) {
// accumulate balance changes
}
return Object.fromEntries(balances);
}
For large contracts — split into 10,000 block chunks. Infura and Alchemy limit getLogs requests by range.
Version Management and Rollback
Tag each upgrade in git: v2.0.0-upgrade-2024-03. Store the old implementation address — in UUPS pattern rollback is technically possible via another upgradeToAndCall to the previous implementation, if storage is compatible.
For critical upgrades use timelock: TimelockController from OpenZeppelin with 24-48 hour delay. The community can check the upgrade transaction and react to unwanted changes.
Process
Current state audit. Analyze storage layout, proxy pattern (if any), volume of data to migrate, dependent protocols (other contracts storing our contract address).
Strategy design. Choose proxy upgrade or full migration. Design backward compatibility for integrated protocols.
Development and testing. Fork tests mainnet via Foundry — simulate upgrade on real state. Verify storage layout via @openzeppelin/upgrades-core. Test rollback scenario.
Deployment. Multisig via Safe{Wallet} for production upgrades. Timelock if governance requires. Monitor via Tenderly Alerts first 24 hours after upgrade.
Timeline Estimates
Proxy upgrade script with fork tests: 1-2 days. Full data migration with merkle tree and lazy claim: 2-5 days depending on data volume and structure complexity. Coordination with timelock and multisig adds operational time but not development time.







