LISA, from A to Z

An AI sets the fees of a Uniswap v4 pool. The pool measures, on its own, whether she is any use. This page holds every detail of the design: the problem, the identity that makes it measurable on-chain, what Lisa does, how she is judged, the hook, its bounds, and what can go wrong.

NameLisa.ai, $LISA
BaseUniswap v4, dynamic fee hook
Problem addressedLVR: what LPs lose to arbitrageurs
What Lisa setsA single number: the fee of the next hour
How she is judgedOne epoch in four, she does not have the hand. The pool compares.
01

The problem, which is not made up.

The price of a pool is stale between two swaps. When the price moves elsewhere, an arbitrageur comes to trade against the pool at the old price and pockets the gap. The LPs pay that gap. This is LVR, loss versus rebalancing, and it is the first item of loss for an LP, ahead of everything else.

arbitrage swap, at the old price market price pool price, stale between two swaps shaded: the gap between the two prices, paid by the LPs. That is the LVR. time price
Between two swaps the pool price does not move. Whoever trades first against the old price takes the difference from the LPs.

The only lever a pool has against it is the fee. Too low, the arbitrage gets through. Too high, aggregators route elsewhere and nobody is left.

And the right setting is not constant: it depends on what the price is about to do. A fee sweep at fixed volatility, on an arbitrage simulator:

Regimeσ per blockOptimal fee
very calm1.5 ticks25 bips
calm3 ticks25 bips
normal6 ticks25 bips
agitated12 ticks85 bips
crisis24 ticks150 bips

The optimum is interior, never at the floor, and the fee to σ ratio is not constant: 17, then 8.3, then 4.2, then 7.1, then 6.2. No linear formula describes this curve. It is a forecasting problem, and it is Lisa's only job.

The crisis regime hits the 150 bips cap: the optimum may lie beyond it, the sweep does not say. The cap is a safety choice, not a result (see section 06).

02

The identity that makes everything measurable on-chain.

Two facts about Uniswap which, side by side, give the protocol.

One tick is exactly one basis point. 1.0001 × price = +0.01 %.

v4 fees are expressed in hundredths of a bip. 3000 = 0.30 % = 30 bips.

So fee ÷ 100 and move in ticks are in the same unit. The hook can compare what it charged to what the price did, with a subtraction: no oracle, no conversion, no assumption.

What this lets the hook compute exactly

On an x × y = k curve, a swap that moves the price by d ticks executes at the middle of its path. The LP therefore gives up d/2 relative to the arrival price. Checked on the exact curve:

Move dExecuted price against final priced/2
2 ticks1.00 tick1.0
10 ticks5.00 ticks5.0
30 ticks15.00 ticks15.0
100 ticks50.00 ticks50.0
300 ticks150.00 ticks150.0

This is not a convenient approximation: it is the geometry of the curve, exact to a hundredth of a tick over two orders of magnitude.

the swap moves the price by d ticks tick before tick after executed price the middle of the path d/2, given up by the LP
A swap that moves the price by d ticks gets the middle of the path. Against the arrival price, the LP has given up d/2.

Hence the result of one swap, for the pool:

        net  =  size × ( fee − move / 2 )            [in bips]

Three quantities, all already in the hook's hands: the size comes from params, the fee is the one it just charged, the move is |tick_after − tick_before| read from slot0.

A swap pays the pool as long as it moves the price by less than twice the fee.

At 30 bips: every swap under 60 ticks (0.60 %) pays the LPs. Beyond that, it drains them. The pool knows, swap by swap, which side it is on.

break-even move = 2 × fee the swap pays the LPs the swap drains them +30 +15 0 −20 0 30 60 100 move of the swap, in ticks, at a 30 bips fee. Above the axis: the net, in bips.
At a 30 bips fee, the break-even sits at a 60 tick move. Left of it the swap pays the LPs, right of it the arbitrageur took more than he paid.
03

One integer, once an hour.

Once per hour, she posts one integer: the fee of the next epoch.

function post(uint24 fee) external {
    require(msg.sender == LISA);
    require(fee >= 500 && fee <= 15000);      // 5 to 150 bips, hard-coded
    proposedFee = fee;
}

That is her entire control surface. A bounded uint24, once an hour.

She is free to compute whatever she wants, wherever she wants, with whatever data she wants: order books elsewhere, implied volatility, stablecoin flows, anything. The protocol does not check that she thinks. It checks that she is right.

04

How we know she is any use.

4.1 The counterfactual is impossible, and it has to be said

Nobody can know what another fee would have brought in over the same hour. A different fee would have attracted a different volume, so the swaps that were observed would not have happened. Any on-chain "simulation" of an alternative fee on the realised flow is wrong.

There is only one honest way to compare two rules: run both of them, for real.

4.2 One epoch in four, Lisa does not have the hand

   at the first swap of each epoch:

        if  uint256(blockhash(block.number - 1)) % 4 == 0
            →  CONTROL EPOCH : the hook applies ITS rule
        else
            →  the hook applies Lisa's fee

Lisa posts before the draw. She cannot know whether the hour she is preparing will count for her or for the control: she therefore always has to post her best fee.

