Unconditional lastUpdated advance in RangePool.sync leads to loss of streamed BMX when pool liquidity == 0
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
/// @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.
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.
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
- Place the test in the
GaugeStream.t.soltest file. - Run the test using this
forge test --mt testLostStreamingWhenZeroLiquidity -vvvv
/// @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
@@
- // 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]);
+ }