Skip to main content
Post-intent hooks enable automated on-chain actions after successful intent fulfillment. This guide covers using existing hooks and creating custom ones.

Overview

A post-intent hook is a smart contract that executes custom logic when an intent is fulfilled. Common use cases:
  • Cross-chain bridging: Automatically bridge USDC to another chain
  • Token swapping: Convert USDC to another token
  • Protocol deposits: Deposit into yield protocols
  • Multi-step workflows: Chain multiple actions together
Hooks are specified at intent creation and executed by the Orchestrator after fees are deducted.

How Hooks Work

Using the Across Bridge Hook

The AcrossBridgeHook automatically bridges USDC to another chain after fulfillment.

Setting Up Bridge Intent

1

Prepare commitment data

Define destination chain parameters at signaling time:
import { ethers } from "ethers";

// Helper to convert address to bytes32
const toBytes32 = (addr: string): string => {
  return ethers.utils.hexZeroPad(addr, 32);
};

// Destination parameters (committed at signal time)
const bridgeCommitment = {
  destinationChainId: 10, // Optimism
  outputToken: toBytes32("0x..."), // USDC on Optimism
  recipient: toBytes32("0x..."), // Your address on Optimism
  minOutputAmount: ethers.utils.parseUnits("49", 6) // Min output after fees
};

// Encode commitment
const commitmentData = ethers.utils.defaultAbiCoder.encode(
  [
    "tuple(uint256 destinationChainId, bytes32 outputToken, bytes32 recipient, uint256 minOutputAmount)"
  ],
  [bridgeCommitment]
);
2

Signal intent with hook

Include the hook address and commitment in your intent:
const ACROSS_HOOK_ADDRESS = "0x..."; // Deployed AcrossBridgeHook

await orchestrator.signalIntent({
  escrow: ESCROW_ADDRESS,
  depositId: depositId,
  amount: ethers.utils.parseUnits("50", 6),
  to: recipientAddress, // Fallback recipient if bridge fails
  paymentMethod: paymentMethod,
  fiatCurrency: fiatCurrency,
  conversionRate: conversionRate,
  referrer: ethers.constants.AddressZero,
  referrerFee: 0,
  gatingServiceSignature: signature,
  signatureExpiration: expiration,
  postIntentHook: ACROSS_HOOK_ADDRESS, // Enable hook
  data: commitmentData // Bridge parameters
});
3

Fulfill with bridge data

At fulfillment, provide just-in-time bridge parameters:
// Get current Across quote from their API
const acrossQuote = await fetch(
  `https://across.to/api/suggested-fees?...`
).then(r => r.json());

// Prepare fulfill data
const bridgeFulfillData = {
  intentHash: intentHash,
  outputAmount: ethers.utils.parseUnits("49.5", 6), // From Across API
  fillDeadlineOffset: 21600, // 6 hours
  exclusiveRelayer: toBytes32(acrossQuote.exclusiveRelayer),
  exclusivityParameter: acrossQuote.exclusivityDeadline
};

const hookData = ethers.utils.defaultAbiCoder.encode(
  [
    "tuple(bytes32 intentHash, uint256 outputAmount, uint32 fillDeadlineOffset, bytes32 exclusiveRelayer, uint32 exclusivityParameter)"
  ],
  [bridgeFulfillData]
);

// Fulfill with hook data
await orchestrator.fulfillIntent({
  intentHash: intentHash,
  paymentProof: paymentProof,
  verificationData: verificationData,
  postIntentHookData: hookData // JIT bridge params
});

Fallback Behavior

The Across hook gracefully handles failures:
// If bridge fails (price moved, SpokePool reverted, etc.)
// Hook automatically transfers USDC to intent.to on source chain
// Emits FallbackTransfer event with reason

