Revenue Sharing: How to Supercharge NFT Rentals

As I mentioned in my hook overview post here, hooks are custom-built contracts that can act as middleware during the rental lifecycle of one or more NFT collections.

In this post, I will go over an implementation for how to create one of these hooks. The hook I’ll be walking through is a reward share hook, which can provide additional incentives on top of a standard rental order. A reward share hook allows a renter and a lender of an asset to accrue rewards in another token during an active rental. Via the terms of the rental, the lender is able to define the exact percentage of how the rewards will be split (e.g., 30% for the renter and 70% for the lender). After the rental has concluded, the accrued assets can then be claimed.

Let’s take a look at the basic structure of a hook.

Scaffolding for a Hook

For the reward share hook, we need a contract that registers an active rental upon its creation, and then, unregisters the rental once it has been stopped. Using those start and stop timestamps, an accrued reward can later be claimed directly by the renter. To do this, we’ll need to leverage the hook’s onStart() and onStop() functions.

To start, let’s look at the structure of the hook contract without getting too bogged down with function implementations just yet.

contract ERC20RewardHook is IHook {
    // This is the protocol contract which will call the hook on rental start 
    address public createPolicy;

    // This is the protocol contract which will call the hook on rental stop
    address public stopPolicy;

    // The rentable token that is compatible with this hook
    address public gameToken;

    // The token which will be distributed as a reward to renters
    IERC20 public rewardToken;

    // award 1 gwei of reward token per block
    uint256 public immutable rewardPerBlock = 1e9;

    // holds info about an asset
    mapping(bytes32 assetHash => RentInfo rentInfo) public rentInfo;

    // holds info about accrued rewards
    mapping(address rewardedAddress => uint256 rewards) public accruedRewards;

    constructor(
        address _createPolicy,
        address _stopPolicy,
        address _gameToken,
        address _rewardToken
    ) {
        createPolicy = _createPolicy;
        stopPolicy = _stopPolicy;
        gameToken = _gameToken;
        rewardToken = IERC20(_rewardToken);
    }

    modifier onlyCreatePolicy() {
        require(msg.sender == createPolicy, "not callable unless create policy");
        _;
    }

    modifier onlyStopPolicy() {
        require(msg.sender == stopPolicy, "not callable unless stop policy");
        _;
    }

    modifier onlySupportedTokens(address token) {
        require(token == gameToken, "token is not supported");
        _;
    }

    // ... Hook function implementations will go here
}

There are two important modifiers ( onlyCreatePolicy and onlyStopPolicy) which have been implemented to prevent the hook from being misused.

Because this hook will be tracking accrued rewards for active rentals and distributing tokens, it must prevent any address except the rental protocol from interacting with the hook, and it also must prevent a rental order which uses some random token from also being able to cash in on this hook.

There are also mappings for the rented assets and the rewards that have been accrued:

// holds info about an asset
mapping(bytes32 assetHash => RentInfo rentInfo) public rentInfo;

// holds info about accrued rewards
mapping(address rewardedAddress => uint256 rewards) public accruedRewards;

Their structs are defined as follows:

// Info stored about each rental
struct RentInfo {
    uint256 amount;
    uint256 lastRewardBlock;
}

// Info about the revenue share
struct RevenueShare {
    address lender;
    uint256 lenderShare;
}

The RevenueShare struct is important because this defines the terms for the revenue split. On rental creation, the lender crafts a RevenueShare struct defining what percentage of the rewards they want to receive from the hook. More on how this works in the next section.

Registering an Active Rental

Next, we’ll handle the functionality for registering a new rental order with the hook contract. This function has a few responsibilities:

  • enforce proper function access using modifiers
  • calculate rewards (if any) that have already accrued for the renter
  • update storage with the asset and timestamp of the new rental

Let’s begin with the onStart definition along with its modifiers:

// hook handler for when a rental has started
function onStart(
    address safe,
    address token,
    uint256 identifier,
    uint256 amount,
    bytes memory data
) external onlyCreatePolicy onlySupportedTokens(token) {
    // Decode the revenue split data
    RevenueShare memory revenueShare = abi.decode(data, (RevenueShare));

    // ... Implementation continues on 
}

onStart modifiers make sure that only expected rentals and expected senders can interact with the hook. Next, the function accepts the rental safe address, the token, its identifier, the amount of tokens in the rental order, and any extra data that the hook may need. The data is passed in by the lender when they sign a new order to lend, which allows for implementation flexibility since not all uses for hooks can be known upfront. For our example, this is a RevenueShare struct which tells the hook how to split the rewards between the renter and lender.

Now, we’ll move on to calculating previous rewards. Since a single renter can have multiple orders that interact with the hook, we must be certain that they accrue rewards for each of their rentals. So, each time a new rental is added, all accrued rewards from active rentals are calculated so they do not get overwritten by a new rental coming in.

// ... Previous code

// get the last block that the rewards were accrued
uint256 lastBlock = rentInfo[assetHash].lastRewardBlock;

// get the amount currently stored
uint256 currentAmount = rentInfo[assetHash].amount;

// if the last block that a reward was accrued exists and the amount is nonzero,
// calculate the latest reward. Otherwise, this is a first-time deposit so
// there are no rewards earned.
if (lastBlock > 0 && currentAmount > 0) {
    // The amount of blocks to reward
    uint256 blocksToReward = block.number - lastBlock;

    // since the last time reward were accrued, the reward is distributed per 
	// block per token stored. Divide by 1e18 to account for token decimals
    uint256 latestAccruedRewards = (blocksToReward * rewardPerBlock * 
		currentAmount) / 1e18;

    // determine the split of the rewards for the lender
    uint256 lenderAccruedRewards = (latestAccruedRewards * 
		revenueShare.lenderShare) / 100;

    // determine the split of the rewards for the renter
    uint256 renterAccruedRewards = latestAccruedRewards - lenderAccruedRewards;

    // Effect: accrue rewards to the lender
    accruedRewards[revenueShare.lender] += lenderAccruedRewards;

    // Effect: accrue rewards to the safe/renter
    accruedRewards[safe] += renterAccruedRewards;
}

