Organization
- @optimistic-ethereum
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Low Risk
6 findings
5 fixed
1 acknowledged
Informational
9 findings
1 fixed
8 acknowledged
Gas Optimizations
2 findings
0 fixed
2 acknowledged
Low Risk6 findings
Missing CGT predeploy mirrors
Severity
- Severity: Low
Submitted by
r0bert
Description
Two Go mirrors of the predeploy address table were never updated when the custom gas token contracts were introduced.
op-service/predeploys/addresses.gostill enumerates only the legacy constants andinit()omits every CGT/superchain predeploy, so downstream tooling that depends on this package can neither discover nor label the new contracts. The same omission exists indevnet-sdk/contracts/constants/constants.go, whose exportedtypes.Addressvalues also stop atSuperchainTokenBridge. Tools that rely on either package like theop-servicestate dump and metadata handlers, tx-intent builders, and the devnet SDK registry, treat the new deployments as nonexistent, leaving custom-gas-token flows unusable even though the Solidity predeploys exist. The trimmed snippet below shows the stale address table in both mirrors:const ( L2ToL1MessagePasser = "0x4200000000000000000000000000000000000016" ... SuperchainTokenBridge = "0x4200000000000000000000000000000000000028" Create2Deployer = "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2" ... EntryPoint_v070 = "0x0000000071727De22E5E9d8BAf0edAc6f37da032")Neither file defines
OptimismSuperchainERC20Factory (0x4200000000000000000000000000000000000026),OptimismSuperchainERC20Beacon (0x4200000000000000000000000000000000000027),NativeAssetLiquidity (0x4200000000000000000000000000000000000029), orLiquidityController (0x420000000000000000000000000000000000002a)and thePredeploysmap never inserts them, so the entire CGT stack is invisible to any consumer of these mirrors.Recommendation
Add the four missing address constants to both
op-service/predeploys/addresses.goanddevnet-sdk/contracts/constants/constants.go, then extend thePredeploysmap to include each new predeploy so every tooling layer remains consistent withPredeploys.sol.OP Labs: Fixed in fd6865e9.
Cantina: Fix verified.
CGT mode strands donateETH funds
State
Severity
- Severity: Low
Submitted by
r0bert
Description
When Custom Gas Token mode is enabled the portal defends against value-bearing withdrawals by rejecting any transaction that finalizes with nonzero ETH, yet the
donateETHhook keeps accepting deposits that never forward to L2. This directly violates the Custom Gas Token spec, which states thatdonateETHMUST revert wheneverisCustomGasToken()is true andmsg.value > 0(https://github.com/defi-wonderland/specs/blob/sc-feat/custom-gas-token/specs/protocol/custom-gas-token/optimism-portal.md#donateeth). The relevant implementation is:function donateETH() external payable { // Intentionally empty.}and the finalization path enforces the following gate whenever CGT is active:
function finalizeWithdrawalTransactionExternalProof( Types.WithdrawalTransaction memory _tx, address _proofSubmitter) public{ _assertNotPaused(); if (_isUsingCustomGasToken()) { if (_tx.value > 0) revert OptimismPortal_NotAllowedOnCGTMode(); } ...}As soon as CGT mode is switched on, ETH can still be pushed into the portal by calling
donateETH, but any attempt to bridge that value out will revert during finalization because_tx.valueis greater than zero. The deposited ether has no escape hatch, so every donation made under CGT effectively strands funds in the contract and inflates its balance without a supported withdrawal mechanism. In practice this produces permanent trapped value.Recommendation
Consider blocking
donateETHwhile CGT mode is active.OP Labs: Fixed in abad267.
Cantina: Fix verified.
Missing zero-value validation in burn() function
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Sujith S
Description
The
burn()function in theLiquidityController.solcontract does not include validation to prevent zero-value burns. This contradicts the spec, which states that the function must revert when msg.value is zero.Recommendation
Consider validating and reverting to zero value burns:
function burn() external payable { ....+ if (msg.value == 0) revert LiquidityController_InvalidAmount(); ....}OP Labs: Acknowledged.
OptimismPortal2 lacks isCustomGasToken flag
State
Severity
- Severity: Low
Submitted by
r0bert
Description
The Custom Gas Token specification mandates that
OptimismPortal2owns a nativeisCustomGasTokenboolean which is set duringinitializeand exposed through a public getter so external components can attest to the chain’s mode (https://github.com/defi-wonderland/specs/blob/sc-feat/custom-gas-token/specs/protocol/custom-gas-token/optimism-portal.md). InOptimismPortal2the initializer only stores theSystemConfigandAnchorStateRegistryand every caller is forced to infer the mode indirectly via_isUsingCustomGasToken()which just forwards tosystemConfig.isFeatureEnabled(Features.CUSTOM_GAS_TOKEN). There is no portal-local storage or publicisCustomGasToken()function, so any consumer following the spec cannot read the flag from the portal. This is a direct violation of the spec and breaks compatibility for tooling that depends on querying the portal for the flag.Recommendation
Introduce a dedicated
bool private isCustomGasToken_;storage slot onOptimismPortal2, set it duringinitialize(or migration) based on the deployment configuration and expose the requiredfunction isCustomGasToken() external view returns (bool)that returns that slot. Keep_isUsingCustomGasToken()as an internal helper if desired but ensure the portal presents the stateful getter expected by the spec.OP Labs: Fixed in cdc2aa0.
Cantina: Fix verified.
NativeAssetLiquidity lacks fund() function
State
Severity
- Severity: Low
Submitted by
r0bert
Description
The Custom Gas Token predeploy spec requires
NativeAssetLiquidityto expose a standalonefund()function callable by any address, reverting whenmsg.value == 0and emittingLiquidityFundedso operators can seed the pool during fresh deployments or migrations (https://github.com/defi-wonderland/specs/blob/sc-feat/custom-gas-token/specs/protocol/custom-gas-token/predeploys.md#fund). The implementation only providesdeposit()andwithdraw()and never declares either thefund()entry point or the accompanyingLiquidityFundedevent. As a result there is no supported on-chain primitive to pre-fund the liquidity vault, making migration procedures described in the spec impossible and violating the spec.Recommendation
Add the missing
function fund() external payablethat reverts on zero value, accepts arbitrary callers and emitsLiquidityFunded(msg.sender, msg.value)before crediting the contract balance. This ensures the predeploy matches the spec and enables operators to seed liquidity safely.OP Labs: Fixed in 3f85e88.
Cantina: Fix verified.
LiquidityController events off-spec
State
Severity
- Severity: Low
Submitted by
r0bert
Description
Per the Custom Gas Token spec,
LiquidityControllermust emitMinterAuthorized(minter, authorizer)andMinterDeauthorized(minter, deauthorizer)so governance actions can be audited, and the mint/burn lifecycle must useAssetsMinted/AssetsBurned(https://github.com/defi-wonderland/specs/blob/sc-feat/custom-gas-token/specs/protocol/custom-gas-token/predeploys.md#events).The implementation instead defines
event MinterAuthorized(address indexed minter)andevent MinterDeauthorized(address indexed minter)and emits them without authorizer context, while minting/burning emitsLiquidityMintedandLiquidityBurned. Tooling expecting the spec events never receives authorizer addresses or the canonical event names, preventing reliable on-chain auditing and not respecting the documented interface.Recommendation
Refactor the events to match the spec by declaring
event MinterAuthorized(address indexed minter, address indexed authorizer)/event MinterDeauthorized(address indexed minter, address indexed deauthorizer)and emitting the caller as the authorizer when minters are added or removed and rename/align the mint/burn events toAssetsMintedandAssetsBurnedwith the same parameter structure as specified.OP Labs: Fixed in 573fdb3.
Cantina: Fix verified.
Informational9 findings
Split CGT flags can cause locked funds
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
OptimismPortal2rejects any transaction with value whensystemConfig.isFeatureEnabled(Features.CUSTOM_GAS_TOKEN)is true, yetL2ToL1MessagePasserCGTonly blocks value-bearing withdrawals whenIL1Block(Predeploys.L1_BLOCK_ATTRIBUTES).isCustomGasToken()has already been flipped. The L2 withdrawal gate looks like:function initiateWithdrawal(address _target, uint256 _gasLimit, bytes memory _data) public payable override { if (IL1Block(Predeploys.L1_BLOCK_ATTRIBUTES).isCustomGasToken() && msg.value > 0) { revert L2ToL1MessagePasserCGT_NotAllowedOnCGTMode(); } super.initiateWithdrawal(_target, _gasLimit, _data);}The
SystemConfigtoggle is switched on governance’s L1 call:function setFeature(bytes32 _feature, bool _enabled) external { if (_enabled == isFeatureEnabled[_feature]) revert SystemConfig_InvalidFeatureState(); isFeatureEnabled[_feature] = _enabled; emit FeatureSet(_feature, _enabled);}while the depositor separately flips the L2 flag once inside
L1BlockCGT:function setCustomGasToken() external { require(msg.sender == Constants.DEPOSITOR_ACCOUNT); require(isCustomGasToken() == false); assembly { sstore(IS_CUSTOM_GAS_TOKEN_SLOT, 1) }}Because the transactions are independent, the portal guard can activate before the L2 gate (blocking value withdrawals only after they reach L1), or the L2 gate can remain active after the portal is turned off. In the first case users submit withdrawals with
msg.value > 0, they pass the L2 check, but later revert forever insideOptimismPortal2:if (_isUsingCustomGasToken()) { if (_tx.value > 0) revert OptimismPortal_NotAllowedOnCGTMode();}In the second case the portal accepts value deposits again while L2 still rejects them, making ETH one-way to L2. Any mismatch traps user funds until governance re-aligns both flags.
Recommendation
Pause both
OptimismPortal2andL2ToL1MessagePasserCGT, wait until there are no inflight deposits or withdrawals, then enable CGT mode by having L1 governance callSystemConfig.setFeature(Features.CUSTOM_GAS_TOKEN, true)and the depositor account callL1BlockCGT.setCustomGasToken()on the L1_BLOCK_ATTRIBUTES predeploy, and finally unpause. This orchestrated sequence keeps the two toggles aligned and avoids permanently stranding ETH during the feature transition.OP Labs: Acknowledged. Chain operators should not change the L1 flag once set.
CLI truncates large init bond values
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
The interop migration CLI reads
--initial-bondwithcliCtx.Uint64and immediately passes the result throughbig.NewInt(int64(...))(op-deployer/pkg/deployer/manage/migrate.go). The flag itself is defined as a string to support values wider than 64 bits yet the current handling forces it back into a signed 64-bit window. Any bond equal to or above 2^63 wei (9.223372036854775808 wei ≈ 9.22 ETH) wraps negative when cast toint64, so the subsequent ABI encoder rejects it and the migration run reverts. The shipping default is 1 ETH:InitialBondFlag = &cli.StringFlag{ Name: "initial-bond", Usage: "Initial bond amount required for the dispute game (value as string, in wei). Defaults to 1 ETH.", EnvVars: deployer.PrefixEnvVar("INITIAL_BOND"), Value: "1000000000000000000",}so a modest governance decision to raise the bond just an order of magnitude, well within operational expectations, would immediately hit this overflow. On-chain governance can set larger bonds (e.g. via
OPContractsManagerInteropMigrator.migratecallingDisputeGameFactory.setInitBond), but any attempt to do so through theop-deployer manage migrateCLI helper fails before the transaction is even sent.Recommendation
Parse the flag into a
*big.Intwithout narrowing conversions—e.g. reuse the existingcliutil.BigIntFlaghelper from the add-game-type path or callnew(big.Int).SetString(...)on the string value and validate it is non-negative before passing it toInteropMigrationInput.InitBond.OP Labs: Acknowledged.
LegacyERC20 name lookup reverts
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
Predeploys.getNamefirst enforces that every queried address lives inside the 0x4200… namespace and only then compares it against the known predeploy constants. The helper looks like:function getName(address _addr) internal pure returns (string memory out_) { require(isPredeployNamespace(_addr), "Predeploys: address must be a predeploy"); if (_addr == LEGACY_MESSAGE_PASSER) return "LegacyMessagePasser"; ... if (_addr == LEGACY_ERC20_ETH) return "LegacyERC20ETH"; ... revert("Predeploys: unnamed predeploy");}but
LEGACY_ERC20_ETHlives at0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000, outside the namespace thatisPredeployNamespaceallows. As a result the guard reverts before ever hitting the branch that would return“LegacyERC20ETH”, and any tooling that loops through the exported predeploy constants like labeling scripts, deployment metadata generators, monitors... crashes when it reaches the legacy token address. The same issue affectsOPTIMISM_SUPERCHAIN_ERC20(also outside of 0x4200…).Recommendation
Special-case the out-of-namespace predeploys before asserting the namespace (or relax the predicate for those constants) so both
LEGACY_ERC20_ETHandOPTIMISM_SUPERCHAIN_ERC20can be labeled without reverting. Add a regression test that callsgetNamefor every exported predeploy constant to guarantee future additions remain reachable.OP Labs: Acknowledged.
CLI can’t enable V2 dispute games
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
op-deployer bootstrap implementationsnow requires fourfault-game-*parameters wheneverDeployV2DisputeGamesis set in the dev-feature bitmap, but the CLI no longer exposes flags or env vars that populate those fields.ImplementationsConfigexpects:type ImplementationsConfig struct { DevFeatureBitmap common.Hash `cli:"dev-feature-bitmap"` FaultGameMaxGameDepth uint64 `cli:"fault-game-max-game-depth"` FaultGameSplitDepth uint64 `cli:"fault-game-split-depth"` FaultGameClockExtension uint64 `cli:"fault-game-clock-extension"` FaultGameMaxClockDuration uint64 `cli:"fault-game-max-clock-duration"`}and its validator aborts when the bitmap enables V2 dispute games but any of the required fields remain zero:
if deployer.IsDevFeatureEnabled(c.DevFeatureBitmap, deployer.DeployV2DisputeGames) { if c.FaultGameMaxGameDepth == 0 { return errors.New("fault game max game depth must be specified when V2 dispute games feature is enabled") } ...}However,
bootstrap/flags.godropped the olddispute-*flags without adding replacements using the new names, socliutil.PopulateStructcan never populate thefault-game-*fields. Any operator who sets the V2 bit runs the documented command, the config fails validation, and the tool exits before deploying the contracts. This regression DoS’s the CLI path for rolling out V2 dispute games until the flags return.Recommendation
Reintroduce CLI (and env) flags for
fault-game-max-game-depth,fault-game-split-depth,fault-game-clock-extension, andfault-game-max-clock-durationso the struct receives non-zero values when V2 is enabled. Update docs and helper scripts to use the new flag names, and add a regression test that ensures everycli:"..."struct tag inImplementationsConfighas a corresponding CLI flag.OP Labs: Acknowledged.
Redundant indexed parameter caller in events
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
NativeAssetLiquiditycontract emits LiquidityDeposited and LiquidityWithdrawn events with an indexed caller parameter. However, both thedeposit()andwithdraw()functions enforce that the msg.sender must be Predeploys.LIQUIDITY_CONTROLLER, otherwise the transaction reverts:function deposit() external payable { if (msg.sender != Predeploys.LIQUIDITY_CONTROLLER) revert NativeAssetLiquidity_Unauthorized(); emit LiquidityDeposited(msg.sender, msg.value); // msg.sender is always LIQUIDITY_CONTROLLER} function withdraw(uint256 _amount) external { if (msg.sender != Predeploys.LIQUIDITY_CONTROLLER) revert NativeAssetLiquidity_Unauthorized(); // ... emit LiquidityWithdrawn(msg.sender, _amount); // msg.sender is always LIQUIDITY_CONTROLLER}Since the caller parameter will always be the same constant value (Predeploys.LIQUIDITY_CONTROLLER), including it as an indexed parameter in the events provides no filtering utility and wastes gas.
Recommendation
Remove the redundant caller parameter from both events since it provides no variable information.
OP Labs: Acknowledged.
CGT override bypasses intent validation
Severity
- Severity: Informational
Submitted by
r0bert
Description
IntentTypeStandarddeployments assume ETH-native behavior and reject custom gas token configs during validation. The check inop-deployer/pkg/deployer/state/intent.goexplicitly fails ifchain.CustomGasToken.Enabledis true:if chain.CustomGasToken.Enabled { return fmt.Errorf("%w: chainId=%s custom gas token must be disabled for standard chains", ErrNonStandardValue, chain.ID)}ensuring standard chains launch without CGT support. However, once per-chain overrides are merged,
calculateL2GenesisOverridesrewrites the validated intent whenever CGT was originally disabled:// op-deployer/pkg/deployer/pipeline/l2genesis.go:157-164// If CustomGasToken is not enabled, update it with override valuesif !thisIntent.CustomGasToken.Enabled { thisIntent.CustomGasToken = state.CustomGasToken{ Enabled: overrides.UseCustomGasToken, Name: overrides.GasPayingTokenName, Symbol: overrides.GasPayingTokenSymbol, InitialLiquidity: overrides.NativeAssetLiquidityAmount, }}Overrides are merged after all validation completes and no subsequent
ChainIntent.Check()call re-runs. A simple JSON override (e.g.,{"useCustomGasToken": true, "gasPayingTokenName": "...", ...}) therefore mutates the chain to CGT mode even when the signed intent, code review and standard template all forbade it. The deployer only needs to run the normal apply command:op-deployer apply \ --intent standard-intent.toml \ --overrides overrides/devnet.json \ --l1-rpc $L1_RPC \ --wallet $DEPLOYER_KEYIf that override happens to flip
useCustomGasToken, the generated genesis now installsLiquidityController/NativeAssetLiquidity, blocks L1 ETH deposits and requires the special liquidity runbooks, yet operators, tooling and users still believe they launched a standard ETH chain. This mismatch can strand deposits, reject withdrawals and break integrations that were never configured for CGT.Recommendation
Treat override-driven CGT toggles as a high-risk change. After merging overrides, re-run
thisIntent.Check()(or at least the portion that enforcesCustomGasToken.Enabled == falsefor standard configs) before proceeding and fail if the override flips the flag. Alternatively, explicitly forbiduseCustomGasTokeninside both global and per-chain override files unless the base intent already opted into CGT mode. This keeps the reviewed intent as the single source of truth and prevents accidental or malicious overrides from silently changing the chain’s economic model.OP Labs: Fixed in 2e44c5f.
Cantina: Fix verified.
Superchain Registry queries invalid CGT ABIs
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
The Superchain Registry report generator (Go code under
packages/contracts-bedrock/lib/superchain-registry/ops/internal/report/) builds anL1Reportby issuing batched RPC calls to the deployedSystemConfigproxy. Once the release tag exceedsSemver160, it unconditionally appends calls forgasPayingToken(),gasPayingTokenName()andgasPayingTokenSymbol():// packages/contracts-bedrock/lib/superchain-registry/ops/internal/report/l1.gocalls = append( calls, …, makeBatchCall(isCustomGasTokenABI, &report.IsGasPayingToken), BatchCall{ To: addr, // SystemConfig proxy Encoder: func() ([]byte, error) { return gasPayingTokenABI.EncodeArgs() }, Decoder: func(rawOutput []byte) error { return gasPayingTokenABI.DecodeReturns(rawOutput, &report.GasPayingToken, &report.GasPayingTokenDecimals) }, }, makeBatchCall(gasPayingTokenNameABI, &report.GasPayingTokenName), makeBatchCall(gasPayingTokenSymbolABI, &report.GasPayingTokenSymbol),)Those ABIs are defined in
bindings.gobut rely on the called contract actually implementing the selectors.SystemConfigdoes not expose any of thegasPayingToken*getters, onlyL1Block/L1BlockCGT(andLiquidityController) do. As soon as the registry scanner contacts aSystemConfigon a release that uses this branch, eacheth_callreverts,CallBatchreturns an error and the tooling fails to emit a report. Practically, every attempt to collect CGT metadata after PR #18076 will break the registry publishing workflow.Recommendation
Adjust the reporter so it fetches CGT metadata from the correct source. Either add proxy functions on
SystemConfigthat exposegasPayingToken*and call those, or point the scanner at the L2 predeploys (L1Block/LiquidityController) whenisCustomGasTokenis true. The goal is to keep the RPC batch addressing contracts that actually implement the queried selectors, otherwise the report generator will continue to revert and no registry entries can be produced for CGT-enabled chains.OP Labs: Acknowledged. After the release we will update the superchain-registry repository.
Superchain Registry staging drops CGT metadata
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
The Superchain Registry staging step (
packages/contracts-bedrock/lib/superchain-registry/ops/internal/manage/staging.go) still assumes CGT deployments expose an ERC20 address on L1 via the now-removedDeployConfig.CustomGasTokenAddress. The only logic that records a gas-paying token is:if dc.CustomGasTokenAddress != (common.Address{}) { cfg.GasPayingToken = config.NewChecksummedAddress(dc.CustomGasTokenAddress)}In the custom gas token architecture introduced by PR #18076, there is no standalone ERC20 on L1. Liquidity is handled by the new
LiquidityController/NativeAssetLiquiditypredeploys and the configuration lives inchainIntent.CustomGasTokenplus flags such asuseCustomGasToken/gasPayingTokenName. Because the staging code never reads those fields, every CGT-enabled chain is exported to the registry as if it were an ETH-native chain. Downstream tooling (dashboards, wallets, governance systems) can no longer tell whether the chain requires custom gas token handling or what metadata (name/symbol/liquidity) to display, leading to incorrect UX and potentially misconfigured integrations.Recommendation
Update
InflateChainConfigto source CGT data from the new intent/deploy config fields. At minimum, persist whetherchainIntent.CustomGasToken.Enabledis true and copy the configuredName,SymbolandInitialLiquidity(or the equivalent values fromDeployConfig.GasTokenDeployConfig). Extend the staged-chain schema to surface this information so registry consumers can distinguish CGT chains from standard ETH chains and display the correct token metadata.OP Labs: Acknowledged. After the release we will update the superchain-registry repository.
Unnecessary ETHLockbox deployment for custom gas token chains
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Sujith S
Summary
The
deploy()function inOPContractsManager.solunconditionally deploys and initializes the ETHLockbox proxy contract for all chains, including those configured with custom gas tokens. This is redundant.Recommendation
Conditionally deploy and initialize the ETHLockbox only when the CUSTOM_GAS_TOKEN feature is disabled. Additionally, consider adding validation to prevent both CUSTOM_GAS_TOKEN and OPTIMISM_PORTAL_INTEROP features are not enabled simultaneously, as they appear to be mutually exclusive use cases at this point.
OP Labs: Acknowledged. We will do further work with feature flags compatibilities.
Gas Optimizations2 findings
Nested if-statements can be combined to save gas
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Sujith S
Description
The
proveWithdrawalTransaction(),depositTransaction()andfinalizeWithdrawalTransaction()functions in OptimismPortal2.sol contains nested if-statements that check two conditions sequentially:if (_isUsingCustomGasToken()) { if (_tx.value > 0) revert OptimismPortal_NotAllowedOnCGTMode();}The current implementation uses nested if-statements, which results in two separate JUMPI opcodes being executed when both conditions need to be evaluated. This pattern is less gas-efficient than combining the conditions with a logical AND operator.
Recommendation
Consider combining the nested if-statements as following:
if (_isUsingCustomGasToken() && _tx.value > 0) revert OptimismPortal_NotAllowedOnCGTMode();OP Labs: Acknowledged.
Inefficient boolean comparison in setCustomGasToken() function
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Sujith S
Description
The
setCustomGasToken()function explicitly compares a boolean value to false, which generates unnecessary bytecode compared to using the negation operator.Recommendation
Replace the explicit boolean comparison with the negation operator:
- require(isCustomGasToken() == false, "L1Block: CustomGasToken already active");+ require(!isCustomGasToken(), "L1Block: CustomGasToken already active");OP Labs: Acknowledged.