// Listen for fallback
orchestrator.on("IntentFulfilled", (intentHash, recipient, amount, isManual) => {
  if (recipient === ACROSS_HOOK_ADDRESS) {
    // Check AcrossBridgeInitiated event
    console.log("Bridge initiated successfully");
  } else {
    // Check FallbackTransfer event
    console.log("Bridge failed, received on source chain");
  }
});

Creating Custom Hooks

Hook Interface

Implement the IPostIntentHook interface:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import { IOrchestrator } from "./interfaces/IOrchestrator.sol";
import { IPostIntentHook } from "./interfaces/IPostIntentHook.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract MyCustomHook is IPostIntentHook {
    using SafeERC20 for IERC20;

    address public immutable orchestrator;
    IERC20 public immutable usdc;

    constructor(address _orchestrator, address _usdc) {
        orchestrator = _orchestrator;
        usdc = IERC20(_usdc);
    }

    /**
     * @notice Execute custom logic after intent fulfillment
     * @param _intent The fulfilled intent data
     * @param _amountNetFees USDC amount after protocol/referrer fees
     * @param _fulfillIntentData Custom data from fulfillIntent call
     */
    function execute(
        IOrchestrator.Intent memory _intent,
        uint256 _amountNetFees,
        bytes calldata _fulfillIntentData
    ) external override {
        require(msg.sender == orchestrator, "Unauthorized");

        // Pull USDC from orchestrator
        usdc.safeTransferFrom(orchestrator, address(this), _amountNetFees);

        // Decode custom data
        // ... your custom logic here ...

        // Must consume exactly _amountNetFees
        // Orchestrator enforces this requirement
    }
}

Example: Yield Deposit Hook

Automatically deposit USDC into a yield protocol:
pragma solidity ^0.8.18;

import { IPostIntentHook } from "./interfaces/IPostIntentHook.sol";
import { IOrchestrator } from "./interfaces/IOrchestrator.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

interface IYieldProtocol {
    function deposit(uint256 amount, address recipient) external;
}

contract YieldDepositHook is IPostIntentHook {
    using SafeERC20 for IERC20;

    address public immutable orchestrator;
    IERC20 public immutable usdc;
    IYieldProtocol public immutable yieldProtocol;

    event YieldDepositExecuted(
        bytes32 indexed intentHash,
        address indexed recipient,
        uint256 amount
    );

    constructor(
        address _orchestrator,
        address _usdc,
        address _yieldProtocol
    ) {
        orchestrator = _orchestrator;
        usdc = IERC20(_usdc);
        yieldProtocol = IYieldProtocol(_yieldProtocol);
    }

    function execute(
        IOrchestrator.Intent memory _intent,
        uint256 _amountNetFees,
        bytes calldata _fulfillIntentData
    ) external override {
        require(msg.sender == orchestrator, "Unauthorized");

        // Pull USDC from orchestrator
        usdc.safeTransferFrom(orchestrator, address(this), _amountNetFees);

        // Approve yield protocol
        usdc.safeApprove(address(yieldProtocol), _amountNetFees);

        // Deposit to yield protocol on behalf of intent.to
        yieldProtocol.deposit(_amountNetFees, _intent.to);

        emit YieldDepositExecuted(
            keccak256(abi.encode(_intent)),
            _intent.to,
            _amountNetFees
        );
    }
}

Example: Token Swap Hook

Swap USDC to another token via a DEX:
pragma solidity ^0.8.18;

import { IPostIntentHook } from "./interfaces/IPostIntentHook.sol";
import { IOrchestrator } from "./interfaces/IOrchestrator.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

interface IUniswapRouter {
    function swapExactTokensForTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external returns (uint256[] memory amounts);
}

