OverlayerWrapCore._credit() (line 543) performs totalBridgedOut -= amountReceivedLD, which reverts on underflow in Solidity 0.8. On any non-hub deployment where totalBridgedOut starts at zero, every incoming cross-chain credit reverts. Tokens burned on the hub via _debit() can never be minted on the spoke, permanently locking bridged user funds under the current contract logic.
OverlayerWrapCore.sol (line 543)519c9e92fd9d80d11e35e9868130f6334b88d676_credit()High Likelihood:
send() triggers itHigh Impact:
totalBridgedOut; contract is not upgradeabletotalBridgedOut was introduced in commit f55efce / PR #70 to fix F-2026-15250 (High): OFT burns reduced hub totalSupply(), causing underflow in AaveHandler.supply(). The fix added totalBridgedOut tracking so totalSupply() + totalBridgedOut reflects effective supply.
This report identifies a regression in that fix: _credit() unconditionally decrements totalBridgedOut without checking if the subtraction is valid on the current chain. On a spoke where totalBridgedOut was never incremented, the subtraction always underflows.
I did not identify a public GitHub issue referencing this spoke-chain underflow.
| Prior (F-2026-15250) | This finding | |
|---|---|---|
| Function | AaveHandler.supply() |
OverlayerWrapCore._credit() |
| Chain | Hub only | Non-hub only |
| Impact | DoS on supply() (High) | Permanent lock of bridged funds (Critical) |
| Root cause | totalSupply drops below accounting expectation | _credit() unconditionally decrements totalBridgedOut without chain-awareness |
// OverlayerWrapCore.sol line 537-544
function _credit(address to_, uint256 amountLD_, uint32 srcEid_)
internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD; // UNDERFLOWS when totalBridgedOut == 0
}
totalBridgedOut starts at 0 on every deployment and is only incremented by _debit(). On a spoke:
_debit() requires tokens to burn — but spoke has none (minting blocked by onlyHubChain)_credit() is the only token entry path — but it underflows because totalBridgedOut == 0totalBridgedOut: Written only by _debit (+) and _credit (-).onlyHubChain: Blocks mint/redeem on spoke — cross-chain credit is the sole entry path.The Hacken audit (12/03/2026) describes the protocol on Ethereum and Base with LayerZero OFT. The onlyHubChain modifier establishes a designated hub with non-hub deployments. The vulnerability manifests on any deployment where hubChainId != block.chainid — the current hub/spoke model, not a hypothetical configuration.
send()._debit() burns 50 tokens._credit() → totalBridgedOut -= 50 → UNDERFLOW REVERT.The PoC exercises the same _debit()/_credit() hooks that OFT send()/lzReceive() uses, following the same methodology as the prior Hacken audit's PoC for F-2026-15250.
File: test/CreditUnderflow.ts | Run: npx hardhat test test/CreditUnderflow.ts --network hardhat
Results (5/5 pass — hub + spoke simulation):
Test 1: Hub baseline — debit/credit round-trip works correctly.
Test 2: Spoke _credit() reverts with panic(0x11) — arithmetic underflow.
Test 3: Spoke mint() reverts with OverlayerWrapCoreNotHubChainId.
Test 4: Spoke deadlock — credit, mint, and debit all revert. No escape.
Test 5: Full chain — Alice mints 100, bridges 50, spoke reverts. Alice has 50 accessible, 50 permanently locked (50% loss).
Only track totalBridgedOut on the hub chain:
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; }
}
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_);
if (block.chainid == hubChainId) { totalBridgedOut += amountSentLD; }
}
OverlayerWrapCore.sol at commit 519c9e92fd9d80d11e35e9868130f6334b88d676.AaveHandler.supply() DoS). This targets spoke _credit()._credit() underflow. PR #70 and PR #46/#44 address related but distinct issues.git clone https://github.com/Overlayerfi/contracts.git
cd contracts
git checkout 519c9e92fd9d80d11e35e9868130f6334b88d676
npm install --force
The PoC uses EndpointV2Mock from @layerzerolabs/oapp-evm and does not interact with any mainnet contracts. However, Ethereum mainnet forking must be enabled in hardhat.config.ts because the repo's Solidity compilation requires the Paris EVM target (set automatically when forking is active). Ensure forking is enabled with any valid Ethereum RPC:
hardhat: {
forking: {
url: "https://eth-mainnet.public.blastapi.io", // or your own Alchemy/Infura URL
enabled: true,
blockNumber: 22917626,
},
allowUnlimitedContractSize: true,
...
}
Place the attached CreditUnderflow.ts file in test/.
npx hardhat test test/CreditUnderflow.ts --network hardhat
Proves the hub chain path works correctly: debit increments totalBridgedOut, credit decrements it. Establishes that the bug is spoke-specific.
=== TEST 1: HUB BASELINE ===
Hub debit 20 -> totalBridgedOut=20 -> credit 20 -> totalBridgedOut=0
Hub chain works correctly (totalBridgedOut was incremented by prior debit).
Deploys a separate spoke instance with hubChainId != block.chainid. Proves _credit() reverts with arithmetic underflow (panic 0x11) because totalBridgedOut is 0.
=== TEST 2: SPOKE _credit REVERTS ===
Spoke hubChainId: 8453 (different from block.chainid: 31337)
totalBridgedOut = 0, credit amount = 20 OW
Result: REVERTED with panic(0x11) — arithmetic underflow
Spoke chain can NEVER receive tokens via _credit.
Proves minting is impossible on spoke chains, confirming _credit is the ONLY token entry path.
=== TEST 3: SPOKE CANNOT MINT ===
mint() reverts with OverlayerWrapCoreNotHubChainId on spoke.
The ONLY way to get tokens on spoke is via _credit — which always reverts.
Proves the chicken-and-egg deadlock with hard assertions: _credit reverts (underflow), _debit reverts (no tokens to burn), mint reverts (onlyHubChain). No operation can break the cycle.
=== TEST 4: SPOKE PERMANENT DEADLOCK ===
Cannot _credit: underflow revert
Cannot mint: onlyHubChain blocks it
Cannot _debit: no tokens exist to burn
DEADLOCK: No admin setter for totalBridgedOut. Not upgradeable.
End-to-end: Alice mints 100 OW on hub, bridges 50 to spoke. Hub burns 50 via _debit. Spoke _credit reverts. Alice permanently loses 50% of her tokens.
=== TEST 5: FULL ATTACK CHAIN ===
Step 1: Alice mints 100.0 OW on hub
Step 2: Alice bridges 50.0 OW from hub (burned via _debit)
Step 3: Spoke _credit REVERTS (totalBridgedOut=0, underflow)
Step 4: Alice's accessible funds:
Hub balance: 50.0 OW
Spoke balance: 0.0 OW
Total: 50.0 OW
Started with: 100.0 OW
LOST: 50.0 OW (50%)
CreditUnderflow.ts — Complete runnable PoC (5 tests, hub + spoke instances). Place in test/ and run with the command above.The PoC deploys two OverlayerWrapMock instances on the same Hardhat network:
hubChainId = 31337 (matches block.chainid) — minting allowedhubChainId = 8453 (does NOT match block.chainid) — minting blocked by onlyHubChainBoth use EndpointV2Mock from @layerzerolabs/oapp-evm. The testDebit()/testCredit() functions on OverlayerWrapMock expose the internal _debit()/_credit() hooks. In the real LayerZero OFT flow, OFTCore.send() calls _debit() on the source chain to burn tokens and send a cross-chain message; the destination endpoint then calls OFTCore._lzReceive() which calls _credit() to mint tokens. The PoC exercises these exact hooks directly, matching the methodology used in the prior Hacken audit's PoC for F-2026-15250.