The reviewed cross-chain accounting logic in OverlayerWrapCore introduces a mismatch between the chain that increments totalBridgedOut and the chain that decrements it. Outbound bridge transfers increment totalBridgedOut on the source chain during _debit, but inbound bridge deliveries also decrement totalBridgedOut on the destination chain during _credit.
On any spoke deployment, totalBridgedOut starts at zero and cannot be pre-seeded through normal user minting because minting is restricted to the hub chain. As a result, the first hub-to-spoke delivery executes totalBridgedOut -= amountReceivedLD against zero and reverts with Solidity 0.8 arithmetic underflow. Because the source-chain burn already happened before the message is delivered, this creates a durable cross-chain fund lock / destruction condition for bridged user tokens rather than a temporary accounting inconsistency.
File: contracts/contracts/overlayer/OverlayerWrapCore.sol
Lines: 537-544
Function/Type: _credit
File: contracts/contracts/overlayer/OverlayerWrapCore.sol
Lines: 353-359
Function/Type: _managerMint
The vulnerable logic sits in the OFT bridge receive path. When a user bridges OverlayerWrap from the hub chain to a spoke chain, the source-side OFT flow burns the user’s tokens during _debit, and OverlayerWrapCore records that burn by incrementing totalBridgedOut on the source chain.
When the LayerZero message is later delivered to the destination chain, the OFT receive flow calls _credit. OverlayerWrapCore overrides _credit and unconditionally decrements totalBridgedOut after minting the destination-side tokens. That assumption is invalid on spoke deployments, because the spoke never executed the matching _debit and therefore has no corresponding totalBridgedOut balance to decrement.
This path is permissionless and reachable through the normal public bridge flow. No privileged role and no unusual token behavior are required. A regular user only needs to bridge tokens from the hub to a spoke. Once the LayerZero delivery reaches the spoke, _credit executes against totalBridgedOut == 0 and reverts deterministically.
The issue is strengthened by the hub-only mint restriction. On a spoke, users cannot create local supply through mint() to build up totalBridgedOut first, because _managerMint enforces onlyHubChain(block.chainid). That means the vulnerable state is the default state of every spoke deployment from genesis.
The root cause is that cross-chain outbound accounting is stored as a local per-chain variable, but the inbound credit path treats it as if it were globally synchronized across chains. _debit increments totalBridgedOut on the source chain, while _credit decrements totalBridgedOut on the destination chain even though that destination chain never recorded the outbound burn.
// File: contracts/contracts/overlayer/OverlayerWrapCore.sol:510-544
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;
}
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD;
}
// File: contracts/contracts/overlayer/OverlayerWrapCore.sol:353-359
function _managerMint(
OverlayerWrapCoreTypes.Order calldata order_
)
internal
belowMaxMintPerBlock(order_.overlayerWrapAmount)
onlyHubChain(block.chainid)
{
Step 1 A user mints or already holds OverlayerWrap on the hub chain.
Step 2 The user initiates a standard OFT bridge transfer from the hub to a spoke.
Step 3 The source chain burns the user’s tokens in _debit, then the destination chain attempts to mint in _credit.
Result: The spoke executes totalBridgedOut -= amountReceivedLD against zero and reverts, leaving the already-burned source tokens durably locked in the failed cross-chain transfer.
Step 1 A spoke deployment starts with totalBridgedOut == 0.
Step 2 A user sends the first inbound bridge transfer from the hub to that spoke.
Step 3 The LayerZero receive path reaches _credit on the spoke.
Step 4 The decrement of totalBridgedOut underflows immediately because the spoke never executed the matching outbound _debit.
Result: The first inbound bridge delivery to the spoke fails deterministically, so the spoke bridge route is unusable from genesis and the transferred user funds remain unrecoverable through the normal path.
OverlayerWrapCore._debit() burns the source-chain tokens and increments totalBridgedOut on the source chain._credit(...) from _lzReceive(...).OverlayerWrapCore._credit() mints destination-side tokens through super._credit(...).OverlayerWrapCore._credit() then executes totalBridgedOut -= amountReceivedLD.totalBridgedOut is zero, so the subtraction underflows and the entire receive flow reverts.This issue causes direct end-user fund loss / permanent lock in . A user can follow the intended bridge flow, have their tokens burned on the hub, and still fail to receive tokens on the destination chain because the destination _credit path always reverts on a spoke with zero totalBridgedOut.
The trigger cost is low because no privilege, large capital base, or external failure is required. Any normal bridge user can trigger the condition simply by sending tokens from the hub to a spoke. The protocol cost is high because the affected route becomes non-functional and transferred value is stranded after the burn occurs. Triagers should care because this is not a soft liveness issue: it is a deterministic failure in the main asset movement path that can permanently lock bridged user funds.
Framework used: hardhat
forking: { url: ETH_RPC, enabled: true, blockNumber: 22917626, }
File 1: contracts/test/PoC_CrossChainCreditUnderflow.ts
it("CRITICAL: _credit on spoke reverts with arithmetic underflow — user tokens permanently lost", async function () {
const { spokeOW, alice } = await loadFixture(deployFixture);
const bridgeAmount = ethers.parseUnits("100", 18);
const srcEid = 1;
expect(await spokeOW.totalBridgedOut()).to.equal(0n);
await expect(
spokeOW.testCredit(alice.address, bridgeAmount, srcEid)
).to.be.reverted;
});
it("hub _credit succeeds only after matching _debit (correct round-trip on hub)", async function () {
const { hubOW, collateral, alice, mintAmount } =
await loadFixture(deployFixture);
await collateral.connect(alice).approve(await hubOW.getAddress(), mintAmount);
const owAmount = ethers.parseUnits("100", 18);
await hubOW.connect(alice).mint({
benefactor: alice.address,
beneficiary: alice.address,
collateral: await collateral.getAddress(),
collateralAmount: mintAmount,
overlayerWrapAmount: owAmount,
});
await hubOW.connect(alice).testDebit(alice.address, owAmount, 0, 2);
await expect(
hubOW.testCredit(alice.address, owAmount, 2)
).to.not.be.reverted;
});
cd contracts
npx hardhat test test/PoC_CrossChainCreditUnderflow.ts
PoC: Critical — _credit underflow permanently locks bridged tokens
✔ spoke chain starts with totalBridgedOut == 0 (4592ms)
Hub totalBridgedOut after _debit: 100000000000000000000
✔ hub _debit increments totalBridgedOut (55ms)
=== PoC: Critical Cross-Chain Bug ===
Calling spokeOW.testCredit(alice, 100e18, 1) — simulating LZ message delivery
This executes: totalBridgedOut (== 0) -= 100e18 → arithmetic underflow
✅ Confirmed: _credit reverts. Tokens burned on hub are permanently lost.
✔ CRITICAL: _credit on spoke reverts with arithmetic underflow — user tokens permanently lost (40ms)
Hub round-trip (_debit → _credit) succeeds. Spoke never gets matching _debit → always fails.
✔ hub _credit succeeds only after matching _debit (correct round-trip on hub) (82ms)
4 passing (5s)
EXIT_CODE=0
The first passing test line, spoke chain starts with totalBridgedOut == 0, proves the vulnerable precondition on a spoke deployment. This matters because the destination-side _credit logic subtracts from this value without first establishing any spoke-local bridge-out balance.
The next log line, Hub totalBridgedOut after _debit: 100000000000000000000, confirms that the source-chain _debit path records outbound bridge accounting only on the hub. This is the exact mismatch that creates the failure: the source chain increments the counter, while the destination chain later tries to decrement its own untouched copy.
The PoC then prints This executes: totalBridgedOut (== 0) -= 100e18 → arithmetic underflow immediately before calling spokeOW.testCredit(...). The subsequent passing assertion, CRITICAL: _credit on spoke reverts with arithmetic underflow, shows that the inbound bridge receive path fails deterministically once a spoke tries to process the credit.
Finally, the last passing line, Hub round-trip (_debit → _credit) succeeds. Spoke never gets matching _debit → always fails., isolates the bug to spoke-side execution rather than _credit generally. In bug-bounty terms, the logs show a concrete user-loss flow: the hub burn succeeds, the spoke receive reverts, and the intended bridge transfer cannot complete through the normal protocol path.
expect(await spokeOW.totalBridgedOut()).to.equal(0n) -> a spoke starts with no outbound bridge accounting to decrement -> PASS
await expect(spokeOW.testCredit(alice.address, bridgeAmount, srcEid)).to.be.reverted -> destination-side _credit on a spoke deterministically reverts during inbound bridge processing -> PASS
await expect(hubOW.testCredit(alice.address, owAmount, 2)).to.not.be.reverted -> the failure is not generic to _credit; it is caused by decrementing an uninitialized spoke-side totalBridgedOut -> PASS
Restrict the decrement of totalBridgedOut to the hub chain, or otherwise maintain separate accounting so a destination chain never decrements bridge-out state that was recorded on a different chain. The safest pattern is to update totalBridgedOut only on the chain where the outbound burn happened.