contract SwapHook is IPostIntentHook {
    using SafeERC20 for IERC20;

    struct SwapParams {
        address outputToken;      // Token to swap to
        uint256 minOutputAmount;  // Minimum tokens out (slippage protection)
        address[] path;           // Swap path
    }

    address public immutable orchestrator;
    IERC20 public immutable usdc;
    IUniswapRouter public immutable router;

    constructor(
        address _orchestrator,
        address _usdc,
        address _router
    ) {
        orchestrator = _orchestrator;
        usdc = IERC20(_usdc);
        router = IUniswapRouter(_router);
    }

    function execute(
        IOrchestrator.Intent memory _intent,
        uint256 _amountNetFees,
        bytes calldata _fulfillIntentData
    ) external override {
        require(msg.sender == orchestrator, "Unauthorized");

        // Decode swap params from _intent.data (committed at signal time)
        SwapParams memory params = abi.decode(_intent.data, (SwapParams));

        // Pull USDC
        usdc.safeTransferFrom(orchestrator, address(this), _amountNetFees);

        // Approve router
        usdc.safeApprove(address(router), _amountNetFees);

        // Execute swap
        router.swapExactTokensForTokens(
            _amountNetFees,
            params.minOutputAmount,
            params.path,
            _intent.to,           // Send output tokens to intent recipient
            block.timestamp + 300 // 5 min deadline
        );
    }
}

Hook Registration

Before use, hooks must be whitelisted in the PostIntentHookRegistry:
import { PostIntentHookRegistry } from "@typechain/PostIntentHookRegistry";

// Registry owner adds hook
const registry = new ethers.Contract(
  REGISTRY_ADDRESS,
  PostIntentHookRegistry_ABI,
  ownerSigner
) as PostIntentHookRegistry;

await registry.addPostIntentHook(MY_HOOK_ADDRESS);

// Verify registration
const isWhitelisted = await registry.isWhitelistedHook(MY_HOOK_ADDRESS);
console.log("Hook whitelisted:", isWhitelisted);
Only governance can add hooks to the registry. Submit a proposal to get your hook whitelisted.

Testing Hooks

import { expect } from "chai";
import { ethers } from "hardhat";

describe("MyCustomHook", () => {
  let hook: Contract;
  let orchestrator: Contract;
  let usdc: Contract;

  beforeEach(async () => {
    // Deploy mocks
    const [owner, taker] = await ethers.getSigners();
    
    const USDC = await ethers.getContractFactory("USDCMock");
    usdc = await USDC.deploy();

    const Orchestrator = await ethers.getContractFactory("OrchestratorMock");
    orchestrator = await Orchestrator.deploy();

    const Hook = await ethers.getContractFactory("MyCustomHook");
    hook = await Hook.deploy(orchestrator.address, usdc.address);

    // Setup
    await usdc.transfer(orchestrator.address, ethers.utils.parseUnits("100", 6));
  });

  it("should execute custom logic", async () => {
    const amount = ethers.utils.parseUnits("50", 6);

    // Approve hook to pull from orchestrator
    await usdc.connect(orchestrator).approve(hook.address, amount);

    // Build mock intent
    const intent = {
      owner: taker.address,
      to: taker.address,
      amount: amount,
      // ... other fields
    };

    // Execute hook
    await hook.connect(orchestrator).execute(
      intent,
      amount,
      "0x" // hookData
    );

    // Verify results
    // ... check balances, events, state changes
  });
});

Best Practices

Exact Consumption

Always consume exactly _amountNetFees. Orchestrator enforces this.

Authorization

Only allow orchestrator to call execute(). Revert for other callers.

Graceful Failures

Handle errors gracefully - consider fallback transfers like AcrossBridge.

Gas Efficiency

Optimize gas usage - takers pay for hook execution.

Security Considerations

Hooks are called during fulfillIntent which has reentrancy guards. Additional protection in hooks is optional but recommended.
Reset token allowances to 0 after operations to prevent residual allowance exploits.
Verify exact token consumption with before/after balance snapshots.
Validate all decoded parameters from _intent.data and _fulfillIntentData.

Next Steps

Testing Guide

Learn how to test your custom hooks

Hook Registry

View the PostIntentHookRegistry contract

Build docs developers (and LLMs) love