Both
_debitand_creditshould only updatetotalBridgedOutwhenblock.chainid == hubChainId. TheonlyHubChainmodifier pattern already exists in the contract for exactly this purpose but was not applied to these two OFT hooks.OverlayerWrapCore.sol:77-82.
OverlayerWrapCore overrides the LayerZero OFT _debit() and _credit() hooks to maintain a totalBridgedOut counter. The intent, documented in the NatSpec, is to track tokens "in flight" so that AaveHandler can compute the effective token supply.
https://github.com/Overlayerfi/contracts/blob/519c9e92fd9d80d11e35e9868130f6334b88d676/contracts/overlayer/OverlayerWrapCore.sol#L543
537: function _credit(
538: address to_,
539: uint256 amountLD_,
540: uint32 srcEid_
541: ) internal virtual override returns (uint256 amountReceivedLD) {
542: amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
543: totalBridgedOut -= amountReceivedLD; // Reverts with Panic(0x11) if totalBridgedOut is 0
544: }
However, the _credit() function unconditionally decrements totalBridgedOut on every chain where the contract is deployed, without checking whether this instance is the hub chain or whether totalBridgedOut is sufficient to cover the subtraction. Under Solidity 0.8.x checked arithmetic, this reverts – permanently blocking cross-chain message delivery on destination spoke chains.
The totalBridgedOut state variable is meant to preserve the invariant:
totalSupply() + totalBridgedOut= effective collateral-backed supply
This is consumed by AaveHandler.supply() to prevent over-counting collateral when tokens are temporarily absent from the hub chain due to cross-chain OFT transfers.
The NatSpec for _debit() states:
"Tracks tokens leaving the hub chain so
AaveHandleraccounting remains correct. OFT burns tokens on the source chain during cross-chain sends; this override records the burned amount so thattotalSupply() + totalBridgedOutreflects the effective supply for collateral-backing calculations."
AaveHandler reads both values together:
_debit() – Source chain (Correct)When a user initiates an OFT cross-chain send from a chain, _debit() fires on that chain and increments totalBridgedOut:
_credit() – Destination chain (Bug)When tokens arrive at the destination chain, _credit() fires and unconditionally decrements totalBridgedOut:
The single-line decrement on line 543 is the root cause:
In the LayerZero OFT architecture:
_debit() executes on the source chain (tokens leave / are burned)_credit() executes on the destination chain (tokens arrive / are minted)These two calls happen on different contract instances with completely independent state. Each deployed instance holds its own totalBridgedOut, initialized at 0.
Hub Chain (H) Spoke Chain (S)
───────────────── ─────────────────
totalBridgedOut = 0 totalBridgedOut = 0
Failure scenario – Hub → Spoke bridge:
When a user bridges tokens from Hub to Spoke:
_debit() fires on Chain H → H.totalBridgedOut += amount ✅_credit() fires on Chain S → S.totalBridgedOut -= amount — REVERTS because S.totalBridgedOut = 0 < amountSolidity 0.8.x performs checked arithmetic by default, so the subtraction at line 543 reverts with an arithmetic underflow panic, and the LayerZero cross-chain message cannot be processed on the destination chain.
The hubChainId state variable exists and is used elsewhere to restrict operations to the hub:
However, _credit() does not apply this same hub-chain guard before modifying totalBridgedOut.
On any destination chain (Arbitrum, Optimism, etc.), totalBridgedOut is initialized to 0. When a user bridges tokens from the Hub chain to a destination chain, the LayerZero executor calls _credit to mint the tokens for the user.
Because totalBridgedOut is 0, the operation 0 - amountReceivedLD triggers an arithmetic underflow in Solidity 0.8.x. This causes the entire transaction to revert.
Users can send tokens out of the Hub, but they can never receive them on any other chain. Tokens become effectively stuck in the bridge.
Any OFT _credit call on a spoke chain whose totalBridgedOut < amountReceivedLD will revert. For a freshly deployed spoke with totalBridgedOut = 0, every incoming cross-chain transfer fails.
If the LayerZero executor exhausts retries on the failed _credit message, tokens burned on the source chain cannot be recovered on the destination, leading to permanent loss.
if _credit reverts for a returning transfer, the effective supply formula totalSupply + totalBridgedOut overstates the backed supply, allowing more collateral to be drawn than is actually available.
If the hub receives more tokens via _credit than were ever _debited from it (e.g., after a contract upgrade or re-deployment), H.totalBridgedOut also underflows and the hub chain is equally affected.
The following PoC demonstrates the failure by simulating a non-hub chain environment.
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { ethers } from "hardhat";
import { expect } from "chai";
describe("BugPoC - OverlayerWrapCore Arithmetic Underflow", function () {
async function deployFixture() {
const [admin, alice] = await ethers.getSigners();
const Collateral = await ethers.getContractFactory("SixDecimalsUsd");
const collateral = await Collateral.deploy(1000, "COLLATERAL", "COLLATERAL");
const aCollateral = await ethers.getContractFactory("SixDecimalsUsd");
const acollateral = await aCollateral.deploy(1000, "aCOLLATERAL", "aCOLLATERAL");
// We need a dummy LZ endpoint to initialize the contract
const LZEndpointMock = await ethers.getContractFactory("LZEndpointMock");
const lzEndpoint = await LZEndpointMock.deploy();
// We deploy as if this is NOT the hub chain.
// Hardhat defaults to chainId 31337. We set hubChainId to 1 (Ethereum Mainnet).
const hubChainId = 1;
const OverlayerWrap = await ethers.getContractFactory("OverlayerWrapMock");
const overlayerWrap = await OverlayerWrap.deploy({
admin: await admin.getAddress(),
lzEndpoint: await lzEndpoint.getAddress(),
name: "OverlayerWrap",
symbol: "OW",
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: hubChainId
});
return { overlayerWrap, alice };
}
it("CRITICAL: _credit reverts with arithmetic underflow on non-hub chains", async function () {
const { overlayerWrap, alice } = await loadFixture(deployFixture);
const amountToCredit = ethers.parseEther("10");
// totalBridgedOut is 0 on a freshly deployed non-hub chain contract
const initialTotalBridgedOut = await overlayerWrap.totalBridgedOut();
expect(initialTotalBridgedOut).to.equal(0);
// This call to _credit (via testCredit helper in the mock) simulates receiving 10 tokens via bridging.
// In OverlayerWrapCore.sol:543, it executes: totalBridgedOut -= amountReceivedLD;
// Since 0 < 10, this will always trigger a Panic(0x11) Arithmetic Underflow in Solidity 0.8.x.
await expect(
overlayerWrap.testCredit(alice.address, amountToCredit, 0)
).to.be.revertedWithPanic(0x11);
});
});
Executed via: npm run unit test/BugPoC.ts -- --network hardhat
> [email protected] unit
> npx hardhat test test/BugPoC.ts --network hardhat
BugPoC - OverlayerWrapCore Arithmetic Underflow
Attempting to credit tokens on a non-hub chain...
Current totalBridgedOut: 0
Successfully demonstrated the revert with Panic(0x11) (Arithmetic Underflow)
✔ Should revert with arithmetic underflow in _credit when totalBridgedOut is 0 (Non-Hub Chain) (2s)
·------------------------|---------------------------|-------------|-----------------------------·
| Solc version: 0.8.20 · Optimizer enabled: true · Runs: 300 · Block limit: 30000000 gas │
·························|···························|·············|······························
| Methods │
··············|··········|·············|·············|·············|···············|··············
| Contract · Method · Min · Max · Avg · # calls · usd (avg) │
··············|··········|·············|·············|·············|···············|··············
| Deployments · · % of limit · │
·························|·············|·············|·············|···············|··············
| LZEndpointMock · - · - · 108945 · 0.4 % · - │
·························|·············|·············|·············|···············|··············
| OverlayerWrapMock · - · - · 5833014 · 19.4 % · - │
·························|·············|·············|·············|···············|··············
| SixDecimalsUsd · 783791 · 783815 · 783803 · 2.6 % · - │
·------------------------|-------------|-------------|-------------|---------------|-------------·
1 passing (2s)
The accounting for totalBridgedOut should only be performed on the hub chain. Wrap the decrement (and increment in _debit) in a check for hubChainId.
function _credit(...) ... {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
+ if (block.chainid == hubChainId) {
totalBridgedOut -= amountReceivedLD;
}
}