Skip to content
0xSlots

Hook reference

The interfaces a hook implements. See the concept for the model; this is the surface.

A slot calls exactly one hook. ISlotHook is required; IDescribedHook is an optional, client-only extension for rendering.

ISlotHook

interface ISlotHook {
    function subscriptions() external view returns (HookFlags memory);
    function validateHookData(bytes32 data) external view;
 
    // decisions — view, revert to veto
    function beforeBuy(SlotContext calldata ctx) external view;
    function beforeSelfAssess(SlotContext calldata ctx) external view;
 
    // effects — gas-capped and revert swallowed, unless `strict` is declared
    function afterBuy(SlotContext calldata ctx) external;
    function afterRelease(SlotContext calldata ctx) external;
    function afterLiquidate(SlotContext calldata ctx) external;
    function afterSettle(SlotContext calldata ctx) external;
}

subscriptions() is view, not pure: a hook may answer it from storage. It is read once at attach and snapshotted, so a hook cannot widen its reach mid-tenure.

validateHookData is read at the same moment and for the same reason. hookData is opaque to the slot, so only the hook can say whether a given word means anything; refusing it here is the only chance anyone gets, because before is fail-closed and a hook that rejects its own configuration vetoes every buy forever. A hook that takes no configuration implements it as a no-op and thereby accepts anything — say so deliberately rather than by omission.

SlotContext

One shape for every callback. Fields not meaningful for a given call are zero.

struct SlotContext {
    address slot;          // always msg.sender, passed explicitly
    address caller;        // who called the slot — not necessarily the occupant
    address account;       // incoming occupant on a transition; current otherwise
    address occupant;      // who holds it now; zero when vacant
    uint256 occupiedSince; // when the current occupancy began; zero when vacant
    uint256 taxBps; // basis points per 30 days
    uint256 currentPrice;
    uint256 newPrice;      // proposed in `before`, just-set in `after`
    uint256 depositAmount;
    uint256 owed;          // afterSettle only — tax that accrued
    uint256 paid;          // afterSettle only — what could be taken from deposit
    bytes32 hookData;      // THIS SLOT's configuration for this hook
}

owed > paid on afterSettle means the occupant has run dry — a useful distress signal. newPrice is a proposal in beforeBuy / beforeSelfAssess and a fact in the after callbacks.

hookData is the slot's storage, not the hook's, and that is the load-bearing part. It is what lets one deployment serve every configuration — a MinimumTenureHook reads its window here, so a seven-day slot and a thirty-day slot point at the same address — and because it lives on the slot, a slot whose mutableHook is false has BOTH halves of its rules frozen. A hook keeping the same setting in its own storage could rewrite a slot's terms while the slot went on reporting itself immutable.

Zero means the slot configured nothing. End-of-tenure callbacks carry the configuration the OUTGOING hook was attached with, not its successor's.

HookFlags

Which callbacks a hook wants. Declared by the hook, snapshotted by the slot.

struct HookFlags {
    bool beforeBuy;
    bool beforeSelfAssess;
    bool afterBuy;
    bool afterRelease;
    bool afterLiquidate;
    bool afterSettle;
    bool strict;          // not a callback — a mode
}

strict is the odd one out. The other six say which callbacks the hook wants; this one changes how they run. Declared, it drops the stipend on every after call and lets the revert through, so work that must land cannot be silently dropped — and the hook can fail the slot, eviction included.

It is snapshotted with the rest, so a hook cannot become strict under a sitting occupant, and it appears in SlotInfo.hookFlags for anyone deciding whether to buy. Leave it false unless a swallowed write would be worse than a stuck slot.

IDescribedHook

Optional. Self-reported metadata for clients — never read by the slot.

struct HookDescriptor {
    bytes32 family;      // identifies the behaviour; the lookup key
    uint32  version;     // the encoding of `data`, and nothing else
    string  signature;   // the ABI types of this family's `hookData`
    bytes   data;        // abi.encode(HookBounds[]) — what each value means
    string  metadataURI; // the human half — label, icon. May be empty.
}
 
struct HookBounds {
    string  name;        // "window"
    string  unit;        // "seconds" — so a client shows 7 days, not 604800
    bool    bounded;     // whether min/max mean anything
    uint256 min;
    uint256 max;
}
 
interface IDescribedHook {
    function descriptors() external view returns (HookDescriptor[] memory);
}

signature is a plain ABI type list — "uint256 window" — so a client parses it with the tools it already has rather than a decoder written for this protocol. It sits outside data deliberately: a schema that is itself encoded cannot be read by the thing that needs it to know how to decode.

Together the two are enough to render a configuration form for a hook nobody has written a screen for. The bounds come from the hook's own constants — MAX_TENURE rather than a literal — so a form built from them cannot offer a value the transaction would refuse, and changing the constant moves the form with it.

MinimumTenureHook

The one hook the protocol ships as a policy. One deployment per chain; the window a slot enforces is its own hookData, 32 bytes of big-endian seconds.

function tenureOf(bytes32 data) external pure returns (uint256);
function requiredDeposit(uint256 price, uint256 taxBps, uint256 window)
    external pure returns (uint256);
 
uint256 public constant MAX_TENURE = 3650 days;
uint256 public constant BUYOUT_PREMIUM_BPS = 100_000;   // 10x

Inside the window it enforces three things:

Entry is fundeda buy must post enough deposit to cover the whole window at its own declared price
No price cutthe occupant cannot lower their price while protected
Buyouts cost a premiuma buyer must declare at least BUYOUT_PREMIUM_BPS of the occupant's price — 10x — to take the slot early

An occupant that leaves is barred from retaking that slot for one window, so the window cannot be renewed for free. The bar is per slot and per account.

Errors

ErrorMeaning
TenureNotConfiguredhookData is zero — a window was never set
TenureTooLong(max)above MAX_TENURE
TenureUnderfunded(required)the deposit does not cover the window
PriceCutDuringTenurea selfAssess lowering the price inside the window
TenureNotElapsed(availableAt)the leaver's re-entry bar has not expired
BuyoutBelowPremium(required)a mid-window buy declaring less than 10x

Identifying a hook

Hook kinds used to ship a factory that deployed at a CREATE2 address derived from the hook's terms, so factory.verify(hook) proved an address was the genuine hook for those exact terms. That is gone, and with it the reason for it: terms are no longer part of an address. A MinimumTenureHook is one deployment per chain and every duration is a slot's hookData.

What a client checks now is the family a hook claims in its descriptors(), which somebody else's implementation of that behaviour answers just as well — and then the slot's own hookData for the terms. descriptors() may still lie; the flags, which the slot snapshotted, are what say whether a hook can refuse a buy.