Anatomy of a Rental Transaction

Endgame, a brand-new NFT rentals marketplace, is 021’s latest product that facilitates non-custodial rentals. We accomplished this by building the protocol on top of some of the industry’s biggest giants, namely, Seaport and Safe.

In this post, I will be focusing on how we use Seaport as an order fulfillment engine so that Endgame can focus on just the rental logic. I’ll walk through the process of creating, fulfilling, and stopping an order to give a full overview of how a rental flows through the protocol.

Let’s get started!

Creating a Rental Order

All new rental orders must first be created by a lender. The lender is allowed to craft the exact terms they wish to see carried out by the order. They get to specify: items to lend, payment items to receive, any hooks that will apply to the rented items (see my post here if you are unfamiliar with hooks), and rental-specific conditions such as the duration of the rental.

To start, creating a rental order involves constructing and signing a valid Seaport order with a special data payload so that the protocol’s zone contract is invoked during the order fulfillment. This step is crucial because without the zone contract, the order fulfillment would be permanent, leaving no way for the lender to get their assets back.

For each new rental order, the lender must sign a Seaport OrderComponents struct. This contains all the parameters for the Seaport order, and is completely agnostic to the fact that we will be using it for rentals.

/**
 * @dev An order contains eleven components: an offerer, a zone (or account that
 *      can cancel the order or restrict who can fulfill the order depending on
 *      the type), the order type (specifying partial fill support as well as
 *      restricted order status), the start and end time, a hash that will be
 *      provided to the zone when validating restricted orders, a salt, a key
 *      corresponding to a given conduit, a counter, and an arbitrary number of
 *      offer items that can be spent along with consideration items that must
 *      be received by their respective recipient.
 */
struct OrderComponents {
    address offerer;
    address zone;
    OfferItem[] offer;
    ConsiderationItem[] consideration;
    OrderType orderType;
    uint256 startTime;
    uint256 endTime;
    bytes32 zoneHash;
    uint256 salt;
    bytes32 conduitKey;
    uint256 counter;
}

The items to pay particular attention to are the zone and zoneHash parameters. In a traditional Seaport order, the zone parameter is typically left blank. In our case, the zone address for all rental orders is the Create Policy contract, which handles creation of rental orders.

By specifying a zone address in the OrderComponents struct, Seaport will hand the flow of execution to the zone contract after it has fulfilled the order. This is precisely the mechanism that Endgame uses to ensure that a fulfilled Seaport order is logged and processed, so that it can eventually be stopped in the future.

The zoneHash parameter is a hashed value of data that contains all of the rental terms and conditions that the lender has specified for a specific order. This data includes the type of rental order, the rental duration, and any hooks for the rentals. After this order has been signed, a counterparty (the fulfiller) will pass the unhashed data that makes up the zoneHash into a Seaport fulfillment function to prove to the protocol that both the lender and the renter of the order have agreed on the same rental terms.

Constructing the Zone Hash

A zone hash is the EIP-712 hashed version of the following struct:

/**
 * @dev Order metadata contains all the details supplied by the offerer when they 
 * 		sign an order. These items include the type of rental order, how long the 
 * 		rental will be active, any hooks associated with the order, and any data that 
 * 		should be emitted when the rental starts.
 */
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;
}

A bit of information about each parameter in the OrderMetadata struct:

  • orderType: When a lender wants to create a rental, they must choose an order type. There are three order types supported by the protocol, but only 2 are external and can be used by lenders.
    • BASE: This order type describes a rental order in which the lender will construct a seaport order that contains at least one ERC721 or ERC1155 offer item, and at least one ERC20 consideration item. A lender would choose this order when they want to be paid by a renter in exchange for lending out their asset(s) for a specific amount of time.
    • PAY: This order type describes an order in which the lender wishes to pay the renter for renting out their asset. This order must contain at least one ERC721 or ERC1155 offer item and at least one ERC20 offer item. It must contain 0 consideration items. This may sound counter-intuitive but the rationale is that some lenders may get benefit (tokens, rewards, etc) from allowing others to interact with contracts (on-chain games, etc) with their assets to extract some type of value from the lended asset.
    • PAYEE: This order type cannot be specified by a lender and should result in a revert if specified. PAYEE orders act as mirror images of a PAY order, and they are used by the protocol to match them up with PAY orders. In other words, a PAYEE order has 0 offer items, and should specify the offer items of the target PAY order as its own consideration items, with the proper recipient addresses.
  • rentDuration: The total duration of the rental in seconds.
  • hooks: The hooks which will be specified for the rental.
  • emittedExtraData: This is any extra data that the lender wishes to emit once a rental has been fulfilled. This can be useful for integrations with Endgame that occur off-chain.

After the OrderMetadata has been constructed, its hash can be constructed using a convenience function which exists on the Create Policy. This will then be the value that is used for the zoneHash.

/**
 * @notice Derives the order metadata EIP-712 compliant hash from an `OrderMetadata`.
 *
 * @param metadata Order metadata converted to a hash.
 */
function getOrderMetadataHash(
    OrderMetadata memory metadata
) external view returns (bytes32) {
    return _deriveOrderMetadataHash(metadata);
}

To see how an OrderMetadata struct is converted into a EIP-712 typehash, check out _deriveOrderMetadataHash in the Signer Package.

Next, the OrderComponents struct can be hashed and then signed by the lender. This happens off-chain but you can see how its done using our test engine:

// generate the order hash
orderHash = seaport.getOrderHash(orderComponents);

// generate the signature for the order components
bytes memory signature = _signSeaportOrder(_offerer.privateKey, orderHash);

