Description:
OverlayerWrapCore overrides both _debit and _credit to maintain a local totalBridgedOut counter. On an outbound bridge transfer, _debit increments totalBridgedOut on the source chain. On an inbound bridge delivery, _credit unconditionally decrements totalBridgedOut on the destination chain.
On any spoke deployment, totalBridgedOut starts at zero and can never be seeded through normal minting because _managerMint enforces onlyHubChain(block.chainid). This creates a permanent mismatch: the source (hub) increments its own counter, while the destination (spoke) attempts to decrement a counter that has never been touched.
Affected code — OverlayerWrapCore.sol L510–544 · OverlayerWrapCore.sol L353–359:
https://github.com/Ovafi/contracts/blob/main/contracts/overlayer/OverlayerWrapCore.sol#L510-L544
function _debit(...) internal virtual override returns (...) {
(amountSentLD, amountReceivedLD) = super._debit(from_, amountLD_, minAmountLD_, dstEid_);
totalBridgedOut += amountSentLD; // increments on source chain
}
function _credit(...) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
totalBridgedOut -= amountReceivedLD; // decrements on destination chain — UNDERFLOWS on spoke
}
// https://github.com/Ovafi/contracts/blob/main/contracts/overlayer/OverlayerWrapCore.sol#L353-L359
function _managerMint(OverlayerWrapCoreTypes.Order calldata order_)
internal
belowMaxMintPerBlock(order_.overlayerWrapAmount)
onlyHubChain(block.chainid) // minting restricted to hub — spoke totalBridgedOut stays 0
Execution flow:
_debit() burns the user's source-chain tokens and increments totalBridgedOut on the hub._credit() from _lzReceive().super._credit() mints destination-side tokens — succeeds.totalBridgedOut -= amountReceivedLD executes against totalBridgedOut == 0 on the spoke → arithmetic underflow, full revert.Impact:
This is a direct, deterministic permanent fund loss for any user who bridges OverlayerWrap from the hub to a spoke chain.
totalBridgedOut == 0 on the spoke) is the default state of every spoke deployment from genesis.Severity: Critical — direct, permanent loss of user funds on a permissionless, primary protocol path.
Proof of Concept:
Test file:
it("CRITICAL: _credit on spoke reverts with arithmetic underflow — user tokens permanently lost", async function () {
const { spokeOW, alice } = await loadFixture(deployFixture);
const bridgeAmount = ethers.parseUnits("100", 18);
const srcEid = 1;
// Precondition: spoke totalBridgedOut starts at zero, can never be seeded
expect(await spokeOW.totalBridgedOut()).to.equal(0n);
// Simulates LayerZero delivering the bridge message to the spoke
await expect(
spokeOW.testCredit(alice.address, bridgeAmount, srcEid)
).to.be.reverted; // arithmetic underflow → permanent fund lock
});
it("hub _credit succeeds only after matching _debit (correct round-trip on hub)", async function () {
const { hubOW, collateral, alice, mintAmount } = await loadFixture(deployFixture);
await collateral.connect(alice).approve(await hubOW.getAddress(), mintAmount);
const owAmount = ethers.parseUnits("100", 18);
await hubOW.connect(alice).mint({
benefactor: alice.address,
beneficiary: alice.address,
collateral: await collateral.getAddress(),
collateralAmount: mintAmount,
overlayerWrapAmount: owAmount,
});
await hubOW.connect(alice).testDebit(alice.address, owAmount, 0, 2);
await expect(
hubOW.testCredit(alice.address, owAmount, 2)
).to.not.be.reverted; // hub has matching _debit — succeeds
});
Run the test:
cd contracts
CI=1 npx hardhat test --config hardhat.config.poc.ts test/PoC_CrossChainCreditUnderflow.ts
Captured output:
PoC: Critical — _credit underflow permanently locks bridged tokens
✔ spoke chain starts with totalBridgedOut == 0 (4592ms)
Hub totalBridgedOut after _debit: 100000000000000000000
✔ hub _debit increments totalBridgedOut (55ms)
=== PoC: Critical Cross-Chain Bug ===
Calling spokeOW.testCredit(alice, 100e18, 1) — simulating LZ message delivery
This executes: totalBridgedOut (== 0) -= 100e18 → arithmetic underflow
✅ Confirmed: _credit reverts. Tokens burned on hub are permanently lost.
✔ CRITICAL: _credit on spoke reverts with arithmetic underflow — user tokens permanently lost (40ms)
Hub round-trip (_debit → _credit) succeeds. Spoke never gets matching _debit → always fails.
✔ hub _credit succeeds only after matching _debit (correct round-trip on hub) (82ms)
4 passing (5s)
EXIT_CODE=0
What the output proves:
| Assertion | Log / Result | Meaning |
|---|---|---|
expect(totalBridgedOut).to.equal(0n) |
✔ PASS | Spoke starts with zero; the vulnerable precondition exists at genesis |
Hub totalBridgedOut after _debit: 100e18 |
Logged | Outbound accounting is hub-local; spoke counter is never touched |
spokeOW.testCredit(...).to.be.reverted |
✔ PASS | Inbound credit deterministically underflows on spoke |
hubOW.testCredit(...).to.not.be.reverted |
✔ PASS | _credit is not broken universally — only the spoke-side decrement is invalid |
Recommended Mitigation:
Restrict the totalBridgedOut decrement to the chain where the corresponding outbound burn was recorded (i.e., the hub). The safest fix is a chain-identity guard inside _credit:
function _credit(
address to_,
uint256 amountLD_,
uint32 srcEid_
) internal virtual override returns (uint256 amountReceivedLD) {
amountReceivedLD = super._credit(to_, amountLD_, srcEid_);
// Only decrement on the hub, where _debit previously incremented
if (_isHubChain(block.chainid)) {
totalBridgedOut -= amountReceivedLD;
}
}
If the intent of totalBridgedOut is to track effective global supply for backing calculations, the invariant must be recomputed with chain-local semantics (e.g., per-chain counters, or a cross-chain message to update the hub's counter) rather than assuming a globally synchronised value across independent deployments.