Solidity Compiler Optimizer Bugs: A 2025 Retrospective
Using Solidity's Yul optimizer reduces gas costs but introduces compiler risk. Optimizer bugs can alter execution logic, making audited source code behave differently once compiled.
1. How Yul Optimizer Bugs Happen
The Yul optimizer reorganizes intermediate representation (IR) assembly instructions. A bug occurs when the optimizer assumes a simplified state that is incorrect under specific edge conditions.
Examples:
- Storage Mapping Corruptions: The optimizer merges storage slots incorrectly, leading to overlapping mapping keys.
- Memory Optimization Corruptions: Out-of-bounds memory cleaning logic getting optimized away.
2. Case Study: Solidity 0.8.13 Yul Optimizer Bug
In 2024, a major optimizer bug was identified affecting compilation under Solidity versions 0.8.13 to 0.8.15 when using Yul IR optimization.
The Mechanics:
The optimizer assumed that calling an inline assembly instruction mstore with variables did not alter the memory layouts of local solidity variables. However, under high optimization runs, the optimizer discarded memory cleanup instructions, allowing stale data to corrupt storage writes.
// Vulnerable Code compiled with Yul Optimizer Enabled
contract CompilerBugDemo {
mapping(uint256 => uint256) public balances;
function resetBalance(uint256 _id) public {
uint256[] memory temp = new uint256[](1);
temp[0] = 0;
// Optimizer incorrectly assumes temp array write is dead code
// and optimizes it away, resulting in mapping memory corruption
balances[_id] = temp[0];
}
}
3. Bytecode Verification Best Practices
To protect against compiler risks:
- Compare Bytecode: Verify target bytecode matches across different optimization levels.
- Decompile Bytecode: Use tools like Panoramix or Ethervm to check decompiled assembly logic.
- Fuzz Compiled Bytecode: Run tests directly on the compiled target rather than local simulation scripts.
4. Risk Factors That Increase Compiler Exposure
Compiler bugs rarely affect plain ERC-20-style code. They cluster around patterns that force Solidity to cross abstraction boundaries:
| Pattern | Why it is risky | Audit response |
|---|---|---|
| Inline assembly | Bypasses type and memory-safety assumptions | Minimize scope and document clobbered memory. |
| ABIEncoderV2-heavy structs | Complex nested memory layouts | Add differential tests across compiler settings. |
viaIR builds | More optimizer passes over Yul IR | Pin compiler patch version and check known-bugs metadata. |
| High optimizer runs | More aggressive transformations | Test at deployment settings, not default settings. |
| Upgradeable storage layouts | Small layout mistakes become persistent corruption | Run storage-layout diff checks before upgrades. |
The highest-risk combination is an upgradeable protocol using inline assembly, custom packed storage, and a high optimizer run count. In that environment, source-level review is incomplete unless the reviewer also inspects emitted bytecode behavior.
5. Differential Testing Against Compiler Settings
Differential testing compiles the same contract under several compiler configurations and runs the same invariant suite against each artifact. The goal is not for bytecode to match; optimized and unoptimized bytecode should differ. The goal is for observable behavior to remain equivalent.
forge test --use 0.8.24 --optimizer-runs 200
forge test --use 0.8.24 --via-ir --optimizer-runs 200
forge test --use 0.8.25 --via-ir --optimizer-runs 200
For protocols with meaningful TVL, we also recommend replaying production calldata traces against candidate bytecode. A compiler regression often appears as a divergence in a rare path, not a unit test failure in common flows.
6. Known-Bugs Metadata Gate
The Solidity compiler publishes known-bugs metadata. CI should fail a release build when the selected compiler version, optimizer setting, EVM version, or viaIR mode intersects with a bug that has medium or high severity for the contract's feature set.
{
"compiler": "0.8.24",
"optimizer": true,
"viaIR": true,
"evmVersion": "cancun",
"blockedSeverities": ["medium", "high"]
}
The important operational detail is to gate on the deployment profile, not the development profile. Many teams test without viaIR and deploy with it enabled for gas savings, which invalidates the test artifact.
7. Assembly Hygiene Rules
When inline assembly is unavoidable, reviewers should require a small local contract between Solidity and assembly:
- Declare which memory slots are read and written.
- Never assume the free memory pointer is unchanged after an assembly block unless restored.
- Avoid writing below
0x80except for documented scratch-space usage. - Do not keep Solidity references to arrays or structs whose memory is mutated in assembly.
- Prefer
memory-safeassembly annotations only when the block genuinely satisfies the compiler's rules.
assembly ("memory-safe") {
let ptr := mload(0x40)
mstore(ptr, value)
mstore(0x40, add(ptr, 0x20))
}
The annotation is a promise to the optimizer. If the promise is false, later optimizer passes may legally transform the surrounding Solidity in ways that look like compiler bugs but are actually undefined behavior caused by the contract.
8. Release Checklist
- Compiler version is pinned exactly, not with a floating pragma.
- Deployment artifact uses the same
viaIR, optimizer, run count, and EVM version tested in CI. - Solidity known-bugs metadata is checked against the release configuration.
- Invariant tests run against optimized bytecode, not only source-level simulations.
- Inline assembly blocks document memory, storage, and returndata assumptions.
- Storage-layout diffs are reviewed for every upgrade.
- Verified source on explorers matches the deployed bytecode hash.
9. Conclusion
Compiler risk is not a reason to avoid optimization entirely. It is a reason to treat the compiler as part of the trusted computing base. Secure teams pin versions, test the exact artifact they deploy, watch known-bugs disclosures, and write assembly as if every undocumented memory assumption will eventually be exploited by an optimizer pass.