Status DataClose notification

Overlayer Disclosed Report

OverlayerWrapCore first inbound non-hub _credit() underflows totalBridgedOut, locking bridged user funds

Company
Created date
Mar 28 2026

Target

hidden

Vulnerability Details

Summary :

I have analysed the cross-chain accounting flow in OverlayerWrapCore and observed a deterministic mismatch between the source-side debit accounting and the destination-side credit accounting.

Root cause

In contracts/overlayer/OverlayerWrapCore.sol, the OFT hooks update totalBridgedOut as follows:


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;
}

I have observed that this logic implicitly assumes the same contract instance that performs _debit() will later perform _credit(). That assumption is false in the actual OFT bridge flow.

In a real bridge transfer:

  • the source-chain instance executes _debit()
  • the destination-chain instance executes _credit()

Because totalBridgedOut is stored locally per deployment, a fresh non-hub destination instance starts with:

totalBridgedOut == 0;

When the first inbound bridge message reaches that destination instance, _credit() executes:

totalBridgedOut -= amountReceivedLD;

This immediately reverts with arithmetic underflow (Panic(0x11)), because the destination contract never previously increased its own local totalBridgedOut.

I also analysed contracts/overlayerbacking/AaveHandler.sol and observed that the protocol treats totalBridgedOut as part of the effective supply used for backing calculations:

uint256 owTotalSupp = IOverlayerWrap(overlayerWrap).totalSupply() +
    IOverlayerWrap(overlayerWrap).totalBridgedOut();

This confirms that totalBridgedOut is intended to compensate for OFT burns that happen when tokens leave the hub-backed supply domain. However, the current implementation decrements the variable on the destination instance unconditionally, even when that destination instance never recorded the corresponding debit locally.

Impact

I have observed that a standard user bridge flow can fail as follows:

  1. The user holds OVA on the hub/source deployment.
  2. The user initiates a bridge to a fresh non-hub destination deployment.
  3. _debit() succeeds on the source and burns or reduces the source-side balance while incrementing source totalBridgedOut.
  4. The destination receive path calls _credit().
  5. _credit() reverts because destination totalBridgedOut is still zero.

As a result, the destination user is not credited. From the user’s perspective, the bridged funds are locked in a failed cross-chain transfer path and the normal bridge receive flow is unavailable on that destination deployment.

This is not a theoretical issue. I have observed that it is a deterministic revert in the current code path, and it is triggerable by a regular user without any privileged role.

Suggested fix / mitigation

I have analysed the accounting intent and observed that the safest fix is to make totalBridgedOut hub-side accounting only.

A simple mitigation is:

  • increment totalBridgedOut in _debit() only when the current deployment is the hub-side backing domain
  • decrement totalBridgedOut in _credit() only when tokens are being credited back into that same hub-side backing domain

In other words, destination deployments should not unconditionally execute:

totalBridgedOut -= amountReceivedLD;

because their local totalBridgedOut does not necessarily correspond to the source-side burn that initiated the transfer.

Validation steps

I have prepared a runnable PoC that runs inside the repository’s Hardhat test suite and demonstrates the issue in a native project-style test.

Files added or changed for the PoC

A. Added endpoint mock

File: contracts/test/LayerZeroEndpointV2Mock.sol

Reason:
OverlayerWrap constructor calls endpoint.setDelegate(...), so a simple local mock endpoint contract is required in a no-fork local test environment.

Content:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract LayerZeroEndpointV2Mock {
    address public delegate;

    function setDelegate(address _delegate) external {
        delegate = _delegate;
    }
}

B. Added PoC test

File: test/OverlayerWrap.crosschain-lock.poc.ts

What the PoC does

  • deploys a local LayerZero endpoint mock
  • deploys a hub OverlayerWrapMock
  • deploys a satellite OverlayerWrapMock
  • mints OVA to the user on the hub instance
  • calls hub.testDebit(...) to simulate the source-side bridge debit
  • verifies hub.totalBridgedOut() increased
  • verifies satellite.totalBridgedOut() is still 0
  • calls satellite.testCredit(...)
  • observes revert with Panic(0x11)

PoC content

import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { ethers } from "hardhat";
import { expect } from "chai";

import { HARDHAT_CHAIN_ID } from "../scripts/constants";

