Skip to content

Latest commit

 

History

History
591 lines (474 loc) · 56.2 KB

SeaportDocumentation.md

File metadata and controls

591 lines (474 loc) · 56.2 KB
title category slug parentDocSlug order hidden
Interacting with Seaport
6520398b749af50013f52ff4
seaport-interactions
seaport-overview
1
false

Seaport Documentation

Documentation around creating orders, fulfillment, and interacting with Seaport.

Table of Contents

Order

Each order contains eleven key components:

  • The offerer of the order supplies all offered items and must either fulfill the order personally (i.e. msg.sender == offerer) or approve the order via signature (either standard 65-byte EDCSA, 64-byte EIP-2098, or an EIP-1271 isValidSignature check) or by listing the order on-chain (i.e. calling validate).
  • The zone of the order is an optional secondary account attached to the order with two additional privileges:
    • The zone may cancel orders where it is named as the zone by calling cancel. (Note that offerers can also cancel their own orders, either individually or for all orders signed with their current counter at once by calling incrementCounter).
    • "Restricted" orders (as specified by the order type) must either be executed by the zone or the offerer, or must be approved as indicated by a call to validateOrder when the caller is not the zone.
  • The offer contains an array of items that may be transferred from the offerer's account, where each item consists of the following components:
    • The itemType designates the type of item, with valid types being:
      • Ether (or other native token for the given chain) enum value: NATIVE = 0
      • ERC20: enum value: ERC20 = 1
      • ERC721: enum value: ERC721 = 2
      • ERC1155: enum value: ERC1155 = 3
      • ERC721 with "criteria" (explained below): enum value: ERC721_WITH_CRITERIA = 4
      • ERC1155 with "criteria" (explained below): enum value: ERC1155_WITH_CRITERIA = 5
    • The token designates the account of the item's token contract (with the null address used for Ether or other native tokens).
    • The identifierOrCriteria represents either the ERC721 or ERC1155 token identifier or, in the case of a criteria-based item type, a merkle root composed of the valid set of token identifiers for the item. This value will be ignored for Ether and ERC20 item types, and can optionally be zero for criteria-based item types to allow for any identifier.
    • The startAmount represents the amount of the item in question that will be required should the order be fulfilled at the moment the order becomes active.
    • The endAmount represents the amount of the item in question that will be required should the order be fulfilled at the moment the order expires. If this value differs from the item's startAmount, the realized amount is calculated linearly based on the time elapsed since the order became active.
  • The consideration contains an array of items that must be received in order to fulfill the order. It contains all of the same components as an offered item, and additionally includes a recipient that will receive each item. This array may be extended by the fulfiller on order fulfillment so as to support "tipping" (e.g. relayer or referral payments).
  • The orderType designates one of four types for the order depending on three distinct preferences:
    • FULL indicates that the order does not support partial fills, whereas PARTIAL enables filling some fraction of the order, with the important caveat that each item must be cleanly divisible by the supplied fraction (i.e. no remainder after division).
    • OPEN indicates that the call to execute the order can be submitted by any account, whereas RESTRICTED requires that the order either be executed by the offerer or the zone of the order, or that a magic value indicating that the order is approved is returned upon calling validateOrder when the caller is not the zone.
    • CONTRACT indicates that the order will be generated by the offerer upon a call from Seaport to generateOrder, then verified after execution with a follow-on call to ratifyOrder on the offerer.
  • The startTime indicates the block timestamp at which the order becomes active.
  • The endTime indicates the block timestamp at which the order expires. This value and the startTime are used in conjunction with the startAmount and endAmount of each item to derive their current amount.
  • The zoneHash represents an arbitrary 32-byte value that will be supplied to the zone when fulfilling restricted orders that the zone can utilize when making a determination on whether to authorize the order.
  • The salt represents an arbitrary source of entropy for the order.
  • The conduitKey is a bytes32 value that indicates what conduit, if any, should be utilized as a source for token approvals when performing transfers. By default (i.e. when conduitKey is set to the zero hash), the offerer will grant ERC20, ERC721, and ERC1155 token approvals to Seaport directly so that it can perform any transfers specified by the order during fulfillment. In contrast, an offerer that elects to utilize a conduit will grant token approvals to the conduit contract corresponding to the supplied conduit key, and Seaport will then instruct that conduit to transfer the respective tokens.
  • The counter indicates a value that must match the current counter for the given offerer.

Order Fulfillment

