Status DataClose notification

Overlayer Disclosed Report

_credit() underflow bricks all cross-chain token reception on non-hub chains

Company
Created date
Apr 02 2026

Target

hidden

Vulnerability Details

The Affected Code is in the OverlayerWrapCore.sol: _credit():

// OverlayerWrapCore.sol : 

function _credit( 

    address to_, 

    uint256 amountLD_, 

    uint32 srcEid_ 

) internal virtual override returns (uint256 amountReceivedLD) { 

    amountReceivedLD = super._credit(to_, amountLD_, srcEid_); 

  @>  totalBridgedOut -= amountReceivedLD;  // ← BUG: underflows on non-hub chains where totalBridgedOut = 0 

} 

The paired function that increments the counter:


// 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;  // ← only incremented on the SENDING chain 

} 

The state variable:


// OverlayerWrapCore.sol : 

@> uint256 public totalBridgedOut;  // ← initialized to 0 on every chain deployment 

The only consumer of totalBridgedOut:


 

// AaveHandler.sol : 

@> uint256 owTotalSupp = IOverlayerWrap(overlayerWrap).totalSupply() + 

    IOverlayerWrap(overlayerWrap).totalBridgedOut(); // ← only exists on hub chain 

The hub-only restriction on minting (the only other way tokens can be created):


 

// OverlayerWrapCore.sol : 

function _managerMint( 

    OverlayerWrapCoreTypes.Order calldata order_ 

) 

    internal 

    belowMaxMintPerBlock(order_.overlayerWrapAmount) 

    @> onlyHubChain(block.chainid)  // ← non-hub chains CANNOT mint via this path 

{ 

The LayerZero OFT base that calls _credit on the destination chain:

// @layerzerolabs/oft-evm OFTCore.sol: 

function _lzReceive(...) internal virtual override { 

    address toAddress = _message.sendTo().bytes32ToAddress(); 

    @> uint256 amountReceivedLD = _credit(toAddress, _toLD(_message.amountSD()), _origin.srcEid); // ← LayerZero calls _credit on the DESTINATION chain when delivering a cross-chain message 

} 

The LayerZero OFT base _credit that super._credit() resolves to:


 

// @layerzerolabs/oft-evm OFT.sol: 

function _credit(address _to, uint256 _amountLD, uint32) internal virtual override returns (uint256) { 

    if (_to == address(0x0)) _to = address(0xdead); 

    @> _mint(_to, _amountLD);  // ← mints tokens on destination chain 

    return _amountLD; 

} 

In OverlayerWrapCore._credit() a the unconditional totalBridgedOut -= amountReceivedLD executes on all chains, but totalBridgedOut is only meaningful on the hub chain where _debit is called first.

totalBridgedOut is a per-chain state variable initialized to 0 on every deployment. _debit() increments it on the sending chain. _credit() decrements it on the receiving chain. These are different contract instances on different chains — they do not share state. When a user bridges tokens from hub to spoke, _debit increments the hub's counter, then LayerZero delivers the message to the spoke where _credit tries to decrement the spoke's counter. But the spoke's totalBridgedOut is 0 — it was never incremented because no _debit ever ran on the spoke. The subtraction 0 - amount underflows in Solidity 0.8.x and reverts, rolling back the _mint that super._credit() just performed. The spoke chain can never receive its first token.

This is reachable in production When Any user calling the standard OFT send() function to bridge USDT+ from Ethereum (hub) to any other chain (e.g. Arbitrum) triggers _lzReceive → _credit on the destination, which reverts on the first-ever incoming transfer.

users are affected because Users who bridge USDT+ to any non-hub chain lose their tokens entirely — burned on the source chain, never minted on the destination, with no recovery path since the LayerZero message delivery reverts every retry.

Impact

Any cross-chain bridge transfer to a non-hub chain deployment permanently fails, causing the sent tokens to be burned on the source chain but never minted on the destination chain — a permanent loss of user funds.

Attack Path

  1. OverlayerWrap is deployed on Ethereum (hub, chainId=1) and Arbitrum (spoke, chainId=42161)

  2. Both deployments have totalBridgedOut = 0

  3. Alice holds 10,000 USDT+ on Ethereum

  4. Alice calls send() to bridge 10,000 USDT+ to Arbitrum:

    Ethereum: _debit() → burns 10,000 USDT+ → totalBridgedOut[hub] = 10,000

    LayerZero delivers message to Arbitrum

    Arbitrum: _credit() → super._credit() mints 10,000 → totalBridgedOut[spoke] -= 10,000 → 0 - 10,000 → UNDERFLOW → REVERT

  5. Mint is rolled back. Alice's 10,000 USDT+ is burned on Ethereum, never arrives on Arbitrum

  6. LayerZero executor retries → same revert every time → funds permanently lost

Fix

Guard both overrides to only execute on the hub chain, where totalBridgedOut is consumed:


 

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; 

    } 

} 

 

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; 

    } 

} 

