Ticks: WTF!?

Conversations exploring the latest Uniswap V4 protocols, hooks and industry news.

The Dynamic Liquidity Cookbook - Three Patterns for Hooks That Don't Just Sit There

By Twade

Static liquidity is leaving money on the table. This piece is a deep-dive cookbook for hooks that actively manage liquidity - JIT positioning, rehypothecation to AAVE, and auto-rebalancing - with token flow diagrams, real Solidity, and an honest accounting of where each pattern can hurt you.


Most V4 hooks treat liquidity the way v3 did: deposit, choose a range, leave it there, hope the price visits. That's static liquidity, and once you've been around V4 for a while you start noticing how much money it leaves on the table.

Liquidity that sits out of range earns nothing while the price drifts elsewhere. Liquidity that's in range earns the fees the AMM curve gives it - fine, but constrained by the curve itself. Liquidity locked in a position is liquidity that isn't earning yield in lending markets, can't be repositioned without paying gas twice, and can't react to a swap that's about to hit it.

V4's hook system is what makes liquidity dynamic. A hook can move, redeploy, redirect, and reposition liquidity in response to what's happening on-chain - sometimes during the same transaction as the trade it's serving. This piece is the cookbook for the three patterns we see come up most often in student questions and in production V4 hooks: JIT positioning around incoming swaps, rehypothecation to AAVE-style lending markets, and auto-rebalancing positions as the price moves.

For each: the mechanism, a token-flow diagram, real Solidity, the pitfalls that bite people, and where the pattern is being run in production. This piece assumes you're already familiar with V4 hooks and the PoolManager singleton model, and goes harder on the code from there.


Foundations: how tokens move

Before any of the dynamic patterns make sense, the token-flow story has to be clear. Every dynamic liquidity hook leans on the same V4 primitives - the unlock callback, balance deltas, and the settle/take pair. This section pins them down.

A hook never holds custody of pool tokens by accident. The PoolManager does. When a swap or liquidity change happens, the PoolManager produces a BalanceDelta describing who owes what:

  • Negative amount for the user means the user owes the PoolManager that amount.
  • Positive amount for the user means the PoolManager owes the user that amount.
  • For a hook taking custody, the signs flip: negative deltas owed to the hook mean the hook is owed tokens; positive deltas mean the hook owes tokens.

At the end of the unlock cycle, every delta must be settled to zero or the transaction reverts. The hook (or the periphery contract that called unlock) does this with two functions:

  • PoolManager.take(currency, recipient, amount) - pulls tokens out of the PoolManager to the recipient. Reduces a positive delta toward zero.
  • PoolManager.settle*(...) - pushes tokens into the PoolManager (via transfer or by minting ERC-6909 claim tokens). Reduces a negative delta toward zero.

A dynamic-liquidity hook is, fundamentally, a contract that does its own modifyLiquidity calls against the PoolManager, then takes and settles the resulting deltas. Wherever the hook keeps the tokens between operations - in its own reserves, deployed to AAVE, held as ERC-6909 claim tokens inside the PoolManager - is a design choice.

Insight #1

A dynamic liquidity hook calls modifyLiquidity on the PoolManager at moments the protocol doesn't normally call it, and handles the resulting balance deltas. The "dynamism" is entirely in when the hook makes those calls and where it holds the tokens between them.

One architectural note that applies to all three patterns: the hook usually is the LP from the PoolManager's perspective. Users deposit underlying tokens into the hook (often via an ERC-4626-style vault). The hook holds the V4 position. The user holds vault shares that represent a claim on whatever the hook has earned. We'll assume that structure in the code below and call it out explicitly where it matters.

