Skip to content
$1,000,000 in security audit grants are live now, Apply here →

Security review · June 2026

LayerZero Integration

for Olympus

Guardian's review of LayerZero Integration for Olympus, published June 2026. The report records 20 findings across 2 review rounds, including 1 medium and 3 low.

Published
Review window
April 16 to June 2, 2026
Rounds
Main Review, Remediation Review
Language
Solidity
Chains
Ethereum, Arbitrum, Optimism, Base, Berachain
Sector
Tokens
  • 0 Critical
  • 0 High
  • 1 Medium
  • 3 Low
  • 16 Informational

16 resolved · 4 acknowledged

Scope

5 files in scope · 997 nSLOC
FilenSLOCLines
src/periphery/bridge/LZCrossChainBridge.sol60106
src/policies/bridge/LZBridgeGateway.sol297568
src/libraries/LZConfigLib.sol161280
src/proposals/LZBridgeSecurityUpgradeProposal.sol329448
src/proposals/LZBridgeActivator.sol150235

Findings 20

Main Review

16 findings · April 16 to 21, 2026
  1. M-01 Medium Optional DVNs inherit LayerZero defaults Logical Error Resolved
    Location
    LZConfigLib.sol
    Round
    Main Review

    Description

    The bridge intends to pin LayerZero verification to exactly two required DVNs per route, but LZConfigLib.encodeUlnConfig() leaves optional DVNs in LayerZero’s default-inherited state.

    The helper encodes optionalDVNCount = 0 with an empty optionalDVNs array. In LayerZero V2 OApp ULN config, 0 does not mean “zero optional DVNs”; it means “use the default config.” To explicitly configure no optional DVNs, the value must be type(uint8).max.

    This conflicts with the proposal’s stated goal of eliminating LayerZero default drag-along and using dual-DVN verification: LayerZero Labs + Google Cloud for non-Berachain routes, and LayerZero Labs + Nethermind for Berachain routes. If current defaults have no optional DVNs, the resolved config may look correct today, but the raw app config still inherits defaults.

    If LayerZero later adds default optional DVNs or changes the optional DVN threshold for a route, Olympus can inherit that change without governance action. Messages may then require an extra default DVN, increasing fees or blocking delivery if that DVN is unavailable or unsupported. In the bridge flow, users can burn OHM on the source chain while the destination mint remains stuck until the inherited requirement is satisfied or admins recover the message.

    Recommendation

    Use LayerZero’s NIL sentinel for no optional DVNs: set optionalDVNCount = type(uint8).max, optionalDVNThreshold = 0, and keep optionalDVNs empty.

  2. L-01 Low Peer Update Strands Verified Messages Compatibility Resolved
    Location
    src/policies/bridge/LZBridgeGateway.sol:319
    Round
    Main Review

    Description

    Calling setPeer can update or clear the peer for an EID. If messages from that peer are already verified on the LZ endpoint but not yet executed, they become permanently undeliverable, and lzReceive reverts on the peer check. Admin must proceed to manually recover each stranded message (i.e., skip, clear).

    Recommendation

    Document this behavior. Consider a two-step peer removal (disable receives → wait for in-flight messages to settle → remove peer) or check for pending messages before clearing.

  3. L-02 Low Bridge fee event can overstate paid fee Events Resolved
    Location
    src/periphery/bridge/LZCrossChainBridge.sol:68
    Round
    Main Review

    Description

    LZCrossChainBridge.sendOhm() emits msg.value as the fees field in Bridged, but msg.value is the native value supplied upfront, not necessarily the actual LayerZero fee paid.

    LayerZero V2 refunds excess native value to the configured refund address. In this bridge flow, the gateway passes the user as the refund address, so any overpayment is returned, but the event still records the full original msg.value.

    The actual fee charged by LayerZero is returned in MessagingReceipt.fee, which the gateway receives from EndpointV2.send(). However, that value is not surfaced in the Bridged event.

    This could also lead to misaccounting in off-chain accounting systems that rely on the emitted fees value.

    Recommendation

    Emit MessagingReceipt.fee.nativeFee as the paid fee, or rename/document the field as native value supplied.

  4. L-03 Low L2 Post-Batch Validation Omits Endpoint Checks Validation Resolved
    Location
    src/scripts/ops/batches/LZBridgeGatewayL2Batch.sol:311
    Round
    Main Review

    Description

    LZBridgeGatewayL2Batch._validateConfigureAndEnable verifies the gateway is enabled, peers are set, and enforced options exist. However, unlike the Ethereum proposal, it does not verify that the LZ libraries are pinned (SendUln302/ReceiveUln302), ULN config (DVN addresses, confirmations), or executor config, The Ethereum proposal's _validate reads these values and endpoint state directly. A misconfigured L2 endpoint setup would therefore not be caught by the L2 post-batch validation.

    Recommendation

    Extend _validateConfigureAndEnable to read endpoint configuration via the gateway's ILZEndpointV2Admin view functions and verify libraries, DVNs, confirmations, and executor match the expected values. Mirror the checks in LZBridgeSecurityUpgradeProposal._validateLZConfig.

  5. I-01 Informational No Receive-Side Validation Of Recipient Validation Resolved
    Location
    src/policies/bridge/LZBridgeGateway.sol:301
    Round
    Main Review

    Description

    _receiveBridgeOhm decodes (address to, uint256 amount) from the cross-chain message but does not validate to. The send side checks to != address(0), but the receive side trusts the payload entirely. If a situation occurs where to = address(0) is sent to the receiver, the OHM mint reverts, stranding the message. If to = address(gateway), OHM is minted to the gateway and locked with no recovery path.

    Recommendation

    Add _requireNonzeroAddress(to, "to") in _receiveBridgeOhm, consider also rejecting to == address(this).

  6. I-02 Informational Skip/Burn Bypass bridgedSupply Accounting Trust Assumptions Resolved
    Location
    src/policies/bridge/LZBridgeGateway.sol:465
    Round
    Main Review

    Description

    The LZ V2 message recovery functions skip, burn, and clear are executed directly to the endpoint without adjusting bridgedSupply or mint approval on the canonical chain. When bridge_admin uses skip or burn to permanently discard an inbound message, the OHM burned on the source chain is never minted on the destination, but bridgedSupply remains inflated. Admin must manually call decreaseBridgedSupply to enforce the bridgedSupply and mint approval are up-to-date, while nothing in the code enforces or signals this.

    Recommendation

    Document that skip and burn of messages require a corresponding decreaseBridgedSupply call on the canonical chain.

  7. I-03 Informational No Token Rescue On Gateway Best Practices Resolved
    Location
    LZBridgeGateway.sol, LZCrossChainBridge.sol
    Round
    Main Review

    Description

    Neither LZBridgeGateway nor LZCrossChainBridge has a mechanism to recover tokens accidentally sent to them. ERC20 and native token is permanently locked, and since the gateway's lzReceive is payable, this can increase the chance user stuck funds.

    Recommendation

    Consider adding an admin-gated rescue function for stuck tokens.

  8. I-04 Informational Dual-Bridge Window During Migration Trust Assumptions Acknowledged
    Location
    src/proposals/LZBridgeSecurityUpgradeProposal.sol:73
    Round
    Main Review

    Description

    The OCG proposal enables the new LZBridgeGateway but does not deactivate the old CrossChainBridge. Deactivation is deferred to a separate DAO multisig batch after proposal execution. During this window both bridges are active. The old bridge lacks bridgedSupply tracking, DVN pinning, and receive-side disable checks. Transfers through the old bridge during this window bypass bridgedSupply tracking, explicit DVN pinning, and receive-side disable controls.

    Recommendation

    Minimize the window between proposal execution and old bridge deactivation. Document the maximum acceptable delay and ensure monitoring is in place to alert if the old bridge is used after the new one is live.

  9. I-05 Informational sendOhm Does Not Validate Recipient Address Gas Optimization Resolved
    Location
    src/policies/bridge/LZBridgeGateway.sol:209
    Round
    Main Review

    Description

    LZCrossChainBridge.sendOhm validates amount_ != 0 but does not check to_ != address(0). The gateway's burnAndSend does validate this, but the user has already paid gas for the OHM transfer before hitting that revert.

    Recommendation

    Add _requireNonzeroAddress(to_, "to") in sendOhm to fail fast.

  10. I-06 Informational Consider Additional DVN For Added Resilience Suggestion Resolved
    Location
    src/libraries/LZConfigLib.sol:271-272
    Round
    Main Review

    Description

    The bridge uses 2-of-2 required DVNs per route, which meets LayerZero's multi-DVN recommendation and would have added an extra layer of protection against the recent KelpDAO exploit (April 2026) which relied on a 1-of-1 configuration. However, a 2-of-2 setup means compromising both DVNs still enables message forgery. A 3-of-3 configuration would provide additional resilience against attackers targeting DVN infrastructure, as demonstrated in the KelpDAO incident.

    Recommendation

    Evaluate whether adding a third required DVN is operationally feasible to add an extra layer of security.

  11. I-07 Informational Unused V1 Chain ID Constants In LZConfigLib Superfluous Code Acknowledged
    Location
    src/libraries/LZConfigLib.sol:117-125
    Round
    Main Review

    Description

    LZConfigLib defines V1 LayerZero chain ID constants (ETH_CHAIN_ID = 101, ARB_CHAIN_ID = 110, etc.) that are not referenced anywhere in the codebase. All active configuration uses the V2 endpoint IDs (ETH_EID = 30101, ARB_EID = 30110, etc.).

    Recommendation

    Remove the unused V1 chain ID constants to reduce confusion between V1 and V2 identifiers.

  12. I-08 Informational Old Bridge Deactivated Without Disabled Check Trust Assumptions Resolved
    Location
    src/scripts/ops/batches/LZCrossChainBridgeBatch.sol:33-59
    Round
    Main Review

    Description

    LZCrossChainBridgeBatch.setup deactivates the old CrossChainBridge in the Kernel without first verifying that bridgeActive == false. A separate disableOldBridge entry point exists and is expected to run beforehand, but setup does not enforce this ordering. If setup is ran before disableOldBridge, user transactions submitted in the intervening window could still interact with the old bridge.

    Recommendation

    Add a pre-condition check in setup that verifies the old bridge's bridgeActive state is already false, or include the disable call in the same batch to guarantee ordering.

  13. I-09 Informational Unresolved TODO Comments Best Practices Acknowledged
    Location
    https://github.com/GuardianOrg/olympus-v3-team1-1776174364780/blob/aaae73e623307712c952179a38cfb66637c0ae21/src/scripts/deploy/DeployV3.s.sol#L67, https://github.com/GuardianOrg/olympus-v3-team1-1776174364780/blob/aaae73e623307712c952179a38cfb66637c0ae21/src/scripts/ops/batches/LZBridgeGatewayBatch.sol#L90, https://github.com/GuardianOrg/olympus-v3-team1-1776174364780/blob/aaae73e623307712c952179a38cfb66637c0ae21/src/proposals/LZBridgeSecurityUpgradeProposal.sol#L64, https://github.com/GuardianOrg/olympus-v3-team1-1776174364780/blob/aaae73e623307712c952179a38cfb66637c0ae21/src/proposals/LZBridgeSecurityUpgradeProposal.sol#L95-L98
    Round
    Main Review

    Description

    Several TODO comments remain in the proposal, deployment scripts, and batch scripts that should be resolved before deployment:

    LZBridgeSecurityUpgradeProposal.sol: proposal ID is hardcoded to 15 with a TODO. The proposal description contains TODO placeholders for the audit report link, PR link, and RFC/OIP reference.

    LZBridgeGatewayBatch.sol: initBridgedSupply reads from an args file with a TODO noting the value must be set before execution.

    DeployV3.s.sol: TODOs flag refactoring and error handling improvements.

    Recommendation

    Resolve all TODO comments before deployment, followed by removing the comments to avoid confusion.

  14. I-10 Informational Enabled/Disable Events Omit Data Parameters Events Acknowledged
    Location
    LZBridgeGateway.sol, PolicyEnabler.sol
    Round
    Main Review

    Description

    PolicyEnabler.enable and PolicyEnabler.disable each accept a bytes calldata data parameter (enableData_ and disableData_) but do not include these in the Enabled and Disabled events. For policies that use this data for configuration at enable/disable time, the values are not captured in on-chain logs.

    Recommendation

    Consider emitting enableData_ and disableData_ as part of the Enabled and Disabled events.

  15. I-11 Informational Non-Canonical Chains Have No Minting Cap Trust Assumptions Resolved
    Location
    src/policies/bridge/LZBridgeGateway.sol:602
    Round
    Main Review

    Description

    In non-canonical chains, _receiveBridgeOhm uses JIT self-approval with no upper bound:

    MINTR.increaseMintApproval(address(this), amount);
    MINTR.mintOhm(to, amount);
    

    If for example LayerZero verification infrastructure for a specific route is compromised (e.g. DVN compromised as demonstrated by the KelpDAO incident in April 2026), unlimited OHM can be minted on the non-canonical chain. The canonical chain's bridgedSupply cap does not protect non-canonical chains.

    The RateLimiter inherited by the gateway caps outbound transfers only. _inflow reduces consumed outbound capacity, freeing it for further sends, rather than capping inbound volume. Rate limits therefore do not provide an effective cap on inbound mints if LZ verification is compromised.

    Recommendation

    Consider whether a maximum-mint cap should be introduced at the gateway level on non-canonical chains as an additional defense layer.

  16. I-12 Informational Periphery Can Use Stale Gateway Informational Resolved
    Location
    DeployV3.s.sol
    Round
    Main Review

    Description

    The saved LZ bridge deployment sequences deploy LZCrossChainBridge before LZBridgeGateway.

    DeployV3.deploy() executes the sequence in order and stores each deployed address in the in-memory deployedTo map only after that deployment completes. DeployV3._getAddressNotZero() first checks this in-memory map, then falls back to env.json.

    However, deployLZCrossChainBridge() reads olympus.policies.LZBridgeGateway during construction. Since LZBridgeGateway appears later in both saved LZ deployment sequences, the newly deployed gateway is not available in deployedTo when the periphery is constructed.

    As a result, the periphery either reverts if env.json has no gateway address, or it is deployed with whatever gateway address was already saved in env.json. If env.json contains an older gateway address, the new periphery is wired to that stale gateway instead of the gateway deployed later in the same sequence.

    Recommendation

    Deploy LZBridgeGateway before LZCrossChainBridge, or explicitly require that env.json already contains the intended gateway address before deploying the periphery.

