HIP-1195: Hiero hooks and an application to allowances
Author | Michael Tinker |
---|---|
Working Group | Richard Bair, Leemon Baird, Jasper Potts, Atul Mahamuni, Matthew DeLorenzo, Giuseppe Bertone, Greg Scullard, Steven Sheehy |
Requested By | Hashgraph |
Discussions-To | https://github.com/hiero-ledger/hiero-improvement-proposals/discussions/1172 |
Status | Approved ⓘ |
Needs Hedera Review | Yes ⓘ |
Needs Hiero Approval | Yes ⓘ |
Last Call Period Ends ⓘ | Fri, 06 Jun 2025 07:00:00 +0000 |
Type | Standards Track ⓘ |
Category | Service ⓘ |
Created | 2025-02-19 |
Updated | 2025-07-11 |
Table of Contents
Abstract
We propose hooks, programmable Hiero extension points that let users customize the behavior of their entities. In principle, hooks could be programmed in any language, but we begin with EVM hooks. Users program EVM hooks by writing contracts in a language like Solidity that compiles to EVM bytecode. EVM hooks are either pure (using neither storage nor external contracts); or lambdas (like event-driven code running in the cloud, that may keep state in a database to use state or call other external services). For any given entity, its user can create many hooks for that entity with different 64-bit hook ids. There is no limit on the number of hook ids than an entity can use; but its storage footprint, and hence rent, will increase proportionally.
As a first Hiero extension point, we propose account allowance hooks. Users can create these hooks on their
accounts, and a Hiero API (HAPI) CryptoTransfer
transaction can then reference an allowance hook just as it does an
ERC-style allowance defined in HIP-376. The network uses the allowance hook by
calling its EVM bytecode at a well-known function signature, passing in the context of the triggering CryptoTransfer
.
If the hook function returns true
, the network continues executing the transfer; otherwise the network rejects the
transfer. Creating an account hook is analogous to adding a function to a smart contract: That is, the hook executes
with the account’s privileges when calling Hedera system contracts, just as a smart contract’s functions do.
Unlike smart contracts, which must encapsulate trust guarantees for multiple parties, lambda hooks belong to a single
owner who can directly update their storage via a new LambdaSStore
transaction that acts on EVM storage slots. This
permits fast, heap adjustments to a lambda’s behavior with less overhead than a typical ConsensusSubmitMessage
; and
far less overhead than a ContractCall
.
Motivation
Hedera users often want to customize native entities instead of migrating their decentralized applications (dApps) exclusively to EVM smart contracts. Consider the following examples:
- HIP-18: Custom Hedera Token Service Fees introduced custom fee payments for HTS transfers.
- HIP-904: Frictionless Airdrops enabled more permissive token association policies.
- HIP-991: Permissionless revenue-generating Topic Ids for Topic Operators proposed fee-based access control for message submissions to topics.
Hooks provide a more general solution to the problem of users needing custom business logic for their entities. For example, a token issuer might need to enforce rules on every transaction involving their token for regulatory or business reasons. A transfer hook for token entities could enforce these rules without requiring the issuer to take a full design proposal through the HIP process. It would also preserve the performance and simplicity of the native APIs, unlike moving everything into custom ERC-20 smart contracts.
In short, by avoiding protocol-level changes for every important customization, hooks can greatly streamline innovation on a Hiero network while maintaining the performance and integrity of the native services.
EVM hook specification
First we specify how a Hiero network will charge, throttle, and execute EVM hooks. The execution section details how the EVM transaction for a hook differs from the EVM transaction of a top-level contract call. (Non-EVM hook programming models would need their own specifications in new HIPs.)
The protobuf API for hooks in general, and EVM hooks in particular, follows in later sections.
Gas charging
A primary concern for EVM hooks is deciding what account pays for the EVM gas upfront before executing the hook. We propose a simple, unified approach that hooks themselves can optimize with refund logic if desired. That is, for every hook,
- The payer of the transaction triggering the hook pays for the upfront gas cost.
- The hook API includes the gas fee charged as an API parameter, so the hook can refund some or all of this gas cost.
- The payer will never be charged for more gas than a gas limit they set at the hook reference point.
We propose the same gas price for EVM hook execution as for other contract operations. However, unlike contract calls, which are charged purely in gas, hook executions are already “gated” by the fee of their triggering transaction. So it makes sense to reduce the intrinsic gas cost of their execution. We propose adding two more properties to give this effect while keeping it customizable for Hiero network operators.
hooks.evm.pureIntrinsicGasCost=1000
hooks.evm.lambdaIntrinsicGasCost=1000
Rent collection
An account’s storage footprint in network state grows with its number of associated tokens, and a contract’s storage footprint grows with its number of used storage slots. In the same way, an entity’s storage footprint will grow with its number of hooks; and the number of storage slots used by those hooks.
At the time of this HIP, rent was not yet enabled on Hedera mainnet, but it is inevitable it will be in the future. To support seamless extension of rent to hooks, we propose to keep in network state two pieces of summary data for each entity using hooks:
- The number of hooks the entity has; and,
- The total number of storage slots used by the entity’s lambdas.
The rent of an entity with N
hooks using S
storage slots will then scale linearly with N
and S
.
Throttling
We propose EVM hooks be subject to the same gas throttle as top-level contract calls. Specifically, when an EVM hook
executes, its initial EVM sender address is the payer of the referencing transaction. If this payer is throttle exempt
(that is, a system account with entity number below accounts.lastThrottleExempt=100
), no throttles are applied.
Otherwise, if the network is at capacity for gas usage, EVM hook execution will be throttled on that basis and the
triggering transaction fail with a status of CONSENSUS_GAS_EXHAUSTED
, just as a top-level contract call would.
The network will also throttle the rate at which hooks can be created, using the same throttle buckets as for contract
creation, with pricing similar to the current 1.00 USD
cost of the HAPI contract create operation. (Or likely higher,
to amortize the extra complexity of the hook lifecycle.) The dApps that build valuable metaprotocols using hooks may
subsidize this cost to ease user onboarding.
The EVM environment for hooks
There are two important differences between the EVM execution environment for a hook and a top-level contract call. Namely,
- Throughout the hook’s EVM transaction, its bytecode always has the special address
0x16d
. - The hook is an extension of its entity; it has that entity’s Hiero system contract privileges.
Everything else is identical to the EVM environment of a top-level contract call in the same block.
Important: We recognize, and strongly affirm, the sensitivity and power of making an entity’s hooks an extension of the entity itself. Users with security hygiene will not casually draft and use hooks. The hooks in broad use will have been published as application HIPs that specify valuable metaprotocols; and will have been extensively reviewed, debated, and audited; and ultimately given special treatment by ecosystem wallets and block explorers.
We now examine the two differences in the EVM hook execution environment in more detail.
0x16d
contract address
Conceptually, the initial EVM message frame for a hook transaction is a DELEGATECALL
from the system contract
address 0x16d
to the hook’s implementing contract. That is, even though the hook’s implementing bytecode was created
with a Hiero contract id 0.0.H
, that bytecode executes with a contract address of 0x16d
. If it calls another
contract and that contract, in turn, calls back to address 0x16d
, then control flow returns to the hook’s bytecode.
As a concrete example, suppose a user creates a ACCOUNT_ALLOWANCE_HOOK
lambda with hook id 1
for account 0.0.A
.
The hook’s implementing contract is 0.0.H
with EVM address 0xab...cd
. Now 0.0.B
with EVM address 0x01...23
sends a CryptoTransfer
transaction that references the hook 0.0.A/1
with gas limit 100_000
.
The network will construct an initial EVM message frame with,
sender
address0x01...23
; and,receiver
address0x16d
; and,contract
address0xab...cd
(hence the source of the executing bytecode); and,- Storage of the
0.0.A/1
lambda; and, - Gas remaining of
99_000
(lower intrinsic gas cost).
The lambda can then proceed with arbitrary actions, including calls to other contracts, SLOAD
and SSTORE
operations
with its storage, and so on. We expect the most common type of EVM hook contract to implement a single external method
that reverts when not executed by the network as a hook. That is,
/// The interface for an EVM hook.
interface IHieroHook {
/// The context the hook is executing in
struct HookContext {
/// The address of the entity the hook is executing on behalf of
address owner;
/// The fee the transaction payer was charged for the triggering transaction
uint256 txnFee;
/// The gas cost the transaction payer was charged for specifically this hook
uint256 gasCost;
/// The memo of the triggering transaction
string memo;
/// Any extra call data passed to the hook
bytes data;
}
}
/// A contract serving as an EVM hook.
contract HookContract {
/// A single external function to be executed as a hook.
function hookFunction(IHieroHook.HookContext calldata context) external payable {
// Revert if we are not executing as a hook
require(address(this) == 0x16d, "Contract can only be called as a hook");
// Continue executing as a hook on behalf of the owner
}
}
(Note that the hookFunction(IHieroHook.HookContext)
signature above is just a representative placeholder. Although the
IHieroHook.HookContext context
parameter will be universal to all hooks, many hooks—such as the ACCOUNT_ALLOWANCE
hook in this HIP—will specify more parameters.)
Privileges of the owner
A hook is an extension of its owning entity, in the style of Ethereum’s account abstraction vision. This means the hook has the Hiero privileges of the entity. If the hook calls Hiero system contracts, they execute on behalf of the entity.
And if the owning entity has a balance, the hook can use that balance to transfer value; for example, to refund gas fees
to msg.sender
.
Once again, we realize and affirm that programmatic entity extension introduces security risks. But users that limit themselves to hooks that the community has audited and adopted through application HIPs can then enjoy the full power of EVM programmability without incurring any special risk.
Pure EVM hooks
Pure EVM hooks execute in the same conceptual model, but in an extremely restricted mode. Not only is the initial EVM
frame marked static
, which prohibits all state-changing operations; but the network also disables the PREVRANDAO
,
SLOAD
, and CALL
opcodes. That is, a pure hook cannot do anything but apply a pure function to its input data.
Mirror node and block explorer support
Mirror nodes and block explorers will want to give at least summary data of an entity’s hooks. For example, the Hedera
public mirror node has an /api/v1/accounts/{accountId}
endpoint
that returns a JSON object with the account’s current balance, keys, and so on. We propose this JSON object be extended
with fields number_hooks
and total_lambda_storage_slots
to give the number of account’s hooks and the total number
of storage slots used by its lambda.
It would also be natural to add a new /api/v1/accounts/{accountId}/hooks
endpoint that returns a paged JSON
object with at least a subset of the information in the example below.
{
"hooks": [
{
/* The entity that owns the hook */
"owner_id": "0.0.123",
/* May be composed with owner id: {owner_id}/{hook_id} */
"hook_id": "1",
/* Extension point this hook implements */
"extension_point": "ACCOUNT_ALLOWANCE_HOOK",
/* PURE | LAMBDA */
"type": "LAMBDA",
/* Contract that contains the executing byte-code */
"hook_contract_id": "0.0.456",
/* Storage utilisation summary (for rent & UI hints) */
"num_storage_slots": 12,
/* Lifecycle metadata */
"created_timestamp": "1726874345.123456789",
"deleted": false,
/* Optional storage key that can manage the hook's EVM storage */
"storage_key": {
"_type": "ED25519",
"key": "0x302a300506032b6570032100e5b2…"
},
/* Convenience links into other mirror node resources */
"links": {
"self": "/api/v1/accounts/0.0.123/hooks/1",
"contract": "/api/v1/contracts/0.0.456",
"storage": "/api/v1/accounts/0.0.123/hooks/1/storage"
}
}
],
/* Standard mirror node paging wrapper */
"links": {
"next": null
}
}
Core HAPI protobufs
A hook’s extension point is one of an enumeration which will first be just the account allowance hook in this HIP.
/***
* The Hiero extension points that accept a hook.
*/
enum HookExtensionPoint {
/**
* Used to customize an account's allowances during a CryptoTransfer transaction.
*/
ACCOUNT_ALLOWANCE_HOOK = 0;
}
Users create hooks by setting new HookCreationDetails
fields on CryptoCreate
, CryptoUpdate
, ContractCreate
,
or ContractUpdate
transactions. This message is,
/***
* The details of a hook's creation.
*/
message HookCreationDetails {
/**
* The extension point for the hook.
*/
HookExtensionPoint extension_point = 1;
/**
* The id to create the hook at.
*/
uint64 hook_id = 2;
/**
* The hook implementation.
*/
oneof hook {
/**
* A hook programmed in EVM bytecode that does not require access to state
* or interactions with external contracts.
*/
PureEvmHook pure_evm_hook = 3;
/**
* A hook programmed in EVM bytecode that may access state or interact with
* external contracts.
*/
LambdaEvmHook lambda_evm_hook = 4;
}
/**
* If set, a key that that can be used to remove or replace the hook; or (if
* applicable, as with a lambda EVM hook) perform transactions that customize
* the hook.
*/
proto.Key admin_key = 5;
}
The PureEvmHook
and LambdaEvmHook
messages share a common EvmHookSpec
message that specifies the source of the
hook’s EVM bytecode. The LambdaEvmHook
message also includes the initial storage slots for a lambda hook, if desired.
/**
* Definition of a pure EVM hook.
*/
message PureEvmHook {
/**
* The specification for the hook.
*/
EvmHookSpec spec = 1;
}
/**
* Definition of a lambda EVM hook.
*/
message LambdaEvmHook {
/**
* The specification for the hook.
*/
EvmHookSpec spec = 1;
/**
* Initial storage updates for the lambda, if any.
*/
repeated LambdaStorageUpdate storage_updates = 2;
}
/**
* Shared specifications for an EVM hook. May be used for any extension point.
*/
message EvmHookSpec {
/**
* The source of the EVM bytecode for the hook.
*/
oneof bytecode_source {
/**
* The id of a contract that implements the extension point API with EVM bytecode.
*/
ContractID contract_id = 1;
}
}
/**
* Specifies a key/value pair in the storage of a lambda, either by the explicit storage
* slot contents; or by a combination of a Solidity mapping's slot key and the key into
* that mapping.
*/
message LambdaStorageUpdate {
oneof update {
/**
* An explicit storage slot update.
*/
LambdaStorageSlot storage_slot = 1;
/**
* An implicit storage slot update specified as a Solidity mapping entry.
*/
LambdaMappingEntry mapping_entry = 2;
}
}
/**
* Specifies a storage slot update via indirection into a Solidity mapping.
* <p>
* Concretely, if the Solidity mapping is itself at slot `mapping_slot`, then
* the * storage slot for key `key` in the mapping is defined by the relationship
* `key_storage_slot = keccak256(abi.encodePacked(mapping_slot, key))`.
* <p>
* This message lets a metaprotocol be specified in terms of changes to a
* Solidity mapping's entries. If only raw slots could be updated, then a block
* stream consumer following the metaprotocol would have to invert the Keccak256
* hash to determine which mapping entry was being updated, which is not possible.
*/
message LambdaMappingEntry {
/**
* The slot that corresponds to the Solidity mapping itself.
*/
bytes mapping_slot = 1;
/**
* The 32-byte key of the mapping entry; leading zeros may be omitted.
*/
bytes key = 2;
/**
* If the mapping entry is present and non-zero, the 32-byte value of key in the mapping;
* leading zeros may be omitted. Leaving this field empty or setting it to binary zeros
* in an update clears the entry from the mapping.
*/
bytes value = 3;
}
/**
* A slot in the storage of a lambda EVM hook.
*/
message LambdaStorageSlot {
/**
* The 32-byte key of the slot; leading zeros may be omitted.
*/
bytes key = 1;
/**
* If the slot is present and non-zero, the 32-byte value of the slot;
* leaving this field empty or setting it to binary zeros in an update
* removes the slot.
*/
bytes value = 2;
}
Once a hook is created for an entity, a transaction references the hook by its id relative to an implicit owner.
Additional details for the hook’s execution may be given as well; for example, EVM hook calls are specified by an
EvmHookCall
message that gives extra call data and gas limit.
If the called hook does not match the provided specification, the network will fail the transaction with
BAD_HOOK_REQUEST
. If the relevant entity has no hook with the given id, the network will fail the transaction with
HOOK_NOT_FOUND
.
/**
* Specifies a call to a hook from within a transaction where the hook owner is implied by the point of use.
* <p>
* For example, if the hook is an account allowance hook, then it is clear from the balance adjustment being
* attempted which account must own the referenced hook.
*/
message HookCall {
/**
* The id of the hook to call.
*/
uint64 hook_id = 1;
/**
* Specifies details of the call.
*/
oneof call_spec {
/**
* Specification of how to call an EVM hook.
*/
EvmHookCall evm_hook_call = 2;
}
}
/**
* Specifies details of a call to an EVM hook.
*/
message EvmHookCall {
/**
* Call data to pass to the hook via the IHieroHook.HookContext#data field.
*/
bytes data = 1;
/**
* The gas limit to use.
*/
uint64 gas_limit = 2;
}
Core system protobufs
Once a hook is created, it has a unique composite id in the network state. The two components are the id of its owning entity and a hook id. The hook id is an arbitrary 64-bit value that need not be sequential relative to other hooks owned by the entity. However, an entity may only have one hook with a certain id at a time.
/**
* Once a hook is created, its full id.
* <p>
* A composite of its owning entity's id and an arbitrary 64-bit hook id (which need not be sequential).
*/
message CreatedHookId {
/**
* The hook's owning entity id.
*/
HookEntityId entity_id = 1;
/**
* An arbitrary 64-bit identifier.
*/
int64 hook_id = 2;
}
/**
* The id of an entity using a hook.
*/
message HookEntityId {
oneof entity_id {
/**
* An account using a hook.
*/
AccountID account_id = 1;
}
}
EVM hooks will be implemented by internal dispatch from each owning entity type’s service to the ContractService
.
(A hook with a different programming model would require very different implementation details, so we restrict our
attention to EVM hooks.)
The dispatch for creating, deleting, and executing, EVM hooks is a new HookDispatchTransactionBody
with a choice
of three actions.
/**
* Dispatches a hook action to an appropriate service.
*/
message HookDispatchTransactionBody {
oneof action {
/**
* The id of the hook to delete.
*/
CreatedHookId hook_id_to_delete = 1;
/**
* A new hook creation.
*/
HookCreation creation = 2;
/**
* Execution details of an existing hook.
*/
HookExecution execution = 3;
}
}
/**
* Details the execution of a hook.
*/
message HookExecution {
/**
* The id of the hook's entity.
*/
HookEntityId hook_entity_id = 1;
/**
* The details of the call, including which hook id to call.
*/
HookCall call = 2;
}
Executing an EVM hook produces ContractCall
block items (SignedTransation
, TransactionResult
, TransactionOutput
,
and possibly TraceData
) as following synthetic children of the triggering transaction, in the order of each executed
hook.
When an EVM hook is created, its representation in ContractService
state is as below. (Note the previous_hook_id
and next_hook_id
pointers are an implementation detail transparent to the user. The protocol uses them to efficiently
traverse the list of hooks when archiving the account.)
/**
* The representation of an EVM hook in state, including any previous and next hook ids in its owner's list.
*/
message EvmHookState {
/**
* For state proofs, the id of this hook.
*/
proto.CreatedHookId hook_id = 1;
/**
* The type of the EVM hook.
*/
EvmHookType type = 2;
/**
* The type of the extension point the hook implements.
*/
proto.HookExtensionPoint extension_point = 3;
/**
* The id of the contract with this hook's bytecode.
*/
proto.ContractID hook_contract_id = 4;
/**
* True if the hook has been deleted.
*/
bool deleted = 5;
/**
* For a lambda, its first storage key.
*/
bytes first_contract_storage_key = 6;
/**
* If set, the id of the hook preceding this one in the owner's
* doubly-linked list of hooks.
*/
google.protobuf.Int64Value previous_hook_id = 7;
/**
* If set, the id of the hook following this one in the owner's
* doubly-linked list of hooks.
*/
google.protobuf.Int64Value next_hook_id = 8;
/**
* The number of storage slots a lambda is using.
*/
uint32 num_storage_slots = 9;
}
/**
* The type of an EVM hook.
*/
enum EvmHookType {
/**
* A pure EVM hook.
*/
PURE = 0;
/**
* A lambda EVM hook.
*/
LAMBDA = 1;
}
And its storage is keyed by the following type,
/**
* The key of a lambda's storage slot.
*
* For each lambda, its EVM storage is a mapping of 256-bit keys (or "words")
* to 256-bit values.
*/
message LambdaSlotKey {
/**
* The id of the lambda EVM hook that owns this slot.
*/
proto.CreatedHookId hook_id = 1;
/**
* The EVM key of this slot; may be left-padded with zeros to form a 256-bit word.
*/
bytes key = 2;
}
After an entity has a lambda, a new LambdaSStore
transaction supports efficiently updating the lambda’s storage. It
must be signed by either the entity’s controlling key or the admin key set in the lambda’s HookCreationDetails
.
/**
* Adds or removes key/value pairs in the storage of a lambda.
* <p>
* Either the lambda owner's key or the lambda's admin key must sign this transaction.
*/
message LambdaSStoreTransactionBody {
/**
* The id of the lambda whose storage is being updated.
*/
CreatedHookId hook_id = 1;
/**
* The updates to the storage of the lambda.
*/
repeated LambdaStorageUpdate storage_updates = 2;
}
Account allowance HAPI protobufs
The account allowance extension point is the only extension point defined in this HIP. Hooks of this type are
created for an account via either a CryptoCreate
or CryptoUpdate
transaction; and for a contract account via
either a ContractCreate
or ContractUpdate
transaction. A future HIP might add Hiero system contracts to let
a contract manage its hooks programmatically, but that is outside our scope here. (Note this also means hooks
cannot be managed via a JSON-RPC relay; only through native HAPI transactions.)
Here we simply extend the CryptoCreateTransactionBody
and ContractCreateTransactionBody
messages with a field to
create initial hooks along with an account or contract; and the CryptoUpdateTransactionBody
and
ContractUpdateTransactionBody
messages with fields to create and delete hooks from existing accounts and contracts.
message CryptoCreateTransactionBody {
// ...
/**
* Details of hooks to add immediately after creating this account.
*/
repeated HookCreationDetails hook_creation_details = 19;
}
message ContractCreateTransactionBody {
// ...
/**
* Details of hooks to add immediately after creating this contract.
*/
repeated HookCreationDetails hook_creation_details = 20;
}
message CryptoUpdateTransactionBody {
// ...
/**
* The hooks to create for the account.
*/
repeated HookCreationDetails hook_creation_details = 19;
/**
* The ids the hooks to delete from the account.
*/
repeated int64 hook_ids_to_delete = 20;
}
message ContractUpdateTransactionBody {
// ...
/**
* The hooks to create for the contract.
*/
repeated HookCreationDetails hook_creation_details = 16;
/**
* The ids the hooks to delete from the contract.
*/
repeated int64 hook_ids_to_delete = 20;
}
If any transaction repeats a hook id in its hook_creation_details
list, it will fail with status
HOOK_ID_REPEATED_IN_CREATION_DETAILS
. If the CryptoUpdateTransactionBody
or ContractUpdateTransactionBody
tries
to create a hook with an id that is already occupied, it will fail with status INDEX_IN_USE
. Similarly, if either
update transaction tries to delete a hook with an id not in use, it will fail with status HOOK_NOT_FOUND
. To support
atomic hook updates for compliance reasons, we do support deleting and recreating a hook with the same id in a
single update transaction. (That is, the hook_ids_to_delete
list is processed first; then the hook_creation_details
list.)
We extend the Account
message in TokenService
state to include the number of hooks in use by an account or contract,
and the total number of storage slots their lambdas use; as well as the id of the last hook in the doubly-linked list of
the account or contract’s hooks.
message Account {
// ...
/**
* The number of hooks currently in use on this account.
*/
uint64 number_hooks_in_use = 36;
/**
* If the account has more than zero hooks in use, the id of the first hook in its
* doubly-linked list of hooks.
*/
int64 first_hook_id = 37;
/**
* The number of storage slots in use by this account's lambdas.
*/
uint64 number_lambda_storage_slots = 38;
}
Now we need to let a CryptoTransfer
reference such a hook. For this we extend the AccountAmount
and NftTransfer
messages used in the CryptoTransferTransactionBody
.
message AccountAmount {
// ...
/**
* If set, a call to a hook of type `ACCOUNT_ALLOWANCE_HOOK` on scoped
* account; the hook's invoked methods must not revert and must return
* true for the containing CryptoTransfer to succeed.
*/
oneof hook_call {
/**
* A single call made before attempting the CryptoTransfer, to a
* method with logical signature allow(HookContext, ProposedTransfers)
*/
HookCall pre_tx_allowance_hook = 4;
/**
* Two calls, the first call before attempting the CryptoTransfer, to a
* method with logical signature allowPre(HookContext, ProposedTransfers);
* and the second call after attempting the CryptoTransfer, to a method
* with logical signature allowPost(HookContext, ProposedTransfers).
*/
HookCall pre_post_tx_allowance_hook = 5;
}
}
message NftTransfer {
// ...
/**
* If set, a call to a hook of type `ACCOUNT_ALLOWANCE_HOOK` installed on
* senderAccountID that must succeed for the transaction to occur.
*/
oneof sender_allowance_hook_call {
/**
* A single call made before attempting the CryptoTransfer, to a
* method with logical signature allow(HookContext, ProposedTransfers)
*/
HookCall pre_tx_sender_allowance_hook = 5;
/**
* Two calls, the first call before attempting the CryptoTransfer, to a
* method with logical signature allowPre(HookContext, ProposedTransfers);
* and the second call after attempting the CryptoTransfer, to a method
* with logical signature allowPost(HookContext, ProposedTransfers).
*/
HookCall pre_post_tx_sender_allowance_hook = 6;
}
/**
* If set, a call to a hook of type `ACCOUNT_ALLOWANCE_HOOK` installed on
* receiverAccountID that must succeed for the transaction to occur.
*/
oneof receiver_allowance_hook_call {
/**
* A single call made before attempting the CryptoTransfer, to a
* method with logical signature allow(HookContext, ProposedTransfers)
*/
HookCall pre_tx_receiver_allowance_hook = 7;
/**
* Two calls, the first call before attempting the CryptoTransfer, to a
* method with logical signature allowPre(HookContext, ProposedTransfers);
* and the second call after attempting the CryptoTransfer, to a method
* with logical signature allowPost(HookContext, ProposedTransfers).
*/
HookCall pre_post_tx_receiver_allowance_hook = 8;
}
}
Note that NftTransfer
supports both sender and receiver transfer allowance hooks, since the transaction may
need to use the receiver hook to satisfy a receiver_sig_required=true
setting.
The transfer allowance ABI
The account allowance EVM hook ABI is as follows,
// SPDX-License-Identifier: Apache-2.0
pragma solidity >=0.4.9 <0.9.0;
pragma experimental ABIEncoderV2;
import './IHederaTokenService.sol';
/// The interface for a generic EVM hook.
interface IHieroHook {
/// The context the hook is executing in
struct HookContext {
/// The address of the entity the hook is executing on behalf of
address owner;
/// The fee the transaction payer was charged for the triggering transaction
uint256 txnFee;
/// The gas cost the transaction payer was charged for specifically this hook
uint256 gasCost;
/// The memo of the triggering transaction
string memo;
/// Any extra call data passed to the hook
bytes data;
}
}
/// The interface for an account allowance hook invoked once before a CryptoTransfer.
interface IHieroAccountAllowanceHook {
/// Combines HBAR and HTS asset transfers.
struct Transfers {
/// The HBAR transfers
IHederaTokenService.TransferList hbar;
/// The HTS token transfers
IHederaTokenService.TokenTransferList[] tokens;
}
/// Combines the full proposed transfers for a Hiero transaction,
/// including both its direct transfers and the implied HIP-18
/// custom fee transfers.
struct ProposedTransfers {
/// The transaction's direct transfers
Transfers direct;
/// The transaction's assessed custom fees
Transfers customFee;
}
/// Decides if the proposed transfers are allowed, optionally in
/// the presence of additional context encoded by the transaction
/// payer in the extra calldata.
/// @param context The context of the hook call
/// @param proposedTransfers The proposed transfers
/// @return true If the proposed transfers are allowed, false or revert otherwise
function allow(
IHieroHook.HookContext calldata context,
ProposedTransfers memory proposedTransfers
) external payable returns (bool);
}
/// The interface for an account allowance hook invoked both before and after a CryptoTransfer.
interface IHieroAccountAllowancePrePostHook {
/// Combines HBAR and HTS asset transfers.
struct Transfers {
/// The HBAR transfers
IHederaTokenService.TransferList hbar;
/// The HTS token transfers
IHederaTokenService.TokenTransferList[] tokens;
}
/// Combines the full proposed transfers for a Hiero transaction,
/// including both its direct transfers and the implied HIP-18
/// custom fee transfers.
struct ProposedTransfers {
/// The transaction's direct transfers
Transfers direct;
/// The transaction's assessed custom fees
Transfers customFee;
}
/// Decides if the proposed transfers are allowed BEFORE the CryptoTransfer
/// business logic is performed, optionally in the presence of additional
/// context encoded by the transaction payer in the extra calldata.
/// @param context The context of the hook call
/// @param proposedTransfers The proposed transfers
/// @return true If the proposed transfers are allowed, false or revert otherwise
function allowPre(
IHieroHook.HookContext calldata context,
ProposedTransfers memory proposedTransfers
) external payable returns (bool);
/// Decides if the proposed transfers are allowed AFTER the CryptoTransfer
/// business logic is performed, optionally in the presence of additional
/// context encoded by the transaction payer in the extra calldata.
/// @param context The context of the hook call
/// @param proposedTransfers The proposed transfers
/// @return true If the proposed transfers are allowed, false or revert otherwise
function allowPost(
IHieroHook.HookContext calldata context,
ProposedTransfers memory proposedTransfers
) external payable returns (bool);
}
Examples
Next we provide two examples of account allowance EVM hooks.
One-time passcode allowances
An NFT project prides itself on having only the very cleverest holders. They distribute their collection by daily
sending a NFT from the treasury to account 0.0.X
, and publishing a puzzle. The answer to the puzzle is a one-time
use passcode that allows the solver to collect the NFT held by 0.0.X
.
In particular, the project team creates a custom hook on account 0.0.X
with hook id 1
. It is an account allowance
lambda EVM hook that references the following contract.
import "./IHieroAccountAllowanceHook.sol";
contract OneTimeCodeTransferAllowance is IHieroAccountAllowanceHook {
/// The hash of a one-time use passcode string, at storage slot 0x00
bytes32 passcodeHash;
/// Allow the proposed transfers if and only if the args are the
/// ABI encoding of the current one-time use passcode in storage.
///
/// NOTE: this lambda's behavior does not depend on what owning
/// address is set in `context.owner`; it depends only the contents
/// of the active lambda's 0x00 storage slot.
function allow(
IHieroHook.HookContext calldata context,
ProposedTransfers memory proposedTransfers
) external override payable returns (bool) {
require(address(this) == 0x16d, "Contract can only be called as a hook");
(string memory passcode) = abi.decode(context.args, (string));
bytes32 hash = keccak256(abi.encodePacked(passcode));
bool matches = hash == passcodeHash;
if (matches) {
passcodeHash = 0;
}
return matches;
}
}
As great aficionados of the project, we see one day that 0.0.X
holds our favorite NFT of all, serial 123
; and that a
LambdaSStore
from 0.0.X
set the storage slot with key 0x00
to the hash
0xc7eba0ccc01e89eb5c2f8e450b820ee9bb6af63e812f7ea12681cfdc454c4687
. We rush to solve the puzzle, and deduce the
passcode is the string, "These violent delights have violent ends"
. Now we can transfer the NFT to our account 0.0.U
by submitting a CryptoTransfer
with,
NftTransfer {
senderAccountID: 0.0.X
receiverAccountID: 0.0.U
serialNumber: 123
sender_allowance_hook_call: HookCall {
hook_Id: 1
evm_hook_call: EvmHookCall {
data: "These violent delights have violent ends"
gas_limit: 30_000
}
}
}
Compare this example to the pure smart contract approach, where the project’s team would need to write a more complex
smart contract that is aware of what serial number it currently holds, and makes calls to the HTS system contract to
distribute NFTs. Instead of the team using LambdaSStore
to update the passcode with less overhead and cost to
the network than even a ConsensusSubmitMessage
, they would need to submit a ContractCall
. Instead of us using a
CryptoTransfer
to collect our prize with maximum legibility and minimum cost, we would also need to submit a
ContractCall
to the project’s smart contract with a significantly higher gas limit.
For a trivial example like this, the cost and efficiency deltas may not seem decisive (unless the project was running a very large number of these puzzles). But the idea of releasing contracts from the burden of duplicating native protocol logic is deceptively powerful. The cost and efficiency savings for a complex dApp could be enormous, unlocking entire new classes of applications that would be impractical to build on Hedera today.
Receiver signature waiver for HTS assets without custom fees
In this example we have our own account 0.0.U
with receiver_sig_required=true
, and want to carve out an exception
for exactly HTS token credits to our account with no assessed custom fees. We create a pure EVM hook with id 2
whose referenced contract is as follows,
import "./IHederaTokenService.sol";
import "./IHieroAccountAllowanceHook.sol";
contract CreditSansCustomFeesTokenAllowance is IHieroAccountAllowanceHook {
/// Allows the proposed transfers only if,
/// (1) The only transfers are direct HTS asset transfers
/// (2) The owner is not debited
/// (3) The owner is credited
function allow(
IHieroHook.HookContext calldata context,
ProposedTransfers memory proposedTransfers
) external override view returns (bool) {
require(address(this) == 0x16d, "Contract can only be called as a hook");
if (proposedTransfers.direct.hbar.transfers.length > 0
|| proposedTransfers.customFee.hbar.transfers.length > 0
|| proposedTransfers.customFee.tokens.length > 0) {
return false;
}
bool ownerCredited = false;
address owner = context.owner;
for (uint256 i = 0; i < proposedTransfers.tokens.length; i++) {
IHederaTokenService.AccountAmount[] memory transfers = proposedTransfers.tokens[i].transfers;
for (uint256 j = 0; j < transfers.length; j++) {
if (transfers[j].accountID == owner) {
if (transfers[j].amount < 0) {
return false;
} else if (transfers[j].amount > 0) {
ownerCredited = true;
}
}
}
IHederaTokenService.NftTransfer[] memory nftTransfers = proposedTransfers.tokens[i].nftTransfers;
for (uint256 j = 0; j < nftTransfers.length; j++) {
if (nftTransfers[j].senderAccountID == owner) {
return false;
} else if (nftTransfers[j].receiverAccountID == owner) {
ownerCredited = true;
}
}
}
return ownerCredited;
}
}
Backwards Compatibility
This HIP adds a net new feature to the protocol. Any account that does not use hooks will see identical behavior in all circumstances. Any payer account that does not explicitly set a gas limit to cover a hook’s execution will be at no risk of paying for a hook’s execution.
Security Implications
Since EVM hook executions are subject to the same gas charges and throttles as normal contract executions; and hook creations are subject to the same throttles as contract creations, we do not expect this HIP to introduce any new denial of service vector.
The main security concerns with account allowance hooks are the same as with smart contracts. That is,
- A hook author could code a bug allowing an attacker to exploit the hook.
- A malicious dApp could trick a user into using a hook with a backdoor for the dApp author to exploit.
Hook authors must mitigate the risk of bugs by rigorous testing and code review. Users must remain vigilant about signing transactions from dApps of questionable integrity. As reiterated above, we recommend and expect that hooks with broad usage will be published as application HIPs, and that users will adopt them only as wallets and block explorers give full support and visibility into their semantics.
How to Teach This
Hooks let users customize a Hiero network with their own metaprotocols without needing to change the L1 itself.
Reference Implementation
In progress, please see here.
Rejected Ideas
- We considered automatic hooks that execute even without being explicitly referenced by a transaction. While this feature could be useful in the future (for example, as an “anti-dusting” hook), we deemed it out of scope here.
- We considered adding an
IHieroExecutionEnv
interface to the0x16d
system contract with APIs available only to executing EVM hooks. While interesting, there was no benefit for the initial use case in this HIP. - We considered using a family of allowance extension points, one for each type of asset exchange. (That is,
PRE_HBAR_DEBIT
,PRE_FUNGIBLE_CREDIT
,PRE_NFT_TRANSFER
, and so on.) Ultimately the singleACCOUNT_ALLOWANCE
extension point seemed more approachable, especially as calls can encode any extra context the hook’sallow()
method needs to efficiently focus on one aspect of the proposed transfers. - We considered support multiple charging schemes for hooks, such as
CALLER_PAYS
andCALLER_PAYS_ON_FAILURE
. Ultimately it seemed better to keep the charging scheme simple and let hooks manage any refunds themselves. - We considered not including the
IHieroAccountAllowancePrePostHook
option, but this left a gap in support for the full range of HTS/ERC-20 integrations that Hedera users will likely be interested in.
Open Issues
No known open issues.
References
- HIP-18: Custom Hedera Token Service Fees
- HIP-376: Support Approve/Allowance/transferFrom standard calls from ERC20 and ERC721
- HIP-904: Frictionless Airdrops
- HIP-991: Permissionless revenue-generating Topic Ids for Topic Operators
- Ethereum account abstraction
- Hedera public mirror node
getAccount
API
Copyright/license
This document is licensed under the Apache License, Version 2.0 – see LICENSE or (https://www.apache.org/licenses/LICENSE-2.0)
Citation
Please cite this document as: