The _credit() override in OverlayerWrapCore.sol (line 543) unconditionally decrements totalBridgedOut on every chain.
Current deployment context: This issue is evaluated against the currently deployed Sepolia (hub) and Base Sepolia (spoke) OFT topology, where both contracts are live, hubChainId() and totalBridgedOut() are callable on-chain, and bidirectional peer wiring is configured.
On spoke chains, totalBridgedOut is initialized to zero and can never be increased (no tokens exist to _debit). Any inbound OFT bridge transfer triggers 0 - amount, which reverts with an arithmetic underflow under Solidity 0.8.20. Since the source chain has already burned the user's tokens via _debit(), the user permanently loses 100% of the bridged amount with no recovery path.
Critical
Broken invariant: Cross-chain OFT bridge transfers must mint on the destination chain the exact tokens burned on the source chain.
Impact quantification: 100% of any user's bridged amount is permanently lost. Every subsequent bridge attempt to the same spoke chain also fails. This affects inbound transfers to the currently deployed Base Sepolia spoke topology.
| Criteria | Assessment |
|---|---|
| Permanent lock/loss of bridged user funds | Yes - user's bridged tokens are burned on source but never minted on destination |
| Permanent lockout | Yes - spoke chain inbound bridge is permanently bricked, no recovery path exists |
| Preconditions | None - the very first inbound transfer triggers the bug unconditionally |
| Repeatability | Every bridge attempt fails identically |
| Scope | Any user who bridges to the currently deployed Base Sepolia spoke topology |
| Privileged role required | No - any token holder calling send() triggers this |
| Amount threshold | Any amount, including 1 wei |
Why Critical: This vulnerability has both high likelihood (zero preconditions, no privileged role, any user can trigger on the first bridge attempt) and high impact (permanent loss exceeding 1% of user's deposit - in fact 100% of the bridged amount is lost).
OverlayerWrapCore.sol, lines 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
}
The NatSpec at lines 531-532 states this function "decrements totalBridgedOut to track tokens returning to the hub chain." However, the code applies this decrement on all chains, not just the hub. On spoke chains where hubChainId != block.chainid, totalBridgedOut starts at zero and can never be increased (there are no tokens to _debit), so any subtraction underflows.
The corresponding _debit() at lines 510-528 has the same issue - it increments totalBridgedOut on all chains, but this direction does not cause an immediate revert (it only creates accounting inaccuracy on spoke chains).
The OverlayerWrap token uses a hub-and-spoke OFT (Omnichain Fungible Token) architecture built on LayerZero V2:
totalBridgedOut to maintain the accounting invariant totalSupply() + totalBridgedOut == effective_circulating_supplyWhen a user bridges tokens from hub to spoke:
_debit() burns the user's tokens and executes totalBridgedOut += amount (succeeds)_credit() calls super._credit() to mint tokens, then executes totalBridgedOut -= amounttotalBridgedOut on spoke is 0: 0 - amount = arithmetic underflow (Solidity 0.8.20 checked math)_credit reverts, which reverts the lzReceive call on the LayerZero endpointAfter the revert, the spoke chain is in an unrecoverable state:
totalBridgedOut remains 0: The reverted transaction changed no state_credit will always fail: Every retry encounters the same 0 - amount underflow_debit cannot help: _debit could increment totalBridgedOut, but it requires burning tokens via _burn(from, amount). Since totalSupply on the spoke is 0, nobody has tokens to burn. This is a chicken-and-egg deadlock.totalBridgedOut is a plain uint256 public variable (line 63) with no setter function. The only write paths are _debit (+= on line 527) and _credit (-= on line 543).When _credit() reverts on the spoke chain:
EndpointV2.lzReceive() transaction reverts entirely_clearPayload from line 180 of EndpointV2.sol is rolled back)lzReceive() again - but every retry hits the same underflowendpoint.clear() to burn the message, but this only removes the stuck message without crediting tokens - the user's funds remain lostendpoint.skip() and endpoint.nilify() similarly cannot credit tokensNo LayerZero V2 mechanism can recover the user's funds.
No attacker is required - this is triggered by normal protocol usage:
send() (the standard OFT cross-chain transfer) targeting a spoke chain_debit() burns user's tokens, totalBridgedOut += amount - succeeds_credit() attempts totalBridgedOut -= amount where totalBridgedOut = 0 - REVERTSThis vulnerability exists in the currently deployed protocol topology:
| Property | Hub (Sepolia) | Spoke (Base Sepolia) |
|---|---|---|
| Address | 0x9Ea94deD198e296D744FD3bcC5A62259fD8313F5 |
0x69da313e37462fa536dcba74d9d4b83e1c003a38 |
| Chain ID | 11155111 | 84532 |
hubChainId() |
11155111 (== local = HUB) | 11155111 (!= local = SPOKE) |
totalBridgedOut() |
0 | 0 |
totalSupply() |
101 tokens | 0 tokens |
| Bytecode size | 24,321 bytes | 24,321 bytes |
totalBridgedOut() selector in bytecode |
Present | Present |
| Peer config | peers(40245) -> Spoke | peers(40161) -> Hub |
| Bidirectional wiring | Yes | Yes |
Both contracts are live, the _credit/_debit/totalBridgedOut logic is present in both bytecodes (verified via selector search: 0xcd2e9866), and the peer configuration is bidirectional. The spoke chain has totalBridgedOut = 0 and totalSupply = 0, exactly matching the vulnerable precondition.
git clone https://github.com/Overlayerfi/contracts.git
cd contracts
git checkout 519c9e92fd9d80d11e35e9868130f6334b88d676
npm install
The PoC requires two helper files already present in the repository or provided below:
contracts/test/EndpointV2MockMinimal.sol - Minimal LayerZero EndpointV2 mock (satisfies OAppCore constructor):// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract EndpointV2MockMinimal {
uint32 public immutable eid;
mapping(address => address) public delegates;
constructor(uint32 _eid) {
eid = _eid;
}
function setDelegate(address _delegate) external {
delegates[msg.sender] = _delegate;
}
function quote(
address, uint32, bytes calldata, bool, bytes calldata
) external pure returns (uint256 nativeFee, uint256 lzTokenFee) {
return (0, 0);
}
function send(
address, uint32, bytes calldata, bytes calldata, address payable
) external payable returns (bytes32) {
return bytes32(0);
}
}
hardhat.config.poc.ts - Minimal Hardhat config for PoC compilation:import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@nomicfoundation/hardhat-ethers";
import "@nomicfoundation/hardhat-chai-matchers";
const config: HardhatUserConfig = {
paths: {
sources: "./contracts",
tests: "./test",
cache: "./cache_poc",
artifacts: "./artifacts_poc",
},
solidity: {
compilers: [
{
version: "0.8.20",
settings: { optimizer: { enabled: true, runs: 300 } },
},
{
version: "0.8.10",
settings: { optimizer: { enabled: true, runs: 999 } },
},
{
version: "0.7.6",
settings: {
optimizer: { enabled: true, runs: 1_000_000 },
metadata: { bytecodeHash: "none" },
},
},
],
},
defaultNetwork: "hardhat",
networks: {
hardhat: { allowUnlimitedContractSize: true },
},
};
export default config;
The mock endpoint is not used to simulate a LayerZero-specific bug. It only satisfies constructor and interface requirements so the in-scope _debit() / _credit() logic can be executed in isolation in a local test environment. The failing condition is entirely caused by the in-scope arithmetic on totalBridgedOut in OverlayerWrapCore.sol, which is why this minimal mock is sufficient and faithful for reproducing the issue.
Some out-of-scope contracts (e.g., contracts/uniswap/, contracts/curve/) may have missing local imports unrelated to the in-scope code. If compilation fails due to these files, temporarily move them out of the contracts/ directory. The in-scope contracts and the PoC compile and run independently of these files.
HARDHAT_CONFIG=hardhat.config.poc.ts npx hardhat test test/CriticalSpokeUnderflowPoC.ts
CRITICAL: Spoke Chain _credit Underflow - Permanent Fund Loss
✔ TEST 1: First inbound _credit to spoke with totalBridgedOut=0 deterministically underflows
✔ TEST 2: Repeated retries always fail - state never changes
✔ TEST 3: Cannot increase totalBridgedOut via _debit - zero supply deadlock
✔ TEST 4: No admin function exists to set totalBridgedOut
✔ TEST 5: Hub-side _debit burns tokens and increments totalBridgedOut
✔ TEST 6: End-to-end - hub burn succeeds, spoke credit fails, funds permanently lost
✔ TEST 7: Hub _credit works correctly - proving spoke-specific failure
7 passing
TEST 6 prints the full damage assessment:
=== PERMANENT DAMAGE ===
Alice total across chains: 20.0 OW+
Alice started with: 50.0 OW+
Permanent loss: 30.0 OW+
Loss percentage: 60%
| Test | Proves |
|---|---|
| TEST 1 | Any amount (1 wei to 1000 tokens) triggers underflow on spoke. Zero preconditions. |
| TEST 2 | Retries after different intervals and by different callers all fail identically. State never changes. This is permanent, not temporary. |
| TEST 3 | _debit cannot break the deadlock because spoke has zero token supply. Chicken-and-egg: need tokens to debit, need debit to fix credit. |
| TEST 4 | No admin setter or rescue function exists for totalBridgedOut. |
| TEST 5 | Hub-side _debit successfully burns tokens and increments totalBridgedOut. The source-side loss is real. |
| TEST 6 | End-to-end flow: hub burn succeeds + spoke credit fails = measurable permanent loss (30 out of 50 tokens = 60%). |
| TEST 7 | Hub _credit works correctly (debit then credit round-trips fine), proving the bug is spoke-chain-specific. |
The full PoC is in test/CriticalSpokeUnderflowPoC.ts (provided as a separate file). See the "Steps to Reproduce" section above for exact run commands and expected output.
Add a chain guard to restrict totalBridgedOut accounting to the hub chain only:
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
- totalBridgedOut -= amountReceivedLD;
+ 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_
);
- totalBridgedOut += amountSentLD;
+ if (block.chainid == hubChainId) {
+ totalBridgedOut += amountSentLD;
+ }
}
This fix ensures totalBridgedOut is only modified on the hub chain (where it is meaningful) and is a no-op on spoke chains (where it has no purpose and causes the underflow). The fix preserves the existing hub chain accounting invariant totalSupply() + totalBridgedOut == effective_supply and has no effect on any other functionality. The hubChainId immutable is already available in the contract (line 61, set during _initialize at line 319).