Status DataClose notification

Overlayer Disclosed Report

`OverlayerWrapCore._credit()` decrements `totalBridgedOut` on every chain, causing first inbound OFT receives on spoke chains to revert and permanently strand bridged funds

Company
Created date
Apr 06 2026

Target

hidden

Vulnerability Details

Severity

Critical

Likelihood

High

Impact

High

If the protocol is currently deployed on at least one non-hub chain, a user bridging OverlayerWrap from the hub to that spoke can have the source-side burn succeed while the destination-side mint always reverts from arithmetic underflow. Because OFT delivery will keep retrying the same failing destination logic, the bridged funds become permanently unclaimable unless the destination contract can be replaced, which this codebase does not indicate for OverlayerWrap.

In-Scope Files

  • contracts/overlayer/OverlayerWrapCore.sol

Summary

The recently added totalBridgedOut accounting is meant to preserve the effective hub-chain supply for backing calculations. However, the implementation applies the accounting on all chains:

  • _debit() increments totalBridgedOut
  • _credit() decrements totalBridgedOut

This is only correct on the hub chain, where outbound transfers should increase outstanding bridged supply and inbound returns should decrease it.

On a spoke chain, the first inbound transfer is a destination-side _credit() with totalBridgedOut == 0, so this line reverts:

totalBridgedOut -= amountReceivedLD;

That means a bridge from hub -> spoke can burn tokens on the source chain and then fail forever on the destination chain.

2. Vulnerability details

The vulnerability is a spoke-chain OFT receive freeze caused by hub-only accounting being applied on every chain.

In OverlayerWrapCore.sol, the new totalBridgedOut variable is updated in both LayerZero OFT hooks:

  • _debit(...) adds the bridged-out amount
  • _credit(...) subtracts the bridged-in amount

That accounting only makes sense on the hub chain, where:

  • outbound transfers should increase outstanding bridged supply
  • inbound return transfers should decrease outstanding bridged supply

But the code does not restrict either update to the hub chain. As a result, a non-hub spoke chain will execute _credit() even though its local totalBridgedOut is still zero.

The failing line is:

totalBridgedOut -= amountReceivedLD;

Under Solidity 0.8 arithmetic checks, that subtraction reverts on the first inbound spoke transfer.

Clear reproduction steps:

  1. Assume OverlayerWrap exists on a hub chain and at least one spoke chain, and users can bridge through OFT.
  2. A user initiates a bridge from the hub to the spoke.
  3. On the source chain, _debit() succeeds and increments the hub chain’s totalBridgedOut.
  4. On the destination spoke, OFT calls _credit() to mint the received amount.
  5. The spoke executes totalBridgedOut -= amountReceivedLD while totalBridgedOut == 0.
  6. The subtraction underflows and reverts.
  7. The destination mint never completes, while the source-side bridge debit has already happened.

The result is a permanent user-funds lock path for bridged funds, assuming the OFT route is currently live on at least one non-hub deployment.

This is distinct from the March 2026 Hacken audit’s hub-side supply() DoS issue. That known issue concerned effective hub supply reductions after OFT transfers. This issue concerns spoke-side destination receives reverting due to the same accounting fix being applied globally instead of only on the hub.

Validation steps

I validated the issue with a minimal local PoC that models the exact totalBridgedOut behavior used by Overlayer’s _debit() and _credit() hooks.

PoC workspace:

Validation logic:

  1. Deploy one harness as the hub and one harness as the spoke.
  2. Call hub.debit(100e18) to model a successful source-chain bridge debit.
  3. Call spoke.credit(100e18) to model the first destination-chain bridge receive.
  4. Confirm that the spoke call reverts because it tries to subtract 100e18 from zero.
  5. Separately, call hub.credit(100e18) after hub.debit(100e18) and confirm it succeeds, proving the accounting only works on a chain that previously debited.

Exact command:

cd /home/dinesh/hackenproof/overlayer-poc
forge test --match-contract OverlayerOFTCreditTest -vvv

Observed output:

forge test --match-contract OverlayerOFTCreditTest -vvv
[⠊] Compiling...
No files changed, compilation skipped

Ran 2 tests for test/OverlayerOFTCredit.t.sol:OverlayerOFTCreditTest
[PASS] test_firstInboundTransferToSpokeReverts() (gas: 35455)
[PASS] test_returnTransferToHubOnlyWorksIfHubPreviouslyDebited() (gas: 20060)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.46ms (652.32µs CPU time)

