OverlayerWrapCore tracks bridged supply with a local totalBridgedOut counter:
uint256 public totalBridgedOut;
On the source chain, _debit() burns the sender's OFT balance and increases the local counter:
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;
}
On the destination chain, _credit() mints the bridged amount and then unconditionally subtracts it from the destination deployment's own local totalBridgedOut counter:
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD;
}
That accounting assumes the same totalBridgedOut storage is shared between the source and destination sides of the bridge. It is not.
Each OverlayerWrap deployment stores its own independent totalBridgedOut value. So a normal bridge from chain A to a fresh deployment on chain B behaves like this:
A, _debit() burns the user's tokens and increments A.totalBridgedOutB, _credit() mints the incoming amountB.totalBridgedOut -= amountReceivedLDB.totalBridgedOut == 0, the subtraction underflows and reverts with panic 0x11So the source-side debit succeeds, but the destination-side credit cannot complete on a fresh destination deployment.
The contract is mixing two different accounting domains inside one local counter:
That is unsafe because these operations happen on different deployments with different storage.
A local counter can safely represent outbound burns on the current deployment. It cannot safely be decremented on another deployment that never observed the corresponding increment.
The result is that the protocol's normal OFT receive path can revert on the first inbound transfer to a fresh destination chain, even though the sender's tokens were already debited on the source chain.
This is not a harmless bookkeeping issue. It breaks cross-chain delivery itself.
Bridging is a core user flow. Because the bug lets the source-side debit complete while the destination-side credit reverts:
This is exactly the type of high-impact failure that can indefinitely lock user funds without protocol intervention.
Any first inbound bridge to a fresh destination deployment reverts regardless of amount, so the bug affects 100% of the user's bridged transfer and therefore exceeds the 1% user-deposit threshold by construction.
_debit() calls the OFT burn logic and then increments the source deployment's local totalBridgedOut:
(amountSentLD, amountReceivedLD) = super._debit(
from_,
amountLD_,
minAmountLD_,
dstEid_
);
totalBridgedOut += amountSentLD;
So after a bridge-out on the source chain, the sender's balance is reduced and only the source deployment records the bridged amount.
_credit() mints the bridged amount first and then executes:
totalBridgedOut -= amountReceivedLD;
There is no check that the destination deployment has ever recorded a matching increment. If the destination deployment is fresh, this becomes 0 - amountReceivedLD, which underflows and reverts the entire transaction.
A newly deployed destination instance has:
expect(await satellite.totalBridgedOut()).to.equal(0);
So the first inbound credit attempts to execute 0 - amountReceivedLD.
After a successful source-side testDebit():
await hub.testDebit(alice.address, bridgeAmount, bridgeAmount, 0);
calling the destination-side testCredit() reverts:
await satellite.testCredit(alice.address, bridgeAmount, 0);
The PoC confirms that the revert is panic 0x11, i.e. arithmetic underflow.
That is the core bug.
totalBridgedOut is needed for hub-side effective supply accounting, so the fix should not simply remove destination-side subtraction everywhere.
The correct approach is to make totalBridgedOut a hub-only accounting variable:
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_
);
if (block.chainid == hubChainId) {
totalBridgedOut += amountSentLD;
}
}
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
if (block.chainid == hubChainId) {
totalBridgedOut -= amountReceivedLD;
}
}
This preserves the intended hub-side backing invariant while preventing fresh destination deployments from underflowing on their first inbound credit.