Validation steps

See what I did before I ran the test:

I created a process.env file with dummy Hardhat account private keys and an ALCHEMY_KEY=demo placeholder (required by hardhat.config.ts), added FORK_RPC fallback support to hardhat.config.ts:118 so the fork URL can be overridden via environment variable, and ran npm install --force to install all existing package.json dependencies (no new packages were added).

Run command

FORK_RPC="https://mainnet.infura.io/v3/f60c39974cc04027aefbd07fe791cd32" npx hardhat test test/PoC_CreditUnderflow.ts --network hardhat

See how my hardhat.config.ts is modified

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@nomicfoundation/hardhat-ethers";
import "@nomicfoundation/hardhat-chai-matchers";
import "@nomicfoundation/hardhat-toolbox/network-helpers";
import { ETH_RPC, GOERLI_RPC, OVA_BETA_RPC, PRIVATE_ARB_SEPOLIA_RPC_PREFIX, PRIVATE_ETH_RPC_PREFIX, PRIVATE_ETH_SEPOLIA_RPC_PREFIX } from './rpc';
import 'solidity-docgen';
import { EndpointId } from '@layerzerolabs/lz-definitions'

dotenv.config({ path: process.cwd() + "/process.env"});

const testAccounts = [
  {
    privateKey: process.env.ADMIN_WALLET_KEY!,
    balance: "10000000000000000000000000",
  },
  {
    privateKey: process.env.TEAM_WALLET_KEY!,
    balance: "10000000000000000000",
  },
  {
    privateKey: process.env.USER_A_WALLET_KEY!,
    balance: "10000000000000000000",
  },
  {
    privateKey: process.env.USER_B_WALLET_KEY!,
    balance: "10000000000000000000",
  },
  {
    privateKey: process.env.USER_C_WALLET_KEY!,
    balance: "10000000000000000000",
  },
];

