There are two approaches to building an NFT marketplace. The first approach, championed by OpenSea and LooksRare, relies on an off-chain order book to match orders. The second approach, championed by Zora, aims to be fully on-chain and permissionless by creating orders on-chain and does not have an off-chain infrastructure. There are pros and cons for each approach. In the case of OpenSea/LooksRare, order makers only have to sign an order hash and not have to submit their orders on chain, saving on gas fees. Order signatures are stored in a centralized database. However, the centralized database is a single point of failure. If the server is down, order signatures cannot be retrieved and transactions cannot be made. In the case of Zora, because every order lives on chain, there isn’t a single point of failure and users can transact as long as the Ethereum blockchain is up and running. However, since each maker order has to be submitted on chain, it is more expensive for users to transact.
We are going to look at how LooksRare and Zora let an NFT owner creates an ask for a fixed price and allow bidders to match the ask.
LooksRare
Creating a maker ask
In order to sell an NFT, an NFT owner has to do 2 things. First, approve LooksRare’s TransferManagerERC721
to be the NFT’s operator so that it can transfer the NFT to the buyer when an order is matched. The transfer manager allows the seller to only approve once per NFT on LooksRare. Second, sign a MakerOrder
that can later be submitted on chain by the buyer to match with his bid.
struct MakerOrder {
bool isOrderAsk; // true --> ask / false --> bid
address signer; // signer of the maker order
address collection; // collection address
uint256 price; // price (used as )
uint256 tokenId; // id of the token
uint256 amount; // amount of tokens to sell/purchase (must be 1 for ERC721, 1+ for ERC1155)
address strategy; // strategy for trade execution (e.g., DutchAuction, StandardSaleForFixedPrice)
address currency; // currency (e.g., WETH)
uint256 nonce; // order nonce (must be unique unless new maker order is meant to override existing one e.g., lower ask price)
uint256 startTime; // startTime in timestamp
uint256 endTime; // endTime in timestamp
uint256 minPercentageToAsk; // slippage protection (9000 --> 90% of the final price must return to ask)
bytes params; // additional parameters
uint8 v; // v: parameter (27 or 28)
bytes32 r; // r: parameter
bytes32 s; // s: parameter
}
There are currently 3 order matching strategies (fixed price on specific token IDs, bidding on the whole collection, selling to a specific address), each of which is its own contract. v (recovery identifier), r and s (ECDSA signature outputs) are the values of the transaction’s signatures.
MakerOrder signature
A seller has to sign an EIP-712 signature of the order’s hash. An EIP-712 signature allows signers to see exactly what they are signing in a client wallet as the signed data is split into different fields and prevents the reuse of signature. It is achieved by having a domain separator in the signature. The domain separator includes the chain ID and LooksRareExchange
’s address, preventing the reuse of signature in another contract/chain (unless there is a fork).
DOMAIN_SEPARATOR = keccak256(
abi.encode(
0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f, // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
0xda9101ba92939daf4bb2e18cd5f942363b9297fbc3232c9dd964abb1fb70ed71, // keccak256("LooksRareExchange")
0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6, // keccak256(bytes("1")) for versionId = 1
block.chainid,
address(this)
)
);
A MakerOrder hash contains all its attributes except the signature values.
function hash(MakerOrder memory makerOrder) internal pure returns (bytes32) {
return
keccak256(
abi.encode(
MAKER_ORDER_HASH,
makerOrder.isOrderAsk,
makerOrder.signer,
makerOrder.collection,
makerOrder.price,
makerOrder.tokenId,
makerOrder.amount,
makerOrder.strategy,
makerOrder.currency,
makerOrder.nonce,
makerOrder.startTime,
makerOrder.endTime,
makerOrder.minPercentageToAsk,
keccak256(makerOrder.params)
)
);
}
EIP-712’s standard encoding prefix is \x19\x01
, so the final digest is
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, hash));
and the SignatureChecker
can call Solidity’s recover function to verify the signer is the same as the maker order’s signer address when the buyer submits the signature on chain.
recover(digest, v, r, s) == signer;
Bidding on a maker ask
The signature created by the seller is stored in a centralized database and can be retrieved by the website through an API. A seller who sees this pending order can make a bid by calling the function LooksRareExchange#matchAskWithTakerBid
. This function takes the maker ask struct and the taker bid struct as arguments and run the selected order matching logic on them. There is no need to store a signature for the TakerOrder as it is submitted on chain.
struct TakerOrder {
bool isOrderAsk; // true --> ask / false --> bid
address taker; // msg.sender
uint256 price; // final price for the purchase
uint256 tokenId;
uint256 minPercentageToAsk; // // slippage protection (9000 --> 90% of the final price must return to ask)
bytes params; // other params (e.g., tokenId)
}
Order validation
The function performs the following checks on the orders.
Only 1 of the order is an ask.
require( (makerAsk.isOrderAsk) && (!takerBid.isOrderAsk), "Order: Wrong sides" );
msg.sender
cannot bid for another address.require( msg.sender == takerBid.taker, "Order: Taker must be the sender" );
The maker order must not have been executed/cancelled or the signer’s max cancelled nonce must not be greater than the maker order’s nonce. An order can be cancelled by explicitly setting the signer’s nonce at
_isUserOrderNonceExecutedOrCancelled
or setting the minimum order nonce atuserMinOrderNonce
such that any signature with a nonce less than it are rendered invalid.require( ( !_isUserOrderNonceExecutedOrCancelled[makerOrder.signer][ makerOrder.nonce ] ) && (makerOrder.nonce >= userMinOrderNonce[makerOrder.signer]), "Order: Matching order expired" );
Signer must be present and order amount must not be 0.
require(makerOrder.signer != address(0), "Order: Invalid signer"); require(makerOrder.amount > 0, "Order: Amount cannot be 0");
The maker order signature must be valid (as mentioned above).
require( SignatureChecker.verify( orderHash, makerOrder.signer, makerOrder.v, makerOrder.r, makerOrder.s, DOMAIN_SEPARATOR ), "Signature: Invalid" );
The transaction currency and execution strategies are whitelisted by the contract owner at the
ExecutionManager/CurrencyManager
contract.require( currencyManager.isCurrencyWhitelisted(makerOrder.currency), "Currency: Not whitelisted" ); require( executionManager.isStrategyWhitelisted(makerOrder.strategy), "Strategy: Not whitelisted" );
Order matching execution
If the orders pass the validations, it will try to match the orders using the strategy selected by the maker.
(
bool isExecutionValid,
uint256 tokenId,
uint256 amount
) = IExecutionStrategy(makerAsk.strategy).canExecuteTakerBid(
takerBid,
makerAsk
);
require(isExecutionValid, "Strategy: Execution invalid");
The strategy StrategyStandardSaleForFixedPrice
checks that the maker order is currently active and the taker is actually bidding on the right token ID with the right price.
((makerBid.price == takerAsk.price) &&
(makerBid.tokenId == takerAsk.tokenId) &&
(makerBid.startTime <= block.timestamp) &&
(makerBid.endTime >= block.timestamp)),
If the strategy is able to match the orders, it will mark the order nonce as executed, transfer the sale amount to the seller, and transfer the NFT to the buyer. The protocol takes a cut from the sale and royalty is also taken from the sale if the NFT supports EIP-2981 or if the royalty amount is set in the protocol’s RoyaltyFeeRegistry
.
_isUserOrderNonceExecutedOrCancelled[makerAsk.signer][
makerAsk.nonce
] = true;
Protocol fee and royalty fee transfers
{
uint256 protocolFeeAmount = _calculateProtocolFee(strategy, amount);
// Check if the protocol fee is different than 0 for this strategy
if (
(protocolFeeRecipient != address(0)) && (protocolFeeAmount != 0)
) {
IERC20(WETH).safeTransfer(
protocolFeeRecipient,
protocolFeeAmount
);
finalSellerAmount -= protocolFeeAmount;
}
}
{
(
address royaltyFeeRecipient,
uint256 royaltyFeeAmount
) = royaltyFeeManager.calculateRoyaltyFeeAndGetRecipient(
collection,
tokenId,
amount
);
// Check if there is a royalty fee and that it is different to 0
if (
(royaltyFeeRecipient != address(0)) && (royaltyFeeAmount != 0)
) {
IERC20(WETH).safeTransfer(
royaltyFeeRecipient,
royaltyFeeAmount
);
finalSellerAmount -= royaltyFeeAmount;
}
}
Slippage protection
The protocol has a mechanism to prevent the sudden change of protocol fees and royalty fees from wrecking sellers. Sellers can set minPercentageToAsk
in their orders to guarantee a minimum sale percentage to receive for the executed order.
require(
(finalSellerAmount * 10000) >= (minPercentageToAsk * amount),
"Fees: Higher than expected"
);
NFT transfer
LooksRare supports both ERC-721 and ERC-1155 collections, so it cannot assume the token to be transferred is an ERC-721 token and converts the NFT address to an IERC721
. The exchange uses a module called TransferSelectorNFT
to check whether a collection supports the ERC-721 or the ERC-1155 interface (via EIP-165), then it uses the corresponding transfer manager to make the ERC-721/ERC-1155 transfers to the buyer.
if (IERC165(collection).supportsInterface(INTERFACE_ID_ERC721)) {
transferManager = TRANSFER_MANAGER_ERC721;
} else if (
IERC165(collection).supportsInterface(INTERFACE_ID_ERC1155)
) {
transferManager = TRANSFER_MANAGER_ERC1155;
}
ITransferManagerNFT(transferManager).transferNonFungibleToken(
collection,
from,
to,
tokenId,
amount
);
Zora
Zora is designed to be modular, where each logic component (Asks
, Offers
, ReserveAuction
, and soon CollectionOffers
and DutchAuction
) is a separate smart contract and has to be registered at the ZoraModuleManager
. The idea is similar to LooksRare’s strategy contracts.
Zora also has NFT transfer helper contracts, which allows sellers to not have to submit multiple approval transactions to each module. If a seller wants to sell an NFT at a fixed price, and later decides to take a buyer’s bid instead, he does not have to approve his NFT to be transferred twice as the ERC721TransferHelper
is the operator and works with all Zora modules. The seller still has to approve individual modules once, but this is user specific, so it only needs to be done once.
Creating a maker ask
An ask in Zora’s protocol is a struct stored on chain.
/// @notice The metadata for an ask
/// @param seller The address of the seller placing the ask
/// @param sellerFundsRecipient The address to send funds after the ask is filled
/// @param askCurrency The address of the ERC-20, or address(0) for ETH, required to fill the ask
/// @param findersFeeBps The fee to the referrer of the ask
/// @param askPrice The price to fill the ask
struct Ask {
address seller;
address sellerFundsRecipient;
address askCurrency;
uint16 findersFeeBps;
uint256 askPrice;
}
An ask is created via AsksV1_1#createAsk
. Besides checking the caller owns the NFT, it just checks the correct approvals have been submitted (mentioned above) and various parameters/addresses are within limit / not 0. The findersFeeBps
is Zora’s way to incentivize different marketplaces to show this ask in their frontends. When the ask is being fulfilled, the frontend injects its address as the finder fees receiver and a percentage of the sale is sent to it.
askForNFT[_tokenContract][_tokenId] = Ask({
seller: tokenOwner,
sellerFundsRecipient: _sellerFundsRecipient,
askCurrency: _askCurrency,
findersFeeBps: _findersFeeBps,
askPrice: _askPrice
});
The ask is stored on chain in a mapping and the order will be available as long as the blockchain is available.
Filling an ask
An order can be filled by calling AsksV1_1#fillAsk
.
First, it checks the ask is active and the bidder’s price and currency match the ask’s (A seller can cancel the ask by calling cancelAsk
, which just deletes the Ask
struct from the storage).
require(ask.seller != address(0), "fillAsk must be active ask");
require(ask.askCurrency == _fillCurrency, "fillAsk _fillCurrency must match ask currency");
require(ask.askPrice == _fillAmount, "fillAsk _fillAmount must match ask amount");
It then transfers the bidder’s tokens to the contract. It applies the pattern of checking the contract’s balance post-transfer to make sure the required amount was transferred. Some tokens have a transfer tax, so it is good to perform this validation.
uint256 beforeBalance = token.balanceOf(address(this));
erc20TransferHelper.safeTransferFrom(_currency, msg.sender, address(this), _amount);
uint256 afterBalance = token.balanceOf(address(this));
require(beforeBalance + _amount == afterBalance, "_handleIncomingTransfer token transfer call did not transfer expected amount");
After having control over the tokens, the function proceeds to royalty/protocol fee/finder fees payout. As the royalty engine is from the Manifold registry and the number of royalty recipients is unknown prior to the transaction, Zora protects the function from failing by creating the option to set a gas limit to pass to the royalty transfers transaction downstream and using a try/catch
block to rescue from any errors.
function _handleRoyaltyPayout(
address _tokenContract,
uint256 _tokenId,
uint256 _amount,
address _payoutCurrency,
uint256 _gasLimit
) internal returns (uint256, bool) {
// If no gas limit was provided or provided gas limit greater than gas left, just pass the remaining gas.
uint256 gas = (_gasLimit == 0 || _gasLimit > gasleft()) ? gasleft() : _gasLimit;
// External call ensuring contract doesn't run out of gas paying royalties
try this._handleRoyaltyEnginePayout{gas: gas}(_tokenContract, _tokenId, _amount, _payoutCurrency) returns (uint256 remainingFunds) {
// Return remaining amount if royalties payout succeeded
return (remainingFunds, true);
} catch {
// Return initial amount if royalties payout failed
return (_amount, false);
}
}
_handleRoyaltyEnginePayout
is supposed to be a private function, but it is made external
such that an explicit gas limit can be passed.
Protocol fee and finder fees are less complicated, it just calculates the amounts to pay the addresses based on the remaining profit and makes ERC-20 transfers.
uint256 protocolFee = protocolFeeSettings.getFeeAmount(address(this), _amount);
(, address feeRecipient) = protocolFeeSettings.moduleFeeSetting(address(this));
_handleOutgoingTransfer(feeRecipient, protocolFee, _payoutCurrency, 50000);
if (_finder != address(0)) {
uint256 findersFee = (remainingProfit * ask.findersFeeBps) / 10000;
_handleOutgoingTransfer(_finder, findersFee, ask.askCurrency, USE_ALL_GAS_FLAG);
remainingProfit = remainingProfit - findersFee;
}
With all the funds settled, the NFT is transferred to the buyer and the ask is deleted from the contract.
erc721TransferHelper.transferFrom(_tokenContract, ask.seller, msg.sender, _tokenId);
delete askForNFT[_tokenContract][_tokenId];
I am a little confused with the step in the looksrare contract with making a makerOrderHash, it includes a variable thats MAKER_ORDER_HASH but it is a little unclear how this is derived. Where does it come from?
hey man, love your dissections, but could you perhaps provide links to the github contracts which you explain