Smart Contract Refactoring
A contract written a year ago works, money isn't lost — but each new feature triggers panic: it's unclear what will break. Storage layout ballooned to 30 variables with no grouping logic, functions are 200 lines long, no tests for edge cases. Refactoring a smart contract differs from refactoring regular code in that the cost of a mistake is loss of user funds.
Where Technical Debt Most Often Hides
Unoptimized storage layout. Solidity packs variables into 32-byte slots. If variables are declared as uint128, uint256, uint128 — that's three slots instead of two. On a popular contract with thousands of calls a day, this is real money. We've seen contracts where reordering 8 variables for slot packing reduced gas on write operations by 40%. This isn't optimization for its own sake — these are concrete thousands of dollars in annual user savings.
Unbounded loops as gas griefing vector. Pattern for (uint i = 0; i < users.length; i++) in a contract where users can grow unbounded — this isn't just inefficiency. An attacker adds 10,000 addresses, and the next distribute() call exceeds block limit (30M gas on mainnet). The function becomes unexecutable — contract stuck. Refactoring to a pull pattern with pagination or enumerable mapping solves this structurally.
Reentrancy without guard at cross-function level. ReentrancyGuard from OpenZeppelin protects one function. But if withdraw() is protected and claim() isn't — and both modify the same balance mapping — cross-function reentrancy is possible. This is exactly how the Fei Protocol exploit worked (80M$ in 2022). When refactoring, audit the entire call graph, not just "suspicious" functions.
How We Approach Refactoring
The first step is static analysis via Slither. It finds in 2-3 minutes:
- reentrancy patterns (including cross-function)
- uninitialized variables
- tx.origin authorization
- incorrect operation order (state change after external call)
- shadow variables
Slither produces hundreds of warnings on any real contract — the key is separating critical from informational. Then Mythril for symbolic execution on key functions.
Coverage audit. We check what's tested and what isn't. Usually: happy path is covered, edge cases are not. No test for "what if owner calls this function twice in a row". No test for "what happens to the contract after emergency pause". We add tests via Foundry — its fuzzer finds in an hour what manual Hardhat tests didn't find in a month.
Structural refactoring. We extract logic into libraries (Library pattern), separate storage and logic via Diamond pattern (EIP-2535) if the contract is large, apply Check-Effects-Interactions on every function with external calls. We rewrite Events — incorrectly indexed parameters make The Graph queries inefficient.
Gas optimization. Specific patterns:
-
storage→memoryfor read-only operations within a function -
uint256instead ofuint8in local variables (EVM operates on 256-bit words, downcasting is more expensive) -
unchecked { i++ }in loop counters where overflow is impossible (Solidity 0.8+) -
calldatainstead ofmemoryfor external function parameters - event packing: don't emit unnecessary fields in events
| Pattern | Gas Savings (approximate) |
|---|---|
| Variable slot packing | 20-40% on SSTORE |
| memory instead of storage in function | 15-30% on reads |
| unchecked increment | 60-80 gas per iteration |
| calldata instead of memory | 50-100 gas per argument |
| Custom errors vs require strings | 50-200 gas per revert |
Solidity Version Upgrade
Refactoring often includes migration from 0.6/0.7 to 0.8+. Main changes:
- Arithmetic overflow/underflow checked by default (SafeMath can be removed)
- Custom errors via
errorkeyword — cheaper and more informative thanrevert("string") - Immutable variables — save gas on constants set in constructor
Migration from 0.6 to 0.8 isn't just changing pragma. ABI encoding changed, some assembly patterns stopped working, .call.value() replaced with .call{value:}(). We test each change in isolation.
Work Process
Day 1. Static analysis (Slither, Mythril), coverage report, create issue registry with priorities.
Days 2-3. Refactor by priority: critical security issues → gas optimization → readability. Each PR is an isolated change with tests. No "one big commit with 50 changes".
Final. Run Foundry fuzz tests on refactored functions, compare gas reports before/after via forge snapshot.
Timeline — 2-3 days for contracts up to 500 lines. More complex multi-contract systems — up to a week.