Orders are fulfilled via one of four methods:

  • Calling one of two "standard" functions, fulfillOrder and fulfillAdvancedOrder, where a second implied order will be constructed with the caller as the offerer, the consideration of the fulfilled order as the offer, and the offer of the fulfilled order as the consideration (with "advanced" orders containing the fraction that should be filled alongside a set of "criteria resolvers" that designate an identifier and a corresponding inclusion proof for each criteria-based item on the fulfilled order). All offer items will be transferred from the offerer of the order to the fulfiller, then all consideration items will be transferred from the fulfiller to the named recipient.
  • Calling the "basic" function, fulfillBasicOrder with one of six basic route types supplied (ETH_TO_ERC721, ETH_TO_ERC1155, ERC20_TO_ERC721, ERC20_TO_ERC1155, ERC721_TO_ERC20, and ERC1155_TO_ERC20) will derive the order to fulfill from a subset of components, assuming the order in question adheres to the following:
    • The order only contains a single offer item and contains at least one consideration item.
    • The order contains exactly one ERC721 or ERC1155 item and that item is not criteria-based.
    • The offerer of the order is the recipient of the first consideration item.
    • All other items have the same Ether (or other native tokens) or ERC20 item type and token.
    • The order does not offer an item with Ether (or other native tokens) as its item type.
    • The startAmount on each item must match that item's endAmount (i.e. items cannot have an ascending/descending amount).
    • All "ignored" item fields (i.e. token and identifierOrCriteria on native items and identifierOrCriteria on ERC20 items) are set to the null address or zero.
    • If the order has an ERC721 item, that item has an amount of 1.
    • If the order has multiple consideration items and all consideration items other than the first consideration item have the same item type as the offered item, the offered item amount is not less than the sum of all consideration item amounts excluding the first consideration item amount.
  • Calling one of two "fulfill available" functions, fulfillAvailableOrders and fulfillAvailableAdvancedOrders, where a group of orders are supplied alongside a group of fulfillments specifying which offer items can be aggregated into distinct transfers and which consideration items can be accordingly aggregated, and where any orders that have been cancelled, have an invalid time, or have already been fully filled will be skipped without causing the rest of the available orders to revert. Additionally, any remaining orders will be skipped once maximumFulfilled available orders have been located. Similar to the standard fulfillment method, all offer items will be transferred from the respective offerer to the fulfiller, then all consideration items will be transferred from the fulfiller to the named recipient.
  • Calling one of two "match" functions, matchOrders and matchAdvancedOrders, where a group of explicit orders are supplied alongside a group of fulfillments specifying which offer items to apply to which consideration items (and with the "advanced" case operating in a similar fashion to the standard method, but supporting partial fills via supplied numerator and denominator fractional values as well as an optional extraData argument that will be supplied as part of a call to the validateOrder function when fulfilling restricted order types or to generateOrder and ratifyOrder as "context" on contract order types). Note that orders fulfilled in this manner do not have an explicit fulfiller; instead, Seaport will simply ensure coincidence of wants across each order. Note also that contract orders do not enforce usage of a specific conduit, but a Seaport app can require the usage of a specific conduit by setting allowances or approval on tokens for specific conduits. If a fulfiller does not supply the correct conduit key, the call will revert. There's currently no endpoint for finding which conduit a given Seaport app prefers.

While the standard method can technically be used for fulfilling any order, it suffers from key efficiency limitations in certain scenarios:

  • It requires additional calldata compared to the basic method for simple "hot paths".
  • It requires the fulfiller to approve each consideration item, even if the consideration item can be fulfilled using an offer item (as is commonly the case when fulfilling an order that offers ERC20 items for an ERC721 or ERC1155 item and also includes consideration items with the same ERC20 item type for paying fees).
  • It can result in unnecessary transfers, whereas in the "match" case those transfers can be reduced to a more minimal set.

Note: Calls to Seaport that would fulfill or match a collection of advanced orders can be monitored and where there are unused offer items, it's possible for a third party to claim them. Anyone can monitor the mempool to find calls to matchOrders or matchAdvancedOrders without "ad-hoc" orders (where the offerer is the caller, hence does not require a signature) and calculate if there are any unused offer item amounts. If there are unused offer item amounts, the third party can frontrun the transaction and supply themselves as the recipient, thereby allowing that third party to claim the unused offer items for themselves. A Seaport app or a zone could prevent this, or the fulfiller can utilize a private mempool, but by default it's possible.

Note: Contract orders can supply additional offer amounts when the order is executed. However, if they supply extra offer items with criteria, on the fly, the fulfiller won't be able to supply the necessary criteria resolvers, which would make fulfilling the order infeasible. Seaport apps should specifically avoid returning criteria-based items and generally avoid mismatches between previewOrder and what's executed on-chain.

Balance and Approval Requirements

When creating an offer, the following requirements should be checked to ensure that the order will be fulfillable:

  • The offerer should have sufficient balance of all offered items.
  • If the order does not indicate to use a conduit, the offerer should have sufficient approvals set for the Seaport contract for all offered ERC20, ERC721, and ERC1155 items.
  • If the order does indicate to use a conduit, the offerer should have sufficient approvals set for the respective conduit contract for all offered ERC20, ERC721 and ERC1155 items.

When fulfilling a basic order, the following requirements need to be checked to ensure that the order will be fulfillable:

  • The above checks need to be performed to ensure that the offerer still has sufficient balance and approvals.
  • The fulfiller should have sufficient balance of all consideration items except for those with an item type that matches the order's offered item type — by way of example, if the fulfilled order offers an ERC20 item and requires an ERC721 item to the offerer and the same ERC20 item to another recipient, the fulfiller needs to own the ERC721 item but does not need to own the ERC20 item as it will be sourced from the offerer.
  • If the fulfiller does not elect to utilize a conduit, they need to have sufficient approvals set for the Seaport contract for all ERC20, ERC721, and ERC1155 consideration items on the fulfilled order except for ERC20 items with an item type that matches the order's offered item type.
  • If the fulfiller does elect to utilize a conduit, they need to have sufficient approvals set for their respective conduit for all ERC20, ERC721, and ERC1155 consideration items on the fulfilled order except for ERC20 items with an item type that matches the order's offered item type.
  • If the fulfilled order specifies Ether (or other native tokens) as consideration items, the fulfiller must be able to supply the sum total of those items as msg.value.

When fulfilling a standard order, the following requirements need to be checked to ensure that the order will be fulfillable:

  • The above checks need to be performed to ensure that the offerer still has sufficient balance and approvals.
  • The fulfiller should have sufficient balance of all consideration items after receiving all offered items — by way of example, if the fulfilled order offers an ERC20 item and requires an ERC721 item to the offerer and the same ERC20 item to another recipient with an amount less than or equal to the offered amount, the fulfiller does not need to own the ERC20 item as it will first be received from the offerer.
  • If the fulfiller does not elect to utilize a conduit, they need to have sufficient approvals set for the Seaport contract for all ERC20, ERC721, and ERC1155 consideration items on the fulfilled order.
  • If the fulfiller does elect to utilize a conduit, they need to have sufficient approvals set for their respective conduit for all ERC20, ERC721, and ERC1155 consideration items on the fulfilled order.
  • If the fulfilled order specifies Ether (or other native tokens) as consideration items, the fulfiller must be able to supply the sum total of those items as msg.value.