The control rule is a single line, computed by the hook from its own counters:

        control fee  =  2 × (mean move per swap over the previous epoch)

It is the naive persistence forecast: tomorrow will look like yesterday. It is the standard reference in forecasting, and it is also the best rule a contract can write for itself.

Lisa posts one integer, 5 to 150 bips The epoch closes previous block hash, drawn she posted before the draw: she cannot know which hour counts for her 3 epochs in 4 Lisa's fee applies for the hour 1 epoch in 4 the control fee applies: 2 × mean move Every swap is measured net = size × (fee − move/2), added to Lisa's bucket or the control's next hour
One hour, one cycle: Lisa posts, the epoch closes, the previous block hash decides who sets the fee, every swap is measured into the right bucket.

4.3 The verdict

The hook holds two accumulators: the realised net of Lisa's epochs, and that of the control epochs, each brought back to its number of epochs.

Lisa keeps the hand as long as her epochs bring in more than the control epochs. Otherwise, the hook switches everything to its own rule.

No intervention, no vote, no administrator. The pool observes and switches.

Lisa epochs net sum ÷ number of epochs = average per epoch Control epochs net sum ÷ number of epochs = average per epoch Lisa average greater than control average ? yes Lisa keeps the hand no the hook applies its own rule recomputed at every epoch close. No vote, no administrator.
The verdict is one inequality between two per-epoch averages, recomputed at every epoch close.

Measured over 6,000 simulated epochs, with a correct forecaster:

Share of control epochsLisa net / epochControl net / epochGap
10 %4,9094,081+20.3 %
25 %4,8854,138+18.0 %
50 %4,8554,124+17.7 %

The gap is stable whatever the size of the control: the sign that something real is being measured, not noise.

4.4 What the control costs, and it has to be priced

One hour in four, the pool deliberately runs on the naive rule. With the figures above, this control costs 3.8 % of the net; a 10 % control would cost 1.5 %, a 50 % control 7.6 %. It is the price of knowing. It is paid, and it is priced.

It is set at deployment, between 1 epoch in 10 and 1 in 2: the smaller the control, the less it costs, and the slower the verdict becomes meaningful.

05

The hook.

5.1 Permissions

PermissionBitRole
BEFORE_INITIALIZE0x2000require a dynamic fee pool
BEFORE_SWAP0x0080return the fee of the epoch
AFTER_SWAP0x0040measure the move and accumulate the net

Mask 0x20C0. No RETURNS_DELTA: the hook takes nothing for itself. It has no treasury, no balance, no withdraw function.

5.2 The hot path

beforeSwap close the epoch if it is due (lazy, once an hour) read tick before, from slot0 return currentFee | OVERRIDE PoolManager the swap executes at the epoch fee afterSwap read tick after, from slot0 move = |after − before| net = size × (fee − move/2) add it to Lisa's or the control's bucket the hook takes nothing for itself: no delta returned, no balance, no withdraw
Two touches per swap: before it, the fee of the epoch; after it, the move the swap actually made and the net it left.
function _beforeSwap(address, PoolKey calldata key, SwapParams calldata, bytes calldata)
    internal override returns (bytes4, BeforeSwapDelta, uint24)
{
    if (block.timestamp >= epochEnd) _close(key);          // lazy, once an hour
    (, tickBefore, , ) = poolManager.getSlot0(key.toId());  // transient
    return (BaseHook.beforeSwap.selector,
            BeforeSwapDeltaLibrary.ZERO_DELTA,
            currentFee | LPFeeLibrary.OVERRIDE_FEE_FLAG);
}

function _afterSwap(address, PoolKey calldata key, SwapParams calldata,
                    BalanceDelta delta, bytes calldata)
    internal override returns (bytes4, int128)
{
    (, int24 tickAfter, , ) = poolManager.getSlot0(key.toId());
    uint256 move  = uint256(int256(tickAfter > tickBefore
                  ? tickAfter - tickBefore : tickBefore - tickAfter));
    uint256 size  = _magnitude(delta);
    uint256 fBips = currentFee / 100;                       // pips to bips

    // net = size × (fee − move/2), signed: it CAN be negative
    int256 net = int256(size) * (int256(fBips) - int256(move) / 2);

    if (controlEpoch) { netControl += net; } else { netLisa += net; }
    moveSum    += move;
    swapsEpoch += 1;

    return (BaseHook.afterSwap.selector, 0);
}

5.3 The epoch close

function _close(PoolKey calldata key) internal {
    // the control rule, on what the past epoch actually showed
    if (swapsEpoch > 0)
        controlFee = uint24(_clamp(2 * 100 * moveSum / swapsEpoch));

    // the epoch that just ended is counted in its bucket
    if (controlEpoch) { controlEpochs += 1; } else { lisaEpochs += 1; }

    // the draw: Lisa has already posted, she could not know it
    controlEpoch = uint256(blockhash(block.number - 1)) % CONTROL_SHARE == 0;

    // the verdict, on per-epoch averages
    bool lisaAhead = controlEpochs == 0 || lisaEpochs == 0
        || netLisa / int256(lisaEpochs) > netControl / int256(controlEpochs);

    currentFee = (controlEpoch || !lisaAhead) ? controlFee : proposedFee;

    moveSum = 0; swapsEpoch = 0;
    epochEnd += 1 hours;
}

