diff --git a/contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol b/contracts/modules/CoveredPuts/Core/ETH/CoveredPutsEth.sol new file mode 100644 index 00000000..d85f9638 --- /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"); + + // 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); + } + + /// /// + /// 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); + } +}