const config: HardhatUserConfig = {
  gasReporter: {
    enabled: true,
    currency: 'USD',
    L1: "ethereum",
    L1Etherscan: process.env.ETHERSCAN_API_KEY!,
    coinmarketcap: process.env.COINMARKETCAP_API_KEY!,
    //outputFile: 'gas-report.txt',
  },
  docgen: {
    output: 'docs',
    exclude: [
      'ambassador',
      'curve', 
      'faucet', 
      'liquidity', 
      'mock_ERC20', 
      'overlayerbacking', 
      'sepolialottery', 
      'shared', 
      'pancake', 
      'uniswap', 
      'test', 
      'whitelist',
      'overlayer/rOVA.sol',
      'overlayer/rOVAV2.sol',
      'overlayer/OVA.sol',
      'overlayer/OvaReferral.sol',
      'overlayer/OverlayerWrapFactory.sol',
      'overlayer/interfaces/IOvaReferral.sol'
    ],
    pages: 'files'
  },
  etherscan: {
    apiKey: {
      sepolia: process.env.ETHERSCAN_API_KEY!,
    },
  },
  sourcify: {
    // Disabled by default
    // Doesn't need an API key
    enabled: true
  },
  solidity: {
    compilers: [
      {
        version: "0.8.20",
        settings: {
          optimizer: {
            enabled: true,
            runs: 300,
          },
          // viaIR: true
        },
      },
      {
        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: "localhost",
  networks: {
    hardhat: {
      forking: {
        url: process.env.FORK_RPC || (PRIVATE_ETH_RPC_PREFIX + process.env.ALCHEMY_KEY!),
        enabled: true,
        blockNumber: 22917626,
      },
      accounts: testAccounts,
      allowUnlimitedContractSize: true,
    },
    ova: {
      url: OVA_BETA_RPC,
      chainId: 0x7A69,
      accounts: [process.env.ADMIN_WALLET_KEY!, process.env.TEAM_WALLET_KEY!],
      gas: "auto",
      gasPrice: "auto",
      allowUnlimitedContractSize: true,
    },
    eth: {
      url: ETH_RPC,
      chainId: 0x1,
      accounts: [process.env.OVA_MAINNET_ROVA_DEPLOYER_KEY!],
      gas: "auto",
      gasPrice: "auto",
      allowUnlimitedContractSize: true,
    },
    eth_sepolia: {
      eid: EndpointId.SEPOLIA_V2_TESTNET,
      url: PRIVATE_ETH_SEPOLIA_RPC_PREFIX + process.env.ALCHEMY_KEY!,
      chainId: 0xAA36A7,
      accounts: [process.env.OVA_SEPOLIA_DEPLOYER_KEY!, process.env.OVA_SEPOLIA_TREASURY_KEY!],
      gasPrice: "auto",
      gas: "auto",
      allowUnlimitedContractSize: true,
    },
    arbitrum_sepolia: {
      eid: EndpointId.ARBITRUM_V2_TESTNET,
      url: PRIVATE_ARB_SEPOLIA_RPC_PREFIX + process.env.ALCHEMY_KEY!,
      chainId: 0x66eee,
      accounts: [process.env.OVA_SEPOLIA_DEPLOYER_KEY!, process.env.OVA_SEPOLIA_TREASURY_KEY!],
      gasPrice: "auto",
      gas: "auto",
      allowUnlimitedContractSize: true,
    }
  },
};

export default config;

See also the process.env I created

ADMIN_WALLET_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
TEAM_WALLET_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
USER_A_WALLET_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
USER_B_WALLET_KEY=0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6
USER_C_WALLET_KEY=0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a
ALCHEMY_KEY=demo
OVA_MAINNET_ROVA_DEPLOYER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
OVA_SEPOLIA_DEPLOYER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
OVA_SEPOLIA_TREASURY_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

Reproduction steps

  1. Deploy two OverlayerWrapMock instances on the same local Ethereum mainnet fork — one configured as the hub (hubChainId == hardhat chainId 31337), one configured as the spoke (hubChainId == 1, a foreign chain id). Both use the same collateral tokens and LZ endpoint address. This simulates two independent on-chain deployments.

  2. Mint 10,000 USDT+ on the hub instance via the normal collateral-backed mint path. This is the only chain where minting is allowed.

  3. Simulate the source side of a Hub→Spoke bridge: call testDebit on the hub instance. This burns tokens on the hub and increments the hub's totalBridgedOut. Verify the hub's accounting is correct.

  4. Simulate the destination side of the same bridge: call testCredit on the spoke instance with the same amount. This is what LayerZero's _lzReceive would call on the spoke. Verify that this reverts with an arithmetic underflow, because the spoke's totalBridgedOut is 0 and cannot be decremented.

  5. Confirm the spoke has zero token balance — the tokens were burned on the hub but never minted on the spoke. Funds are permanently lost.

Attachments

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