The code below is illustrative, not copy-paste-ready. Each snippet is here to make the mechanism legible - which hook fires, what it reads, when it moves tokens. The settlement and custody plumbing (exactly how each take/settle nets the hook's balance deltas to zero before the unlock closes) is simplified in places, with inline comments flagging where a production hook does more work. Treat these as annotated reference sketches, not audited contracts.


Why none of these hooks use return-delta flags

One decision shapes all three patterns, so we'll settle it before any code: none of these hooks use the return-delta flags - beforeSwapReturnDelta, afterSwapReturnDelta, afterAddLiquidityReturnDelta, or afterRemoveLiquidityReturnDelta. Understanding why is the key to reasoning about every dynamic-liquidity hook you'll build.

The return-delta flags exist to do exactly one thing: change the BalanceDelta that some other actor settles. A hook with afterSwapReturnDelta can take a slice of a swapper's output. A hook with afterAddLiquidityReturnDelta can hand an external LP back more, or less, than the curve says they're owed. These flags are the only mechanism in V4 for altering someone else's settlement figure.

Dynamic-liquidity hooks don't touch anyone else's figure. They relocate their own tokens - between their reserves, the PoolManager, and an external venue like AAVE - and settle their own modifyLiquidity deltas against their own reserves. The swapper still settles exactly what the curve says. The LP still settles exactly what the curve says. Nothing about another party's accounting changes, so there is no return delta to return.

That holds because all three patterns share one structure: the hook is the sole LP. It owns the V4 position and issues vault shares to depositors, the ERC-4626 model from the previous section. Yield and captured spread reach users through the share price - each share is worth more underlying over time - never by rewriting a settlement. A hook that is the only LP has no counterparty whose delta it could rewrite.

You reach for the return-delta flags only when you want to change what another party settles:

  • beforeSwapReturnDelta - fill a swap from your own inventory before the curve runs (custom-curve and RFQ-style hooks).
  • afterSwapReturnDelta - charge a fee, or pay a rebate, in a specific token on top of the swap.
  • afterAddLiquidityReturnDelta / afterRemoveLiquidityReturnDelta - in a design where external LPs add to the pool directly, hand them principal plus accrued yield, or skim a fee, in a single settlement.

That last option is the tempting shortcut for rehypothecation: let external LPs deposit straight into the pool and fold the AAVE interest into their exit delta. It works, but it folds yield into the protocol's accounting rather than a share price, and it is precisely the surface where "pays out more than it holds" bugs live - the class of error behind the Bunni exploit. The sole-LP vault keeps the accounting boring, which for this category of hook is both the safer default and the model used throughout this piece.

Insight #2

Return-delta flags change someone else's settlement. A sole-LP vault hook only ever moves its own tokens and settles its own deltas, so it needs none of them - the yield reaches depositors through the share price, not through rewritten accounting.


Pattern 1: Just-In-Time (JIT) liquidity

JIT is the most-discussed dynamic-liquidity pattern and the simplest to motivate. The hook detects an incoming swap in beforeSwap, deposits tight concentrated liquidity around the current price, lets the swap execute against the inflated liquidity (capturing fees on the trade), then withdraws the position in afterSwap.

The economic claim is that JIT captures a slice of swap fees that would otherwise have gone to passive LPs, without putting capital at risk between trades. The trade-off is gas - every JIT'd swap eats two modifyLiquidity operations - and the broader question of whether you're competing with the AMM curve or just front-running it.

The flow

The two modifyLiquidity calls - one in beforeSwap to add the JIT position, one in afterSwap to remove it - are the whole pattern. Everything else is bookkeeping.

The code

A few permissions to declare first. The hook needs beforeSwap and afterSwap for the entry points, plus the corresponding return-delta flags if it wants to charge a custom fee on top of the captured spread (we'll skip that here for clarity):

function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
    Hooks.Permissions memory p;     // all false by default
    p.beforeSwap = true;            // bit 7
    p.afterSwap = true;             // bit 6
    return p;
}

We need transient state to carry the JIT range between beforeSwap and afterSwap within the same transaction. EIP-1153 transient storage is the right home - cheap reads/writes, scoped to the transaction:

// transient storage slots
bytes32 constant JIT_LOWER_SLOT = keccak256("jit.tickLower");
bytes32 constant JIT_UPPER_SLOT = keccak256("jit.tickUpper");
bytes32 constant JIT_LIQ_SLOT   = keccak256("jit.liquidity");
 
function _stash(int24 tickLower, int24 tickUpper, uint128 liquidity) internal {
    assembly {
        tstore(JIT_LOWER_SLOT, tickLower)
        tstore(JIT_UPPER_SLOT, tickUpper)
        tstore(JIT_LIQ_SLOT, liquidity)
    }
}
 
function _loadStash() internal view returns (int24 lower, int24 upper, uint128 liq) {
    assembly {
        lower := tload(JIT_LOWER_SLOT)
        upper := tload(JIT_UPPER_SLOT)
        liq   := tload(JIT_LIQ_SLOT)
    }
}

Now beforeSwap. Read the current tick, compute a tight range, work out how much liquidity to provide from the hook's reserves, and add it via modifyLiquidity:

import {StateLibrary} from "v4-core/libraries/StateLibrary.sol";
import {LiquidityAmounts} from "v4-periphery/libraries/LiquidityAmounts.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
 
// getSlot0 / getLiquidity etc. are extension functions on IPoolManager.
using StateLibrary for IPoolManager;
 
// Illustrative band width. 30 * tickSpacing each side is wide for a "tight"
// JIT; a real hook would size this to the expected swap impact (often a few
// tickSpacings) so the captured fees aren't diluted across an over-wide range.
uint256 public constant JIT_TICK_HALF_WIDTH = 30;
 
function _beforeSwap(
    address,
    PoolKey calldata key,
    SwapParams calldata params,
    bytes calldata
) internal override returns (bytes4, BeforeSwapDelta, uint24) {
    PoolId id = key.toId();
    (uint160 sqrtPriceX96, int24 currentTick, , ) = poolManager.getSlot0(id);
 
    // Align to tick spacing and build a tight range around the current tick.
    int24 spacing = key.tickSpacing;
    int24 centred = (currentTick / spacing) * spacing;
    int24 tickLower = centred - int24(int256(JIT_TICK_HALF_WIDTH) * spacing);
    int24 tickUpper = centred + int24(int256(JIT_TICK_HALF_WIDTH) * spacing);
 
    // How much liquidity can we put on this range with the reserves the hook holds?
    // currency{0,1}Reserves() are hook-internal views over the tokens the hook
    // custodies (e.g. ERC20 balanceOf(address(this)) minus anything earmarked).
    // Defined elsewhere on the hook; omitted here.
    uint256 amount0Available = currency0Reserves();
    uint256 amount1Available = currency1Reserves();
    uint128 liquidity = LiquidityAmounts.getLiquidityForAmounts(
        sqrtPriceX96,
        TickMath.getSqrtPriceAtTick(tickLower),
        TickMath.getSqrtPriceAtTick(tickUpper),
        amount0Available,
        amount1Available
    );
    if (liquidity == 0) {
        // Nothing added this swap. Clear any stash from an earlier swap in the
        // same transaction so afterSwap doesn't try to burn a stale position.
        _stash(0, 0, 0);
        return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
    }
 
    // Add the JIT position to the pool.
    (BalanceDelta delta, ) = poolManager.modifyLiquidity(
        key,
        ModifyLiquidityParams({
            tickLower: tickLower,
            tickUpper: tickUpper,
            liquidityDelta: int256(uint256(liquidity)),
            salt: bytes32("JIT")
        }),
        ""
    );
 
    // Settle the tokens the hook now owes to the PoolManager.
    if (delta.amount0() < 0) {
        key.currency0.settle(poolManager, address(this), uint128(-delta.amount0()), false);
    }
    if (delta.amount1() < 0) {
        key.currency1.settle(poolManager, address(this), uint128(-delta.amount1()), false);
    }
 
    _stash(tickLower, tickUpper, liquidity);
    return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
}

Then afterSwap removes the position and pulls the tokens (including the share of swap fees the JIT position earned) back into the hook:

function _afterSwap(
    address,
    PoolKey calldata key,
    SwapParams calldata,
    BalanceDelta,
    bytes calldata
) internal override returns (bytes4, int128) {
    (int24 tickLower, int24 tickUpper, uint128 liquidity) = _loadStash();
    if (liquidity == 0) return (this.afterSwap.selector, 0);
 
    // Clear the stash immediately so a second swap on this pool in the same
    // transaction can't re-load this (now consumed) range.
    _stash(0, 0, 0);
 
    // Burn the JIT position.
    (BalanceDelta delta, BalanceDelta feesAccrued) = poolManager.modifyLiquidity(
        key,
        ModifyLiquidityParams({
            tickLower: tickLower,
            tickUpper: tickUpper,
            liquidityDelta: -int256(uint256(liquidity)),
            salt: bytes32("JIT")
        }),
        ""
    );
 
    // Take what we're owed back to the hook (principal + fees combined in `delta`).
    if (delta.amount0() > 0) {
        poolManager.take(key.currency0, address(this), uint128(delta.amount0()));
    }
    if (delta.amount1() > 0) {
        poolManager.take(key.currency1, address(this), uint128(delta.amount1()));
    }
 
    // (optionally) account the feesAccrued portion into vault share accounting.
    // _recordFees is hook-internal bookkeeping (updates the share price so
    // depositors capture the spread); defined elsewhere, omitted here.
    _recordFees(key.toId(), feesAccrued);
 
    return (this.afterSwap.selector, 0);
}

The feesAccrued return value from modifyLiquidity is what makes JIT economically interesting - it's the share of swap fees the JIT position earned for being in range while the swap executed. That's the spread your hook just captured.

The pitfalls

Gas overhead. Two modifyLiquidity calls per swap is meaningful - easily 100k+ gas added to the swap path. Below a certain swap size, the captured fees won't cover the gas. JIT only pays off above a swap-size threshold that depends on chain, gas price, and the fee tier you're competing on. Hooks that JIT every swap regardless of size are silently bleeding gas on small trades.

You can't outrun the curve, you can only join it. JIT liquidity earns fees proportional to its share of the active liquidity in the range, so it captures a slice of what's already happening - it doesn't create new spread. If passive liquidity is already concentrated where the swap is happening, JIT's slice gets smaller. JIT shines most in pools with thin, spread-out passive liquidity.

MEV exposure. A JIT hook deposits liquidity visible to mempool watchers between beforeSwap and the swap itself. Searchers who can predict your JIT behaviour can sandwich-attack your position. Production JIT hooks usually bind themselves to specific routers or signed intent flows to avoid this - a JIT hook on a fully public mempool is a target.

Settlement bugs are punishing. Get the delta signs wrong in beforeSwap and your transaction reverts when the unlock cycle settles. Worse, get them subtly wrong in afterSwap and you might silently lose tokens. The Bunni hook had a high-profile incident in this category - custom liquidity logic with subtle accounting errors paid out tokens it shouldn't have. Test your settlement paths exhaustively.

Where this pattern lives in production

The JIT-style pattern shows up adjacent to MEV protection hooks. Sorella Labs' Angstrom uses V4 hooks to control transaction ordering and effectively run an app-specific sequencer that mitigates LP loss-versus-rebalancing - adjacent to JIT in spirit, though the mechanism is auction-based rather than per-swap. Standalone JIT-hook implementations also exist in the open-source ecosystem; the awesome-uniswap-hooks list is the best running catalogue.


Pattern 2: Rehypothecation to AAVE

The second pattern is the one that's produced the most value in production: liquidity in V4 pools sometimes sits idle, and idle tokens can earn lending interest while they wait.

A v3-style concentrated liquidity position is "active" only when the price is within its range. Outside the range, one token of the pair is entirely passive - it isn't being traded, it isn't earning swap fees, it's just sitting there waiting for the price to come back. The rehypothecation pattern says: while it's idle, deploy it to a lending market. When the price re-enters the range and the pool needs that token again, withdraw it back.

The economic claim is real: dual revenue from swap fees plus lending interest, with the pool's actual swap behaviour unchanged. The production case for this pattern is Bunni v2, which built one of the most-deployed V4 hooks specifically around this idea - pairing concentrated liquidity with AAVE-style yield on the idle side.

The flow

The shape: users interact with the hook's vault, never with the pool directly. On deposit, the hook splits the funds - in-range liquidity into the position, the idle side into AAVE. Swaps drive the rest: beforeSwap recalls from AAVE and re-adds liquidity if the swap is about to need the idle side, and afterSwap re-parks whatever the price move just left idle. Withdrawals unwind both legs back to the user.

Because the hook is the only LP, it never reacts to external addLiquidity / removeLiquidity calls - so it needs no liquidity callbacks at all. It drives every liquidity change itself, either inside its own unlock (deposits and withdrawals) or inside the swap's unlock (recall and re-idle).

The code

This is the most complex of the three patterns, so we'll walk it in pieces. State first - the hook is an ERC-4626-style vault that also holds the V4 position, so it tracks both the live position and whatever principal is currently parked in AAVE:

import {IPool as IAavePool} from "@aave/v3-core/contracts/interfaces/IPool.sol";
import {StateLibrary} from "v4-core/libraries/StateLibrary.sol";
import {CurrencySettler} from "v4-core/test/utils/CurrencySettler.sol";
 
contract RehypoHook is BaseHook, ERC20 /* ERC20 = vault shares */ {
    using StateLibrary for IPoolManager;
    using CurrencySettler for Currency;
 
    IAavePool public immutable aave;
 
    struct Managed {
        int24    tickLower;
        int24    tickUpper;
        uint128  liquidity;      // currently live in the pool
        Currency idleToken;      // which side is parked in AAVE (if any)
        uint256  idlePrincipal;  // amount supplied to AAVE (interest accrues on top)
    }
    mapping(PoolId => Managed) public managed;
 
    enum Action { Deposit, Withdraw }

Permissions - and this is the payoff of the vault model. Because the hook is the only LP and users transact with the vault rather than the pool, it never has to react to external liquidity changes. It only reacts to price moves:

function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
    Hooks.Permissions memory p;
    // No add/remove callbacks: the hook drives its own liquidity.
    p.beforeSwap = true;   // recall idle liquidity before a swap can need it
    p.afterSwap  = true;   // re-park whatever went idle after the price moved
    return p;
}

Deposits are the hook's own entry point, not a protocol callback. Pull the user's tokens into the hook's reserves, mint vault shares, then open an unlock to put the funds to work (modifyLiquidity can only run inside one):

function deposit(PoolKey calldata key, uint256 amount0, uint256 amount1)
    external
    returns (uint256 shares)
{
    // Pull underlying from the user into the hook's own reserves.
    if (amount0 > 0) IERC20(Currency.unwrap(key.currency0)).transferFrom(msg.sender, address(this), amount0);
    if (amount1 > 0) IERC20(Currency.unwrap(key.currency1)).transferFrom(msg.sender, address(this), amount1);
 
    // Mint vault shares for the deposit. Share math (and the interest accrued
    // in AAVE since the last touch) is folded in here; omitted for length.
    shares = _mintShares(msg.sender, key.toId(), amount0, amount1);
 
    poolManager.unlock(abi.encode(Action.Deposit, key, amount0, amount1));
}
 
function unlockCallback(bytes calldata data) external returns (bytes memory) {
    require(msg.sender == address(poolManager), "only PM");
    (Action action, PoolKey memory key, uint256 a0, uint256 a1) =
        abi.decode(data, (Action, PoolKey, uint256, uint256));
    if (action == Action.Deposit) _allocate(key, a0, a1);
    else _deallocate(key, a0, a1);
    return "";
}

_mintShares carries the real vault accounting, so it deserves a closer look rather than a black box. A share is a claim on the hook's total value under management (TVM): the value of the live V4 position plus the principal and interest sitting in AAVE. Because the AAVE balance grows on its own, TVM grows with no new deposit, and that growth is exactly how depositors earn the lending yield - no return delta required, just a share that's worth more underlying than it was yesterday.

The minting itself is the standard ERC-4626 ratio, valuing the deposit and the existing TVM in a common unit (here token1, converting token0 at the current pool price):

function _mintShares(address to, PoolId id, uint256 amount0, uint256 amount1)
    internal
    returns (uint256 shares)
{
    uint256 depositValue = _valueInToken1(id, amount0, amount1);
    uint256 supply = totalSupply();
 
    if (supply == 0) {
        // First deposit anchors the share:value ratio at 1:1.
        shares = depositValue;
    } else {
        // Later deposits mint pro-rata against current value under management,
        // which already includes accrued AAVE interest - so existing holders
        // keep their (now larger) claim and the new depositor isn't handed any
        // of the yield earned before they arrived.
        shares = (depositValue * supply) / _totalValueManaged(id);
    }
    _mint(to, shares);   // ERC20 _mint: the hook itself is the share token
}

_totalValueManaged is the function that has to be exactly right: it prices the live position (LiquidityAmounts.getAmountsForLiquidity at the current tick) and adds the hook's aToken balance (principal plus interest). Value the position at a stale tick, or forget the AAVE leg, and the share price is wrong - the vault-side version of the settlement bugs the pitfalls warn about. Withdrawal is the mirror: _burnShares burns the caller's shares and computes their pro-rata slice of TVM, which the unlock then sources from the position and from AAVE.

Allocation is where the split happens: add as much in-range liquidity as the deposit supports, settle it from the hook's reserves, then park the leftover in AAVE - straight from the hook's reserves, so no PoolManager delta is ever created:

function _allocate(PoolKey memory key, uint256 a0, uint256 a1) internal {
    PoolId id = key.toId();
    Managed storage m = managed[id];
    (uint160 sqrtPriceX96, int24 tick, , ) = poolManager.getSlot0(id);
 
    // Choose/refresh the range around the current tick (_rangeAround omitted).
    (m.tickLower, m.tickUpper) = _rangeAround(tick, key.tickSpacing);
 
    uint128 liq = LiquidityAmounts.getLiquidityForAmounts(
        sqrtPriceX96,
        TickMath.getSqrtPriceAtTick(m.tickLower),
        TickMath.getSqrtPriceAtTick(m.tickUpper),
        a0, a1
    );
 
    // Add the position; settle exactly what it costs from the hook's reserves.
    (BalanceDelta delta, ) = poolManager.modifyLiquidity(
        key,
        ModifyLiquidityParams(m.tickLower, m.tickUpper, int256(uint256(liq)), bytes32(0)),
        ""
    );
    uint256 used0 = delta.amount0() < 0 ? uint256(uint128(-delta.amount0())) : 0;
    uint256 used1 = delta.amount1() < 0 ? uint256(uint128(-delta.amount1())) : 0;
    if (used0 > 0) key.currency0.settle(poolManager, address(this), used0, false);
    if (used1 > 0) key.currency1.settle(poolManager, address(this), used1, false);
    m.liquidity += liq;
 
    // Whatever the position didn't consume is idle - park it in AAVE. These
    // tokens come from the hook's reserves, never via take(), so there's no
    // stray PoolManager delta to reconcile.
    if (a0 - used0 > 0) _supplyToAave(id, key.currency0, a0 - used0);
    if (a1 - used1 > 0) _supplyToAave(id, key.currency1, a1 - used1);
}
 
function _supplyToAave(PoolId id, Currency token, uint256 amount) internal {
    address asset = Currency.unwrap(token);
    IERC20(asset).approve(address(aave), amount);   // SafeERC20 in production
    aave.supply(asset, amount, address(this), 0);
    managed[id].idleToken = token;
    managed[id].idlePrincipal += amount;
}

Now the swap-driven dynamics. beforeSwap already runs inside the swap's unlock, so the hook can call modifyLiquidity directly here. If the swap is about to need the idle side, recall the principal from AAVE and put it back to work as liquidity:

function _beforeSwap(
    address,
    PoolKey calldata key,
    SwapParams calldata params,
    bytes calldata
) internal override returns (bytes4, BeforeSwapDelta, uint24) {
    PoolId id = key.toId();
    Managed storage m = managed[id];
    if (m.idlePrincipal == 0) return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
 
    (, int24 tick, , ) = poolManager.getSlot0(id);
    // Only recall if this swap could move the price into the range where the
    // idle side is needed. Precise trigger via SwapMath omitted; conservative
    // hooks just always recall when idlePrincipal > 0.
    if (!_swapNeedsIdle(m, tick, params)) {
        return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
    }
 
    // Pull the principal back from AAVE into the hook's reserves. aTokens
    // accrue interest, so the balance is >= idlePrincipal; we pull exactly the
    // tracked principal and leave the interest to compound (or harvest it
    // separately into the share price).
    aave.withdraw(Currency.unwrap(m.idleToken), m.idlePrincipal, address(this));
    m.idlePrincipal = 0;
 
    // Re-add it as liquidity and settle from reserves. We're inside the swap's
    // unlock, so the deltas net here - no bare settle, the tokens end up
    // backing the position the swap is about to hit.
    (uint160 sqrtPriceX96, , , ) = poolManager.getSlot0(id);
    uint128 liq = LiquidityAmounts.getLiquidityForAmounts(
        sqrtPriceX96,
        TickMath.getSqrtPriceAtTick(m.tickLower),
        TickMath.getSqrtPriceAtTick(m.tickUpper),
        currency0Reserves(), currency1Reserves()
    );
    (BalanceDelta delta, ) = poolManager.modifyLiquidity(
        key,
        ModifyLiquidityParams(m.tickLower, m.tickUpper, int256(uint256(liq)), bytes32(0)),
        ""
    );
    if (delta.amount0() < 0) key.currency0.settle(poolManager, address(this), uint128(-delta.amount0()), false);
    if (delta.amount1() < 0) key.currency1.settle(poolManager, address(this), uint128(-delta.amount1()), false);
    m.liquidity += liq;
 
    return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
}

After the swap, if the price has left the range the position is now 100% one token - idle. Unwind it, take the proceeds into reserves, and re-park them in AAVE. This runs in the same unlock as the swap, so the burn's deltas net cleanly:

function _afterSwap(
    address,
    PoolKey calldata key,
    SwapParams calldata,
    BalanceDelta,
    bytes calldata
) internal override returns (bytes4, int128) {
    PoolId id = key.toId();
    Managed storage m = managed[id];
    if (m.liquidity == 0) return (this.afterSwap.selector, 0);
 
    (, int24 tick, , ) = poolManager.getSlot0(id);
    if (tick > m.tickLower && tick < m.tickUpper) {
        return (this.afterSwap.selector, 0);   // still in range, fully active
    }
 
    // Out of range: remove the position, take the resulting single token into
    // reserves, then supply it to AAVE.
    (BalanceDelta delta, BalanceDelta fees) = poolManager.modifyLiquidity(
        key,
        ModifyLiquidityParams(m.tickLower, m.tickUpper, -int256(uint256(m.liquidity)), bytes32(0)),
        ""
    );
    if (delta.amount0() > 0) poolManager.take(key.currency0, address(this), uint128(delta.amount0()));
    if (delta.amount1() > 0) poolManager.take(key.currency1, address(this), uint128(delta.amount1()));
    _recordFees(id, fees);   // swap fees -> vault share price
    m.liquidity = 0;
 
    // The side the price left behind is the idle one.
    Currency idleSide = tick <= m.tickLower ? key.currency0 : key.currency1;
    uint256 amount = idleSide == key.currency0 ? currency0Reserves() : currency1Reserves();
    _supplyToAave(id, idleSide, amount);
 
    return (this.afterSwap.selector, 0);
}

Withdrawals are the mirror of deposits - burn shares, then unwind enough liquidity and recall enough from AAVE to cover the user's claim. The position-removal happens inside _deallocate (the Withdraw branch of unlockCallback), which is why withdraw opens its own unlock:

function withdraw(PoolKey calldata key, uint256 shares)
    external
    returns (uint256 amount0, uint256 amount1)
{
    // Burn shares and compute the pro-rata claim. Because share price already
    // includes AAVE interest, the claim is principal + the user's slice of
    // yield; math omitted.
    (amount0, amount1) = _burnShares(msg.sender, key.toId(), shares);
 
    // Unwind position + recall from AAVE so the hook holds amount0/amount1.
    poolManager.unlock(abi.encode(Action.Withdraw, key, amount0, amount1));
 
    // Pay the user from the hook's now-replenished reserves.
    if (amount0 > 0) IERC20(Currency.unwrap(key.currency0)).transfer(msg.sender, amount0);
    if (amount1 > 0) IERC20(Currency.unwrap(key.currency1)).transfer(msg.sender, amount1);
}
 
// _deallocate (Withdraw branch of unlockCallback, omitted for length): remove
// the slice of liquidity needed to cover the claim - take()-ing the proceeds
// into reserves - and aave.withdraw() any shortfall from the idle principal.
// No PoolManager delta is left dangling: every take() is balanced by the
// liquidity it removed, and AAVE withdrawals land directly in hook reserves.

Insight #3

The whole rehypothecation pattern hinges on predicting when the pool needs the idle token back. Recall too early and you give up yield; recall too late and the swap fails. The cleverness in production rehypothecation hooks isn't the AAVE integration - it's the prediction.

The pitfalls

The recall problem is hard. Knowing the swap is about to need 1,234 USDC from the idle side requires precise pricing maths in beforeSwap. Over-recall (pull everything from AAVE, just in case) wastes gas and gives up yield. Under-recall (pull only what you estimate the swap needs) risks insufficient tokens at settlement. Most production hooks lean conservative - withdraw everything when the price approaches the range edge - and accept the yield drag.

AAVE risk becomes pool risk. Depositing pool tokens into AAVE means the pool now has counterparty exposure to AAVE's solvency and liquidation mechanics. A bad debt event on AAVE could mean the pool can't recall enough tokens to honour withdrawals or swaps. This is the kind of risk that's invisible until it isn't.

Subtle accounting bugs pay out fantasy money. This pattern is exactly the one where the Bunni hook had a high-profile exploit - the Bunni DEX hack involved custom liquidity logic with accounting that didn't quite match what was in the pool, and the hook paid out tokens it didn't have a real claim on. If you build this pattern: every recall path needs invariants, every settlement needs reconciliation, and external audits are not optional.

Gas costs compound. Every swap that triggers a recall pays an AAVE withdrawal's gas. Every liquidity add or remove pays an AAVE deposit/withdraw. These add up - particularly on L1 - and need to be priced into LP fee shares so the hook is net-positive for users.

Where this pattern lives in production

Bunni v2 is the canonical implementation on V4 - they've publicly described their pool design as combining concentrated liquidity with rehypothecation to lending markets, and at peak achieved 100x volume/TVL ratios versus the equivalent vanilla pool. The hack was a real event and should inform your design choices, but it doesn't invalidate the pattern; rehypothecation is going to keep producing the highest yields on V4 LP capital for the foreseeable future, and the design space for doing it safely is still wide open.


Pattern 3: Auto-rebalancing positions

The third pattern is the one most "managed LP" protocols on V4 are doing. Rather than picking a range and hoping the price stays in it, the hook actively repositions liquidity as the price moves - keeping the position centred on (or near) the current price, where fees concentrate.

The economic claim is straightforward: a position centred on the current price earns dramatically more fees per dollar of capital than a position that's drifted out of range. Active management captures more of the curve's available revenue. The trade-off is that every rebalance pays gas to remove the old position and add the new one, plus some impermanent loss is realised in cash terms at each rebalance rather than left as a paper loss to recover from later.

The flow

The decision tree is the interesting part: a rebalance has to be triggered conservatively, or you're paying gas on every swap for marginal benefit. Common triggers are tick drift exceeds N bp from position centre, time since last rebalance exceeds T, or cumulative volume since last rebalance exceeds V. Tick drift is the most common.

The code

The hook holds the position and the bookkeeping:

contract AutoRebalanceHook is BaseHook {
    using StateLibrary for IPoolManager;
 
    int24 public constant REBALANCE_TICK_THRESHOLD = 200;  // 2% drift
    int24 public constant RANGE_HALF_WIDTH = 500;          // 5% range on each side
 
    struct ManagedPosition {
        int24 tickLower;
        int24 tickUpper;
        uint128 liquidity;
    }
    mapping(PoolId => ManagedPosition) public position;

Then the trigger check in beforeSwap:

function _beforeSwap(
    address,
    PoolKey calldata key,
    SwapParams calldata,
    bytes calldata
) internal override returns (bytes4, BeforeSwapDelta, uint24) {
    PoolId id = key.toId();
    ManagedPosition memory pos = position[id];
    if (pos.liquidity == 0) return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
 
    (, int24 currentTick, , ) = poolManager.getSlot0(id);
    int24 centre = (pos.tickLower + pos.tickUpper) / 2;
    int24 drift = currentTick > centre ? currentTick - centre : centre - currentTick;
 
    if (drift >= REBALANCE_TICK_THRESHOLD) {
        _rebalance(key, currentTick);
    }
 
    return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
}

And the rebalance itself - remove the old position, compute the new range, add the new one, and reconcile the deltas:

function _rebalance(PoolKey calldata key, int24 currentTick) internal {
    PoolId id = key.toId();
    ManagedPosition memory old = position[id];
 
    // 1. Burn the existing position.
    (BalanceDelta burnDelta, BalanceDelta feesAccrued) = poolManager.modifyLiquidity(
        key,
        ModifyLiquidityParams({
            tickLower: old.tickLower,
            tickUpper: old.tickUpper,
            liquidityDelta: -int256(uint256(old.liquidity)),
            salt: bytes32(0)
        }),
        ""
    );
 
    // Take the unwound principal + accrued fees back to the hook.
    if (burnDelta.amount0() > 0) {
        poolManager.take(key.currency0, address(this), uint128(burnDelta.amount0()));
    }
    if (burnDelta.amount1() > 0) {
        poolManager.take(key.currency1, address(this), uint128(burnDelta.amount1()));
    }
    // Hook-internal fee bookkeeping (same helper as the JIT pattern); the
    // burned position's accrued fees now sit in the hook's reserves and are
    // rolled into the new position below or credited to vault shares.
    _recordFees(id, feesAccrued);
 
    // 2. Compute the new range, centred on the current tick (aligned to spacing).
    int24 spacing = key.tickSpacing;
    int24 centred = (currentTick / spacing) * spacing;
    int24 newLower = centred - RANGE_HALF_WIDTH;
    int24 newUpper = centred + RANGE_HALF_WIDTH;
 
    // 3. Compute how much liquidity our reserves can support on the new range.
    (uint160 sqrtPriceX96, , , ) = poolManager.getSlot0(id);
    uint128 newLiquidity = LiquidityAmounts.getLiquidityForAmounts(
        sqrtPriceX96,
        TickMath.getSqrtPriceAtTick(newLower),
        TickMath.getSqrtPriceAtTick(newUpper),
        currency0Reserves(),
        currency1Reserves()
    );
 
    // 4. Mint the new position.
    (BalanceDelta mintDelta, ) = poolManager.modifyLiquidity(
        key,
        ModifyLiquidityParams({
            tickLower: newLower,
            tickUpper: newUpper,
            liquidityDelta: int256(uint256(newLiquidity)),
            salt: bytes32(0)
        }),
        ""
    );
 
    // Settle what the hook now owes.
    if (mintDelta.amount0() < 0) {
        key.currency0.settle(poolManager, address(this), uint128(-mintDelta.amount0()), false);
    }
    if (mintDelta.amount1() < 0) {
        key.currency1.settle(poolManager, address(this), uint128(-mintDelta.amount1()), false);
    }
 
    // 5. Store the new position.
    position[id] = ManagedPosition({
        tickLower: newLower,
        tickUpper: newUpper,
        liquidity: newLiquidity
    });
}

That's the whole loop. Every swap with sufficient tick drift triggers a rebalance; everything else passes through with no overhead beyond a getSlot0 read.

The pitfalls

Gas costs concentrate in volatile markets. Tight tick drift thresholds rebalance more frequently and capture more fees, but pay more gas. Wide thresholds save gas but drift further out of range. In volatile pools the rebalance frequency can spike to every few swaps, which gets expensive fast on L1. Some hooks gate rebalances by minimum-time-elapsed in addition to tick drift, just to put a cap on the worst-case cost.

The user pays for the rebalance. The swap that triggers the rebalance is the swap that pays its gas. That's an unpleasant surprise for whatever user happens to cross the drift threshold - their swap silently costs 300k more gas than the previous one. Production hooks sometimes amortise by maintaining a treasury that subsidises rebalance gas, or by deferring rebalance to a separate keeper transaction. Both add complexity.

Realised IL versus paper IL. A rebalance crystallises the impermanent loss the position has accumulated since the last rebalance - you sell some of the appreciated side and buy more of the depreciated side, locking in the loss. In a strongly trending market, frequent rebalancing can compound into real losses faster than the fees compensate for. Auto-rebalancing is most valuable in mean-reverting pools (stables, pegged assets, low-volatility correlated pairs) and most punishing in unidirectional trends.

MEV around the rebalance. A predictable rebalance is a predictable opportunity. Searchers who know your tick threshold can sandwich the rebalance trade, pushing the tick into the threshold themselves, profiting from the forced position change. The simplest defence is randomisation (don't rebalance every time the threshold is crossed; rebalance probabilistically) but that introduces its own behavioural inconsistencies.

Where this pattern lives in production

Arrakis Finance has built dynamic liquidity management hooks for V4, with auto-rebalancing as a core feature, including ML-guided range selection in their Pro Hook. Their V4 module documentation is worth reading if you're designing in this space. Other named protocols active in this pattern on V4 include A51 Carbon (multi-strategy LP via hooks), Aperture Finance (open-source hook implementations), and assorted "active LP" managers carrying over their v3 designs to V4. The pattern is also implicit in Maverick V4 deployments, where active liquidity is a core protocol feature rather than a hook-added layer.


Combining patterns

In production, these three patterns rarely live in isolation. The interesting hooks combine them.

Rehypothecation + auto-rebalancing is the most common combo. Bunni-style hooks pair concentrated liquidity (auto-managed) with yield on the idle side (rehypothecated). Each rebalance picks a new range; the old idle side gets recalled from AAVE, the new idle side gets re-deployed. Two patterns, one hook, much better LP economics than either alone.

JIT + auto-rebalancing is less common but exists. The hook holds a wide passive position that auto-rebalances slowly, then layers JIT positions on top of individual swaps to capture additional spread. The auto-rebalance covers steady-state earnings; the JIT layer captures opportunistic spikes.

JIT + rehypothecation is possible but rare. Reserves not currently deployed as JIT can sit in AAVE earning yield, withdrawn just-in-time alongside the JIT deposit. The recall coordination is finicky; few production hooks bother.

The shared lesson: dynamic liquidity is compositional. Once your hook is comfortable doing one of these patterns cleanly - with the settlement plumbing and the reconciliation invariants you need - adding another isn't fundamentally harder. The constraint is usually gas budget and design complexity, not protocol limitation.


The shortfall that hits all three

One trade-off sits underneath every dynamic liquidity hook, and students often discover it the hard way: dynamism costs gas, and gas costs grow non-linearly with cleverness.

A passive concentrated liquidity position has roughly zero per-swap overhead - the swap touches the position via the AMM curve and that's it. A dynamic position pays gas at every decision point: getSlot0 reads, modifyLiquidity calls, AAVE deposit/withdraw round trips, settlement loops, transient storage reads and writes. Individually each one is small. Cumulatively they can make your hook unprofitable on small swaps, especially on L1.

The way the strongest production hooks deal with this is graduated complexity: do the cheap checks early, gate the expensive operations on whether the cheap checks pass. A beforeSwap that returns immediately for 95% of swaps and only does real work for the 5% that meet some threshold is dramatically cheaper than one that does even moderate work on every swap. Build your hooks the same way.

The other shortfall - security - applies even more sharply. Every dynamic-liquidity pattern adds custom accounting on top of the protocol's native flow, and custom accounting is where the bugs that lose money live. Audit budgets for hooks doing this kind of work are not small for a reason.

Insight #4

Dynamic liquidity hooks are the highest-yield and highest-risk hooks in the V4 ecosystem. The patterns work; the protocols winning with them are winning meaningfully. But the gap between a hook that looks like one of these patterns and a hook that's safe to deploy is wide. Plan for it.


Recap

  1. Dynamic liquidity hooks are just hooks that call modifyLiquidity at moments the protocol doesn't normally call it, and handle the resulting balance deltas. The "dynamism" is in when and where the tokens live between operations.
  2. JIT liquidity adds a tight position in beforeSwap and removes it in afterSwap, capturing a slice of swap fees on individual trades. Best in pools with thin passive liquidity; sandwich-vulnerable on public mempools.
  3. Rehypothecation deploys idle (out-of-range) tokens to a lending market like AAVE for yield, recalling them when the pool needs them. Highest production yields on V4 LP capital; also the hardest pattern to get the accounting right on.
  4. Auto-rebalancing keeps positions centred on the current price, capturing more fees per dollar of capital. Realises IL at each rebalance, so best for mean-reverting pairs.
  5. The patterns compose. Most production dynamic-liquidity hooks combine two or three.
  6. The shared trade-off is gas and risk. Cheap checks first, expensive operations gated. Audits are not optional.

  • Hook Data Standards - dynamic-liquidity hooks bypass enough of the protocol's native events that emitting HookSwap, HookModifyLiquidity, and friends is essential for staying visible to indexers. The standards article covers exactly which events go where.
  • Bunni's research write-up by Auditless - a thoughtful production-side perspective on the rehypothecation pattern and what made Bunni's design work.
  • Arrakis V4 module docs - for the auto-rebalancing pattern, run by a team that's been doing managed LP since v3.
  • The v4-core and v4-hooks-public repos - StateLibrary, LiquidityAmounts, CurrencySettler, and BaseHook are the four pieces you'll lean on most heavily for any of the patterns above.
  • awesome-uniswap-hooks - the running catalogue of V4 hook implementations, including JIT and rebalancing examples in the wild.

If you're shipping a dynamic-liquidity hook and want a second pair of eyes on the accounting, reach out on X and let me know.