Remediation Review

4 findings · May 28 to June 2, 2026
  1. I-13 Informational Stale _subActionTargetKind Storage Best Practices Resolved
    Location
    https://github.com/GuardianOrg/olympus-v3-team1-1776174364780/blob/lz-bridge-upgrade/src/policies/bridge/LZBridgeAndDelegateConfig.sol#L297, https://github.com/GuardianOrg/olympus-v3-team1-1776174364780/blob/lz-bridge-upgrade/src/policies/bridge/LZBridgeAndDelegateConfig.sol#L93C63-L93C83
    Round
    Remediation Review

    Description

    When a queued action is executed or cancelled, _queuedActions[actionId].actions is deleted, but the per queued sub-action _subActionTargetKind[actionId][index] mapping entries are not. These entries remain in storage permanently after the action completes, even though they are never read again.

    Recommendation

    Clear _subActionTargetKind[actionId][index] entries in the execution and cancellation paths.

  2. I-14 Informational Queued Actions Remain After Policy Disable Documentation Resolved
    Location
    src/policies/bridge/LZBridgeAndDelegateConfig.sol:287
    Round
    Remediation Review

    Description

    When LZBridgeAndDelegateConfig is disabled for security purposes, _validateExecution blocks execution of queued actions via _requireEnabled(), but the queued actions remain in storage.

    If the policy is disabled and later re-enabled, any previously-queued actions (whose execution windows have not expired yet) become executable again. An operator who re-enables the policy without first cancelling all potentially dangerous queued actions could allow a malicious action to complete.

    Recommendation

    Document the operational procedure that all malicious queued actions must be cancelled by the emergency role before the policy is re-enabled.

  3. I-15 Informational Cross-Batch Execution Order Is Not Enforced Documentation Resolved
    Location
    src/policies/utils/TimelockBatchQueue.sol:126
    Round
    Remediation Review

    Description

    Within a queued batch, sub-actions execute atomically in array order. Across separate queued actions, there is no enforced ordering, and execution is permissionless, so two independently-queued batches can be executed in any order once their timelocks elapse. Operators who queue dependent actions in separate batches cannot rely on them executing in queue order.

    Recommendation

    Document that actions with execution-order dependencies must be queued within a single batch, and that independent batches should not be assumed to execute in queue order.

  4. I-16 Informational Delegate Clear Docs Mismatch Documentation Resolved
    Location
    ILZBridgeGateway.sol
    Round
    Remediation Review

    Description

    The ILZBridgeGateway.setDelegate() interface documents that passing address(0) clears the LayerZero endpoint delegate.

    However, LZBridgeGateway.setDelegate() calls validateSetDelegate(delegate_), and validateSetDelegate() rejects the zero address. The timelocked LZBridgeAndDelegateConfig path also calls the same validation helper before queueing setDelegate, so zero-address delegate clearing is not available through either direct or timelocked configuration.

    As a result, operators or scripts following the interface documentation may attempt to clear the delegate with address(0) and see the action revert at validation or execution time. The implementation still supports replacing the delegate with another nonzero address, so this is a documentation/API consistency issue rather than a loss of delegate control.

    Recommendation

    Update the interface documentation to state that delegate_ must be nonzero.

More from Olympus

  1. Price Feed

    59 findings1 high 59 findings: 1 high, 15 medium, 24 low, 19 informational
  2. Migration

    15 findings 15 findings: 3 low, 12 informational
  3. Convertible Deposits

    92 findings1 critical · 13 high 92 findings: 1 critical, 13 high, 13 medium, 39 low, 26 informational

Put your code through the same review.

This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.

Get a quote