Custom share hook
The token compliance page gives an overview of the hook system and the four built-in compliance profiles. This page covers how to extend BaseTransferHook to build a custom profile.
BaseTransferHook and the handlers described here are designed for the protocol's existing vault and AsyncRequestManager setup. If you deploy a fully custom vault, these hooks may not be called or may not apply in full. Verify against your vault's transfer path.
Callbacks
The share token calls into the hook on every transfer. ITransferHook defines three entry points:
// Called on every standard ERC-20 transfer. Can revert to block the transfer.
// Must return ITransferHook.onERC20Transfer.selector on success.
function onERC20Transfer(address from, address to, uint256 value, HookData calldata hookData)
external returns (bytes4);
// Called on authorized transfers (mint, burn, protocol-internal moves).
// Cannot block the transfer; return value is ignored. Override only to update state.
function onERC20AuthTransfer(address sender, address from, address to, uint256 value, HookData calldata hookData)
external returns (bytes4);
// View-only pre-check. Called before submission to surface eligibility without executing.
function checkERC20Transfer(address from, address to, uint256 value, HookData calldata hookData)
external view returns (bool);
In BaseTransferHook, onERC20Transfer delegates to checkERC20Transfer and reverts if it returns false. onERC20AuthTransfer is a no-op by default (it cannot be blocked). checkERC20Transfer is abstract and must be implemented.
Transfer type classifiers
BaseTransferHook provides a set of view helpers that identify what kind of transfer is occurring, based on the from/to address pattern:
function isDepositRequestOrIssuance(address from, address to) external view returns (bool);
function isDepositFulfillment(address from, address to) external view returns (bool);
function isDepositClaim(address from, address to) external view returns (bool);
function isRedeemRequest(address from, address to) external pure returns (bool);
function isRedeemFulfillment(address from, address to) external view returns (bool);
function isRedeemClaimOrRevocation(address from, address to) external view returns (bool);
function isCrosschainTransfer(address from, address to) external view returns (bool);
function isCrosschainTransferExecution(address from, address to) external view returns (bool);
Use these in your checkERC20Transfer implementation to apply different rules per transfer type. For example, to require membership for deposits but allow free peer-to-peer transfers:
function checkERC20Transfer(address from, address to, uint256, HookData calldata hookData)
public view override returns (bool)
{
if (isSourceOrTargetFrozen(from, to, hookData)) return false;
if (isDepositRequestOrIssuance(from, to)) return isTargetMember(to, hookData);
return true;
}
hookData: per-address storage
Every address holding share tokens carries 16 bytes of custom data (hookData), stored alongside its token balance. The struct has two fields, one for each side of a transfer:
struct HookData {
bytes16 from; // hook data for the sender
bytes16 to; // hook data for the receiver
}
BaseTransferHook uses these 16 bytes with a fixed layout:
- Upper 8 bytes (bits 127–64):
validUntil, auint64Unix timestamp. The address is a valid member until this time. - Bit 0 (least significant): freeze flag. Set to
1to freeze the address.
The remaining bits are unused and available for custom extensions. The 16-byte limit exists because hook data is packed with the token balance in a single storage slot.
Two view helpers decode this for you:
function isSourceMember(address from, HookData calldata hookData) external view returns (bool);
function isTargetMember(address to, HookData calldata hookData) external view returns (bool);
function isSourceOrTargetFrozen(address from, address to, HookData calldata hookData) external view returns (bool);
Hook managers
The hub manager can authorize per-token managers on each spoke that can update hook state locally, without a cross-chain Hub round-trip. This is useful for compliance oracles or KYC providers that need to act immediately on a single chain.
A manager is granted via a Hub-side updateContract call that encodes a TrustedCall.UpdateHookManager payload. Once authorized, the manager can call:
// Add or extend a member's validity window
function updateMember(address token, address user, uint64 validUntil) external;
// Set or clear the freeze bit for an address
function freeze(address token, address user) external;
function unfreeze(address token, address user) external;
These calls take effect immediately on the spoke where they are submitted. For changes that need to propagate to every chain, use the Hub-driven updateRestriction path instead, which uses the same sync parameters pattern.
Note that endorsed protocol addresses and pool escrows cannot be frozen or added as members; the hook enforces this.
Updating the hook
The hook contract is set per share class and can be replaced at any time. The hub manager calls updateShareHook to point the share token at a new hook contract without redeploying the token. This makes the compliance layer fully upgradeable: if regulatory requirements change, deploy a new hook and switch to it.