Status DataClose notification

Overlayer Disclosed Report

Cross-Chain Token Reception Permanently Broken on Spoke Chains - totalBridgedOut Underflow in _credit

Company
Created date
Mar 28 2026

Target

hidden

Vulnerability Details

Severity: Critical - Any user attempting to bridge tokens from the hub chain to any spoke chain will have their tokens permanently burned on the source chain while the destination chain reverts, resulting in irreversible loss of funds.

Affected Code: contracts/overlayer/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; // @audit UNDERFLOW on spoke chains
  }            

And the corresponding _debit at line 510-528:

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

Root Cause:

The totalBridgedOut state variable starts at 0 (Solidity default) on every chain where OverlayerWrap is deployed. The _credit function, called by LayerZero when tokens
arrive on a destination chain, unconditionally subtracts from totalBridgedOut. On spoke chains (any chain where block.chainid != hubChainId):

  1. totalBridgedOut is initialized to 0 and can never be incremented because:
    • _debit (the only function that increments it) requires tokens to exist on the chain to burn them
    • mint() is restricted to the hub chain via the onlyHubChain(block.chainid) modifier in _managerMint

Therefore, no tokens can ever exist on spoke chains to trigger _debit

  1. When LayerZero delivers a cross-chain message to the spoke chain, _credit executes 0 - amountReceivedLD, which always reverts with an arithmetic underflow in Solidity
    0.8+.

This creates an impossible chicken-and-egg situation: tokens cannot arrive on spoke chains (because _credit underflows), and tokens cannot be sent from spoke chains (because none exist to send).

Exploit Scendario:

  1. Protocol deploys OverlayerWrap on Ethereum mainnet (hub, hubChainId = 1) and Arbitrum (spoke)
  2. Alice mints 1000 USDT+ on Ethereum mainnet by depositing 1000 USDT
  3. Alice calls send() (inherited from OFT) to bridge 500 USDT+ from Ethereum to Arbitrum
  4. On Ethereum: _debit burns 500 USDT+ from Alice, totalBridgedOut becomes 500
  5. LayerZero delivers the message to Arbitrum
  6. On Arbitrum: _credit is called → super._credit tries to mint 500 USDT+ → then totalBridgedOut -= 500 → REVERTS (0 - 500 underflows)
  7. The LayerZero message enters "stored" state and can be retried, but will always fail
  8. Alice's 500 USDT+ are permanently burned on Ethereum and can never be minted on Arbitrum
  9. Result: Alice loses 500 USDT+ permanently

Impact:

Permanent, irreversible loss of user funds. Any tokens sent via LayerZero OFT bridge from the hub chain to any spoke chain are:

  • Burned permanently on the hub chain (via _debit)
  • Never mintable on the spoke chain (because _credit always reverts)
  • Not recoverable — OverlayerWrap is not upgradeable, and LayerZero stored messages will always fail on retry

The multi-chain architecture is core to the protocol design (evidenced by LayerZero OFT inheritance, hubChainId parameter, eth_sepolia/arbitrum_sepolia network
configurations with LayerZero endpoint IDs, and the totalBridgedOut tracking in AaveHandler.supply()).

Remediation:

The _credit override should only decrement totalBridgedOut on the hub chain, since that's the only chain where the accounting invariant (totalSupply + totalBridgedOut =
effective supply) matters for collateral backing:

function _credit(
      address to_,
      uint256 amountLD_,
      uint32 srcEid_                                                                                                                                                         
  ) internal virtual override returns (uint256 amountReceivedLD) {
      amountReceivedLD = super._credit(to_, amountLD_, srcEid_);                                                                                                             
      // Only track on hub chain where the backing accounting requires it
      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_                                                                                                                            
      );
      // Only track on hub chain                                                                                                                                             
      if (block.chainid == hubChainId) {
          totalBridgedOut += amountSentLD;
      }                                                                                                                                                                      
  }

This preserves the effective-supply invariant on the hub chain (where AaveHandler.supply() uses totalSupply() + totalBridgedOut()) while allowing spoke chains to receive
and send tokens normally via the standard OFT mint/burn mechanism.

Validation steps

PoC (Hardhat):

