Status DataClose notification

Overlayer Disclosed Report

OverlayerWrap` hub-to-satellite OFT transfers can burn on the hub and fail on the first satellite credit

Company
Created date
Apr 06 2026

Target

hidden

Vulnerability Details

The function totalBridgedOut is used for hub-side supply accounting, but it is currently updated on every deployment.

In contracts/overlayer/OverlayerWrapCore.sol:

function _debit(...) internal virtual override returns (...) {
    (amountSentLD, amountReceivedLD) = super._debit(...);
    totalBridgedOut += amountSentLD;
}

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

That logic is only safe if both hooks run against the same storage. In production they do not:

  • _debit() runs on the source deployment
  • _credit() runs on the destination deployment

For a fresh satellite, totalBridgedOut starts at 0, so the first inbound credit underflows on:

totalBridgedOut -= amountReceivedLD;

The bridge flow is source-burn / destination-mint, as defined in node_modules/@layerzerolabs/oft-evm/contracts/OFT.sol:

function _debit(...) internal virtual override returns (...) {
    (amountSentLD, amountReceivedLD) = _debitView(...);
    _burn(_from, amountSentLD);
}

function _credit(...) internal virtual override returns (uint256 amountReceivedLD) {
    _mint(_to, _amountLD);
    return _amountLD;
}

This means a normal hub-to-satellite transfer can fail after the hub-side burn has already happened.

The issue is made worse by the fact that minting is hub-only, so the satellite has no normal in-scope way to pre-seed a positive totalBridgedOut before that first inbound transfer.

Impact

This breaks the intended OFT bridge path.

A normal user flow is:

  1. User mints OverlayerWrap on the hub.
  2. User bridges from hub to satellite.
  3. Hub _debit() burns the user's balance.
  4. Satellite _credit() reverts on underflow.

Result:

  • the source-side balance is already burned
  • the destination-side mint does not happen
  • the transferred amount is stranded unless there is an out-of-band recovery path

This is critical because the failure happens on the main advertised bridge path, not on an edge case.

The PoC shows two things:

  1. The satellite cannot mint in-scope to seed totalBridgedOut, because minting reverts with OverlayerWrapCoreNotHubChainId.
  2. After hub.testDebit(...) burns 10 OWrap on the hub, satellite.testCredit(...) reverts with panic 0x11, while the hub-side burned balance remains reduced.

Relevant PoC lines:

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

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

And the post-state:

expect(await hub.balanceOf(alice.address)).to.equal(
  hubBalanceBefore - burnAmount
);
expect(await satellite.balanceOf(alice.address)).to.equal(0);

5. Recommendation

totalBridgedOut should be hub-local accounting only.

The simplest fix is:

  • increment it only when block.chainid == hubChainId inside _debit()
  • decrement it only when block.chainid == hubChainId inside _credit()

Also add a permanent regression test that uses two separate deployments:

  1. debit on the hub
  2. credit on the satellite
  3. assert that the first inbound satellite credit succeeds

Validation steps

Proof of concept

PoC file:

  • test/OverlayerWrapBridgePoC.ts

Run:

npx hardhat test test\OverlayerWrapBridgePoC.ts
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";
import { ethers } from "hardhat";

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

describe("OverlayerWrap bridge PoC", function () {
  async function deployFixture() {
    const [admin, alice] = await ethers.getSigners();

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

    const Collateral = await ethers.getContractFactory("SixDecimalsUsd");
    const collateral = await Collateral.deploy(
      1000,
      "COLLATERAL",
      "COLLATERAL",
      defaultTransactionOptions
    );
    const aCollateral = await Collateral.deploy(
      1000,
      "aCOLLATERAL",
      "aCOLLATERAL",
      defaultTransactionOptions
    );

    const collateralDecimals = await collateral.decimals();
    const aCollateralDecimals = await aCollateral.decimals();

    const OverlayerWrap = await ethers.getContractFactory("OverlayerWrapMock");
    const commonParams = {
      admin: admin.address,
      lzEndpoint: LZ_ENDPOINT_ETH_MAINNET_V2,
      name: "O",
      symbol: "O+",
      collateral: {
        addr: await collateral.getAddress(),
        decimals: collateralDecimals
      },
      aCollateral: {
        addr: await aCollateral.getAddress(),
        decimals: aCollateralDecimals
      },
      maxMintPerBlock: ethers.MaxUint256,
      maxRedeemPerBlock: ethers.MaxUint256,
      minValmaxRedeemPerBlock: 1n
    };

    const hub = await OverlayerWrap.deploy(
      {
        ...commonParams,
        hubChainId: HARDHAT_CHAIN_ID
      },
      defaultTransactionOptions
    );
    await hub.waitForDeployment();

    // Keep this instance off-hub so its local bridge counter starts cold.
    const satellite = await OverlayerWrap.deploy(
      {
        ...commonParams,
        hubChainId: HARDHAT_CHAIN_ID + 1
      },
      defaultTransactionOptions
    );
    await satellite.waitForDeployment();

    await collateral
      .connect(admin)
      .transfer(alice.address, ethers.parseUnits("50", collateralDecimals));

    await collateral
      .connect(alice)
      .approve(await hub.getAddress(), ethers.MaxUint256);
    await collateral
      .connect(alice)
      .approve(await satellite.getAddress(), ethers.MaxUint256);

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

  async function buildHubOrder(
    collateral: any,
    collateralDecimals: bigint,
    userAddress: string,
    amount: string
  ) {
    return {
      benefactor: userAddress,
      beneficiary: userAddress,
      collateral: await collateral.getAddress(),
      collateralAmount: ethers.parseUnits(amount, collateralDecimals),
      overlayerWrapAmount: ethers.parseEther(amount)
    };
  }

  it("should show the satellite cannot seed totalBridgedOut through an in-scope mint", async function () {
    const { alice, collateral, collateralDecimals, satellite } =
      await loadFixture(deployFixture);

    const order = await buildHubOrder(
      collateral,
      collateralDecimals,
      alice.address,
      "1"
    );

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

    await expect(satellite.connect(alice).mint(order)).to.be.revertedWithCustomError(
      satellite,
      "OverlayerWrapCoreNotHubChainId"
    );

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

  it("should burn on the hub and then revert on the first inbound satellite credit", async function () {
    const { alice, collateral, collateralDecimals, hub, satellite } =
      await loadFixture(deployFixture);

    const order = await buildHubOrder(
      collateral,
      collateralDecimals,
      alice.address,
      "20"
    );
    await hub.connect(alice).mint(order);

    const burnAmount = ethers.parseEther("10");
    const hubBalanceBefore = await hub.balanceOf(alice.address);

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

    // The source side is already burned before the destination credit runs.
    expect(await hub.balanceOf(alice.address)).to.equal(
      hubBalanceBefore - burnAmount
    );
    expect(await hub.totalSupply()).to.equal(ethers.parseEther("10"));
    expect(await hub.totalBridgedOut()).to.equal(burnAmount);

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

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

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

    // After the failed delivery, the hub side stays burned.
    expect(await hub.balanceOf(alice.address)).to.equal(
      hubBalanceBefore - burnAmount
    );
    expect(await hub.totalSupply()).to.equal(ethers.parseEther("10"));
    expect(await hub.totalBridgedOut()).to.equal(burnAmount);
  });
});

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$69
Visibilitypartially
VulnerabilityDoS with (Unexpected) revert
Participants
hidden