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.
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.
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:
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.
| Loan | Price impact | Max notional | Interest claimed | Time to payout |
|---|---|---|---|---|
| 1M (template) | 6x | 2.2M | 11k/day | 100 days |
| 43.7M (onchain) | 5,000x | 140M | 693.5k (1% fee) | 1 day |
| 50M (campaign) | 4x | 2.4M across 2 orders | 1.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:
- swap 625,851 USDC → Lpd: price 0.1269 → 0.5149 (4x)
buy(1,149,520e18): interestTop 574,760- warp past the issue period
claimInterest(0): pays 568,896.57 USDC, burns 799,145.70 LPbuy(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:
- Spot-oracle pricing: does the protocol price off
getReserves()? - User-chosen notional: does
buy/depositlet the caller pickuAmountwhile the real deposit is priced at spot? - Caps that scale with the manipulable input:
interestTop = uAmount / 2 - Payouts backed by the protocol’s own balance:
claimInterestburns LpdFi’s LP, not the deposited value - 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.
| Tags | ripfuzz , solidity , fuzzing , security , defi , evm |
|---|