Bridging tokens from hub to any non-hub chain (OP, Arb, etc.) permanently burns the user's funds. _credit() in OverlayerWrapCore always subtracts from totalBridgedOut, but on non-hub chains that value is 0, so Solidity 0.8 panics with an underflow and the whole tx reverts. The tokens are already burned on the hub side by _debit() at that point — they're just gone, no recovery.
I traced the full path and there's nothing between the LZ endpoint and _credit() that could prevent this. Tested it end-to-end with the real lzReceive() flow in the PoC — same result. Any user who tries to bridge loses their tokens. Every single time, not just edge cases.
function _credit(address to_, uint256 amountLD_, uint32 srcEid_)
internal virtual override returns (uint256 amountReceivedLD)
{
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD; // underflows when totalBridgedOut is 0
}
Same problem in _debit() at line 527 — touches totalBridgedOut unconditionally too.
There's no way around it. mint() is gated by onlyHubChain. _debit() needs a balance, but the only way to get balance on a non-hub chain is through _credit(). So _credit() is always the first operation, and it does 0 - amount → panic. It's a deadlock.
Hub Non-Hub
─── ───────
send(500 OW)
_debit() burns 500
LZ msg ─────────────────────────────► _lzReceive() → _credit(500)
totalBridgedOut = 0
500 OW gone 0 - 500 → panic → revert
nothing minted
Call path: EndpointV2.lzReceive() → OAppReceiver.lzReceive() (peer check) → OFTCore._lzReceive() line 277 calls _credit() directly. No guards in between.
clear()/nilify() can unblock the LZ queue but don't recover burned tokensmint() only works on hub — can't compensate users on the destination chainhubChainId exists to distinguish chains but _credit()/_debit() ignore itAlready deployed cross-chain on testnet per contracts/docs/deployments.md in the repo. Checked on-chain: the OP Sepolia contract 0xfd0E0c5132B1C0C59A413e52f97BC421592Ba460 returns hubChainId = 11155111 (Eth Sepolia) — confirmed non-hub.
Attached:
PoC_CreditUnderflow.ts — 5 test cases, including full lzReceive() pathMockLZEndpoint.sol — minimal mock for the LZ endpointhardhat.config.poc.ts — standalone config, no keys or fork neededpoc-output.png — test run screenshotJust guard totalBridgedOut so it only runs on hub:
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;
}
}
Drop 3 files into the repo:
MockLZEndpoint.sol → contracts/contracts/test/MockLZEndpoint.sol
PoC_CreditUnderflow.ts → contracts/test/PoC_CreditUnderflow.ts
hardhat.config.poc.ts → contracts/hardhat.config.poc.ts
cd contracts
npx hardhat test test/PoC_CreditUnderflow.ts --config hardhat.config.poc.ts
No env vars, no keys, no fork.
PoC: _credit underflow on non-hub chain
✔ NON-HUB: _credit reverts with underflow (totalBridgedOut = 0)
✔ NON-HUB: even 1 wei bridge amount causes underflow
✔ HUB: _credit works correctly after _debit (control test)
✔ NON-HUB: full attack flow — user loses funds permanently
✔ NON-HUB: full lzReceive path reverts with underflow
5 passing
Screenshot: poc-output.png
Tests 1–2: underflow on non-hub. Test 3: hub works fine (control). Test 4: full flow — 500 OW burned on hub, 0 minted on non-hub. Test 5: end-to-end through real lzReceive() path.