diff --git a/integration/integration.test.ts b/integration/integration.test.ts index 619f92f..f87904a 100644 --- a/integration/integration.test.ts +++ b/integration/integration.test.ts @@ -2225,6 +2225,30 @@ describe("Call Instrument Tests", function () { expect(callCreatedEvent.args.strikePrice).to.equal(1000); expect(callCreatedEvent.args.expiration).to.equal(expiration); }); + + it("should mint covered call with unvaulted erc721 with existing multivault", async function () { + await vaultFactory.makeMultiVault(token.address); + + const expiration = String( + Math.floor(Date.now() / 1000 + SECS_IN_A_DAY * 1.5) + ); + + // Mint call option + const createCall = await calls + .connect(writer) + .mintWithErc721(token.address, 0, 1000, expiration); + const cc = await createCall.wait(); + + const callCreatedEvent = cc.events.find( + (event: any) => event?.event === "CallCreated" + ); + + expect(createCall).to.emit(calls, "CallCreated"); + expect(callCreatedEvent.args.writer).to.equal(writer.address); + expect(callCreatedEvent.args.optionId).to.equal(1); + expect(callCreatedEvent.args.strikePrice).to.equal(1000); + expect(callCreatedEvent.args.expiration).to.equal(expiration); + }); }); /* @@ -2549,6 +2573,41 @@ describe("Call Instrument Tests", function () { expect(createCall).to.emit(calls, "CallCreated"); }); + + it("should mint covered call with entitled solo vault", async function () { + // Create solovault for token 1 + await vaultFactory.makeSoloVault(token.address, 1); + const soloValutAddress = await vaultFactory.getVault(token.address, 1); + const soloVault = await ethers.getContractAt( + "HookERC721VaultImplV1", + soloValutAddress + ); + + await token + .connect(writer) + ["safeTransferFrom(address,address,uint256)"]( + writer.address, + soloVault.address, + 1 + ); + + const expiration = Math.floor(Date.now() / 1000 + SECS_IN_A_DAY * 1.5); + + await soloVault.connect(writer).grantEntitlement({ + beneficialOwner: writer.address, + operator: calls.address, + vaultAddress: soloVault.address, + assetId: 0, + expiry: expiration, + }); + + // Mint call option + const createCall = await calls + .connect(writer) + .mintWithEntitledVault(soloVault.address, 0, 1000, expiration); + + expect(createCall).to.emit(calls, "CallCreated"); + }); }); /* @@ -2852,7 +2911,8 @@ describe("Call Instrument Tests", function () { const settleCall = calls .connect(writer) .settleOption(optionTokenId, false); - await expect(settleCall).to.emit(calls, "CallDestroyed"); + await expect(settleCall) + .to.emit(calls, "CallSettled"); const vaultAddress = await calls.getVaultAddress(optionTokenId); const vault = await ethers.getContractAt( @@ -2872,7 +2932,7 @@ describe("Call Instrument Tests", function () { const settleCall = calls .connect(writer) .settleOption(optionTokenId, true); - await expect(settleCall).to.emit(calls, "CallDestroyed"); + await expect(settleCall).to.emit(calls, "CallSettled"); expect(await token.ownerOf(0)).to.eq(writer.address); }); @@ -2884,7 +2944,8 @@ describe("Call Instrument Tests", function () { const settleCall = calls .connect(secondBidder) .settleOption(secondOptionTokenId, false); - await expect(settleCall).to.emit(calls, "CallDestroyed"); + await expect(settleCall) + .to.emit(calls, "CallSettled"); const vaultAddress = await calls.getVaultAddress(secondOptionTokenId); const vault = await ethers.getContractAt( @@ -2996,7 +3057,7 @@ describe("Call Instrument Tests", function () { .connect(writer) .reclaimAsset(optionTokenId, false); - await expect(reclaimAsset).to.emit(calls, "CallDestroyed"); + await expect(reclaimAsset).to.emit(calls, "CallReclaimed"); // Check that there's no entitlment on the vault const vaultAddress = await calls.getVaultAddress(optionTokenId); @@ -3014,7 +3075,7 @@ describe("Call Instrument Tests", function () { .connect(writer) .reclaimAsset(optionTokenId, false); - await expect(reclaimAsset).to.emit(calls, "CallDestroyed"); + await expect(reclaimAsset).to.emit(calls, "CallReclaimed"); // Check that there's no entitlment on the vault const vaultAddress = await calls.getVaultAddress(optionTokenId); @@ -3129,9 +3190,6 @@ describe("Call Instrument Tests", function () { expiry: expiration, }); - console.log(await multiVault.getCurrentEntitlementOperator(0)); - console.log(calls.address); - // Mint call option const createCall = await calls .connect(writer) @@ -3162,4 +3220,68 @@ describe("Call Instrument Tests", function () { expect(await calls.getExpiration(optionTokenId)).to.eq(expiration); }); }); + + /* + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ~~~~~~~~ burnExpiredOption ~~~~~~~~~ + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + describe("burnExpiredOption", function () { + let optionTokenId: BigNumber; + + this.beforeEach(async function () { + // Create solovault for token 0 + await vaultFactory.makeSoloVault(token.address, 0); + const soloValutAddress = await vaultFactory.getVault(token.address, 0); + const soloVault = await ethers.getContractAt( + "HookERC721VaultImplV1", + soloValutAddress + ); + + const blockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(blockNumber); + const blockTimestamp = block.timestamp; + const expiration = BigNumber.from(Math.floor(blockTimestamp + SECS_IN_A_DAY * 1.5)); + + // Mint call option + const createCall = await calls + .connect(writer) + .mintWithErc721(token.address, 0, 1000, expiration); + + const cc = await createCall.wait(); + + const callCreatedEvent = cc.events.find( + (event: any) => event?.event === "CallCreated" + ); + + optionTokenId = callCreatedEvent.args.optionId; + }); + + it("should not burn expired option before expiration", async function () { + await expect(calls.burnExpiredOption(optionTokenId)).to.be.revertedWith("burnExpiredOption -- the option must be expired") + }); + + it("should burn expired option", async function () { + // Move forward past expiration + await ethers.provider.send("evm_increaseTime", [2 * SECS_IN_A_DAY]); + + await expect(calls.burnExpiredOption(optionTokenId)) + .to.emit(calls, 'ExpiredCallBurned') + }); + + it("should not burn expired option with bids", async function () { + // Move forward to settlement auction + await ethers.provider.send("evm_increaseTime", [0.5 * SECS_IN_A_DAY]); + + // First bidder bid + await calls.connect(firstBidder).bid(optionTokenId, { value: 1001 }); + + // Move forward past option expiration + await ethers.provider.send("evm_increaseTime", [2 * SECS_IN_A_DAY]); + + // Burn expired option + await expect(calls.burnExpiredOption(optionTokenId)).to.be.revertedWith("burnExpiredOption -- the option must not have bids"); + }); + + }); }); diff --git a/src/HookCoveredCallImplV1.sol b/src/HookCoveredCallImplV1.sol index 512d96a..7a98219 100644 --- a/src/HookCoveredCallImplV1.sol +++ b/src/HookCoveredCallImplV1.sol @@ -413,15 +413,19 @@ contract HookCoveredCallImplV1 is return true; } - if ( - vaultAddress == - Create2.computeAddress( - BeaconSalts.soloVaultSalt(underlyingAddress, assetId), - BeaconSalts.ByteCodeHash, - address(_erc721VaultFactory) - ) - ) { - return true; + try IHookERC721Vault(vaultAddress).assetTokenId(assetId) returns (uint256 _tokenId) { + if ( + vaultAddress == + Create2.computeAddress( + BeaconSalts.soloVaultSalt(underlyingAddress, _tokenId), + BeaconSalts.ByteCodeHash, + address(_erc721VaultFactory) + ) + ) { + return true; + } + } catch(bytes memory) { + return false; } return false; @@ -525,7 +529,7 @@ contract HookCoveredCallImplV1 is // set settled to prevent an additional attempt to settle the option optionParams[optionId].settled = true; - emit CallDestroyed(optionId); + emit CallSettled(optionId); } /// @dev See {IHookCoveredCall-reclaimAsset}. @@ -577,7 +581,7 @@ contract HookCoveredCallImplV1 is // settle the option call.settled = true; - emit CallDestroyed(optionId); + emit CallReclaimed(optionId); /// WARNING: /// Currently, if the owner writes an option, and never sells that option, a settlement auction will exist on @@ -586,6 +590,34 @@ contract HookCoveredCallImplV1 is /// the current bidder to reclaim their money. } + /// @dev See {IHookCoveredCall-burnExpiredOption}. + function burnExpiredOption(uint256 optionId) external { + CallOption storage call = optionParams[optionId]; + + require( + block.timestamp > call.expiration, + "burnExpiredOption -- the option must be expired" + ); + + require( + !call.settled, + "burnExpiredOption -- the option has already been settled" + ); + + require( + call.highBidder == address(0), + "burnExpiredOption -- the option must not have bids" + ); + + // burn the option NFT + _burn(optionId); + + // settle the option + call.settled = true; + + emit ExpiredCallBurned(optionId); + } + //// ---- Administrative Fns. // forward to protocol pauseability diff --git a/src/interfaces/IHookCoveredCall.sol b/src/interfaces/IHookCoveredCall.sol index 127c46f..6a3f4da 100644 --- a/src/interfaces/IHookCoveredCall.sol +++ b/src/interfaces/IHookCoveredCall.sol @@ -42,8 +42,14 @@ interface IHookCoveredCall is IERC721Metadata { uint256 expiration ); - /// @notice emitted when a call option is destroyed - event CallDestroyed(uint256 optionId); + /// @notice emitted when a call option is settled + event CallSettled(uint256 optionId); + + /// @notice emitted when a call option is reclaimed + event CallReclaimed(uint256 optionId); + + /// @notice emitted when a expired call option is burned + event ExpiredCallBurned(uint256 optionId); /// @notice emitted when a call option settlement auction gets and accepts a new bid /// @param bidder the account placing the bid that is now the high bidder @@ -126,4 +132,9 @@ interface IHookCoveredCall is IERC721Metadata { /// @param optionId of the option to settle. /// @param returnNft true if token should be withdrawn from vault, false to leave token in the vault. function settleOption(uint256 optionId, bool returnNft) external; + + + /// @notice Allows anyone to burn the instrument NFT for an expired option. + /// @param optionId of the option to burn. + function burnExpiredOption(uint256 optionId) external; } diff --git a/src/test/HookCoveredCallIntegrationTest.t.sol b/src/test/HookCoveredCallIntegrationTest.t.sol index 0a1bbef..2dd8d5b 100644 --- a/src/test/HookCoveredCallIntegrationTest.t.sol +++ b/src/test/HookCoveredCallIntegrationTest.t.sol @@ -30,7 +30,7 @@ contract HookCoveredCallIntegrationTest is HookProtocolTest { weth.approve(address(calls), 50 ether); } - function test_MintOption() public { + function testMintOption() public { vm.startPrank(address(writer)); uint256 expiration = block.timestamp + 3 days; @@ -57,7 +57,7 @@ contract HookCoveredCallIntegrationTest is HookProtocolTest { vm.stopPrank(); } - function testRevert_MintOptionMustBeOwnerOrOperator() public { + function testRevertMintOptionMustBeOwnerOrOperator() public { vm.expectRevert("mintWithErc721 -- caller must be token owner or operator"); calls.mintWithErc721( address(token), @@ -67,7 +67,7 @@ contract HookCoveredCallIntegrationTest is HookProtocolTest { ); } - function testRevert_MintOptionExpirationMustBeMoreThan1DayInTheFuture() + function testRevertMintOptionExpirationMustBeMoreThan1DayInTheFuture() public { vm.startPrank(address(writer)); @@ -84,7 +84,7 @@ contract HookCoveredCallIntegrationTest is HookProtocolTest { vm.stopPrank(); } - function test_SuccessfulAuctionAndSettlement() public { + function testSuccessfulAuctionAndSettlement() public { // create the call option vm.startPrank(address(writer)); uint256 writerStartBalance = writer.balance; @@ -181,38 +181,10 @@ contract HookCoveredCallIntegrationTest is HookProtocolTest { ); } - // Test that the option was sold as per usual, but no settlement - // bid activity occurred. - function test_NoSettlemetBidAssetReclaim() public { - // create the call option - vm.startPrank(address(writer)); - uint256 baseTime = block.timestamp; - uint256 expiration = baseTime + 3 days; - uint256 optionId = calls.mintWithErc721( - address(token), - underlyingTokenId, - 1000, - expiration - ); - - // assume that the writer somehow sold to the buyer, outside the scope of this test - - calls.safeTransferFrom(writer, buyer, optionId); - - vm.warp(expiration + 50 seconds); - - calls.reclaimAsset(optionId, true); - assertTrue( - token.ownerOf(underlyingTokenId) == writer, - "the nft should have returned to the buyer" - ); - vm.stopPrank(); - } - // Test that the option was not transferred, a bid was made, // but the owner re-obtained the option and therefore can stop // the auction. - function test_NoSettlemetBidAssetEarlyReclaim() public { + function testNoSettlemetBidAssetEarlyReclaim() public { // create the call option vm.startPrank(address(writer)); uint256 baseTime = block.timestamp; @@ -236,7 +208,7 @@ contract HookCoveredCallIntegrationTest is HookProtocolTest { calls.reclaimAsset(optionId, false); } - function test_NoSettlemetBidAssetRecaimFailRandomClaimer() public { + function testNoSettlemetBidAssetRecaimFailRandomClaimer() public { // create the call option vm.startPrank(address(writer)); uint256 baseTime = block.timestamp; @@ -262,7 +234,7 @@ contract HookCoveredCallIntegrationTest is HookProtocolTest { } // test: writer must not steal asset by buying back option nft after expiration. - function test_WriterCannotStealBackAssetAfterExpiration() public { + function testWriterCannotStealBackAssetAfterExpiration() public { // create the call option vm.startPrank(address(writer)); uint256 baseTime = block.timestamp; @@ -285,47 +257,14 @@ contract HookCoveredCallIntegrationTest is HookProtocolTest { vm.prank(bidder1); calls.bid{value: 1050}(optionId); - vm.warp(expiration + 3 seconds); + vm.warp(expiration + 1 days); // The writer somehow buys back the option vm.prank(address(buyer)); calls.safeTransferFrom(buyer, writer, optionId); vm.prank(address(writer)); - vm.expectRevert("reclaimAsset -- cannot reclaim a sold asset"); - calls.reclaimAsset(optionId, true); - } - - // make sure that the writer cannot reclaim when a settlement bid is ongoing. - function test_ActiveSettlementBidAssetRecaimFail() public { - // create the call option - vm.startPrank(address(writer)); - uint256 baseTime = block.timestamp; - uint256 expiration = baseTime + 3 days; - uint256 optionId = calls.mintWithErc721( - address(token), - underlyingTokenId, - 1000, - expiration - ); - - // assume that the writer somehow sold to the buyer, outside the scope of this test - calls.safeTransferFrom(writer, buyer, optionId); - vm.stopPrank(); - - // made a bid - vm.warp(baseTime + 2.1 days); - address bidder1 = address(3456); - vm.deal(bidder1, 1100); - vm.prank(bidder1); - calls.bid{value: 1050}(optionId); - - vm.warp(expiration + 3 seconds); - - vm.prank(address(writer)); - vm.expectRevert( - "reclaimAsset -- cannot reclaim a sold asset if the option is not writer-owned." - ); + vm.expectRevert("reclaimAsset -- the option must not be expired"); calls.reclaimAsset(optionId, true); } } diff --git a/src/test/HookCoveredCallTests.t.sol b/src/test/HookCoveredCallTests.t.sol index 8d3be27..328415b 100644 --- a/src/test/HookCoveredCallTests.t.sol +++ b/src/test/HookCoveredCallTests.t.sol @@ -1223,29 +1223,28 @@ contract HookCoveredCallReclaimTests is HookProtocolTest { token.mint(address(writer), underlyingTokenId); setUpMintOption(); + + // Transfer option NFT from buyer back to the writer + // Writer needs to own the option NFT for reclaimAsset + vm.prank(address(buyer)); + calls.safeTransferFrom(buyer, writer, optionTokenId); } function testReclaimAsset() public { - // Option expires in 3 days from current block; bidding starts in 2 days. - vm.warp(block.timestamp + 3.1 days); + // Option expires in 3 days from current block + vm.warp(block.timestamp + 2.1 days); vm.prank(writer); calls.reclaimAsset(optionTokenId, false); } function testReclaimAssetReturnNft() public { - // Option expires in 3 days from current block; bidding starts in 2 days. - vm.warp(block.timestamp + 3.1 days); + // Option expires in 3 days from current block + vm.warp(block.timestamp + 2.1 days); vm.startPrank(writer); - IHookERC721Vault vault = vaultFactory.getVault( - address(token), - underlyingTokenId - ); - vm.expectCall( - address(vault), - abi.encodeWithSignature("withdrawalAsset(uint256)", 0) - ); + vm.expectEmit(true, false, false, false); + emit CallReclaimed(optionTokenId); calls.reclaimAsset(optionTokenId, true); } @@ -1271,28 +1270,37 @@ contract HookCoveredCallReclaimTests is HookProtocolTest { calls.reclaimAsset(optionTokenId, true); } - function testCannotReclaimWithActiveBid() public { - setUpOptionBids(); + function testReclaimWithActiveBid() public { + vm.warp(block.timestamp + 2.1 days); + vm.deal(address(firstBidder), 1 ether); + + vm.prank(firstBidder); + + calls.bid{value: 0.1 ether}(optionTokenId); vm.startPrank(writer); - vm.expectRevert( - "reclaimAsset -- cannot reclaim a sold asset if the option is not writer-owned." - ); + + vm.expectEmit(true, false, false, false); + emit CallReclaimed(optionTokenId); calls.reclaimAsset(optionTokenId, true); + + assertTrue( + token.ownerOf(0) == address(writer), + "writer should own the underlying asset" + ); } - function testCannotReclaimBeforeExpiration() public { + function testCannotReclaimAfterExpiration() public { vm.startPrank(writer); - vm.warp(block.timestamp + 2.1 days); + vm.warp(block.timestamp + 3.1 days); vm.expectRevert( - "reclaimAsset -- the option must expired unless writer-owned" + "reclaimAsset -- the option must not be expired" ); calls.reclaimAsset(optionTokenId, true); } function testReclaimAssetWriterBidFirst() public { - address firstBidder = address(37); vm.startPrank(writer); uint256 underlyingTokenId2 = 1; token.mint(writer, underlyingTokenId2); @@ -1324,23 +1332,14 @@ contract HookCoveredCallReclaimTests is HookProtocolTest { vm.prank(firstBidder); calls.bid{value: 2000 wei}(optionId); - vm.warp(block.timestamp + 1 days); - vm.startPrank(writer); - IHookERC721Vault vault = vaultFactory.getVault( - address(token), - underlyingTokenId - ); - vm.expectCall( - address(vault), - abi.encodeWithSignature("withdrawalAsset(uint256)", 0) - ); + vm.expectEmit(true, false, false, false); + emit CallReclaimed(optionTokenId); calls.reclaimAsset(optionTokenId, true); } function testReclaimAssetWriterBidLast() public { - address firstBidder = address(37); vm.startPrank(writer); uint256 underlyingTokenId2 = 1; token.mint(writer, underlyingTokenId2); @@ -1374,22 +1373,14 @@ contract HookCoveredCallReclaimTests is HookProtocolTest { vm.prank(writer); calls.bid{value: 2 wei}(optionId); - vm.warp(block.timestamp + 1 days); - vm.startPrank(writer); - IHookERC721Vault vault = vaultFactory.getVault( - address(token), - underlyingTokenId - ); - vm.expectCall( - address(vault), - abi.encodeWithSignature("withdrawalAsset(uint256)", 0) - ); + + vm.expectEmit(true, false, false, false); + emit CallReclaimed(optionTokenId); calls.reclaimAsset(optionTokenId, true); } function testReclaimAssetWriterBidMultiple() public { - address firstBidder = address(37); vm.startPrank(writer); uint256 underlyingTokenId2 = 1; token.mint(writer, underlyingTokenId2); @@ -1426,17 +1417,10 @@ contract HookCoveredCallReclaimTests is HookProtocolTest { vm.prank(firstBidder); calls.bid{value: 1003 wei}(optionId); - vm.warp(block.timestamp + 1 days); - vm.startPrank(writer); - IHookERC721Vault vault = vaultFactory.getVault( - address(token), - underlyingTokenId - ); - vm.expectCall( - address(vault), - abi.encodeWithSignature("withdrawalAsset(uint256)", 0) - ); + + vm.expectEmit(true, false, false, false); + emit CallReclaimed(optionTokenId); calls.reclaimAsset(optionTokenId, true); } } diff --git a/src/test/HookMultiVaultTests.t.sol b/src/test/HookMultiVaultTests.t.sol index f18db2a..a3157d9 100644 --- a/src/test/HookMultiVaultTests.t.sol +++ b/src/test/HookMultiVaultTests.t.sol @@ -479,7 +479,7 @@ contract HookMultiVaultTests is HookProtocolTest { // verify that beneficial owner cannot withdrawl // during an active entitlement. vm.expectRevert( - "withdrawalAsset -- the asset canot be withdrawn with an active entitlement" + "withdrawalAsset -- the asset cannot be withdrawn with an active entitlement" ); vm.prank(writer); vaultImpl.withdrawalAsset(tokenId); diff --git a/src/test/utils/base.t.sol b/src/test/utils/base.t.sol index 592c2c0..0658703 100644 --- a/src/test/utils/base.t.sol +++ b/src/test/utils/base.t.sol @@ -52,6 +52,12 @@ contract HookProtocolTest is Test, EIP712, PermissionConstants { uint256 expiration ); + event CallSettled(uint256 optionId); + + event CallReclaimed(uint256 optionId); + + event ExpiredCallBurned(uint256 optionId); + function setUpAddresses() public { token = new TestERC721(); weth = new WETH(); @@ -65,6 +71,12 @@ contract HookProtocolTest is Test, EIP712, PermissionConstants { admin = address(69); vm.label(admin, "contract admin"); + + firstBidder = address(37); + vm.label(firstBidder, "First option bidder"); + + secondBidder = address(38); + vm.label(secondBidder, "Second option bidder"); } function setUpFullProtocol() public { @@ -151,12 +163,8 @@ contract HookProtocolTest is Test, EIP712, PermissionConstants { } function setUpOptionBids() public { - firstBidder = address(37); - vm.label(firstBidder, "First option bidder"); vm.deal(address(firstBidder), 1 ether); - secondBidder = address(38); - vm.label(secondBidder, "Second option bidder"); vm.deal(address(secondBidder), 1 ether); vm.warp(block.timestamp + 2.1 days);