Smart contract upgrade and maintenance
Smart contract in production is not the end, it's the beginning. Protocols evolve: new requirements appear, vulnerabilities found, external dependencies change (oracles, tokens, other protocols). Managing changes in immutable system is separate engineering discipline.
Most expensive mistake in this area — not logic vulnerability, but storage collision in proxy pattern. When new implementation version accidentally overwrites previous data due to variable order change. Real example: team added one variable at contract beginning, entire balances mapping shifted one slot. User balances read as addresses. Deployment had to rollback via emergency multisig at 3 AM.
Proxy patterns: architecture choice determines maintenance cost
Transparent Proxy (EIP-1967)
Classic from OpenZeppelin. ProxyAdmin manages updates, users interact directly with proxy. Problem: each call proxy checks if msg.sender is admin. Additional SLOAD — about 2100 gas on first slot access.
Suits: most protocols without gas micro-optimization.
UUPS (EIP-1822)
Update logic moved to implementation contract. Proxy lighter, less gas on regular calls. But: if deploying implementation without update function — contract becomes immutable forever. This isn't hypothetical — several projects faced this.
// UUPS: upgrade function must be in implementation
function _authorizeUpgrade(address newImplementation)
internal override onlyOwner {}
Suits: gas-sensitive protocols with careful deploy review process.
Beacon Proxy
One beacon contract stores implementation address. Hundreds proxy contracts read from beacon. Updating all proxies — one beacon call. Critical for factory patterns: lending positions, NFT collections with logic, per-user vaults.
Diamond Pattern (EIP-2535)
Splits logic into facets — multiple implementation contracts. Bypasses contract size limit (24KB). Complex to maintain: storage layout must be controlled manually via DiamondStorage pattern.
Use only when contract objectively doesn't fit limit. Otherwise complexity unjustified.
How we conduct upgrade
1. Analyze storage layout
Before writing new version — compare storage layout of old and new implementation. Foundry provides forge inspect ContractName storage-layout command. Critical rule: never change order and types of existing variables. Only add at end.
// ❌ Wrong: balances shifts from slot 0 to slot 1
contract TokenV2 {
address public newFeature; // added at beginning
mapping(address => uint256) public balances;
}
// ✅ Right: new variables only at end
contract TokenV2 {
mapping(address => uint256) public balances;
address public newFeature; // added at end
}
For UUPS and Transparent proxy OpenZeppelin provides @openzeppelin/upgrades-plugins for Hardhat and Foundry — plugin automatically checks storage layout compatibility on update.
2. Data migration
If update requires data transformation (e.g., mapping structure change), write separate migration script. For small datasets — on-chain migration in new version initializer. For large — off-chain script with batched transactions.
3. Staging deploy
Always test upgrade on testnet fork of real mainnet state:
# Fork mainnet with real contract state
anvil --fork-url $MAINNET_RPC --fork-block-number latest
# Deploy new implementation and upgrade
forge script UpgradeScript --fork-url http://localhost:8545
Verify all storage slots remain correct, old data reads properly, new functionality works.
4. Multisig + Timelock chain
Production upgrade goes through: proposal in multisig → delay in Timelock → execution. Minimum timelock for upgrade — 48 hours. Community and auditors can check new implementation.
Support and monitoring
After deployment set monitoring via Tenderly Alerts or OpenZeppelin Defender Sentinel: notifications on large transactions, unusual call patterns, key variable changes.
For critical events (pause, owner change, large withdrawal) — immediate alerts to Telegram/PagerDuty.
Typical maintenance retainer: monitoring + priority incident response + quarterly codebase review for new vulnerabilities. Parameters and cost individual.
Common upgrade mistakes
Forget to call parent contract __init in new initializer. OpenZeppelin contracts with Initializable require initializer chain call on each upgrade via reinitializer(N). Skipping initialization of AccessControl or ERC20 causes role loss or incorrect state.
Upgrade without testnet check. "We only added view function" — yet storage layout changed due to inherited contract.
No rollback plan. If upgrade goes wrong, need to return to previous implementation. Possible in Transparent and UUPS proxy if old implementation address saved. Ensure this address in deploy history.







