OverlayerWrap is an OFT (Omnichain Fungible Token) built on LayerZero V2. When tokens are bridged cross-chain, LayerZero calls two hooks:
_debit() runs on the source chain and burns the sender's tokens_credit() runs on the destination chain and mints tokens to the recipientTo track how many tokens have left the hub chain for collateral-backing calculations in AaveHandler, the team added a state variable totalBridgedOut and overrides both hooks in OverlayerWrapCore.sol, introduced in PR #70, commit f55efce:
// OverlayerWrapCore.sol L510-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; // correct: source chain tracks outbound tokens
}
// OverlayerWrapCore.sol L537-544
function _credit(address to_, uint256 amountLD_, uint32 srcEid_)
internal virtual override returns (uint256 amountReceivedLD)
{
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD; // BUG: runs on ALL chains unconditionally
}
totalBridgedOut is only ever incremented on the chain that sends tokens via _debit. The variable initializes to 0 on every freshly deployed contract instance.
When tokens arrive at a destination chain (non-hub), LayerZero calls _credit() on that chain. At this point the destination chain's totalBridgedOut is still 0 because it has never sent anything out. The subtraction 0 - amountReceivedLD triggers a Solidity 0.8.x arithmetic underflow panic (0x11), which reverts the entire lzReceive() call.
The onlyHubChain modifier already exists in the codebase and is correctly applied to _managerMint at line 358 and _managerRedeem at line 377. It was simply not applied to the _credit accounting update.
Step 1 — Current state (single chain, no bug triggered yet):
OverlayerWrap is deployed on Ethereum mainnet with hubChainId = 1. Users mint tokens by depositing USDT collateral. totalBridgedOut = 0, totalSupply > 0.
Step 2 — Team enables cross-chain:
Team deploys OverlayerWrap on Arbitrum with hubChainId = 1. Team calls setPeer() on both contracts to connect them via LayerZero.
Step 3 — First bridge, Ethereum to Arbitrum:
User calls send() on Ethereum's OverlayerWrap to bridge 100 USDT+ to Arbitrum.
On Ethereum (source chain), _debit() executes: burns 100 tokens from the user, totalBridgedOut = 100. LayerZero sends the cross-chain message to Arbitrum.
Step 4 — Delivery fails on Arbitrum:
LayerZero executor calls lzReceive() on Arbitrum's OverlayerWrap. _credit() executes:
super._credit() mints 100 tokens to the user (succeeds)totalBridgedOut -= 100 evaluates to 0 - 100, which is an arithmetic underflow panic 0x11Step 5 — Permanent loss, no recovery:
Tokens are permanently burned on Ethereum and never minted on Arbitrum. LayerZero marks the message as failed and queues it for retry. Every retry executes the same code path and reverts indefinitely. The message is stuck forever.
The contract is non-upgradeable, deployed via new OverlayerWrap(params) with no proxy pattern. The team cannot patch this without a full redeployment and manual compensation of all affected users.
All four cross-chain tests in test/OverlayerWrap.ts lines 1222 to 1364 deploy a single hub-chain instance with hubChainId = HARDHAT_CHAIN_ID and always call testDebit before testCredit on the same contract. They only test the round-trip on the hub chain. They never tested _credit on a non-hub instance where totalBridgedOut = 0.
function _credit(address to_, uint256 amountLD_, uint32 srcEid_)
internal virtual override returns (uint256 amountReceivedLD)
{
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
if (block.chainid == hubChainId) {
totalBridgedOut -= amountReceivedLD;
}
}
git clone https://github.com/Overlayerfi/contracts
cd contracts
git checkout 519c9e92fd9d80d11e35e9868130f6334b88d676
npm install
Create a file named process.env in the project root:
ADMIN_WALLET_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
TEAM_WALLET_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
USER_A_WALLET_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
USER_B_WALLET_KEY=0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6
USER_C_WALLET_KEY=0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a
ETHERSCAN_API_KEY=dummy
COINMARKETCAP_API_KEY=dummy
ALCHEMY_KEY=dummy
OVA_MAINNET_ROVA_DEPLOYER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
OVA_SEPOLIA_DEPLOYER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
OVA_SEPOLIA_TREASURY_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
In hardhat.config.ts, the fork config uses a private Alchemy URL. Change line 125 so the hardhat network uses the public RPC instead:
// Before:
url: PRIVATE_ETH_RPC_PREFIX + process.env.ALCHEMY_KEY!,
// After:
url: ETH_RPC,
Place the attached CreditUnderflowPOC.ts file inside the test/ directory, then run:
npx hardhat test test/CreditUnderflowPOC.ts --network hardhat
POC: _credit underflow on non-hub chain
✔ Hub chain: _debit succeeds and increments totalBridgedOut
✔ NON-HUB chain: _credit REVERTS due to totalBridgedOut underflow
✔ Hub chain: _credit works fine after _debit (tokens returning to hub)
✔ NON-HUB chain: deadlock - can't _debit first because no tokens exist
4 passing
Test 1 — _debit on the hub chain works correctly. totalBridgedOut increments as expected.
Test 2 — _credit on a non-hub chain reverts with panic 0x11. This is the core vulnerability. Alice's tokens are never received despite being burned on the source chain.
Test 3 — The hub chain round-trip works correctly. Tokens bridged out and returned to the hub chain are accounted for properly. The bug is specific to non-hub destination chains.
Test 4 — A non-hub chain is in a permanent deadlock. It cannot receive tokens because _credit underflows. It cannot send tokens because it has none. The chain can never participate in cross-chain transfers.
Run the following to confirm no proxy pattern is used:
grep -n "new OverlayerWrap" contracts/overlayer/OverlayerWrapFactory.sol
Output:
62: OverlayerWrap token = new OverlayerWrap(params);
105: OverlayerWrap token = new OverlayerWrap(params);
The contract is deployed directly with no proxy. There is no upgrade path. Fixing this bug requires redeploying all affected contract instances and manually compensating any users who lost funds during the window between peer activation and redeployment.