Cover photo

How Endgame Forked Seaport to Plug in to Better Order Data

an exercise in tweaking the optimized Seaport core contracts

Endgame, a non-custodial NFT rentals marketplace, recently went through an audit with Code4rena. During the course of the audit, a vulnerability was surfaced which showed that if a Seaport order was set to PARTIAL_RESTRICTED, it could be used to replay the order on Endgame and lock assets in the protocol indefinitely.

To mitigate this, the Endgame protocol would need to reject any incoming Seaport orders that were set to PARTIAL_RESTRICTED. Since all data coming from Seaport to the Endgame protocol occurs via a Seaport Zone contract, using the ZoneParameters struct for all the data, this mitigation should be as easy as checking the struct for the orderType and rejecting the fulfillment, right?

Well... not exactly. Much to my dismay, Seaport Core v1.5 doesn't expose the orderType of the order on its own. If I wanted to access the orderType, I would have to fork Seaport.

This blog post will be an overview of how I did that, and will hopefully help unlock some of the interesting tricks that the optimized Seaport contracts use under the hood.

Let's get started!

Seaport Zones

A Seaport zone contract is a permission-less extension of the Seaport protocol which can be implemented by a 3rd party. The signer of an order gets the option of whether they want to include that zone as part of the execution of the order.

The zone will execute its logic only after all token swaps in the order have occurred. This gives the zone contract the final say on whether the order should be fulfilled or not. Intuitively, you can think of zones as hooks for the Seaport protocol.

The Zone interface exposes one public function which must be implemented for the Seaport core contract to call:

interface ZoneInterface {
    /**
     * @dev Validates an order.
     *
     * @param zoneParameters The context about the order fulfillment and any
     *                       supplied extraData.
     *
     * @return validOrderMagicValue The magic value that indicates a valid
     *                              order.
     */
    function validateOrder(
        ZoneParameters calldata zoneParameters
    ) external returns (bytes4 validOrderMagicValue);
}

And its ZoneParameters are as follows:

struct ZoneParameters {
    bytes32 orderHash;
    address fulfiller;
    address offerer;
    SpentItem[] offer;
    ReceivedItem[] consideration;
    bytes extraData;
    bytes32[] orderHashes;
    uint256 startTime;
    uint256 endTime;
    bytes32 zoneHash;
}

For the purposes of Endgame, I would need to include a new parameter called orderType which could be tacked onto the ZoneParameters struct.

Bridging the Gap: From Seaport to a Zone

So, where in the weeds of the Seaport protocol does it make its call to a zone contract? The answer can be found in the ZoneInteraction.sol contract.

Inside, there is a function _assertRestrictedAdvancedOrderValidity which will manually build up the calldata for the zone interaction and then make the call. A truncated version, with only the parts we care about, looks like this:

function _assertRestrictedAdvancedOrderValidity(
    AdvancedOrder memory advancedOrder,
    bytes32[] memory orderHashes,
    bytes32 orderHash
) internal {
    // Declare variables that will be assigned based on the order type.
    address target;
    uint256 errorSelector;
    MemoryPointer callData;
    uint256 size;

    // Retrieve the parameters of the order in question.
    OrderParameters memory parameters = advancedOrder.parameters;

    // ... 

    // Encode the `validateOrder` call in memory.
    (callData, size) = _encodeValidateOrder(orderHash, parameters, advancedOrder.extraData, orderHashes);

    // ...

    // Perform call and ensure a corresponding magic value was returned.
    _callAndCheckStatus(target, orderHash, callData, size, errorSelector);
}

We can see that _encodeValidateOrder returns the calldata for a CALL to the validateOrder function on the ZoneInterface. This calldata can then be executed by _callAndCheckStatus.

Let's now turn to _encodeValidateOrder.

Encoding Calldata by Hand

Now, we find ourselves inside the ConsiderationEncoder.sol contract to get a better look at _encodeValidateOrder.

Let's start by reviewing the first few lines of code in the function:

function _encodeValidateOrder(
    bytes32 orderHash,
    OrderParameters memory orderParameters,
    bytes memory extraData,
    bytes32[] memory orderHashes
) internal view returns (MemoryPointer dst, uint256 size) {
    // Get free memory pointer to write calldata to. This isn't allocated as
    // it is only used for a single function call.
    dst = getFreeMemoryPointer();

    // Write validateOrder selector and get pointer to start of calldata.
    dst.write(validateOrder_selector);
    dst = dst.offset(validateOrder_selector_offset);

    // ...logic continues on
}

That got complicated very quickly. The first thing to notice is the return type of MemoryPointer. This is just a custom type wrapper around a uint256, and points to the address in memory where this constructed calldata will reside. And, by returning its size as well, the full calldata can be extracted from memory when needed.

Additionally, Seaport has a few helper functions such as write and offset which makes interacting directly with memory a bit easier to understand. For example, the write implementation looks like this:

function write(MemoryPointer mPtr, uint256 value) internal pure {
    assembly {
        mstore(mPtr, value)
    }
}

To start, we can see that the free memory pointer has been obtained, and that the selector for validateOrder has been added to it. Once it has been added, the current pointer dst is incremented forward in memory by an offset which is equal in size to the selector.

With the selector added, we can skip around a bit in this function to see how the values of the ZoneParameters struct are added to the calldata as well.

Let's look at how the offerer value is added.

// Get the memory pointer to the order parameters struct.
MemoryPointer src = orderParameters.toMemoryPointer();

