Status DataClose notification

Overlayer Disclosed Report

_credit() underflow on non-hub chains — permanent loss of bridged funds

Company
Created date
Mar 27 2026

Target

hidden

Vulnerability Details

Summary

Bridging tokens from hub to any non-hub chain (OP, Arb, etc.) permanently burns the user's funds. _credit() in OverlayerWrapCore always subtracts from totalBridgedOut, but on non-hub chains that value is 0, so Solidity 0.8 panics with an underflow and the whole tx reverts. The tokens are already burned on the hub side by _debit() at that point — they're just gone, no recovery.

I traced the full path and there's nothing between the LZ endpoint and _credit() that could prevent this. Tested it end-to-end with the real lzReceive() flow in the PoC — same result. Any user who tries to bridge loses their tokens. Every single time, not just edge cases.


Bug location

OverlayerWrapCore.sol:537-544

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

Same problem in _debit() at line 527 — touches totalBridgedOut unconditionally too.

Why totalBridgedOut is always 0 on non-hub

There's no way around it. mint() is gated by onlyHubChain. _debit() needs a balance, but the only way to get balance on a non-hub chain is through _credit(). So _credit() is always the first operation, and it does 0 - amount → panic. It's a deadlock.

How the funds are lost

Hub                                    Non-Hub
───                                    ───────

send(500 OW)
  _debit() burns 500
  LZ msg ─────────────────────────────► _lzReceive() → _credit(500)
                                          totalBridgedOut = 0
  500 OW gone                             0 - 500 → panic → revert
                                          nothing minted

Call path: EndpointV2.lzReceive()OAppReceiver.lzReceive() (peer check) → OFTCore._lzReceive() line 277 calls _credit() directly. No guards in between.

No recovery possible

  • Retrying through LZ always hits the same deterministic underflow
  • clear()/nilify() can unblock the LZ queue but don't recover burned tokens
  • mint() only works on hub — can't compensate users on the destination chain
  • Contracts are not behind a proxy (checked on-chain — EIP-1967 slot is zero, bytecode is 23,925 bytes). Can't be patched, needs full redeployment
  • This is a code bug, not a config issue — hubChainId exists to distinguish chains but _credit()/_debit() ignore it

Existing deployments

Already deployed cross-chain on testnet per contracts/docs/deployments.md in the repo. Checked on-chain: the OP Sepolia contract 0xfd0E0c5132B1C0C59A413e52f97BC421592Ba460 returns hubChainId = 11155111 (Eth Sepolia) — confirmed non-hub.

PoC

Attached:

  • PoC_CreditUnderflow.ts — 5 test cases, including full lzReceive() path
  • MockLZEndpoint.sol — minimal mock for the LZ endpoint
  • hardhat.config.poc.ts — standalone config, no keys or fork needed
  • poc-output.png — test run screenshot

Fix

Just guard totalBridgedOut so it only runs on hub:

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

How to reproduce

Drop 3 files into the repo:

MockLZEndpoint.sol     → contracts/contracts/test/MockLZEndpoint.sol
PoC_CreditUnderflow.ts → contracts/test/PoC_CreditUnderflow.ts
hardhat.config.poc.ts  → contracts/hardhat.config.poc.ts
cd contracts
npx hardhat test test/PoC_CreditUnderflow.ts --config hardhat.config.poc.ts

No env vars, no keys, no fork.

Expected output

  PoC: _credit underflow on non-hub chain
    ✔ NON-HUB: _credit reverts with underflow (totalBridgedOut = 0)
    ✔ NON-HUB: even 1 wei bridge amount causes underflow
    ✔ HUB: _credit works correctly after _debit (control test)
    ✔ NON-HUB: full attack flow — user loses funds permanently
    ✔ NON-HUB: full lzReceive path reverts with underflow

  5 passing

Screenshot: poc-output.png

Tests 1–2: underflow on non-hub. Test 3: hub works fine (control). Test 4: full flow — 500 OW burned on hub, 0 minted on non-hub. Test 5: end-to-end through real lzReceive() path.

Attachments

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