OverlayerWrapCore tracks outbound bridged supply in totalBridgedOut and assumes that this value can be incremented on the source chain during _debit() and decremented on the destination chain during _credit().
This assumption is incorrect because totalBridgedOut is ordinary contract storage and therefore chain-local state. A debit on the source chain updates the source chain's local totalBridgedOut, but the destination chain maintains its own independent storage slot, which remains unchanged.
As a result, when a user bridges OVA from the hub chain to a satellite chain:
_debit().totalBridgedOut._credit().totalBridgedOut -= amountReceivedLD against its own local value.0, so the subtraction underflows and the receive-side credit reverts.Because the burn already happened on the source chain, the user can end up with:
This is a permanent lock / loss-of-access condition for bridged user funds under the current design.
contracts/overlayer/OverlayerWrapCore.sol
uint256 public totalBridgedOut;
This matters because non-hub chains do not have an independent local path to build matching accounting state via mint/redeem.
contracts/overlayer/OverlayerWrapCore.sol
modifier onlyHubChain(uint256 chainId_) {
if (chainId_ != hubChainId) {
revert OverlayerWrapCoreNotHubChainId();
}
_;
}
contracts/overlayer/OverlayerWrapCore.sol
function _managerMint(...) internal ... onlyHubChain(block.chainid) { ... }
function _managerRedeem(...) internal ... onlyHubChain(block.chainid) returns (...) { ... }
contracts/overlayer/OverlayerWrapCore.sol
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;
}
contracts/overlayer/OverlayerWrapCore.sol
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD;
}
This issue satisfies the program's stated criticality bar:
OverlayerWrapMock instance representing the hub chain.OverlayerWrapMock instance representing a satellite chain.testDebit() on the hub instance for a bridge amount.
totalBridgedOut.totalBridgedOut == 0.testCredit() on the satellite instance for the same bridge amount.0 - amountReceivedLD.contracts/overlayer/OverlayerWrapCore.solcontracts/test/OverlayerWrapMock.soltest/OverlayerWrap.tsThe existing bridge-accounting tests use one single contract instance and then call:
testDebit(...)testCredit(...)on that same instance.
That setup is not representative of a real cross-chain flow because it reuses one storage context for both operations. In production:
_debit() runs on the source chain contract instance, and_credit() runs on a different destination chain contract instance.The bug appears specifically because those two instances do not share storage.
contracts/test/OverlayerWrapMock.sol and confirm that the mock exposes both _debit() and _credit().contracts/overlayer/OverlayerWrapCore.sol and confirm:
totalBridgedOut is a state variable,_debit() increments it, and_credit() decrements it.test/OverlayerWrap.ts and confirm the current tests perform debit and credit on a single deployed mock.npx hardhat test test/OverlayerWrap_totalBridgedOut_desync.ts
0x11 (arithmetic underflow/overflow),After the failed receive-side credit:
hub.totalBridgedOut() equals the amount burned on the hub instance,satellite.totalBridgedOut() remains 0,hub.balanceOf(user) is reduced,satellite.balanceOf(user) remains 0.This is sufficient to validate that the system can burn tokens on the source side while failing to mint them on the destination side.