What fuzzing LpdFi taught me

I have been using ripfuzz to audit DeFi protocols. The fork cheatcode posts covered how the fuzzer works. This post is the first field report from using it: what the LpdFi fuzz campaign taught me about writing harnesses, and what to check in the next protocol of the same shape.

The protocol

LpdFi is a buy-and-earn protocol on BSC. You deposit Lpd through buy(uAmount) and the contract pays per-issue interest. claimInterest pays the accrued interest in USDC by burning the protocol’s own LP position (removeLp → router removeLiquidity), not from the deposited value.

Solidity
function buy(uint256 uAmount) external nonReentrant {
    // ...
    uint256 tokenAmount = (uAmount * 1e18) / token.price();
    IERC20(token).safeTransferFrom(msg.sender, address(this), tokenAmount);
    // interestTop: uAmount * INTEREST_TOP / BASE  (50% in the deployed config)
}

function claimInterest(uint256 id) external nonReentrant {
    // ...
    (, uint256 amountB) = removeLp(order.interestClaimable);
    uint256 a = (amountB * 99) / 100;
    IERC20(USDC_ADDRESS).safeTransfer(msg.sender, a);
}

Two details matter. uAmount is caller-chosen and the Lpd deposit is priced at Lpd.price(), which reads the LPD/USDC pool’s current reserves, a spot price. And the interest cap, interestTop = uAmount / 2, scales with that caller-chosen number. At the fork block (113,613,922) the pool held 616k USDC against 4.86M Lpd, so the honest price was about 0.127 USDC per Lpd.

The objective

The harness maximizes net profit after a simulated flash loan, never gross balance. Gross balance makes the loan itself the finding: borrow 50M, do nothing, “profit” 50M.

Solidity
function max_profit() external view returns (uint256) {
    uint256 finalBalance = usdc.balanceOf(address(this));
    if (finalBalance <= LOAN + FEE) return 0;
    return finalBalance - LOAN - FEE;
}

Multi-asset objectives need a frozen price. Value secondary holdings at a price captured in setup(), or the fuzzer pumps the price of assets it already holds and fabricates profit. That pitfall is measurable: the LpdFi harness scored 726,040.66 with Lpd valued at the frozen price, while the PoC asserts the conservative 415,935.53 USDC after repaying LOAN + FEE. The objective finds leads; the PoC proves them.

The skip that hid the bug

The first claimInterest handler skipped when the claim would need more LP than the protocol held:

Solidity
function skip(ClaimInterestInputs memory inputs) internal view returns (bool) {
    // interest * totalLP > reserveUsdc * protocolLp → removeLp would fail
    if (inputs.interest * lpTotalSupply > reserveUsdc * protocolLp) return true;
    return false;
}

That condition is adjustable. The actual onchain exploit donated dust USDC to the pair and called sync, so removeLp’s needLpAmount fit inside the protocol’s LP balance. The skip pruned the actual bug path, and the fuzzer could only find a weaker, longer-wait form of the same issue.

Lesson: skip only static preconditions. If any handler in the harness can change a precondition, do not skip it; let the call revert. A revert costs one sequence; a skip removes a path forever.

Sizing the loan against the oracle

The first runs used the template LOAN of 1M USDC. A 1M loan pumped the price only 6x (0.127 → 0.764), which capped buy’s notional at 2.2M USDC and one day’s interest at 11k USDC. The fuzzer had to wait for the 50% interest cap, 100 days, before the payout showed a profit.

LoanPrice impactMax notionalInterest claimedTime to payout
1M (template)6x2.2M11k/day100 days
43.7M (onchain)5,000x140M693.5k (1% fee)1 day
50M (campaign)4x2.4M across 2 orders1.2M (50% cap)2 claims

The actual onchain exploit dumped 43.7M USDC, 70x the pool, multiplied the price 5,000x, opened a 140M notional with a 214k LPD deposit, and claimed 693.5k USDC from a single day’s interest, 701.6k accrued minus the 1% fee. The harness found the same root cause in a weak, time-expensive shape. The loan was the missing lever.

For spot-oracle protocols, size the loan against the oracle pool, not the protocol TVL: several times the reserve, tens of times for manipulation-heavy targets. A small pool next to a large TVL is exactly the manipulation target.

The finding

With a 50M loan the campaign (2026-08-11-021213-bc7d6f55) found the issue in one transaction:

  1. swap 625,851 USDC → Lpd: price 0.1269 → 0.5149 (4x)
  2. buy(1,149,520e18): interestTop 574,760
  3. warp past the issue period
  4. claimInterest(0): pays 568,896.57 USDC, burns 799,145.70 LP
  5. buy(1,258,364e18), warp, claimInterest(1): pays 622,890.18 USDC, burns 874,992.11 LP

LpdFi’s LP went from 1,678,049.36 to 3,911.56, 99.77% gone after two claims. The PoC nets 415,935.53 USDC after repaying the 50M loan plus the 0.3% fee. The sequence even keeps a 28-wei dust swap: the shrinker keeps the shortest profitable prefix, and a dust call can be load-bearing.

The fuzzer does not learn root cause. It returns a call sequence. The root cause is what I extract from it: a spot price feeding a user-chosen notional, a cap that scales with that notional, and a payout backed by the protocol’s own LP.

What to look for

The checklist I want future me to have:

  1. Spot-oracle pricing: does the protocol price off getReserves()?
  2. User-chosen notional: does buy/deposit let the caller pick uAmount while the real deposit is priced at spot?
  3. Caps that scale with the manipulable input: interestTop = uAmount / 2
  4. Payouts backed by the protocol’s own balance: claimInterest burns LpdFi’s LP, not the deposited value
  5. Anyone can open and claim: no position gate on the payout path

That is the whole lesson: the fuzzer finds sequences, I extract root causes, and the checklist is what survives. Next time I see a buy-and-earn contract that prices off spot reserves and pays interest from its own LP, I know what to check before it is exploited.