import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
  import { ethers } from "hardhat";                                                                                                                                          
  import { expect } from "chai";
                                                                                                                                                                             
  describe("OverlayerWrapCore _credit Underflow PoC", function () {                                                                                                          
    async function deploySpokeFixture() {
      const [admin, alice] = await ethers.getSigners();                                                                                                                      
      const block = await admin.provider.getBlock("latest");
      const baseFee = block?.baseFeePerGas ?? 1n;                                                                                                                            
      const txOpts = { maxFeePerGas: baseFee * 10n };
                                                                                                                                                                             
      // Deploy mock collateral tokens
      const Collateral = await ethers.getContractFactory("SixDecimalsUsd");                                                                                                  
      const collateral = await Collateral.deploy(1000, "USDT", "USDT", txOpts);                                                                                              
      const aCollateral = await Collateral.deploy(1000, "aUSDT", "aUSDT", txOpts);
                                                                                                                                                                             
      // Get the real LZ endpoint address (for constructor only)                                                                                                             
      const LZ_ENDPOINT = "0x1a44076050125825900e736c501f859c50fE728c";                                                                                                      
                                                                                                                                                                             
      // Deploy OverlayerWrapMock as a SPOKE chain:                                                                                                                          
      // hubChainId is set to 1 (Ethereum mainnet), but we're on Hardhat (chainId 31337)                                                                                     
      // This simulates deploying OverlayerWrap on a non-hub chain (e.g., Arbitrum)                                                                                          
      const OverlayerWrap = await ethers.getContractFactory("OverlayerWrapMock");                                                                                            
      const spokeOverlayerWrap = await OverlayerWrap.deploy(                                                                                                                 
        {                                                                                                                                                                    
          admin: admin.address,
          lzEndpoint: LZ_ENDPOINT,                                                                                                                                           
          name: "Tether USD+",
          symbol: "USDT+",                                                                                                                                                   
          collateral: {
            addr: await collateral.getAddress(),                                                                                                                             
            decimals: await collateral.decimals(),
          },
          aCollateral: {
            addr: await aCollateral.getAddress(),
            decimals: await aCollateral.decimals(),                                                                                                                          
          },
          maxMintPerBlock: ethers.MaxUint256,                                                                                                                                
          maxRedeemPerBlock: ethers.MaxUint256,
          minValmaxRedeemPerBlock: 1n,                                                                                                                                       
          hubChainId: 1, // <-- Hub is Ethereum mainnet, NOT this chain
        },                                                                                                                                                                   
        txOpts    
      );                                                                                                                                                                     
                  
      return { spokeOverlayerWrap, alice };                                                                                                                                  
    }
                                                                                                                                                                             
    it("CRITICAL: _credit reverts on spoke chain due to totalBridgedOut underflow", async function () {                                                                      
      const { spokeOverlayerWrap, alice } = await loadFixture(deploySpokeFixture);
                                                                                                                                                                             
      // Verify totalBridgedOut starts at 0 on spoke chain                                                                                                                   
      expect(await spokeOverlayerWrap.totalBridgedOut()).to.equal(0);
                                                                                                                                                                             
      // Verify minting is impossible on spoke chain (onlyHubChain modifier)                                                                                                 
      // This means no tokens can ever exist on this chain to send out via _debit                                                                                            
      // Therefore totalBridgedOut can NEVER be incremented above 0                                                                                                          
                                                                                                                                                                             
      // Simulate what happens when LayerZero delivers a cross-chain message                                                                                                 
      // (i.e., someone bridged 10 USDT+ from the hub chain to this spoke chain)                                                                                             
      const bridgeAmount = ethers.parseEther("10");                                                                                                                          
                                                                                                                                                                             
      // _credit attempts: totalBridgedOut -= 10e18, which is 0 - 10e18 = UNDERFLOW                                                                                          
      await expect(                                                                                                                                                          
        spokeOverlayerWrap.testCredit(alice.address, bridgeAmount, 0)                                                                                                        
      ).to.be.revertedWithPanic(0x11); // 0x11 = arithmetic underflow/overflow                                                                                               
                                                                                                                                                                             
      // User's 10 USDT+ were burned on the hub chain via _debit,                                                                                                            
      // but can NEVER be minted here. Funds are permanently lost.                                                                                                           
      expect(await spokeOverlayerWrap.totalSupply()).to.equal(0);                                                                                                            
      expect(await spokeOverlayerWrap.totalBridgedOut()).to.equal(0);                                                                                                        
    });
  });                                                                                 

The above code demonstrates that testCredit (which calls internal _credit) reverts with panic code 0x11 (arithmetic underflow) on a spoke chain instance.

Attachments

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