When fulfilling a set of match orders, the following requirements need to be checked to ensure that the order will be fulfillable:

  • Each account that sources the ERC20, ERC721, or ERC1155 item for an execution that will be performed as part of the fulfillment must have sufficient balance and approval on Seaport or the indicated conduit at the time the execution is triggered. Note that prior executions may supply the necessary balance for subsequent executions.
  • The sum total of all executions involving Ether (or other native tokens) must be supplied as msg.value. Note that executions where the offerer and the recipient are the same account will be filtered out of the final execution set.

Partial Fills

When constructing an order, the offerer may elect to enable partial fills by setting an appropriate order type. Then, orders that support partial fills can be fulfilled for some fraction of the respective order, allowing subsequent fills to bypass signature verification. To summarize a few key points on partial fills:

  • When creating orders that support partial fills or determining a fraction to fill on those orders, all items (both offer and consideration) on the order must be cleanly divisible by the supplied fraction (i.e. no remainder after division).

  • If the desired fraction to fill would result in more than the full order amount being filled, that fraction will be reduced to the amount remaining to fill. This applies to both partial fill attempts as well as full fill attempts. If this behavior is not desired (i.e. the fill should be "all or none"), the fulfiller can either use a "basic" order method if available (which requires that the full order amount be filled), or use the "match" order method and explicitly provide an order that requires the full desired amount be received back.

    • By way of example: if one fulfiller tries to fill 1/2 of an order but another fulfiller first fills 3/4 of the order, the original fulfiller will end up filling 1/4 of the order.
  • If any of the items on a partially fillable order specify a different "startAmount" and "endAmount (e.g. they are ascending-amount or descending-amount items), the fraction will be applied to both amounts prior to determining the current price. This ensures that cleanly divisible amounts can be chosen when constructing the order without a dependency on the time when the order is ultimately fulfilled.

  • Partial fills can be combined with criteria-based items to enable constructing orders that offer or receive multiple items that would otherwise not be partially fillable (e.g. ERC721 items).

    • By way of example: an offerer can create a partially fillable order to supply up to 10 ETH for up to 10 ERC721 items from a given collection; then, any fulfiller can fill a portion of that order until it has been fully filled (or cancelled).

Sequence of Events

Fulfill Order

When fulfilling an order via fulfillOrder or fulfillAdvancedOrder:

  1. Hash order
    • Derive hashes for offer items and consideration items
    • Retrieve current counter for the offerer
    • Derive hash for order
  2. Perform initial validation
    • Ensure current time is inside order range
    • Ensure valid caller for the order type; if the order type is restricted and the caller is not the offerer or the zone, call the zone to determine whether the order is valid
  3. Retrieve and update order status
    • Ensure order is not cancelled
    • Ensure order is not fully filled
      • If the order is partially filled, reduce the supplied fill amount if necessary so that the order is not overfilled
    • Verify the order signature if not already validated
    • Determine fraction to fill based on preference + available amount
    • Update order status (validated + fill fraction)
  4. Determine amount for each item
    • Compare start amount and end amount
      • if they are equal: apply fill fraction to either one, ensure it divides cleanly, and use that amount
      • if not: apply fill fraction to both, ensuring they both divide cleanly, then find linear fit based on current time
  5. Apply criteria resolvers
    • Ensure each criteria resolver refers to a criteria-based order item
    • Ensure the supplied identifier for each item is valid via inclusion proof if the item has a non-zero criteria root
    • Update each item type and identifier
    • Ensure all remaining items are non-criteria-based
  6. Emit OrderFulfilled event
    • Include updated items (i.e. after amount adjustment and criteria resolution)
  7. Transfer offer items from offerer to caller
    • Use either conduit or Seaport directly to source approvals, depending on order type
  8. Transfer consideration items from caller to respective recipients
    • Use either conduit or Seaport directly to source approvals, depending on the fulfiller's stated preference

Note: fulfillBasicOrder works in a similar fashion, with a few exceptions: it reconstructs the order from a subset of order elements, skips linear fit amount adjustment and criteria resolution, requires that the full order amount be fillable, and performs a more minimal set of transfers by default when the offer item shares the same type and token as additional consideration items.

Match Orders

When matching a group of orders via matchOrders or matchAdvancedOrders, steps 1 through 6 are nearly identical but are performed for each supplied order. From there, the implementation diverges from standard fulfillments:

  1. Apply fulfillments
    • Ensure each fulfillment refers to one or more offer items and one or more consideration items, all with the same type and token, and with the same approval source for each offer item and the same recipient for each consideration item
    • Reduce the amount on each offer item and each consideration item to zero and track total reduced amounts for each
    • Compare total amounts for each and add back the remaining amount to the first item on the appropriate side of the order
    • Return a single execution for each fulfillment
  2. Scan each consideration item and ensure that none still have a nonzero amount remaining
  3. Perform transfers as part of each execution
    • Use either conduit or Seaport directly to source approvals, depending on the original order type
    • Ignore each execution where to == from

Contract Orders

Seaport v1.2 introduced support for a new type of order: the contract order. In brief, a smart contract that implements the ContractOffererInterface (referred to as an “Seaport app contract” or "Seaport app" in the docs and a “contract offerer” in the code) can now provide a dynamically generated order (a contract order) in response to a buyer or seller’s contract order request. Support for contract orders puts on-chain liquidity on equal footing with off-chain liquidity in the Seaport ecosystem. Further, the two types of liquidity are now broadly composable.

