Status DataClose notification

Overlayer Disclosed Report

Integer Underflow in OverlayerWrapCore._credit() Permanently Locks User Funds on Cross-Chain Transfers to Non-Hub Chains

Company
Created date
Mar 28 2026

Target

hidden

Vulnerability Details

File: contracts/overlayer/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; // UNDERFLOW: totalBridgedOut == 0 on spoke chains }

Root Cause

totalBridgedOut starts at 0 on every contract instance. On spoke chains:

  • mint() is blocked by onlyHubChain(block.chainid) modifier (line 358)
  • Without tokens, _debit() cannot be called (nothing to burn)
  • Therefore totalBridgedOut can NEVER be incremented above 0
  • Every _credit() call attempts 0 - N, causing arithmetic underflow and revert

Attack Flow

  1. User holds OverlayerWrap tokens on the hub chain (e.g. Ethereum)
  2. User initiates OFT send() to bridge tokens to a spoke chain (e.g. Optimism, Arbitrum, both listed in docs/deployments.md)
  3. Hub chain: _debit() burns user tokens, totalBridgedOut += amount, succeeds
  4. LayerZero relays the message to the spoke chain
  5. Spoke chain: _credit() calls super._credit() (would mint tokens), then executes totalBridgedOut -= amount where totalBridgedOut == 0, reverts with panic 0x11
  6. LayerZero stores the message as failed. Retries also revert (same structural code, no state change can fix it)
  7. Tokens are permanently burned on hub. User collateral backing remains locked in the protocol. Funds are permanently lost.

Impact

  • Direct permanent loss of user funds for any cross-chain transfer to a non-hub chain
  • Amount lost: 100% of the bridged amount per transaction, potentially exceeding 2% of TVL
  • No privileged access required, any user initiating a standard bridge triggers it
  • No recovery path exists. No admin function, no retry mechanism, no code path can recover the funds
  • All spoke chain deployments are non-functional. The OFT cross-chain capability is completely broken
  • The protocol deploys on Ethereum Sepolia, Optimism Sepolia, and Arbitrum Sepolia (per docs/deployments.md), confirming multi-chain intent

Suggested Fix

Guard totalBridgedOut accounting with a hub-chain check:

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;
}
}

Validation steps

Prerequisites

  • Node.js installed
  • Clone the repository at commit 519c9e92fd9d80d11e35e9868130f6334b88d676
  • Run: npm install --force

Step 1: Add the mock endpoint contract

Create file contracts/test/MockLzEndpoint.sol with a minimal mock that implements setDelegate (the only function called by OFT constructor). This avoids needing a mainnet fork.

Step 2: Add the standalone Hardhat config

Create file hardhat.config.poc.ts with solidity 0.8.20/0.8.10/0.7.6 compilers and hardhat network with allowUnlimitedContractSize: true. No env
vars or forking required.

Step 3: Add the PoC test file

Create file test/PoC_CreditUnderflow.ts (attached in supporting files). The test deploys two OverlayerWrapMock instances:

  • hubWrap with hubChainId = 31337 (matches Hardhat block.chainid, simulates hub chain)
  • spokeWrap with hubChainId = 1 (does not match block.chainid, simulates spoke chain)

Step 4: Run the PoC

HARDHAT_CONFIG=hardhat.config.poc.ts npx hardhat test test/PoC_CreditUnderflow.ts

Expected Output (all 5 passing):

PoC: _credit() Underflow Locks Cross-Chain Funds                                                                                               
  Pass - Baseline: hub chain debit then credit works correctly (785ms)
  Pass - CRITICAL: _credit() reverts when totalBridgedOut == 0                                                                                 
  Pass - CRITICAL: spoke chain can NEVER receive tokens via _credit()                                                                          
  Pass - CRITICAL: spoke chain mint() blocked, totalBridgedOut stuck at 0
  Pass - END-TO-END: user bridges from hub, tokens burned, spoke rejects, funds LOST                                                           
                                                                                                                                               
5 passing (802ms)

What Each Test Proves

Test 1 (Baseline): Hub chain debit then credit round-trip works. totalBridgedOut goes 0 to N to 0. This is the ONLY working path.

Test 2 (Underflow): Calling _credit() when totalBridgedOut == 0 reverts with panic 0x11 (arithmetic underflow). Proves the core bug.

Test 3 (Spoke deadlock): On a spoke chain contract instance, _credit() ALWAYS reverts because totalBridgedOut is permanently 0.

Test 4 (No bootstrap): mint() is blocked on spoke chains by onlyHubChain modifier. There is no way to get tokens or increment totalBridgedOut.
Chicken-and-egg deadlock confirmed.

Test 5 (End-to-end): Full attack scenario. User mints 20 tokens on hub, bridges 15 to spoke. Hub burns 15 tokens successfully. Spoke rejects
delivery with underflow revert. User permanently loses 15 tokens and their underlying collateral backing. No recovery possible.

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$69
Visibilitypartially
VulnerabilityInteger Underflow
Participants
hidden