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 deploymentFor 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.
This breaks the intended OFT bridge path.
A normal user flow is:
OverlayerWrap on the hub._debit() burns the user's balance._credit() reverts on underflow.Result:
This is critical because the failure happens on the main advertised bridge path, not on an edge case.
The PoC shows two things:
totalBridgedOut, because minting reverts with OverlayerWrapCoreNotHubChainId.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);
totalBridgedOut should be hub-local accounting only.
The simplest fix is:
block.chainid == hubChainId inside _debit()block.chainid == hubChainId inside _credit()Also add a permanent regression test that uses two separate deployments:
PoC file:
test/OverlayerWrapBridgePoC.tsRun:
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);
});
});