Organization
- @optimistic-ethereum
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
6 findings
5 fixed
1 acknowledged
Informational
15 findings
15 fixed
0 acknowledged
Medium Risk1 finding
OPContractsManagerMigrator::migrate fails to re-initialize SystemConfig with migrated DelayedWETH proxy address
State
- Fixed
PR #19281
Severity
- Severity: Medium
Submitted by
Giovanni Di Siena
Description
OPContractsManagerMigrator::migratefirst deploys new proxies for theETHLockbox,DisputeGameFactory,AnchorStateRegistry, andDelayedWETHcontracts before initializing through_upgrade()calls. Unlike the other proxies that are obtained within OPCMv2_loadChainContracts()via theOptimismPortal2source address,DelayedWETHandDisputeGameFactoryare obtained from theSystemConfig.While slightly more convoluted, the resolution of
DisputeGameFactoryis correct given that it is obtained directly from theAnchorStateRegistryreference updated within the call toOptimismPortalInterop::migrateToSuperRoots; however,SystemConfigis never re-initialized to reference the newDelayedWETHproxy address. As such, OPCMv2 will continue to reference the legacy pre-migration proxy address which will cause issues for future upgrades. Consider the following scenario:_loadChainContracts()pulls the legacyDelayedWETHcontract fromSystemConfig._apply()rewritesDisputeGameFactorygame args using the legacyDelayedWETHaddress.- Since migration creates a shared
DisputeGameFactoryfor the interop set, this rewrite affects all chains using the shared factory, not just the chain that was upgraded. - All subsequent games will use a different (legacy)
DelayedWETHcontract compared with games created immediately after migration.
Recommendation
Re-initialize
SystemConfigby updating theDELAYED_WETH_SLOTstorage slot.OP Labs
Fixed in PR #19281.
Cantina Managed
Verified. The existing
DelayedWETHfromchainSystemConfigs[0]is now reused rather than deploying a new one.
Low Risk6 findings
Dirty upper bits in SystemConfig::batcherHash will cause upgrades to revert
State
- Acknowledged
PR #19281
Severity
- Severity: Low
Submitted by
Giovanni Di Siena
Description
OPContractsManagerV2::_loadFullConfigdecodes the batcher as anaddress, butSystemConfig::batcherHashis abytes32public variable and so its getter returns raw ABI-encodedbytes32. The dev comment states it is represented as an address left-padded with zeros to 32 bytes; however,SystemConfig::setBatcherHashaccepts arbitrarybytes32from the owner without enforcement. This can result inbatcherHashhaving non-zero upper bytes which will revert the OPCMv2upgrade()call for the chain during this decode step until overridden by the owner.Recommendation
Consider explicitly zeroing out the potentially dirty upper 96 bits.
OP Labs
Acknowledged. We have made the decision that OPCMv2 forcing this to be an address is actually safer behavior because non-address batcher hash is not actually supported in the specification for op-node. Although the variable is
bytes32, it's far safer for OPCMv2 to restrict to the officially supported configuration.Cantina Managed
Acknowledged.
OPContractsManagerMigrator::migrate does not enforce SuperchainConfig version floor
State
- Fixed
PR #19281
Severity
- Severity: Low
Submitted by
Giovanni Di Siena
Description
OPContractsManagerMigratorcurrently only validates that all chains reference the sameSuperchainConfigaddress but, unlikeOPContractsManagerV2::_apply, does not enforce that it is upgraded to the latest version. This can allow migration to proceed with an outdatedSuperchainConfigand potentially unsupported prestate.Recommendation
Consider adding a similar version floor check in
OPContractsManagerMigrator::migrateand revert when the sharedSuperchainConfigis behind the target implementation version.OP Labs
Fixed in PR #19281.
Cantina Managed
Verified. It is now explicitly documented that
migrate()does not enforce aSuperchainConfigversion floor.Initial deployment does not validate startingRespectedGameType against enabled game set
State
- Fixed
PR #19272
Severity
- Severity: Low
Submitted by
Giovanni Di Siena
Description
OPContractsManagerV2restricts the dispute game type of initial deployments toPERMISSIONED_CANNONas enforced within_assertValidFullConfig(); however, it is nowhere validated that_cfg.startingRespectedGameTypeis consistent with this restriction. A caller could passstartingRespectedGameType = CANNON(or any other game type) while onlyPERMISSIONED_CANNONhas an implementation registered in theDisputeGameFactory, initializing theAnchorStateRegistrywith a respected game type that has no corresponding implementation.Recommendation
Consider having
_assertValidFullConfig()also assert thatstartingRespectedGameType == PERMISSIONED_CANNONduring initial deployments.OP Labs
Fixed in PR #19272.
Cantina Managed
Verified.
startingRespectedGameTypeis now validated against enabled game configs such that deployment reverts withOPContractsManagerV2_InvalidGameConfigs()for disabled game types.Invalid game configs can be supplied during migration and enabled state is not respected
State
- Fixed
PR #19281
Severity
- Severity: Low
Submitted by
Giovanni Di Siena
Description
Unlike
OPContractsManagerV2::_assertValidFullConfigwhich validates game types, ordering, and bond constraints,OPContractsManagerMigrator::migrateaccepts arbitrarydisputeGameConfigswithout validation. The only constraint is that the staticcall toOPContractsManagerUtils::getGameImplmust recognize the game type, otherwise it will bubble up a revert for unsupported types.One impact is that non-super game types can be registered even though the invocation of
_makeGameArgs()hardcodesl2ChainIdas 0, meaning thatFaultDisputeGamewill revert due to chain ID mismatch. This logic also ignores thisenabledflag and unconditionally registers each supplied game type in the newDisputeGameFactory, meaning operators performing interoperability migration may unintentionally activate dispute‑game types they believed were disabled.Recommendation
Add explicit validation to reject incompatible configurations containing non-super game types. Additionally ensure that only the explicitly enabled game types are registered within the
DisputeGameFactory.OP Labs
Fixed in PR #19281.
Cantina Managed
Verified. Documentation has been added to explain why migration game config validation is deliberately minimal.
Repeated chain migration clears shared DisputeGameFactory implementations and breaks unmigrated portals
State
- Fixed
PR #19271
Severity
- Severity: Low
Submitted by
Giovanni Di Siena
Description
OPContractsManagerMigrator::migratecurrently assumes it is always operating on a legacy per‑chainDisputeGameFactoryand does not prevent repeated migration. For already migrated portals,portal.disputeGameFactory()resolves to the sharedDisputeGameFactorycorresponding to the sharedAnchorStateRegistry; however, subsequent logic then unconditionally clears several game types before pointing the portal to a newly deployedAnchorStateRegistry. This corrupts the shared factory that other portals continue to use, preventing new games from being created.Recommendation
While it is understood that the migrator logic is not yet production-ready, consider explicitly preventing repeated chain migration.
OP Labs
Fixed in PR #19271.
Cantina Managed
Verified. Documentation has been added to communicate the lack of support for re-migration.
Calls to deploy() can be forced to revert via front-running
Description
In
OPContractsManagerV2, the following functions are meant to be delegate-called by theProxyAdminOwner:upgradeSuperchain()upgrade()migrate()
On the other hand,
deploy()is meant to be called directly via a regular external call. However, the contract does not implement any form of checks to ensure that the functions are called as intended.More importantly,
deploy()calls_loadChainContracts(), which deploys proxy contracts usingcreate2based on a user-provided_l2ChainIdand_saltMixer. For example:if (isInitialDeployment) { // Deploy the ProxyAdmin. proxyAdmin = IProxyAdmin( Blueprint.deployFrom( blueprints().proxyAdmin, _computeSalt(_l2ChainId, _saltMixer, "ProxyAdmin"), abi.encode(address(this)) ) );This introduces a risk of an attacker front-running a user to call
deploy()with the samesaltMixerandl2ChainIdto deploy contracts to the same address (but differentproxyAdminOwner). If this occurs, the victim'sdeploy()transaction revert ascreate2cannot deploy a proxy contract to the same address.Recommendation
Consider the following:
- In
deploy(), hashsaltMixerwithmsg.senderto prevent different callers from deploying contracts to the same address. - Adding a
onlyDelegateCallcheck (which existed in OPCM v1) toupgradeSuperchain(),upgrade()andmigrate().
OP Labs
Fixed in PR #19272.
Cantina Managed
Verified. Upgrade/migration calls are now enforced with
onlyDelegateCall()andmsg.senderhas been added to the deployment salt to prevent cross-caller CREATE2 collisions.
Informational15 findings
Missing NatSpec documentation in VerifyOPCM
State
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
The
OpcmContractRefstruct is currently missing NatSpec@paramdocumentation for theblueprintmember.Recommendation
Update the NatSpec documentation to include the
blueprintboolean.OP Labs
Fixed in commit 5161204.
Cantina Managed
Verified.
Guardian pause can block OPCMv2 upgrades when the OPTIMISM_PORTAL_INTEROP dev feature is enabled
State
- Fixed
PR #19271
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
While it is understood that portal interop is a dev feature that is not yet production ready, when
OPTIMISM_PORTAL_INTEROPis enabled,SystemConfig::setFeaturewithETH_LOCKBOXwill revert if the system is paused at the time of upgrade. This allows a guardian pause to block OPCMv2 upgrades that enable interoperability.Recommendation
Consider whether it is intended for upgrades to revert when the system is in a paused state.
OP Labs
Fixed in PR #19271.
Cantina Managed
Verified. Guardian pause blocking interop upgrades has been explicitly documented as acceptable for a dev feature.
VerifyOPCM::runSingle does not execute the setUp() function
State
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
Unlike
VerifyOPCM::run, the standaloneVerifyOPCM::runSingledoes not execute thesetUp()function but still depends on state referenced within_buildArtifactPath()and_verifyStandardValidatorArgs().Recommendation
Implement the same setup logic within
runSingle()as inrun()to avoid having standalone execution with uninitialized mappings producing incorrect artifact resolution or false verification failures.OP Labs
Fixed in commit 56ee47e.
Cantina Managed
Verified.
VerifyOPCM::_findChar is not used and can be removed
State
- Fixed
PR #19272
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
VerifyOPCM::_findChardoes not currently appear to be used.Recommendation
Consider removing the unused
_findChar()function.OP Labs
Removed in OPCMv1 cleanup. Fixed in PR #19792.
Cantina Managed
Verified. The function is no longer present.
A subset of game types are not cleared during migration
State
- Fixed
PR #19281
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
Types.soldefines additional game types that are not cleared during migration:ASTERISC(2),ASTERISC_KONA(3),OP_SUCCINCT(6),SUPER_ASTERISC_KONA(7),OPTIMISTIC_ZK_GAME_TYPE(10),KAILUA(1337).If any of these had implementations set in the old
DisputeGameFactory, they would remain active post-migration and new games of those types could still be created through the old proxy. While it is understood that this is not currently possible, and also that these old games cannot affect the new system's anchor state, this implementation seem to contradict the dev comment.Recommendation
Consider clearing all possible game type implementations during migration.
OP Labs
Fixed in PR #19281.
Cantina Managed
Verified. It is now explicitly documented that hardcoded game type lists in
_assertValidFullConfig()and_migratePortal()are intentional and must be kept in sync when new types are added.Partial migration can result in loss of access to shared ETHLockbox liquidity for non-migrated portals
State
- Fixed
PR #19271
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
OPContractsManagerMigrator::migratetransfers the full ETH balance of a portal’s currentETHLockboxinto a newly deployed shared lockbox. While it is understood that the current intended use is for N independent chains to be merged into a single chain, if the old lockbox is shared by multiple portals while the migration batch includes only a subset of these chains, the migrator still transfers all pooled liquidity. If, for example, five portals initially share the same lockbox, but only three of these chains are migrated, the remaining two unmigrated chains will not be able to access their share of the pooled liquidity as it has already been transferred to the new shared lockbox.Recommendation
Consider documenting these assumptions and edge cases more clearly.
OP Labs
Fixed in PR #19271.
Cantina Managed
Verified. Documentation has been added to communicate the lack of support for partial migration.
Duplicate upgrade instructions can cause silent key shadowing
State
- Fixed
PR #19272
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
OPContractsManagerV2::_assertValidUpgradeInstructionsvalidates each instruction individually but does not reject duplicates. The only currently permitted instruction is{PermittedProxyDeployment, DelayedWETH}which is a completely different key namespace from the config override keys (overrides.cfg.*) used by_loadBytes(). Duplicates of this instruction are harmless sine_loadOrDeployProxy()checks existence andgetInstructionByKey()returns the first match. However, if a future version permits an instruction whose key overlaps with a_loadBytes()override key, duplicate entries with the same key but different data would silently shadow each other, with only the first taking effect.Recommendation
Consider rejecting duplicate keys as a defensive measure before new instruction types are added.
OP Labs
Fixed in PR #19272.
Cantina Managed
Verified. Upgrades now revert with
OPContractsManagerV2_DuplicateUpgradeInstruction()when duplicate non-PermittedProxyDeploymentinstruction keys are provided.OPContractsManagerMigrator should pass the correct AddressManager to proxy deployment args
State
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
The assumption within
OPContractsManagerMigrator::migratethat the actualaddressManageraddress is not required as part of theproxyDeployArgsis true for the current implementation, althoughOPContractsManagerUtils::loadOrDeployProxydoes require it for theL1CrossDomainMessengerlegacyResolvedDelegateProxyspecial case. While it is not currently relevant, given that this proxy is not migrated, it is worth calling out as an example of how the use of fixed named instructions (ETHLockbox,DisputeGameFactory,AnchorStateRegistry,DelayedWETH) could be preferred overPERMIT_ALL_CONTRACTS_INSTRUCTION. The current usage is fail-open, so if another_loadOrDeployProxy()call that overlooks this requirement is later added to the migrator, deployment of a potentially misconfigured proxy would be automatically permitted.Recommendation
Consider passing the real
AddressManageraddress to the proxy deployment args.OP Labs
Fixed in commit 56ee47e.
Cantina Managed
Verified. The real
AddressManageris now passed.Inaccurate PERMIT_ALL_CONTRACTS_INSTRUCTION comment
State
- Fixed
PR #19271
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
The inline documentation relating to the
PERMIT_ALL_CONTRACTS_INSTRUCTIONconstant currently states that this special value is only used for deployments, however this is inaccurate since it is also used during migration.Recommendation
Consider updating the comment or, perhaps more preferably, restricting usage within migration.
OP Labs
Fixed in PR #19271.
Cantina Managed
Verified. The comment has been updated to indicate use for both initial deployments and migrations.
Shared migration anchors governance configuration to chain 0
State
- Fixed
PR #19271
Severity
- Severity: Informational
Submitted by
Giovanni Di Siena
Description
When migrating N chains, the new shared
ETHLockbox,AnchorStateRegistry, andDelayedWETHare all initialized with the first chain'sSystemConfig. Validation checks that all chains share the sameproxyAdmin().owner()but not the sameproxyAdmin(). It is acknowledged by an inline comment that different chains may have differentProxyAdmincontracts, although it is assumed to be fine so long as the ownership validation holds.That said, if the first chain's
SystemConfigreferences a differentProxyAdminthan another chain's, the access control on the shared contracts would be anchored to only one chain'sProxyAdmin. DifferentProxyAdminswith the same owner would pass validation but could diverge post-migration, for example if ownership of one is transferred. Furthermore, theSystemConfigaddress of migrated portals remains the same, meaning that it could point to a different address from theSystemConfigaddress used by the sharedEthLockboxandAnchorStateRegistry.Recommendation
Consider refactoring, or at least clearly documenting, this fragile coupling between
ProxyAdminowners andSystemConfigaddresses for migrated chains. For example, if differentProxyAdminowners andSystemConfigaddresses must be supported, then it may be preferable to define a canonical governance owner andSystemConfigaddress for shared contracts rather than implicitly relying on the zeroth element.OP Labs
Fixed in PR #19271.
Cantina Managed
Verified. The intentional use of
chainSystemConfigs[0]for shared contracts has been explicitly documented.loadBytes() returns empty bytes instead of reverting for addresses with no code
Description
In
OPContractsManagerUtils,loadBytes()performs a low-level staticcall to the_sourceaddress and returnsresultis the call is successful:// Otherwise, load the data from the source contract.(bool success, bytes memory result) = address(_source).staticcall(abi.encodePacked(_selector));if (!success) { revert OPContractsManagerUtils_ConfigLoadFailed(_name);}However, if
_sourceis an address with no code,loadBytes()doesn't revert but instead returns an emptyresultbytes. This could cause issues when usingloadBytes()to fetch values from a_sourcewhich has not been deployed.Note that this is not an issue in the current implementation since the result of
loadBytes()is always passed toabi.decode(), which would always revert for empty bytes.Recommendation
Consider checking that the
_sourceaddress has code as well.OP Labs
Fixed in PR #19272.
Cantina Managed
Verified.
loadBytes()now reverts withOPContractsManagerUtils_ConfigLoadFailed()if the address has no code.AnchorStateRegistry.isGameRegistered() no longer explicitly checks a dispute game's ASR address
Description
In the previous version of
AnchorStateRegistry(i.e.op-contracts/v6.0.0-rc.2),isGameRegistered()checked that_gameusesaddress(this)as itsAnchorStateRegistry, which invalidates all games when a newAnchorStateRegistryis deployed:// Return whether the game is factory registered and uses this AnchorStateRegistry. We// check for both of these conditions because the game could be using a different// AnchorStateRegistry if the registry was updated at some point. We mitigate the risks of// an outdated AnchorStateRegistry by invalidating all previous games in the initializer of// this contract, but an explicit check avoids potential footguns in the future.return address(factoryRegisteredGame) == address(_game) && asr == address(this);This check no longer exists in the current
AnchorStateRegistry. While removing this check is safe sinceretirementTimestampis set toblock.timestampininitialize(), which would invalidate all old dispute games, consider keeping this check to explicitly invalidate old games.Recommendation
Consider if the check should be re-added.
OP Labs
Fixed in PR #19281.
Cantina Managed
Verified. Explicit documentation of the theoretical risk has been added.
Potential footgun when re-initialize ETHLockbox with only its own OptimismPortal
Description
In
OPContractsManagerV2._apply(), a chain'sethLockboxis initialized with only its ownOptimismPortalas theportalsargument:IOptimismPortal[] memory portals = new IOptimismPortal[](1);portals[0] = _cts.optimismPortal;_upgrade( _cts.proxyAdmin, address(_cts.ethLockbox), impls.ethLockboxImpl, abi.encodeCall(IETHLockbox.initialize, (_cts.systemConfig, portals)));However, this is a potential footgun which could very easily introduce a bug when upgrading the
EthLockboximplementation in the future. For example:- If an
authorizedPortals[_portal] == falsesanity check was added inETHLockbox._authorizePortal(), the call here would revert. - What if the existing lockbox has multiple portals authorized? If
ETHLockBox.initialize()expects all authorized lockboxes to be passed into it, this argument would be wrong.
In general, the OPCM introduces the assumption that re-initializing
ETHLockboxwith only its own portal does not have any side-effects, which may not always be true in the future.Note that this is not an issue in the current implementation.
Recommendation
An implementation which may be safer would be to fetch all existing portals from
ETHLockboxand pass them intoinitialize().Alternatively, consider adding a warning in
ETHLockboxabout this potential footgun.OP Labs
The triage decision was to add review rules (not contract code changes) to catch this class of issue —
initialize()functions that are not safe to re-run because they increment counters, append to arrays, make non-idempotent external calls, or overwrite variables that other systems depend on. Fixed in PR #19273.Cantina Managed
Verified. Sufficient documentation has been added.
- If an
OPContractsManagerV2.migrate() should only be callable in testing environments
Description/Recommendation
In the current version of the OPCM, migration functionality (i.e.
OPContractsManagerV2.migrate()is in development and isn't production-ready yet; it currently exists for testing out interop basics in integration tests and devnets.As such,
migrate()should explicitly check that the contract is running in a testing environment to prevent it from being called onchain. This can be achieved by makingOPContractsManagerContainer._isTestingEnvironment()public and checking it.OP Labs
Fixed in PR #19285.
Cantina Managed
Verified.
OPContractsManagerMigrator.migrate()now reverts withOPContractsManagerMigrator_InteropNotEnabled()if theOPTIMISM_PORTAL_INTEROPdev feature is not enabled in the contracts container.Potential risk with StorageSetter upgrade pattern
Description
In
OPContractsManagerUtils.upgrade(), contracts are upgraded with the following process:- Upgrade the proxy's implementation to
StorageSetter - Zero out the contract's
_initializedslot - Upgrade the proxy to its actual implementation
The contract's initialized slot is zeroed out as shown:
// Otherwise, we need to reset the initialized slot and call the initializer.// Reset the initialized slot by zeroing the single byte at `_offset` (from the right).bytes32 current = IStorageSetter(_target).getBytes32(_slot);uint256 mask = ~(uint256(0xff) << (uint256(_offset) * 8));IStorageSetter(_target).setBytes32(_slot, bytes32(uint256(current) & mask));As seen from above, only one byte of the entire slot is zeroed out. As such, if
_initialized(i.e. the initialized value) is ever greater than 255, this will not work since only one byte is zeroed out.However, note that this isn't an issue in practice since all L1 contracts seem to inherit Openzeppelin v4.7.0 which has
_initializedasuint8; only some L2 contracts inherit Openzeppelin v5 which usesuint64.Recommendation
For future upgrades, ensure L1 contracts always inherit
Initializablefrom Openzeppelin v4.7.0.OP Labs
Fixed in PR #19286.
Cantina Managed
Verified.
OPContractsManagerUtils.upgrade()is now forward-compatible with OpenZeppelin v5'sInitializablestorage layout by also clearing the ERC-7201 namespaced slot and everts withOPContractsManagerUtils_InitializingDuringUpgrade()if the_initializingbool is set, since a contract should never be mid-initialization during an upgrade.However, the following issues were also identified:
_offsetis auint8, so it can be0..255, but a storage slot only has byte offsets0..31._offset >= 32can silently fail to clear the intended initializer byte.- The v5
_initializingcheck happens after a storage write. If_slot == ozV5Slotand_offset == 8, the generic byte clear can zero the OZ v5_initializingbyte before the check runs, making the check ineffective. Avoid running the generic one-byte clear if_slot == ozV5Slot– let it be handled by the v5-specific low-64-bit clear below. - The hardcoded OZ v5 slot only supports the default OZ v5 layout. The incorrect slot will be cleared if
_initializableStorageSlot()is overridden.
OP Labs
Fixed in PR #20289 and PR #20371.
Cantina Managed
Verified. The
Initializableupgrade limitations have been explicitly documented.- Upgrade the proxy's implementation to