Status DataClose notification

Overlayer Disclosed Report

Fresh destination-chain bridge-ins permanently lock user funds because `_credit()` subtracts `totalBridgedOut` from zero

Company
Created date
Mar 27 2026

Target

hidden

Vulnerability Details

Description

OverlayerWrapCore tracks bridged-out supply with totalBridgedOut, but _credit() decrements that counter on every destination chain.

Vulnerable code:

function _debit(
    address from_,
    uint256 amountLD_,
    uint256 minAmountLD_,
    uint32 dstEid_
) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) {
    (amountSentLD, amountReceivedLD) = super._debit(from_, amountLD_, minAmountLD_, dstEid_);
    totalBridgedOut += amountSentLD;
}

function _credit(
    address to_,
    uint256 amountLD_,
    uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
    amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
    totalBridgedOut -= amountReceivedLD;
}

Source: contracts/contracts/overlayer/OverlayerWrapCore.sol:510-543

This logic is only correct on the chain that previously executed _debit(). A fresh destination deployment starts with totalBridgedOut == 0. When the first inbound LayerZero message arrives, _credit() mints first and then subtracts amountReceivedLD from zero, which triggers Panic(0x11) and reverts the whole receive path.

That means the real bridge path is:

  1. source chain burns Alice's tokens in _debit()
  2. source chain increments its own totalBridgedOut
  3. destination chain tries to mint in _credit()
  4. destination chain underflows totalBridgedOut
  5. destination mint reverts

The result is not a harmless accounting mismatch. The source-side burn already happened, while the destination-side credit cannot complete. From the bridger's perspective, this is a direct end-user funds lock:

  1. the user has already surrendered spendable tokens on the source chain
  2. the user receives nothing on the destination chain
  3. the user has no public function available to repair destination-side totalBridgedOut
  4. recovery requires an out-of-band admin / governance / upgrade intervention

Root Cause

totalBridgedOut is treated as if it were a global system-wide net bridge counter, but it is actually stored per deployment.

On a fresh destination chain there was no prior local _debit(), so this state transition is invalid:

totalBridgedOut = 0;
totalBridgedOut -= amountReceivedLD;

The decrement must only happen on the hub chain, or only when there is verified prior local bridged-out balance to consume.

Impact

Consider the following scenario:

  1. Alice deposits 10 units of collateral on the hub chain and mints 10 OverlayerWrap.
  2. Alice bridges 5 OverlayerWrap from the hub deployment to a fresh destination deployment.
  3. The hub deployment executes _debit(), burns 5 tokens from Alice, and records totalBridgedOut += 5e18.
  4. The destination deployment executes _credit(), but its local totalBridgedOut is still zero, so 0 - 5e18 underflows and the receive reverts.
  5. After the revert, Alice holds only 5 OverlayerWrap on the source chain and 0 OverlayerWrap on the destination chain. The missing 5 OverlayerWrap are no longer user-spendable on either chain.

In this scenario, an attacker doesn't need additional privileges; any legitimate bridging user will trigger this issue. The victim is the user who initiated the bridging. The victim will lose the portion of their disposable OverlayerWrap that was already burned on the source chain, and the target chain will not receive the corresponding tokens. More importantly, this is not a problem that the user can resolve by retrying or redeeming, because there is no publicly known user path to fix the target chain's local state totalBridgedOut. Therefore, this aligns with the Permanent lock of end-user funds.

Why this is in-scope:

  1. End-user funds are consumed on the source chain by _debit().
  2. The corresponding destination mint deterministically reverts on a fresh deployment.
  3. The user cannot recover the burned source balance through any public function in scope.
  4. The failed bridge amount remains inaccessible unless privileged operators deploy an out-of-band recovery.

Summarize

Any user can trigger a source-chain burn that cannot be credited on a fresh destination deployment, permanently locking the bridged OverlayerWrap amount when the destination chain has no prior local totalBridgedOut.

  1. Actor: any user
  2. Action: bridge OverlayerWrap into a fresh destination deployment
  3. Asset: user OverlayerWrap balance being bridged
  4. Result: source tokens are burned while destination mint reverts, leaving the bridged amount inaccessible
  5. Condition: destination deployment has totalBridgedOut == 0

Remediation

Only decrement totalBridgedOut on the hub chain, or explicitly guard against destination-side underflow and use a separate per-chain accounting model.

One minimal fix is:

function _credit(
    address to_,
    uint256 amountLD_,
    uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
    amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
-   totalBridgedOut -= amountReceivedLD;
+   if (block.chainid == hubChainId) {
+       if (amountReceivedLD > totalBridgedOut) revert OverlayerWrapCoreOverflow();
+       totalBridgedOut -= amountReceivedLD;
+   }
}

