Pool Hooks

A hook lends a leg’s idle capital to a curated external venue while the pool keeps pricing against its full economic reserves. Each leg’s book splits into a liquid slice (which serves swaps, withdrawals and flash loans) and an invested slice held at the venue. A swap the liquid slice covers makes no external call at all, so a hooked leg costs roughly unhooked gas on the hot path.

Idle cash beyond the liquid buffer earns nothing on-pool; hooks rehypothecate that slice into vetted money markets and vaults, and a keeper harvest credits the gains to the LP index.


1. Callback surface

IPoolHooks is two void callbacks. Naming is strictly pre/post.

  • preOutflow unifies every liquid shortfall: swap-out, withdraw, flash.
  • postInflow stays action-specific, because only LP deposits dispatch an inflow hook: swap-in and donate do not.
interface IPoolHooks { /// Recall invested → liquid when R_liq < amountNeeded. /// Pool fails closed if still short after this call. function preOutflow( address pool, address sender, address token, uint256 amountNeeded ) external; /// Optional deploy after a deposit. Pool pre-approves the liquid book; /// the hook books its take via the balance delta. function postInflow( address pool, address sender, address token, uint256 amountIn, uint256 lpMinted ) external; }

There are no other callbacks, no fee-override surface, no ERC-165 and no bytes data parameter.

Abstract YieldHook implements both, plus buffer, recall, harvest, rebalance, venue incentive claims and the incentive sweep. A venue adapter fills in the venue surface, three functions it must implement, and two it should:

// Abstract: no default. The adapter MUST implement all three. function _venueDeposit(uint256 assets) internal virtual; function _venueWithdraw(uint256 assets) internal virtual returns (uint256 received); function _navAssets() internal view virtual returns (uint256); // Defaulted: override when the venue can refuse. function _maxWithdrawable() internal view virtual returns (uint256); // default: _navAssets() function _maxDepositable() internal view virtual returns (uint256); // default: type(uint256).max

_maxDepositable is the dangerous one to skip. Pool.deposit calls postInflow with no try/catch, so a venue that refuses a deposit propagates its revert all the way out and bricks deposits for the whole asset until the owner rewires the buffer. Venues do refuse: ERC-4626 must revert past maxDeposit, Aave raises SUPPLY_CAP_EXCEEDED / RESERVE_FROZEN, Compound returns a non-zero code. Clamping to it turns “venue full” into “stay liquid”, which is the correct reading anyway.

The shipped AaveV3YieldHook, ERC4626YieldHook and CompoundV2YieldHook all override it. MorphoBlueYieldHook deliberately does not: a Blue market has no supply cap and no pause. If you write an adapter for a venue that can ever refuse, override it.

_maxWithdrawable defaults to _navAssets(); override it when the venue can gate redemption (utilization caps, withdrawal queues).

A hook is bound 1:1 to an immutable pool and must reject any caller that is not it.

1.1. Flags and storage

FlagBitValue
HOOK_PRE_OUTFLOW01
HOOK_POST_INFLOW12

HOOK_FLAGS_MASK is both. Unknown bits are rejected at setAssetHook. While invested != 0 you cannot clear the hook, soft-clear it (flags = 0), or drop HOOK_PRE_OUTFLOW. Recommended flags are both.

struct HookSlot { address target; // address(0) = disabled uint32 flags; uint32 lastCreditAt; // rate-bucket clock for hookCreditYield }

One SLOAD covers all three. lastCreditAt lives in the slot’s free tail because every read and write of it is adjacent to the slot itself; it is seeded at asset init and reset on setAssetHook, so a late hook install cannot harvest a multi-day phantom bucket.


2. Dual ledger

Economic reserves stay one number for pricing and coverage. Executable cash is a slice of it:

R=Rliq+Rinv

