Status DataClose notification

Overlayer Disclosed Report

Cross-Chain Accounting Desynchronization in OVA (`totalBridgedOut`) Can Permanently Lock Bridged User Funds

Company
Created date
Apr 01 2026

Target

hidden

Vulnerability Details

Summary

OverlayerWrapCore tracks outbound bridged supply in totalBridgedOut and assumes that this value can be incremented on the source chain during _debit() and decremented on the destination chain during _credit().

This assumption is incorrect because totalBridgedOut is ordinary contract storage and therefore chain-local state. A debit on the source chain updates the source chain's local totalBridgedOut, but the destination chain maintains its own independent storage slot, which remains unchanged.

As a result, when a user bridges OVA from the hub chain to a satellite chain:

  1. The source chain burns the user's OVA during _debit().
  2. The source chain increments its own local totalBridgedOut.
  3. The destination chain later executes _credit().
  4. The destination chain attempts to run totalBridgedOut -= amountReceivedLD against its own local value.
  5. On a fresh or otherwise unsynchronized satellite deployment, that local value is 0, so the subtraction underflows and the receive-side credit reverts.

Because the burn already happened on the source chain, the user can end up with:

  • fewer or zero OVA on the source chain, and
  • no OVA minted on the destination chain.

This is a permanent lock / loss-of-access condition for bridged user funds under the current design.

Impacted code

1) Chain-local storage used as if it were global bridge state

contracts/overlayer/OverlayerWrapCore.sol

uint256 public totalBridgedOut;

2) Minting and redeeming are restricted to the hub chain

This matters because non-hub chains do not have an independent local path to build matching accounting state via mint/redeem.

contracts/overlayer/OverlayerWrapCore.sol

modifier onlyHubChain(uint256 chainId_) {
    if (chainId_ != hubChainId) {
        revert OverlayerWrapCoreNotHubChainId();
    }
    _;
}

contracts/overlayer/OverlayerWrapCore.sol

function _managerMint(...) internal ... onlyHubChain(block.chainid) { ... }
function _managerRedeem(...) internal ... onlyHubChain(block.chainid) returns (...) { ... }

3) Source-chain debit increments only the source chain's local value

contracts/overlayer/OverlayerWrapCore.sol

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

4) Destination-chain credit decrements the destination chain's unrelated local value

contracts/overlayer/OverlayerWrapCore.sol

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

Why this is critical

This issue satisfies the program's stated criticality bar:

  • High likelihood: a normal user can trigger it by performing a standard bridge flow; no privileged role is required.
  • High impact: the source-chain burn occurs before the destination-side accounting underflow is encountered, so the user can lose access to the bridged amount.
  • In-scope effect: this can permanently lock user funds in transit.

Clear reproduction flow

  1. Deploy one OverlayerWrapMock instance representing the hub chain.
  2. Deploy a second OverlayerWrapMock instance representing a satellite chain.
  3. Mint OVA on the hub instance to a user.
  4. Call testDebit() on the hub instance for a bridge amount.
    • This burns the user's OVA on the hub instance.
    • This increments the hub instance's totalBridgedOut.
  5. Confirm that the satellite instance still has totalBridgedOut == 0.
  6. Call testCredit() on the satellite instance for the same bridge amount.
  7. Observe that the call reverts with arithmetic underflow because the satellite instance attempts to execute 0 - amountReceivedLD.
  8. Confirm the user no longer has the burned amount on the hub instance and still has not received the bridged amount on the satellite instance.

Validation steps

Repository locations to review

  • contracts/overlayer/OverlayerWrapCore.sol
  • contracts/test/OverlayerWrapMock.sol
  • test/OverlayerWrap.ts

Why the current repository tests do not catch the issue

The existing bridge-accounting tests use one single contract instance and then call:

  1. testDebit(...)
  2. testCredit(...)

on that same instance.

That setup is not representative of a real cross-chain flow because it reuses one storage context for both operations. In production:

  • _debit() runs on the source chain contract instance, and
  • _credit() runs on a different destination chain contract instance.

The bug appears specifically because those two instances do not share storage.

Fast validation procedure

  1. Open contracts/test/OverlayerWrapMock.sol and confirm that the mock exposes both _debit() and _credit().
  2. Open contracts/overlayer/OverlayerWrapCore.sol and confirm:
    • totalBridgedOut is a state variable,
    • _debit() increments it, and
    • _credit() decrements it.
  3. Open test/OverlayerWrap.ts and confirm the current tests perform debit and credit on a single deployed mock.
  4. Add the fPoC below as a new Hardhat test file.
  5. Run:
npx hardhat test test/OverlayerWrap_totalBridgedOut_desync.ts
  1. Expected result:
    • the hub-side debit succeeds,
    • the satellite-side credit reverts with panic code 0x11 (arithmetic underflow/overflow),
    • the user's balance on the hub instance remains reduced by the bridged amount, and
    • the user receives no balance on the satellite instance.

Expected observable outcome

After the failed receive-side credit:

  • hub.totalBridgedOut() equals the amount burned on the hub instance,
  • satellite.totalBridgedOut() remains 0,
  • hub.balanceOf(user) is reduced,
  • satellite.balanceOf(user) remains 0.

This is sufficient to validate that the system can burn tokens on the source side while failing to mint them on the destination side.

Attachments

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