Transfer Hooks

Transfer hooks — per-project contracts that Art Blocks Engine cores call on every mint and transfer.

A transfer hook is a contract that an Art Blocks Engine core calls every time a token in a project changes hands. The core calls it after the ownership write, on both mints and transfers, and passes it the token, the previous owner, the new owner, and the operator that initiated the move.

Hooks are configured per project, not per contract, so two projects on the same core can behave differently. A project with no hook configured behaves exactly as projects always have.

Because the hook is called as part of the transfer, a hook that reverts aborts the mint or transfer. That makes hooks a genuine extension point — a project can record provenance, emit richer events, or impose conditions on when a token may move — and it also makes them something a collector should look at before assuming a token is freely transferable.

Compatibility

Transfer hooks require core version v3.3.0 or later (GenArt721CoreV3_Engine) or v3.3.1 or later (GenArt721CoreV3_Engine_Flex).

Support cannot be added to a contract that already exists. Engine cores are deployed as minimal proxies, and a proxy's implementation address is fixed in its bytecode at deployment, so an existing core will never gain hook support. Projects that need transfer hooks must live on a contract deployed after the v3.3 rollout. If you need a new core contract, contact the Art Blocks team.

To check a specific project, read the supports_transfer_hooks field described in Reading the configuration — it is false on every pre-v3.3 core.

Configuring a hook

Either the artist or the contract's Admin ACL can set a project's hook:

core.configureProjectTransferHook(projectId, hookAddress);

Passing address(0) clears the hook and returns the project to standard transfer behavior.

A non-zero hook must advertise the ITransferHook interface via ERC-165, using interface ID 0x6344b0e2. The core rejects any address that does not. That ID is the selector of onTokenTransfer alone — Solidity excludes inherited interface functions from the XOR, so it does not include IERC165's 0x01ffc9a7.

The Creator Dashboard exposes this for projects on a supporting contract, letting an artist paste a hook address rather than send the transaction by hand.

Locking

A project's hook configuration can be frozen permanently:

core.lockProjectTransferHook(projectId, expectedHook);

Only the artist can lock, and locking is one-way. expectedHook must match the project's currently configured hook. That argument is not ceremony: the hook can be changed by either the artist or the Admin ACL, so without it a configuration transaction landing first could permanently lock in a hook nobody intended.

Locking at address(0) is meaningful — it guarantees the project will never have a transfer hook, which is a commitment a collector can rely on.

There is also an automatic lock. Every Art Blocks project's metadata locks four weeks after completion. If that window elapses while a project has no hook configured, the hook configuration is locked at address(0) too, and configureProjectTransferHook will revert from then on. A project that does have a hook set stays configurable past that point until the artist locks it explicitly.

Reading the configuration

On chain:

(address hook, bool locked) = core.projectTransferHookConfig(projectId);

Off chain, projects_metadata in the Art Blocks GraphQL API carries three fields:

Field Meaning
transfer_hook The configured hook address, or null if none
supports_transfer_hooks Whether this project's core supports hooks at all
transfer_hook_configuration_locked Whether the configuration can still change

Use transfer_hook_configuration_locked rather than the raw transfer_hook_locked column. The raw column reflects only an explicit lockProjectTransferHook call, and it is null rather than false while unlocked — it does not account for the time-based automatic lock, which has no event to index and has to be derived when read.

Two events are emitted, and are indexed by the Art Blocks subgraph:

event ProjectTransferHookUpdated(uint256 indexed _projectId, address indexed _hook);
event ProjectTransferHookLocked(uint256 indexed _projectId, address indexed _hook);

The automatic lock emits nothing, because no transaction causes it.

Writing a hook

A hook implements a single function:

interface ITransferHook is IERC165 {
    function onTokenTransfer(
        address coreContract,
        uint256 tokenId,
        address from,
        address to,
        address operator
    ) external;
}
  • from is address(0) on mint.
  • operator is the ERC-721 operator on a transfer, and on a mint it is the address that initiated the mint — the collector, not the minter contract.
  • Reverting aborts the mint or transfer.

The recommended starting point is AbstractTransferHook, which satisfies the ERC-165 requirement and enforces that the caller really is the coreContract it was handed. Inheriting it, you implement _onTokenTransfer instead.

