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.
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:
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.
I have observed that a standard user bridge flow can fail as follows:
_debit() succeeds on the source and burns or reduces the source-side balance while incrementing source totalBridgedOut._credit()._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.
I have analysed the accounting intent and observed that the safest fix is to make totalBridgedOut hub-side accounting only.
A simple mitigation is:
totalBridgedOut in _debit() only when the current deployment is the hub-side backing domaintotalBridgedOut in _credit() only when tokens are being credited back into that same hub-side backing domainIn 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.
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.
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;
}
}
File: test/OverlayerWrap.crosschain-lock.poc.ts
OverlayerWrapMockOverlayerWrapMockhub.testDebit(...) to simulate the source-side bridge debithub.totalBridgedOut() increasedsatellite.totalBridgedOut() is still 0satellite.testCredit(...)Panic(0x11)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);
});
});
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
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)
I have observed the following from the successful PoC run:
totalBridgedOut increases as expectedtotalBridgedOut remains zero_credit() reverts with Panic(0x11)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)