// ... Implementation continues on

The last block used to calculate an accrual combined with the current block creates a time range. This range is then multiplied by the preconfigured reward per block which determines the total token reward for the range.

Then, those tokens are credited to the renter’s and lender’s accounts, which will be transferred once the tokens are claimed from the hook.

Ending an Active Rental

The function definition for onStop is very similar to what we have seen previously. The only difference is that the modifier now checks that the Stop Policy is the one that calls the hook.

// handler for when a rental has stopped
function onStop(
    address safe,
    address token,
    uint256 identifier,
    uint256 amount,
    bytes memory data
) external onlyStopPolicy onlySupportedTokens(token) {
    // ...Implementation will go here
}

For the body of the function, the hook will again behave very similar to onStop. Rewards will be accrued in storage, and the total number of rented assets for the renter will be decremented by the amount field.

// ... Previous code

// get the last block that the rewards were accrued
uint256 lastBlock = rentInfo[assetHash].lastRewardBlock;

// get the amount currently stored
uint256 currentAmount = rentInfo[assetHash].amount;

// The amount of blocks to reward
uint256 blocksToReward = block.number - lastBlock;

// since the last time reward were accrued, the reward is distributed per block per token stored.
// Divide by 1e18 to account for token decimals
uint256 latestAccruedRewards = (blocksToReward * rewardPerBlock * currentAmount) / 1e18;

// determine the split of the rewards for the lender
uint256 lenderAccruedRewards = (latestAccruedRewards * revenueShare.lenderShare) / 100;

// determine the split of the rewards for the renter
uint256 renterAccruedRewards = latestAccruedRewards - lenderAccruedRewards;

// Effect: accrue rewards to the lender and renter
accruedRewards[revenueShare.lender] += lenderAccruedRewards;
accruedRewards[safe] += renterAccruedRewards;

// Effect: update the amount of tokens currently rented and the latest block that rewards accrued
rentInfo[assetHash].amount -= amount;
rentInfo[assetHash].lastRewardBlock = block.number;

// ... Implementation continues on

Claiming Accrued Rewards

Claiming Accrued Rewards

Once a rental has ended, this hook contract supports claiming the reward tokens directly from the rental hook. Either the lender, renter EOA, or the rental wallet can claim their share of the tokens.

function claimRewards(address rewardedAddress) external {
    // check if the caller is the lender or is a rental safe
    bool isClaimer = msg.sender == rewardedAddress;

    // make sure the caller is the claimer, or they are the owner
    // of the safe
    require(
        isClaimer || ISafe(rewardedAddress).isOwner(msg.sender),
        "not allowed to access rewards for this safe"
    );

    // store the amount to withdraw
    uint256 withdrawAmount = accruedRewards[rewardedAddress];

    // Effect: update the accrued rewards
    accruedRewards[rewardedAddress] = 0;

    // Interaction: Transfer the accrued rewards
    rewardToken.transfer(msg.sender, withdrawAmount);
}

Running a Foundry Test

Running a Foundry Test

To kick off the test, we’ll start with the setup function. The contracts will be deployed, the hook will be registered with the protocol, and tokens will be minted to the hook contract so that it can payout rewards.

// ... Previous code

// deploy contracts needed for hook
rewardToken = new MockERC20();
hook = new ERC20RewardHook(
    address(create),
    address(stop),
    address(gameToken),
    address(rewardToken)
);

// admin enables the hook. Use binary 00000110 so that the hook
// is enabled for `onStart` and `onStop` calls
vm.prank(deployer.addr);
guard.updateHookStatus(address(hook), uint8(6));

// fund the hook contract with some reward tokens
rewardToken.mint(address(hook), 100e18);

// ... Implementation continues on

Next, we can kick off a test that will perform a rental that will execute the onStart function of the hook contract. Then, we’ll roll forward in time and stop the rental so that the onStop function is called.

Finally, the rewards can be claimed by both the lender and the renter.

function test_Success_RewardShare() public {
    // start the rental. This should activate the hook and begin
    // accruing rewards while the rental is active.
    RentalOrder memory rentalOrder = _startRentalWithGameToken();

    // roll ahead by 100 blocks so that rewards can accrue
    vm.roll(block.number + 100);

    // speed up in time past the rental expiration
    vm.warp(block.timestamp + 750);

    // stop the rental
    vm.prank(alice.addr);
    stop.stopRent(rentalOrder);

    // owner of the safe can claim tokens
    vm.prank(bob.addr);
    hook.claimRewards(address(bob.safe));

    // lender of the rental can claim tokens
    vm.prank(alice.addr);
    hook.claimRewards(alice.addr);

    // earned rewards should be 100 blocks * 1 gwei reward per block * 1e18 token, 
	// which is 100 gwei
    assertEq(rewardToken.balanceOf(bob.addr), 30000000000); // 30 gwei
    assertEq(rewardToken.balanceOf(alice.addr), 70000000000); // 70 gwei
}

Conclusion

Hopefully this concrete example of a hook implementation is enough to show how powerful the hook architecture can be when it comes to extending rental functionality.

This tutorial left out portions of code in the implementation that weren’t relevant for discussion. To view the full source, you can take a look at the contract implementation and the test file.

If you have any questions on how hooks work, feel free to reach out to myself at [email protected]. And, if you want to integrate hooks into your own ERC721 or ERC1155 project, you can contact [email protected] for more details.