The fix for known issue F-2026-15250 introduced a totalBridgedOut counter in OverlayerWrapCore.sol to track tokens leaving the hub chain via LayerZero OFT cross-chain sends. The _debit override increments this counter, and the _credit override decrements it:
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD; // @audit UNDERFLOW on non-hub chains
}
The _credit function is called on the destination chain when tokens arrive via a cross-chain bridge transfer. On the destination, super._credit() mints tokens to the recipient and returns the minted amount. The override then subtracts this amount from totalBridgedOut.
The root cause: On a non-hub chain deployment, totalBridgedOut is initialized to 0 and can never be incremented, because:
_debit (which increments totalBridgedOut) requires the caller to own tokens to burn._credit (bridging in), since mint() is restricted by the onlyHubChain modifier._credit requires totalBridgedOut >= amountReceivedLD to avoid underflow.This creates a chicken-and-egg deadlock: tokens cannot arrive on the non-hub chain (because _credit reverts), so no tokens exist to _debit (which would increment totalBridgedOut and enable future _credit calls).
Execution trace for a user bridging from hub to non-hub:
send() on the hub chain OverlayerWrap (LayerZero OFT send)_debit() runs successfully — burns user's tokens, increments hub's totalBridgedOutlzReceive() → _credit() is invokedsuper._credit() attempts to mint tokens — succeeds within the calltotalBridgedOut -= amountReceivedLD → arithmetic underflow (0 - positive number) → Reverts_credit call reverts, including the mint from step 5totalBridgedOut on the non-hub chain remains permanently 0The user's tokens are burned on the hub chain but never minted on the destination chain. In LayerZero v2, the failed message can be retried, but every retry produces the same underflow revert. The tokens are permanently lost (burned on source, never credited on destination) unless the protocol team deploys a patched contract on the non-hub chain and manually intervenes.
The totalBridgedOut state variable has no setter, no initialization path, and no admin override:
/// @notice Cumulative amount of tokens debited via OFT cross-chain transfers
uint256 public totalBridgedOut; // initialized to 0
Note: On the hub chain, _credit works correctly because totalBridgedOut is properly incremented by prior _debit calls (tokens can only return to hub after being sent from hub). The bug is exclusively on non-hub chain deployments.
Any user who initiates a cross-chain transfer from the hub chain to any non-hub chain will have their tokens burned on the source chain and never credited on the destination chain. The funds are permanently lost.
The totalBridgedOut counter is only meaningful on the hub chain for the AaveHandler.supply() accounting invariant. On non-hub chains, it should not be decremented in _credit.
Create a new file test/CreditUnderflow_PoC.ts and run with:
npx hardhat test test/CreditUnderflow_PoC.ts --network localhost
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { ethers } from "hardhat";
import { expect } from "chai";
import { LZ_ENDPOINT_ETH_MAINNET_V2 } from "../scripts/addresses";
import { HARDHAT_CHAIN_ID } from "../scripts/constants";
describe("OverlayerWrapCore._credit underflow on non-hub chain", function () {
// Deploys OverlayerWrapMock as if it were a NON-HUB chain
// (hubChainId set to a different chain id than the local Hardhat chain)
async function deployNonHubFixture() {
const [admin, alice] = await ethers.getSigners();
const block = await admin.provider.getBlock("latest");
const baseFee = block?.baseFeePerGas ?? 1n;
const txOpts = { maxFeePerGas: baseFee * 10n };
const Collateral = await ethers.getContractFactory("SixDecimalsUsd");
const collateral = await Collateral.deploy(1000, "C", "C", txOpts);
const acollateral = await Collateral.deploy(1000, "aC", "aC", txOpts);
const OverlayerWrap = await ethers.getContractFactory("OverlayerWrapMock");
// CRITICAL: hubChainId is set to (HARDHAT_CHAIN_ID + 1), so the local
// Hardhat chain is NOT the hub. This is the non-hub deployment scenario.
const NON_HUB_HUB_ID = BigInt(HARDHAT_CHAIN_ID) + 1n;
const overlayerWrap = await OverlayerWrap.deploy(
{
admin: await admin.getAddress(),
lzEndpoint: LZ_ENDPOINT_ETH_MAINNET_V2,
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: NON_HUB_HUB_ID
},
txOpts
);
await overlayerWrap.waitForDeployment();
return { overlayerWrap, admin, alice };
}
it("PoC: starting state on a non-hub chain — totalBridgedOut == 0 and totalSupply == 0", async () => {
const { overlayerWrap } = await loadFixture(deployNonHubFixture);
expect(await overlayerWrap.totalBridgedOut()).to.equal(0n);
expect(await overlayerWrap.totalSupply()).to.equal(0n);
});
it("PoC: mint() is unreachable on non-hub chain (so _debit can never increment totalBridgedOut)", async () => {
const { overlayerWrap, admin } = await loadFixture(deployNonHubFixture);
const order = {
benefactor: admin.address,
beneficiary: admin.address,
collateral: ethers.ZeroAddress,
collateralAmount: 0n,
overlayerWrapAmount: ethers.parseEther("1")
};
await expect(
overlayerWrap.connect(admin).mint(order)
).to.be.revertedWithCustomError(overlayerWrap, "OverlayerWrapCoreNotHubChainId");
});
it("PoC: first inbound LayerZero credit on non-hub chain reverts with arithmetic underflow", async () => {
const { overlayerWrap, alice } = await loadFixture(deployNonHubFixture);
expect(await overlayerWrap.totalBridgedOut()).to.equal(0n);
const bridgedAmount = ethers.parseEther("100");
const srcEid = 1;
await expect(
overlayerWrap.connect(alice).testCredit(alice.address, bridgedAmount, srcEid)
).to.be.revertedWithPanic(0x11);
expect(await overlayerWrap.balanceOf(alice.address)).to.equal(0n);
expect(await overlayerWrap.totalBridgedOut()).to.equal(0n);
expect(await overlayerWrap.totalSupply()).to.equal(0n);
});
it("PoC: every retry continues to revert — the failure is permanent (chicken-and-egg deadlock)", async () => {
const { overlayerWrap, alice } = await loadFixture(deployNonHubFixture);
for (let i = 0; i < 5; i++) {
await expect(
overlayerWrap
.connect(alice)
.testCredit(alice.address, ethers.parseEther("1"), 1)
).to.be.revertedWithPanic(0x11);
expect(await overlayerWrap.totalBridgedOut()).to.equal(0n);
expect(await overlayerWrap.balanceOf(alice.address)).to.equal(0n);
}
});
});