This issue is a regression in the cross-chain accounting fix introduced in the scoped commit. The protocol added totalBridgedOut to preserve effective supply after OFT burns, but that value is stored per token instance, not globally. On the source chain, _debit() increases the local counter. On the destination chain, _credit() decreases the destination-local counter. A fresh non-hub remote starts with totalBridgedOut == 0, so its first inbound credit attempts to subtract from zero and reverts after the source-side burn path has already executed. The result is a full lock of the affected bridged amount under normal protocol logic. This report targets the exact DualDefense scoped commit and the explicitly listed in-scope contracts.
The impact is a direct and permanent lock of end-user bridged funds in the affected transfer.
This is not a theoretical issue and it does not depend on privileged abuse. The failure occurs in the intended OFT send and receive path once a fresh non-hub remote receives its first inbound transfer. The attached proof of concept shows the burn on the hub side, the destination-side panic11, repeated retry failure, zero accessible user balance on both ends, and 100 percent of the affected amount stranded in bookkeeping only.
If the same pre-state is reached in production, the user’s source balance is already gone, the destination never credits, and the bridged amount cannot be recovered through the ordinary user-callable protocol paths demonstrated by the proof of concept. That is a permanent lock outcome for the affected transfer amount.
The key point is call ordering.
The source-side OFT flow burns first through _debit(). The destination-side flow then reaches _credit(). In this implementation, _credit() performs the underlying credit logic and then decrements totalBridgedOut. On a fresh remote, that counter is still zero. Solidity arithmetic reverts, which unwinds the destination credit, but the source-side burn has already happened in the separate source transaction path that initiated the bridge.
That is why this is not a mere accounting mismatch. The user does not end up with a bad view of balance. The user ends up with no accessible balance on either side of the bridge, while the amount remains represented only by bookkeeping on the source instance.
The proof of concept uses OverlayerWrapMock only as a minimal harness to expose the already deployed internal paths _debit() and _credit(). It does not modify their logic. The mock simply forwards external calls to the real internal implementations. The bug is therefore in OverlayerWrapCore, not in the harness.
totalBridgedOut is local state, and hub-only logic prevents normal local seeding on a remoteFrom OverlayerWrapCore.sol:
uint256 public hubChainId;
uint256 public totalBridgedOut;
From OverlayerWrapCore.sol:
onlyHubChain(block.chainid)
From OverlayerWrapCore.sol:
onlyHubChain(block.chainid)
totalBridgedOut is ordinary contract storage on each instance. It is not shared across chains. At the same time, mint and redeem manager paths are restricted to hubChainId. That means a fresh non-hub remote has no normal local mint or redeem path that can initialize or increase its own totalBridgedOut before the first inbound OFT receive arrives.
From OverlayerWrapCore.sol:
totalBridgedOut += amountSentLD;
This update happens only on the sending instance. It records that a local burn has occurred and that effective supply must still be counted for backing purposes. It does not update any remote contract state.
From OverlayerWrapCore.sol:
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD;
This is the root cause. The destination instance decrements its own local totalBridgedOut, but a fresh remote starts at zero and had no previous outbound burn from that instance. The subtraction underflows and reverts. Since the revert happens inside _credit(), the destination mint does not persist. The first inbound transfer to a fresh non-hub remote therefore fails deterministically.
From AaveHandler.sol:
totalSupply() + totalBridgedOut()
This line shows why totalBridgedOut was introduced. The fix wanted backing calculations to treat burned-outgoing OFT supply as still economically outstanding. That goal is reasonable, but the destination-side decrement assumes the same counter is meaningful on the receiving instance. It is not. The counter is local, while the bridge event is cross-instance.
From OverlayerWrapMock.sol:
return _debit(from_, amountLD_, minAmountLD_, dstEid_);
return _credit(to_, amountLD_, srcEid_);
The harness does not add new behavior. It exposes the real internal bridge accounting methods so the regression can be isolated cleanly.
A realistic failure sequence is straightforward:
hubChainId != block.chainid.totalBridgedOut is still zero._debit() burns and increments the source-local totalBridgedOut._credit() reaches totalBridgedOut -= amountReceivedLD and reverts.This is exactly the state demonstrated by the passing proof of concept.
The attached overlayer.sh automates the full reproduction. It downloads the scoped repository, pins the audited commit, installs dependencies, writes the isolated harness and test files, compiles, and runs the proof of concept.
The passing run demonstrates all of the following:
_credit() reverting with panic11Download overlayer.zip, which contains the complete script that downloads the repository, installs the dependencies, and runs the proof of concept.
To reproduce the proof of concept:
unzip overlayer.zip
chmod +x overlayer.sh
bash overlayer.sh
This is the exact reproduction flow expected by this report.
Bridge
╔═══════════════════════════════╤═══════════════════════╤═══════════════════════╗
║ TOTALBRIDGEDOUT DESTINATION CREDIT UNDERFLOW (FRESH REMOTE FIRST INBOUND) ║
╠═══════════════════════════════╪═══════════════════════╪═══════════════════════╣
║ Metric │ Local Control │ Fresh Remote Probe ║
╠═══════════════════════════════╪═══════════════════════╪═══════════════════════╣
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║ [ PRE-STATE ] ║
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║ Bridge Amount │ 100000000000000000000 │ 100000000000000000000 ║
║ Hub Alice Before │ 100000000000000000000 │ 100000000000000000000 ║
║ Dst Alice Before │ N/A │ 0 ║
║ Hub Supply Before │ 100000000000000000000 │ 100000000000000000000 ║
║ Hub BridgedOut Before │ 0 │ 0 ║
║ Dst Supply Before │ N/A │ 0 ║
║ Dst BridgedOut Before │ N/A │ 0 ║
║ Dst Is Fresh? │ N/A │ YES ║
║ Dst Is Non-Hub? │ N/A │ YES ║
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║ [ EXECUTION ] ║
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║ Source Debit Success? │ YES │ YES ║
║ Credit Target │ hub │ dst ║
║ Credit Result │ ok │ revert panic11 ║
║ Retry Result │ N/A │ revert panic11 ║
║ Hub Redeem Path │ YES │ NO ║
║ Dst Redeem Path │ N/A │ NO ║
║ Dst Local Mint Path │ N/A │ NO ║
║ Dst Local Debit Path │ N/A │ NO ║
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║ [ POST-STATE ] ║
╟───────────────────────────────┼───────────────────────┼───────────────────────╢
║ Hub Alice After │ 100000000000000000000 │ 0 ║
║ Dst Alice After │ N/A │ 0 ║
║ Hub Supply After │ 100000000000000000000 │ 0 ║
║ Hub BridgedOut After │ 0 │ 100000000000000000000 ║
║ Dst Supply After │ N/A │ 0 ║
║ Dst BridgedOut After │ N/A │ 0 ║
║ Accessible User Balance │ 100000000000000000000 │ 0 ║
║ Bookkeeping-Only Amount │ 0 │ 100000000000000000000 ║
║ Stranded Amount │ 0 │ 100000000000000000000 ║
║ Locked Share of Deposit (bps) │ 0 │ 10000 ║
║ In-Logic Recovery Possible? │ YES │ NO ║
╚═══════════════════════════════╧═══════════════════════╧═══════════════════════╝
╔═══════════════════╤═══════════════════════╤════════════════════════════════════════════════╗
║ DELTA ANALYSIS ║
╠═══════════════════╪═══════════════════════╪════════════════════════════════════════════════╣
║ Metric │ Delta │ Meaning ║
╠═══════════════════╪═══════════════════════╪════════════════════════════════════════════════╣
║ accessibleGap │ 100000000000000000000 │ user-accessible balance lost in probe ║
║ strandedGap │ 100000000000000000000 │ burn-not-credited amount ║
║ bookGap │ 100000000000000000000 │ value stranded only in bookkeeping ║
║ depositLockGapBps │ 10000 │ share of the affected deposit locked ║
║ dstCreditGap │ 1 │ first inbound works in control, fails in probe ║
║ retryGap │ 1 │ retry remains impossible in probe ║
╚═══════════════════╧═══════════════════════╧════════════════════════════════════════════════╝
-> Source burn applied
-> Fresh destination first inbound reverts on arithmetic underflow
-> Repeated retries remain impossible
-> User recovery paths fail under normal protocol logic
-> User balance stays zero on both ends
-> Bridged amount remains stranded in bookkeeping only
-> Locked share of affected deposit = 10000 bps (100%)
✔ proves the regression and path-B user-level no-recovery state (2919ms)
1 passing (3s)
The fix should ensure that a destination instance never decrements a local counter that was not created by a local outbound debit.
A robust fix can follow one of these directions:
totalBridgedOut only on the source or hub bookkeeping domain, and never decrement it on the destination instance.The safest long-term design is the first one. The variable was introduced to preserve effective supply for backing calculations after source-side burns. That purpose is source-local and should remain source-local.
The DualDefense program pays only Critical issues and defines in-scope Critical impact as loss of end-user funds or permanent lock of end-user funds. It also requires a working PoC and a clear mitigation. This report satisfies those conditions on the scoped commit. This issue also matches the program’s high-likelihood model:
The attached output shows a 100 percent lock of the affected deposit, with Accessible User Balance = 0, Stranded Amount = 100000000000000000000, and Locked Share of Deposit (bps) = 10000.
This finding relies only on the explicitly scoped DualDefense commit and the contracts listed in scope by the program, especially OverlayerWrapCore.sol and AaveHandler.sol. It does not depend on imported-contract bugs, front-running, style issues, or privileged abuse.
The bug is in the protocol’s own cross-chain accounting logic inside the scoped files, and the proof of concept demonstrates the failure directly on that code path.