<!-- canonical: https://0xsimao.com/findings/bmx-unconditional-range-streamed-bmx -->

# Unconditional lastUpdated advance in RangePool.sync leads to loss of streamed BMX when pool liquidity == 0

Medium · Sherlock · Perpetuals DEX · 2nd September 2025

Finding M-2 of the BMX competition.

- Protocol: https://audits.sherlock.xyz/contests/1154
- Source: https://github.com/sherlock-audit/2025-09-bmx-deli-swap-judging/issues/53

---

### Summary

A logic ordering issue in RangePool::sync causes the pool's lastUpdated timestamp to advance even when liquidity == 0. If the gauge consumes its N+2 day-bucket during a period with zero active liquidity, the streamed BMX is never credited to the pool's rewards-per-liquidity accumulator and therefore cannot be claimed later, even after liquidity returns. The tokens remain in the gauge contract balance but are unreachable by LP positions.



### Root Cause

https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/libraries/RangePool.sol#L282
https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/libraries/RangePool.sol#L287
```javascript
    /// @notice High-level helper used by gauges: initialise (if needed), accumulate rewards and adjust to new tick.
    /// @param self        Pool state storage pointer.
    /// @param perTokenAmounts  Token amounts accrued over the window since last update (0 allowed).
    /// @param tickSpacing Pool tick spacing (passed to adjustToTick).
    /// @param activeTick  Current active tick from PoolManager.slot0.
    function sync(
        State storage self,
        address[] memory tokens,
        uint256[] memory perTokenAmounts,
        int24 tickSpacing,
        int24 activeTick
    ) internal {
        // Bootstrap state on first touch so accumulate sees dt = 0
        if (self.lastUpdated == 0) {
            self.initialize(activeTick);
            // no need to accumulate or adjust because lastUpdated = now and tick == activeTick
            return;
        }
        // 1. Update lastUpdated and credit per-token amounts
@>>        self.lastUpdated = uint64(block.timestamp);

        if (self.liquidity > 0) {
            uint256 len = tokens.length;
            for (uint256 i; i < len; ++i) {
  @>>              _accumulateToken(self, tokens[i], perTokenAmounts[i]);
            }
        }
        // 2. If price moved out of current range adjust liquidity & tick, flipping per-token outside
        if (activeTick != self.tick) {
            self.adjustToTick(tickSpacing, activeTick, tokens);
        }
    }
}
```
RangePool::sync sets self.lastUpdated = uint64(block.timestamp) before applying per-token accumulation. It only executes `_accumulateToken` when self.liquidity > 0.

When self.liquidity == 0, lastUpdated advances but accumulation is skipped so, `DailyEpochGauge::_syncPoolStateCore's` computed `_amountOverWindow` is effectively consumed and lost for distribution.

```javascript
    function _syncPoolStateCore(PoolId pid, int24 activeTick, int24 tickSpacing) internal {
        RangePool.State storage pool = poolRewards[pid];
        uint256 t0 = pool.lastUpdated;
        uint256 t1 = block.timestamp;
        address[] memory toks = new address[](1);
        toks[0] = address(BMX);
        uint256[] memory amts = new uint256[](1);
        if (t1 > t0) {
 @>>           amts[0] = _amountOverWindow(pid, t0, t1);
        } else {
            amts[0] = 0;
        }
 @>>      pool.sync(toks, amts, tickSpacing, activeTick);
    }
```




### Internal Pre-conditions

- DailyEpochGauge has a non‑zero day bucket for a pool (via DailyEpochGauge::addRewards called by FeeProcessor).
- The pool's RangePool.State.lastUpdated is older than the target streaming window.
- RangePool.State.liquidity == 0 at the time DailyEpochGauge syncs the pool (no active in-range liquidity).
- DailyEpochGauge::pokePool (or any sync path) is invoked, causing RangePool::sync to run.


### External Pre-conditions

- A position that previously provided liquidity was unsubscribed (via PositionManagerAdapter::notifyUnsubscribeWithContext), resulting in the pool having zero active liquidity.
- The scheduled streaming day arrives (N+2), and an external actor or hook triggers a pool sync (e.g., DeliHook calls pokePool on swaps).
- The fee flow has previously transferred BMX into a gauge balance (via FeeProcessor → DAILY_GAUGE.addRewards), so tokens exist but are unallocated.


### Attack Path

No response

### Impact

- Funds meant for distribution (BMX) are retained in the gauge balance but never credited to any pool accumulator for claim by LP positions.
- LPs present after liquidity returns cannot claim past streaming amounts; protocol revenue intended for LPs can be effectively sidelined.
- Denial of reward for LPs; accounting mismatch between gauge token balance and claimable amounts.
- This is not an immediate theft but a correctness/availability failure with lasting distribution impact.

### PoC

