---
title: "Pool Hooks"
description: "Farm idle pool capital via YieldHook adapters: callback surface, dual ledger, buffer, harvest cap"
audience: tech
type: explanation
status: live
lang: en
updated: "2026-08-30"
publish: true
---
# 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.

```solidity
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**:

```solidity
// 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

| 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.

```solidity
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 = R_{\mathrm{liq}} + R_{\mathrm{inv}}$$

- $R$ = `Asset.reserves`, the full economic book that pricing and coverage read
- $R_{\mathrm{inv}}$ = `invested[token]`, capital at the venue
- $R_{\mathrm{liq}} = R - R_{\mathrm{inv}}$, 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)`:

| Writer | Effect |
|---|---|
| `hookCreditYield` | raises $R$, $L$ and $R_{\mathrm{inv}}$: an index donate |
| `hookWriteDown` | cuts $R_{\mathrm{inv}}$ and $R$; haircuts $L$ and the index |
| `hookDeploy` | pushes liquid → venue, books $R_{\mathrm{inv}}$ up |
| `hookRecall` | books $R_{\mathrm{inv}}$ 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 $R_{\mathrm{liq}}$ 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 |
|---|---|
| $R_{\mathrm{liq}}$ 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 $R$; executable capacity is $R_{\mathrm{liq}}$.** 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 $R_{\mathrm{liq}} - \mathrm{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

| 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](/docs/1-2-3-admin) and [Pool Deployment & Curation §2.2](/docs/5-1-2-pool-deployment-curation#22-ownership-model).

---

## 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 $R_{\mathrm{inv}}$ exits the band, and `_deploy` never leaves $R_{\mathrm{liq}}$ 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 > book` → `hookCreditYield`, 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 < book` → `hookWriteDown`, cutting $R_{\mathrm{inv}}$ 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](/docs/5-3-3-incentivization#3-donations)).

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](/docs/1-2-1-pool) · [Admin](/docs/1-2-3-admin) · [Flash](/docs/1-2-4-flash)
- [Inventory Management](/docs/1-1-1-inventory-management): reserves and coverage
- [Pool Deployment & Curation](/docs/5-1-2-pool-deployment-curation) · [Incentivization](/docs/5-3-3-incentivization)
- [Security Overview](/docs/3-overview)