5.4 The three traps in this code

int256, not uint256, for the net. fee − move/2 is negative as soon as a swap moves the price by more than twice the fee, the exact case the hook is there to measure. In uint256, Solidity 0.8 reverts, and the hook would break a perfectly legitimate swap, precisely when it matters most.

move / 2, never move × fee / 2. The order matters: dividing move by 2 before subtracting loses at most half a tick, and that loss always goes against the pool, so never in Lisa's favour.

currentFee is read, never recomputed, during the epoch. The fee is frozen for the whole hour. A trader can read it before signing, and it cannot move between the signature and the execution.

5.5 The hook never reverts a swap

No path leads to a revert on a swap. If Lisa posts nothing, if her integer is out of bounds, if she is behind the control, if the epoch is empty: the hook falls back on controlFee, which it computed itself.

Lisa's worst state is a dynamic fee pool driven by a moving average. That is to say, a decent pool.

06

What Lisa cannot do, and what she can.

She cannot

  • Leave the bounds. [5 ; 150] bips, hard-coded in the contract. Even with a stolen key, the amplitude of harm is known in advance.
  • Touch the funds. The hook has no balance and no exit.
  • Block a swap. See 5.5.
  • Keep herself in place. The verdict is recomputed at every close, on data she does not produce.
  • Avoid the control. The draw happens after she has posted.

She can

Torch one epoch after winning thirty. Nothing stops her from posting 150 bips in a calm market. The cost is bounded by the cap and her lead melts immediately, but the epoch is lost.

Trade in her own pool. She knows her fee before it is public. The edge is capped by the gap between two successive fees and is not detectable on-chain.

Influence the draw, at the margin. blockhash can be chosen by a proposer willing to skip a block. The gain, steering the assignment of a single epoch, is not worth the cost of a skipped block, but it is not zero.

07

What can go wrong.

The control rule may be too weak. If persistence is already close to the optimum on a given market, beating the control by 2 % proves nothing useful. The victory threshold is a bare inequality, with no margin of statistical significance: this is the known flaw of the design, and the fix (requiring a minimum gap, or a minimum number of epochs) is not written.

The LVR model is a model. net = size × (fee − move/2) assumes locally uniform concentrated liquidity. On a v4 pool with very uneven liquidity, the executed price drifts away from the middle of the path and the formula drifts with it. It stays right in sign, not necessarily in magnitude.

Nothing proves that an AI beats persistence on real data. The +18 % of section 4.3 comes from a GARCH simulator, not from a price history. It is an instrumented hypothesis, not a result.

08

One curve and a limit.

The object that tells the whole protocol is a statistical control chart: a process under watch, and a control group. One curve, the fee, hour by hour. Under the axis, the control epochs, marked. At the bottom, the two averages and the gap, the only number that decides anything.

bips 150 75 25 5 cap floor now, 85 bips control epochs, one in four epochs, one per hour LISA 4,885 / epoch CONTROL 4,138 / epoch gap +18.0 %
When the gap goes below zero, the curve turns grey: Lisa no longer has the hand, and it shows without a legend.
09

Constants and vectors.

uint24  constant FEE_MIN       = 500;      // 5 bips
uint24  constant FEE_MAX       = 15_000;   // 150 bips
uint256 constant EPOCH         = 1 hours;
uint256 constant CONTROL_SHARE = 4;        // 1 epoch in 4
uint256 constant K_CONTROL     = 2;        // control fee = 2 × mean move

Vectors. net = size × (fee − move/2), size = 1, fee in bips.

FeeMoveNetReading
300+30pure retail swap, no move
3030+15move equal to the fee: the pool still gains
3059+1the last swap that pays
30600the break-even: move = 2 × fee
30100−20the arbitrageur took more than he paid
5100−45a fee too low, exactly what must be avoided
150100+100a fee that covers, and empties the pool

The last line is the protocol in one line: the fee that maximises the net of one swap is not the one that maximises the net of the pool. You have to forecast.

Glossary

The words.

LVR
Loss versus rebalancing: what LPs lose to arbitrageurs because the pool price is stale between two swaps.
Tick
The unit of price on Uniswap: one tick is a 0.01 % step, so exactly one basis point.
Bip
One basis point, 0.01 %. v4 stores fees in hundredths of a bip: 3000 is 30 bips.
Move
The absolute distance, in ticks, between the pool price before and after a swap.
Net
What one swap leaves to the pool: size × (fee − move/2), in bips. It can be negative.
Epoch
One hour. The fee is frozen for its whole duration.
Control epoch
An epoch, drawn one in four by the previous block hash, where the hook applies its own rule instead of Lisa's fee.
Control rule
Twice the mean move per swap of the previous epoch: the persistence forecast, tomorrow looks like yesterday.
The hand
Whose fee applies. Lisa keeps it as long as her per-epoch average net is above the control's.