Remediating Constructor State in Upgradeable Contracts
How to move state initialization from constructors to initializer functions in proxy-based upgradeable contracts.
Remediating Constructor State in Upgradeable Contracts
Overview
Related Detector: Constructor State
When an upgradeable contract sets state in its constructor, that state exists only in the implementation contract’s storage. The proxy’s storage — where all user interactions occur via delegatecall — never receives these values. The fix is to move all state initialization to an initialize() function and call _disableInitializers() in the constructor.
Recommended Fix
Move State to an Initializer Function
// BEFORE: State set in constructor -- invisible to proxy
contract TokenV1 {
address public owner;
string public name;
uint256 public totalSupply;
constructor(string memory _name, uint256 _supply) {
owner = msg.sender;
name = _name;
totalSupply = _supply;
}
}
// AFTER: State set in initializer -- written to proxy storage
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract TokenV1 is Initializable {
address public owner;
string public name;
uint256 public totalSupply;
constructor() { _disableInitializers(); }
function initialize(string memory _name, uint256 _supply) external initializer {
owner = msg.sender;
name = _name;
totalSupply = _supply;
}
}
Alternative Mitigations
Use OpenZeppelin Upgradeable Variants
Replace standard OpenZeppelin imports with their upgradeable equivalents:
// BEFORE: standard OZ (uses constructors internally)
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// AFTER: upgradeable OZ (uses initializers internally)
import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
contract TokenV1 is ERC20Upgradeable {
constructor() { _disableInitializers(); }
function initialize(string memory name, string memory symbol, uint256 supply)
external initializer
{
__ERC20_init(name, symbol);
_mint(msg.sender, supply);
}
}
Immutable Variables for Constants
Values that never change can use Solidity immutable variables, which are stored in the bytecode (not storage) and are visible to the proxy:
contract VaultV1 is Initializable {
// Immutable -- stored in bytecode, visible to proxy
IERC20 public immutable asset;
constructor(IERC20 _asset) {
asset = _asset; // OK: immutable is in bytecode
_disableInitializers();
}
function initialize() external initializer {
// Mutable state goes here
}
}
Common Mistakes
Mistake: Inheriting Non-Upgradeable Base Contracts
// WRONG: Ownable uses a constructor internally
import "@openzeppelin/contracts/access/Ownable.sol";
contract VaultV1 is Ownable, Initializable {
constructor() Ownable(msg.sender) { // Owner set in implementation storage
_disableInitializers();
}
}
Use OwnableUpgradeable and call __Ownable_init() inside initialize().
Mistake: Forgetting to Disable Initializers
contract VaultV1 is Initializable {
// Missing: constructor() { _disableInitializers(); }
function initialize() external initializer { /* ... */ }
}
// Anyone can call initialize() directly on the implementation contract
Always call _disableInitializers() in the constructor.
Mistake: Using constructor Parameters for Proxy State
constructor(address _oracle) {
oracle = _oracle; // Only in implementation storage
}
Constructor parameters for mutable state must be moved to initialize() parameters.