This unlocks a broad range of Seaport-native functionality, including instant conversion from an order’s specified currency (e.g. WETH) to a fulfiller’s preferred currency (e.g. ETH or DAI), flashloan-enriched functionality, liquidation engines, and more. In general, Seaport apps allow the Seaport community to extend default Seaport functionality. Developers with ideas or use cases that could be implemented as Seaport apps should open PRs in the Seaport Improvement Protocol (SIP) repo.

Creating a Seaport App

Anyone can build a Seaport app contract that interfaces with Seaport. A Seaport app just has to comply with the following interface:

interface ContractOffererInterface {

function generateOrder(
        address fulfiller,
        SpentItem[] calldata minimumReceived,
        SpentItem[] calldata maximumSpent,
        bytes calldata context
    )
        external
        returns (SpentItem[] memory offer, ReceivedItem[] memory consideration);

function ratifyOrder(
        SpentItem[] calldata offer,
        ReceivedItem[] calldata consideration,
        bytes calldata context,
        bytes32[] calldata orderHashes,
        uint256 contractNonce
    ) external returns (bytes4 ratifyOrderMagicValue);

function previewOrder(
        address caller,
        address fulfiller,
        SpentItem[] calldata minimumReceived,
        SpentItem[] calldata maximumSpent,
        bytes calldata context
    )
        external
        view
        returns (SpentItem[] memory offer, ReceivedItem[] memory consideration);

function getSeaportMetadata()
        external
        view
        returns (
            string memory name,
            Schema[] memory schemas
        );
}

See the TestContractOfferer.sol file in ./contracts/test/ for an example of an MVP Seaport app contract.

Arguments and Basic Functionality

When Seaport receives a contract order request from a fulfiller, it calls the Seaport app contract’s generateOrder function, which returns an array of SpentItems and an array of ReceivedItems. The Seaport app can adjust the response according to its own rules and if its response falls within the acceptable range specified in the original requester's offer (minimumReceived) and consideration (maximumSpent) parameters, Seaport will execute the orders. If not, the call will revert.

Note that when a request for a contract order is made, the requester is not supplying a conventional, signed order. Instead, the requester is supplying parameters that specify an acceptable range for the Seaport app to work within.

The minimumReceived array represents the smallest set that a requester is willing to accept from the Seaport app contract in the deal, though the Seaport app can provide more. The maximumSpent array represents the largest set that a requester is willing to provide to the Seaport app in the deal, though the Seaport app can accept less. In a very straightforward case, the requester's minimumReceived array would become the offer array on the Seaport app's contract order and the requester's maximumSpent array would become the consideration array on the Seaport app's contract order. These two guardrails can provide protection against slippage, among other safety functions.

Where a Seaport app provides extra offer items, increases offer item amounts (i.e. where it voluntarily exceeds the minimumReceived specified by the requester), removes consideration items, or reduces consideration item amounts (i.e. where it voluntarily demands less than the maximumSpent specified by the requester), those changes are collectively referred to as a "rebate." When a Seaport app attempts to provide fewer offer items, decreased offer item amounts, additional consideration items, or increased consideration item amounts, those changes are collectively referred to as a "penalty," and Seaport will catch and reject the order.

An optimal Seaport app should return an order with penalties when its previewOrder function is called with unacceptable minimumReceived and maximumSpent arrays, so that the caller can learn what the Seaport app expects. But it should revert when its generateOrder is called with unacceptable minimumReceived and maximumSpent arrays, so the function fails fast, gets skipped, and avoids wasting gas by leaving the validation to Seaport.

The Context Concept

The third argument provided to a Seaport app contract is context, which functions analogously to a zone’s extraData argument. For example, a Seaport app that provides AMM-like functionality might use context to determine which token IDs a buyer prefers or whether to take an “exact in” or “exact out” approach to deriving the order. The context is arbitrary bytes, but should be encoded according to a standard provided in the Seaport Improvement Protocol (SIP) repo.

While it’s still early days for the SIP ecosystem, every order generator contract should eventually be able to find an SIP that provides a context encoding and decoding standard that matches its use case. Order generators that adopt one or more SIP-standardized encoding or decoding approaches should signal that fact according to the specifications found in SIP 5, which functions analogously to EIP 165.

Context may be left empty, or it may contain all of the information necessary to fulfill the contract order (in place of fleshed-out minimumReceived and maximumSpent arguments). The latter case should only be utilized when the Seaport app contract in question is known to be reliable, as using the minimumReceived and maximumSpent arrays will cause Seaport to perform additional validation that the returned order meets the fulfiller’s expectations. Note that minimumReceived is optional, but maximumSpent is not. Even if the context is doing the majority of the work, maximumSpent must still be present as a safeguard.

Lifecycle

Contract orders are not signed and validated ahead of time like the other Seaport order types, but instead are generated on demand by the Seaport app contract. Order hashes for orders created by order generators are derived on the fly in _getGeneratedOrder, based on the Seaport app’s address and the contractNonce, which is incremented per order generator on each generated contract order. By virtue of responding to a call from Seaport, a Seaport app is effectively stating that its provided offer is acceptable and valid from its perspective.

The contract order lifecycle contains both a stateful generateOrder call to derive the contract order prior to execution and a stateful ratifyOrder call performed after execution. This means that contract orders can respond to the condition of e.g. the price of a fungible token before execution and verify post-execution that a flashloan was repaid or a critical feature of an NFT was not changed mid-flight.

Divergence from Non-Contract Orders

Note that when a collection-wide criteria-based item (criteria = 0) is provided as an input to a contract order, the Seaport app contract has full latitude to choose any identifier they want mid-flight. This deviates from Seaport’s behavior elsewhere, where the fulfiller can pick which identifier to receive by providing a CriteriaResolver. For contract order requests with identifierOrCriteria = 0, Seaport does not expect a corresponding CriteriaResolver, and will revert if one is provided. See _getGeneratedOrder and _compareItems for more detail.

