From 11dd361157f99192056661fec16dfd7739d6fd6d Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Tue, 25 Jan 2022 17:18:41 -0500 Subject: [PATCH 1/4] [feat] Covered Puts v1.0 --- .../modules/CoveredPuts/V1/CoveredPutsV1.sol | 269 ++++++++++++++ .../V1/CoveredPuts.integration.t.sol | 221 +++++++++++ .../modules/CoveredPuts/V1/CoveredPuts.t.sol | 350 ++++++++++++++++++ 3 files changed, 840 insertions(+) create mode 100644 contracts/modules/CoveredPuts/V1/CoveredPutsV1.sol create mode 100644 contracts/test/modules/CoveredPuts/V1/CoveredPuts.integration.t.sol create mode 100644 contracts/test/modules/CoveredPuts/V1/CoveredPuts.t.sol diff --git a/contracts/modules/CoveredPuts/V1/CoveredPutsV1.sol b/contracts/modules/CoveredPuts/V1/CoveredPutsV1.sol new file mode 100644 index 00000000..39ba0235 --- /dev/null +++ b/contracts/modules/CoveredPuts/V1/CoveredPutsV1.sol @@ -0,0 +1,269 @@ +// 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 {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 Covered Puts V1 +/// @author kulkarohan +/// @notice This module allows users to sell covered put options on any ERC-721 token +contract CoveredPutsV1 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 number of covered put options placed + uint256 public putCount; + + /// @notice The ZORA ERC-721 Transfer Helper + ERC721TransferHelper public immutable erc721TransferHelper; + + /// @notice The metadata of a covered put option + /// @param seller The address of the seller that created the option + /// @param buyer The address of the buyer, or address(0) if not purchased, that purchased the option + /// @param currency The address of the ERC-20, or address(0) for ETH, denominating the strike and premium + /// @param premium The price to purchase the option + /// @param strike The offer to exercise the option + /// @param expiration The expiration time of the option + struct Put { + address seller; + address buyer; + address currency; + uint256 premium; + uint256 strike; + uint256 expiration; + } + + /// ------------ STORAGE ------------ + + /// @notice The metadata of a covered put option for a given NFT and put option ID + /// @dev ERC-721 token address => ERC-721 token ID => Put ID => Put + mapping(address => mapping(uint256 => mapping(uint256 => Put))) public puts; + + /// @notice The covered put options placed for a given NFT + /// @dev ERC-721 token address => ERC-721 token ID => put IDs + mapping(address => mapping(uint256 => uint256[])) public putsForNFT; + + /// ------------ EVENTS ------------ + + /// @notice Emitted when a covered put option is created + /// @param tokenContract The ERC-721 token address for the created put option + /// @param tokenId The ERC-721 token ID for the created put option + /// @param putId The ID of the created put option + /// @param put The metadata of the created put option + event PutCreated(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed putId, Put put); + + /// @notice Emitted when a covered put option is canceled + /// @param tokenContract The ERC-721 token address of the canceled put option + /// @param tokenId The ERC-721 token ID of the canceled put option + /// @param putId The ID of the canceled put option + /// @param put The metadata of the canceled put option + event PutCanceled(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed putId, Put put); + + /// @notice Emitted when the strike from an expired put option is reclaimed + /// @param tokenContract The ERC-721 token address of the reclaimed put option + /// @param tokenId The ERC-721 token ID of the reclaimed put option + /// @param putId The ID of the reclaimed put option + /// @param put The metadata of the reclaimed put option + event PutReclaimed(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed putId, Put put); + + /// @notice Emitted when a covered put option is purchased + /// @param tokenContract The ERC-721 token address for the purchased put option + /// @param tokenId The ERC-721 token ID for the purchased put option + /// @param putId The ID of the purchased put option + /// @param put The metadata of the purchased put option + event PutPurchased(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed putId, Put put); + + /// @notice Emitted when a covered put option is exercised + /// @param tokenContract The ERC-721 token address for the exercised put option + /// @param tokenId The ERC-721 token ID for the exercised put option + /// @param putId The ID of the exercised put option + /// @param put The metadata of the exercised put option + event PutExercised(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed putId, Put put); + + /// ------------ 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("Covered Puts: v1.0") + { + erc721TransferHelper = ERC721TransferHelper(_erc721TransferHelper); + } + + /// ------------ SELLER FUNCTIONS ------------ + + /// @notice Places a covered put option on an NFT + /// @param _tokenContract The address of the desired ERC-721 token + /// @param _tokenId The ID of the desired ERC-721 token + /// @param _premiumPrice The amount to purchase the option + /// @param _strike The offer to exercise the option + /// @param _expiration The expiration time of the option + /// @param _currency The address of the ERC-20, or address(0) for ETH, denominating the strike and premium + function createPut( + address _tokenContract, + uint256 _tokenId, + uint256 _premiumPrice, + uint256 _strike, + uint256 _expiration, + address _currency + ) external payable nonReentrant returns (uint256) { + require(IERC721(_tokenContract).ownerOf(_tokenId) != msg.sender, "createPut cannot create put on owned NFT"); + require(_expiration > block.timestamp, "createPut _expiration must be future time"); + + // Hold strike in escrow + _handleIncomingTransfer(_strike, _currency); + + putCount++; + + puts[_tokenContract][_tokenId][putCount] = Put({ + seller: msg.sender, + buyer: address(0), + currency: _currency, + premium: _premiumPrice, + strike: _strike, + expiration: _expiration + }); + + putsForNFT[_tokenContract][_tokenId].push(putCount); + + emit PutCreated(_tokenContract, _tokenId, putCount, puts[_tokenContract][_tokenId][putCount]); + + return putCount; + } + + /// @notice Cancels a non-purchased covered put option and refunds the strike + /// @param _tokenContract The address of the ERC-721 token + /// @param _tokenId The ID of the ERC-721 token + /// @param _putId The ID of the option to cancel + function cancelPut( + address _tokenContract, + uint256 _tokenId, + uint256 _putId + ) external nonReentrant { + Put storage put = puts[_tokenContract][_tokenId][_putId]; + + require(put.seller == msg.sender, "cancelPut must be seller"); + require(put.buyer == address(0), "cancelPut put has been purchased"); + + // Refund strike + _handleOutgoingTransfer(msg.sender, put.strike, put.currency, USE_ALL_GAS_FLAG); + + emit PutCanceled(_tokenContract, _tokenId, _putId, put); + + delete puts[_tokenContract][_tokenId][_putId]; + } + + /// @notice Reclaims the strike from a purchased, but non-exercised put option + /// @param _tokenContract The address of the ERC-721 token + /// @param _tokenId The ID of the ERC-721 token + /// @param _putId The ID of the option to reclaim + function reclaimPut( + address _tokenContract, + uint256 _tokenId, + uint256 _putId + ) external nonReentrant { + Put storage put = puts[_tokenContract][_tokenId][_putId]; + + require(put.seller == msg.sender, "reclaimPut must be seller"); + require(put.buyer != address(0), "reclaimPut put not purchased"); + require(block.timestamp >= put.expiration, "reclaimPut put is active"); + + _handleOutgoingTransfer(msg.sender, put.strike, put.currency, USE_ALL_GAS_FLAG); + + emit PutReclaimed(_tokenContract, _tokenId, _putId, put); + + delete puts[_tokenContract][_tokenId][_putId]; + } + + /// ------------ BUYER FUNCTIONS ------------ + + /// @notice Purchases a covered put option and transfers the premium to the seller + /// @param _tokenContract The address of the ERC-721 token to trade + /// @param _tokenId The ID of the ERC-721 token to trade + /// @param _putId The ID of the option to purchase + /// @param _currency The ERC-20 address of the strike and premium, or address(0) for ETH + /// @param _premium The price to pay for the put option + /// @param _strike The amount offered with exercise + function buyPut( + address _tokenContract, + uint256 _tokenId, + uint256 _putId, + address _currency, + uint256 _premium, + uint256 _strike + ) external payable nonReentrant { + Put storage put = puts[_tokenContract][_tokenId][_putId]; + + require(put.seller != address(0), "buyPut put does not exist"); + require(put.buyer == address(0), "buyPut put already purchased"); + require(put.expiration > block.timestamp, "buyPut put expired"); + require(put.currency == _currency, "buyPut _currency must match put"); + require(put.premium == _premium, "buyPut _premium must match put"); + require(put.strike == _strike, "buyPut _strike must match put"); + + // Ensure premium payment is valid and take custody + _handleIncomingTransfer(put.premium, put.currency); + + // Send premium to seller + _handleOutgoingTransfer(put.seller, put.premium, put.currency, USE_ALL_GAS_FLAG); + + // Mark option as purchased + put.buyer = msg.sender; + + emit PutPurchased(_tokenContract, _tokenId, _putId, put); + } + + /// @notice Exercises a covered put option, transferring the NFT to the seller and strike to the buyer + /// @param _tokenContract The address of the ERC-721 token to trade + /// @param _tokenId The ID of the ERC-721 token to trade + /// @param _putId The ID of the option to exercise + function exercisePut( + address _tokenContract, + uint256 _tokenId, + uint256 _putId + ) external nonReentrant { + Put storage put = puts[_tokenContract][_tokenId][_putId]; + + require(put.buyer == msg.sender, "exercisePut must be buyer"); + require(IERC721(_tokenContract).ownerOf(_tokenId) == msg.sender, "exercisePut must own token"); + require(put.expiration > block.timestamp, "exercisePut put expired"); + + // Ensure NFT royalties are honored + (uint256 remainingProfit, ) = _handleRoyaltyPayout(_tokenContract, _tokenId, put.strike, put.currency, USE_ALL_GAS_FLAG); + + // Payout optional protocol fee + remainingProfit = _handleProtocolFeePayout(remainingProfit, put.currency); + + // Transfer ETH/ERC-20 strike to buyer + _handleOutgoingTransfer(msg.sender, remainingProfit, put.currency, USE_ALL_GAS_FLAG); + + // Transfer NFT to seller + erc721TransferHelper.transferFrom(_tokenContract, msg.sender, put.seller, _tokenId); + + ExchangeDetails memory userAExchangeDetails = ExchangeDetails({tokenContract: put.currency, tokenId: 0, amount: put.strike}); + ExchangeDetails memory userBExchangeDetails = ExchangeDetails({tokenContract: _tokenContract, tokenId: _tokenId, amount: 1}); + + emit ExchangeExecuted(put.seller, put.buyer, userAExchangeDetails, userBExchangeDetails); + emit PutExercised(_tokenContract, _tokenId, _putId, put); + + delete puts[_tokenContract][_tokenId][_putId]; + } +} diff --git a/contracts/test/modules/CoveredPuts/V1/CoveredPuts.integration.t.sol b/contracts/test/modules/CoveredPuts/V1/CoveredPuts.integration.t.sol new file mode 100644 index 00000000..3a03d839 --- /dev/null +++ b/contracts/test/modules/CoveredPuts/V1/CoveredPuts.integration.t.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {CoveredPutsV1} from "../../../../modules/CoveredPuts/V1/CoveredPutsV1.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 CoveredPutsV1IntegrationTest +/// @notice Integration Tests for Covered Put Options v1.0 +contract CoveredPutsV1IntegrationTest is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + CoveredPutsV1 internal puts; + 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 Covered Put Options v1.0 + puts = new CoveredPutsV1(address(erc20TransferHelper), address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(puts)); + + // Set user balances + vm.deal(address(seller), 100 ether); + vm.deal(address(buyer), 100 ether); + + // Mint buyer token + token.mint(address(buyer), 0); + + // Users swap 50 ETH <> 50 WETH + vm.prank(address(seller)); + weth.deposit{value: 50 ether}(); + + vm.prank(address(buyer)); + weth.deposit{value: 50 ether}(); + + // Users approve CoveredPuts module + seller.setApprovalForModule(address(puts), true); + buyer.setApprovalForModule(address(puts), true); + + // Users approve ERC20TransferHelper + vm.prank(address(seller)); + weth.approve(address(erc20TransferHelper), 50 ether); + + vm.prank(address(buyer)); + weth.approve(address(erc20TransferHelper), 50 ether); + + // Buyer approve ERC721TransferHelper + vm.prank(address(buyer)); + token.setApprovalForAll(address(erc721TransferHelper), true); + } + + /// ------------ ETH PURCHASED PUT OPTION ------------ /// + + function runETHPurchase() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(1 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + } + + function test_ETHPurchaseIntegration() public { + uint256 beforeSellerBalance = address(seller).balance; + uint256 beforeBuyerBalance = address(buyer).balance; + + runETHPurchase(); + + uint256 afterSellerBalance = address(seller).balance; + uint256 afterBuyerBalance = address(buyer).balance; + + require(beforeSellerBalance - afterSellerBalance == 0.5 ether); + require(beforeBuyerBalance - afterBuyerBalance == 0.5 ether); + } + + /// ------------ ETH EXERCISED PUT OPTION ------------ /// + + function runETHExercise() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(1 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.exercisePut(address(token), 0, 1); + } + + function test_ETHExerciseIntegration() public { + uint256 beforeSellerBalance = address(seller).balance; + uint256 beforeBuyerBalance = address(buyer).balance; + uint256 beforeRoyaltyRecipientBalance = address(royaltyRecipient).balance; + address beforeTokenOwner = token.ownerOf(0); + + runETHExercise(); + + uint256 afterSellerBalance = address(seller).balance; + uint256 afterBuyerBalance = address(buyer).balance; + uint256 afterRoyaltyRecipientBalance = address(royaltyRecipient).balance; + address afterTokenOwner = token.ownerOf(0); + + require(beforeSellerBalance - afterSellerBalance == 0.5 ether); + require(afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance == 0.05 ether); + require(afterBuyerBalance - beforeBuyerBalance == 0.45 ether); + require(beforeTokenOwner == address(buyer) && afterTokenOwner == address(seller)); + } + + /// ------------ ERC-20 PURCHASED PUT OPTION ------------ /// + + function runERC20Purchase() public { + vm.prank(address(seller)); + puts.createPut(address(token), 0, 0.5 ether, 1 ether, 1 days, address(weth)); + + vm.warp(1 hours); + + vm.prank(address(buyer)); + puts.buyPut(address(token), 0, 1, address(weth), 0.5 ether, 1 ether); + } + + function test_ERC20PurchaseIntegration() public { + uint256 beforeSellerBalance = weth.balanceOf(address(seller)); + uint256 beforeBuyerBalance = weth.balanceOf(address(buyer)); + + runERC20Purchase(); + + uint256 afterSellerBalance = weth.balanceOf(address(seller)); + uint256 afterBuyerBalance = weth.balanceOf(address(buyer)); + + require(beforeSellerBalance - afterSellerBalance == 0.5 ether); + require(beforeBuyerBalance - afterBuyerBalance == 0.5 ether); + } + + /// ------------ ERC-20 EXERCISED PUT OPTION ------------ /// + + function runERC20Exercise() public { + vm.prank(address(seller)); + puts.createPut(address(token), 0, 0.5 ether, 1 ether, 1 days, address(weth)); + + vm.warp(1 hours); + + vm.prank(address(buyer)); + puts.buyPut(address(token), 0, 1, address(weth), 0.5 ether, 1 ether); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.exercisePut(address(token), 0, 1); + } + + function test_ERC20ExerciseIntegration() public { + uint256 beforeSellerBalance = weth.balanceOf(address(seller)); + uint256 beforeBuyerBalance = weth.balanceOf(address(buyer)); + uint256 beforeRoyaltyRecipientBalance = weth.balanceOf(address(royaltyRecipient)); + address beforeTokenOwner = token.ownerOf(0); + + runERC20Exercise(); + + uint256 afterSellerBalance = weth.balanceOf(address(seller)); + uint256 afterBuyerBalance = weth.balanceOf(address(buyer)); + uint256 afterRoyaltyRecipientBalance = weth.balanceOf(address(royaltyRecipient)); + address afterTokenOwner = token.ownerOf(0); + + require(beforeSellerBalance - afterSellerBalance == 0.5 ether); + require(afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance == 0.05 ether); + require(afterBuyerBalance - beforeBuyerBalance == 0.45 ether); + require(beforeTokenOwner == address(buyer) && afterTokenOwner == address(seller)); + } +} diff --git a/contracts/test/modules/CoveredPuts/V1/CoveredPuts.t.sol b/contracts/test/modules/CoveredPuts/V1/CoveredPuts.t.sol new file mode 100644 index 00000000..bac4652f --- /dev/null +++ b/contracts/test/modules/CoveredPuts/V1/CoveredPuts.t.sol @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {CoveredPutsV1} from "../../../../modules/CoveredPuts/V1/CoveredPutsV1.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 CoveredPutsV1Test +/// @notice Unit Tests for Covered Put Options v1.0 +contract CoveredPutsV1Test is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + CoveredPutsV1 internal puts; + 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 Covered Put Options v1.0 + puts = new CoveredPutsV1(address(erc20TransferHelper), address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(puts)); + + // Set user balances + vm.deal(address(seller), 100 ether); + vm.deal(address(buyer), 100 ether); + + // Mint put buyer token + token.mint(address(buyer), 0); + + // Buyer swap 50 ETH <> 50 WETH + vm.prank(address(buyer)); + weth.deposit{value: 50 ether}(); + + // Users approve CoveredPuts module + seller.setApprovalForModule(address(puts), true); + buyer.setApprovalForModule(address(puts), true); + + // Buyer approve ERC721TransferHelper + vm.prank(address(buyer)); + token.setApprovalForAll(address(erc721TransferHelper), true); + + // Buyer approve ERC20TransferHelper + vm.prank(address(seller)); + weth.approve(address(erc20TransferHelper), 50 ether); + } + + /// ------------ CREATE PUT ------------ /// + + function testGas_CreatePut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + } + + function test_CreatePut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + (address putSeller, address putBuyer, address putCurrency, uint256 putPremium, uint256 putStrike, uint256 putExpiry) = puts.puts( + address(token), + 0, + 1 + ); + + require(putSeller == address(seller)); + require(putBuyer == address(0)); + require(putCurrency == address(0)); + require(putPremium == 0.5 ether); + require(putStrike == 1 ether); + require(putExpiry == 1 days); + } + + function testRevert_CannotCreatePutOnOwnNFT() public { + vm.prank(address(buyer)); + vm.expectRevert("createPut cannot create put on owned NFT"); + puts.createPut(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + } + + function testRevert_CreatePutMustAttachStrikeFunds() public { + vm.prank(address(seller)); + vm.expectRevert("_handleIncomingTransfer msg value less than expected amount"); + puts.createPut(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + } + + /// ------------ CANCEL PUT ------------ /// + + function test_CancelPut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(seller)); + puts.cancelPut(address(token), 0, 1); + } + + function testRevert_CancelPutMustBeSeller() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.expectRevert("cancelPut must be seller"); + puts.cancelPut(address(token), 0, 1); + } + + function testRevert_CancelPutDoesNotExist() public { + vm.expectRevert("cancelPut must be seller"); + puts.cancelPut(address(token), 0, 1); + } + + function testRevert_CancelPutAlreadyPurchased() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + vm.prank(address(seller)); + vm.expectRevert("cancelPut put has been purchased"); + puts.cancelPut(address(token), 0, 1); + } + + /// ------------ BUY PUT ------------ /// + + function test_BuyPut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + (, address putBuyer, , , , ) = puts.puts(address(token), 0, 1); + require(putBuyer == address(buyer)); + } + + function testRevert_BuyPutDoesNotExist() public { + vm.expectRevert("buyPut put does not exist"); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + } + + function testRevert_BuyPutAlreadyPurchased() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + vm.warp(11 hours); + + vm.expectRevert("buyPut put already purchased"); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + } + + function testRevert_BuyPutExpired() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(1 days + 1 seconds); + + vm.prank(address(buyer)); + vm.expectRevert("buyPut put expired"); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + } + + /// ------------ EXERCISE PUT ------------ /// + + function test_ExercisePut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + vm.warp(23 hours); + + vm.prank(address(buyer)); + puts.exercisePut(address(token), 0, 1); + + require(token.ownerOf(0) == address(seller)); + } + + function testRevert_ExercisePutMustBeBuyer() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + vm.warp(23 hours); + + vm.expectRevert("exercisePut must be buyer"); + puts.exercisePut(address(token), 0, 1); + } + + function testRevert_ExercisePutMustOwnToken() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + vm.warp(23 hours); + + vm.prank(address(buyer)); + token.transferFrom(address(buyer), address(this), 0); + + vm.prank(address(buyer)); + vm.expectRevert("exercisePut must own token"); + puts.exercisePut(address(token), 0, 1); + } + + function testRevert_ExercisePutExpired() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + vm.warp(1 days + 1 seconds); + + vm.prank(address(buyer)); + vm.expectRevert("exercisePut put expired"); + puts.exercisePut(address(token), 0, 1); + } + + /// ------------ RECLAIM PUT ------------ /// + + function test_ReclaimPut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + vm.warp(1 days + 1 seconds); + + uint256 beforeBalance = address(seller).balance; + + vm.prank(address(seller)); + puts.reclaimPut(address(token), 0, 1); + + uint256 afterBalance = address(seller).balance; + require(afterBalance - beforeBalance == 1 ether); + } + + function testRevert_ReclaimPutMustBeSeller() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + vm.warp(1 days + 1 seconds); + + vm.expectRevert("reclaimPut must be seller"); + puts.reclaimPut(address(token), 0, 1); + } + + function testRevert_ReclaimPutNotPurchased() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(seller)); + vm.expectRevert("reclaimPut put not purchased"); + puts.reclaimPut(address(token), 0, 1); + } + + function testRevert_ReclaimPutActive() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); + + vm.warp(23 hours); + + vm.prank(address(seller)); + vm.expectRevert("reclaimPut put is active"); + puts.reclaimPut(address(token), 0, 1); + } +} From 44ceecf742201b6d0e101fe33bd59ff3bef920fc Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Sat, 9 Apr 2022 16:54:42 -0400 Subject: [PATCH 2/4] chore: remove outdated version --- .../modules/CoveredPuts/V1/CoveredPutsV1.sol | 269 -------------- .../V1/CoveredPuts.integration.t.sol | 221 ----------- .../modules/CoveredPuts/V1/CoveredPuts.t.sol | 350 ------------------ 3 files changed, 840 deletions(-) delete mode 100644 contracts/modules/CoveredPuts/V1/CoveredPutsV1.sol delete mode 100644 contracts/test/modules/CoveredPuts/V1/CoveredPuts.integration.t.sol delete mode 100644 contracts/test/modules/CoveredPuts/V1/CoveredPuts.t.sol diff --git a/contracts/modules/CoveredPuts/V1/CoveredPutsV1.sol b/contracts/modules/CoveredPuts/V1/CoveredPutsV1.sol deleted file mode 100644 index 39ba0235..00000000 --- a/contracts/modules/CoveredPuts/V1/CoveredPutsV1.sol +++ /dev/null @@ -1,269 +0,0 @@ -// 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 {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 Covered Puts V1 -/// @author kulkarohan -/// @notice This module allows users to sell covered put options on any ERC-721 token -contract CoveredPutsV1 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 number of covered put options placed - uint256 public putCount; - - /// @notice The ZORA ERC-721 Transfer Helper - ERC721TransferHelper public immutable erc721TransferHelper; - - /// @notice The metadata of a covered put option - /// @param seller The address of the seller that created the option - /// @param buyer The address of the buyer, or address(0) if not purchased, that purchased the option - /// @param currency The address of the ERC-20, or address(0) for ETH, denominating the strike and premium - /// @param premium The price to purchase the option - /// @param strike The offer to exercise the option - /// @param expiration The expiration time of the option - struct Put { - address seller; - address buyer; - address currency; - uint256 premium; - uint256 strike; - uint256 expiration; - } - - /// ------------ STORAGE ------------ - - /// @notice The metadata of a covered put option for a given NFT and put option ID - /// @dev ERC-721 token address => ERC-721 token ID => Put ID => Put - mapping(address => mapping(uint256 => mapping(uint256 => Put))) public puts; - - /// @notice The covered put options placed for a given NFT - /// @dev ERC-721 token address => ERC-721 token ID => put IDs - mapping(address => mapping(uint256 => uint256[])) public putsForNFT; - - /// ------------ EVENTS ------------ - - /// @notice Emitted when a covered put option is created - /// @param tokenContract The ERC-721 token address for the created put option - /// @param tokenId The ERC-721 token ID for the created put option - /// @param putId The ID of the created put option - /// @param put The metadata of the created put option - event PutCreated(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed putId, Put put); - - /// @notice Emitted when a covered put option is canceled - /// @param tokenContract The ERC-721 token address of the canceled put option - /// @param tokenId The ERC-721 token ID of the canceled put option - /// @param putId The ID of the canceled put option - /// @param put The metadata of the canceled put option - event PutCanceled(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed putId, Put put); - - /// @notice Emitted when the strike from an expired put option is reclaimed - /// @param tokenContract The ERC-721 token address of the reclaimed put option - /// @param tokenId The ERC-721 token ID of the reclaimed put option - /// @param putId The ID of the reclaimed put option - /// @param put The metadata of the reclaimed put option - event PutReclaimed(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed putId, Put put); - - /// @notice Emitted when a covered put option is purchased - /// @param tokenContract The ERC-721 token address for the purchased put option - /// @param tokenId The ERC-721 token ID for the purchased put option - /// @param putId The ID of the purchased put option - /// @param put The metadata of the purchased put option - event PutPurchased(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed putId, Put put); - - /// @notice Emitted when a covered put option is exercised - /// @param tokenContract The ERC-721 token address for the exercised put option - /// @param tokenId The ERC-721 token ID for the exercised put option - /// @param putId The ID of the exercised put option - /// @param put The metadata of the exercised put option - event PutExercised(address indexed tokenContract, uint256 indexed tokenId, uint256 indexed putId, Put put); - - /// ------------ 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("Covered Puts: v1.0") - { - erc721TransferHelper = ERC721TransferHelper(_erc721TransferHelper); - } - - /// ------------ SELLER FUNCTIONS ------------ - - /// @notice Places a covered put option on an NFT - /// @param _tokenContract The address of the desired ERC-721 token - /// @param _tokenId The ID of the desired ERC-721 token - /// @param _premiumPrice The amount to purchase the option - /// @param _strike The offer to exercise the option - /// @param _expiration The expiration time of the option - /// @param _currency The address of the ERC-20, or address(0) for ETH, denominating the strike and premium - function createPut( - address _tokenContract, - uint256 _tokenId, - uint256 _premiumPrice, - uint256 _strike, - uint256 _expiration, - address _currency - ) external payable nonReentrant returns (uint256) { - require(IERC721(_tokenContract).ownerOf(_tokenId) != msg.sender, "createPut cannot create put on owned NFT"); - require(_expiration > block.timestamp, "createPut _expiration must be future time"); - - // Hold strike in escrow - _handleIncomingTransfer(_strike, _currency); - - putCount++; - - puts[_tokenContract][_tokenId][putCount] = Put({ - seller: msg.sender, - buyer: address(0), - currency: _currency, - premium: _premiumPrice, - strike: _strike, - expiration: _expiration - }); - - putsForNFT[_tokenContract][_tokenId].push(putCount); - - emit PutCreated(_tokenContract, _tokenId, putCount, puts[_tokenContract][_tokenId][putCount]); - - return putCount; - } - - /// @notice Cancels a non-purchased covered put option and refunds the strike - /// @param _tokenContract The address of the ERC-721 token - /// @param _tokenId The ID of the ERC-721 token - /// @param _putId The ID of the option to cancel - function cancelPut( - address _tokenContract, - uint256 _tokenId, - uint256 _putId - ) external nonReentrant { - Put storage put = puts[_tokenContract][_tokenId][_putId]; - - require(put.seller == msg.sender, "cancelPut must be seller"); - require(put.buyer == address(0), "cancelPut put has been purchased"); - - // Refund strike - _handleOutgoingTransfer(msg.sender, put.strike, put.currency, USE_ALL_GAS_FLAG); - - emit PutCanceled(_tokenContract, _tokenId, _putId, put); - - delete puts[_tokenContract][_tokenId][_putId]; - } - - /// @notice Reclaims the strike from a purchased, but non-exercised put option - /// @param _tokenContract The address of the ERC-721 token - /// @param _tokenId The ID of the ERC-721 token - /// @param _putId The ID of the option to reclaim - function reclaimPut( - address _tokenContract, - uint256 _tokenId, - uint256 _putId - ) external nonReentrant { - Put storage put = puts[_tokenContract][_tokenId][_putId]; - - require(put.seller == msg.sender, "reclaimPut must be seller"); - require(put.buyer != address(0), "reclaimPut put not purchased"); - require(block.timestamp >= put.expiration, "reclaimPut put is active"); - - _handleOutgoingTransfer(msg.sender, put.strike, put.currency, USE_ALL_GAS_FLAG); - - emit PutReclaimed(_tokenContract, _tokenId, _putId, put); - - delete puts[_tokenContract][_tokenId][_putId]; - } - - /// ------------ BUYER FUNCTIONS ------------ - - /// @notice Purchases a covered put option and transfers the premium to the seller - /// @param _tokenContract The address of the ERC-721 token to trade - /// @param _tokenId The ID of the ERC-721 token to trade - /// @param _putId The ID of the option to purchase - /// @param _currency The ERC-20 address of the strike and premium, or address(0) for ETH - /// @param _premium The price to pay for the put option - /// @param _strike The amount offered with exercise - function buyPut( - address _tokenContract, - uint256 _tokenId, - uint256 _putId, - address _currency, - uint256 _premium, - uint256 _strike - ) external payable nonReentrant { - Put storage put = puts[_tokenContract][_tokenId][_putId]; - - require(put.seller != address(0), "buyPut put does not exist"); - require(put.buyer == address(0), "buyPut put already purchased"); - require(put.expiration > block.timestamp, "buyPut put expired"); - require(put.currency == _currency, "buyPut _currency must match put"); - require(put.premium == _premium, "buyPut _premium must match put"); - require(put.strike == _strike, "buyPut _strike must match put"); - - // Ensure premium payment is valid and take custody - _handleIncomingTransfer(put.premium, put.currency); - - // Send premium to seller - _handleOutgoingTransfer(put.seller, put.premium, put.currency, USE_ALL_GAS_FLAG); - - // Mark option as purchased - put.buyer = msg.sender; - - emit PutPurchased(_tokenContract, _tokenId, _putId, put); - } - - /// @notice Exercises a covered put option, transferring the NFT to the seller and strike to the buyer - /// @param _tokenContract The address of the ERC-721 token to trade - /// @param _tokenId The ID of the ERC-721 token to trade - /// @param _putId The ID of the option to exercise - function exercisePut( - address _tokenContract, - uint256 _tokenId, - uint256 _putId - ) external nonReentrant { - Put storage put = puts[_tokenContract][_tokenId][_putId]; - - require(put.buyer == msg.sender, "exercisePut must be buyer"); - require(IERC721(_tokenContract).ownerOf(_tokenId) == msg.sender, "exercisePut must own token"); - require(put.expiration > block.timestamp, "exercisePut put expired"); - - // Ensure NFT royalties are honored - (uint256 remainingProfit, ) = _handleRoyaltyPayout(_tokenContract, _tokenId, put.strike, put.currency, USE_ALL_GAS_FLAG); - - // Payout optional protocol fee - remainingProfit = _handleProtocolFeePayout(remainingProfit, put.currency); - - // Transfer ETH/ERC-20 strike to buyer - _handleOutgoingTransfer(msg.sender, remainingProfit, put.currency, USE_ALL_GAS_FLAG); - - // Transfer NFT to seller - erc721TransferHelper.transferFrom(_tokenContract, msg.sender, put.seller, _tokenId); - - ExchangeDetails memory userAExchangeDetails = ExchangeDetails({tokenContract: put.currency, tokenId: 0, amount: put.strike}); - ExchangeDetails memory userBExchangeDetails = ExchangeDetails({tokenContract: _tokenContract, tokenId: _tokenId, amount: 1}); - - emit ExchangeExecuted(put.seller, put.buyer, userAExchangeDetails, userBExchangeDetails); - emit PutExercised(_tokenContract, _tokenId, _putId, put); - - delete puts[_tokenContract][_tokenId][_putId]; - } -} diff --git a/contracts/test/modules/CoveredPuts/V1/CoveredPuts.integration.t.sol b/contracts/test/modules/CoveredPuts/V1/CoveredPuts.integration.t.sol deleted file mode 100644 index 3a03d839..00000000 --- a/contracts/test/modules/CoveredPuts/V1/CoveredPuts.integration.t.sol +++ /dev/null @@ -1,221 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.10; - -import {DSTest} from "ds-test/test.sol"; - -import {CoveredPutsV1} from "../../../../modules/CoveredPuts/V1/CoveredPutsV1.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 CoveredPutsV1IntegrationTest -/// @notice Integration Tests for Covered Put Options v1.0 -contract CoveredPutsV1IntegrationTest is DSTest { - VM internal vm; - - ZoraRegistrar internal registrar; - ZoraProtocolFeeSettings internal ZPFS; - ZoraModuleManager internal ZMM; - ERC20TransferHelper internal erc20TransferHelper; - ERC721TransferHelper internal erc721TransferHelper; - RoyaltyEngine internal royaltyEngine; - - CoveredPutsV1 internal puts; - 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 Covered Put Options v1.0 - puts = new CoveredPutsV1(address(erc20TransferHelper), address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); - registrar.registerModule(address(puts)); - - // Set user balances - vm.deal(address(seller), 100 ether); - vm.deal(address(buyer), 100 ether); - - // Mint buyer token - token.mint(address(buyer), 0); - - // Users swap 50 ETH <> 50 WETH - vm.prank(address(seller)); - weth.deposit{value: 50 ether}(); - - vm.prank(address(buyer)); - weth.deposit{value: 50 ether}(); - - // Users approve CoveredPuts module - seller.setApprovalForModule(address(puts), true); - buyer.setApprovalForModule(address(puts), true); - - // Users approve ERC20TransferHelper - vm.prank(address(seller)); - weth.approve(address(erc20TransferHelper), 50 ether); - - vm.prank(address(buyer)); - weth.approve(address(erc20TransferHelper), 50 ether); - - // Buyer approve ERC721TransferHelper - vm.prank(address(buyer)); - token.setApprovalForAll(address(erc721TransferHelper), true); - } - - /// ------------ ETH PURCHASED PUT OPTION ------------ /// - - function runETHPurchase() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(1 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - } - - function test_ETHPurchaseIntegration() public { - uint256 beforeSellerBalance = address(seller).balance; - uint256 beforeBuyerBalance = address(buyer).balance; - - runETHPurchase(); - - uint256 afterSellerBalance = address(seller).balance; - uint256 afterBuyerBalance = address(buyer).balance; - - require(beforeSellerBalance - afterSellerBalance == 0.5 ether); - require(beforeBuyerBalance - afterBuyerBalance == 0.5 ether); - } - - /// ------------ ETH EXERCISED PUT OPTION ------------ /// - - function runETHExercise() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(1 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.exercisePut(address(token), 0, 1); - } - - function test_ETHExerciseIntegration() public { - uint256 beforeSellerBalance = address(seller).balance; - uint256 beforeBuyerBalance = address(buyer).balance; - uint256 beforeRoyaltyRecipientBalance = address(royaltyRecipient).balance; - address beforeTokenOwner = token.ownerOf(0); - - runETHExercise(); - - uint256 afterSellerBalance = address(seller).balance; - uint256 afterBuyerBalance = address(buyer).balance; - uint256 afterRoyaltyRecipientBalance = address(royaltyRecipient).balance; - address afterTokenOwner = token.ownerOf(0); - - require(beforeSellerBalance - afterSellerBalance == 0.5 ether); - require(afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance == 0.05 ether); - require(afterBuyerBalance - beforeBuyerBalance == 0.45 ether); - require(beforeTokenOwner == address(buyer) && afterTokenOwner == address(seller)); - } - - /// ------------ ERC-20 PURCHASED PUT OPTION ------------ /// - - function runERC20Purchase() public { - vm.prank(address(seller)); - puts.createPut(address(token), 0, 0.5 ether, 1 ether, 1 days, address(weth)); - - vm.warp(1 hours); - - vm.prank(address(buyer)); - puts.buyPut(address(token), 0, 1, address(weth), 0.5 ether, 1 ether); - } - - function test_ERC20PurchaseIntegration() public { - uint256 beforeSellerBalance = weth.balanceOf(address(seller)); - uint256 beforeBuyerBalance = weth.balanceOf(address(buyer)); - - runERC20Purchase(); - - uint256 afterSellerBalance = weth.balanceOf(address(seller)); - uint256 afterBuyerBalance = weth.balanceOf(address(buyer)); - - require(beforeSellerBalance - afterSellerBalance == 0.5 ether); - require(beforeBuyerBalance - afterBuyerBalance == 0.5 ether); - } - - /// ------------ ERC-20 EXERCISED PUT OPTION ------------ /// - - function runERC20Exercise() public { - vm.prank(address(seller)); - puts.createPut(address(token), 0, 0.5 ether, 1 ether, 1 days, address(weth)); - - vm.warp(1 hours); - - vm.prank(address(buyer)); - puts.buyPut(address(token), 0, 1, address(weth), 0.5 ether, 1 ether); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.exercisePut(address(token), 0, 1); - } - - function test_ERC20ExerciseIntegration() public { - uint256 beforeSellerBalance = weth.balanceOf(address(seller)); - uint256 beforeBuyerBalance = weth.balanceOf(address(buyer)); - uint256 beforeRoyaltyRecipientBalance = weth.balanceOf(address(royaltyRecipient)); - address beforeTokenOwner = token.ownerOf(0); - - runERC20Exercise(); - - uint256 afterSellerBalance = weth.balanceOf(address(seller)); - uint256 afterBuyerBalance = weth.balanceOf(address(buyer)); - uint256 afterRoyaltyRecipientBalance = weth.balanceOf(address(royaltyRecipient)); - address afterTokenOwner = token.ownerOf(0); - - require(beforeSellerBalance - afterSellerBalance == 0.5 ether); - require(afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance == 0.05 ether); - require(afterBuyerBalance - beforeBuyerBalance == 0.45 ether); - require(beforeTokenOwner == address(buyer) && afterTokenOwner == address(seller)); - } -} diff --git a/contracts/test/modules/CoveredPuts/V1/CoveredPuts.t.sol b/contracts/test/modules/CoveredPuts/V1/CoveredPuts.t.sol deleted file mode 100644 index bac4652f..00000000 --- a/contracts/test/modules/CoveredPuts/V1/CoveredPuts.t.sol +++ /dev/null @@ -1,350 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity 0.8.10; - -import {DSTest} from "ds-test/test.sol"; - -import {CoveredPutsV1} from "../../../../modules/CoveredPuts/V1/CoveredPutsV1.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 CoveredPutsV1Test -/// @notice Unit Tests for Covered Put Options v1.0 -contract CoveredPutsV1Test is DSTest { - VM internal vm; - - ZoraRegistrar internal registrar; - ZoraProtocolFeeSettings internal ZPFS; - ZoraModuleManager internal ZMM; - ERC20TransferHelper internal erc20TransferHelper; - ERC721TransferHelper internal erc721TransferHelper; - RoyaltyEngine internal royaltyEngine; - - CoveredPutsV1 internal puts; - 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 Covered Put Options v1.0 - puts = new CoveredPutsV1(address(erc20TransferHelper), address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); - registrar.registerModule(address(puts)); - - // Set user balances - vm.deal(address(seller), 100 ether); - vm.deal(address(buyer), 100 ether); - - // Mint put buyer token - token.mint(address(buyer), 0); - - // Buyer swap 50 ETH <> 50 WETH - vm.prank(address(buyer)); - weth.deposit{value: 50 ether}(); - - // Users approve CoveredPuts module - seller.setApprovalForModule(address(puts), true); - buyer.setApprovalForModule(address(puts), true); - - // Buyer approve ERC721TransferHelper - vm.prank(address(buyer)); - token.setApprovalForAll(address(erc721TransferHelper), true); - - // Buyer approve ERC20TransferHelper - vm.prank(address(seller)); - weth.approve(address(erc20TransferHelper), 50 ether); - } - - /// ------------ CREATE PUT ------------ /// - - function testGas_CreatePut() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - } - - function test_CreatePut() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - (address putSeller, address putBuyer, address putCurrency, uint256 putPremium, uint256 putStrike, uint256 putExpiry) = puts.puts( - address(token), - 0, - 1 - ); - - require(putSeller == address(seller)); - require(putBuyer == address(0)); - require(putCurrency == address(0)); - require(putPremium == 0.5 ether); - require(putStrike == 1 ether); - require(putExpiry == 1 days); - } - - function testRevert_CannotCreatePutOnOwnNFT() public { - vm.prank(address(buyer)); - vm.expectRevert("createPut cannot create put on owned NFT"); - puts.createPut(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - } - - function testRevert_CreatePutMustAttachStrikeFunds() public { - vm.prank(address(seller)); - vm.expectRevert("_handleIncomingTransfer msg value less than expected amount"); - puts.createPut(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - } - - /// ------------ CANCEL PUT ------------ /// - - function test_CancelPut() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(seller)); - puts.cancelPut(address(token), 0, 1); - } - - function testRevert_CancelPutMustBeSeller() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.expectRevert("cancelPut must be seller"); - puts.cancelPut(address(token), 0, 1); - } - - function testRevert_CancelPutDoesNotExist() public { - vm.expectRevert("cancelPut must be seller"); - puts.cancelPut(address(token), 0, 1); - } - - function testRevert_CancelPutAlreadyPurchased() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - vm.prank(address(seller)); - vm.expectRevert("cancelPut put has been purchased"); - puts.cancelPut(address(token), 0, 1); - } - - /// ------------ BUY PUT ------------ /// - - function test_BuyPut() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - (, address putBuyer, , , , ) = puts.puts(address(token), 0, 1); - require(putBuyer == address(buyer)); - } - - function testRevert_BuyPutDoesNotExist() public { - vm.expectRevert("buyPut put does not exist"); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - } - - function testRevert_BuyPutAlreadyPurchased() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - vm.warp(11 hours); - - vm.expectRevert("buyPut put already purchased"); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - } - - function testRevert_BuyPutExpired() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(1 days + 1 seconds); - - vm.prank(address(buyer)); - vm.expectRevert("buyPut put expired"); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - } - - /// ------------ EXERCISE PUT ------------ /// - - function test_ExercisePut() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - vm.warp(23 hours); - - vm.prank(address(buyer)); - puts.exercisePut(address(token), 0, 1); - - require(token.ownerOf(0) == address(seller)); - } - - function testRevert_ExercisePutMustBeBuyer() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - vm.warp(23 hours); - - vm.expectRevert("exercisePut must be buyer"); - puts.exercisePut(address(token), 0, 1); - } - - function testRevert_ExercisePutMustOwnToken() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - vm.warp(23 hours); - - vm.prank(address(buyer)); - token.transferFrom(address(buyer), address(this), 0); - - vm.prank(address(buyer)); - vm.expectRevert("exercisePut must own token"); - puts.exercisePut(address(token), 0, 1); - } - - function testRevert_ExercisePutExpired() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - vm.warp(1 days + 1 seconds); - - vm.prank(address(buyer)); - vm.expectRevert("exercisePut put expired"); - puts.exercisePut(address(token), 0, 1); - } - - /// ------------ RECLAIM PUT ------------ /// - - function test_ReclaimPut() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - vm.warp(1 days + 1 seconds); - - uint256 beforeBalance = address(seller).balance; - - vm.prank(address(seller)); - puts.reclaimPut(address(token), 0, 1); - - uint256 afterBalance = address(seller).balance; - require(afterBalance - beforeBalance == 1 ether); - } - - function testRevert_ReclaimPutMustBeSeller() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - vm.warp(1 days + 1 seconds); - - vm.expectRevert("reclaimPut must be seller"); - puts.reclaimPut(address(token), 0, 1); - } - - function testRevert_ReclaimPutNotPurchased() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(seller)); - vm.expectRevert("reclaimPut put not purchased"); - puts.reclaimPut(address(token), 0, 1); - } - - function testRevert_ReclaimPutActive() public { - vm.prank(address(seller)); - puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 ether, 1 days, address(0)); - - vm.warp(10 hours); - - vm.prank(address(buyer)); - puts.buyPut{value: 0.5 ether}(address(token), 0, 1, address(0), 0.5 ether, 1 ether); - - vm.warp(23 hours); - - vm.prank(address(seller)); - vm.expectRevert("reclaimPut put is active"); - puts.reclaimPut(address(token), 0, 1); - } -} From e34dc73d4c6d7375021fad09dddd3a36e5130750 Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Sat, 9 Apr 2022 22:39:16 -0400 Subject: [PATCH 3/4] feat: Covered Puts ETH --- .../CoveredPuts/Core/ETH/CoveredPutsEth.sol | 294 +++++++++++++++++ .../Core/ETH/CoveredPuts.integration.t.sol | 146 +++++++++ .../CoveredPuts/Core/ETH/CoveredPuts.t.sol | 298 ++++++++++++++++++ 3 files changed, 738 insertions(+) create mode 100644 contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol create mode 100644 contracts/test/modules/CoveredPuts/Core/ETH/CoveredPuts.integration.t.sol create mode 100644 contracts/test/modules/CoveredPuts/Core/ETH/CoveredPuts.t.sol diff --git a/contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol b/contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol new file mode 100644 index 00000000..eb7fb3e6 --- /dev/null +++ b/contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol @@ -0,0 +1,294 @@ +// 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 {ERC721TransferHelper} from "../../../../transferHelpers/ERC721TransferHelper.sol"; +import {FeePayoutSupportV1} from "../../../../common/FeePayoutSupport/FeePayoutSupportV1.sol"; +import {ModuleNamingSupportV1} from "../../../../common/ModuleNamingSupport/ModuleNamingSupportV1.sol"; + +/// @title Covered Puts ETH +/// @author kulkarohan +/// @notice Module for minimal ETH covered put options for ERC-721 tokens +contract CoveredPutsEth is ReentrancyGuard, FeePayoutSupportV1, ModuleNamingSupportV1 { + /// /// + /// MODULE SETUP /// + /// /// + + /// @notice The ZORA ERC-721 Transfer Helper + ERC721TransferHelper public immutable erc721TransferHelper; + + /// @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 _erc721TransferHelper, + address _royaltyEngine, + address _protocolFeeSettings, + address _weth + ) + FeePayoutSupportV1(_royaltyEngine, _protocolFeeSettings, _weth, ERC721TransferHelper(_erc721TransferHelper).ZMM().registrar()) + ModuleNamingSupportV1("Covered Puts ETH") + { + erc721TransferHelper = ERC721TransferHelper(_erc721TransferHelper); + } + + /// /// + /// PUT STORAGE /// + /// /// + + /// @notice The metadata for a covered put option + /// @param seller The address of the seller + /// @param premium The price to purchase the option + /// @param buyer The address of the buyer, or address(0) if not yet purchased + /// @param strike The price to exercise the option + /// @param expiry The expiration time of the option + struct Put { + address seller; + uint96 premium; + address buyer; + uint96 strike; + uint256 expiry; + } + + /// @notice The number of covered put options placed + uint256 public putCount; + + /// @notice The covered put option for a given NFT + /// @dev ERC-721 token address => ERC-721 token id => Put id + mapping(address => mapping(uint256 => mapping(uint256 => Put))) public puts; + + /// /// + /// CREATE PUT /// + /// /// + + /// @notice Emitted when a covered put option is created + /// @param tokenContract The ERC-721 token address of the created put option + /// @param tokenId The ERC-721 token id of the created put option + /// @param putId The id of the created put option + /// @param put The metadata of the created put option + event PutCreated(address tokenContract, uint256 tokenId, uint256 putId, Put put); + + /// @notice Creates a covered put option for an NFT + /// @dev The amount of ETH attached is held in escrow as the strike + /// @param _tokenContract The ERC-721 token address + /// @param _tokenId The ERC-721 token id + /// @param _premium The purchase price + /// @param _expiry The expiration time + /// @return The created put option id + function createPut( + address _tokenContract, + uint256 _tokenId, + uint256 _premium, + uint256 _expiry + ) external payable nonReentrant returns (uint256) { + // Used to store the option id + uint256 putId; + + // Get the next available option id + // The increment cannot realistically overflow + unchecked { + putId = ++putCount; + } + + // Used to store the option metadata + Put storage put = puts[_tokenContract][_tokenId][putId]; + + // Store the caller as the seller + put.seller = msg.sender; + + // Store the specified premium + // The maximum value this holds is greater than the total supply of ETH + put.premium = uint96(_premium); + + // Store the amount of ETH attached as the strike + // Peep 4 lines above + put.strike = uint96(msg.value); + + // Store the specified expiration time + put.expiry = _expiry; + + emit PutCreated(_tokenContract, _tokenId, putId, put); + + // Return the option id + return putId; + } + + /// /// + /// CANCEL PUT /// + /// /// + + /// @notice Emitted when a covered put option is canceled + /// @param tokenContract The ERC-721 token address of the canceled put option + /// @param tokenId The ERC-721 token id of the canceled put option + /// @param putId The id of the canceled put option + /// @param put The metadata of the canceled put option + event PutCanceled(address tokenContract, uint256 tokenId, uint256 putId, Put put); + + /// @notice Cancels a put option that has not yet been purchased + /// @param _tokenContract The ERC-721 token address + /// @param _tokenId The ERC-721 token id + /// @param _putId The put option id + function cancelPut( + address _tokenContract, + uint256 _tokenId, + uint256 _putId + ) external nonReentrant { + // Get the specified option + Put memory put = puts[_tokenContract][_tokenId][_putId]; + + // Ensure the caller is the seller + require(put.seller == msg.sender, "ONLY_SELLER"); + + // Ensure the option has not been purchased + require(put.buyer == address(0), "PURCHASED"); + + // Refund the strike to the seller + _handleOutgoingTransfer(msg.sender, put.strike, address(0), 50000); + + emit PutCanceled(_tokenContract, _tokenId, _putId, put); + + // Remove the option from storage + delete puts[_tokenContract][_tokenId][_putId]; + } + + /// /// + /// BUY PUT /// + /// /// + + /// @notice Emitted when a covered put option is purchased + /// @param tokenContract The ERC-721 token address of the purchased put option + /// @param tokenId The ERC-721 token id of the purchased put option + /// @param putId The id of the purchased put option + /// @param put The metadata of the purchased put option + event PutPurchased(address tokenContract, uint256 tokenId, uint256 putId, Put put); + + /// @notice Purchases a put option for an NFT + /// @param _tokenContract The ERC-721 token address + /// @param _tokenId The ERC-721 token id + /// @param _putId The put option id + /// @param _strike The strike price held in escrow + function buyPut( + address _tokenContract, + uint256 _tokenId, + uint256 _putId, + uint256 _strike + ) external payable nonReentrant { + // Get the specified option + Put storage put = puts[_tokenContract][_tokenId][_putId]; + + // Ensure the option has not been purchased + require(put.buyer == address(0), "PURCHASED"); + + // Ensure the option has not expired + require(put.expiry > block.timestamp, "EXPIRED"); + + // Ensure the specified strike matches the option strike + require(put.strike == _strike, "INVALID_STRIKE"); + + // Cache the premium price + uint256 premium = put.premium; + + // Ensure the attached ETH matches the premium + require(msg.value == premium, "INVALID_PREMIUM"); + + // Transfer the premium to seller + _handleOutgoingTransfer(put.seller, premium, address(0), 50000); + + // Mark the option as purchased + put.buyer = msg.sender; + + emit PutPurchased(_tokenContract, _tokenId, _putId, put); + } + + /// /// + /// EXERCISE PUT /// + /// /// + + /// @notice Emitted when a covered put option is exercised + /// @param tokenContract The ERC-721 token address of the exercised put option + /// @param tokenId The ERC-721 token id of the exercised put option + /// @param putId The id of the exercised put option + /// @param put The metadata of the exercised put option + event PutExercised(address tokenContract, uint256 tokenId, uint256 putId, Put put); + + /// @notice Exercises a purchased put option + /// @param _tokenContract The ERC-721 token address + /// @param _tokenId The ERC-721 token id + /// @param _putId The put option id + function exercisePut( + address _tokenContract, + uint256 _tokenId, + uint256 _putId + ) external nonReentrant { + // Get the specified option + Put memory put = puts[_tokenContract][_tokenId][_putId]; + + // Ensure the caller is the buyer + require(put.buyer == msg.sender, "ONLY_BUYER"); + + // Ensure the option has not expired + require(put.expiry > block.timestamp, "EXPIRED"); + + // Payout associated token royalties, if any + (uint256 remainingProfit, ) = _handleRoyaltyPayout(_tokenContract, _tokenId, put.strike, address(0), 300000); + + // Payout the module fee, if configured + remainingProfit = _handleProtocolFeePayout(remainingProfit, address(0)); + + // Transfer the remaining profit to the option buyer + _handleOutgoingTransfer(msg.sender, remainingProfit, address(0), 50000); + + // Transfer the NFT to the seller + // Reverts if the buyer did not approve the ERC721TransferHelper or no longer owns the token + erc721TransferHelper.transferFrom(_tokenContract, msg.sender, put.seller, _tokenId); + + emit PutExercised(_tokenContract, _tokenId, _putId, put); + + // Remove the option from storage + delete puts[_tokenContract][_tokenId][_putId]; + } + + /// /// + /// RECLAIM PUT /// + /// /// + + /// @notice Emitted when the strike from an expired put option is reclaimed + /// @param tokenContract The ERC-721 token address of the reclaimed put option + /// @param tokenId The ERC-721 token id of the reclaimed put option + /// @param putId The id of the reclaimed put option + /// @param put The metadata of the reclaimed put option + event PutReclaimed(address tokenContract, uint256 tokenId, uint256 putId, Put put); + + /// @notice Reclaims the ETH from an expired put option + /// @param _tokenContract The ERC-721 token address + /// @param _tokenId The ERC-721 token id + /// @param _putId The put option id + function reclaimPut( + address _tokenContract, + uint256 _tokenId, + uint256 _putId + ) external nonReentrant { + // Get the specified option + Put memory put = puts[_tokenContract][_tokenId][_putId]; + + // Ensure the caller is the seller + require(put.seller == msg.sender, "ONLY_SELLER"); + + // Ensure the option has been purchased + require(put.buyer != address(0), "NOT_PURCHASED"); + + // Ensure the option has expired + require(block.timestamp >= put.expiry, "NOT_EXPIRED"); + + // Transfer the strike back to the seller + _handleOutgoingTransfer(msg.sender, put.strike, address(0), 50000); + + emit PutReclaimed(_tokenContract, _tokenId, _putId, put); + + // Remove the option from storage + delete puts[_tokenContract][_tokenId][_putId]; + } +} diff --git a/contracts/test/modules/CoveredPuts/Core/ETH/CoveredPuts.integration.t.sol b/contracts/test/modules/CoveredPuts/Core/ETH/CoveredPuts.integration.t.sol new file mode 100644 index 00000000..3284e067 --- /dev/null +++ b/contracts/test/modules/CoveredPuts/Core/ETH/CoveredPuts.integration.t.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {CoveredPutsEth} from "../../../../../modules/CoveredPuts/Core/ETH/CoveredPutsEth.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 CoveredPutsEthIntegrationTest +/// @notice Integration Tests for ETH Covered Put Options +contract CoveredPutsEthIntegrationTest is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + CoveredPutsEth internal puts; + TestERC721 internal token; + WETH internal weth; + + Zorb internal seller; + Zorb internal buyer; + Zorb internal royaltyRecipient; + + function setUp() public { + // Cheatcodes + vm = VM(HEVM_ADDRESS); + + // 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)); + royaltyRecipient = new Zorb(address(ZMM)); + + // Deploy mocks + royaltyEngine = new RoyaltyEngine(address(royaltyRecipient)); + token = new TestERC721(); + weth = new WETH(); + + // Deploy ETH Covered Put Options + puts = new CoveredPutsEth(address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(puts)); + + // Set user balances + vm.deal(address(seller), 100 ether); + vm.deal(address(buyer), 100 ether); + + // Mint buyer token + token.mint(address(buyer), 0); + + // Buyer approve CoveredPutsEth module + buyer.setApprovalForModule(address(puts), true); + + // Buyer approve ERC721TransferHelper + vm.prank(address(buyer)); + token.setApprovalForAll(address(erc721TransferHelper), true); + } + + /// /// + /// BUY PUT /// + /// /// + + function runETHPurchase() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(1 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + } + + function test_ETHPurchaseIntegration() public { + uint256 beforeSellerBalance = address(seller).balance; + uint256 beforeBuyerBalance = address(buyer).balance; + + runETHPurchase(); + + uint256 afterSellerBalance = address(seller).balance; + uint256 afterBuyerBalance = address(buyer).balance; + + require(beforeSellerBalance - afterSellerBalance == 0.5 ether); + require(beforeBuyerBalance - afterBuyerBalance == 0.5 ether); + } + + /// /// + /// EXERCISE PUT /// + /// /// + + function runETHExercise() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(1 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.exercisePut(address(token), 0, 1); + } + + function test_ETHExerciseIntegration() public { + uint256 beforeSellerBalance = address(seller).balance; + uint256 beforeBuyerBalance = address(buyer).balance; + uint256 beforeRoyaltyRecipientBalance = address(royaltyRecipient).balance; + address beforeTokenOwner = token.ownerOf(0); + + runETHExercise(); + + uint256 afterSellerBalance = address(seller).balance; + uint256 afterBuyerBalance = address(buyer).balance; + uint256 afterRoyaltyRecipientBalance = address(royaltyRecipient).balance; + address afterTokenOwner = token.ownerOf(0); + + require(beforeSellerBalance - afterSellerBalance == 0.5 ether); + require(afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance == 0.05 ether); + require(afterBuyerBalance - beforeBuyerBalance == 0.45 ether); + require(beforeTokenOwner == address(buyer) && afterTokenOwner == address(seller)); + } +} diff --git a/contracts/test/modules/CoveredPuts/Core/ETH/CoveredPuts.t.sol b/contracts/test/modules/CoveredPuts/Core/ETH/CoveredPuts.t.sol new file mode 100644 index 00000000..a298eefd --- /dev/null +++ b/contracts/test/modules/CoveredPuts/Core/ETH/CoveredPuts.t.sol @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {CoveredPutsEth} from "../../../../../modules/CoveredPuts/Core/ETH/CoveredPutsEth.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 CoveredPutsEthTest +/// @notice Unit Tests for ETH Covered Put Options +contract CoveredPutsEthTest is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + CoveredPutsEth internal puts; + TestERC721 internal token; + WETH internal weth; + + Zorb internal seller; + Zorb internal buyer; + Zorb internal royaltyRecipient; + + function setUp() public { + // Cheatcodes + vm = VM(HEVM_ADDRESS); + + // 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)); + royaltyRecipient = new Zorb(address(ZMM)); + + // Deploy mocks + royaltyEngine = new RoyaltyEngine(address(royaltyRecipient)); + token = new TestERC721(); + weth = new WETH(); + + // Deploy ETH Covered Put Options + puts = new CoveredPutsEth(address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(puts)); + + // Set user balances + vm.deal(address(seller), 100 ether); + vm.deal(address(buyer), 100 ether); + + // Mint put buyer token + token.mint(address(buyer), 0); + + // Buyer approve CoveredPutsEth module + buyer.setApprovalForModule(address(puts), true); + + // Buyer approve ERC721TransferHelper + vm.prank(address(buyer)); + token.setApprovalForAll(address(erc721TransferHelper), true); + } + + function test_CreatePut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + (address putSeller, uint256 putPremium, address putBuyer, uint256 putStrike, uint256 putExpiry) = puts.puts(address(token), 0, 1); + + require(putSeller == address(seller)); + require(putBuyer == address(0)); + require(putPremium == 0.5 ether); + require(putStrike == 1 ether); + require(putExpiry == 1 days); + } + + function test_CreateMaxPremium() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 2**96 - 1, 1 days); + + (, uint256 putPremium, , , ) = puts.puts(address(token), 0, 1); + + require(putPremium == 2**96 - 1); + } + + /// /// + /// CANCEL PUT /// + /// /// + + function test_CancelPut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + vm.prank(address(seller)); + puts.cancelPut(address(token), 0, 1); + } + + function testRevert_CancelPutMustBeSeller() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + vm.expectRevert("ONLY_SELLER"); + puts.cancelPut(address(token), 0, 1); + } + + function testRevert_CancelPutAlreadyPurchased() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + + vm.prank(address(seller)); + vm.expectRevert("PURCHASED"); + puts.cancelPut(address(token), 0, 1); + } + + /// /// + /// BUY PUT /// + /// /// + + function test_BuyPut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + + (, , address putBuyer, , ) = puts.puts(address(token), 0, 1); + + require(putBuyer == address(buyer)); + } + + function testRevert_BuyPutAlreadyPurchased() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(1 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + + vm.warp(2 hours); + + vm.prank(address(buyer)); + vm.expectRevert("PURCHASED"); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + } + + function testRevert_BuyPutExpired() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(1 days + 1 seconds); + + vm.prank(address(buyer)); + vm.expectRevert("EXPIRED"); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + } + + /// /// + /// EXERCISE PUT /// + /// /// + + function test_ExercisePut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + + vm.warp(23 hours); + + vm.prank(address(buyer)); + puts.exercisePut(address(token), 0, 1); + + require(token.ownerOf(0) == address(seller)); + } + + function testRevert_ExercisePutMustBeBuyer() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + + vm.warp(23 hours); + vm.expectRevert("ONLY_BUYER"); + puts.exercisePut(address(token), 0, 1); + } + + function testRevert_ExercisePutExpired() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + + vm.warp(1 days + 1 seconds); + + vm.prank(address(buyer)); + vm.expectRevert("EXPIRED"); + puts.exercisePut(address(token), 0, 1); + } + + /// /// + /// RECLAIM PUT /// + /// /// + + function test_ReclaimPut() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + + vm.warp(1 days + 1 seconds); + + uint256 beforeBalance = address(seller).balance; + + vm.prank(address(seller)); + puts.reclaimPut(address(token), 0, 1); + + uint256 afterBalance = address(seller).balance; + require(afterBalance - beforeBalance == 1 ether); + } + + function testRevert_ReclaimPutMustBeSeller() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + vm.warp(1 days + 1 seconds); + vm.expectRevert("ONLY_SELLER"); + puts.reclaimPut(address(token), 0, 1); + } + + function testRevert_ReclaimPutNotPurchased() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(seller)); + vm.expectRevert("NOT_PURCHASED"); + puts.reclaimPut(address(token), 0, 1); + } + + function testRevert_ReclaimPutActive() public { + vm.prank(address(seller)); + puts.createPut{value: 1 ether}(address(token), 0, 0.5 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + puts.buyPut{value: 0.5 ether}(address(token), 0, 1, 1 ether); + + vm.warp(23 hours); + + vm.prank(address(seller)); + vm.expectRevert("NOT_EXPIRED"); + puts.reclaimPut(address(token), 0, 1); + } +} From 325d7d2624a1a99a3bfcd8e3a347ebb901a67e60 Mon Sep 17 00:00:00 2001 From: Rohan Kulkarni Date: Sun, 10 Apr 2022 15:33:35 -0400 Subject: [PATCH 4/4] fix: store buyer before calling seller --- contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol b/contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol index eb7fb3e6..d85f9638 100644 --- a/contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol +++ b/contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol @@ -194,12 +194,12 @@ contract CoveredPutsEth is ReentrancyGuard, FeePayoutSupportV1, ModuleNamingSupp // Ensure the attached ETH matches the premium require(msg.value == premium, "INVALID_PREMIUM"); - // Transfer the premium to seller - _handleOutgoingTransfer(put.seller, premium, address(0), 50000); - // Mark the option as purchased put.buyer = msg.sender; + // Transfer the premium to seller + _handleOutgoingTransfer(put.seller, premium, address(0), 50000); + emit PutPurchased(_tokenContract, _tokenId, _putId, put); }