Skip to main content
Hooks extend vault behavior at specific lifecycle points without modifying core contracts. For background on how the hook system works, see the Hooks concept page. This guide covers building hooks: understanding the interfaces, composing them, and implementing one end-to-end.
Before starting, make sure you have:
  • Foundry installed (forge, cast, anvil)
  • Familiarity with Solidity development and testing
  • Understanding of the Aera V3 Hooks concept page

Hook Interfaces

Aera V3 defines six hook interfaces that fire at different points in the vault lifecycle. Each interface is a single function that the hook contract must implement.
InterfaceFunctionWhen It Fires
IBeforeSubmitHookbeforeSubmit(Operation[])Before the entire guardian submission batch executes. Used for batch-level validation.
IAfterSubmitHookafterSubmit(Operation[])After all operations in the batch complete. Used for post-batch accounting and state snapshots.
IBeforeOperationHookbeforeOperation(address, bytes, bytes)Before each individual operation’s target call. Used for calldata validation (e.g., slippage checks).
IAfterOperationHookafterOperation(address, bytes, bytes)After each individual operation completes. Used for post-condition checks (e.g., approval cleanup).
IBeforeTransferHookbeforeTransfer(address, address, uint256)Before vault unit transfers in multi-depositor vaults. Used for access control and compliance.
IBeforeClaimHookbeforeClaim(address, address, uint256)Before fee claim operations. Used for claim validation and authorization.
For full function signatures, parameter types, and detailed documentation, see the Contract Reference: Hooks.

Hook Lifecycle

When a guardian submits a batch of operations, hooks execute in a deterministic order:
  1. beforeSubmit fires once for the entire batch
  2. For each operation in the batch:
    • beforeOperation fires with the operation’s target, calldata, and hook data
    • The operation executes against the target contract
    • afterOperation fires with the same parameters
  3. afterSubmit fires once after all operations complete
Transfer hooks and claim hooks fire independently of the submission flow:
  • beforeTransfer fires during ERC-20 transfer calls on multi-depositor vault units
  • beforeClaim fires during fee claim operations
If any hook reverts, the entire transaction reverts. Hooks act as hard constraints — the vault cannot bypass a failing hook. The Hooks concept page includes a detailed breakdown of execution flow with examples of common hook configurations.

Composition Patterns

Chaining hooks. A hook contract can internally delegate to other hook contracts, building a pipeline of validation logic. For example, a submit hook might first check aggregate position limits, then update a fee accounting snapshot, then emit events for off-chain monitoring — each concern implemented as a separate internal hook. The vault owner configures hooks via vault settings, and the vault calls the configured hook contract at each lifecycle point. Configurable hooks. Hook contracts can accept configuration parameters rather than having behavior hardcoded. Operation hooks receive hookData from the Merkle tree, which means the same hook contract can enforce different slippage bounds for different operations or different position limits for different assets. This is configured per-guardian through the Merkle tree leaves, not by modifying the hook contract itself. For more on composition patterns, see Hooks: Hook Composition.

Tutorial: Building a Transfer Restriction Hook

This tutorial builds a complete hook from scratch: a transfer restriction hook that only allows vault unit transfers to allowlisted addresses. This is a common compliance requirement for multi-depositor vaults where vault shares should only be held by approved counterparties.

Overview

The hook implements IBeforeTransferHook. When a vault unit transfer occurs, the hook checks whether the recipient is on an allowlist. If not, the transfer reverts. The hook owner can add and remove addresses from the allowlist at any time.

Step 1: Project Setup

Create a new Foundry project and set up the directory structure:
forge init transfer-restriction-hook
cd transfer-restriction-hook
Create the hook interface file. This mirrors the IBeforeTransferHook interface from the Aera V3 protocol:
// src/IBeforeTransferHook.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

interface IBeforeTransferHook {
    function beforeTransfer(address from, address to, uint256 amount) external;
}

Step 2: Implement the Hook

Create the transfer restriction hook contract:
// src/TransferRestrictionHook.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import {IBeforeTransferHook} from "./IBeforeTransferHook.sol";