describe("OverlayerWrap critical PoC - destination credit underflow", function () {
  async function deployBridgeFixture() {
    const [admin, alice] = await ethers.getSigners();

    const block = await admin.provider.getBlock("latest");
    const baseFee = block?.baseFeePerGas ?? 1n;
    const txOpts = { maxFeePerGas: baseFee * 10n };

    const Endpoint = await ethers.getContractFactory("LayerZeroEndpointV2Mock");
    const endpoint = await Endpoint.deploy(txOpts);
    await endpoint.waitForDeployment();

    const Collateral = await ethers.getContractFactory("SixDecimalsUsd");
    const collateral = await Collateral.deploy(
      1_000_000,
      "COLLATERAL",
      "COLLATERAL",
      txOpts
    );
    await collateral.waitForDeployment();

    const ACollateral = await ethers.getContractFactory("SixDecimalsUsd");
    const aCollateral = await ACollateral.deploy(
      1_000_000,
      "aCOLLATERAL",
      "aCOLLATERAL",
      txOpts
    );
    await aCollateral.waitForDeployment();

    const OverlayerWrap = await ethers.getContractFactory("OverlayerWrapMock");

    const hub = await OverlayerWrap.deploy(
      {
        admin: admin.address,
        lzEndpoint: await endpoint.getAddress(),
        name: "Hub OVA",
        symbol: "hOVA",
        collateral: {
          addr: await collateral.getAddress(),
          decimals: await collateral.decimals(),
        },
        aCollateral: {
          addr: await aCollateral.getAddress(),
          decimals: await aCollateral.decimals(),
        },
        maxMintPerBlock: ethers.MaxUint256,
        maxRedeemPerBlock: ethers.MaxUint256,
        minValmaxRedeemPerBlock: 1n,
        hubChainId: HARDHAT_CHAIN_ID,
      },
      txOpts
    );
    await hub.waitForDeployment();

    const satellite = await OverlayerWrap.deploy(
      {
        admin: admin.address,
        lzEndpoint: await endpoint.getAddress(),
        name: "Satellite OVA",
        symbol: "sOVA",
        collateral: {
          addr: await collateral.getAddress(),
          decimals: await collateral.decimals(),
        },
        aCollateral: {
          addr: await aCollateral.getAddress(),
          decimals: await aCollateral.decimals(),
        },
        maxMintPerBlock: ethers.MaxUint256,
        maxRedeemPerBlock: ethers.MaxUint256,
        minValmaxRedeemPerBlock: 1n,
        hubChainId: 1n,
      },
      txOpts
    );
    await satellite.waitForDeployment();

    const mintAmountLD = ethers.parseUnits("20", await collateral.decimals());
    await collateral.transfer(alice.address, mintAmountLD);
    await collateral.connect(alice).approve(await hub.getAddress(), mintAmountLD);

    return { alice, collateral, hub, satellite };
  }

  it("burns on the hub, then reverts on the first inbound credit to a fresh satellite", async function () {
    const { alice, collateral, hub, satellite } = await loadFixture(deployBridgeFixture);

    const mintAmountLD = ethers.parseUnits("20", await collateral.decimals());
    const mintAmountOW = ethers.parseEther("20");

    await hub.connect(alice).mint({
      benefactor: alice.address,
      beneficiary: alice.address,
      collateral: await collateral.getAddress(),
      collateralAmount: mintAmountLD,
      overlayerWrapAmount: mintAmountOW,
    });

    const bridgeAmount = ethers.parseEther("10");

    await hub.testDebit(alice.address, bridgeAmount, bridgeAmount, 0);

    expect(await hub.totalSupply()).to.equal(ethers.parseEther("10"));
    expect(await hub.totalBridgedOut()).to.equal(bridgeAmount);

    expect(await satellite.totalSupply()).to.equal(0);
    expect(await satellite.totalBridgedOut()).to.equal(0);

    await expect(
      satellite.testCredit(alice.address, bridgeAmount, 0)
    ).to.be.revertedWithPanic(0x11);

    expect(await satellite.balanceOf(alice.address)).to.equal(0);
    expect(await satellite.totalSupply()).to.equal(0);
    expect(await satellite.totalBridgedOut()).to.equal(0);
  });
});

Run the PoC from Repo root:

I ran the following commands from the repository root:

rm -rf cache artifacts typechain-types
npx hardhat test test/OverlayerWrap.crosschain-lock.poc.ts --config hardhat.poc.config.ts

Console Output Log:

Compiled 183 Solidity files successfully (evm targets: istanbul, paris).


  OverlayerWrap critical PoC - destination credit underflow
    ✔ burns on the hub, then reverts on the first inbound credit to a fresh satellite (2883ms)

What the PoC proved

I have observed the following from the successful PoC run:

  • source-side debit works normally
  • source-side totalBridgedOut increases as expected
  • fresh destination-side totalBridgedOut remains zero
  • first inbound destination _credit() reverts with Panic(0x11)
  • the destination user receives no minted tokens

This proves that the current bridge receive path is broken for a fresh non-hub destination instance due to local totalBridgedOut underflow.

1 passing (3s)

Attachments

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