Ran 1 test suite in 27.90ms (1.46ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

What this proves:

  • the first inbound spoke receive reverts from zero totalBridgedOut
  • the same accounting succeeds only on a chain that had previously debited
  • so the logic is hub-specific, but the current code applies it globally

Full PoC code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @notice Minimal model of the exact OverlayerWrapCore totalBridgedOut accounting.
/// @dev The source chain calls debit before LayerZero delivery; the destination chain calls credit.
contract OverlayerOFTCreditHarness {
    uint256 public totalBridgedOut;

    function debit(uint256 amountLD) external returns (uint256 amountSentLD, uint256 amountReceivedLD) {
        amountSentLD = amountLD;
        amountReceivedLD = amountLD;
        totalBridgedOut += amountSentLD;
    }

    function credit(uint256 amountLD) external returns (uint256 amountReceivedLD) {
        amountReceivedLD = amountLD;
        totalBridgedOut -= amountReceivedLD;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "../src/OverlayerOFTCreditHarness.sol";

contract OverlayerOFTCreditTest {
    OverlayerOFTCreditHarness internal hub;
    OverlayerOFTCreditHarness internal spoke;

    function setUp() public {
        hub = new OverlayerOFTCreditHarness();
        spoke = new OverlayerOFTCreditHarness();
    }

    function test_firstInboundTransferToSpokeReverts() public {
        hub.debit(100e18);

        (bool ok,) = address(spoke).call(
            abi.encodeWithSelector(OverlayerOFTCreditHarness.credit.selector, 100e18)
        );
        require(!ok, "spoke credit should revert from zero totalBridgedOut");
    }

    function test_returnTransferToHubOnlyWorksIfHubPreviouslyDebited() public {
        hub.debit(100e18);

        require(hub.totalBridgedOut() == 100e18, "hub bridged out should increase on debit");

        hub.credit(100e18);

        require(hub.totalBridgedOut() == 0, "hub credit should only clear prior bridged amount");
    }
}

Root Cause

The code comments describe hub-specific accounting, but the implementation lacks any hub-chain guard:

Relevant code from the scoped commit:

  • uint256 public totalBridgedOut;
  • _debit(...) does totalBridgedOut += amountSentLD;
  • _credit(...) does totalBridgedOut -= amountReceivedLD;

The comments explicitly say:

  • _debit() tracks tokens leaving the hub chain
  • _credit() tracks tokens returning to the hub chain

But neither function checks block.chainid == hubChainId.

Broken Security Guarantee

Cross-chain OFT transfers are expected to preserve user ownership across chains.

Instead, for a first inbound spoke transfer:

  1. the source chain debits/burns the user's tokens
  2. the destination chain tries to mint via _credit()
  3. _credit() underflows on totalBridgedOut
  4. the receive fails permanently under the current code

So a normal user bridge can turn into a permanent lock of user funds.

Attack / Failure Path

Preconditions:

  • OverlayerWrap is deployed on a hub chain and at least one spoke chain
  • OFT transfers are enabled between them
  • the spoke has not previously debited enough totalBridgedOut to cover the incoming amount, which is true for the first inbound transfer

Steps:

  1. A user bridges amount OverlayerWrap from the hub to a spoke.
  2. On the source chain, OFT _debit() succeeds and burns/transfers out the user's balance.
  3. On the destination spoke, OFT calls _credit().
  4. _credit() executes totalBridgedOut -= amountReceivedLD.
  5. Since the spoke has totalBridgedOut == 0, the subtraction underflows and the receive reverts.
  6. The user cannot obtain funds on the destination, while their source-side tokens are already gone.

Why Existing Protections Do Not Save This

  • This does not require any privileged role.
  • It does not depend on front-running.
  • It does not depend on imported-library bugs.
  • It is not a speculative state assumption: it follows directly from the scoped commit’s _debit/_credit implementation and standard OFT destination-credit semantics.
  • The March 2026 Hacken audit found the opposite-side issue, Hub Chain OverLayer Supply Reduction After OFT Transfers Lead to supply() DoS, but that finding does not cover the new spoke-side underflow introduced by applying the fix globally.

Recommendation

Restrict totalBridgedOut accounting to the hub chain only.

For example:

if (block.chainid == hubChainId) {
    totalBridgedOut += amountSentLD;
}

and

if (block.chainid == hubChainId) {
    totalBridgedOut -= amountReceivedLD;
}

That matches the code comments and the intended backing invariant.

Proof of Concept

Local PoC workspace:

Command:

cd /home/dinesh/hackenproof/overlayer-poc
forge test --match-contract OverlayerOFTCreditTest -vvv

Observed output:

forge test --match-contract OverlayerOFTCreditTest -vvv
[⠊] Compiling...
No files changed, compilation skipped

Ran 2 tests for test/OverlayerOFTCredit.t.sol:OverlayerOFTCreditTest
[PASS] test_firstInboundTransferToSpokeReverts() (gas: 35455)
[PASS] test_returnTransferToHubOnlyWorksIfHubPreviouslyDebited() (gas: 20060)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.46ms (652.32µs CPU time)

Ran 1 test suite in 27.90ms (1.46ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

What the PoC proves:

  • a first destination-side credit() on a spoke reverts from zero totalBridgedOut
  • the accounting only works on a chain that previously performed debit(), i.e. the intended hub-side invariant

Full PoC code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @notice Minimal model of the exact OverlayerWrapCore totalBridgedOut accounting.
/// @dev The source chain calls debit before LayerZero delivery; the destination chain calls credit.
contract OverlayerOFTCreditHarness {
    uint256 public totalBridgedOut;

    function debit(uint256 amountLD) external returns (uint256 amountSentLD, uint256 amountReceivedLD) {
        amountSentLD = amountLD;
        amountReceivedLD = amountLD;
        totalBridgedOut += amountSentLD;
    }

    function credit(uint256 amountLD) external returns (uint256 amountReceivedLD) {
        amountReceivedLD = amountLD;
        totalBridgedOut -= amountReceivedLD;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "../src/OverlayerOFTCreditHarness.sol";

contract OverlayerOFTCreditTest {
    OverlayerOFTCreditHarness internal hub;
    OverlayerOFTCreditHarness internal spoke;

    function setUp() public {
        hub = new OverlayerOFTCreditHarness();
        spoke = new OverlayerOFTCreditHarness();
    }

    function test_firstInboundTransferToSpokeReverts() public {
        hub.debit(100e18);

        (bool ok,) = address(spoke).call(
            abi.encodeWithSelector(OverlayerOFTCreditHarness.credit.selector, 100e18)
        );
        require(!ok, "spoke credit should revert from zero totalBridgedOut");
    }

    function test_returnTransferToHubOnlyWorksIfHubPreviouslyDebited() public {
        hub.debit(100e18);

        require(hub.totalBridgedOut() == 100e18, "hub bridged out should increase on debit");

        hub.credit(100e18);

        require(hub.totalBridgedOut() == 0, "hub credit should only clear prior bridged amount");
    }
}

Attachments

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