/// @title TransferRestrictionHook
/// @notice Restricts vault unit transfers to allowlisted addresses.
/// @dev Implements IBeforeTransferHook. The vault calls beforeTransfer
///      on every unit transfer. If the recipient is not allowlisted,
///      the transfer reverts.
contract TransferRestrictionHook is IBeforeTransferHook {
    address public owner;
    mapping(address => bool) public allowlisted;

    error NotOwner();
    error RecipientNotAllowlisted(address to);

    event AddressAllowlisted(address indexed account);
    event AddressRemovedFromAllowlist(address indexed account);

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    constructor(address _owner) {
        owner = _owner;
    }

    /// @notice Called by the vault before every unit transfer.
    /// @dev Reverts if the recipient is not on the allowlist.
    ///      The `from` and `amount` parameters are available for
    ///      more complex restrictions but unused in this example.
    function beforeTransfer(address, address to, uint256) external view {
        if (!allowlisted[to]) revert RecipientNotAllowlisted(to);
    }

    /// @notice Add an address to the transfer allowlist.
    function addToAllowlist(address account) external onlyOwner {
        allowlisted[account] = true;
        emit AddressAllowlisted(account);
    }

    /// @notice Remove an address from the transfer allowlist.
    function removeFromAllowlist(address account) external onlyOwner {
        allowlisted[account] = false;
        emit AddressRemovedFromAllowlist(account);
    }
}
The contract is deliberately simple: an owner, an allowlist mapping, and the beforeTransfer function that reverts if the recipient is not allowlisted. In production, you might add batch operations, timelocks, or role-based access — but the core pattern stays the same.

Step 3: Write Tests

Create a test file that verifies the hook behaves correctly:
// test/TransferRestrictionHook.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import {Test} from "forge-std/Test.sol";
import {TransferRestrictionHook} from "../src/TransferRestrictionHook.sol";

contract TransferRestrictionHookTest is Test {
    TransferRestrictionHook hook;
    address owner = address(this);
    address alice = address(0xA11CE);
    address bob = address(0xB0B);

    function setUp() public {
        hook = new TransferRestrictionHook(owner);
    }

    function test_allowlistedTransferSucceeds() public {
        hook.addToAllowlist(alice);
        // Should not revert -- alice is allowlisted
        hook.beforeTransfer(bob, alice, 100);
    }

    function test_disallowedTransferReverts() public {
        // Bob is not allowlisted -- transfer should revert
        vm.expectRevert(
            abi.encodeWithSelector(
                TransferRestrictionHook.RecipientNotAllowlisted.selector,
                bob
            )
        );
        hook.beforeTransfer(alice, bob, 100);
    }

    function test_ownerCanUpdateAllowlist() public {
        hook.addToAllowlist(alice);
        assertTrue(hook.allowlisted(alice));

        hook.removeFromAllowlist(alice);
        assertFalse(hook.allowlisted(alice));
    }

    function test_nonOwnerCannotUpdateAllowlist() public {
        vm.prank(alice);
        vm.expectRevert(TransferRestrictionHook.NotOwner.selector);
        hook.addToAllowlist(bob);
    }
}

Step 4: Run Tests

Run the test suite with verbose output to see individual test results:
forge test -vv
Expected output:
[PASS] test_allowlistedTransferSucceeds() (gas: ...)
[PASS] test_disallowedTransferReverts() (gas: ...)
[PASS] test_ownerCanUpdateAllowlist() (gas: ...)
[PASS] test_nonOwnerCannotUpdateAllowlist() (gas: ...)
All four tests should pass: allowlisted transfers succeed, disallowed transfers revert with RecipientNotAllowlisted, the owner can add and remove addresses, and non-owners cannot modify the allowlist.

Step 5: Deploy

Create a deployment script:
// script/DeployHook.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import {Script} from "forge-std/Script.sol";
import {TransferRestrictionHook} from "../src/TransferRestrictionHook.sol";

contract DeployHook is Script {
    function run() external {
        uint256 deployerKey = vm.envUint("PRIVATE_KEY");
        address hookOwner = vm.envAddress("HOOK_OWNER");

        vm.startBroadcast(deployerKey);
        TransferRestrictionHook hook = new TransferRestrictionHook(hookOwner);
        vm.stopBroadcast();
    }
}
Deploy with Forge:
forge script script/DeployHook.s.sol:DeployHook \
  --rpc-url $RPC_URL \
  --broadcast \
  --verify
After deploying, the vault owner must configure the hook on the target vault. Hook registration is an owner operation, not a guardian operation. The owner calls setBeforeTransferHook on the multi-depositor vault, passing the deployed hook’s address. See the Multi-Depositor Vault contract reference for details.