OverlayerWrapCore tracks bridged-out supply with totalBridgedOut, but _credit() decrements that counter on every destination chain.
Vulnerable code:
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;
}
Source: contracts/contracts/overlayer/OverlayerWrapCore.sol:510-543
This logic is only correct on the chain that previously executed _debit(). A fresh destination deployment starts with totalBridgedOut == 0. When the first inbound LayerZero message arrives, _credit() mints first and then subtracts amountReceivedLD from zero, which triggers Panic(0x11) and reverts the whole receive path.
That means the real bridge path is:
_debit()totalBridgedOut_credit()totalBridgedOutThe result is not a harmless accounting mismatch. The source-side burn already happened, while the destination-side credit cannot complete. From the bridger's perspective, this is a direct end-user funds lock:
totalBridgedOuttotalBridgedOut is treated as if it were a global system-wide net bridge counter, but it is actually stored per deployment.
On a fresh destination chain there was no prior local _debit(), so this state transition is invalid:
totalBridgedOut = 0;
totalBridgedOut -= amountReceivedLD;
The decrement must only happen on the hub chain, or only when there is verified prior local bridged-out balance to consume.
Consider the following scenario:
OverlayerWrap.OverlayerWrap from the hub deployment to a fresh destination deployment._debit(), burns 5 tokens from Alice, and records totalBridgedOut += 5e18._credit(), but its local totalBridgedOut is still zero, so 0 - 5e18 underflows and the receive reverts.OverlayerWrap on the source chain and 0 OverlayerWrap on the destination chain. The missing 5 OverlayerWrap are no longer user-spendable on either chain.In this scenario, an attacker doesn't need additional privileges; any legitimate bridging user will trigger this issue. The victim is the user who initiated the bridging. The victim will lose the portion of their disposable OverlayerWrap that was already burned on the source chain, and the target chain will not receive the corresponding tokens. More importantly, this is not a problem that the user can resolve by retrying or redeeming, because there is no publicly known user path to fix the target chain's local state totalBridgedOut. Therefore, this aligns with the Permanent lock of end-user funds.
Why this is in-scope:
_debit().Any user can trigger a source-chain burn that cannot be credited on a fresh destination deployment, permanently locking the bridged OverlayerWrap amount when the destination chain has no prior local totalBridgedOut.
OverlayerWrap into a fresh destination deploymentOverlayerWrap balance being bridgedtotalBridgedOut == 0Only decrement totalBridgedOut on the hub chain, or explicitly guard against destination-side underflow and use a separate per-chain accounting model.
One minimal fix is:
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) {
+ if (amountReceivedLD > totalBridgedOut) revert OverlayerWrapCoreOverflow();
+ totalBridgedOut -= amountReceivedLD;
+ }
}
If the protocol needs bridge accounting on non-hub chains too, it should maintain a different invariant there instead of reusing the hub-side backing counter.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {OverlayerWrapMock} from "../contracts/contracts/test/OverlayerWrapMock.sol";
import {IOverlayerWrapDefs} from "../contracts/contracts/overlayer/interfaces/IOverlayerWrapDefs.sol";
import {OverlayerWrapCoreTypes} from "../contracts/contracts/overlayer/types/OverlayerWrapCoreTypes.sol";
interface VmPoc1 {
function prank(address sender) external;
function expectRevert(bytes calldata revertData) external;
}
contract Poc1MockERC20Decimals is ERC20 {
uint8 private immutable tokenDecimals;
constructor(
string memory name_,
string memory symbol_,
uint8 decimals_
) ERC20(name_, symbol_) {
tokenDecimals = decimals_;
}
function decimals() public view override returns (uint8) {
return tokenDecimals;
}
function mint(address to_, uint256 amount_) external {
_mint(to_, amount_);
}
}
contract Poc1MockEndpoint {
function setDelegate(address) external {}
}
contract Poc1Test {
VmPoc1 private constant vm =
VmPoc1(address(uint160(uint256(keccak256("hevm cheat code")))));
address private constant ADMIN = address(0xA11CE);
address private constant ALICE = address(0xB0B);
function test_freshDestinationCreditRevertsAfterSourceDebit() external {
Poc1MockEndpoint endpoint = new Poc1MockEndpoint();
Poc1MockERC20Decimals collateral = new Poc1MockERC20Decimals(
"Collateral",
"COL",
6
);
Poc1MockERC20Decimals aCollateral = new Poc1MockERC20Decimals(
"Aave Collateral",
"aCOL",
6
);
OverlayerWrapMock hub = _deployOverlayerWrap(
address(endpoint),
block.chainid,
collateral,
aCollateral
);
OverlayerWrapMock remote = _deployOverlayerWrap(
address(endpoint),
block.chainid + 1,
collateral,
aCollateral
);
collateral.mint(ALICE, 10e6);
vm.prank(ALICE);
collateral.approve(address(hub), type(uint256).max);
OverlayerWrapCoreTypes.Order memory mintOrder = OverlayerWrapCoreTypes
.Order({
benefactor: ALICE,
beneficiary: ALICE,
collateral: address(collateral),
collateralAmount: 10e6,
overlayerWrapAmount: 10 ether
});
vm.prank(ALICE);
hub.mint(mintOrder);
vm.prank(ALICE);
hub.testDebit(ALICE, 5 ether, 5 ether, 30101);
require(hub.balanceOf(ALICE) == 5 ether, "hub debit failed");
require(hub.totalBridgedOut() == 5 ether, "hub bridged accounting wrong");
require(remote.totalBridgedOut() == 0, "fresh remote should start at zero");
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
remote.testCredit(ALICE, 5 ether, 30101);
}
function _deployOverlayerWrap(
address endpoint_,
uint256 hubChainId_,
Poc1MockERC20Decimals collateral_,
Poc1MockERC20Decimals aCollateral_
) private returns (OverlayerWrapMock) {
IOverlayerWrapDefs.ConstructorParams memory params =
IOverlayerWrapDefs.ConstructorParams({
admin: ADMIN,
lzEndpoint: endpoint_,
name: "Overlayer Wrap",
symbol: "OW",
collateral: OverlayerWrapCoreTypes.StableCoin({
addr: address(collateral_),
decimals: collateral_.decimals()
}),
aCollateral: OverlayerWrapCoreTypes.StableCoin({
addr: address(aCollateral_),
decimals: aCollateral_.decimals()
}),
maxMintPerBlock: type(uint256).max,
maxRedeemPerBlock: type(uint256).max,
minValmaxRedeemPerBlock: 1,
hubChainId: hubChainId_
});
return new OverlayerWrapMock(params);
}
}
forge test --match-path audit-poc/poc1.t.sol -vv
Ran 1 test for audit-poc/poc1.t.sol:Poc1Test
[PASS] test_freshDestinationCreditRevertsAfterSourceDebit() (gas: 11749203)
Suite result: ok. 1 passed; 0 failed; 0 skipped