  • R = Asset.reserves, the full economic book that pricing and coverage read
  • Rinv = invested[token], capital at the venue
  • Rliq=R-Rinv, on-pool cash: what flash and outflow actually draw on

Recall and deploy permute the two; R is constant on a pure recall. Yield and loss move R through hook-gated writers on Pool, each taking (address token, uint256 amount):

WriterEffect
hookCreditYieldraises R, L and Rinv: an index donate
hookWriteDowncuts Rinv and R; haircuts L and the index
hookDeploypushes liquid → venue, books Rinv up
hookRecallbooks Rinv down after a keeper-path redeem

msg.sender must be the registered HookSlot.target. Deploy paths require HOOK_PRE_OUTFLOW, so invested capital always has a recall path. Neither hookDeploy nor YieldHook._deploy may leave Rliq below minLiquidity.


3. Dispatch and gas

Direct CALL from the orchestrators (Pricing, PoolLiquidity, Pool.flashPrepare). No DELEGATECALL trampoline. The liquid check runs before the HookSlot SLOAD on the typical path.

PathBehaviour
Rliq covers the need0 calls, no HookSlot SLOAD
Shortfall, flag off or target == 0Fail closed
Shortfall with HOOK_PRE_OUTFLOW1 call → hard recall; fail closed if still short
EventHook
Swap, withdraw or flash shortfallpreOutflow
Deposit (optional)postInflow
Keeper or ownerrebalance() on the hook

Deploy is never on the swap path.

Pricing uses R; executable capacity is Rliq. A quote that assumes instant fill of the full R may need a recall at settlement when the liquid slice is thin. flashPrepare recalls amount + minLiquidity, and maxFlashLoan is Rliq-minLiquidity. Never call convertToAssets, exchangeRate or balanceOfUnderlying on the swap hot path; strategy NAV is read only on keeper harvest and recall sizing.


4. Admin

OpTiming
requestOp(UPDATE_HOOK)executeSetAssetHookHIGH tier, 3 days under PROD_DELAYS
cancelTimelock(UPDATE_HOOK)Immediate
clearAssetHookImmediate; requires invested == 0

invested == 0 gates replacement, not just clearing. Pointing a leg at a different hook reverts InvalidState while invested != 0; the check is hook != prev && invested != 0. So swapping venues is a two-step operation: recall everything first (drive invested to zero via rebalance/_trimToTarget), then land the UPDATE_HOOK. Re-writing the same target with new flags is allowed while invested, except that HOOK_PRE_OUTFLOW cannot be dropped: invested capital must always retain a recall path.

Install rides the custody tier rather than the listing tier because a hook takes fund custody. Pool-side trampolines are adminSetAssetHook(token, hook, flags) and adminClearAssetHook(token), both onlyAdmin. See Admin and Pool Deployment & Curation §2.2.


5. YieldHook family

AdapterFamilyVenue examples
CompoundV2YieldHookCompound V2-like (cToken)Venus, Moonwell, Flux, Benqi
AaveV3YieldHookAave V3-like (aToken)Aave V3, SparkLend, HyperLend
ERC4626YieldHookERC-4626 vaultMorpho Vaults, Fluid fToken, sUSDS, Spark Savings
MorphoBlueYieldHookMorpho Blue loan-asset supplyIsolated Morpho Blue markets

Euler V2 Earn vaults are ERC-4626 and run through ERC4626YieldHook. Liquity-style CDPs are out of scope; Felix Vanilla qualifies only as a Morpho Vault through ERC4626YieldHook.

None of the shipped hooks carries a compiled-in venue address: venues are constructor arguments. Whether a hook is wired, and to which venue, is per-chain, per-pool configuration. Never wire a live mainnet venue’s underlying to a testnet pool.

Morpho Blue NAV caveat. _navAssets uses SharesMathLib virtual shares (toAssetsDown) and does not simulate IRM interest accrual in view; that would need IIrm.borrowRateView. Between market interactions a harvest may see lastUpdate-stale totals. Harvest after a Morpho touch, or accept understated NAV until the next accrue.

5.1. Buffer

Default target is DEFAULT_TARGET_INVESTED_BPS = 6500 (65% invested, 35% liquid) with DEFAULT_HYSTERESIS_BPS = 500 either side. setBuffer(targetInvestedBps, hysteresisBps) is owner-only. Deploy and trim fire only when Rinv exits the band, and _deploy never leaves Rliq below minLiquidity. Sized so most outflows fit in the liquid slice.

5.2. Harvest and write-downs

rebalance() is keeper-or-owner and cold:

  1. Read venue NAV once, never on a swap.
  2. nav > bookhookCreditYield, capped as a rate: at most book × capBps × dt / (BPS × 1 days). Default DEFAULT_MAX_HARVEST_CREDIT_BPS = 100 BPS/day, hard ceiling MAX_HARVEST_CREDIT_BPS = 500, and the pool independently enforces MAX_HOOK_CREDIT_BPS_PER_DAY = 500. Setting it to 0 disables crediting.
  3. nav < bookhookWriteDown, cutting Rinv and R and haircutting the index.
  4. Then _deploy or _trimToTarget.

A rate, not a per-call allowance: a second harvest in the same block credits nothing, unused allowance accrues, and a genuine gain still lands in full given enough elapsed time. That is a stronger anti-sandwich bound than any per-harvest cap, and it means a campaign dumping a large one-shot gain into the venue is credited over days rather than in one block. hookCreditYield also clamps dt to one day.

Hot-path preOutflow recalls under book and venue-cash constraints; it does not re-price NAV into the pool books.

forceWriteDown(uint256) is an owner-only escape hatch for a loss the automatic path cannot see.

5.3. Venue incentives

Two calls move reward tokens:

  • claimVenueIncentives(bytes data) lands reward tokens on the hook: the base route is a Merkl proof-carrying claim built off chain, and the Aave and Compound adapters override with their native claim.
  • sweepIncentives(address[] rewardTokens) pushes non-underlying, non-position-token balances to pool.treasury(), or to incentivesReceiver when set; it skips aToken, cToken and ERC-4626 shares.

Adapters never swap rewards. Reward tokens do not reach LPs on their own: routing that value back is a treasury decision, executed as a donate (Incentivization §3).

Authority splits three ways:

  • Keeper or owner: rebalance, claimVenueIncentives, sweepIncentives.
  • Owner only: setBuffer, setMaxHarvestCreditBps, setMerklDistributor, forceWriteDown.
  • AccessControl.treasuryOwner(), not the param owner: setIncentivesReceiver, because it redirects custody.