Intent Hooks
V3 supports two types of hooks that let you add custom logic to the intent lifecycle:
- Pre-intent hooks — run during
signalIntentbefore funds are locked. Used for access control (signature gating, whitelists). - Post-intent hooks — run during
fulfillIntentafter 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.
For the full protocol-level specification of these hooks, see:
- Pre-Intent Hooks — interface details, built-in implementations, trust model
- Post-Intent Hooks — execution model, invariants, security considerations
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:
- Deducts protocol, manager, and referral fees from the release amount to get
executableAmount. - Approves your hook to pull exactly
executableAmountof the deposit token. - Calls
hook.execute(ctx, fulfillHookData). - Verifies your hook pulled exactly
executableAmountand 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 time →
SignalIntentParams.data— persisted in the intent, delivered asctx.intent.signalHookData. Use for static config (target address, split ratios, destination chain). - At fulfill time →
FulfillIntentParams.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
- Deploy your hook contract with the OrchestratorV2 address (
0x888888359E981B5225CA48fbCdCeff702FC3b888on Base). - 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);
- 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:
- Generic pre-intent hook — general-purpose validation.
- 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:
| Hook | Address (Base) | Purpose |
|---|---|---|
| SignatureGatingPreIntentHook | 0x62D410a3d6FC766dd2192be2a67a5fc79c5c2e1F | Require an EIP-191 signature from a configurable signer |
| WhitelistPreIntentHook | 0xd793369b11357cdd076A9c631F6c44ff8e6353eA | Restrict 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
SafeERC20for tokens that don't returnboolontransfer. - Store the Orchestrator address as
immutableand checkmsg.senderto 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
| Contract | Address (Base) | Basescan |
|---|---|---|
| OrchestratorV2 | 0x888888359E981B5225CA48fbCdCeff702FC3b888 | View |
| EscrowV2 | 0x777777779d229cdF3110e9de47943791c26300Ef | View |
| SignatureGatingPreIntentHook | 0x62D410a3d6FC766dd2192be2a67a5fc79c5c2e1F | View |
| WhitelistPreIntentHook | 0xd793369b11357cdd076A9c631F6c44ff8e6353eA | View |
| AcrossBridgeHookV2 | 0xCcC9163451DE31a625D48e417e0fD1a329c7f7cf | View |
See V3 Deployments for the full contract address list.