// Copy offerer to zoneParameters.
dst.offset(ZoneParameters_offerer_offset).write(src.readUint256());

Again, we can see that the offset here is used to figure out where, in memory, the offerer address should be copied. By doing this for all inputs to the ZoneParameters, the calldata will be completely built up and ready to execute.

Adding to the Consideration Encoder

Since we want to add orderType to the ZoneParameters struct, let's see how that would look:

struct ZoneParameters {
    bytes32 orderHash;
    address fulfiller;
    address offerer;
    SpentItem[] offer;
    ReceivedItem[] consideration;
    bytes extraData;
    bytes32[] orderHashes;
    uint256 startTime;
    uint256 endTime;
    bytes32 zoneHash;
    OrderType orderType; // <-- new field added
}

Because all offsets in memory for this struct are hardcoded, we will need to insert a new offset so that we can know where to place the OrderType.

uint256 constant ZoneParameters_orderHash_offset = 0x00;
uint256 constant ZoneParameters_fulfiller_offset = 0x20;
uint256 constant ZoneParameters_offerer_offset = 0x40;
uint256 constant ZoneParameters_offer_head_offset = 0x60;
uint256 constant ZoneParameters_consideration_head_offset = 0x80;
uint256 constant ZoneParameters_extraData_head_offset = 0xa0;
uint256 constant ZoneParameters_orderHashes_head_offset = 0xc0;
uint256 constant ZoneParameters_startTime_offset = 0xe0;
uint256 constant ZoneParameters_endTime_offset = 0x100;
uint256 constant ZoneParameters_zoneHash_offset = 0x120;
uint256 constant ZoneParameters_orderType_offset = 0x140; // <-- our new order type value
uint256 constant ZoneParameters_base_tail_offset = 0x160; // was 0x140, bumped to 0x160 to make room

The rest of the constant definitions can be found here.

Once the offset is in place, we can now extract the OrderType from the OrderParameters struct:

// Get the memory pointer to the order parameters struct.
MemoryPointer src = orderParameters.toMemoryPointer();

// ... arbitrary logic

// Write the OrderType into memory
dstHead.offset(ZoneParameters_orderType_offset).write(src.offset(OrderParameters_orderType_offset).readUint256());

And that should be everything! Seaport will now pass in our customized data during calls to ValidateOrder.

A Debugging Side-Quest

So close, yet so far. One of the hard things about constructing raw calldata is that if you don't get it right the first time, it can be impossible to try to debug. While testing my adjusted ZoneParameters struct with validateOrder(), I was getting inexplicable reverts each time I executed a Seaport order.

So what's the deal? To save you hours of hair-pulling, the issue lies in the hard-coded nature of how Seaport creates this calldata. Recall the validateOrder_selector which gets written to memory at the start of the encoding process.

Since we have changed the construction of the ZoneParameters struct, we also have changed the calculated value of the function selector for validateOrder(). By updating validateOrder_selector to the newly generated value, I was able to get the call to start succeeding again.

A Final Test

Now all that's left is a quick test to ensure everything is looking good. For this, we can start with a simple zone implementation that receives the ZoneParameters and assigns them to storage:

contract Zone {
    // public values to test against
    OrderType public orderType;
  
    function validateOrder(ZoneParameters calldata zoneParams) external returns (bytes4 validOrderMagicValue) {

        // store zone orderType
        orderType = zoneParams.orderType;

        // ... store all other zone parameters
    }
}

In our test, we can construct an order like so:

function test_Success_AssertRestrictedAdvancedOrderValidity() public {
    // ... setup logic occurs here

    // create the order parameters
    OrderParameters memory orderParameters = OrderParameters({
        // ...
        orderType: OrderType.FULL_RESTRICTED,
        // ...
    }); 

    // create the advanced order
    AdvancedOrder memory advancedOrder = AdvancedOrder({
        parameters: orderParameters,
        numerator: 1,
        denominator: 1,
        signature: "signature",
        extraData: "extraData"
    });

    // create the order hash
    bytes32 orderHash = keccak256("order hash");

    // create the order hashes
    bytes32[] memory orderHashes = new bytes32[](1);
    orderHashes[0] = orderHash;

    // make a call to the zone using the internal Seaport `validateOrder` calldata builder
    _assertRestrictedAdvancedOrderValidity(
        advancedOrder,
        orderHashes,
        orderHash
    );

    // Assert the orderType is expected
    assertEq(OrderType.FULL_RESTRICTED, zone.orderType());
}

Here we construct an order with a type of OrderType.FULL_RESTRICTED, and the test shows that the call was successfully made to the zone contract because the proper OrderType value was set in storage. You can view the full test here.

Conclusion

Hopefully this post has shed a bit of light on what it takes to fork seaport core and make small tweaks to it. The codebase is filled with low-level gems and I encourage you to poke around the repository as well.

This post focused on adjusting a single value in ZoneParameters. However, not included in this post is how I extended the ZoneParameters struct to include an array of all transfers that occur within a single order.

This technique required considerably more work because of the increase in complexity to encode arrays in calldata, and I felt that a tutorial on that would be a bit out of reach. But for the full source of the forked seaport core repo, you can view that here.

As always, if you have any questions about my forked Seaport core implementation, feel free to reach out to myself at alec@021.gg or on x.com.

Zero To One logo
Subscribe to Zero To One and never miss a post.