Skip to main content

Intent Hooks

V3 supports two types of hooks that let you add custom logic to the intent lifecycle:

  • Pre-intent hooks — run during signalIntent before funds are locked. Used for access control (signature gating, whitelists).
  • Post-intent hooks — run during fulfillIntent after verification. Used for custom fund routing (bridging, splitting, swapping).

Both hook types are set per-deposit by the depositor or their delegate. No governance whitelisting is required.

info

For the full protocol-level specification of these hooks, see:


Post-Intent Hooks

Interface

Post-intent hooks implement IPostIntentHookV2. The Orchestrator builds a HookExecutionContext with everything the hook needs:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

interface IPostIntentHookV2 {
struct HookIntentContext {
address owner;
address to;
address escrow;
uint256 depositId;
uint256 amount;
uint256 timestamp;
bytes32 paymentMethod;
bytes32 fiatCurrency;
uint256 conversionRate;
bytes32 payeeId;
bytes signalHookData; // from SignalIntentParams.data
}

struct HookExecutionContext {
bytes32 intentHash;
address token; // deposit token address
uint256 executableAmount;// amount after all fees
HookIntentContext intent;
}

function execute(
HookExecutionContext calldata _ctx,
bytes calldata _fulfillHookData
) external;
}

Source: zkp2p-v2-contracts/contracts/interfaces/IPostIntentHookV2.sol

How it works

After fulfillIntent, the Orchestrator:

  1. Deducts protocol, manager, and referral fees from the release amount to get executableAmount.
  2. Approves your hook to pull exactly executableAmount of the deposit token.
  3. Calls hook.execute(ctx, fulfillHookData).
  4. Verifies your hook pulled exactly executableAmount and didn't send tokens back. Resets allowance to 0.

If any check fails, the entire fulfillment reverts.

Minimal template

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IPostIntentHookV2 } from "zkp2p-v2-contracts/contracts/interfaces/IPostIntentHookV2.sol";

contract MyHook is IPostIntentHookV2 {
address public immutable orchestrator;

constructor(address _orchestrator) {
orchestrator = _orchestrator;
}

function execute(
HookExecutionContext calldata ctx,
bytes calldata /* fulfillHookData */
) external override {
require(msg.sender == orchestrator, "only orchestrator");

// Token address and amount are in the context — no need to query Escrow
IERC20(ctx.token).transferFrom(msg.sender, ctx.intent.to, ctx.executableAmount);
}
}

Passing data to hooks

  • At signal timeSignalIntentParams.data — persisted in the intent, delivered as ctx.intent.signalHookData. Use for static config (target address, split ratios, destination chain).
  • At fulfill timeFulfillIntentParams.postIntentHookData — delivered as _fulfillHookData. Use for dynamic inputs (bridge quote, memo, execution flags).

Example: 95/5 split

function execute(HookExecutionContext calldata ctx, bytes calldata) external override {
require(msg.sender == orchestrator, "only orchestrator");
uint256 fee = ctx.executableAmount * 5 / 100;
uint256 toAmt = ctx.executableAmount - fee;
IERC20(ctx.token).transferFrom(msg.sender, ctx.intent.to, toAmt);
IERC20(ctx.token).transferFrom(msg.sender, builder, fee);
}

Example: pull-then-act pattern

function execute(HookExecutionContext calldata ctx, bytes calldata) external override {
require(msg.sender == orchestrator, "only orchestrator");
IERC20(ctx.token).transferFrom(msg.sender, address(this), ctx.executableAmount);
// Now you hold the tokens — approve a router, bridge, stake, swap, etc.
// Forward remainder to ctx.intent.to
}

What will revert

  • Pulling less than executableAmount → revert.
  • Sending tokens into the Orchestrator during execution → revert.
  • Hook reverting for any reason → entire fulfillment reverts.

Deploy and use

  1. Deploy your hook contract with the OrchestratorV2 address (0x888888359E981B5225CA48fbCdCeff702FC3b888 on Base).
  2. Signal an intent with your hook:
const params = {
escrow,
depositId,
amount,
to,
paymentMethod, // bytes32
fiatCurrency, // bytes32
conversionRate, // 1e18 scale
referralFees: [], // IReferralFee.ReferralFee[]
gatingServiceSignature,
signatureExpiration,
postIntentHook: myHookAddress,
preIntentHookData: "0x",
data: abi.encode(targetAddress) // static config for the hook
};
await orchestrator.signalIntent(params);
  1. Fulfill with optional hook data:
await orchestrator.fulfillIntent({
paymentProof: encodedPaymentAttestation,
intentHash,
verificationData: "0x",
postIntentHookData: abi.encode(/* dynamic flags, memo, etc. */)
});

Pre-Intent Hooks

Interface

Pre-intent hooks implement IPreIntentHook. They run during signalIntent before any state changes and can only revert to reject:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

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

interface IPreIntentHook {
struct PreIntentContext {
address taker;
address escrow;
uint256 depositId;
uint256 amount;
address to;
bytes32 paymentMethod;
bytes32 fiatCurrency;
uint256 conversionRate;
IReferralFee.ReferralFee[] referralFees;
bytes preIntentHookData; // from SignalIntentParams.preIntentHookData
}

function validateSignalIntent(PreIntentContext calldata _ctx) external;
}

Source: zkp2p-v2-contracts/contracts/interfaces/IPreIntentHook.sol

How it works

Each deposit has two hook slots on OrchestratorV2:

  1. Generic pre-intent hook — general-purpose validation.
  2. Whitelist hook — address-based access control.

Both run sequentially during signalIntent. If either reverts, the intent is rejected.

Built-in implementations

ZKP2P ships two ready-to-use pre-intent hooks:

HookAddress (Base)Purpose
SignatureGatingPreIntentHook0x62D410a3d6FC766dd2192be2a67a5fc79c5c2e1FRequire an EIP-191 signature from a configurable signer
WhitelistPreIntentHook0xd793369b11357cdd076A9c631F6c44ff8e6353eARestrict to whitelisted taker addresses per payment method

Setup

Set a hook on your deposit (depositor or delegate only):

// Set a generic pre-intent hook
await orchestrator.setDepositPreIntentHook(escrowAddress, depositId, hookAddress);

// Set a whitelist hook
await orchestrator.setDepositWhitelistHook(escrowAddress, depositId, hookAddress);

// Remove a hook (set to zero address)
await orchestrator.setDepositPreIntentHook(escrowAddress, depositId, ethers.ZeroAddress);

For the whitelist hook, add addresses after setting:

const hook = WhitelistPreIntentHook__factory.connect(hookAddress, signer);
await hook.addToWhitelist(escrowAddress, depositId, paymentMethod, [addr1, addr2]);

Writing a custom pre-intent hook

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import { IPreIntentHook } from "zkp2p-v2-contracts/contracts/interfaces/IPreIntentHook.sol";

contract MyGatingHook is IPreIntentHook {
function validateSignalIntent(PreIntentContext calldata ctx) external override {
// Example: only allow intents above 100 USDC (6 decimals)
require(ctx.amount >= 100e6, "amount too low");

// Example: only allow specific payment methods
require(ctx.paymentMethod == keccak256("venmo"), "method not allowed");

// Returning without reverting = intent approved
}
}

Deploy and set it on your deposit — no governance approval needed.


Tips

  • Keep hook execution short and deterministic — reverts undo the entire transaction.
  • Use SafeERC20 for tokens that don't return bool on transfer.
  • Store the Orchestrator address as immutable and check msg.sender to prevent unauthorized calls.
  • Post-intent hooks receive the token address in ctx.token — no need to query the Escrow contract.
  • Pre-intent hooks are validation-only — they should not modify state.

Addresses

ContractAddress (Base)Basescan
OrchestratorV20x888888359E981B5225CA48fbCdCeff702FC3b888View
EscrowV20x777777779d229cdF3110e9de47943791c26300EfView
SignatureGatingPreIntentHook0x62D410a3d6FC766dd2192be2a67a5fc79c5c2e1FView
WhitelistPreIntentHook0xd793369b11357cdd076A9c631F6c44ff8e6353eAView
AcrossBridgeHookV20xCcC9163451DE31a625D48e417e0fD1a329c7f7cfView

See V3 Deployments for the full contract address list.