How I Found My First Real DeFi Bug: The LP Fee Skip

The finding that I think will actually pay out — the one I'm most confident in after having many others rejected — started with a specific question about fee accounting, not a pattern search.

Here's how it happened, and what it taught me about the difference between auditing as a process and auditing as a craft.

The protocol: Doppler

Doppler is an automated liquidity provisioning protocol — it creates and manages LP positions on Uniswap V4 with a single-sided liquidity model. You deposit one token, the protocol creates a price range, and fees accumulate as the price moves through the range.

With 45 findings in the program when I started, it was on the edge of "competitive" but not yet exhausted. The interesting protocols tend to have novel mechanisms that create non-obvious accounting interactions. Doppler's single-sided LP model meant the fee accounting was more complex than a standard two-sided position.

The question I started with

Not "what access control is missing?" or "where are there unchecked returns?" — those searches had already been done by 45 other researchers.

The question was: When does the LP position change state in a way that affects fee entitlement, and is the fee accounting updated before or after that state change?

This is a specific, mechanically answerable question about the protocol's accounting model. Fee accounting bugs in AMMs tend to follow a specific pattern: an action that changes position size or price range happens without first checkpointing the accrued fees. The fees that accrued up to that point get attributed to the wrong state.

Tracing the fee path

I read the liquidity migration path — the code executed when the protocol migrates an LP position from the initial price range to a new range as the price moves. This is the highest-risk code in a single-sided LP protocol because it's the moment when position parameters change.

The migration function looked like this (simplified):

function migrate(uint256 positionId) external {
    Position storage pos = positions[positionId];

    // Calculate new range based on current price
    (uint160 newSqrtLower, uint160 newSqrtUpper) = _calculateNewRange(pos);

    // Remove liquidity from old range
    _removeLiquidity(pos.sqrtLower, pos.sqrtUpper, pos.liquidity);

    // Update position state to new range
    pos.sqrtLower = newSqrtLower;
    pos.sqrtUpper = newSqrtUpper;

    // Add liquidity to new range
    _addLiquidity(pos.sqrtLower, pos.sqrtUpper, pos.liquidity);

    // Collect fees (happens at end)
    _updateFeeCheckpoint(positionId);
}

The fee checkpoint update happened at the end, after the position parameters had already changed. When _updateFeeCheckpoint() ran, it calculated fees based on the new range parameters — not the old range parameters that were active when the fees accrued.

For certain price movements (where the new range was narrower than the old range), this caused the fee calculation to undercount. Fees that had accumulated in the outer portion of the old range got zero-valued in the checkpoint because that portion was now outside the new range.

Verifying it wasn't intentional

Before writing this up, I went through the design intent check. This is the step where I've been burned most often — finding what looks like a bug that turns out to be a documented design choice.

I checked: the existing audit reports (none mentioned this fee migration path), the protocol documentation (described migration as preserving LP entitlements — implying fee continuity was intended), and the similar patterns in the codebase (the single-range fee path did update checkpoints before migration, suggesting the multi-range case was an oversight rather than a deliberate optimization).

The documentation's language about "preserving LP entitlements" was the key signal. If the team had intended to forfeit pre-migration fees, they would either have documented it as a known tradeoff or noted it as a limitation. The phrasing suggested they expected fee continuity.

The checklist moment: Every valid finding has an expected behavior and an actual behavior that differ. The expected behavior should be derivable from the documentation or the protocol's stated invariants — not from what "seems right." If the documentation doesn't say fees should be preserved, I can't claim violating that is a bug.

The PoC

The PoC was a call sequence:

  1. Create a position at price range [A, B]
  2. Simulate price movement to mid-range, accruing fees in the outer region
  3. Trigger migration — protocol moves position to range [B, C]
  4. Collect fees — compare received amount to expected amount based on accrual period

The delta was the skipped fees. Not large in absolute terms for a single position — a few basis points. But for a protocol managing many positions over time, the accumulated effect on LP returns was material.

What makes this a valid finding vs. the ones that got rejected

Looking back at the findings that got rejected alongside this one, the contrast is clear. The rejected findings were all capability findings: admin can do X, function Y is callable by anyone, contract Z has no reentrancy guard. They described capabilities without tracing economic impact.

The LP fee skip describes a consequence: an LP position holder receives less than their documented entitlement because of an accounting checkpoint ordering issue. There's a real party (the LP), a real loss (the skipped fees), and a root cause (the ordering of the checkpoint update relative to the migration state change).

That's the anatomy of a valid DeFi bug. I didn't fully understand that structure until I'd been through enough rejections to see what was missing in each one. The LP fee skip was the first finding I built from that understanding rather than stumbled into it.