diff --git a/contracts/modules/Offers/Omnibus/IOffersOmnibus.sol b/contracts/modules/Offers/Omnibus/IOffersOmnibus.sol new file mode 100644 index 00000000..8c2997ec --- /dev/null +++ b/contracts/modules/Offers/Omnibus/IOffersOmnibus.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {OffersDataStorage} from "./OffersDataStorage.sol"; + +/// @title IOffersOmnibus +/// @author jgeary +/// @notice Interface for Offers Omnibus +interface IOffersOmnibus { + error INSUFFICIENT_ALLOWANCE(); + + error MODULE_NOT_APPROVED(); + + error NO_ZERO_OFFERS(); + + error MSG_VALUE_NEQ_ZERO_WITH_OTHER_CURRENCY(); + + error INSUFFICIENT_BALANCE(); + + error MSG_VALUE_NEQ_OFFER_AMOUNT(); + + error INVALID_FEES(); + + error INVALID_EXPIRY(); + + error CALLER_NOT_MAKER(); + + error SAME_OFFER(); + + error INACTIVE_OFFER(); + + error NOT_TOKEN_OWNER(); + + error INCORRECT_CURRENCY_OR_AMOUNT(); + + error TOKEN_TRANSFER_AMOUNT_INCORRECT(); + + error OFFER_EXPIRED(); + + function createOfferMinimal(address _tokenContract, uint256 _tokenId) external payable returns (uint256); + + function createOffer( + address _tokenContract, + uint256 _tokenId, + address _offerCurrency, + uint256 _offerAmount, + uint96 _expiry, + uint16 _findersFeeBps, + uint16 _listingFeeBps, + address _listingFeeRecipient + ) external payable returns (uint256); + + function setOfferAmount( + address _tokenContract, + uint256 _tokenId, + uint256 _offerId, + address _offerCurrency, + uint256 _offerAmount + ) external payable; + + function cancelOffer( + address _tokenContract, + uint256 _tokenId, + uint256 _offerId + ) external; + + function fillOffer( + address _tokenContract, + uint256 _tokenId, + uint256 _offerId, + uint256 _amount, + address _currency, + address _finder + ) external; + + function getFullOffer( + address _tokenContract, + uint256 _tokenId, + uint256 _offerId + ) external view returns (OffersDataStorage.FullOffer memory); +} diff --git a/contracts/modules/Offers/Omnibus/OffersDataStorage.sol b/contracts/modules/Offers/Omnibus/OffersDataStorage.sol new file mode 100644 index 00000000..a9198b03 --- /dev/null +++ b/contracts/modules/Offers/Omnibus/OffersDataStorage.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +contract OffersDataStorage { + struct StoredOffer { + uint256 amount; + address maker; + uint32 features; + mapping(uint32 => uint256) featureData; + } + + mapping(address => mapping(uint256 => mapping(uint256 => StoredOffer))) public offers; + + uint256 public offerCount; + + mapping(address => mapping(uint256 => uint256[])) public offersForNFT; + + uint32 constant FEATURE_MASK_LISTING_FEE = 1 << 3; + uint32 constant FEATURE_MASK_FINDERS_FEE = 1 << 4; + uint32 constant FEATURE_MASK_EXPIRY = 1 << 5; + uint32 constant FEATURE_MASK_ERC20_CURRENCY = 1 << 6; + + function _getListingFee(StoredOffer storage offer) internal view returns (uint16 listingFeeBps, address listingFeeRecipient) { + uint256 data = offer.featureData[FEATURE_MASK_LISTING_FEE]; + listingFeeBps = uint16(data); + listingFeeRecipient = address(uint160(data >> 16)); + } + + function _setListingFee( + StoredOffer storage offer, + uint16 listingFeeBps, + address listingFeeRecipient + ) internal { + offer.features |= FEATURE_MASK_LISTING_FEE; + offer.featureData[FEATURE_MASK_LISTING_FEE] = listingFeeBps | (uint256(uint160(listingFeeRecipient)) << 16); + } + + function _getFindersFee(StoredOffer storage offer) internal view returns (uint16) { + return uint16(offer.featureData[FEATURE_MASK_FINDERS_FEE]); + } + + function _setFindersFee(StoredOffer storage offer, uint16 _findersFeeBps) internal { + offer.features |= FEATURE_MASK_FINDERS_FEE; + offer.featureData[FEATURE_MASK_FINDERS_FEE] = uint256(_findersFeeBps); + } + + function _getExpiry(StoredOffer storage offer) internal view returns (uint96 expiry) { + uint256 data = offer.featureData[FEATURE_MASK_EXPIRY]; + expiry = uint96(data); + } + + function _setExpiry(StoredOffer storage offer, uint96 expiry) internal { + offer.features |= FEATURE_MASK_EXPIRY; + offer.featureData[FEATURE_MASK_EXPIRY] = expiry; + } + + function _getERC20CurrencyWithFallback(StoredOffer storage offer) internal view returns (address) { + if (!_hasFeature(offer.features, FEATURE_MASK_ERC20_CURRENCY)) { + return address(0); + } + return address(uint160(offer.featureData[FEATURE_MASK_ERC20_CURRENCY])); + } + + function _setERC20Currency(StoredOffer storage offer, address currency) internal { + offer.features |= FEATURE_MASK_ERC20_CURRENCY; + offer.featureData[FEATURE_MASK_ERC20_CURRENCY] = uint256(uint160(currency)); + } + + function _setETHorERC20Currency(StoredOffer storage offer, address currency) internal { + // turn off erc20 feature if previous currency was erc20 and new currency is eth + if (currency == address(0) && _hasFeature(offer.features, FEATURE_MASK_ERC20_CURRENCY)) { + offer.features &= ~FEATURE_MASK_ERC20_CURRENCY; + } + if (currency != address(0)) { + // turn on erc20 feature if previous currency was eth and new currency is erc20 + if (!_hasFeature(offer.features, FEATURE_MASK_ERC20_CURRENCY)) { + offer.features |= FEATURE_MASK_ERC20_CURRENCY; + } + offer.featureData[FEATURE_MASK_ERC20_CURRENCY] = uint256(uint160(currency)); + } + } + + struct FullOffer { + uint256 amount; + address maker; + uint96 expiry; + address currency; + uint16 findersFeeBps; + uint16 listingFeeBps; + address listingFeeRecipient; + } + + function _hasFeature(uint32 features, uint32 feature) internal pure returns (bool) { + return (features & feature) == feature; + } + + function _getFullOffer(StoredOffer storage offer) internal view returns (FullOffer memory) { + uint32 features = offer.features; + FullOffer memory fullOffer; + + if (_hasFeature(features, FEATURE_MASK_LISTING_FEE)) { + (fullOffer.listingFeeBps, fullOffer.listingFeeRecipient) = _getListingFee(offer); + } + + if (_hasFeature(features, FEATURE_MASK_FINDERS_FEE)) { + fullOffer.findersFeeBps = _getFindersFee(offer); + } + + if (_hasFeature(features, FEATURE_MASK_EXPIRY)) { + fullOffer.expiry = _getExpiry(offer); + } + + fullOffer.currency = _getERC20CurrencyWithFallback(offer); + fullOffer.maker = offer.maker; + fullOffer.amount = offer.amount; + + return fullOffer; + } +} diff --git a/contracts/modules/Offers/Omnibus/OffersOmnibus.sol b/contracts/modules/Offers/Omnibus/OffersOmnibus.sol new file mode 100644 index 00000000..a75940e3 --- /dev/null +++ b/contracts/modules/Offers/Omnibus/OffersOmnibus.sol @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {ReentrancyGuard} from "@rari-capital/solmate/src/utils/ReentrancyGuard.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import {ERC721TransferHelper} from "../../../transferHelpers/ERC721TransferHelper.sol"; +import {IncomingTransferSupportV1} from "../../../common/IncomingTransferSupport/V1/IncomingTransferSupportV1.sol"; +import {FeePayoutSupportV1} from "../../../common/FeePayoutSupport/FeePayoutSupportV1.sol"; +import {ModuleNamingSupportV1} from "../../../common/ModuleNamingSupport/ModuleNamingSupportV1.sol"; + +import {IOffersOmnibus} from "./IOffersOmnibus.sol"; +import {OffersDataStorage} from "./OffersDataStorage.sol"; + +/// @title Offers Omnibus +/// @author jgeary +/// @notice Omnibus module for multi-featured offers for ERC-721 tokens +contract OffersOmnibus is IOffersOmnibus, ReentrancyGuard, IncomingTransferSupportV1, FeePayoutSupportV1, ModuleNamingSupportV1, OffersDataStorage { + /// @dev The indicator to pass all remaining gas when paying out royalties + uint256 private constant USE_ALL_GAS_FLAG = 0; + + /// @notice The ZORA ERC-721 Transfer Helper + ERC721TransferHelper public immutable erc721TransferHelper; + + /// @notice Emitted when an offer is created + /// @param tokenContract The ERC-721 token address of the created offer + /// @param tokenId The ERC-721 token ID of the created offer + /// @param id The ID of the created offer + /// @param offer The metadata of the created offer + event OfferCreated(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed id, FullOffer offer); + + /// @notice Emitted when an offer amount is updated + /// @param tokenContract The ERC-721 token address of the updated offer + /// @param tokenId The ERC-721 token ID of the updated offer + /// @param id The ID of the updated offer + /// @param offer The metadata of the updated offer + event OfferUpdated(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed id, FullOffer offer); + + /// @notice Emitted when an offer is canceled + /// @param tokenContract The ERC-721 token address of the canceled offer + /// @param tokenId The ERC-721 token ID of the canceled offer + /// @param id The ID of the canceled offer + /// @param offer The metadata of the canceled offer + event OfferCanceled(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed id, FullOffer offer); + + /// @notice Emitted when an offer is filled + /// @param tokenContract The ERC-721 token address of the filled offer + /// @param tokenId The ERC-721 token ID of the filled offer + /// @param id The ID of the filled offer + /// @param taker The address of the taker who filled the offer + /// @param finder The address of the finder who referred the offer + /// @param offer The metadata of the filled offer + event OfferFilled(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed id, address taker, address finder, FullOffer offer); + + /// @param _erc20TransferHelper The ZORA ERC-20 Transfer Helper address + /// @param _erc721TransferHelper The ZORA ERC-721 Transfer Helper address + /// @param _royaltyEngine The Manifold Royalty Engine address + /// @param _protocolFeeSettings The ZORA Protocol Fee Settings address + /// @param _weth The WETH token address + constructor( + address _erc20TransferHelper, + address _erc721TransferHelper, + address _royaltyEngine, + address _protocolFeeSettings, + address _weth + ) + IncomingTransferSupportV1(_erc20TransferHelper) + FeePayoutSupportV1(_royaltyEngine, _protocolFeeSettings, _weth, ERC721TransferHelper(_erc721TransferHelper).ZMM().registrar()) + ModuleNamingSupportV1("Offers Omnibus") + { + erc721TransferHelper = ERC721TransferHelper(_erc721TransferHelper); + } + + /// @notice Implements EIP-165 for standard interface detection + /// @dev `0x01ffc9a7` is the IERC165 interface id + /// @param _interfaceId The identifier of a given interface + /// @return If the given interface is supported + function supportsInterface(bytes4 _interfaceId) external pure returns (bool) { + return _interfaceId == type(IOffersOmnibus).interfaceId || _interfaceId == type(IERC165).interfaceId; + } + + /// @notice Creates a simple WETH offer for a given NFT + /// @param _tokenContract The address of the ERC-721 token + /// @param _tokenId The id of the ERC-721 token + function createOfferMinimal(address _tokenContract, uint256 _tokenId) external payable nonReentrant returns (uint256) { + uint256 _offerAmount = msg.value; + weth.deposit{value: msg.value}(); + weth.transferFrom(address(this), msg.sender, _offerAmount); + + if (weth.allowance(msg.sender, address(erc20TransferHelper)) < _offerAmount) revert INSUFFICIENT_ALLOWANCE(); + if (!erc721TransferHelper.isModuleApproved(msg.sender)) revert MODULE_NOT_APPROVED(); + + ++offerCount; + + StoredOffer storage offer = offers[_tokenContract][_tokenId][offerCount]; + offer.amount = _offerAmount; + offer.maker = msg.sender; + offer.features = 0; + + offersForNFT[_tokenContract][_tokenId].push(offerCount); + emit OfferCreated(_tokenContract, _tokenId, offerCount, _getFullOffer(offer)); + return offerCount; + } + + /// @notice Creates an offer for a given NFT + /// @param _tokenContract The address of the ERC-721 token + /// @param _tokenId The id of the ERC-721 token + /// @param _offerAmount The amount of ERC20 token offered + /// @param _offerCurrency Address of ERC20 token + /// @param _expiry Timestamp after which the ask expires + /// @param _findersFeeBps Finders fee basis points + /// @param _listingFeeBps Listing fee basis points + /// @param _listingFeeRecipient Listing fee recipient + function createOffer( + address _tokenContract, + uint256 _tokenId, + address _offerCurrency, + uint256 _offerAmount, + uint96 _expiry, + uint16 _findersFeeBps, + uint16 _listingFeeBps, + address _listingFeeRecipient + ) external payable nonReentrant returns (uint256) { + if (_offerAmount == 0) revert NO_ZERO_OFFERS(); + + ++offerCount; + offersForNFT[_tokenContract][_tokenId].push(offerCount); + StoredOffer storage offer = offers[_tokenContract][_tokenId][offerCount]; + + if (_offerCurrency != address(0)) { + if (msg.value > 0) revert MSG_VALUE_NEQ_ZERO_WITH_OTHER_CURRENCY(); + IERC20 token = IERC20(_offerCurrency); + if (token.balanceOf(msg.sender) < _offerAmount) revert INSUFFICIENT_BALANCE(); + if (token.allowance(msg.sender, address(erc20TransferHelper)) < _offerAmount) revert INSUFFICIENT_ALLOWANCE(); + } else { + if (msg.value != _offerAmount) revert MSG_VALUE_NEQ_OFFER_AMOUNT(); + weth.deposit{value: msg.value}(); + weth.transferFrom(address(this), msg.sender, _offerAmount); + if (weth.balanceOf(msg.sender) < _offerAmount) revert INSUFFICIENT_BALANCE(); + if (weth.allowance(msg.sender, address(erc20TransferHelper)) < _offerAmount) revert INSUFFICIENT_ALLOWANCE(); + } + if (!erc721TransferHelper.isModuleApproved(msg.sender)) revert MODULE_NOT_APPROVED(); + + offer.maker = msg.sender; + offer.amount = _offerAmount; + offer.features = 0; + + _setETHorERC20Currency(offer, _offerCurrency); + + if (_findersFeeBps + _listingFeeBps > 10000) revert INVALID_FEES(); + + if (_listingFeeBps > 0) { + _setListingFee(offer, _listingFeeBps, _listingFeeRecipient); + } + + if (_findersFeeBps > 0) { + _setFindersFee(offer, _findersFeeBps); + } + + if (_expiry > 0) { + if (_expiry < block.timestamp) revert INVALID_EXPIRY(); + _setExpiry(offer, _expiry); + } + + emit OfferCreated(_tokenContract, _tokenId, offerCount, _getFullOffer(offer)); + return offerCount; + } + + /// @notice Updates the price of the given offer + /// @param _tokenContract The address of the offer ERC-721 token + /// @param _tokenId The ID of the offer ERC-721 token + /// @param _offerId The ID of the offer + /// @param _offerCurrency The address of the ERC-20 token offered + /// @param _offerAmount The new amount offered + function setOfferAmount( + address _tokenContract, + uint256 _tokenId, + uint256 _offerId, + address _offerCurrency, + uint256 _offerAmount + ) external payable nonReentrant { + StoredOffer storage offer = offers[_tokenContract][_tokenId][_offerId]; + + if (offer.maker != msg.sender) revert CALLER_NOT_MAKER(); + + if (_offerAmount == 0) revert NO_ZERO_OFFERS(); + if (_offerAmount == offer.amount && _offerCurrency == _getERC20CurrencyWithFallback(offer)) revert SAME_OFFER(); + + IERC20 token = IERC20(address(weth)); + if (_offerCurrency != address(0)) { + if (msg.value != 0) revert MSG_VALUE_NEQ_ZERO_WITH_OTHER_CURRENCY(); + token = IERC20(_offerCurrency); + } + if (_offerCurrency == address(0) && msg.value > 0) { + weth.deposit{value: msg.value}(); + weth.transferFrom(address(this), msg.sender, msg.value); + } + if (token.balanceOf(msg.sender) < _offerAmount) revert INSUFFICIENT_BALANCE(); + if (token.allowance(msg.sender, address(erc20TransferHelper)) < _offerAmount) revert INSUFFICIENT_ALLOWANCE(); + + _setETHorERC20Currency(offer, _offerCurrency); + offer.amount = _offerAmount; + + emit OfferUpdated(_tokenContract, _tokenId, _offerId, _getFullOffer(offer)); + } + + /// @notice Cancels the given offer for an NFT + /// @param _tokenContract The ERC-721 token address of the offer + /// @param _tokenId The ERC-721 token ID of the offer + /// @param _offerId The ID of the offer + function cancelOffer( + address _tokenContract, + uint256 _tokenId, + uint256 _offerId + ) external nonReentrant { + if (offers[_tokenContract][_tokenId][_offerId].maker != msg.sender) revert CALLER_NOT_MAKER(); + + emit OfferCanceled(_tokenContract, _tokenId, _offerId, _getFullOffer(offers[_tokenContract][_tokenId][_offerId])); + + // Remove the offer from storage + delete offers[_tokenContract][_tokenId][_offerId]; + } + + function _handleListingAndFindersFees( + uint256 _remainingProfit, + StoredOffer storage offer, + address _currency, + address _finder + ) internal returns (uint256 remainingProfit) { + remainingProfit = _remainingProfit; + uint256 listingFee; + address listingFeeRecipient; + uint256 findersFee; + + if (_hasFeature(offer.features, FEATURE_MASK_LISTING_FEE)) { + uint16 listingFeeBps; + (listingFeeBps, listingFeeRecipient) = _getListingFee(offer); + listingFee = (remainingProfit * listingFeeBps) / 10000; + } + + if (_finder != address(0) && _hasFeature(offer.features, FEATURE_MASK_FINDERS_FEE)) { + findersFee = (remainingProfit * _getFindersFee(offer)) / 10000; + } + + if (listingFee > 0) { + _handleOutgoingTransfer(listingFeeRecipient, listingFee, _currency, 50000); + remainingProfit -= listingFee; + } + if (findersFee > 0) { + _handleOutgoingTransfer(_finder, findersFee, _currency, 50000); + remainingProfit -= findersFee; + } + } + + /// @notice Fills an offer for a given NFT + /// @param _tokenContract The address of the ERC-721 token + /// @param _tokenId The id of the ERC-721 token + /// @param _offerId The id of the offer + /// @param _amount The offer amount + /// @param _currency The offer currency + /// @param _finder The offer finder + function fillOffer( + address _tokenContract, + uint256 _tokenId, + uint256 _offerId, + uint256 _amount, + address _currency, + address _finder + ) external nonReentrant { + StoredOffer storage offer = offers[_tokenContract][_tokenId][_offerId]; + + if (offer.maker == address(0)) revert INACTIVE_OFFER(); + if (IERC721(_tokenContract).ownerOf(_tokenId) != msg.sender) revert NOT_TOKEN_OWNER(); + address incomingTransferCurrency = _getERC20CurrencyWithFallback(offer); + + if (incomingTransferCurrency != _currency || offer.amount != _amount) revert INCORRECT_CURRENCY_OR_AMOUNT(); + if (_currency == address(0)) { + incomingTransferCurrency = address(weth); + } + IERC20 token = IERC20(incomingTransferCurrency); + uint256 beforeBalance = token.balanceOf(address(this)); + erc20TransferHelper.safeTransferFrom(incomingTransferCurrency, offer.maker, address(this), _amount); + uint256 afterBalance = token.balanceOf(address(this)); + if (beforeBalance + _amount != afterBalance) revert TOKEN_TRANSFER_AMOUNT_INCORRECT(); + if (_currency == address(0)) { + weth.withdraw(_amount); + } + + if (_hasFeature(offer.features, FEATURE_MASK_EXPIRY)) { + if (_getExpiry(offer) < block.timestamp) revert OFFER_EXPIRED(); + } + + // Payout associated token royalties, if any + (uint256 remainingProfit, ) = _handleRoyaltyPayout(_tokenContract, _tokenId, _amount, _currency, 300000); + + // Payout the module fee, if configured + remainingProfit = _handleProtocolFeePayout(remainingProfit, _currency); + + remainingProfit = _handleListingAndFindersFees(remainingProfit, offer, _currency, _finder); + + // Transfer the remaining profit to the filler + _handleOutgoingTransfer(msg.sender, remainingProfit, _currency, 50000); + + // Transfer the NFT to the buyer + // Reverts if the seller did not approve the ERC721TransferHelper or no longer owns the token + erc721TransferHelper.transferFrom(_tokenContract, msg.sender, offer.maker, _tokenId); + + emit OfferFilled(_tokenContract, _tokenId, _offerId, msg.sender, _finder, _getFullOffer(offer)); + + // Remove the ask from storage + delete offers[_tokenContract][_tokenId][_offerId]; + } + + function getFullOffer( + address _tokenContract, + uint256 _tokenId, + uint256 _offerId + ) external view returns (FullOffer memory) { + return _getFullOffer(offers[_tokenContract][_tokenId][_offerId]); + } + + // This fallback is necessary so the module can call weth.withdraw + fallback() external payable { + require(msg.sender == address(weth)); + } +} diff --git a/contracts/test/modules/Offers/Omnibus/OffersOmnibus.integration.t.sol b/contracts/test/modules/Offers/Omnibus/OffersOmnibus.integration.t.sol new file mode 100644 index 00000000..927859b3 --- /dev/null +++ b/contracts/test/modules/Offers/Omnibus/OffersOmnibus.integration.t.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {OffersOmnibus} from "../../../../modules/Offers/Omnibus/OffersOmnibus.sol"; +import {OffersDataStorage} from "../../../../modules/Offers/Omnibus/OffersDataStorage.sol"; +import {Zorb} from "../../../utils/users/Zorb.sol"; +import {ZoraRegistrar} from "../../../utils/users/ZoraRegistrar.sol"; +import {ZoraModuleManager} from "../../../../ZoraModuleManager.sol"; +import {ZoraProtocolFeeSettings} from "../../../../auxiliary/ZoraProtocolFeeSettings/ZoraProtocolFeeSettings.sol"; +import {ERC20TransferHelper} from "../../../../transferHelpers/ERC20TransferHelper.sol"; +import {ERC721TransferHelper} from "../../../../transferHelpers/ERC721TransferHelper.sol"; +import {RoyaltyEngine} from "../../../utils/modules/RoyaltyEngine.sol"; + +import {TestERC721} from "../../../utils/tokens/TestERC721.sol"; +import {WETH} from "../../../utils/tokens/WETH.sol"; +import {VM} from "../../../utils/VM.sol"; + +/// @title OffersV1IntegrationTest +/// @notice Integration Tests for Offers v1.0 +contract OffersOmnibusIntegrationTest is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + OffersOmnibus internal offers; + TestERC721 internal token; + WETH internal weth; + + Zorb internal seller; + Zorb internal buyer; + Zorb internal finder; + Zorb internal royaltyRecipient; + Zorb internal listingFeeRecipient; + + function setUp() public { + // Cheatcodes + vm = VM(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + + // Deploy V3 + registrar = new ZoraRegistrar(); + ZPFS = new ZoraProtocolFeeSettings(); + ZMM = new ZoraModuleManager(address(registrar), address(ZPFS)); + erc20TransferHelper = new ERC20TransferHelper(address(ZMM)); + erc721TransferHelper = new ERC721TransferHelper(address(ZMM)); + + // Init V3 + registrar.init(ZMM); + ZPFS.init(address(ZMM), address(0)); + + // Create users + seller = new Zorb(address(ZMM)); + buyer = new Zorb(address(ZMM)); + finder = new Zorb(address(ZMM)); + royaltyRecipient = new Zorb(address(ZMM)); + listingFeeRecipient = new Zorb(address(ZMM)); + + // Deploy mocks + royaltyEngine = new RoyaltyEngine(address(royaltyRecipient)); + token = new TestERC721(); + weth = new WETH(); + + // Deploy Offers v1.0 + offers = new OffersOmnibus(address(erc20TransferHelper), address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(offers)); + + // Set buyer balance + vm.deal(address(buyer), 100 ether); + + // Mint buyer token + token.mint(address(seller), 0); + + // buyer swap 50 ETH <> 50 WETH + vm.prank(address(buyer)); + weth.deposit{value: 50 ether}(); + + // Users approve Offers module + buyer.setApprovalForModule(address(offers), true); + seller.setApprovalForModule(address(offers), true); + + // Buyer approve ERC20TransferHelper + vm.prank(address(buyer)); + weth.approve(address(erc20TransferHelper), 50 ether); + + // Seller approve ERC721TransferHelper + vm.prank(address(seller)); + token.setApprovalForAll(address(erc721TransferHelper), true); + } + + /// ------------ ETH Offer ------------ /// + + function runETH() public { + vm.prank(address(buyer)); + uint256 id = offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 0, 100, 200, address(listingFeeRecipient)); + + vm.prank(address(seller)); + offers.fillOffer(address(token), 0, id, 1 ether, address(0), address(finder)); + } + + function test_ETHIntegration() public { + uint256 beforeSellerBalance = address(seller).balance; + uint256 beforeBuyerBalance = address(buyer).balance; + uint256 beforeRoyaltyRecipientBalance = address(royaltyRecipient).balance; + uint256 beforeFinderBalance = address(finder).balance; + uint256 beforeListingFeeRecipientBalance = address(listingFeeRecipient).balance; + address beforeTokenOwner = token.ownerOf(0); + runETH(); + uint256 afterSellerBalance = address(seller).balance; + uint256 afterBuyerBalance = address(buyer).balance; + uint256 afterRoyaltyRecipientBalance = address(royaltyRecipient).balance; + uint256 afterFinderBalance = address(finder).balance; + uint256 afterListingFeeRecipientBalance = address(listingFeeRecipient).balance; + address afterTokenOwner = token.ownerOf(0); + // 1 ETH withdrawn from buyer + require((beforeBuyerBalance - afterBuyerBalance) == 1 ether); + // 0.05 ETH creator royalty + require((afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance) == 0.05 ether); + // 100 bps finders fee (Remaining 0.95 ETH * finders fee = 0.0095 ETH) + require((afterFinderBalance - beforeFinderBalance) == 0.0095 ether); + // 200 bps listing fee (Remaining 0.95 ETH * listing fee = 0.019 ETH) + require((afterListingFeeRecipientBalance - beforeListingFeeRecipientBalance) == 0.019 ether); + // Remaining 0.855 ETH paid to seller + require((afterSellerBalance - beforeSellerBalance) == 0.9215 ether); + // NFT transferred to buyer + require((beforeTokenOwner == address(seller)) && afterTokenOwner == address(buyer)); + } + + // /// ------------ ERC-20 Offer ------------ /// + + function runERC20() public { + vm.prank(address(buyer)); + uint256 id = offers.createOffer(address(token), 0, address(weth), 1 ether, 0, 100, 200, address(listingFeeRecipient)); + + vm.prank(address(seller)); + offers.fillOffer(address(token), 0, id, 1 ether, address(weth), address(finder)); + } + + function test_ERC20Integration() public { + uint256 beforeSellerBalance = weth.balanceOf(address(seller)); + uint256 beforeBuyerBalance = weth.balanceOf(address(buyer)); + uint256 beforeRoyaltyRecipientBalance = weth.balanceOf(address(royaltyRecipient)); + uint256 beforeFinderBalance = weth.balanceOf(address(finder)); + uint256 beforeListingFeeRecipientBalance = weth.balanceOf(address(listingFeeRecipient)); + address beforeTokenOwner = token.ownerOf(0); + runERC20(); + uint256 afterSellerBalance = weth.balanceOf(address(seller)); + uint256 afterBuyerBalance = weth.balanceOf(address(buyer)); + uint256 afterRoyaltyRecipientBalance = weth.balanceOf(address(royaltyRecipient)); + uint256 afterFinderBalance = weth.balanceOf(address(finder)); + uint256 afterListingFeeRecipientBalance = weth.balanceOf(address(listingFeeRecipient)); + address afterTokenOwner = token.ownerOf(0); + + // 1 WETH withdrawn from seller + assertEq((beforeBuyerBalance - afterBuyerBalance), 1 ether); + // 0.05 WETH creator royalty + assertEq((afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance), 0.05 ether); + // 0.095 WETH finders fee (0.95 WETH * 10% finders fee) + assertEq((afterFinderBalance - beforeFinderBalance), 0.0095 ether); + assertEq((afterListingFeeRecipientBalance - beforeListingFeeRecipientBalance), 0.019 ether); + + // Remaining 0.9215 WETH paid to buyer + assertEq((afterSellerBalance - beforeSellerBalance), 0.9215 ether); + // NFT transferred to seller + require((beforeTokenOwner == address(seller)) && afterTokenOwner == address(buyer)); + } +} diff --git a/contracts/test/modules/Offers/Omnibus/OffersOmnibus.t.sol b/contracts/test/modules/Offers/Omnibus/OffersOmnibus.t.sol new file mode 100644 index 00000000..47c4c33f --- /dev/null +++ b/contracts/test/modules/Offers/Omnibus/OffersOmnibus.t.sol @@ -0,0 +1,377 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {OffersOmnibus} from "../../../../modules/Offers/Omnibus/OffersOmnibus.sol"; +import {OffersDataStorage} from "../../../../modules/Offers/Omnibus/OffersDataStorage.sol"; +import {Zorb} from "../../../utils/users/Zorb.sol"; +import {ZoraRegistrar} from "../../../utils/users/ZoraRegistrar.sol"; +import {ZoraModuleManager} from "../../../../ZoraModuleManager.sol"; +import {ZoraProtocolFeeSettings} from "../../../../auxiliary/ZoraProtocolFeeSettings/ZoraProtocolFeeSettings.sol"; +import {ERC20TransferHelper} from "../../../../transferHelpers/ERC20TransferHelper.sol"; +import {ERC721TransferHelper} from "../../../../transferHelpers/ERC721TransferHelper.sol"; +import {RoyaltyEngine} from "../../../utils/modules/RoyaltyEngine.sol"; + +import {TestERC721} from "../../../utils/tokens/TestERC721.sol"; +import {WETH} from "../../../utils/tokens/WETH.sol"; +import {VM} from "../../../utils/VM.sol"; + +/// @title OffersOmnibusTest +/// @notice Unit Tests for Offers Omnibus +contract OffersOmnibusTest is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + OffersOmnibus internal offers; + TestERC721 internal token; + WETH internal weth; + + Zorb internal maker; + Zorb internal taker; + Zorb internal finder; + Zorb internal royaltyRecipient; + Zorb internal listingFeeRecipient; + + function setUp() public { + // Cheatcodes + vm = VM(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + + // Deploy V3 + registrar = new ZoraRegistrar(); + ZPFS = new ZoraProtocolFeeSettings(); + ZMM = new ZoraModuleManager(address(registrar), address(ZPFS)); + erc20TransferHelper = new ERC20TransferHelper(address(ZMM)); + erc721TransferHelper = new ERC721TransferHelper(address(ZMM)); + + // Init V3 + registrar.init(ZMM); + ZPFS.init(address(ZMM), address(0)); + + // Create users + maker = new Zorb(address(ZMM)); + taker = new Zorb(address(ZMM)); + finder = new Zorb(address(ZMM)); + royaltyRecipient = new Zorb(address(ZMM)); + listingFeeRecipient = new Zorb(address(ZMM)); + + // Deploy mocks + royaltyEngine = new RoyaltyEngine(address(royaltyRecipient)); + token = new TestERC721(); + weth = new WETH(); + + // Deploy Offers v1.0 + offers = new OffersOmnibus(address(erc20TransferHelper), address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(offers)); + + // Set maker balance + vm.deal(address(maker), 100 ether); + + // Mint taker token + token.mint(address(taker), 0); + + // Maker swap 50 ETH <> 50 WETH + vm.prank(address(maker)); + weth.deposit{value: 50 ether}(); + + // Users approve Offers module + maker.setApprovalForModule(address(offers), true); + taker.setApprovalForModule(address(offers), true); + + // Maker approve ERC20TransferHelper + vm.prank(address(maker)); + weth.approve(address(erc20TransferHelper), 50 ether); + + // Taker approve ERC721TransferHelper + vm.prank(address(taker)); + token.setApprovalForAll(address(erc721TransferHelper), true); + } + + /// ------------ CREATE NFT OFFER ------------ /// + + function testGas_CreateOffer() public { + vm.prank(address(maker)); + offers.createOffer(address(token), 0, address(weth), 1 ether, uint96(block.timestamp + 100000), 100, 200, address(listingFeeRecipient)); + } + + function testGas_CreateOfferMinimal() public { + vm.prank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + } + + function test_CreateETHOffer() public { + uint256 makerBalanceBefore = address(maker).balance; + uint256 makerWethBalanceBefore = weth.balanceOf(address(maker)); + vm.prank(address(maker)); + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 0, 100, 200, address(listingFeeRecipient)); + uint256 makerBalanceAfter = address(maker).balance; + uint256 makerWethBalanceAfter = weth.balanceOf(address(maker)); + assertEq(makerBalanceBefore - makerBalanceAfter, 1 ether); + assertEq(makerWethBalanceAfter - makerWethBalanceBefore, 1 ether); + OffersDataStorage.FullOffer memory offer = offers.getFullOffer(address(token), 0, 1); + assertEq(offer.amount, 1 ether); + assertEq(offer.maker, address(maker)); + assertEq(offer.expiry, 0); + assertEq(offer.findersFeeBps, 100); + assertEq(offer.currency, address(0)); + assertEq(offer.listingFeeRecipient, address(listingFeeRecipient)); + assertEq(offer.listingFeeBps, 200); + } + + function test_CreateERC20Offer() public { + vm.prank(address(maker)); + offers.createOffer(address(token), 0, address(weth), 1 ether, 0, 100, 200, address(listingFeeRecipient)); + OffersDataStorage.FullOffer memory offer = offers.getFullOffer(address(token), 0, 1); + assertEq(offer.amount, 1 ether); + assertEq(offer.maker, address(maker)); + assertEq(offer.expiry, 0); + assertEq(offer.findersFeeBps, 100); + assertEq(offer.currency, address(weth)); + assertEq(offer.listingFeeRecipient, address(listingFeeRecipient)); + assertEq(offer.listingFeeBps, 200); + } + + function test_CreateOfferMinimal() public { + vm.prank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + OffersDataStorage.FullOffer memory offer = offers.getFullOffer(address(token), 0, 1); + assertEq(offer.amount, 1 ether); + assertEq(offer.maker, address(maker)); + assertEq(offer.expiry, 0); + assertEq(offer.findersFeeBps, 0); + assertEq(offer.currency, address(0)); + assertEq(offer.listingFeeRecipient, address(0)); + assertEq(offer.listingFeeBps, 0); + } + + function test_CreateOfferWithExpiry() public { + uint256 makerBalanceBefore = address(maker).balance; + uint256 makerWethBalanceBefore = weth.balanceOf(address(maker)); + vm.prank(address(maker)); + uint96 start = uint96(block.timestamp); + uint96 tomorrow = start + 1 days; + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, tomorrow, 100, 200, address(listingFeeRecipient)); + vm.warp(tomorrow + 1 days); + vm.startPrank(address(taker)); + vm.expectRevert(abi.encodeWithSignature("OFFER_EXPIRED()")); + offers.fillOffer(address(token), 0, 1, 1 ether, address(0), address(0)); + vm.warp(start + 1 hours); + offers.fillOffer(address(token), 0, 1, 1 ether, address(0), address(0)); + } + + function testFail_CannotCreateOfferWithoutAttachingFunds() public { + vm.prank(address(maker)); + offers.createOffer(address(token), 0, address(0), 1 ether, 0, 0, 0, address(0)); + } + + function testFail_CannotCreateOfferWithInvalidFindersFeeBps() public { + vm.prank(address(maker)); + offers.createOffer(address(token), 0, address(weth), 1 ether, 0, 10001, 0, address(0)); + } + + function testFail_CannotCreateOfferWithInvalidFindersAndListingFeeBps() public { + vm.prank(address(maker)); + offers.createOffer(address(token), 0, address(weth), 1 ether, 0, 5000, 5001, address(listingFeeRecipient)); + } + + function testFail_CannotCreateOfferWithInvalidExpiry() public { + vm.prank(address(maker)); + vm.warp(1000); + offers.createOffer(address(token), 0, address(weth), 1 ether, 500, 100, 200, address(listingFeeRecipient)); + } + + function testFail_CannotCreateERC20OfferWithMsgValue() public { + vm.prank(address(maker)); + offers.createOffer{value: 1 ether}(address(token), 0, address(weth), 1 ether, 0, 100, 200, address(listingFeeRecipient)); + } + + function testFail_CannotCreateERC20OfferInsufficientBalance() public { + vm.prank(address(maker)); + offers.createOffer(address(token), 0, address(weth), 1000 ether, 0, 100, 200, address(listingFeeRecipient)); + } + + function testFail_CannotCreateETHOfferInsufficientWethAllowance() public { + vm.startPrank(address(maker)); + weth.approve(address(erc20TransferHelper), 0); + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 0, 100, 200, address(listingFeeRecipient)); + vm.stopPrank(); + } + + function testFail_CannotCreateERC20OfferInsufficientAllowance() public { + vm.startPrank(address(maker)); + weth.approve(address(erc20TransferHelper), 0); + offers.createOffer(address(token), 0, address(weth), 1 ether, 0, 100, 200, address(listingFeeRecipient)); + vm.stopPrank(); + } + + /// ------------ SET NFT OFFER ------------ /// + + function test_IncreaseETHOffer() public { + uint256 wethBalanceBefore = weth.balanceOf(address(maker)); + vm.startPrank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + uint256 wethBalanceAfterCreate = weth.balanceOf(address(maker)); + vm.warp(1 hours); + offers.setOfferAmount{value: 1 ether}(address(token), 0, 1, address(0), 2 ether); + vm.stopPrank(); + uint256 wethBalanceAfterUpdate = weth.balanceOf(address(maker)); + OffersDataStorage.FullOffer memory offer = offers.getFullOffer(address(token), 0, 1); + assertEq(offer.amount, 2 ether); + assertEq(wethBalanceAfterCreate - wethBalanceBefore, 1 ether); + assertEq(wethBalanceAfterUpdate - wethBalanceBefore, 2 ether); + } + + function test_DecreaseETHOffer() public { + uint256 wethBalanceBefore = weth.balanceOf(address(maker)); + vm.startPrank(address(maker)); + offers.createOfferMinimal{value: 2 ether}(address(token), 0); + uint256 wethBalanceAfterCreate = weth.balanceOf(address(maker)); + vm.warp(1 hours); + offers.setOfferAmount(address(token), 0, 1, address(0), 1 ether); + vm.stopPrank(); + uint256 wethBalanceAfterUpdate = weth.balanceOf(address(maker)); + OffersDataStorage.FullOffer memory offer = offers.getFullOffer(address(token), 0, 1); + assertEq(offer.amount, 1 ether); + assertEq(wethBalanceAfterCreate - wethBalanceBefore, 2 ether); + assertEq(wethBalanceAfterCreate, wethBalanceAfterUpdate); + } + + function test_IncreaseETHOfferWithERC20() public { + vm.startPrank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.warp(1 hours); + offers.setOfferAmount(address(token), 0, 1, address(weth), 2 ether); + vm.stopPrank(); + OffersDataStorage.FullOffer memory offer = offers.getFullOffer(address(token), 0, 1); + assertEq(offer.amount, 2 ether); + assertEq(offer.currency, address(weth)); + assertEq(address(offers).balance, 0 ether); + } + + function test_DecreaseETHOfferWithERC20() public { + vm.startPrank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.warp(1 hours); + offers.setOfferAmount(address(token), 0, 1, address(weth), 0.5 ether); + vm.stopPrank(); + OffersDataStorage.FullOffer memory offer = offers.getFullOffer(address(token), 0, 1); + assertEq(offer.amount, 0.5 ether); + assertEq(offer.currency, address(weth)); + assertEq(address(offers).balance, 0 ether); + } + + function testRevert_OnlySellerCanUpdateOffer() public { + vm.prank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.expectRevert(abi.encodeWithSignature("CALLER_NOT_MAKER()")); + offers.setOfferAmount(address(token), 0, 1, address(0), 0.5 ether); + } + + function testRevert_CannotIncreaseEthOfferWithoutAttachingNecessaryFunds() public { + vm.startPrank(address(maker)); + offers.createOfferMinimal{value: 0.1 ether}(address(token), 0); + vm.expectRevert(abi.encodeWithSignature("INSUFFICIENT_BALANCE()")); + offers.setOfferAmount(address(token), 0, 1, address(0), 51 ether); + vm.stopPrank(); + } + + function testRevert_CannotUpdateOfferWithPreviousAmount() public { + vm.startPrank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.warp(1 hours); + vm.expectRevert(abi.encodeWithSignature("SAME_OFFER()")); + offers.setOfferAmount{value: 1 ether}(address(token), 0, 1, address(0), 1 ether); + vm.stopPrank(); + } + + function testRevert_CannotUpdateInactiveOffer() public { + vm.prank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.prank(address(taker)); + offers.fillOffer(address(token), 0, 1, 1 ether, address(0), address(finder)); + vm.prank(address(maker)); + vm.expectRevert(abi.encodeWithSignature("CALLER_NOT_MAKER()")); + offers.setOfferAmount(address(token), 0, 1, address(0), 0.5 ether); + } + + /// ------------ CANCEL NFT OFFER ------------ /// + + function test_CancelNFTOffer() public { + vm.startPrank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + (uint256 beforeAmount, , ) = offers.offers(address(token), 0, 1); + require(beforeAmount == 1 ether); + offers.cancelOffer(address(token), 0, 1); + (uint256 afterAmount, , ) = offers.offers(address(token), 0, 1); + require(afterAmount == 0); + vm.stopPrank(); + } + + function testRevert_CannotCancelInactiveOffer() public { + vm.prank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.prank(address(taker)); + offers.fillOffer(address(token), 0, 1, 1 ether, address(0), address(finder)); + vm.prank(address(maker)); + vm.expectRevert(abi.encodeWithSignature("CALLER_NOT_MAKER()")); + offers.cancelOffer(address(token), 0, 1); + } + + function testRevert_OnlySellerCanCancelOffer() public { + vm.prank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.expectRevert(abi.encodeWithSignature("CALLER_NOT_MAKER()")); + offers.cancelOffer(address(token), 0, 1); + } + + // /// ------------ FILL NFT OFFER ------------ /// + + function test_FillNFTOffer() public { + vm.prank(address(maker)); + offers.createOfferMinimal{value: 1 ether}(address(token), 0); + address beforeTokenOwner = token.ownerOf(0); + vm.prank(address(taker)); + offers.fillOffer(address(token), 0, 1, 1 ether, address(0), address(finder)); + address afterTokenOwner = token.ownerOf(0); + require(beforeTokenOwner == address(taker) && afterTokenOwner == address(maker)); + } + + function testRevert_OnlyTokenHolderCanFillOffer() public { + vm.prank(address(maker)); + uint256 id = offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.expectRevert(abi.encodeWithSignature("NOT_TOKEN_OWNER()")); + offers.fillOffer(address(token), 0, id, 1 ether, address(0), address(finder)); + } + + function testRevert_CannotFillInactiveOffer() public { + vm.prank(address(maker)); + uint256 id = offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.prank(address(taker)); + offers.fillOffer(address(token), 0, id, 1 ether, address(0), address(finder)); + vm.prank(address(taker)); + vm.expectRevert(abi.encodeWithSignature("INACTIVE_OFFER()")); + offers.fillOffer(address(token), 0, id, 1 ether, address(0), address(finder)); + } + + function testRevert_AcceptCurrencyMustMatchOffer() public { + vm.prank(address(maker)); + uint256 id = offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.prank(address(taker)); + vm.expectRevert(abi.encodeWithSignature("INCORRECT_CURRENCY_OR_AMOUNT()")); + offers.fillOffer(address(token), 0, id, 1 ether, address(weth), address(finder)); + } + + function testRevert_AcceptAmountMustMatchOffer() public { + vm.prank(address(maker)); + uint256 id = offers.createOfferMinimal{value: 1 ether}(address(token), 0); + vm.prank(address(taker)); + vm.expectRevert(abi.encodeWithSignature("INCORRECT_CURRENCY_OR_AMOUNT()")); + offers.fillOffer(address(token), 0, id, 0.5 ether, address(0), address(finder)); + } +}