With the signature and the OrderComponents generated, they can be combined into a Seaport Order struct which will be used to fulfill the order.

/**
 * @dev Orders require a signature in addition to the other order parameters.
 */
struct Order {
    OrderParameters parameters;
    bytes signature;
}

The Protocol Signer

Before an order can be fulfilled, it must first be submitted to the Endgame backend to receive a signature from the protocol signer. The protocol signer is an EOA managed by 021 which will tell the protocol if an order is still fulfillable. Without this signature, the order will be considered invalid to fulfill.

Some responsibilities of the protocol signer include:

  • Signing off on orders that have not been canceled.
  • Ensuring the wallet address that requested the fulfillment is the actual address which will execute the fulfillment.
  • Tying together the rental order with the correct OrderMetadata struct.

The protocol signer will generate a signature from a RentPayload struct by invoking getRentPayloadHash on the Create Policy contract.

A RentPayload struct looks like this:

struct RentPayload {
    // Hash of the order being fulfilled
    bytes32 orderHash; 
    // contains the rental wallet address that will receive the rented assets 
    OrderFulfillment fulfillment;
    // Metadata of the order to fulfill.
    OrderMetadata metadata;
    // Timestamp that the protocol signer's signature will expire
    uint256 expiration;
    // EOA expected to initiate the order fulfillment
    address intendedFulfiller;
}

Fulfilling a Rental Order

With a RentPayload struct acquired, along with a properly constructed and signed Seaport Order, we can invoke one of Seaport’s fulfillment functions to process a signed order.

Signed orders can be fulfilled by the protocol in a few ways depending on how many orders are being filled at once, and what type of orders are being fulfilled. All Seaport fulfillment functions can be found here, but the ones specifically used by the protocol include:

  • fulfillAdvancedOrder
  • fulfillAvailableAdvancedOrders
  • matchAdvancedOrders

For the purposes of this post, I will show the fulfillment of a single order using fulfillAdvancedOrder, but example usage for all of these fulfillment methods can be found in the protocol test engine code here.

To use fulfillAdvancedOrder, an AdvancedOrder struct must be created from a standard Seaport Order struct:

struct AdvancedOrder {
    // Order parameters that were signed
    OrderParameters parameters;
    // Don't worry about this
    uint120 numerator;
    // Don't worry about this
    uint120 denominator;
    // Signature of the order parameters
    bytes signature;
    // This data will be passed to the zone contract
    bytes extraData;
}

For the extraData field, it is constructed as a concatenation of the RentPayload struct and the signature of that struct from the protocol signer:

bytes memory extraData = abi.encode(rentPayload, rentPayloadSignature);

With the AdvancedOrder created, we now have everything we need to fulfill the order and create a rental:

seaport.fulfillAdvancedOrder(
    advancedOrder,             // The order being fulfilled
    new CriteriaResolver[](0), // Dont worry about this
    conduitKey,                // Key of the conduit address to interact with seaport
    recipientOfOfferItems      // Fulfiller decides who to send received items to 
);

Once the order is fulfilled, the items offered up by the lender will now be sitting inside the rental wallet of the owner that invoked the fulfillment. Meanwhile, the payment for the order will be held in an escrow contract until the rental is stopped and assets are returned to the lender.

Stopping a Rental Order

The function signature for stopping a rental order is very simple, accepting a RentalOrder struct as its sole parameter:

function stopRent(rentalOrder memory order) external;

struct rentalOrder {
    bytes32 seaportOrderHash;
    item[] items;
    hook[] hooks;
    ordertype orderType;
    address lender;
    address renter;
    address rentalWallet;
    uint256 startTimestamp;
    uint256 endTimestamp;
}

One way that the protocol saves gas during rental creation is by writing data to storage as infrequently as possible. So, rather than store each parameter of the RentalOrder struct, only its hash is saved in protocol storage. For the rest of the parameters, they can be retrieved from the RentalOrderStarted event which is emitted on each successful rental order fulfillment.

/**
 * @dev Emitted when a new rental order is started. PAYEE orders are excluded from
 *      emitting this event.
 *
 * @param orderHash        Hash of the rental order struct.
 * @param emittedExtraData Data passed to the order to be emitted as an event.
 * @param seaportOrderHash Order hash of the seaport order struct.
 * @param items            Items in the rental order.
 * @param hooks            Hooks defined for the rental order.
 * @param orderType        Order type of the rental.
 * @param lender           Lender EOA of the assets in the order.
 * @param renter           Renter EOA of the assets in the order.
 * @param rentalWallet     Wallet contract which holds the rented assets.
 * @param startTimestamp   Timestamp which marks the start of the rental.
 * @param endTimestamp     Timestamp which marks the end of the rental.
*/
event RentalOrderStarted(
    bytes32 orderHash,
    bytes emittedExtraData,
    bytes32 seaportOrderHash,
    Item[] items,
    Hook[] hooks,
    OrderType orderType,
    address indexed lender,
    address indexed renter,
    address rentalWallet,
    uint256 startTimestamp,
    uint256 endTimestamp
);

Using this data, the RentalOrder can be properly reconstructed. Then, during the call to stopRent, the order will be hashed and compared against the hashed orders that exist in storage.

When a match is found, the protocol will return the assets back to their original owner, and award a payout from the escrow contract. Thus, completing the life cycle of a rental.

Conclusion

There you have it! The complete breakdown of how rental transactions are processed by Endgame in tandem with Seaport. By offloading the fulfillment logic to Seaport, the Endgame protocol is able to focus on just the logic related to rental construction and processing. If you want to dig deeper into Endgame, you can view the protocol contract repository here.

And, if you have any questions on rental fulfillments, feel free to reach out to myself at [email protected].