If the protocol needs bridge accounting on non-hub chains too, it should maintain a different invariant there instead of reusing the hub-side backing counter.

Validation steps

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.20;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {OverlayerWrapMock} from "../contracts/contracts/test/OverlayerWrapMock.sol";
import {IOverlayerWrapDefs} from "../contracts/contracts/overlayer/interfaces/IOverlayerWrapDefs.sol";
import {OverlayerWrapCoreTypes} from "../contracts/contracts/overlayer/types/OverlayerWrapCoreTypes.sol";

interface VmPoc1 {
    function prank(address sender) external;
    function expectRevert(bytes calldata revertData) external;
}

contract Poc1MockERC20Decimals is ERC20 {
    uint8 private immutable tokenDecimals;

    constructor(
        string memory name_,
        string memory symbol_,
        uint8 decimals_
    ) ERC20(name_, symbol_) {
        tokenDecimals = decimals_;
    }

    function decimals() public view override returns (uint8) {
        return tokenDecimals;
    }

    function mint(address to_, uint256 amount_) external {
        _mint(to_, amount_);
    }
}

contract Poc1MockEndpoint {
    function setDelegate(address) external {}
}

contract Poc1Test {
    VmPoc1 private constant vm =
        VmPoc1(address(uint160(uint256(keccak256("hevm cheat code")))));

    address private constant ADMIN = address(0xA11CE);
    address private constant ALICE = address(0xB0B);
    function test_freshDestinationCreditRevertsAfterSourceDebit() external {
        Poc1MockEndpoint endpoint = new Poc1MockEndpoint();
        Poc1MockERC20Decimals collateral = new Poc1MockERC20Decimals(
            "Collateral",
            "COL",
            6
        );
        Poc1MockERC20Decimals aCollateral = new Poc1MockERC20Decimals(
            "Aave Collateral",
            "aCOL",
            6
        );

        OverlayerWrapMock hub = _deployOverlayerWrap(
            address(endpoint),
            block.chainid,
            collateral,
            aCollateral
        );
        OverlayerWrapMock remote = _deployOverlayerWrap(
            address(endpoint),
            block.chainid + 1,
            collateral,
            aCollateral
        );

        collateral.mint(ALICE, 10e6);
        vm.prank(ALICE);
        collateral.approve(address(hub), type(uint256).max);

        OverlayerWrapCoreTypes.Order memory mintOrder = OverlayerWrapCoreTypes
            .Order({
                benefactor: ALICE,
                beneficiary: ALICE,
                collateral: address(collateral),
                collateralAmount: 10e6,
                overlayerWrapAmount: 10 ether
            });

        vm.prank(ALICE);
        hub.mint(mintOrder);

        vm.prank(ALICE);
        hub.testDebit(ALICE, 5 ether, 5 ether, 30101);

        require(hub.balanceOf(ALICE) == 5 ether, "hub debit failed");
        require(hub.totalBridgedOut() == 5 ether, "hub bridged accounting wrong");
        require(remote.totalBridgedOut() == 0, "fresh remote should start at zero");

        vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
        remote.testCredit(ALICE, 5 ether, 30101);
    }

    function _deployOverlayerWrap(
        address endpoint_,
        uint256 hubChainId_,
        Poc1MockERC20Decimals collateral_,
        Poc1MockERC20Decimals aCollateral_
    ) private returns (OverlayerWrapMock) {
        IOverlayerWrapDefs.ConstructorParams memory params =
            IOverlayerWrapDefs.ConstructorParams({
                admin: ADMIN,
                lzEndpoint: endpoint_,
                name: "Overlayer Wrap",
                symbol: "OW",
                collateral: OverlayerWrapCoreTypes.StableCoin({
                    addr: address(collateral_),
                    decimals: collateral_.decimals()
                }),
                aCollateral: OverlayerWrapCoreTypes.StableCoin({
                    addr: address(aCollateral_),
                    decimals: aCollateral_.decimals()
                }),
                maxMintPerBlock: type(uint256).max,
                maxRedeemPerBlock: type(uint256).max,
                minValmaxRedeemPerBlock: 1,
                hubChainId: hubChainId_
            });

        return new OverlayerWrapMock(params);
    }
}

forge test --match-path audit-poc/poc1.t.sol -vv
  • result1:
Ran 1 test for audit-poc/poc1.t.sol:Poc1Test
[PASS] test_freshDestinationCreditRevertsAfterSourceDebit() (gas: 11749203)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$69
Visibilitypartially
VulnerabilityOther
Participants
hidden