Critical
High
High
If the protocol is currently deployed on at least one non-hub chain, a user bridging OverlayerWrap from the hub to that spoke can have the source-side burn succeed while the destination-side mint always reverts from arithmetic underflow. Because OFT delivery will keep retrying the same failing destination logic, the bridged funds become permanently unclaimable unless the destination contract can be replaced, which this codebase does not indicate for OverlayerWrap.
contracts/overlayer/OverlayerWrapCore.solThe recently added totalBridgedOut accounting is meant to preserve the effective hub-chain supply for backing calculations. However, the implementation applies the accounting on all chains:
_debit() increments totalBridgedOut_credit() decrements totalBridgedOutThis is only correct on the hub chain, where outbound transfers should increase outstanding bridged supply and inbound returns should decrease it.
On a spoke chain, the first inbound transfer is a destination-side _credit() with totalBridgedOut == 0, so this line reverts:
totalBridgedOut -= amountReceivedLD;
That means a bridge from hub -> spoke can burn tokens on the source chain and then fail forever on the destination chain.
The vulnerability is a spoke-chain OFT receive freeze caused by hub-only accounting being applied on every chain.
In OverlayerWrapCore.sol, the new totalBridgedOut variable is updated in both LayerZero OFT hooks:
_debit(...) adds the bridged-out amount_credit(...) subtracts the bridged-in amountThat accounting only makes sense on the hub chain, where:
But the code does not restrict either update to the hub chain. As a result, a non-hub spoke chain will execute _credit() even though its local totalBridgedOut is still zero.
The failing line is:
totalBridgedOut -= amountReceivedLD;
Under Solidity 0.8 arithmetic checks, that subtraction reverts on the first inbound spoke transfer.
Clear reproduction steps:
_debit() succeeds and increments the hub chain’s totalBridgedOut._credit() to mint the received amount.totalBridgedOut -= amountReceivedLD while totalBridgedOut == 0.The result is a permanent user-funds lock path for bridged funds, assuming the OFT route is currently live on at least one non-hub deployment.
This is distinct from the March 2026 Hacken audit’s hub-side supply() DoS issue. That known issue concerned effective hub supply reductions after OFT transfers. This issue concerns spoke-side destination receives reverting due to the same accounting fix being applied globally instead of only on the hub.
I validated the issue with a minimal local PoC that models the exact totalBridgedOut behavior used by Overlayer’s _debit() and _credit() hooks.
PoC workspace:
/home/dinesh/hackenproof/overlayer-poc/foundry.toml/home/dinesh/hackenproof/overlayer-poc/src/OverlayerOFTCreditHarness.sol/home/dinesh/hackenproof/overlayer-poc/test/OverlayerOFTCredit.t.solValidation logic:
hub.debit(100e18) to model a successful source-chain bridge debit.spoke.credit(100e18) to model the first destination-chain bridge receive.100e18 from zero.hub.credit(100e18) after hub.debit(100e18) and confirm it succeeds, proving the accounting only works on a chain that previously debited.Exact command:
cd /home/dinesh/hackenproof/overlayer-poc
forge test --match-contract OverlayerOFTCreditTest -vvv
Observed output:
forge test --match-contract OverlayerOFTCreditTest -vvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 2 tests for test/OverlayerOFTCredit.t.sol:OverlayerOFTCreditTest
[PASS] test_firstInboundTransferToSpokeReverts() (gas: 35455)
[PASS] test_returnTransferToHubOnlyWorksIfHubPreviouslyDebited() (gas: 20060)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.46ms (652.32µs CPU time)
Ran 1 test suite in 27.90ms (1.46ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)
What this proves:
totalBridgedOutFull PoC code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice Minimal model of the exact OverlayerWrapCore totalBridgedOut accounting.
/// @dev The source chain calls debit before LayerZero delivery; the destination chain calls credit.
contract OverlayerOFTCreditHarness {
uint256 public totalBridgedOut;
function debit(uint256 amountLD) external returns (uint256 amountSentLD, uint256 amountReceivedLD) {
amountSentLD = amountLD;
amountReceivedLD = amountLD;
totalBridgedOut += amountSentLD;
}
function credit(uint256 amountLD) external returns (uint256 amountReceivedLD) {
amountReceivedLD = amountLD;
totalBridgedOut -= amountReceivedLD;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "../src/OverlayerOFTCreditHarness.sol";
contract OverlayerOFTCreditTest {
OverlayerOFTCreditHarness internal hub;
OverlayerOFTCreditHarness internal spoke;
function setUp() public {
hub = new OverlayerOFTCreditHarness();
spoke = new OverlayerOFTCreditHarness();
}
function test_firstInboundTransferToSpokeReverts() public {
hub.debit(100e18);
(bool ok,) = address(spoke).call(
abi.encodeWithSelector(OverlayerOFTCreditHarness.credit.selector, 100e18)
);
require(!ok, "spoke credit should revert from zero totalBridgedOut");
}
function test_returnTransferToHubOnlyWorksIfHubPreviouslyDebited() public {
hub.debit(100e18);
require(hub.totalBridgedOut() == 100e18, "hub bridged out should increase on debit");
hub.credit(100e18);
require(hub.totalBridgedOut() == 0, "hub credit should only clear prior bridged amount");
}
}
The code comments describe hub-specific accounting, but the implementation lacks any hub-chain guard:
Relevant code from the scoped commit:
uint256 public totalBridgedOut;_debit(...) does totalBridgedOut += amountSentLD;_credit(...) does totalBridgedOut -= amountReceivedLD;The comments explicitly say:
_debit() tracks tokens leaving the hub chain_credit() tracks tokens returning to the hub chainBut neither function checks block.chainid == hubChainId.
Cross-chain OFT transfers are expected to preserve user ownership across chains.
Instead, for a first inbound spoke transfer:
_credit()_credit() underflows on totalBridgedOutSo a normal user bridge can turn into a permanent lock of user funds.
Preconditions:
totalBridgedOut to cover the incoming amount, which is true for the first inbound transferSteps:
amount OverlayerWrap from the hub to a spoke._debit() succeeds and burns/transfers out the user's balance._credit()._credit() executes totalBridgedOut -= amountReceivedLD.totalBridgedOut == 0, the subtraction underflows and the receive reverts._debit/_credit implementation and standard OFT destination-credit semantics.Hub Chain OverLayer Supply Reduction After OFT Transfers Lead to supply() DoS, but that finding does not cover the new spoke-side underflow introduced by applying the fix globally.Restrict totalBridgedOut accounting to the hub chain only.
For example:
if (block.chainid == hubChainId) {
totalBridgedOut += amountSentLD;
}
and
if (block.chainid == hubChainId) {
totalBridgedOut -= amountReceivedLD;
}
That matches the code comments and the intended backing invariant.
Local PoC workspace:
/home/dinesh/hackenproof/overlayer-poc/foundry.toml/home/dinesh/hackenproof/overlayer-poc/src/OverlayerOFTCreditHarness.sol/home/dinesh/hackenproof/overlayer-poc/test/OverlayerOFTCredit.t.solCommand:
cd /home/dinesh/hackenproof/overlayer-poc
forge test --match-contract OverlayerOFTCreditTest -vvv
Observed output:
forge test --match-contract OverlayerOFTCreditTest -vvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 2 tests for test/OverlayerOFTCredit.t.sol:OverlayerOFTCreditTest
[PASS] test_firstInboundTransferToSpokeReverts() (gas: 35455)
[PASS] test_returnTransferToHubOnlyWorksIfHubPreviouslyDebited() (gas: 20060)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.46ms (652.32µs CPU time)
Ran 1 test suite in 27.90ms (1.46ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)
What the PoC proves:
credit() on a spoke reverts from zero totalBridgedOutdebit(), i.e. the intended hub-side invariantFull PoC code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice Minimal model of the exact OverlayerWrapCore totalBridgedOut accounting.
/// @dev The source chain calls debit before LayerZero delivery; the destination chain calls credit.
contract OverlayerOFTCreditHarness {
uint256 public totalBridgedOut;
function debit(uint256 amountLD) external returns (uint256 amountSentLD, uint256 amountReceivedLD) {
amountSentLD = amountLD;
amountReceivedLD = amountLD;
totalBridgedOut += amountSentLD;
}
function credit(uint256 amountLD) external returns (uint256 amountReceivedLD) {
amountReceivedLD = amountLD;
totalBridgedOut -= amountReceivedLD;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "../src/OverlayerOFTCreditHarness.sol";
contract OverlayerOFTCreditTest {
OverlayerOFTCreditHarness internal hub;
OverlayerOFTCreditHarness internal spoke;
function setUp() public {
hub = new OverlayerOFTCreditHarness();
spoke = new OverlayerOFTCreditHarness();
}
function test_firstInboundTransferToSpokeReverts() public {
hub.debit(100e18);
(bool ok,) = address(spoke).call(
abi.encodeWithSelector(OverlayerOFTCreditHarness.credit.selector, 100e18)
);
require(!ok, "spoke credit should revert from zero totalBridgedOut");
}
function test_returnTransferToHubOnlyWorksIfHubPreviouslyDebited() public {
hub.debit(100e18);
require(hub.totalBridgedOut() == 100e18, "hub bridged out should increase on debit");
hub.credit(100e18);
require(hub.totalBridgedOut() == 0, "hub credit should only clear prior bridged amount");
}
}