Severity: Critical - Any user attempting to bridge tokens from the hub chain to any spoke chain will have their tokens permanently burned on the source chain while the destination chain reverts, resulting in irreversible loss of funds.
Affected Code: contracts/overlayer/OverlayerWrapCore.sol:537-544
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 spoke chains
}
And the corresponding _debit at line 510-528:
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;
}
Root Cause:
The totalBridgedOut state variable starts at 0 (Solidity default) on every chain where OverlayerWrap is deployed. The _credit function, called by LayerZero when tokens
arrive on a destination chain, unconditionally subtracts from totalBridgedOut. On spoke chains (any chain where block.chainid != hubChainId):
Therefore, no tokens can ever exist on spoke chains to trigger _debit
This creates an impossible chicken-and-egg situation: tokens cannot arrive on spoke chains (because _credit underflows), and tokens cannot be sent from spoke chains (because none exist to send).
Exploit Scendario:
Impact:
Permanent, irreversible loss of user funds. Any tokens sent via LayerZero OFT bridge from the hub chain to any spoke chain are:
The multi-chain architecture is core to the protocol design (evidenced by LayerZero OFT inheritance, hubChainId parameter, eth_sepolia/arbitrum_sepolia network
configurations with LayerZero endpoint IDs, and the totalBridgedOut tracking in AaveHandler.supply()).
Remediation:
The _credit override should only decrement totalBridgedOut on the hub chain, since that's the only chain where the accounting invariant (totalSupply + totalBridgedOut =
effective supply) matters for collateral backing:
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
// Only track on hub chain where the backing accounting requires it
if (block.chainid == hubChainId) {
totalBridgedOut -= amountReceivedLD;
}
}
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_
);
// Only track on hub chain
if (block.chainid == hubChainId) {
totalBridgedOut += amountSentLD;
}
}
This preserves the effective-supply invariant on the hub chain (where AaveHandler.supply() uses totalSupply() + totalBridgedOut()) while allowing spoke chains to receive
and send tokens normally via the standard OFT mint/burn mechanism.
PoC (Hardhat):
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { ethers } from "hardhat";
import { expect } from "chai";
describe("OverlayerWrapCore _credit Underflow PoC", function () {
async function deploySpokeFixture() {
const [admin, alice] = await ethers.getSigners();
const block = await admin.provider.getBlock("latest");
const baseFee = block?.baseFeePerGas ?? 1n;
const txOpts = { maxFeePerGas: baseFee * 10n };
// Deploy mock collateral tokens
const Collateral = await ethers.getContractFactory("SixDecimalsUsd");
const collateral = await Collateral.deploy(1000, "USDT", "USDT", txOpts);
const aCollateral = await Collateral.deploy(1000, "aUSDT", "aUSDT", txOpts);
// Get the real LZ endpoint address (for constructor only)
const LZ_ENDPOINT = "0x1a44076050125825900e736c501f859c50fE728c";
// Deploy OverlayerWrapMock as a SPOKE chain:
// hubChainId is set to 1 (Ethereum mainnet), but we're on Hardhat (chainId 31337)
// This simulates deploying OverlayerWrap on a non-hub chain (e.g., Arbitrum)
const OverlayerWrap = await ethers.getContractFactory("OverlayerWrapMock");
const spokeOverlayerWrap = await OverlayerWrap.deploy(
{
admin: admin.address,
lzEndpoint: LZ_ENDPOINT,
name: "Tether USD+",
symbol: "USDT+",
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: 1, // <-- Hub is Ethereum mainnet, NOT this chain
},
txOpts
);
return { spokeOverlayerWrap, alice };
}
it("CRITICAL: _credit reverts on spoke chain due to totalBridgedOut underflow", async function () {
const { spokeOverlayerWrap, alice } = await loadFixture(deploySpokeFixture);
// Verify totalBridgedOut starts at 0 on spoke chain
expect(await spokeOverlayerWrap.totalBridgedOut()).to.equal(0);
// Verify minting is impossible on spoke chain (onlyHubChain modifier)
// This means no tokens can ever exist on this chain to send out via _debit
// Therefore totalBridgedOut can NEVER be incremented above 0
// Simulate what happens when LayerZero delivers a cross-chain message
// (i.e., someone bridged 10 USDT+ from the hub chain to this spoke chain)
const bridgeAmount = ethers.parseEther("10");
// _credit attempts: totalBridgedOut -= 10e18, which is 0 - 10e18 = UNDERFLOW
await expect(
spokeOverlayerWrap.testCredit(alice.address, bridgeAmount, 0)
).to.be.revertedWithPanic(0x11); // 0x11 = arithmetic underflow/overflow
// User's 10 USDT+ were burned on the hub chain via _debit,
// but can NEVER be minted here. Funds are permanently lost.
expect(await spokeOverlayerWrap.totalSupply()).to.equal(0);
expect(await spokeOverlayerWrap.totalBridgedOut()).to.equal(0);
});
});
The above code demonstrates that testCredit (which calls internal _credit) reverts with panic code 0x11 (arithmetic underflow) on a spoke chain instance.