During fulfillment, contract orders may designate native token (e.g. Ether) offer items; order generator contracts can then send native tokens directly to Seaport as part of the generateOrder call (or otherwise), allowing the fulfiller to use those native tokens. Any unused native tokens will be sent to the fulfiller (i.e. the caller). Native tokens can only be sent to Seaport when the reentrancy lock is set, and only then under specific circumstances. This enables conversion between ETH and WETH on-the-fly, among other possibilities. Note that any native tokens sent to Seaport will be immediately spendable by the current (or next) caller. Note also that this is a deviation from Seaport’s behavior elsewhere, where buyers may not supply native tokens as offer items.

Seaport also makes an exception to its normal reentrancy policies for order generator contracts. Order generator contracts may call the receive hook and provide native tokens. Anything that’s available to the Seaport app can be spent, including msg.value and balance.

Buyers interacting with order generator contracts should note that in some cases, order generator contracts will be able to lower the value of an offered NFT by transferring out valuable tokens that are attached to the NFT. For example, a Seaport app could modify a property of an NFT it owns when Seaport calls its generateOrder function. Consider using a mirrored order that allows for a post-transfer validation, such as a contract order or a restricted order, in cases like this.

Example Lifecycle Journey

To recap everything discussed above, here’s a description of the lifecycle of an example contract order:

  • An EOA buyer calls fulfillOrder and passes in an Order struct with OrderParameters that has OrderType of CONTRACT. Basically, the order says, "Go to the Seaport app contract at 0x123 and tell it I want to buy at least one Blitmap. Tell the Seaport app that I'm willing to spend up to 10 ETH but no more."
  • fulfillOrder calls _validateAndFulfillAdvancedOrder, as with other order types.
  • _validateAndFulfillAdvancedOrder calls _validateOrderAndUpdateStatus, as with other order types.
  • Inside _validateOrderAndUpdateStatus, at the point where the code path hits the line if (orderParameters.orderType == OrderType.CONTRACT) { ..., the code path for contract orders diverges from the code path for other order types.
  • After some initial checks, _validateOrderAndUpdateStatus calls _getGeneratedOrder.
  • _getGeneratedOrder does a low level call to the targeted order generator's generateOrder function.
  • The Seaport app contract can do pretty much anything it wants at this point, but a typical example would include processing the arguments it received, picking some NFTs it’s willing to sell, and returning a SpentItem array and a ReceivedItem array. In this example narrative, the Seaport app's response says "OK, I'm willing to sell the Blitmaps item for 10 ETH."
  • _getGeneratedOrder massages the result of the external generateOrder call into Seaport format, does some checks, and then returns the order hash to _validateOrderAndUpdateStatus.
  • _validateOrderAndUpdateStatus transfers the NFTs and the payment via _applyFractionsAndTransferEach and performs further checks, including calling _assertRestrictedAdvancedOrderValidity. _assertRestrictedAdvancedOrderValidity calls the Seaport app contract’s ratifyOrder function, which gives the Seaport app a chance to object to the way things played out. If, from the perspective of the Seaport app, something went wrong in the process of the transfer, the Seaport app contract has the opportunity to pass along a revert to Seaport, which will revert the entire fulfillOrder function call.
  • If _assertRestrictedAdvancedOrderValidity and the other checks all pass, _validateOrderAndUpdateStatus emits an OrderFulfilled event, and returns true to fulfillOrder, which in turn returns true itself, as with other order types.

Here’s a simplified code example of what the Seaport app contract from the example above might look like:

import {
    ContractOffererInterface
} from "../interfaces/ContractOffererInterface.sol";

import { ItemType } from "../lib/ConsiderationEnums.sol";

import {
    ReceivedItem,
    Schema,
    SpentItem
} from "../lib/ConsiderationStructs.sol";

/**
 * @title ExampleContractOfferer
 * @notice ExampleContractOfferer is a pseudocode sketch of a Seaport app
 *         contract that sells one Blitmaps NFT at a time for 10 or more ETH.
 */
contract ExampleContractOfferer is ContractOffererInterface {
    error OrderUnavailable();

    address private immutable _SEAPORT;
    address private immutable _BLITMAPS;

    constructor(address seaport, address blitmaps) {
        _SEAPORT = seaport;
        _BLITMAPS = blitmaps;
    }

    receive() external payable {}

    function generateOrder(
        address,
        SpentItem[] calldata originalOffer,
        SpentItem[] calldata originalConsideration,
        bytes calldata /* context */
    )
        external
        virtual
        override
        returns (SpentItem[] memory offer, ReceivedItem[] memory consideration)
    {
        SpentItem memory _originalOffer = originalOffer[0];
        SpentItem memory _originalConsideration = originalConsideration[0];
        
        if (
            // Ensure that the original prompt was looking for a Blitmaps item.
            (_originalOffer.token == _BLITMAPS && _originalOffer.amount == 1) &&
            // Ensure that the original prompt was willing to spend 10 ETH.
            (_originalConsideration.amount >= 10 ether)
        ) {
            // Set the offer and consideration that were supplied during deployment.
            offer = new SpentItem[](1);
            consideration = new ReceivedItem[](1);

            offer[0] = _originalOffer;
            consideration[0] = ReceivedItem({
                itemType: ItemType.NATIVE,
                token: address(0),
                identifier: 0,
                amount: 10 ether,
                recipient: payable(address(this))
            });
        } else {
            revert OrderUnavailable();
        }
    }

    function previewOrder(
        address /* caller */,
        address,
        SpentItem[] calldata,
        SpentItem[] calldata,
        bytes calldata /* context */
    )
        external
        view
        override
        returns (SpentItem[] memory offer, ReceivedItem[] memory consideration)
    {
        // Show what the order would look like given some set of params.
        // Should match the order that would be generated by `generateOrder`.
        SpentItem[] memory _offer;
        ReceivedItem[] memory _consideration;
        return (_offer, _consideration);
    }

    function ratifyOrder(
        SpentItem[] calldata /* offer */,
        ReceivedItem[] calldata /* consideration */,
        bytes calldata /* context */,
        bytes32[] calldata /*orderHashes*/,
        uint256 /* contractNonce */
    )
        external
        pure
        virtual
        override
        returns (bytes4 /* ratifyOrderMagicValue */)
    {
        // Do some post-execution validation here if desired.
        return ContractOffererInterface.ratifyOrder.selector;
    }

    /**
     * @dev Returns the metadata for this contract offerer.
     */
    function getSeaportMetadata()
        external
        pure
        override
        returns (
            string memory name,
            Schema[] memory schemas // map to Seaport Improvement Proposal IDs
        )
    {
        schemas = new Schema[](1);
        schemas[0].id = 1337;
        schemas[0].metadata = new bytes(0);

        return ("ExampleContractOfferer", schemas);
    }
}

Remember to create a Seaport Improvement Protocol (SIP) proposal for any novel Seaport app.

Bulk Order Creation

Seaport v1.2 introduced a bulk order creation feature. In brief, a buyer or seller can now sign a single bulk order payload that creates multiple orders with one ECDSA signature. So, instead of signing a dozen single order payloads to create a dozen orders, a user can now create the same dozen orders with a single click in their wallet UI.

Bulk signature payloads of depth 1 (2 orders) to depth 24 (16,777,216 orders) are fully supported as of v1.2. Just as with single order signing, bulk order payloads will be typed, human-readable EIP 712 data. Any individual order created in the course of bulk order creation is fulfillable independently. In other words, one order or multiple orders created in the course of bulk order creation can be included in a fulfillment transaction.

Note that there is a gas cost increase associated with fulfilling orders created in the course of bulk order creation. The cost increases logarithmically with the number of orders in the bulk order payload: roughly 4,000 gas for a tree height of 1 and then roughly an additional 700 gas per extra unit of height. Accordingly, it’s advisable to balance the convenience of creating multiple orders at once against the additional gas cost imposed on fulfillers.

Bulk Order Cancellation

Note that the incrementCounter function was modified in v1.2 to increment the counter by a quasi-random value derived from the last block hash. This change prevents the type of situation where a user is tricked into signing a malicious bulk signature payload containing orders that are fulfillable at both the current counter value and future counter values, which would be possible if counters were still incremented serially. Instead, since the counter jumps a very large, quasi-random amount, the effects of a malicious signature can still be neutralized by incrementing the counter a single time. In other words, the change to incrementCounter gives buyers and sellers the ability to "hard reset" regardless of what orders might have been unknowingly signed for in a large, malicious bulk order payload.

Note that orders created in the course of bulk order creation still need to be canceled individually. For example, if a maker creates 4 orders in a single bulk order payload, it will take 4 cancel transactions to cancel those 4 orders. Alternatively, the maker could call incrementCounter once, but that will also cause all of the maker’s other active orders to become unfillable. Users should exercise caution in creating large numbers of orders using bulk order creation and should prefer to regularly create short-lived orders instead of occasionally creating long lasting orders.

Bulk Order Signing and Structure

A bulk signature is an EIP 712 type Merkle tree where the root is a BulkOrder and the leaves are OrderComponents. Each level will be either a pair of orders or an order and an array. Each level gets hashed up the tree until it’s all rolled up into a single hash, which gets signed. The signature on the rolled up hash is the ECDSA signature referred to throughout.

A marketplace can either use the signature in combination with the entire set of orders (to fulfill the entire set of orders) or enable the maker to iterate over each order, set the appropriate key, and compute the proof for each order. Then, each proof gets appended onto the end of the ECDSA signature, which allows a fulfiller to target one or more specific orders from the bulk signature payload. See below for more detail.

Because of the Merkle tree structure of the bulk order payload and the limitations of EIP 712, each payload must contain exactly 2^N orders, where 1 ≤ N ≤ 24. If the desired number of orders to sign for is not a permissible value, empty (and hence unfulfillable) orders must be provided to bring the total order count to an acceptable value (4, 8, 16, 32, etc.). In other words, you can create any number of orders between 2 and 2^24, but the bulk signature payload needs to be padded with dummy orders. The dummy orders need to be present and have the right “shape” to make the bulk signature payload play nicely with EIP 712, but they should have no other effect and they should not be actionable. See the signSparseBulkOrder function in the Seaport Foundry tests, for an example of a bulk signature payload padded with empty orders.

Here’s a diagram of a bulk order payload for the case where a seller wants to list 9 different NFTs at once:

bulk-sig-payload-diagram

A valid bulk signature will have a length greater than or equal to 99 (1 x 32 + 67) and less than or equal to 836 (24 x 32 + 68) and will satisfy the following formula: ((length - 67) % 32) ≤ 1, since each proof should be 32 bytes long. The 67 and 68 bytes referenced in the preceding sentences are made up of a 64 or 65 byte ECDSA signature plus a 3 byte index. In other words, the recipe for a valid bulk signature is:

A 64 or 65 byte ECDSA signature
+ a three byte index
+ a series of 32 byte proof elements up to 24 proofs long

If a bulk order payload contains 4 orders, there will be one unique “bulk signature” for each, where 1) the beginning of the bulk signature is the same ECDSA signature for each, then 2) a unique index for each (0-3) depending on which order in the bulk order payload the signature is for, then 3) a series distinct proofs for each order.

