OverlayerWrapCore._credit() (line 543) unconditionally executes totalBridgedOut -= amountReceivedLD on every chain where the contract is deployed. On spoke chains, totalBridgedOut is initialized to 0 (default storage value) and has no setter or initializer function. The very first incoming cross-chain transfer to any spoke chain triggers an arithmetic underflow revert under Solidity 0.8.20 checked math, permanently blocking ALL LayerZero OFT bridge transfers to that chain.
This affects every spoke chain deployment of the OverlayerWrap contract. Tokens sent from any chain to a spoke are permanently lost in transit — the LayerZero message delivers but the receiving _credit function reverts, and the tokens have already been burned on the source chain via _debit.
The totalBridgedOut accounting variable tracks how many tokens have been bridged out from a chain (via _debit, line 527) and decrements when tokens return (via _credit, line 543). This design assumes that credits (incoming tokens) are always preceded by debits (outgoing tokens) on the same contract instance.
This assumption holds on the hub chain (where tokens are minted and bridged out before returning). It does NOT hold on spoke chains, where the first interaction is always an incoming credit with no prior debit.
// OverlayerWrapCore.sol:510-528 — SOURCE chain
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 SOURCE chain
}
// OverlayerWrapCore.sol:537-544 — DESTINATION chain
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD; // Decrements on DESTINATION chain
// BUG: On spoke chains, totalBridgedOut = 0, this UNDERFLOWS
}
totalBridgedOut is declared at line 63 as uint256 public totalBridgedOut — defaults to 0 in storage_debit (line 527) increments totalBridgedOut on the source chain only_credit (line 543) decrements totalBridgedOut on the destination chain_credit fires with totalBridgedOut = 0 and amountReceivedLD > 0 → 0 - X underflowsonlyHubChain Doesn't HelpThe contract correctly uses onlyHubChain to guard _managerMint (line 358) and _managerRedeem (line 377). However, _credit and _debit have no such guard — they are called by the LayerZero endpoint on every chain as part of the OFT standard messaging flow.
totalBridgedOut on spoke chainsWhen a user bridges tokens from hub to spoke:
_debit on hub burns the tokens and increments hub's totalBridgedOut (works)_credit on spoke reverts due to underflowEvery spoke chain is affected. If the protocol deploys on N chains, (N-1) spoke chains cannot receive ANY bridge transfers. This makes the entire cross-chain functionality of the OverlayerWrap token non-functional on all non-hub chains.
Guard the totalBridgedOut accounting with a hub-chain check, since it only makes sense on the hub where mint/redeem happen:
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
- totalBridgedOut -= amountReceivedLD;
+ 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_);
- totalBridgedOut += amountSentLD;
+ if (block.chainid == hubChainId) {
+ totalBridgedOut += amountSentLD;
+ }
}
This matches the existing pattern where _managerMint and _managerRedeem already use onlyHubChain(block.chainid).
Alternatively, the normalizedSupply calculation in AaveHandler.supply() that reads totalBridgedOut should also be hub-only, ensuring the accounting variable is only meaningful where it's maintained.
Scenario: Hub sends 1000 OVA to Spoke Chain A
State before:
Hub: totalBridgedOut = 500e18 (from prior bridge-outs)
Spoke: totalBridgedOut = 0 (freshly deployed, default storage)
Step 1: User calls send() on hub to bridge 1000 OVA to Spoke A
→ Hub._debit() fires
→ super._debit() burns 1000e18 tokens from user
→ totalBridgedOut += 1000e18 → Hub totalBridgedOut = 1500e18
→ LayerZero message sent
Step 2: LayerZero delivers message to Spoke A
→ Spoke._credit() fires
→ super._credit() would mint 1000e18 tokens to recipient
→ totalBridgedOut -= 1000e18
→ 0 - 1000e18 = UNDERFLOW → REVERT
Result:
Hub: 1000 OVA burned (gone forever)
Spoke: 0 OVA minted (revert prevented it)
User: Lost 1000 OVA permanently
The existing test at test/OverlayerWrap.ts:1292-1363 tests _debit and _credit using mock functions (testDebit/testCredit) on the same contract instance, masking the cross-chain state separation. A real cross-chain test with two separate contract instances would immediately reveal the underflow.