diff --git a/addresses/1.json b/addresses/1.json index cb7cd27f..0acc6c52 100644 --- a/addresses/1.json +++ b/addresses/1.json @@ -6,5 +6,6 @@ "ERC20TransferHelper": "0xCCA379FDF4Beda63c4bB0e2A3179Ae62c8716794", "ERC721TransferHelper": "0x909e9efE4D87d1a6018C2065aE642b6D0447bc91", "AsksV1": "0xCe6cEf2A9028e1C3B21647ae3B4251038109f42a", - "AsksV1_1": "0x6170B3C3A54C3d8c854934cBC314eD479b2B29A3" + "AsksV1_1": "0x6170B3C3A54C3d8c854934cBC314eD479b2B29A3", + "OffersV1": "0x76744367ae5a056381868f716bdf0b13ae1aeaa3" } diff --git a/addresses/4.json b/addresses/4.json index 9e9add05..db6556aa 100644 --- a/addresses/4.json +++ b/addresses/4.json @@ -6,5 +6,6 @@ "ERC20TransferHelper": "0x408AbC192a5e9696085EBaFC7C5A88e19e66241b", "ERC721TransferHelper": "0x029AA5a949C9C90916729D50537062cb73b5Ac92", "AsksV1": "0x850356153abBdFA1B473e2D86F2DF11a85B408B8", - "AsksV1_1": "0xA98D3729265C88c5b3f861a0c501622750fF4806" -} \ No newline at end of file + "AsksV1_1": "0xA98D3729265C88c5b3f861a0c501622750fF4806", + "OffersV1": "0x1240ef9f9c56ee981d10cffc6ba5807b6c7fecaa" +} diff --git a/contracts/modules/Offers/V1/OffersV1.sol b/contracts/modules/Offers/V1/OffersV1.sol new file mode 100644 index 00000000..bc35ede2 --- /dev/null +++ b/contracts/modules/Offers/V1/OffersV1.sol @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +/// ------------ IMPORTS ------------ + +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 {ERC721TransferHelper} from "../../../transferHelpers/ERC721TransferHelper.sol"; +import {UniversalExchangeEventV1} from "../../../common/UniversalExchangeEvent/V1/UniversalExchangeEventV1.sol"; +import {IncomingTransferSupportV1} from "../../../common/IncomingTransferSupport/V1/IncomingTransferSupportV1.sol"; +import {FeePayoutSupportV1} from "../../../common/FeePayoutSupport/FeePayoutSupportV1.sol"; +import {ModuleNamingSupportV1} from "../../../common/ModuleNamingSupport/ModuleNamingSupportV1.sol"; + +/// @title Offers V1 +/// @author kulkarohan +/// @notice This module allows users to make ETH/ERC-20 offers for any ERC-721 token +contract OffersV1 is ReentrancyGuard, UniversalExchangeEventV1, IncomingTransferSupportV1, FeePayoutSupportV1, ModuleNamingSupportV1 { + /// @dev The indicator to pass all remaining gas when paying out royalties + uint256 private constant USE_ALL_GAS_FLAG = 0; + + /// @notice The total number of offers made + uint256 public offerCount; + + /// @notice The ZORA ERC-721 Transfer Helper + ERC721TransferHelper public immutable erc721TransferHelper; + + /// @notice The metadata of an offer + /// @param maker The address of the user who made the offer + /// @param currency The address of the ERC-20 offered, or address(0) for ETH + /// @param findersFeeBps The fee to the referrer of the offer + /// @param amount The amount of ETH/ERC-20 tokens offered + struct Offer { + address maker; + address currency; + uint16 findersFeeBps; + uint256 amount; + } + + /// ------------ STORAGE ------------ + + /// @notice The metadata for a given offer + /// @dev ERC-721 token address => ERC-721 token ID => Offer ID => Offer + mapping(address => mapping(uint256 => mapping(uint256 => Offer))) public offers; + + /// @notice The offers for a given NFT + /// @dev ERC-721 token address => ERC-721 token ID => Offer IDs + mapping(address => mapping(uint256 => uint256[])) public offersForNFT; + + /// ------------ EVENTS ------------ + + /// @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, Offer offer); + + /// @notice Emitted when an offer 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, Offer 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, Offer 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, Offer offer); + + /// ------------ CONSTRUCTOR ------------ + + /// @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 ZoraProtocolFeeSettingsV1 address + /// @param _wethAddress The WETH token address + constructor( + address _erc20TransferHelper, + address _erc721TransferHelper, + address _royaltyEngine, + address _protocolFeeSettings, + address _wethAddress + ) + IncomingTransferSupportV1(_erc20TransferHelper) + FeePayoutSupportV1(_royaltyEngine, _protocolFeeSettings, _wethAddress, ERC721TransferHelper(_erc721TransferHelper).ZMM().registrar()) + ModuleNamingSupportV1("Offers: v1.0") + { + erc721TransferHelper = ERC721TransferHelper(_erc721TransferHelper); + } + + /// ------------ MAKER FUNCTIONS ------------ + + // ,-. + // `-' + // /|\ + // | ,--------. ,-------------------. + // / \ |OffersV1| |ERC20TransferHelper| + // Caller `---+----' `---------+---------' + // | createOffer() | | + // | -----------------> | + // | | | + // | | transferFrom() | + // | | -----------------------------> + // | | | + // | | |----. + // | | | | transfer tokens into escrow + // | | |<---' + // | | | + // | |----. | + // | | | ++offerCount | + // | |<---' | + // | | | + // | |----. | + // | | | create offer | + // | |<---' | + // | | | + // | |----. + // | | | offersFor[NFT].append(id) + // | |<---' + // | | | + // | |----. | + // | | | emit OfferCreated() | + // | |<---' | + // | | | + // | id | | + // | <----------------- | + // Caller ,---+----. ,---------+---------. + // ,-. |OffersV1| |ERC20TransferHelper| + // `-' `--------' `-------------------' + // /|\ + // | + // / \ + /// @notice Creates an offer for an NFT + /// @param _tokenContract The address of the desired ERC-721 token + /// @param _tokenId The ID of the desired ERC-721 token + /// @param _currency The address of the ERC-20 token offering, or address(0) for ETH + /// @param _amount The amount offering + /// @param _findersFeeBps The bps of the amount (post-royalties) to send to a referrer of the sale + /// @return The ID of the created offer + function createOffer( + address _tokenContract, + uint256 _tokenId, + address _currency, + uint256 _amount, + uint16 _findersFeeBps + ) external payable nonReentrant returns (uint256) { + require(_findersFeeBps <= 10000, "createOffer finders fee bps must be less than or equal to 10000"); + + // Validate offer and take custody + _handleIncomingTransfer(_amount, _currency); + + // "the sun will devour the earth before it could ever overflow" - @transmissions11 + // offerCount++ --> unchecked { offerCount++ } + + // "Although the increment part is cheaper with unchecked, the opcodes after become more expensive for some reason" - @joshieDo + // unchecked { offerCount++ } --> offerCount++ + + // "Earlier today while reviewing c4rena findings I learned that doing ++offerCount would save 5 gas per increment here" - @devtooligan + // offerCount++ --> ++offerCount + + // TLDR; unchecked checked + // non-optimized 130,037 gas < 130,149 gas + // optimized 127,932 gas > *127,298 gas* + + ++offerCount; + + offers[_tokenContract][_tokenId][offerCount] = Offer({ + maker: msg.sender, + currency: _currency, + findersFeeBps: _findersFeeBps, + amount: _amount + }); + + offersForNFT[_tokenContract][_tokenId].push(offerCount); + + emit OfferCreated(_tokenContract, _tokenId, offerCount, offers[_tokenContract][_tokenId][offerCount]); + + return offerCount; + } + + // ,-. + // `-' + // /|\ + // | ,--------. ,-------------------. + // / \ |OffersV1| |ERC20TransferHelper| + // Caller `---+----' `---------+---------' + // | setOfferAmount() | | + // | -----------------> | + // | | | + // | | | + // | _______________________________________________________________________ + // | ! ALT / same token? | ! + // | !_____/ | | ! + // | ! | retrieve increase / refund decrease| ! + // | ! | -----------------------------------> ! + // | !~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~! + // | ! [different token] | ! + // | ! | refund previous offer | ! + // | ! | -----------------------------------> ! + // | ! | | ! + // | ! | retrieve new offer | ! + // | ! | -----------------------------------> ! + // | !~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~! + // | | | + // | |----. | + // | | | emit OfferUpdated() | + // | |<---' | + // Caller ,---+----. ,---------+---------. + // ,-. |OffersV1| |ERC20TransferHelper| + // `-' `--------' `-------------------' + // /|\ + // | + // / \ + /// @notice Updates the given offer for an NFT + /// @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 _currency The address of the ERC-20 token offering, or address(0) for ETH + /// @param _amount The new amount offering + function setOfferAmount( + address _tokenContract, + uint256 _tokenId, + uint256 _offerId, + address _currency, + uint256 _amount + ) external payable nonReentrant { + Offer storage offer = offers[_tokenContract][_tokenId][_offerId]; + + require(offer.maker == msg.sender, "setOfferAmount must be maker"); + + // If same currency -- + if (_currency == offer.currency) { + // Get initial amount + uint256 prevAmount = offer.amount; + // Ensure valid update + require(_amount > 0 && _amount != prevAmount, "setOfferAmount invalid _amount"); + + // If offer increase -- + if (_amount > prevAmount) { + unchecked { + // Get delta + uint256 increaseAmount = _amount - prevAmount; + // Custody increase + _handleIncomingTransfer(increaseAmount, offer.currency); + // Update storage + offer.amount += increaseAmount; + } + // Else offer decrease -- + } else { + unchecked { + // Get delta + uint256 decreaseAmount = prevAmount - _amount; + // Refund difference + _handleOutgoingTransfer(offer.maker, decreaseAmount, offer.currency, USE_ALL_GAS_FLAG); + // Update storage + offer.amount -= decreaseAmount; + } + } + // Else other currency -- + } else { + // Refund previous offer + _handleOutgoingTransfer(offer.maker, offer.amount, offer.currency, USE_ALL_GAS_FLAG); + // Custody new offer + _handleIncomingTransfer(_amount, _currency); + + // Update storage + offer.currency = _currency; + offer.amount = _amount; + } + + emit OfferUpdated(_tokenContract, _tokenId, _offerId, offer); + } + + // ,-. + // `-' + // /|\ + // | ,--------. ,-------------------. + // / \ |OffersV1| |ERC20TransferHelper| + // Caller `---+----' `---------+---------' + // | cancelOffer() | | + // | -----------------> | + // | | | + // | | transferFrom() | + // | | ------------------------> + // | | | + // | | |----. + // | | | | refund tokens from escrow + // | | |<---' + // | | | + // | |----. + // | | | emit OfferCanceled() + // | |<---' + // | | | + // | |----. | + // | | | delete offer | + // | |<---' | + // Caller ,---+----. ,---------+---------. + // ,-. |OffersV1| |ERC20TransferHelper| + // `-' `--------' `-------------------' + // /|\ + // | + // / \ + /// @notice Cancels and refunds 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 { + Offer memory offer = offers[_tokenContract][_tokenId][_offerId]; + + require(offer.maker == msg.sender, "cancelOffer must be maker"); + + // Refund offer + _handleOutgoingTransfer(offer.maker, offer.amount, offer.currency, USE_ALL_GAS_FLAG); + + emit OfferCanceled(_tokenContract, _tokenId, _offerId, offer); + + delete offers[_tokenContract][_tokenId][_offerId]; + } + + /// ------------ TAKER FUNCTIONS ------------ + + // ,-. + // `-' + // /|\ + // | ,--------. ,--------------------. + // / \ |OffersV1| |ERC721TransferHelper| + // Caller `---+----' `---------+----------' + // | fillOffer() | | + // | -----------------> | + // | | | + // | |----. | + // | | | validate token owner | + // | |<---' | + // | | | + // | |----. | + // | | | handle royalty payouts | + // | |<---' | + // | | | + // | | | + // | __________________________________________________ + // | ! ALT / finders fee configured for this offer? ! + // | !_____/ | | ! + // | ! |----. | ! + // | ! | | handle finders fee payout| ! + // | ! |<---' | ! + // | !~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~! + // | !~[noop]~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~! + // | | | + // | | transferFrom() | + // | | ------------------------------> + // | | | + // | | |----. + // | | | | transfer NFT from taker to maker + // | | |<---' + // | | | + // | |----. | + // | | | emit ExchangeExecuted() | + // | |<---' | + // | | | + // | |----. | + // | | | emit OfferFilled() | + // | |<---' | + // | | | + // | |----. + // | | | delete offer from contract + // | |<---' + // Caller ,---+----. ,---------+----------. + // ,-. |OffersV1| |ERC721TransferHelper| + // `-' `--------' `--------------------' + // /|\ + // | + // / \ + /// @notice Fills a given offer for an owned NFT, in exchange for ETH/ERC-20 tokens + /// @param _tokenContract The address of the ERC-721 token to transfer + /// @param _tokenId The ID of the ERC-721 token to transfer + /// @param _offerId The ID of the offer to fill + /// @param _currency The address of the ERC-20 to take, or address(0) for ETH + /// @param _amount The amount to take + /// @param _finder The address of the offer referrer + function fillOffer( + address _tokenContract, + uint256 _tokenId, + uint256 _offerId, + address _currency, + uint256 _amount, + address _finder + ) external nonReentrant { + Offer memory offer = offers[_tokenContract][_tokenId][_offerId]; + + require(offer.maker != address(0), "fillOffer must be active offer"); + require(IERC721(_tokenContract).ownerOf(_tokenId) == msg.sender, "fillOffer must be token owner"); + require(offer.currency == _currency && offer.amount == _amount, "fillOffer _currency & _amount must match offer"); + + // Payout respective parties, ensuring royalties are honored + (uint256 remainingProfit, ) = _handleRoyaltyPayout(_tokenContract, _tokenId, offer.amount, offer.currency, USE_ALL_GAS_FLAG); + + // Payout optional protocol fee + remainingProfit = _handleProtocolFeePayout(remainingProfit, offer.currency); + + // Payout optional finders fee + if (_finder != address(0)) { + uint256 findersFee = (remainingProfit * offer.findersFeeBps) / 10000; + _handleOutgoingTransfer(_finder, findersFee, offer.currency, USE_ALL_GAS_FLAG); + + remainingProfit -= findersFee; + } + + // Transfer remaining ETH/ERC-20 tokens to offer taker + _handleOutgoingTransfer(msg.sender, remainingProfit, offer.currency, USE_ALL_GAS_FLAG); + + // Transfer NFT to offer maker + erc721TransferHelper.transferFrom(_tokenContract, msg.sender, offer.maker, _tokenId); + + ExchangeDetails memory userAExchangeDetails = ExchangeDetails({tokenContract: offer.currency, tokenId: 0, amount: offer.amount}); + ExchangeDetails memory userBExchangeDetails = ExchangeDetails({tokenContract: _tokenContract, tokenId: _tokenId, amount: 1}); + + emit ExchangeExecuted(offer.maker, msg.sender, userAExchangeDetails, userBExchangeDetails); + emit OfferFilled(_tokenContract, _tokenId, _offerId, msg.sender, _finder, offer); + + delete offers[_tokenContract][_tokenId][_offerId]; + } +} diff --git a/contracts/test/ZoraModuleManager.t.sol b/contracts/test/ZoraModuleManager.t.sol index bdff4ceb..2a27d07b 100644 --- a/contracts/test/ZoraModuleManager.t.sol +++ b/contracts/test/ZoraModuleManager.t.sol @@ -48,7 +48,7 @@ contract ZoraModuleManagerTest is DSTest { module = batchModules[0]; } - /// ------------ APPROVE MODULE ------------ + /// ------------ APPROVE MODULE ------------ /// function test_SetApproval() public { registrar.registerModule(module); @@ -62,7 +62,7 @@ contract ZoraModuleManagerTest is DSTest { bob.setApprovalForModule(module, true); } - /// ------------ APPROVE MODULE BATCH ------------ + /// ------------ APPROVE MODULE BATCH ------------ /// function test_SetBatchApproval() public { for (uint256 i = 0; i < 3; i++) { diff --git a/contracts/test/modules/Offers/V1/Offers.integration.t.sol b/contracts/test/modules/Offers/V1/Offers.integration.t.sol new file mode 100644 index 00000000..bc19cb79 --- /dev/null +++ b/contracts/test/modules/Offers/V1/Offers.integration.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {OffersV1} from "../../../../modules/Offers/V1/OffersV1.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 OffersV1IntegrationTest is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + OffersV1 internal offers; + TestERC721 internal token; + WETH internal weth; + + Zorb internal seller; + Zorb internal buyer; + Zorb internal finder; + Zorb internal royaltyRecipient; + + 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)); + + // Deploy mocks + royaltyEngine = new RoyaltyEngine(address(royaltyRecipient)); + token = new TestERC721(); + weth = new WETH(); + + // Deploy Offers v1.0 + offers = new OffersV1(address(erc20TransferHelper), address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(offers)); + + // Set seller balance + vm.deal(address(seller), 100 ether); + + // Mint buyer token + token.mint(address(buyer), 0); + + // Seller swap 50 ETH <> 50 WETH + vm.prank(address(seller)); + weth.deposit{value: 50 ether}(); + + // Users approve Offers module + seller.setApprovalForModule(address(offers), true); + buyer.setApprovalForModule(address(offers), true); + + // Seller approve ERC20TransferHelper + vm.prank(address(seller)); + weth.approve(address(erc20TransferHelper), 50 ether); + + // Buyer approve ERC721TransferHelper + vm.prank(address(buyer)); + token.setApprovalForAll(address(erc721TransferHelper), true); + } + + /// ------------ ETH Offer ------------ /// + + function runETH() public { + vm.prank(address(seller)); + uint256 id = offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.prank(address(buyer)); + offers.fillOffer(address(token), 0, id, address(0), 1 ether, 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; + 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; + address afterTokenOwner = token.ownerOf(0); + + // 1 ETH withdrawn from seller + require((beforeSellerBalance - afterSellerBalance) == 1 ether); + // 0.05 ETH creator royalty + require((afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance) == 0.05 ether); + // 1000 bps finders fee (Remaining 0.95 ETH * 10% finders fee = 0.095 ETH) + require((afterFinderBalance - beforeFinderBalance) == 0.095 ether); + // Remaining 0.855 ETH paid to buyer + require((afterBuyerBalance - beforeBuyerBalance) == 0.855 ether); + // NFT transferred to seller + require((beforeTokenOwner == address(buyer)) && afterTokenOwner == address(seller)); + } + + /// ------------ ERC-20 Offer ------------ /// + + function runERC20() public { + vm.prank(address(seller)); + uint256 id = offers.createOffer(address(token), 0, address(weth), 1 ether, 1000); + + vm.prank(address(buyer)); + offers.fillOffer(address(token), 0, id, address(weth), 1 ether, 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)); + 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)); + address afterTokenOwner = token.ownerOf(0); + + // 1 WETH withdrawn from seller + require((beforeSellerBalance - afterSellerBalance) == 1 ether); + // 0.05 WETH creator royalty + require((afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance) == 0.05 ether); + // 0.095 WETH finders fee (0.95 WETH * 10% finders fee) + require((afterFinderBalance - beforeFinderBalance) == 0.095 ether); + // Remaining 0.855 WETH paid to buyer + require((afterBuyerBalance - beforeBuyerBalance) == 0.855 ether); + // NFT transferred to seller + require((beforeTokenOwner == address(buyer)) && afterTokenOwner == address(seller)); + } +} diff --git a/contracts/test/modules/Offers/V1/Offers.t.sol b/contracts/test/modules/Offers/V1/Offers.t.sol new file mode 100644 index 00000000..27cde30a --- /dev/null +++ b/contracts/test/modules/Offers/V1/Offers.t.sol @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {OffersV1} from "../../../../modules/Offers/V1/OffersV1.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 OffersV1Test +/// @notice Unit Tests for Offers v1.0 +contract OffersV1Test is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + OffersV1 internal offers; + TestERC721 internal token; + WETH internal weth; + + Zorb internal maker; + Zorb internal taker; + Zorb internal finder; + Zorb internal royaltyRecipient; + + 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)); + + // Deploy mocks + royaltyEngine = new RoyaltyEngine(address(royaltyRecipient)); + token = new TestERC721(); + weth = new WETH(); + + // Deploy Offers v1.0 + offers = new OffersV1(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 { + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + } + + function test_CreateOffer() public { + vm.prank(address(maker)); + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + (address offeror, , , ) = offers.offers(address(token), 0, 1); + + require(offeror == address(maker)); + } + + function testFail_CannotCreateOfferWithoutAttachingFunds() public { + vm.prank(address(maker)); + offers.createOffer(address(token), 0, address(0), 1 ether, 1000); + } + + function testFail_CannotCreateOfferWithInvalidFindersFeeBps() public { + vm.prank(address(maker)); + offers.createOffer(address(token), 0, address(0), 1 ether, 10001); + } + + /// ------------ SET NFT OFFER ------------ /// + + function test_IncreaseETHOffer() public { + vm.startPrank(address(maker)); + + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.warp(1 hours); + + offers.setOfferAmount{value: 1 ether}(address(token), 0, 1, address(0), 2 ether); + + vm.stopPrank(); + + (, , , uint256 amount) = offers.offers(address(token), 0, 1); + + require(amount == 2 ether); + require(address(offers).balance == 2 ether); + } + + function test_DecreaseETHOffer() public { + vm.startPrank(address(maker)); + + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.warp(1 hours); + + offers.setOfferAmount(address(token), 0, 1, address(0), 0.5 ether); + + vm.stopPrank(); + + (, , , uint256 amount) = offers.offers(address(token), 0, 1); + + require(amount == 0.5 ether); + require(address(offers).balance == 0.5 ether); + } + + function test_IncreaseETHOfferWithERC20() public { + vm.startPrank(address(maker)); + + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.warp(1 hours); + + offers.setOfferAmount(address(token), 0, 1, address(weth), 2 ether); + + vm.stopPrank(); + + (, , , uint256 amount) = offers.offers(address(token), 0, 1); + + require(amount == 2 ether); + require(weth.balanceOf(address(offers)) == 2 ether); + require(address(offers).balance == 0 ether); + } + + function test_DecreaseETHOfferWithERC20() public { + vm.startPrank(address(maker)); + + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.warp(1 hours); + + offers.setOfferAmount(address(token), 0, 1, address(weth), 0.5 ether); + + vm.stopPrank(); + + (, , , uint256 amount) = offers.offers(address(token), 0, 1); + + require(amount == 0.5 ether); + require(weth.balanceOf(address(offers)) == 0.5 ether); + require(address(offers).balance == 0 ether); + } + + function testRevert_OnlySellerCanUpdateOffer() public { + vm.prank(address(maker)); + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.expectRevert("setOfferAmount must be maker"); + offers.setOfferAmount(address(token), 0, 1, address(0), 0.5 ether); + } + + function testRevert_CannotIncreaseOfferWithoutAttachingFunds() public { + vm.startPrank(address(maker)); + + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + vm.expectRevert("_handleIncomingTransfer msg value less than expected amount"); + offers.setOfferAmount(address(token), 0, 1, address(0), 2 ether); + + vm.stopPrank(); + } + + function testRevert_CannotUpdateOfferWithPreviousAmount() public { + vm.startPrank(address(maker)); + + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.warp(1 hours); + + vm.expectRevert("setOfferAmount invalid _amount"); + + offers.setOfferAmount{value: 1 ether}(address(token), 0, 1, address(0), 1 ether); + + vm.stopPrank(); + } + + function testRevert_CannotUpdateInactiveOffer() public { + vm.prank(address(maker)); + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.prank(address(taker)); + offers.fillOffer(address(token), 0, 1, address(0), 1 ether, address(finder)); + + vm.prank(address(maker)); + vm.expectRevert("setOfferAmount must be maker"); + offers.setOfferAmount(address(token), 0, 1, address(0), 0.5 ether); + } + + /// ------------ CANCEL NFT OFFER ------------ /// + + function test_CancelNFTOffer() public { + vm.startPrank(address(maker)); + + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + (, , , 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.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.prank(address(taker)); + offers.fillOffer(address(token), 0, 1, address(0), 1 ether, address(finder)); + + vm.prank(address(maker)); + vm.expectRevert("cancelOffer must be maker"); + offers.cancelOffer(address(token), 0, 1); + } + + function testRevert_OnlySellerCanCancelOffer() public { + vm.prank(address(maker)); + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.expectRevert("cancelOffer must be maker"); + offers.cancelOffer(address(token), 0, 1); + } + + /// ------------ FILL NFT OFFER ------------ /// + + function test_FillNFTOffer() public { + vm.prank(address(maker)); + offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + address beforeTokenOwner = token.ownerOf(0); + + vm.prank(address(taker)); + offers.fillOffer(address(token), 0, 1, address(0), 1 ether, 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.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.expectRevert("fillOffer must be token owner"); + offers.fillOffer(address(token), 0, id, address(0), 1 ether, address(finder)); + } + + function testRevert_CannotFillInactiveOffer() public { + vm.prank(address(maker)); + uint256 id = offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.prank(address(taker)); + offers.fillOffer(address(token), 0, id, address(0), 1 ether, address(finder)); + + vm.prank(address(taker)); + vm.expectRevert("fillOffer must be active offer"); + offers.fillOffer(address(token), 0, id, address(0), 1 ether, address(finder)); + } + + function testRevert_AcceptCurrencyMustMatchOffer() public { + vm.prank(address(maker)); + uint256 id = offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.prank(address(taker)); + vm.expectRevert("fillOffer _currency & _amount must match offer"); + offers.fillOffer(address(token), 0, id, address(weth), 1 ether, address(finder)); + } + + function testRevert_AcceptAmountMustMatchOffer() public { + vm.prank(address(maker)); + uint256 id = offers.createOffer{value: 1 ether}(address(token), 0, address(0), 1 ether, 1000); + + vm.prank(address(taker)); + vm.expectRevert("fillOffer _currency & _amount must match offer"); + offers.fillOffer(address(token), 0, id, address(0), 0.5 ether, address(finder)); + } +} diff --git a/uml/OffersV1/cancelOffer.atxt b/uml/OffersV1/cancelOffer.atxt new file mode 100644 index 00000000..a32b9fe9 --- /dev/null +++ b/uml/OffersV1/cancelOffer.atxt @@ -0,0 +1,29 @@ + ,-. + `-' + /|\ + | ,--------. ,-------------------. + / \ |OffersV1| |ERC20TransferHelper| + Caller `---+----' `---------+---------' + | cancelOffer() | | + | -----------------> | + | | | + | | transferFrom() | + | | ------------------------> + | | | + | | |----. + | | | | refund tokens from escrow + | | |<---' + | | | + | |----. + | | | emit OfferCanceled() + | |<---' + | | | + | |----. | + | | | delete offer | + | |<---' | + Caller ,---+----. ,---------+---------. + ,-. |OffersV1| |ERC20TransferHelper| + `-' `--------' `-------------------' + /|\ + | + / \ diff --git a/uml/OffersV1/cancelOffer.txt b/uml/OffersV1/cancelOffer.txt new file mode 100644 index 00000000..4c6cb5f1 --- /dev/null +++ b/uml/OffersV1/cancelOffer.txt @@ -0,0 +1,13 @@ +@startuml +actor Caller +participant OffersV1 +participant ERC20TransferHelper + +Caller -> OffersV1 : cancelOffer() +OffersV1 -> ERC20TransferHelper : transferFrom() +ERC20TransferHelper -> ERC20TransferHelper : refund tokens from escrow +OffersV1 -> OffersV1 : emit OfferCanceled() +OffersV1 -> OffersV1 : delete offer + +@enduml + diff --git a/uml/OffersV1/createOffer.atxt b/uml/OffersV1/createOffer.atxt new file mode 100644 index 00000000..2933234d --- /dev/null +++ b/uml/OffersV1/createOffer.atxt @@ -0,0 +1,40 @@ + ,-. + `-' + /|\ + | ,--------. ,-------------------. + / \ |OffersV1| |ERC20TransferHelper| + Caller `---+----' `---------+---------' + | createOffer() | | + | -----------------> | + | | | + | | transferFrom() | + | | -----------------------------> + | | | + | | |----. + | | | | transfer tokens into escrow + | | |<---' + | | | + | |----. | + | | | offer count ++ | + | |<---' | + | | | + | |----. | + | | | create offer | + | |<---' | + | | | + | |----. + | | | offersFor[NFT].append(id) + | |<---' + | | | + | |----. | + | | | emit OfferCreated() | + | |<---' | + | | | + | id | | + | <----------------- | + Caller ,---+----. ,---------+---------. + ,-. |OffersV1| |ERC20TransferHelper| + `-' `--------' `-------------------' + /|\ + | + / \ diff --git a/uml/OffersV1/createOffer.txt b/uml/OffersV1/createOffer.txt new file mode 100644 index 00000000..67c2ffb8 --- /dev/null +++ b/uml/OffersV1/createOffer.txt @@ -0,0 +1,16 @@ +@startuml +actor Caller +participant OffersV1 +participant ERC20TransferHelper + +Caller -> OffersV1 : createOffer() +OffersV1 -> ERC20TransferHelper : transferFrom() +ERC20TransferHelper -> ERC20TransferHelper : transfer tokens into escrow +OffersV1 -> OffersV1 : offer count ++ +OffersV1 -> OffersV1 : create offer +OffersV1 -> OffersV1 : offersFor[NFT].append(id) +OffersV1 -> OffersV1 : emit OfferCreated() +OffersV1 -> Caller :id + +@enduml + diff --git a/uml/OffersV1/fillOffer.atxt b/uml/OffersV1/fillOffer.atxt new file mode 100644 index 00000000..6c941010 --- /dev/null +++ b/uml/OffersV1/fillOffer.atxt @@ -0,0 +1,51 @@ + ,-. + `-' + /|\ + | ,--------. ,--------------------. + / \ |OffersV1| |ERC721TransferHelper| + Caller `---+----' `---------+----------' + | fillOffer() | | + | -----------------> | + | | | + | |----. | + | | | validate token owner | + | |<---' | + | | | + | |----. | + | | | handle royalty payouts | + | |<---' | + | | | + | | | + | __________________________________________________ + | ! ALT / finders fee configured for this offer? ! + | !_____/ | | ! + | ! |----. | ! + | ! | | handle finders fee payout| ! + | ! |<---' | ! + | !~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~! + | !~[noop]~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~! + | | | + | | transferFrom() | + | | ------------------------------> + | | | + | | |----. + | | | | transfer NFT from taker to maker + | | |<---' + | | | + | |----. | + | | | emit ExchangeExecuted() | + | |<---' | + | | | + | |----. | + | | | emit OfferFilled() | + | |<---' | + | | | + | |----. + | | | delete offer from contract + | |<---' + Caller ,---+----. ,---------+----------. + ,-. |OffersV1| |ERC721TransferHelper| + `-' `--------' `--------------------' + /|\ + | + / \ diff --git a/uml/OffersV1/fillOffer.txt b/uml/OffersV1/fillOffer.txt new file mode 100644 index 00000000..6a9642c5 --- /dev/null +++ b/uml/OffersV1/fillOffer.txt @@ -0,0 +1,27 @@ +@startuml +actor Caller +participant OffersV1 +participant ERC721TransferHelper + +Caller -> OffersV1 : fillOffer() + +OffersV1 -> OffersV1 : validate token owner + +OffersV1 -> OffersV1 : handle royalty payouts + +alt finders fee configured for this offer? + + OffersV1 -> OffersV1 : handle finders fee payout + +else noop + +end + +OffersV1 -> ERC721TransferHelper : transferFrom() +ERC721TransferHelper -> ERC721TransferHelper : transfer NFT from taker to maker + +OffersV1 -> OffersV1 : emit ExchangeExecuted() +OffersV1 -> OffersV1 : emit OfferFilled() +OffersV1 -> OffersV1 : delete offer from contract + +@enduml \ No newline at end of file diff --git a/uml/OffersV1/setOfferAmount.atxt b/uml/OffersV1/setOfferAmount.atxt new file mode 100644 index 00000000..737f7a6d --- /dev/null +++ b/uml/OffersV1/setOfferAmount.atxt @@ -0,0 +1,33 @@ + ,-. + `-' + /|\ + | ,--------. ,-------------------. + / \ |OffersV1| |ERC20TransferHelper| + Caller `---+----' `---------+---------' + | setOfferAmount() | | + | -----------------> | + | | | + | | | + | _______________________________________________________________________ + | ! ALT / same token? | ! + | !_____/ | | ! + | ! | retrieve increase / refund decrease| ! + | ! | -----------------------------------> ! + | !~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~! + | ! [different token] | ! + | ! | refund previous offer | ! + | ! | -----------------------------------> ! + | ! | | ! + | ! | retrieve new offer | ! + | ! | -----------------------------------> ! + | !~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~! + | | | + | |----. | + | | | emit OfferUpdated() | + | |<---' | + Caller ,---+----. ,---------+---------. + ,-. |OffersV1| |ERC20TransferHelper| + `-' `--------' `-------------------' + /|\ + | + / \ diff --git a/uml/OffersV1/setOfferAmount.txt b/uml/OffersV1/setOfferAmount.txt new file mode 100644 index 00000000..d14310d3 --- /dev/null +++ b/uml/OffersV1/setOfferAmount.txt @@ -0,0 +1,21 @@ +@startuml +actor Caller +participant OffersV1 +participant ERC20TransferHelper + +Caller -> OffersV1 : setOfferAmount() + +alt same token? + + OffersV1 -> ERC20TransferHelper : retrieve increase / refund decrease + +else different token + + OffersV1 -> ERC20TransferHelper : refund previous offer + + OffersV1 -> ERC20TransferHelper : retrieve new offer +end + +OffersV1 -> OffersV1 : emit OfferUpdated() + +@enduml \ No newline at end of file