Status DataClose notification

Overlayer Disclosed Report

Hub-to-Spoke OFT `_credit` Underflow Permanently Locks Bridged OverlayerWrap Tokens

Company
Created date
Apr 08 2026

Target

hidden

Vulnerability Details

Description:

OverlayerWrapCore overrides both _debit and _credit to maintain a local totalBridgedOut counter. On an outbound bridge transfer, _debit increments totalBridgedOut on the source chain. On an inbound bridge delivery, _credit unconditionally decrements totalBridgedOut on the destination chain.

On any spoke deployment, totalBridgedOut starts at zero and can never be seeded through normal minting because _managerMint enforces onlyHubChain(block.chainid). This creates a permanent mismatch: the source (hub) increments its own counter, while the destination (spoke) attempts to decrement a counter that has never been touched.

Affected codeOverlayerWrapCore.sol L510–544 · OverlayerWrapCore.sol L353–359:

https://github.com/Ovafi/contracts/blob/main/contracts/overlayer/OverlayerWrapCore.sol#L510-L544
function _debit(...) internal virtual override returns (...) {
    (amountSentLD, amountReceivedLD) = super._debit(from_, amountLD_, minAmountLD_, dstEid_);
    totalBridgedOut += amountSentLD;       // increments on source chain
}

function _credit(...) internal virtual override returns (uint256 amountReceivedLD) {
    amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
    totalBridgedOut -= amountReceivedLD;   // decrements on destination chain — UNDERFLOWS on spoke
}

// https://github.com/Ovafi/contracts/blob/main/contracts/overlayer/OverlayerWrapCore.sol#L353-L359
function _managerMint(OverlayerWrapCoreTypes.Order calldata order_)
    internal
    belowMaxMintPerBlock(order_.overlayerWrapAmount)
    onlyHubChain(block.chainid)            // minting restricted to hub — spoke totalBridgedOut stays 0

Execution flow:

  1. User initiates a public OFT bridge transfer from the hub chain.
  2. _debit() burns the user's source-chain tokens and increments totalBridgedOut on the hub.
  3. LayerZero delivers the cross-chain message to the spoke.
  4. The OFT receive path calls _credit() from _lzReceive().
  5. super._credit() mints destination-side tokens — succeeds.
  6. totalBridgedOut -= amountReceivedLD executes against totalBridgedOut == 0 on the spoke → arithmetic underflow, full revert.
  7. The source-side burn has already been finalised; the user's tokens are permanently locked / destroyed.

Impact:

This is a direct, deterministic permanent fund loss for any user who bridges OverlayerWrap from the hub to a spoke chain.

  • No privilege required — reachable through the standard public bridge flow.
  • No prior state required — the vulnerable precondition (totalBridgedOut == 0 on the spoke) is the default state of every spoke deployment from genesis.
  • Irreversible — once the source-chain burn is confirmed and the LayerZero message is delivered, the revert on the spoke leaves the user's tokens unrecoverable through the normal protocol path.
  • Entire spoke bridge route is bricked from genesis — no hub-to-spoke bridge delivery can ever succeed until the root cause is fixed.

Severity: Critical — direct, permanent loss of user funds on a permissionless, primary protocol path.

Validation steps

Proof of Concept:

Test file:

it("CRITICAL: _credit on spoke reverts with arithmetic underflow — user tokens permanently lost", async function () {
  const { spokeOW, alice } = await loadFixture(deployFixture);

  const bridgeAmount = ethers.parseUnits("100", 18);
  const srcEid = 1;

  // Precondition: spoke totalBridgedOut starts at zero, can never be seeded
  expect(await spokeOW.totalBridgedOut()).to.equal(0n);

  // Simulates LayerZero delivering the bridge message to the spoke
  await expect(
    spokeOW.testCredit(alice.address, bridgeAmount, srcEid)
  ).to.be.reverted;  // arithmetic underflow → permanent fund lock
});

it("hub _credit succeeds only after matching _debit (correct round-trip on hub)", async function () {
  const { hubOW, collateral, alice, mintAmount } = await loadFixture(deployFixture);

  await collateral.connect(alice).approve(await hubOW.getAddress(), mintAmount);
  const owAmount = ethers.parseUnits("100", 18);
  await hubOW.connect(alice).mint({
    benefactor: alice.address,
    beneficiary: alice.address,
    collateral: await collateral.getAddress(),
    collateralAmount: mintAmount,
    overlayerWrapAmount: owAmount,
  });

  await hubOW.connect(alice).testDebit(alice.address, owAmount, 0, 2);
  await expect(
    hubOW.testCredit(alice.address, owAmount, 2)
  ).to.not.be.reverted;  // hub has matching _debit — succeeds
});

Run the test:

cd contracts
CI=1 npx hardhat test --config hardhat.config.poc.ts test/PoC_CrossChainCreditUnderflow.ts

Captured output:

PoC: Critical — _credit underflow permanently locks bridged tokens
  ✔ spoke chain starts with totalBridgedOut == 0 (4592ms)
Hub totalBridgedOut after _debit: 100000000000000000000
  ✔ hub _debit increments totalBridgedOut (55ms)

=== PoC: Critical Cross-Chain Bug ===
Calling spokeOW.testCredit(alice, 100e18, 1) — simulating LZ message delivery
This executes: totalBridgedOut (== 0) -= 100e18  →  arithmetic underflow

✅  Confirmed: _credit reverts.  Tokens burned on hub are permanently lost.

  ✔ CRITICAL: _credit on spoke reverts with arithmetic underflow — user tokens permanently lost (40ms)
Hub round-trip (_debit → _credit) succeeds. Spoke never gets matching _debit → always fails.
  ✔ hub _credit succeeds only after matching _debit (correct round-trip on hub) (82ms)

4 passing (5s)

EXIT_CODE=0

What the output proves:

Assertion Log / Result Meaning
expect(totalBridgedOut).to.equal(0n) ✔ PASS Spoke starts with zero; the vulnerable precondition exists at genesis
Hub totalBridgedOut after _debit: 100e18 Logged Outbound accounting is hub-local; spoke counter is never touched
spokeOW.testCredit(...).to.be.reverted ✔ PASS Inbound credit deterministically underflows on spoke
hubOW.testCredit(...).to.not.be.reverted ✔ PASS _credit is not broken universally — only the spoke-side decrement is invalid

Recommended Mitigation:

Restrict the totalBridgedOut decrement to the chain where the corresponding outbound burn was recorded (i.e., the hub). The safest fix is a chain-identity guard inside _credit:

function _credit(
    address to_,
    uint256 amountLD_,
    uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
    amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
    // Only decrement on the hub, where _debit previously incremented
    if (_isHubChain(block.chainid)) {
        totalBridgedOut -= amountReceivedLD;
    }
}

If the intent of totalBridgedOut is to track effective global supply for backing calculations, the invariant must be recomputed with chain-local semantics (e.g., per-chain counters, or a cross-chain message to update the hub's counter) rather than assuming a globally synchronised value across independent deployments.

Attachments

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