File: contracts/overlayer/OverlayerWrapCore.sol, lines 537-544
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
}
Root Cause
totalBridgedOut starts at 0 on every contract instance. On spoke chains:
Attack Flow
Impact
Suggested Fix
Guard totalBridgedOut accounting with a hub-chain check:
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;
}
}
Prerequisites
Step 1: Add the mock endpoint contract
Create file contracts/test/MockLzEndpoint.sol with a minimal mock that implements setDelegate (the only function called by OFT constructor). This avoids needing a mainnet fork.
Step 2: Add the standalone Hardhat config
Create file hardhat.config.poc.ts with solidity 0.8.20/0.8.10/0.7.6 compilers and hardhat network with allowUnlimitedContractSize: true. No env
vars or forking required.
Step 3: Add the PoC test file
Create file test/PoC_CreditUnderflow.ts (attached in supporting files). The test deploys two OverlayerWrapMock instances:
Step 4: Run the PoC
HARDHAT_CONFIG=hardhat.config.poc.ts npx hardhat test test/PoC_CreditUnderflow.ts
Expected Output (all 5 passing):
PoC: _credit() Underflow Locks Cross-Chain Funds
Pass - Baseline: hub chain debit then credit works correctly (785ms)
Pass - CRITICAL: _credit() reverts when totalBridgedOut == 0
Pass - CRITICAL: spoke chain can NEVER receive tokens via _credit()
Pass - CRITICAL: spoke chain mint() blocked, totalBridgedOut stuck at 0
Pass - END-TO-END: user bridges from hub, tokens burned, spoke rejects, funds LOST
5 passing (802ms)
What Each Test Proves
Test 1 (Baseline): Hub chain debit then credit round-trip works. totalBridgedOut goes 0 to N to 0. This is the ONLY working path.
Test 2 (Underflow): Calling _credit() when totalBridgedOut == 0 reverts with panic 0x11 (arithmetic underflow). Proves the core bug.
Test 3 (Spoke deadlock): On a spoke chain contract instance, _credit() ALWAYS reverts because totalBridgedOut is permanently 0.
Test 4 (No bootstrap): mint() is blocked on spoke chains by onlyHubChain modifier. There is no way to get tokens or increment totalBridgedOut.
Chicken-and-egg deadlock confirmed.
Test 5 (End-to-end): Full attack scenario. User mints 20 tokens on hub, bridges 15 to spoke. Hub burns 15 tokens successfully. Spoke rejects
delivery with underflow revert. User permanently loses 15 tokens and their underlying collateral backing. No recovery possible.