For example:

ECDSA sig index proof 1 proof 2 proof 3 proof 4
0x95eb…3e9a 000000 4a…e1 9d…3f 7b…0c 2d…5b
0x95eb…3e9a 000001 4a…e1 9d…3f 7b…0c 2d…5b
0x95eb…3e9a 000002 4a…e1 9d…3f 7b…0c 2d…5b
0x95eb…3e9a 000003 4a…e1 9d…3f 7b…0c 2d…5b

This structure allows a fulfiller to disregard the fact that a signature is for a bulk order. A fulfiller can just select the full bulk signature that has the index of the order they want to fulfill and pass it in as if it were a bare signature for a single order. Seaport handles parsing of the bulk signature into its component parts and allows the fulfiller to fulfill exclusively the order they are targeting.

Bulk Order Construction Example

In JavaScript, the bulkOrderType is defined like this:

​​const bulkOrderType = {
  BulkOrder: [{ name: "tree", type: "OrderComponents[2][2][2][2][2][2][2]" }],
  OrderComponents: [
    { name: "offerer", type: "address" },
    { name: "zone", type: "address" },
    { name: "offer", type: "OfferItem[]" },
    { name: "consideration", type: "ConsiderationItem[]" },
    { name: "orderType", type: "uint8" },
    { name: "startTime", type: "uint256" },
    { name: "endTime", type: "uint256" },
    { name: "zoneHash", type: "bytes32" },
    { name: "salt", type: "uint256" },
    { name: "conduitKey", type: "bytes32" },
    { name: "counter", type: "uint256" },
  ],
  OfferItem: [
    { name: "itemType", type: "uint8" },
    { name: "token", type: "address" },
    { name: "identifierOrCriteria", type: "uint256" },
    { name: "startAmount", type: "uint256" },
    { name: "endAmount", type: "uint256" },
  ],
  ConsiderationItem: [
    { name: "itemType", type: "uint8" },
    { name: "token", type: "address" },
    { name: "identifierOrCriteria", type: "uint256" },
    { name: "startAmount", type: "uint256" },
    { name: "endAmount", type: "uint256" },
    { name: "recipient", type: "address" },
  ],
};