1. Place the test in the `GaugeStream.t.sol` test file.
2. Run the test using this ` forge test --mt testLostStreamingWhenZeroLiquidity -vvvv`
```JavaScript
 /// @notice Demonstrates that if a pool has zero liquidity when the gauge
    ///         sync runs, the per-day bucket amounts are not applied to the
    ///         pool accumulator (they become effectively unallocated to any
    ///         position). This reproduces the "lost streaming when
    ///         liquidity==0" behaviour: tokens remain in the gauge balance
    ///         but positions can't claim them.
    function testLostStreamingWhenZeroLiquidity() public {
        // Precondition: setUp has funded the gauge and added a day-bucket.
        uint256 bucket = 1000 ether;

        // Ensure initial gauge balance contains the bucket
        assertGe(bmx.balanceOf(address(gauge)), bucket);

        // 1) Remove the only tracked liquidity so pool active liquidity == 0
        positionManager.unsubscribe(wideTokenId);

        // Confirm pool has zero active liquidity now via gauge view
        (,, uint128 activeLiq) = gauge.getPoolData(pid);
        assertEq(activeLiq, 0, "pool liquidity should be zero");

        // 2) Advance to the streaming day (N+2) so the gauge will try to credit
        uint256 dayEnd = TimeLibrary.dayNext(block.timestamp);
        vm.warp(dayEnd + 1 days);

        // Sanity: streamRate should be non-zero because the day-bucket exists
        assertGt(gauge.streamRate(pid), 0, "streamRate should be active for the day");

        // 3) Trigger pool sync while liquidity == 0. Because RangePool.sync
        //    updates lastUpdated before accumulating, the amounts for the
        //    elapsed window are not applied when liquidity==0.
        vm.prank(address(hook));
        gauge.pokePool(key);

        // 4) Re-add liquidity (mint and subscribe a fresh position after the
        //    missed window). This new position cannot recover the previously
        //    scheduled streaming for the earlier window.
        uint256 tokenIdNew;
        (tokenIdNew,) = EasyPosm.mint(
            positionManager,
            key,
            -60000,
            60000,
            1e21,
            type(uint256).max,
            type(uint256).max,
            address(this),
            block.timestamp + 1 hours,
            bytes("")
        );
        positionManager.subscribe(tokenIdNew, address(adapter), bytes(""));

        // 5) Sync now that liquidity > 0. Only amounts since the previous
        //    lastUpdated will be applied — the bucket that streamed during
        //    The earlier zero-liquidity window is not credited to positions.
        vm.prank(address(hook));
        gauge.pokePool(key);

        // 6) Claim for owner: should receive zero (or very small) because the
        // Earlier, the streaming window was missed when liquidity was 0.
        PoolId[] memory arr = new PoolId[](1);
        arr[0] = pid;
        uint256 balBefore = bmx.balanceOf(address(this));
        gauge.claimAllForOwner(arr, address(this));
        uint256 claimed = bmx.balanceOf(address(this)) - balBefore;

        // The test demonstrates the bug: the bucket is still sitting in the
        // gauge contract balance but positions received nothing for the
        // streaming window that occurred while liquidity was zero.
        assertEq(claimed, 0, "expected no rewards allocated to position");
        assertGe(bmx.balanceOf(address(gauge)), bucket, "gauge should still hold the bucket funds");
    }
```


### Mitigation

Do not advance lastUpdated and therefore do not consume the elapsed-window amounts. when `self.liquidity == 0`. Return early so the pending per-day amounts remain available and are applied once liquidity appears.

Apply changes in the `RangePool.sol::sync`
```diff
@@
-        // 1. Update lastUpdated and credit per-token amounts
-        self.lastUpdated = uint64(block.timestamp);
-
-        if (self.liquidity > 0) {
-            uint256 len = tokens.length;
-            for (uint256 i; i < len; ++i) {
-                _accumulateToken(self, tokens[i], perTokenAmounts[i]);
-            }
-        }
+        // If no active liquidity, do not advance lastUpdated or consume amounts.
+        // Preserve the time window so amounts are processed later when liquidity exists.
+        if (self.liquidity == 0) {
+            // Adjust price movement if needed, but keep lastUpdated unchanged.
+            if (activeTick != self.tick) {
+                self.adjustToTick(tickSpacing, activeTick, tokens);
+            }
+            return;
+        }
+
+        // 1. Update lastUpdated and credit per-token amounts
+        self.lastUpdated = uint64(block.timestamp);
+
+        uint256 len = tokens.length;
+        for (uint256 i; i < len; ++i) {
+            _accumulateToken(self, tokens[i], perTokenAmounts[i]);
+        }
```

---

Related findings:

- [Potential ETH Loss Due to transfer Usage in Requestor Contract on `zkSync`](https://0xsimao.com/findings/pooltogether-the-prize-layer-for-defi-eth-transfer-requestor-sync): PoolTogether Prize Layer
- [Malicious user can call `borrowing::calculateCumulativeRate()` any number of times to inflate debt rate as `lastEventTime` is not updated](https://0xsimao.com/findings/autonomint-calculate-times-inflate-debt): Autonomint
- [Liquidator will leave a pool with unassigned earnings on `Market::clearBadDebt()` free to claim for anyone when the repaid maturity is not the last](https://0xsimao.com/findings/exactly-protocol-update-staking-contract-ii-liquidator-clear-debt-repaid): Exactly Protocol Update - Staking Contract
- [`Vault::withdraw()` withdraws too much liquidity leading to idle capital and loss of fees](https://0xsimao.com/findings/yieldoor-withdraw-withdraws-capital-fees): Yieldoor
