For the past 8 months, I have been working on the smart contract protocol for 021's latest product, Endgame, which is an NFT rentals marketplace powered by Seaport and Safe. Endgame is designed to be a highly extensible protocol with a focus on allowing any project to extend its rental functionality.
One of the most powerful features of Endgame is its ability to extend rental functionality through the use of hooks. These are custom contracts attached to an order by a lender. Once the order is fulfilled, these hooks can operate at the start of a rental, at the end of a rental, or alongside transactions that originate from rental wallets, allowing customized rental flows on a per-collection basis.
Hook Lifecycles
To understand how hooks fit into the protocol, we'll first look at the IHook
interface, which must be implemented to construct a valid hook contract.
The fully commented source code can be found here.
interface IHook {
// Triggers this hook call during a transaction involving a rented asset with
// an active hook address attached to its metadata.
function onTransaction(
address safe,
address to,
uint256 value,
bytes memory data
) external;
// Triggers this hook call when a rental has been started.
function onStart(
address safe,
address token,
uint256 identifier,
uint256 amount,
bytes memory extraData
) external;
// Triggers this hook call when a rental has been stopped.
function onStop(
address safe,
address token,
uint256 identifier,
uint256 amount,
bytes memory extraData
) external;
}
At different points in the lifecycle of a rental, the hook contract can be invoked by the protocol, so not all functions in this interface have to be implemented to get the desired behavior.
OnTransaction Hook
After an asset has been rented, the owner of the rental safe is now allowed to begin making transactions that originate from the rental wallet. If the hook in the order implements the onTransaction
function, it will first be associated with a target contract. This can be any contract that the rental safe may interact with while renting the asset. If a transaction specifies this target contract in its to
field of a transaction, then the hook contract will be called first to act as a middleware between the rental safe and the target contract.
A good example for wanting to use this hook is for an asset that might have a single-use function that can only be called once, which would want to be reserved for the original owner of the asset to call, and not the renter.
OnStart Hook
During the fulfillment process of a rental, the onStart
hook can be invoked to perform any actions at the very start of the rental. At the time that the onStart
hook is invoked, the asset will have just been sent to the rental wallet.
OnStop Hook
During the process of stopping a rental, the onStop
hook can be invoked to perform any actions at the very end of the rental. At the time that the onStop
hook is invoked, the asset will have already been removed from the rental wallet.
Registering a Hook with the Protocol
Hooks are not permission-less. Since hook contracts are middleware by nature, it is important that the use of them be limited to a well-vetted, whitelisted selection to prevent protocol exploits. Any hooks deemed fit for use can be added via the protocol's hook updating functions:
/**
* @notice Connects a target contract to a hook.
*
* @param to The destination contract of a call.
* @param hook The hook middleware contract to sit between the call
* and the destination.
*/
function updateHookPath(address to, address hook) external;
/**
* @notice Toggle the status of a hook contract, which defines the functionality
* that the hook supports.
*
* @param hook The hook contract address.
* @param bitmap Bitmap of the status.
*/
function updateHookStatus(address hook, uint8 bitmap) external;
For a hook to be added to the protocol, both of these functions must be invoked by an admin:
UpdateHookPath: This is used to point a hook contract at an intended target address. This will ensure that this hook contract can only be invoked when a rental wallet creates a transaction with a specific
to
address.UpdateHookStatus: This function determines at which point in the lifecycle of the rental that the hook will activate. It can be set to be invoked in any combination of: on rental start, on rental end, and during rental transactions.
When updating a hook's status, the protocol uses a bitmap to track the exact configuration of the hook. This allows specifying any combination of hook functions as a single uint8
. Our current implementation is as follows:
00000001
or1
: enables hook activation during a transaction with an active rental00000010
or2
: enables hook activation on rental start00000100
or4
: enables hook activation on renal stop
For example, updating a hook status for both onStart
and onTransaction
might look like this:
// Use binary 00000011 so that the hook is enabled for onStart
// and onTransaction calls only
guard.updateHookStatus(address(hook), uint8(3));
Using Hooks in Rental Orders
With a hook contract deployed and activated on the protocol, a lender will be able to specify assets to lend alongside any hooks to apply to those assets. This gives the lender full control over how their assets will be used throughout the lifecycle of the rental.
Adding a hook to a rental order requires passing in an array of hooks to the order before signing it. An example hook might look something like this:
// Define the hook for the rental
Hook[] memory hooks = new Hook[](1);
hooks[0] = Hook({
// the hook contract to target
target: address(hook),
// index of the item in the order to apply the hook to
itemIndex: 0,
// any extra data that the hook will need.
extraData: ""
});
Lets break down each of these fields:
Target: The address of the hook contract being invoked. Control flow will pass to this contract during the rental process to execute additional functionality.
ItemIndex: Since we use seaport to power our token swaps, the asset for which you want the hook to be activated will be the item index of the seaport
OfferItem
array in the order.ExtraData: This is any extra data that the hook contract may want when it is invoked. This allows for maximum flexibility when designing hooks since we cannot predict all possible use-cases.
The hooks will then be passed into an OrderMetadata
struct. Each rental order will contain a single metadata struct that defines the terms for the rental. It is crucial that both the lender and the renter have agreed upon the same terms. To guarantee this, the OrderMetadata
struct is hashed and then placed in the zoneHash
field of a seaport OrderComponents
struct:
// Endgame order metadata for a rental order
struct OrderMetadata {
// Type of order being created.
OrderType orderType;
// Duration of the rental in seconds.
uint256 rentDuration;
// Hooks that will act as middleware for the items in the order.
Hook[] hooks;
// Any extra data to be emitted upon order fulfillment.
bytes emittedExtraData;
}
// Seaport order components
struct OrderComponents {
address offerer;
address zone;
OfferItem[] offer;
ConsiderationItem[] consideration;
OrderType orderType;
uint256 startTime;
uint256 endTime;
bytes32 zoneHash; // <-- the one we care about
uint256 salt;
bytes32 conduitKey;
uint256 counter;
}
From here, the rental order is executed with seaport. To ensure that the fulfiller has agreed to the same order metadata as the lender, they will need to include the exact same OrderMetadata
struct into their order as extraData
. But for this post, I wont go into how exactly a rental order is constructed. Once the order has been fulfilled, the hooks will reach Endgame's contract and will be extracted and decoded. More on this in the next section.
Hooks in Action
Lets look at how a hook will behave in each of the three different ways that it can be invoked during the process of a rental order.
At the Start of a Rental
A hook starts its journey once it is decoded by a seaport zone contract after an order has been fulfilled. A zone contract allows you to extend the behavior of a traditional seaport order fulfillment. In our case, we perform the order fulfillment and introduce extra bookkeeping to create rentals instead of permanent trades.
During rental creation, a call to _addHooks
is made in the Create policy contract that, in turn, will reach out to the hook contract to execute. It passes the rental wallet address, token, token ID, amount, and any extra data provided. There is no limit to how many hooks can be executed on a single order, so each one will be looped through and called during the rental creation process.
// Check that the hook is reNFT-approved to execute on rental start. This checks to see
// if the 0x00000010 bit is active on the hooks's status bitmap in storage
if (STORE.hookOnStart(target)) {
// ... Do some stuff
// Call the hook with data about the rented item.
IHook(target).onStart(
rentalWallet,
offer.token,
offer.identifier,
offer.amount,
hooks[i].extraData
);
// ... Do some stuff
}
From here, the contract will execute normally and allow for any extension of functionality that the implementor has introduced.
At the End of a Rental
The process of executing a hook at the end of the rental is very similar to how it behaves at the start of the rental. During a rental stop, all data that was used to create the rental order must also be given to the Stop policy contract to end the order. This includes the original hook entries that were present on the order.
A call to _removeHooks
is made in the Stop policy contract that, in turn, will reach out to the same hook contracts which were passed in during the start of the order. They will also receive the same parameters from before.
// Check that the hook is reNFT-approved to execute on rental stop. This checks to see
// if the 0x00000100 bit is active on the hooks's status bitmap in storage
if (STORE.hookOnStop(target)) {
// ... Do some stuff
// Call the hook with data about the rented item.
IHook(target).onStop(
rentalWallet,
item.token,
item.identifier,
item.amount,
hooks[i].extraData
)
// ... Do some stuff
}
After this, the stopping of the rental will wrap up as usual.
During the middle of a Rental
Hooks can additionally be invoked throughout the duration of the rental, depending on what kinds of transactions the renter makes from their rental wallet. By default, the rental wallet disables any transactions originating from it that involve transferring or burning a rented asset.
For everything else, it leaves it up to the hooks associated with the target address in the transaction to handle whether it should either reject the transaction or, in other cases, provide additional functionality by acting as middleware.
Endgame uses Safe wallets as the default rental wallet for the protocol. Any transaction coming from the rental wallet requires a signed transaction from the owner which is then passed through a custom Guard contract.
Within the guard, there is logic that determines if the target for the transaction has a hook contract associated with it. This association is set by an admin like this:
// admin enables the hook path to point to the game contract
guard.updateHookPath(address(game), address(hook));
By doing this, any time the guard contract detects a transaction to address(game)
, it will check storage for the hook contract associated with that address. Once it has the hook, it then needs to check if the hook has been activated for onTransaction
hooks:
// Checks if the 0x00000001 bit is active on the hooks's status bitmap in storage
bool hookIsActive = STORE.hookOnTransaction(hook);
If the address being called in the transaction both has an associated hook contract and the hook contract is enabled for onTransaction
, then control flow will be passed to the hook contract with _forwardToHook
found here.
function _forwardToHook(
address hook,
address safe,
address to,
uint256 value,
bytes memory data
) private {
// Call the onTransaction hook function.
IHook(hook).onTransaction(safe, to, value, data);
// ... Do some stuff
}
With this construction, the hook will get the final say on whether the transaction should be prevented. This is a great use-case for hooks that wish to prevent custom function selectors on a contract from being executed by a renter. For example, you could imagine some type of ERC721 with a function that allows combining two assets together to make a third and destroying the original two in the process.
A hook contract would allow the original owner of the asset to prevent the rented ERC721 from being able to participate in this function by defining a custom rule that will revert if any asset tries to use the function selector associated with that functionality.
Conclusion
The possibilities for how hooks can be used truly have no limit. They were designed with flexibility in mind so that each project using a hook could define the use-case that works best for them. For some examples of implemented hooks, you can find them all here.
If you have any questions on how hooks work, feel free to reach out to myself at alec@021.gg. And, if you want to integrate hooks into your own ERC721 or ERC1155 project, you can contact naz@021.gg for more details.