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.
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.
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 block | Optimal fee |
|---|---|---|
| very calm | 1.5 ticks | 25 bips |
| calm | 3 ticks | 25 bips |
| normal | 6 ticks | 25 bips |
| agitated | 12 ticks | 85 bips |
| crisis | 24 ticks | 150 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).
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 d | Executed price against final price | d/2 |
|---|---|---|
| 2 ticks | 1.00 tick | 1.0 |
| 10 ticks | 5.00 ticks | 5.0 |
| 30 ticks | 15.00 ticks | 15.0 |
| 100 ticks | 50.00 ticks | 50.0 |
| 300 ticks | 150.00 ticks | 150.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.
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.
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.
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.
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.
Measured over 6,000 simulated epochs, with a correct forecaster:
| Share of control epochs | Lisa net / epoch | Control net / epoch | Gap |
|---|---|---|---|
| 10 % | 4,909 | 4,081 | +20.3 % |
| 25 % | 4,885 | 4,138 | +18.0 % |
| 50 % | 4,855 | 4,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.
The hook.
5.1 Permissions
| Permission | Bit | Role |
|---|---|---|
BEFORE_INITIALIZE | 0x2000 | require a dynamic fee pool |
BEFORE_SWAP | 0x0080 | return the fee of the epoch |
AFTER_SWAP | 0x0040 | measure 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
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.
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.
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.
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.
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.
| Fee | Move | Net | Reading |
|---|---|---|---|
| 30 | 0 | +30 | pure retail swap, no move |
| 30 | 30 | +15 | move equal to the fee: the pool still gains |
| 30 | 59 | +1 | the last swap that pays |
| 30 | 60 | 0 | the break-even: move = 2 × fee |
| 30 | 100 | −20 | the arbitrageur took more than he paid |
| 5 | 100 | −45 | a fee too low, exactly what must be avoided |
| 150 | 100 | +100 | a 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.
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.