Dissecting DeFi Protocols

Share this post

Dissecting LooksRare/Zora

0xkowloon.substack.com

Dissecting LooksRare/Zora

A tale of two approaches

0xkowloon
Apr 20, 2022
6
3
Share this post

Dissecting LooksRare/Zora

0xkowloon.substack.com

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.

  1. Only 1 of the order is an ask.

    require(
        (makerAsk.isOrderAsk) && (!takerBid.isOrderAsk),
        "Order: Wrong sides"
    );
  2. msg.sender cannot bid for another address.

    require(
        msg.sender == takerBid.taker,
        "Order: Taker must be the sender"
    );
  3. 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 at userMinOrderNonce 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"
    );
  4. 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");
  5. 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"
    );
  6. 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];
3
Share this post

Dissecting LooksRare/Zora

0xkowloon.substack.com
3 Comments
deus
Nov 2, 2022

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?

Expand full comment
Reply
james
May 1, 2022

hey man, love your dissections, but could you perhaps provide links to the github contracts which you explain

Expand full comment
Reply
1 reply by 0xkowloon
1 more comment…
TopNewCommunity

No posts

Ready for more?

© 2023 0xkowloon
Privacy ∙ Terms ∙ Collection notice
Start WritingGet the app
Substack is the home for great writing