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: rehypothecation into vetted money markets and vaults. Each leg’s book splits into a liquid slice serving swaps, withdrawals and flash loans and an invested slice held at the venue (dual ledger). A swap the liquid slice covers makes no external call, so a hooked leg costs roughly unhooked gas on the hot path. Only rebalance invests; a keeper sync books venue gains to the liquidity index.
1. Callback surface
IPoolHooks is one void callback. preOutflow covers every liquid shortfall: swap-out, withdraw, flash. Deposits, swap-ins and donate never call the hook: the only invest path is rebalance.
interface IPoolHooks {
/// Liquidate venue → pool when R_liq < amountNeeded.
/// Pool fails closed if still short after this call.
function preOutflow(
address pool,
address sender,
address token,
uint256 amountNeeded
) external;
}There are no other callbacks, no fee-override surface, no ERC-165 and no bytes data parameter.
Abstract YieldHook implements it, plus the liquidity curve, invest, liquidate, sync, rebalance, the two incentive claims and the incentive sweep. A venue adapter fills in the venue surface: three functions it must implement, two it should. _navAssets() is not one of them: it is the venue position plus idle underlying on the hook.
// 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 _venueAssets() internal view virtual returns (uint256); // the venue position only
// Defaulted: override when the venue can refuse.
function _maxWithdrawable() internal view virtual returns (uint256); // default: _venueAssets()
function _maxDepositable() internal view virtual returns (uint256); // default: type(uint256).maxSkipping
_maxDepositablewedges the keeper. A venue that refuses the invest reverts the wholerebalance, sync and liquidate with it. Venues do refuse: ERC-4626 must revert pastmaxDeposit, Aave raisesSUPPLY_CAP_EXCEEDED/RESERVE_FROZEN, Compound returns a non-zero code. Clamping keeps the capital liquid when the venue is full.
AaveV3YieldHook,ERC4626YieldHookandCompoundV2YieldHookall override it.MorphoBlueYieldHookdoes not: a Blue market has no supply cap and no pause. Override it for any venue that can refuse.
_maxWithdrawable defaults to _venueAssets(); override it when the venue can gate redemption (utilization caps, withdrawal queues).
A hook is bound 1:1 to the pool and token in its clone args (§5) and must reject any preOutflow caller that is not that pool.
1.1. Flags and storage
| Flag | Bit | Value |
|---|---|---|
HOOK_PRE_OUTFLOW | 0 | 1 |
HOOK_RECALL_ONLY | 2 | 4 |
HOOK_FLAGS_MASK is HOOK_PRE_OUTFLOW: an install with any other bit, the retired deposit bit 1 included, reverts InvalidInput. HOOK_RECALL_ONLY is never installed: a partial clearAssetHook latches it, and hookInvest refuses the slot until a re-install lifts it. While invested != 0 you cannot clear the hook, soft-clear it (flags = 0), or drop HOOK_PRE_OUTFLOW.
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: every read and write of it is adjacent to the slot. Seeded at asset init, 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 draw on
Invest and liquidate permute the two; is constant on either. Yield and loss move through hook-gated writers on Pool, each taking (address token, uint256 amount):
| Writer | Effect |
|---|---|
hookCreditYield | raises by the realised amount and by face amount · 1e18 / C, leaving and unchanged: a donate at the pool rate. Requires the tokens on the pool first, rate-bucketed, reverts FeedUnavailable while a roster mark is unusable; not bounded by depositCapCode |
hookWriteDown | cuts and ; cuts by ceil(loss / C) and the index with it, leaving unchanged; reverts FeedUnavailable while a roster mark is unusable |
hookInvest | pushes liquid → hook, books up; refused on a halted or recall-only leg |
hookLiquidate | books down after a keeper-path redeem, against a balance proof |
msg.sender must be the registered HookSlot.target. hookInvest requires HOOK_PRE_OUTFLOW, so invested capital always has a liquidate path, and never leaves below minLiquidity. The swap-side liquidate books by the pool’s balance delta around preOutflow, not through hookLiquidate.
3. Dispatch and gas
Direct CALL from the orchestrators (PricingLib, PoolLiquidityLib; flash never recalls). 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 → liquidate; fail closed if still short |
| Event | Hook |
|---|---|
| Swap, withdraw or flash shortfall | preOutflow |
| Deposit, swap-in, donate | none |
| Keeper or owner | rebalance() on the hook: the only invest path |
Pricing uses ; executable capacity is . A quote assuming instant fill of the full may need a liquidate at settlement when the liquid slice is thin. A flash loan recalls nothing: maxFlashLoan is (Flash). Never call convertToAssets, exchangeRate or balanceOfUnderlying on the swap hot path; strategy NAV is read only on keeper sync and liquidate sizing.
4. Admin
| Op | Timing |
|---|---|
requestOp(UPDATE_HOOK) → executeUpdateHook | LISTING tier, 1 day under PROD_DELAYS |
cancelOp(UPDATE_HOOK) | Immediate; owner or guardian on an unseated pool, the seat on a seated one |
UPDATE_HOOK with hook 0 | Timelocked force-clear (adminForceClearHook): books the whole invested tranche as lost at , then drops the hook. The exit from a hook that refuses recall |
clearAssetHook | Immediate, steward or owner: sync, void a queued install, liquidate what the venue returns; all back drops the hook, a remainder leaves it recall-only (HOOK_RECALL_ONLY) |
invested == 0 gates replacement. Pointing a leg at a different hook reverts InvalidState while invested != 0; the check is hook != prev && invested != 0. Swapping venues is two steps: drive invested to zero (an all-liquid curve, then rebalance), then land the UPDATE_HOOK. Re-writing the same target with new flags is allowed while invested, except HOOK_PRE_OUTFLOW, which cannot be dropped: invested capital must always retain a liquidate path.
The pool-side entrypoints are adminSetAssetHook(token, hook, flags) and adminClearAssetHook(token, keep), both onlyDelegateCall and callable only by Admin. 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 vaults are ERC-4626 and run through ERC4626YieldHook.
There is one implementation per venue kind and one EIP-1167 clone per (pool, leg). The clone’s immutable args are 32-byte words read from its own code, not storage: AC, pool, token, then the adapter’s venue words (Aave: pool, aToken, rewards controller; ERC-4626: vault). No implementation carries a chain-specific byte, so the same implementation address serves every chain, and which venue a leg uses is set by its clone’s args.
A clone is inert until initialize(), which anyone may call once. It reads only the args and the venue (token() matches the venue’s underlying, the aToken matches the Aave reserve), installs the default curve, credit cap and cache age, and every state-changing entry reverts before it. A front-runner can therefore only do what the deployer would. The implementation itself refuses initialize and stays dead. Never wire a live mainnet venue’s underlying to a testnet pool.
Morpho Blue NAV: _navAssets reads the position through virtual shares, rounded down, and does not simulate IRM accrual, so the navAssets() view lags between market touches. sync and rebalance call accrueInterest first (Morpho Blue and Venus alike), so what they credit is current.
5.1. Liquidity curve
Each hook serves one asset and carries one curve: the target liquid share of the leg as a function of its reserves (token units), with .
curve() returns tMin, t0 (token units), l0Bps, lMinBps, wBps and maxInvested (token units), the cap on the invested book. initialize() installs the owner’s calibration for a $1 token: tMin 10k, t0 1M, 10%, 5%: nothing invested under 10k, the flow-fit envelope below 1M (17.7% at 150k), 10% at 1M, then log-linear and continuous down to 5% (the lMin floor) at 10M, band = 30%. It sets maxInvested to 0, so a fresh hook invests nothing until the owner lands a curve with a cap. The target liquid amount is ; minLiquidity stays the pool’s absolute floor.
| Liquid | Keeper rebalance |
|---|---|
| invest back to | |
| inside | nothing moves |
| , or with capital invested | liquidate back to |
Invested therefore stays at or under of the leg between keeper runs, and never above maxInvested: an invest stops at the cap, and a book above it (the cap lowered) liquidates down to it. rebalanceAmount() returns that move, (trim, amt), with maxInvested applied and before the venue-side caps of §5.4; keepers read it rather than re-derive the band. A swap served from the liquid slice never reads the curve.
A swap, withdraw or flash the liquid slice cannot cover liquidates the shortfall, refilled to of the post-outflow reserves: max(shortfall, invested − (1 − L) × (R − outflow)), capped at invested, idle token on the hook first, then the venue up to its withdrawable. A one-way run pays one venue redeem per band instead of one per swap. The Venus adapter refills only while the comptroller’s treasuryPercent (a fee on every redeem) is 0; otherwise it liquidates the shortfall alone. The keeper rebalance and the swap share the same liquidate and invest paths.
| Who | Curve lever |
|---|---|
| Seat | requestCurve: no point of lower, the band no wider and maxInvested no higher (every param but wBps and maxInvested ≥ current, those two ≤ current) lands at once and drops a queued raise; anything else, a raised cap included, queues for riskUpDelay() (LISTING) and lands via executeCurve. |
| Guardian or seat | cancelRequests drops every queued raise. |
5.2. Sync, cache age and write-downs
Venues compound in place; nothing is harvested. The pool books the position at invested, a cache refreshed by sync(): it reads venue NAV and books the difference, with no venue withdraw.
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.0disables crediting.nav < book→hookWriteDown, cutting and , and the leg’s and index at the pool rate, so the loss stays on this leg.lastSyncresets.
rebalance() syncs first, then invests or liquidates when the leg sits out of band, so every keeper move refreshes the cache. A swap-side liquidate books nothing and leaves lastSync alone.
| Adapter | Credit |
|---|---|
| Aave V3 | Book-only: invested and rise, no token moves. The aToken balance is scaled balance × liquidity index, not a cash read. |
| Venus / Compound V2 | Book-only, at the accrued exchange rate. A donation to the market lifts the rate for every holder: the donor pays at least the booked credit over our supply share, inside the caps. |
| ERC-4626, Morpho Blue | Realized: withdraw the credit to the pool, book it, then hookLiquidate it to liquid against the balance proof. Their NAV reads can be moved inside one transaction. |
A book-only credit raises invested, so a later liquidate or clear pulls principal plus booked yield.
maxCacheAge (default 6 h, bounds 1 h to 24 h) is the age past which booked yield counts as stale; stale() reports it. The seat, or the AC risk steward on a protocol pool, sets it instantly within bounds, and keepers read it from the hook rather than carry a cadence of their own. The keeper sends rebalance when rebalanceAmount() is non-zero, on a loss, on a gain past its threshold, or once maxCacheAge has elapsed, and pages when a hook makes no progress for 4 × maxCacheAge. Unbooked yield older than the TTL is a small transfer from holders to anyone depositing before the next sync; the credit caps bound that same window from the other side.
On a halted leg the gain waits for the un-halt (hookCreditYield refuses the leg); loss booking and the liquidate stay live, and an invest is skipped. rebalance skips the credit, and sync() reverts InvalidState so that clearAssetHook latches recall-only rather than detach a hook still holding the gain. The keeper never syncs a halted leg.
The adapter cap binds keeper, guardian, steward and owner syncs. Admin’s sync inside clearAssetHook drops it, and so does any sync at invested == 0, where it would read 0; the pool cap still applies. Admin’s sync also reverts while venue value above liabilities / 1e9 stays uncredited (pool cap spent, or a dry venue on a realizing adapter), so a clear detaches only an emptied hook. A halted leg’s gain credits on the first clear after the un-halt. If the leg’s dust is below the venue’s redeem floor the slot can stay latched; the force-clear (§4) is the exit.
A rate, not a per-call allowance: a second sync in the same block credits nothing, unused allowance accrues, and a genuine gain still lands in full given enough elapsed time. A campaign dumping a large one-shot gain into the venue is credited over days rather than in one block. hookCreditYield clamps dt to one day.
Hot-path preOutflow liquidates under book and venue-cash constraints; it does not re-price NAV into the pool books.
requestWriteDown(uint256) is the owner-only manual path for a loss the automatic path cannot see.
5.3. Venue incentives
Three calls move reward tokens. claimMerkl(address[] tokens, uint256[] amounts, bytes32[][] proofs) claims Merkl rewards to the hook. Merkl pays the hook address or its operator, so only the hook can claim for itself; the call forwards the proofs to Merkl’s distributor with the hook as the claimant. The distributor is one constant address on every chain with no setter, so a claim cannot be pointed elsewhere. claimVenueIncentives(bytes data) is the venue-native claim and nothing else: Aave’s RewardsController (data = abi.encode(address[] assets), empty claims the aToken’s) and Venus’s claimVenus; it is a no-op on a venue with no program of its own, ERC-4626 included. sweepIncentives(address[] rewardTokens) pushes non-underlying, non-position-token balances to pool.treasury(), skipping the aToken, cToken or ERC-4626 share; the hook has no receiver of its own.
Rewards leave the hook only through the sweep. One paid in the leg’s own token counts as NAV and is credited to LPs by sync under the credit caps.
Adapters never swap rewards, so reward tokens do not reach LPs on their own: routing that value back is a treasury decision, executed as a donate (Incentivization §3).
| Authority | Calls |
|---|---|
| Keeper or owner | rebalance, claimMerkl, claimVenueIncentives, sweepIncentives |
Keeper, guardian, risk steward, owner or Admin | sync |
| Owner or risk steward | setMaxCacheAge |
| Guardian or owner | cancelRequests |
| Owner only | requestCurve, executeCurve, requestHarvestCreditCap, executeHarvestCreditCap, requestWriteDown, executeWriteDown |
There is no per-hook receiver setter and no distributor setter. The reward sink is the pool’s treasury(), which the owner rotates on the GOVERNANCE tier, so redirecting incentives carries the same 7-day notice as any other pointer move.
5.4. Position caps
Three bounds apply to an invest, on top of the curve:
| Bound | Rule |
|---|---|
maxInvested | Curve field, token units. Starts at 0; raising it queues for riskUpDelay(), lowering it is instant and trims the book back. |
| Venue cash | The position, held plus the new invest, stays within 5% of the venue’s live cash. The hook reads cash and total from the venue (Aave: the reserve’s virtual underlying balance and the aToken supply; ERC-4626: totalAssets(), or the Euler EVK cash() where the vault has one). |
| Venue utilization | No invest lands while utilization, 1 − cash/total, is above 95%. The call skips silently and the rest of rebalance (sync, loss booking, liquidate) still runs. |
A venue that reports no total (empty, or no view) is unconstrained by the last two; maxInvested and the venue’s own supply cap still bind. An invest below 1e-6 of venue cash is skipped as dust, since a share-based venue rounds it to zero shares.
The cash cap is checked at invest time only. A later borrow run drains venue cash and leaves the position above 5% until the next liquidate; there is no utilization trim, only the keeper’s own band moves and the swap-side liquidate. The 5% bound sizes how large a position the venue can pay out, it does not guarantee it.
6. Related
- Pool · Admin · Flash
- Inventory Management: reserves and coverage
- Pool Deployment & Curation · Incentivization
- Security Overview