Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

design doc for 'Always using _disableInitializers() in constructors' #144

Merged
merged 2 commits into from
Nov 4, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions protocol/disableInitializers-in-constructor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Always using `_disableInitializers()` in constructors

## Context

Currently, most OP Stack proxied contracts prevent direct calls to their `initialize` functions on the implementation contract by calling it with default parameter values in the constructor. Additionally, these contracts do not expose a public `reinitialize` function to prevent reinitialization attempts.

## Problem Statement

So far this does the job but has a few drawbacks, namely:

- It's a manual process (per contract) given that `initialize` function's input arguments are different for each contract. Also, a change in the `initialize` function's arguments will require updating the call to `initialize` manually.
- If a public `reinitialize` function is added in the future, calling the `initialize` function in the constructor will not prevent reinitialization.
maurelian marked this conversation as resolved.
Show resolved Hide resolved
- It can get verbose for initializers with many arguments. Here's an [example](https://github.com/ethereum-optimism/optimism/blob/36b41baea45f07e8644ff778a1ffd81fb0ad9e77/packages/contracts-bedrock/src/L1/SystemConfig.sol#L152).

## Proposed Solution

A proposed solution is to always use `_disableInitializers()` in the constructor of the implementation contract instead. This will, like the current approach, prevent anyone from calling the `initialize` function directly on the implementation contract but also solves the drawbacks mentioned above.

- It has the benefit of being a one-time manual change for each contract regardless of how many arguments the `initialize` function expects or if it changes in the future.
- If a public `reinitialize` function is used in the future, `_disableInitializers()`, already called in the constructor, prevents reinitialization attempts on the implementation contract.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear to my why the current approach of calling initialize() (as long as it has the initializer modifier on it) is different from calling _disableInitializers()?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The major benefit is that _disableInitializers() is less verbose and does not need modification if initialize parameters change.

But then also, there's a scenario where if reinitializer modifier is used in the future for some reason, the call to initialize in the constructor does not stop the implementation contract from being reinitialized after deployment. This is because initialize sets the initialized storage value to 1 which makes it reinitializable if the public function to do so exists but _disableInitializers() sets this value to type(uint8).max, preventing this entirely.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, I see, thanks for clarifying.

Copy link
Contributor

@Ethnical Ethnical Nov 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because initialize sets the initialized storage value to 1 which makes it reinitializable if the public function to do so exists but _disableInitializers() sets this value to type(uint8).max, preventing this entirely.

What do you mean by this, I don't get it.
Does this means that reinitializer() should check that the slot value is 1 only and should revert if the slot is set to type(uint8).max ?
I don't see it here: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/448efeea6640bbbc09373f03fbc9c88e280147ba/contracts/proxy/utils/Initializable.sol#L152

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works if the slot is 1 but it can't be nested. For the max, it reverts if the version is > type(uint64*).max (which can be set manually via reinitialize or via _disableInitializers()

- It's not verbose regardless of how many arguments the `initialize` function expects.

Another advantage of this approach is that it's a simple change, and it's easy to use Semgrep to find and assert that proxied contracts follow this pattern.

Here are the contract files that would be affected by this change:

- DelayedWETH.sol
- DisputeGame.sol
- DataAvailabilityChallenge.sol
- L1CrossDomainMessenger.sol
- L1ERC721Bridge.sol
- L1StandardBridge.sol
- L2OutputOracle.sol
- OptimismPortal.sol
- OptimismPortal2.sol
- ProtocolVersions.sol
- SuperchainConfig.sol
- SystemConfig.sol
- L2CrossDomainMessenger.sol
- L2ERC721Bridge.sol
- L2StandardBridge.sol

## Alternatives Considered

No other alternatives considered.

## Risks, Uncertainties, and Considerations

### Consideration

- Another rule that might help this proposal is asserting via Semgrep that all `initialize()` functions have an `external` modifier and there's no occurence of `this.initialize()`. This would help in enforcing this rule. Is there any scenario where `initialize()` functions need to be called from within a contract or an inheriting contract?