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 custom validation such as signature gating.
  • Post-intent hooks — run during fulfillIntent after verification. Used for custom fund routing (bridging, splitting, swapping).

The depositor or delegate controls the deposit's generic pre-intent hook. A post-intent hook is selected when the intent is signaled. Neither custom hook requires a governance allowlist. The separate production lifecycle hook is governance-selected and enforces Groups access policies.

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-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-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 OrchestratorV3 address (0x014025fDE093f8701d86e9f38e2C3a9b779cb5c7 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-contracts/contracts/interfaces/IPreIntentHook.sol

How it works

Each deposit has one generic pre-intent hook slot on OrchestratorV3. It runs during signalIntent; if it reverts, the complete transaction is rejected.

Built-in implementations

ZKP2P ships a ready-to-use signature-gating pre-intent hook:

HookAddress (Base)Purpose
SignatureGatingPreIntentHook0x62D410a3d6FC766dd2192be2a67a5fc79c5c2e1FRequire an EIP-191 signature from a configurable signer

Setup

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

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

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

For direct-wallet or Group restrictions, configure client.accessPolicy. The active WhitelistLifecycleHook enforces that policy independently from this generic pre-intent slot.

Writing a custom pre-intent hook

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

import { IPreIntentHook } from "zkp2p-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
OrchestratorV30x014025fDE093f8701d86e9f38e2C3a9b779cb5c7View
EscrowV20x777777779d229cdF3110e9de47943791c26300EfView
SignatureGatingPreIntentHook0x62D410a3d6FC766dd2192be2a67a5fc79c5c2e1FView
WhitelistLifecycleHook0x251d78fb6bBb4071995Bce74bAfC9E4168638622View
AcrossBridgeHookV20xCcC9163451DE31a625D48e417e0fD1a329c7f7cfView

See V3 Deployments for the full contract address list.