OverlayerWrapCore inherits from LayerZero's OFT (Omnichain Fungible Token) and overrides _debit() and _credit() to track cross-chain supply via the totalBridgedOut state variable:
/// @notice Cumulative amount of tokens debited via OFT cross-chain transfers (not yet credited back)
uint256 public totalBridgedOut;
On the hub chain, _debit() increments totalBridgedOut when tokens are burned for cross-chain transfer, and _credit() decrements it when tokens return. This accounting works on the hub chain because totalBridgedOut is always incremented before it is decremented.
However, on spoke chains (any non-hub deployment), totalBridgedOut is initialized to 0 and is never incremented because _debit() on a spoke chain only fires when a user bridges tokens out of that spoke — not when tokens arrive. When LayerZero delivers a cross-chain message to a spoke chain, _credit() is invoked to mint tokens for the recipient:
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD; // UNDERFLOW: totalBridgedOut = 0 on spoke chains
}
Since Solidity 0.8.20 uses checked arithmetic, totalBridgedOut -= amountReceivedLD when totalBridgedOut = 0 causes an arithmetic underflow and reverts the entire transaction. The super._credit() call (which mints tokens to the recipient) is rolled back.
The result:
_debit(), and totalBridgedOut is incremented. The user's tokens are gone._credit() reverts due to underflow. Tokens are never minted to the recipient.totalBridgedOut remains 0 on the spoke chain — there is no mechanism to increment it without an outbound bridge (which requires tokens that can never arrive).This creates a permanent, irrecoverable loss of all tokens bridged from hub to any spoke chain.
_debit() / _credit() AsymmetryThe _debit() function only increments totalBridgedOut (addition is always safe):
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;
}
On the hub chain, the flow is: _debit (totalBridgedOut goes UP) → bridge → _credit on return (totalBridgedOut goes DOWN). This is balanced.
On spoke chains, the FIRST operation is always _credit (receiving tokens from hub), but totalBridgedOut starts at 0. There is no prior _debit to bring it above zero.
totalBridgedOut. The stored LayerZero message will always fail on retry. The only fix is a contract upgrade on the spoke chain.Likelihood: HIGH
send() to bridge tokenshubChainId state variable, onlyHubChain modifier on mint/redeem)Impact: HIGH
Run command:
HARDHAT_CONFIG=hardhat.config.poc.ts npx hardhat test test/PoC_C01_TotalBridgedOutUnderflow.ts
Test output:
WARNING: You are currently using Node.js v25.5.0, which is not supported by Hardhat. This can lead to unexpected behavior. See https://hardhat.org/nodejs-versions
PoC C-01: totalBridgedOut Underflow on Spoke Chains
✅ CONFIRMED: _credit() reverts on spoke chain
→ totalBridgedOut = 0, attempting to subtract 10000000000000000000000
→ Solidity 0.8.20 checked arithmetic causes underflow panic (0x11)
→ Tokens burned on hub chain are NEVER minted on spoke chain
→ Result: PERMANENT FUND LOSS for any user bridging to spoke chains
✔ CRITICAL: _credit() reverts on spoke chain due to totalBridgedOut underflow (653ms)
✅ CONTROL: _credit() succeeds on hub chain (totalBridgedOut > 0)
→ Hub chain: debit first, then credit works correctly
✔ CONTROL: _credit() succeeds on hub chain after _debit()
✅ CONFIRMED: Even 1 wei bridge to spoke chain reverts
→ Attack has ZERO cost threshold — any amount is lost
✔ PROOF: Even 1 wei bridge to spoke chain is permanently lost
3 passing (683ms)
Test descriptions:
hubChainId = 1 (Ethereum mainnet, not Hardhat's 31337 — simulating a spoke chain). Verifies totalBridgedOut = 0. Calls testCredit() to simulate an inbound bridge of 10,000 OVW. Reverts with Panic(0x11) — arithmetic underflow. Alice receives zero tokens. totalBridgedOut remains 0.testDebit() (totalBridgedOut goes up), then testCredit(). Succeeds — proving the bug is spoke-chain-specific.PoC source files (3 files): https://gist.github.com/0xiehnnkta/499bae42f53471c54ac61ea223271b95
The gist contains:
PoC_C01_TotalBridgedOutUnderflow.ts — Hardhat test (3 tests: spoke underflow, hub control, 1-wei proof)MockLZEndpoint.sol — Minimal LayerZero endpoint mock for local testinghardhat.config.poc.ts — Standalone Hardhat config (no forking required)The PoC also uses the pre-existing OverlayerWrapMock.sol already in the repository, which exposes internal _debit() / _credit() for unit testing.
Option A (Recommended): Only track totalBridgedOut on the hub chain. Skip the decrement on spoke chains:
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;
}
}
Option B: Use a safe subtraction with a floor at zero:
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
if (totalBridgedOut >= amountReceivedLD) {
totalBridgedOut -= amountReceivedLD;
} else {
totalBridgedOut = 0;
}
}
Option C: Maintain per-chain accounting with a separate totalBridgedIn counter on spoke chains for accurate supply tracking.
1. Protocol deploys OverlayerWrap on Hub Chain (Ethereum) and Spoke Chain (Arbitrum)
2. User holds 10,000 OVW on Hub Chain
3. User calls send() to bridge 10,000 OVW to Spoke Chain (Arbitrum)
4. Hub Chain: _debit() burns 10,000 OVW, totalBridgedOut += 10,000 ✓
5. LayerZero delivers message to Spoke Chain
6. Spoke Chain: _credit() calls super._credit() to mint 10,000 OVW to user
7. Spoke Chain: totalBridgedOut -= 10,000 → UNDERFLOW (totalBridgedOut = 0)
8. Spoke Chain: ENTIRE TRANSACTION REVERTS — tokens NOT minted
9. Result: User lost 10,000 OVW permanently
- Hub: tokens burned, totalBridgedOut = 10,000
- Spoke: tokens never minted, totalBridgedOut = 0
- LayerZero: message stored, retry always fails
``