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
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 ]
);
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
});
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