Status DataClose notification

Overlayer Disclosed Report

`totalBridgedOut` Underflow in `_credit()` Permanently Blocks All Inbound Cross-Chain Transfers on Spoke Chains — Bridged Funds Are Irrecoverably Lost

Company
Created date
Mar 28 2026

Target

hidden

Vulnerability Details

Description

OverlayerWrapCore inherits from LayerZero's OFT (Omnichain Fungible Token) and overrides _debit() and _credit() to track cross-chain supply via the totalBridgedOut state variable:

/// @notice Cumulative amount of tokens debited via OFT cross-chain transfers (not yet credited back)
uint256 public totalBridgedOut;

On the hub chain, _debit() increments totalBridgedOut when tokens are burned for cross-chain transfer, and _credit() decrements it when tokens return. This accounting works on the hub chain because totalBridgedOut is always incremented before it is decremented.

However, on spoke chains (any non-hub deployment), totalBridgedOut is initialized to 0 and is never incremented because _debit() on a spoke chain only fires when a user bridges tokens out of that spoke — not when tokens arrive. When LayerZero delivers a cross-chain message to a spoke chain, _credit() is invoked to mint tokens for the recipient:

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
}

Since Solidity 0.8.20 uses checked arithmetic, totalBridgedOut -= amountReceivedLD when totalBridgedOut = 0 causes an arithmetic underflow and reverts the entire transaction. The super._credit() call (which mints tokens to the recipient) is rolled back.

The result:

  1. Source chain (hub): Tokens are successfully burned via _debit(), and totalBridgedOut is incremented. The user's tokens are gone.
  2. Destination chain (spoke): _credit() reverts due to underflow. Tokens are never minted to the recipient.
  3. LayerZero: The failed message is stored in the endpoint. Retrying will always fail because totalBridgedOut remains 0 on the spoke chain — there is no mechanism to increment it without an outbound bridge (which requires tokens that can never arrive).

This creates a permanent, irrecoverable loss of all tokens bridged from hub to any spoke chain.

The _debit() / _credit() Asymmetry

The _debit() function only increments totalBridgedOut (addition is always safe):

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

On the hub chain, the flow is: _debit (totalBridgedOut goes UP) → bridge → _credit on return (totalBridgedOut goes DOWN). This is balanced.

On spoke chains, the FIRST operation is always _credit (receiving tokens from hub), but totalBridgedOut starts at 0. There is no prior _debit to bring it above zero.

Impact

  • Direct permanent loss of end-user funds: Any user who bridges OVW tokens from the hub chain to any spoke chain loses 100% of the bridged amount. Tokens are burned on hub, never minted on spoke, and cannot be recovered.
  • Complete DoS of cross-chain functionality: No spoke chain can ever receive tokens. The LayerZero OFT cross-chain feature — a core protocol capability — is entirely non-functional.
  • No admin recovery path: There is no admin function to manually set totalBridgedOut. The stored LayerZero message will always fail on retry. The only fix is a contract upgrade on the spoke chain.

Likelihood: HIGH

  • No privileged role required — any OVW token holder can call send() to bridge tokens
  • No significant token balance required — any amount triggers the bug (even 1 wei)
  • No complex conditions — simply bridging to any spoke chain
  • The protocol is explicitly designed for multi-chain deployment (LayerZero OFT inheritance, hubChainId state variable, onlyHubChain modifier on mint/redeem)

Impact: HIGH

  • Permanent, irrecoverable loss of 100% of bridged tokens
  • Affects every spoke chain deployment (Arbitrum, any future L2/chain)
  • Amount at risk: all tokens any user attempts to bridge

Proof of Concept — [POC-PASS] ✅ Mechanically Verified

Run command:

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

Test output:

WARNING: You are currently using Node.js v25.5.0, which is not supported by Hardhat. This can lead to unexpected behavior. See https://hardhat.org/nodejs-versions



  PoC C-01: totalBridgedOut Underflow on Spoke Chains

  ✅ CONFIRMED: _credit() reverts on spoke chain
  → totalBridgedOut = 0, attempting to subtract 10000000000000000000000
  → Solidity 0.8.20 checked arithmetic causes underflow panic (0x11)
  → Tokens burned on hub chain are NEVER minted on spoke chain
  → Result: PERMANENT FUND LOSS for any user bridging to spoke chains

    ✔ CRITICAL: _credit() reverts on spoke chain due to totalBridgedOut underflow (653ms)

  ✅ CONTROL: _credit() succeeds on hub chain (totalBridgedOut > 0)
  → Hub chain: debit first, then credit works correctly

    ✔ CONTROL: _credit() succeeds on hub chain after _debit()

  ✅ CONFIRMED: Even 1 wei bridge to spoke chain reverts
  → Attack has ZERO cost threshold — any amount is lost

    ✔ PROOF: Even 1 wei bridge to spoke chain is permanently lost


  3 passing (683ms)

Test descriptions:

  • Test 1: Deploys OverlayerWrapMock with hubChainId = 1 (Ethereum mainnet, not Hardhat's 31337 — simulating a spoke chain). Verifies totalBridgedOut = 0. Calls testCredit() to simulate an inbound bridge of 10,000 OVW. Reverts with Panic(0x11) — arithmetic underflow. Alice receives zero tokens. totalBridgedOut remains 0.
  • Test 2 (control): Deploys as hub chain. Mints 100 OVW, calls testDebit() (totalBridgedOut goes up), then testCredit(). Succeeds — proving the bug is spoke-chain-specific.
  • Test 3: Same spoke deployment, attempts to bridge just 1 wei. Reverts with Panic(0x11) — proving zero cost threshold.

PoC source files (3 files): https://gist.github.com/0xiehnnkta/499bae42f53471c54ac61ea223271b95

The gist contains:

  • PoC_C01_TotalBridgedOutUnderflow.ts — Hardhat test (3 tests: spoke underflow, hub control, 1-wei proof)
  • MockLZEndpoint.sol — Minimal LayerZero endpoint mock for local testing
  • hardhat.config.poc.ts — Standalone Hardhat config (no forking required)

The PoC also uses the pre-existing OverlayerWrapMock.sol already in the repository, which exposes internal _debit() / _credit() for unit testing.

Recommendation

Option A (Recommended): Only track totalBridgedOut on the hub chain. Skip the decrement on spoke chains:

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

Option B: Use a safe subtraction with a floor at zero:

function _credit(
    address to_,
    uint256 amountLD_,
    uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
    amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
    if (totalBridgedOut >= amountReceivedLD) {
        totalBridgedOut -= amountReceivedLD;
    } else {
        totalBridgedOut = 0;
    }
}

Option C: Maintain per-chain accounting with a separate totalBridgedIn counter on spoke chains for accurate supply tracking.

Validation steps

1. Protocol deploys OverlayerWrap on Hub Chain (Ethereum) and Spoke Chain (Arbitrum)
2. User holds 10,000 OVW on Hub Chain
3. User calls send() to bridge 10,000 OVW to Spoke Chain (Arbitrum)
4. Hub Chain: _debit() burns 10,000 OVW, totalBridgedOut += 10,000 ✓
5. LayerZero delivers message to Spoke Chain
6. Spoke Chain: _credit() calls super._credit() to mint 10,000 OVW to user
7. Spoke Chain: totalBridgedOut -= 10,000 → UNDERFLOW (totalBridgedOut = 0)
8. Spoke Chain: ENTIRE TRANSACTION REVERTS — tokens NOT minted
9. Result: User lost 10,000 OVW permanently
   - Hub: tokens burned, totalBridgedOut = 10,000
   - Spoke: tokens never minted, totalBridgedOut = 0
   - LayerZero: message stored, retry always fails
``

Attachments

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