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.
preOutflowunifies every liquid shortfall: swap-out, withdraw, flash.postInflowstays action-specific, because only LP deposits dispatch an inflow hook: swap-in anddonatedo 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
_maxDepositableis the dangerous one to skip.Pool.depositcallspostInflowwith 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 pastmaxDeposit, Aave raisesSUPPLY_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,ERC4626YieldHookandCompoundV2YieldHookall override it.MorphoBlueYieldHookdeliberately 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
| Flag | Bit | Value |
|---|---|---|
HOOK_PRE_OUTFLOW | 0 | 1 |
HOOK_POST_INFLOW | 1 | 2 |
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:
- =
Asset.reserves, the full economic book that pricing and coverage read - =
invested[token], capital at the venue - , on-pool cash: what flash and outflow actually draw on
Recall and deploy permute the two; is constant on a pure recall. Yield and loss move through hook-gated writers on Pool, each taking (address token, uint256 amount):
| Writer | Effect |
|---|---|
hookCreditYield | raises , and : an index donate |
hookWriteDown | cuts and ; haircuts and the index |
hookDeploy | pushes liquid → venue, books up |
hookRecall | books 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 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.
| Path | Behaviour |
|---|---|
| covers the need | 0 calls, no HookSlot SLOAD |
Shortfall, flag off or target == 0 | Fail closed |
Shortfall with HOOK_PRE_OUTFLOW | 1 call → hard recall; fail closed if still short |
| Event | Hook |
|---|---|
| Swap, withdraw or flash shortfall | preOutflow |
| Deposit (optional) | postInflow |
| Keeper or owner | rebalance() on the hook |
Deploy is never on the swap path.
Pricing uses ; executable capacity is . A quote that assumes instant fill of the full may need a recall at settlement when the liquid slice is thin. flashPrepare recalls amount + minLiquidity, and maxFlashLoan is . Never call convertToAssets, exchangeRate or balanceOfUnderlying on the swap hot path; strategy NAV is read only on keeper harvest and recall sizing.
4. Admin
| Op | Timing |
|---|---|
requestOp(UPDATE_HOOK) → executeSetAssetHook | HIGH tier, 3 days under PROD_DELAYS |
cancelTimelock(UPDATE_HOOK) | Immediate |
clearAssetHook | Immediate; 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
| Adapter | Family | Venue examples |
|---|---|---|
CompoundV2YieldHook | Compound V2-like (cToken) | Venus, Moonwell, Flux, Benqi |
AaveV3YieldHook | Aave V3-like (aToken) | Aave V3, SparkLend, HyperLend |
ERC4626YieldHook | ERC-4626 vault | Morpho Vaults, Fluid fToken, sUSDS, Spark Savings |
MorphoBlueYieldHook | Morpho Blue loan-asset supply | Isolated 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 exits the band, and _deploy never leaves below minLiquidity. Sized so most outflows fit in the liquid slice.
5.2. Harvest and write-downs
rebalance() is keeper-or-owner and cold:
- Read venue NAV once, never on a swap.
nav > book→hookCreditYield, capped as a rate: at mostbook × capBps × dt / (BPS × 1 days). DefaultDEFAULT_MAX_HARVEST_CREDIT_BPS = 100BPS/day, hard ceilingMAX_HARVEST_CREDIT_BPS = 500, and the pool independently enforcesMAX_HOOK_CREDIT_BPS_PER_DAY = 500. Setting it to0disables crediting.nav < book→hookWriteDown, cutting and and haircutting the index.- Then
_deployor_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 topool.treasury(), or toincentivesReceiverwhen 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.
6. Related
- Pool · Admin · Flash
- Inventory Management: reserves and coverage
- Pool Deployment & Curation · Incentivization
- Security Overview