diff --git a/contracts/modules/CoveredCalls/Core/ETH/CoveredCallsEth.sol b/contracts/modules/CoveredCalls/Core/ETH/CoveredCallsEth.sol new file mode 100644 index 00000000..3a16fb07 --- /dev/null +++ b/contracts/modules/CoveredCalls/Core/ETH/CoveredCallsEth.sol @@ -0,0 +1,274 @@ +// 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 Calls ETH +/// @author kulkarohan +/// @notice Module for minimal ETH covered call options for ERC-721 tokens +contract CoveredCallsEth 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 Calls ETH") + { + erc721TransferHelper = ERC721TransferHelper(_erc721TransferHelper); + } + + /// /// + /// CALL STORAGE /// + /// /// + + /// @notice The metadata for a covered call 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 Call { + address seller; + uint96 premium; + address buyer; + uint96 strike; + uint256 expiry; + } + + /// @notice The covered call option for a given NFT + /// @dev ERC-721 token address => ERC-721 token id + mapping(address => mapping(uint256 => Call)) public callForNFT; + + /// /// + /// CREATE CALL /// + /// /// + + /// @notice Emitted when a covered call option is created + /// @param tokenContract The ERC-721 token address of the created call option + /// @param tokenId The ERC-721 token id of the created call option + event CallCreated(address tokenContract, uint256 tokenId, Call call); + + /// @notice Creates a covered call option for an NFT + /// @param _tokenContract The ERC-721 token address + /// @param _tokenId The ERC-721 token id + /// @param _premium The purchase price + /// @param _strike The exercise price + /// @param _expiry The expiration time + function createCall( + address _tokenContract, + uint256 _tokenId, + uint256 _premium, + uint256 _strike, + uint256 _expiry + ) external nonReentrant { + // Get the owner of the specified token + address tokenOwner = IERC721(_tokenContract).ownerOf(_tokenId); + + // Ensure the caller is the owner or an approved operator + require(msg.sender == tokenOwner || IERC721(_tokenContract).isApprovedForAll(tokenOwner, msg.sender), "ONLY_TOKEN_OWNER_OR_OPERATOR"); + + // Used to store the option metadata + Call storage call = callForNFT[_tokenContract][_tokenId]; + + // Store the token owner as the seller + call.seller = tokenOwner; + + // Store the specified premium + // This holds a max value greater than the total supply of ETH + call.premium = uint96(_premium); + + // Store the specified strike + // Peep 4 lines above + call.strike = uint96(_strike); + + // Store the specified expiration + call.expiry = _expiry; + + emit CallCreated(_tokenContract, _tokenId, call); + } + + /// /// + /// CANCEL CALL /// + /// /// + + /// @notice Emitted when a covered call option is canceled + /// @param tokenContract The ERC-721 token address of the canceled call option + /// @param tokenId The ERC-721 token id of the canceled call option + /// @param call The metadata of the canceled call option + event CallCanceled(address tokenContract, uint256 tokenId, Call call); + + /// @notice Cancels a call option that has not yet been purchased + /// @param _tokenContract The ERC-721 token address + /// @param _tokenId The ERC-721 token id + function cancelCall(address _tokenContract, uint256 _tokenId) external { + // Get the option for the specified token + Call memory call = callForNFT[_tokenContract][_tokenId]; + + // Ensure the option has not been purchased + require(call.buyer == address(0), "PURCHASED"); + + // Ensure the caller is the seller or a new token owner + require(msg.sender == call.seller || msg.sender == IERC721(_tokenContract).ownerOf(_tokenId), "ONLY_SELLER_OR_TOKEN_OWNER"); + + emit CallCanceled(_tokenContract, _tokenId, call); + + // Remove the option from storage + delete callForNFT[_tokenContract][_tokenId]; + } + + /// /// + /// BUY CALL /// + /// /// + + /// @notice Emitted when a covered call option is purchased + /// @param tokenContract The ERC-721 token address of the purchased call option + /// @param tokenId The ERC-721 token id of the purchased call option + /// @param call The metadata of the purchased call option + event CallPurchased(address tokenContract, uint256 tokenId, Call call); + + /// @notice Purchases a call option for an NFT + /// @param _tokenContract The address of the ERC-721 token + /// @param _tokenId The ERC-721 token id + /// @param _strike The strike price of the option + function buyCall( + address _tokenContract, + uint256 _tokenId, + uint256 _strike + ) external payable nonReentrant { + // Get the option for the specified token + Call storage call = callForNFT[_tokenContract][_tokenId]; + + // Ensure the option has not been purchased + require(call.buyer == address(0), "INVALID_PURCHASE"); + + // Ensure the option has not expired + require(call.expiry > block.timestamp, "INVALID_CALL"); + + // Ensure the specified strike matches the call strike + require(call.strike == _strike, "MUST_MATCH_STRIKE"); + + // Cache the premium price + uint256 premium = call.premium; + + // Ensure the attached ETH matches the premium + require(msg.value == premium, "MUST_MATCH_PREMIUM"); + + // Mark the option as purchased + call.buyer = msg.sender; + + // Cache the seller address + address seller = call.seller; + + // Transfer the NFT from the seller into escrow for the duration of the option + // Reverts if the seller did not approve the ERC721TransferHelper or no longer owns the token + erc721TransferHelper.transferFrom(_tokenContract, seller, address(this), _tokenId); + + // Transfer the premium to the seller + _handleOutgoingTransfer(seller, premium, address(0), 50000); + + emit CallPurchased(_tokenContract, _tokenId, call); + } + + /// /// + /// EXERCISE CALL /// + /// /// + + /// @notice Emitted when a covered call option is exercised + /// @param tokenContract The ERC-721 token address of the exercised call option + /// @param tokenId The ERC-721 token id of the exercised call option + /// @param call The metadata of the exercised call option + event CallExercised(address tokenContract, uint256 tokenId, Call call); + + /// @notice Exercises a purchased call option for an NFT + /// @param _tokenContract The ERC-721 token address + /// @param _tokenId The ERC-721 token id + function exerciseCall(address _tokenContract, uint256 _tokenId) external payable nonReentrant { + // Get the option for the specified token + Call memory call = callForNFT[_tokenContract][_tokenId]; + + // Ensure the caller is the buyer + require(call.buyer == msg.sender, "ONLY_BUYER"); + + // Ensure the option has not expired + require(call.expiry > block.timestamp, "INVALID_EXERCISE"); + + // Cache the strike price + uint256 strike = call.strike; + + // Ensure the attached ETH matches the strike + require(msg.value == strike, "MUST_MATCH_STRIKE"); + + // Payout associated token royalties, if any + (uint256 remainingProfit, ) = _handleRoyaltyPayout(_tokenContract, _tokenId, strike, address(0), 300000); + + // Payout the module fee, if configured + remainingProfit = _handleProtocolFeePayout(remainingProfit, address(0)); + + // Transfer the remaining profit to the seller + _handleOutgoingTransfer(call.seller, remainingProfit, address(0), 50000); + + // Transfer the NFT to the buyer + IERC721(_tokenContract).transferFrom(address(this), msg.sender, _tokenId); + + emit CallExercised(_tokenContract, _tokenId, call); + + // Remove the option from storage + delete callForNFT[_tokenContract][_tokenId]; + } + + /// /// + /// RECLAIM CALL /// + /// /// + + /// @notice Emitted when the NFT from an expired call option is reclaimed + /// @param tokenContract The ERC-721 token address of the expired call option + /// @param tokenId The ERC-721 token id of the expired call option + /// @param call The metadata of the expired call option + event CallReclaimed(address tokenContract, uint256 tokenId, Call call); + + /// @notice Reclaims the NFT from an expired call option + /// @param _tokenContract The ERC-721 token address + /// @param _tokenId The ERC-721 token id + function reclaimCall(address _tokenContract, uint256 _tokenId) external nonReentrant { + // Get the option for the specified token + Call memory call = callForNFT[_tokenContract][_tokenId]; + + // Cache the seller address + address seller = call.seller; + + // Ensure the caller is the seller + require(msg.sender == seller, "ONLY_SELLER"); + + // Ensure the option has been purchased + require(call.buyer != address(0), "INVALID_RECLAIM"); + + // Ensure the option has expired + require(block.timestamp >= call.expiry, "ACTIVE_OPTION"); + + // Transfer the NFT back to seller + IERC721(_tokenContract).transferFrom(address(this), seller, _tokenId); + + emit CallReclaimed(_tokenContract, _tokenId, call); + + // Remove the option from storage + delete callForNFT[_tokenContract][_tokenId]; + } +} diff --git a/contracts/test/modules/CoveredCalls/Core/ETH/CoveredCallsEth.integration.t.sol b/contracts/test/modules/CoveredCalls/Core/ETH/CoveredCallsEth.integration.t.sol new file mode 100644 index 00000000..d2199682 --- /dev/null +++ b/contracts/test/modules/CoveredCalls/Core/ETH/CoveredCallsEth.integration.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {CoveredCallsEth} from "../../../../../modules/CoveredCalls/Core/ETH/CoveredCallsEth.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 CoveredCallsEthIntegrationTest +/// @notice Integration Tests for ETH Covered Call Options +contract CoveredCallsEthIntegrationTest is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + CoveredCallsEth internal calls; + 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 Covered Calls ETH + calls = new CoveredCallsEth(address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(calls)); + + // Set user balances + vm.deal(address(buyer), 100 ether); + + // Mint seller token + token.mint(address(seller), 0); + + // Users approve CoveredCalls module + seller.setApprovalForModule(address(calls), true); + buyer.setApprovalForModule(address(calls), true); + + // Seller approve ERC721TransferHelper + vm.prank(address(seller)); + token.setApprovalForAll(address(erc721TransferHelper), true); + } + + /// /// + /// PURCHASED CALL /// + /// /// + + function runETHPurchase() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(1 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + } + + function test_ETHPurchaseIntegration() public { + uint256 beforeSellerBalance = address(seller).balance; + uint256 beforeBuyerBalance = address(buyer).balance; + address beforeTokenOwner = token.ownerOf(0); + + runETHPurchase(); + + uint256 afterSellerBalance = address(seller).balance; + uint256 afterBuyerBalance = address(buyer).balance; + address afterTokenOwner = token.ownerOf(0); + + require(afterSellerBalance - beforeSellerBalance == 0.5 ether); + require(beforeBuyerBalance - afterBuyerBalance == 0.5 ether); + require(beforeTokenOwner == address(seller) && afterTokenOwner == address(calls)); + } + + /// /// + /// EXERCISED CALL /// + /// /// + + function runETHExercise() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(1 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.exerciseCall{value: 1 ether}(address(token), 0); + } + + 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(beforeBuyerBalance - afterBuyerBalance == 1.5 ether); + require(afterRoyaltyRecipientBalance - beforeRoyaltyRecipientBalance == 0.05 ether); + require(afterSellerBalance - beforeSellerBalance == 1.45 ether); + require(beforeTokenOwner == address(seller) && afterTokenOwner == address(buyer)); + } +} diff --git a/contracts/test/modules/CoveredCalls/Core/ETH/CoveredCallsEth.t.sol b/contracts/test/modules/CoveredCalls/Core/ETH/CoveredCallsEth.t.sol new file mode 100644 index 00000000..6226fe7b --- /dev/null +++ b/contracts/test/modules/CoveredCalls/Core/ETH/CoveredCallsEth.t.sol @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.10; + +import {DSTest} from "ds-test/test.sol"; + +import {CoveredCallsEth} from "../../../../../modules/CoveredCalls/Core/ETH/CoveredCallsEth.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 CoveredCallsEthTest +/// @notice Unit Tests for ETH Covered Call Options +contract CoveredCallsEthTest is DSTest { + VM internal vm; + + ZoraRegistrar internal registrar; + ZoraProtocolFeeSettings internal ZPFS; + ZoraModuleManager internal ZMM; + ERC20TransferHelper internal erc20TransferHelper; + ERC721TransferHelper internal erc721TransferHelper; + RoyaltyEngine internal royaltyEngine; + + CoveredCallsEth internal calls; + TestERC721 internal token; + WETH internal weth; + + Zorb internal seller; + Zorb internal sellerFundsRecipient; + Zorb internal operator; + Zorb internal otherSeller; + Zorb internal buyer; + Zorb internal otherBuyer; + Zorb internal finder; + 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)); + sellerFundsRecipient = new Zorb(address(ZMM)); + operator = new Zorb(address(ZMM)); + otherSeller = new Zorb(address(ZMM)); + buyer = new Zorb(address(ZMM)); + otherBuyer = 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 Calls ETH + calls = new CoveredCallsEth(address(erc721TransferHelper), address(royaltyEngine), address(ZPFS), address(weth)); + registrar.registerModule(address(calls)); + + // Set user balances + vm.deal(address(buyer), 100 ether); + vm.deal(address(otherBuyer), 100 ether); + + // Mint seller token + token.mint(address(seller), 0); + + // Users approve CoveredCalls module + seller.setApprovalForModule(address(calls), true); + buyer.setApprovalForModule(address(calls), true); + + // Seller approve ERC721TransferHelper + vm.prank(address(seller)); + token.setApprovalForAll(address(erc721TransferHelper), true); + } + + /// /// + /// CREATE CALL /// + /// /// + + function test_CreateCall() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + (address callSeller, uint256 callPremium, address callBuyer, uint256 callStrike, uint256 callExpiry) = calls.callForNFT(address(token), 0); + + require(callSeller == address(seller)); + require(callBuyer == address(0)); + require(callPremium == 0.5 ether); + require(callStrike == 1 ether); + require(callExpiry == 1 days); + } + + function test_CreateCallAsOperator() public { + vm.prank(address(seller)); + token.setApprovalForAll(address(operator), true); + + vm.startPrank(address(operator)); + + ZMM.setApprovalForModule(address(calls), true); + token.setApprovalForAll(address(erc721TransferHelper), true); + + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.stopPrank(); + } + + function test_CreateWithMaxPrices() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 2**96 - 1, 2**96 - 1, 1 days); + + (, uint256 callPremium, , uint256 callStrike, ) = calls.callForNFT(address(token), 0); + + require(callStrike == 2**96 - 1); + require(callPremium == 2**96 - 1); + } + + function testRevert_CreateCallMustBeOwnerOrOperator() public { + vm.expectRevert("ONLY_TOKEN_OWNER_OR_OPERATOR"); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + } + + /// /// + /// CANCEL CALL /// + /// /// + + function test_CancelCall() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(seller)); + calls.cancelCall(address(token), 0); + } + + function testRevert_CancelCallMustBeSeller() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.expectRevert("ONLY_SELLER_OR_TOKEN_OWNER"); + calls.cancelCall(address(token), 0); + } + + function testRevert_CancelCallMustExist() public { + vm.expectRevert("ONLY_SELLER_OR_TOKEN_OWNER"); + calls.cancelCall(address(token), 0); + } + + function testRevert_CannotCancelPurchasedCall() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + vm.prank(address(seller)); + vm.expectRevert("PURCHASED"); + calls.cancelCall(address(token), 0); + } + + /// /// + /// BUY CALL /// + /// /// + + function test_BuyCall() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + (, , address beforeCallBuyer, , ) = calls.callForNFT(address(token), 0); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + (, , address afterCallBuyer, , ) = calls.callForNFT(address(token), 0); + + require(beforeCallBuyer == address(0) && afterCallBuyer == address(buyer)); + require(token.ownerOf(0) == address(calls)); + } + + function testRevert_BuyCallDoesNotExist() public { + vm.expectRevert("INVALID_CALL"); + calls.buyCall(address(token), 0, 1 ether); + } + + function testRevert_BuyCallAlreadyPurchased() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + vm.expectRevert("INVALID_PURCHASE"); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + } + + function testRevert_BuyCallExpired() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(1 days + 1 minutes); + + vm.expectRevert("INVALID_CALL"); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + } + + /// /// + /// EXERCISE CALL /// + /// /// + + function test_ExerciseCall() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + vm.warp(23 hours); + + vm.prank(address(buyer)); + calls.exerciseCall{value: 1 ether}(address(token), 0); + + require(token.ownerOf(0) == address(buyer)); + } + + function testRevert_ExerciseCallMustBeBuyer() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + vm.warp(23 hours); + + vm.expectRevert("ONLY_BUYER"); + calls.exerciseCall{value: 1 ether}(address(token), 0); + } + + function testRevert_ExerciseCallExpired() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + vm.warp(1 days + 1 seconds); + + vm.prank(address(buyer)); + vm.expectRevert("INVALID_EXERCISE"); + calls.exerciseCall{value: 1 ether}(address(token), 0); + } + + function testRevert_MustMatchStrike() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + vm.warp(23 hours); + + vm.prank(address(buyer)); + vm.expectRevert("MUST_MATCH_STRIKE"); + calls.exerciseCall{value: 0.99 ether}(address(token), 0); + } + + /// /// + /// RECLAIM CALL /// + /// /// + + function test_ReclaimCall() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + vm.warp(1 days + 1 seconds); + + vm.prank(address(seller)); + calls.reclaimCall(address(token), 0); + + require(token.ownerOf(0) == address(seller)); + } + + function testRevert_ReclaimCallMustBeSeller() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + vm.warp(1 days + 1 seconds); + + vm.expectRevert("ONLY_SELLER"); + calls.reclaimCall(address(token), 0); + } + + function testRevert_ReclaimCallNotPurchased() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(seller)); + vm.expectRevert("INVALID_RECLAIM"); + calls.reclaimCall(address(token), 0); + } + + function testRevert_ReclaimCallActive() public { + vm.prank(address(seller)); + calls.createCall(address(token), 0, 0.5 ether, 1 ether, 1 days); + + vm.warp(10 hours); + + vm.prank(address(buyer)); + calls.buyCall{value: 0.5 ether}(address(token), 0, 1 ether); + + vm.warp(23 hours); + + vm.prank(address(seller)); + vm.expectRevert("ACTIVE_OPTION"); + calls.reclaimCall(address(token), 0); + } +}