Value deltas for the max search

I have been using ripfuzz to search for high-value sequences with ripfuzz max. Coverage tells the fuzzer which code is new. The objective is value(), a view function the harness already exposes. For a long time the search only looked at that number at the end of the sequence.

That is the wrong number for a lot of exploits.

The ladder

DRLVaultV3 is a USDC/WETH Uniswap V3 rebalance vault (0x6A06707ab339BEE00C6663db17DdB422301ff5e8). On 10 Nov 2025 a whitehat drained it in one Ethereum transaction, block 23,769,387.

swapToWETH is permissionless. Its slippage floor is a live QuoterV2 quote of the same 0.01% USDC/WETH pool the swap executes against, minus 0.5%. Crash the pool, the minimum out crashes with it. Verichains called that a self-referential slippage check.

The onchain sequence, all inside a Morpho flash loan of 13,980,773 USDC:

  1. Dump the loan through 1inch into the pool: 13,980,773 USDC → 812.95 ETH. WETH in pool terms moves from about 3,600 to about 865 million.
  2. Call DRLVaultV3.swapToWETH(100000 USDC). The vault sells its full 100,000 USDC and receives 0.0001205 WETH (about $0.43). At the honest price that 100k should have bought about 27.8 WETH.
  3. Restore: spend 780 WETH through 1inch for 13,959,481 USDC, top up 5.83 WETH for 21,291 USDC, repay Morpho to the wei.

The vault’s 100k USDC stays in the pool during the crash and comes back to the attacker on the round trip. That is the ladder: dump, force the vault to trade, unwind.

The ripfuzz campaign measures the same shape with value() after each handler. reducePrice is the dump, swapToWETH is the vault, increasePrice is the unwind:

Plain Text
baseline                  5008 ETH
after reducePrice          923 ETH   delta -4085
after swapToWETH           923 ETH   delta     0
after increasePrice       5035 ETH   delta +4112

The campaign profit is 27 ETH. The path to it is a 4000 ETH hole. If you rank prefixes by current value, reducePrice looks like damage. The fuzzer throws away the mandatory first step.

Profit is not monotonic. Exploit trajectories usually dip before they pay out.

Value after every call

The worker now executes the sequence and calls value() after every handler:

Plain Text
[call_1, value(), call_2, value(), call_3, value()]

That yields V0, V1, ..., Vn, where V0 is the baseline after setup and V_i is the value after the first i calls. The delta of call i is d_i = V_i - V_{i-1}.

value() is a view. It reads a handful of balances. A typical handler runs swaps, router hops, or accounting. The extra views at most double the number of EVM calls and add a small fraction of the gas. That price buys the only thing a search over stateful sequences needs: which call moved the objective, and by how much.

Two rules follow.

Records, recoveries, activity

Each delta is classified. A class is a (prefix signature, final handler) pair: the selectors before the call, plus the handler that just ran. Cheap to compute, stable across runs. One min and one max per class, never a history.

SignalMeaningFuzzer action
new record min dlargest drop ever seen for this classadmit the prefix
new record max dlargest gain ever seen for this classadmit the prefix, weight up
recoverybelow baseline and d > 0admit the prefix
d == 0the measured wallet did not moveneutral, neither pruned nor admitted

The records are a poor man’s potential estimate. Instead of learning P(future profit | state) with a model, the fuzzer keeps the extreme deltas per class and treats a new extreme as novelty. A call that sets a new record changed the objective more than any call of its kind before it. That is the state a search should keep exploring from.

The interest check is a disjunction. Coverage still admits new code. A local best still admits a climb. Deltas sit next to those:

Rust
let interesting =
    update.is_interesting() || beats_local || observation.is_interesting();

observation.is_interesting() is a new min, a new max, or a recovery.

Corpus sampling weights entries by delta activity, not by current value. Activity is zero for a flat call. Otherwise it scales with the magnitude bit length, so a large dump is drawn more often than a one-wei nudge. Ranking the current value would kill the dip again. Ranking the movement does not.

When a sequence sets a new best, every prefix of it becomes a corpus entry, each carrying its measured value. That is the payoff of measuring the full trajectory. The fuzzer learns not just which sequence won, but which of its prefixes built toward the win, and it keeps every one of them for later extension.

The in-repo Ladder

I added Ladder and LadderWithNoise under fixtures/maxer/challenges. They replay the same 5008 → 923 → 923 → 5035 path without the vault:

Solidity
function reduce() external {
    require(step == 0);
    step = 1;
    wallet = 923;
}

function swap() external {
    require(step == 1);
    step = 2;
}

function increase() external {
    require(step == 2);
    step = 3;
    wallet = 5035;
}

function value() external view returns (uint256) {
    return wallet;
}

reduce is the dump. swap is the flat middle rung. increase is the recovery. The highest value is 5035, and only that exact order reaches it. LadderWithNoise is the same ladder plus twenty unused handlers, so the search has to keep the dip while noise writes try to knock it off the path.

make challenges reaches 5035 on both.

The flat rung

Delta signals only see movement in the measured wallet. The DRLVaultV3 middle rung has d = 0: onchain, swapToWETH moves 100,000 USDC from the vault into the pool and the attacker wallet is unchanged. Ladder.swap is the same shape. No delta class fires on the state the ladder needs.

The rung is not lost, because admission is a disjunction.

A zero delta is also genuinely ambiguous. An approval is flat and enables later profit. A failed call is flat because nothing happened. The layer treats zero as neutral: neither pruned nor admitted by delta alone. Enabler detection needs a different signal than value. That is out of scope here.

What I did not ship

I did not add a potential score:

Plain Text
score(s) = value(s) + lambda * estimated_future_profit(s)

The challenge suite did not demand it. Corpus sampling already weights by delta activity. Lambda and depth decay only matter if that score is added. I will revisit if a ladder appears that activity weighting cannot keep alive.

Records also do not retire mid-campaign. An early extreme stays the bar. One min and one max per class, never a history. Novelty resets between campaigns because the records are not persisted.

No harness changes. Same value() the campaign already had. One extra view per handler call.

That is the whole change: measure after every call, keep dumps as evidence, keep recoveries as exploit path, keep every prefix of a new best, and stop ranking the hole as if it were the answer.