So, an example bulk order object in Javascript might look like this:

const bulkOrder = {
  name: "tree",
  type: "OrderComponents[2][2][2][2][2][2][2]",
  BulkOrder: [{
    offerer: "0x123...",
    zone: "0x456...",
    offer: [{
      itemType: 1,
      token: "0x789...",
      identifierOrCriteria: 123456,
      startAmount: 100,
      endAmount: 200
    }],
    consideration: [{
      itemType: 2,
      token: "0xabc...",
      identifierOrCriteria: 789012,
      startAmount: 1,
      endAmount: 1,
      recipient: "0xdef..."
    }],
    orderType: 0,
    startTime: 1546300800,
    endTime: 1546387199,
    zoneHash: "0x9abcdef...",
    salt: 123456,
    conduitKey: "0xabcdef...",
    counter: 789012345678901234
  },
  {
    offerer: "0x987...",
    zone: "0x654...",
    offer: [{
      itemType: 1,
      token: "0x321...",
      identifierOrCriteria: 654321,
      startAmount: 150,
      endAmount: 250
    }],
    consideration: [{
      itemType: 2,
      token: "0xcba...",
      identifierOrCriteria: 987654,
      startAmount: 1,
      endAmount: 1,
      recipient: "0xfed..."
    }],
    orderType: 1,
    startTime: 1547300800,
    endTime: 1547387199,
    zoneHash: "0x1abcdef...",
    salt: 987654,
    conduitKey: "0x1abcdef...",
    counter: 789012345678901234
  }]
};

So, creating a bulk signature might happen like this:

const signature = _signTypedData(
  domainData,
  bulkOrderType,
  value
);

Where domainData is the same as it would be for a single order, the bulkOrderType is defined as it is above, and the value is a tree of OrderComponents, as illustrated above. For an implementation example, see the signBulkOrder function in seaport-js.

Note again that the heavy lifting for marketplaces supporting bulk orders happens on the maker signature creation side. On the taker side, a fulfiller will be able to pass in a bulk signature just as if it were a signature for a normal order. For completeness and general interest, the following two paragraphs provide a sketch of how Seaport internally parses bulk signatures.

Bulk Signature Processing in Seaport

When processing a signature, Seaport will first check if the signature is a bulk signature (a 64 or 65 byte ECDSA signature, followed by a three-byte index, followed by additional proof elements). Then, Seaport will remove the extra data to create a new digest and process the remaining 64 or 65 byte ECDSA signature normally, following the usual code paths starting with signature validation.

In other words, if _isValidBulkOrderSize returns true, Seaport will call _computeBulkOrderProof using the full signature and the orderHash that were passed into _verifySignature to generate the trimmed ECDSA signature and relevant bulkOrderHash. Then, _deriveEIP712Digest creates the relevant digest. From that point onwards, Seaport handles the digest and the ECDSA signature normally, starting with _assertValidSignature.