Two more constraints worth designing around:

  • No reentrancy into the core. For the duration of the hook call the core blocks mints and transfers across every project on that core. A hook that tries to mint or transfer on the calling core will revert the transaction that triggered it.
  • Gas is charged to the transferrer. Every mint and every secondary transfer pays for the hook's work. Storage writes on each transfer are the usual cost driver, and an expensive hook makes the token more expensive to move for its whole life.

Reference implementations

Art Blocks deploys two first-party hooks. Both are already deployed and verified, so a project can configure either without writing or deploying anything.

OwnerHistoryTransferHook

Art Blocks deploys OwnerHistoryTransferHook at 0x00000000cb60788043f4F779bfC192F1c5bd09FA on every supported network. It records each token's chain of owners on chain and exposes it through ownerHistory, previousOwners, lastRecordedOwner, and ownerHistoryLength.

It is inert until a project opts in: it verifies with the calling core that it is the hook configured for that project, and reverts otherwise. It never blocks a transfer. Because it is already deployed and verified, a project wanting on-chain provenance can configure it directly without writing or deploying anything.

MintTimeAndTransferCountHooks

MintTimeAndTransferCountHooks is a combined hook — a transfer hook and a PostParams read-augment hook in one contract. It records each token's mint timestamp and its count of ownership-changing transfers, and injects three values into the token's params on every read:

Key Value
mintTimestamp Unix seconds of the mint block, or "0" if the hook was not configured before the mint
secondsSinceMint Computed at read time, so it changes every block
transferCount Ownership-changing transfers after the mint, excluding the mint itself

Configure it as the project's transfer hook to record, and as its read-augment hook to inject. A project can do one without the other.

It also answers the re-render gotcha below. If the project configures a transferCount param with Address authorization pointing at this hook, the hook writes that value to the PMP contract on each counted transfer, which is what triggers an off-chain re-render. That write is try/catch'd and emits TransferCountPMPSyncFailed on failure, so a misconfigured param costs a stale image rather than a frozen collection.

Network Address
Mainnet, Arbitrum, Base, Shape, Sepolia (artist staging) 0x000000002099d6BB23Ebd24aDCbee931ad461a39
Sepolia (dev) 0x2B530627ed72e3F77EAC0d1c8b3904E6d8f67c25

The two differ because the hook is constructor-bound to a PMP contract, and Sepolia dev runs its own PMP instance.

Gotchas

A hook does not re-render the token. Running a hook is not a signal to the rendering pipeline, so a token's stored image and features stay as they were. This matters most for the projects most likely to want a hook: if the artwork reacts to its owner — say through the InjectTokenOwner augment hook — the live generator reflects the new owner immediately, while the thumbnail on artblocks.io and in marketplaces still shows the previous owner's output.

Writing a PostParam is the signal that does trigger a re-render, along with a recompute of the token's features. So a hook that needs the image to follow the transfer should write one from inside onTokenTransfer — which is exactly what MintTimeAndTransferCountHooks does, if you would rather configure a deployed hook than write one. That works because a PostParam can be authorized to a specific address rather than to the artist or the collector: configure the parameter with the Address authorization option, set its authorized address to the hook contract, and the hook can then call configureTokenParams on the PMP contract as the transfer happens.

Two things to keep in mind if you do this. The core blocks reentrant mints and transfers for the duration of the hook, but not calls to other contracts, so writing to the PMP contract is allowed. And it is a storage write on top of a storage write — every transfer of every token in the project pays for both, forever.

A hook affects mints, not just secondary transfers. A hook that reverts under some condition will also block minting under that condition. Test the mint path.

Setting a hook does not backfill. A hook only sees transfers that happen after it is configured. Any history from before is not available to it.

Clearing a hook does not undo its effects. State a hook has already written stays written, and remains readable from the hook contract.

Marketplaces may not anticipate a reverting hook. A transfer that a hook rejects fails like any other failed transfer, which some interfaces surface poorly. Hooks that can block transfers deserve clear communication to collectors.

Additional resources

Have an idea for a hook, or questions about whether one fits your project? Reach out to the Art Blocks team.