Status DataClose notification

Overlayer Disclosed Report

Arithmetic Underflow in `_credit()` on Spoke Chains Permanently Bricks Inbound OFT Bridge and Causes Irrecoverable User Fund Loss

Company
Created date
Apr 09 2026

Target

hidden

Vulnerability Details

Brief Vulnerability Details

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.

Severity

Critical

Severity Justification

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).

Description

Root Cause

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).

Mechanism

The OverlayerWrap token uses a hub-and-spoke OFT (Omnichain Fungible Token) architecture built on LayerZero V2:

  • Hub chain: Holds collateral, mints/redeems tokens, tracks totalBridgedOut to maintain the accounting invariant totalSupply() + totalBridgedOut == effective_circulating_supply
  • Spoke chains: Hold only the OFT token for cross-chain transfers

When a user bridges tokens from hub to spoke:

  1. On hub: _debit() burns the user's tokens and executes totalBridgedOut += amount (succeeds)
  2. LayerZero delivers the message to the spoke chain
  3. On spoke: _credit() calls super._credit() to mint tokens, then executes totalBridgedOut -= amount
  4. Since totalBridgedOut on spoke is 0: 0 - amount = arithmetic underflow (Solidity 0.8.20 checked math)
  5. The entire _credit reverts, which reverts the lzReceive call on the LayerZero endpoint

Why This Creates a Permanent Deadlock

After 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.
  • No admin setter exists: 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).
  • No protocol-defined in-place recovery or upgrade path exists for the current deployment. The contract is not deployed behind a proxy, and there is no setter or administrative function that can repair totalBridgedOut for the affected spoke state.

LayerZero V2 Message Lifecycle After Revert

When _credit() reverts on the spoke chain:

  1. The LayerZero EndpointV2.lzReceive() transaction reverts entirely
  2. The payload remains in "verified but not executed" state (the _clearPayload from line 180 of EndpointV2.sol is rolled back)
  3. Anyone can retry by calling lzReceive() again - but every retry hits the same underflow
  4. The OApp delegate can call endpoint.clear() to burn the message, but this only removes the stuck message without crediting tokens - the user's funds remain lost
  5. endpoint.skip() and endpoint.nilify() similarly cannot credit tokens

No LayerZero V2 mechanism can recover the user's funds.

Attack Chain

No attacker is required - this is triggered by normal protocol usage:

  1. User holds OverlayerWrap tokens on the hub chain
  2. User calls send() (the standard OFT cross-chain transfer) targeting a spoke chain
  3. Hub chain: _debit() burns user's tokens, totalBridgedOut += amount - succeeds
  4. LayerZero delivers message to spoke chain
  5. Spoke chain: _credit() attempts totalBridgedOut -= amount where totalBridgedOut = 0 - REVERTS
  6. User's tokens are burned on hub but never minted on spoke
  7. Permanent loss: burned tokens are irrecoverable

Impact

  • User loss: 100% of the bridged amount, permanently. For the PoC scenario: Alice bridges 30 OW+ (backed 1:1 by USDC), loses all 30 OW+ = $30 equivalent.
  • Protocol impact: All inbound OFT bridge transfers to the spoke chain are permanently bricked. The spoke chain deployment is functionally dead for receiving tokens.
  • Repeatability: Every bridge attempt to the spoke chain fails identically.
  • No recovery: No retry, no admin action, no LayerZero mechanism can restore the funds.

Live Deployment Evidence

This 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.

Validation steps

Steps to Reproduce

Prerequisites

git clone https://github.com/Overlayerfi/contracts.git
cd contracts
git checkout 519c9e92fd9d80d11e35e9868130f6334b88d676
npm install

Required Files

The PoC requires two helper files already present in the repository or provided below:

  1. 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);
    }
}
  1. 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;

Why the Minimal Endpoint Mock Is Sufficient

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.

Compilation Note

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.

Run Command

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

Expected Output

  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%

What Each Test Proves

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.

Proof of Concept

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.

Suggested Fix

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).

Attachments

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