Status DataClose notification

Overlayer Disclosed Report

Integer underflow in `OverlayerWrapCore._credit()` permanently breaks inbound cross-chain crediting and locking bridged user funds

Company
Created date
Mar 30 2026

Target

hidden

Vulnerability Details

Summary

OverlayerWrapCore tracks cross-chain net outflow with totalBridgedOut. In _credit, the contract always decrements this counter:totalBridgedOut -= amountReceivedLD;

On a destination chain receiving tokens, totalBridgedOut can be 0 before the first inbound credit. That subtraction underflows and reverts with panic 0x11, causing the inbound credit path to fail.

Because source-side bridging burns/debits tokens before destination credit, this can produce a user-funds lock/liveness failure in cross-chain transfers.

Details and root cause

The root cause is in this file contracts/overlayer/OverlayerWrapCore.sol, the function:_credit(address to_, uint256 amountLD_, uint32 srcEid_).

Scenario of a problem:

  1. User bridges from chain A to chain B.
  2. On chain B, _credit() executes and attempts to subtract amountReceivedLD from local totalBridgedOut
  3. If totalBridgedOut < amountReceivedLD, like 0 on first net inbound, underflow occurs
  4. The transaction reverts, the user does not receive bridged tokens on destination

Impact

It seems like a Critical according to your Hackenproof classification for smart contracts because it causes permanent freezing of funds.

It causes:

  • the Bridge receive can revert for valid inbound transfers.
  • Because the user value is burned/debited at source but not credited at destination until manual intervention/recovery.
  • This breaks a core protocol function, the cross-chain transfer, and can lock user funds in practical use.

Mitigations

I think we should use bounded or saturating decrement like:

if (amountReceivedLD >= totalBridgedOut) {
    totalBridgedOut = 0;
} else {
    totalBridgedOut -= amountReceivedLD;
}

We could also constrain totalBridgedOut accounting updates to the chain/context where this metric is intended to represent net outflow.

Validation steps

PoC overview

The PoC deploys:

  • OverlayerWrapMock (test wrapper that exposes internal _credit)
  • a minimal mock endpoint with setDelegate() because we need to satisfy the OFT constructor expectations

Then it calls exposed _credit while totalBridgedOut == 0, reproducing the revert.

How to run

We check out this commit to respect the scope:519c9e92fd9d80d11e35e9868130f6334b88d676

  1. Install dependencies and the copy the poc code:
cp ~/Downloads/poc_credit_underflow.js contracts/test/poc_credit_underflow.js
npm i --force
npx hardhat run --config hardhat.audit.config.ts test/poc_credit_underflow.js

Save this poc as poc_credit_underflow.js

const { ethers } = require('hardhat');

async function main() {
  const [admin] = await ethers.getSigners();

  const Endpoint = await ethers.getContractFactory('EndpointSetterMock');
  const endpoint = await Endpoint.deploy();

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

  const OverlayerWrap = await ethers.getContractFactory('OverlayerWrapMock');
  const overlayerWrap = await OverlayerWrap.deploy({
    admin: admin.address,
    lzEndpoint: await endpoint.getAddress(),
    name: 'O',
    symbol: 'O+',
    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: 31337n
  });

  const amt = ethers.parseEther('1');
  console.log('totalBridgedOut(before)=', (await overlayerWrap.totalBridgedOut()).toString());

  try {
    const tx = await overlayerWrap.testCredit(admin.address, amt, 0);
    await tx.wait();
    console.log('UNEXPECTED: credit succeeded');
  } catch (e) {
    console.log('credit reverted as expected');
    console.log(String(e.message).slice(0, 300));
  }

  console.log('totalBridgedOut(after)=', (await overlayerWrap.totalBridgedOut()).toString());
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});

Expected output

totalBridgedOut(before)= 0
credit reverted as expected
VM Exception while processing transaction: reverted with panic code 0x11 (Arithmetic operation overflowed outside of an unchecked block)
totalBridgedOut(after)= 0

Thank you for reading my submission!

Attachments

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