Known Limitations and Workarounds

  • As all offer and consideration items are allocated against one another in memory, there are scenarios in which the actual received item amount will differ from the amount specified by the order — notably, this includes items with a fee-on-transfer mechanic. Orders that contain items of this nature (or, more broadly, items that have some post-fulfillment state that should be met) should leverage "restricted" order types and route the order fulfillment through a zone contract that performs the necessary checks after order fulfillment is completed.
  • As all offer items are taken directly from the offerer and all consideration items are given directly to the named recipient, there are scenarios where those accounts can increase the gas cost of order fulfillment or block orders from being fulfilled outright depending on the item being transferred. If the item in question is Ether or a similar native token, a recipient can throw in the payable fallback or even spend excess gas from the submitter. Similar mechanics can be leveraged by both offerers and receivers if the item in question is a token with a transfer hook (like ERC1155 and ERC777) or a non-standard token implementation. Potential remediations to this category of issue include wrapping Ether as WETH as a fallback if the initial transfer fails and allowing submitters to specify the amount of gas that should be allocated as part of a given fulfillment. Orders that support explicit fulfillments can also elect to leave problematic or unwanted offer items unspent as long as all consideration items are received in full.
  • As fulfillments may be executed in whatever sequence the fulfiller specifies as long as the fulfillments are all executable, as restricted orders are validated via zones prior to execution, and as orders may be combined with other orders or have additional consideration items supplied, any items with modifiable state are at risk of having that state modified during execution if a payable Ether recipient or onReceived 1155 transfer hook is able to modify that state. By way of example, imagine an offerer offers WETH and requires some ERC721 item as consideration, where the ERC721 should have some additional property like not having been used to mint some other ERC721 item. Then, even if the offerer enforces that the ERC721 have that property via a restricted order that checks for the property, a malicious fulfiller could include a second order (or even just an additional consideration item) that uses the ERC721 item being sold to mint before it is transferred to the offerer. One category of remediation for this problem is to use restricted orders that do not implement isValidOrder and actually require that order fulfillment is routed through them so that they can perform post-fulfillment validation. Another interesting solution to this problem that retains order composability is to "fight fire with fire" and have the offerer include a "validator" ERC1155 consideration item on orders that require additional assurances; this would be a contract that contains the ERC1155 interface but is not actually an 1155 token, and instead leverages the onReceived hook as a means to validate that the expected invariants were upheld, reverting the "transfer" if the check fails (so in the case of the example above, this hook would ensure that the offerer was the owner of the ERC721 item in question and that it had not yet been used to mint the other ERC721). The key limitation to this mechanic is the amount of data that can be supplied in-band via this route; only three arguments ("from", "identifier", and "amount") are available to utilize.
  • As all consideration items are supplied at the time of order creation, dynamic adjustment of recipients or amounts after creation (e.g. modifications to royalty payout info) is not supported. However, a zone can enforce that a given restricted order contains new dynamically computed consideration items by deriving them and either supplying them manually or ensuring that they are present via validateOrder since consideration items can be extended arbitrarily, with the important caveat that no more than the original offer item amounts can be spent.
  • As all criteria-based items are tied to a particular token, there is no native way to construct orders where items specify cross-token criteria. Additionally, each potential identifier for a particular criteria-based item must have the same amount as any other identifier.
  • As orders that contain items with ascending or descending amounts may not be filled as quickly as a fulfiller would like (e.g. transactions taking longer than expected to be included), there is a risk that fulfillment on those orders will supply a larger item amount, or receive back a smaller item amount, than they intended or expected. One way to prevent these outcomes is to utilize matchOrders, supplying a contrasting order for the fulfiller that explicitly specifies the maximum allowable offer items to be spent and consideration items to be received back. Special care should be taken when handling orders that contain both brief durations as well as items with ascending or descending amounts, as realized amounts may shift appreciably in a short window of time.
  • As all items on orders supporting partial fills must be "cleanly divisible" when performing a partial fill, orders with multiple items should be constructed with care. A straightforward heuristic is to start with a "unit" bundle (e.g. 1 NFT item A, 3 NFT item B, and 5 NFT item C for 2 ETH) then applying a multiple to that unit bundle (e.g. 7 of those units results in a partial order for 7 NFT item A, 21 NFT item B, and 35 NFT item C for 14 ETH).
  • As Ether cannot be "taken" from an account, any order that contains Ether or other native tokens as an offer item (including "implied" mirror orders) must be supplied by the caller executing the order(s) as msg.value. This also explains why there are no ERC721_TO_ETH and ERC1155_TO_ETH basic order route types, as Ether cannot be taken from the offerer in these cases. One important takeaway from this mechanic is that, technically, anyone can supply Ether on behalf of a given offerer (whereas the offerer themselves must supply all other items). It also means that all Ether must be supplied at the time the order or group of orders is originally called (and the amount available to spend by offer items cannot be increased by an external source during execution as is the case for token balances).
  • As extensions to the consideration array on fulfillment (i.e. "tipping") can be arbitrarily set by the caller, fulfillments where all matched orders have already been signed for or validated can be frontrun on submission, with the frontrunner modifying any tips. Therefore, it is important that orders fulfilled in this manner either leverage "restricted" order types with a zone that enforces appropriate allocation of consideration extensions, or that each offer item is fully spent and each consideration item is appropriately declared on order creation.
  • As orders that have been verified (via a call to validate) or partially filled will skip signature validation on subsequent fulfillments, orders that utilize EIP-1271 for verifying orders may end up in an inconsistent state where the original signature is no longer valid but the order is still fulfillable. In these cases, the offerer must explicitly cancel the previously verified order in question if they no longer wish for the order to be fulfillable.
  • As orders filled by the "fulfill available" method will only be skipped if those orders have been cancelled, fully filled, or are inactive, fulfillments may still be attempted on unfulfillable orders (examples include revoked approvals or insufficient balances). This scenario (as well as issues with order formatting) will result in the full batch failing. One remediation to this failure condition is to perform additional checks from an executing zone or wrapper contract when constructing the call and filtering orders based on those checks.
  • As order parameters must be supplied upon cancellation, orders that were meant to remain private (e.g. were not published publicly) will be made visible upon cancellation. While these orders would not be fulfillable without a corresponding signature, cancellation of private orders without broadcasting intent currently requires the offerer (or the zone, if the order type is restricted and the zone supports it) to increment the counter.
  • As order fulfillment attempts may become public before being included in a block, there is a risk of those orders being front-run. This risk is magnified in cases where offered items contain ascending amounts or consideration items contain descending amounts, as there is added incentive to leave the order unfulfilled until another interested fulfiller attempts to fulfill the order in question. Remediation efforts include utilization of a private mempool (e.g. flashbots) and/or restricted orders where the respective zone enforces a commit-reveal scheme.