From 33fbbbdf38e59963161ff2320107f73bfd85ba40 Mon Sep 17 00:00:00 2001 From: Klemen <64400885+zajck@users.noreply.github.com> Date: Mon, 11 Dec 2023 14:44:45 +0100 Subject: [PATCH] Add missing reentrancy guard (#844) * Add missing reentrancy guard * remove duplicate tests * remove unnecessary reenctrancy protection --- .../protocol/facets/ExchangeHandlerFacet.sol | 13 +- .../facets/PriceDiscoveryHandlerFacet.sol | 2 +- test/protocol/ExchangeHandlerTest.js | 7482 ++++++++--------- 3 files changed, 3630 insertions(+), 3867 deletions(-) diff --git a/contracts/protocol/facets/ExchangeHandlerFacet.sol b/contracts/protocol/facets/ExchangeHandlerFacet.sol index c8347fe6b..4fa9a9f63 100644 --- a/contracts/protocol/facets/ExchangeHandlerFacet.sol +++ b/contracts/protocol/facets/ExchangeHandlerFacet.sol @@ -543,6 +543,10 @@ contract ExchangeHandlerFacet is DisputeBase, BuyerBase, IBosonExchangeHandler, * - Voucher has expired * - New buyer's existing account is deactivated * + * N.B. This method is not protected with reentrancy guard, since it clashes with price discovery flows. + * Given that it does not rely on msgSender() for authentication and it does not modify it, it is safe to leave it unprotected. + * In case of reentrancy the only inconvenience that could happen is that `executedBy` field in `VoucherTransferred` event would not be set correctly. + * * @param _tokenId - the voucher id * @param _newBuyer - the address of the new buyer */ @@ -605,6 +609,10 @@ contract ExchangeHandlerFacet is DisputeBase, BuyerBase, IBosonExchangeHandler, * - Offer price is discovery, transaction is not starting from protocol nor seller is _from address * - Any reason that ExchangeHandler commitToOfferInternal reverts. See ExchangeHandler.commitToOfferInternal * + * N.B. This method is not protected with reentrancy guard, since it clashes with price discovery flows. + * Given that it does not rely on msgSender() for authentication and it does not modify it, it is safe to leave it unprotected. + * In case of reentrancy the only inconvenience that could happen is that `executedBy` field in `BuyerCommitted` event would not be set correctly. + * * @param _tokenId - the voucher id * @param _to - the receiver address * @param _from - the address of current owner @@ -620,11 +628,6 @@ contract ExchangeHandlerFacet is DisputeBase, BuyerBase, IBosonExchangeHandler, // Cache protocol status for reference ProtocolLib.ProtocolStatus storage ps = protocolStatus(); - // Make sure that protocol is not reentered - // Cannot use modifier `nonReentrant` since it also changes reentrancyStatus to `ENTERED` - // This would break the flow since the protocol should be allowed to re-enter in this case. - if (ps.reentrancyStatus == ENTERED) revert ReentrancyGuard(); - // Derive the offer id uint256 offerId = _tokenId >> 128; diff --git a/contracts/protocol/facets/PriceDiscoveryHandlerFacet.sol b/contracts/protocol/facets/PriceDiscoveryHandlerFacet.sol index 8221b0180..a248e79d6 100644 --- a/contracts/protocol/facets/PriceDiscoveryHandlerFacet.sol +++ b/contracts/protocol/facets/PriceDiscoveryHandlerFacet.sol @@ -75,7 +75,7 @@ contract PriceDiscoveryHandlerFacet is IBosonPriceDiscoveryHandler, PriceDiscove address payable _buyer, uint256 _tokenIdOrOfferId, PriceDiscovery calldata _priceDiscovery - ) external payable override exchangesNotPaused buyersNotPaused { + ) external payable override exchangesNotPaused buyersNotPaused nonReentrant { // Make sure buyer address is not zero address if (_buyer == address(0)) revert InvalidAddress(); diff --git a/test/protocol/ExchangeHandlerTest.js b/test/protocol/ExchangeHandlerTest.js index ba431758c..613a053e6 100644 --- a/test/protocol/ExchangeHandlerTest.js +++ b/test/protocol/ExchangeHandlerTest.js @@ -910,217 +910,139 @@ describe("IBosonExchangeHandler", function () { }); }); - context("👉 onPremintedVoucherTransferred()", async function () { - // These tests are mainly for preminted vouchers of fixed price offers - // The part of onPremintedVoucherTransferred that is specific to - // price discovery offers is indirectly tested in `PriceDiscoveryHandlerFacet.js` - let tokenId; - beforeEach(async function () { - // Reserve range - await offerHandler - .connect(assistant) - .reserveRange(offer.id, offer.quantityAvailable, await assistant.getAddress()); - - // expected address of the first clone - const voucherCloneAddress = calculateCloneAddress( - await accountHandler.getAddress(), - beaconProxyAddress, - admin.address - ); - bosonVoucher = await getContractAt("BosonVoucher", voucherCloneAddress); - await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); - - tokenId = deriveTokenId(offer.id, exchangeId); - }); - - it("should emit a BuyerCommitted event", async function () { - // Commit to preminted offer, retrieving the event - tx = await bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - txReceipt = await tx.wait(); - event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); - - // Get the block timestamp of the confirmed tx - blockNumber = tx.blockNumber; - block = await provider.getBlock(blockNumber); - - // Update the committed date in the expected exchange struct with the block timestamp of the tx - voucher.committedDate = block.timestamp.toString(); - - // Update the validUntilDate date in the expected exchange struct - voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); - - // Examine event - assert.equal(event.exchangeId.toString(), exchangeId, "Exchange id is incorrect"); - assert.equal(event.offerId.toString(), offerId, "Offer id is incorrect"); - assert.equal(event.buyerId.toString(), buyerId, "Buyer id is incorrect"); - - // Examine the exchange struct - assert.equal( - Exchange.fromStruct(event.exchange).toString(), - exchange.toString(), - "Exchange struct is incorrect" - ); - - // Examine the voucher struct - assert.equal(Voucher.fromStruct(event.voucher).toString(), voucher.toString(), "Voucher struct is incorrect"); - }); - - it("should not increment the next exchange id counter", async function () { - // Get the next exchange id - let nextExchangeIdBefore = await exchangeHandler.connect(rando).getNextExchangeId(); - - // Commit to preminted offer, creating a new exchange - await bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - - // Get the next exchange id and ensure it was incremented by the creation of the offer - nextExchangeId = await exchangeHandler.connect(rando).getNextExchangeId(); - expect(nextExchangeId).to.equal(nextExchangeIdBefore); - }); + context("👉 commitToConditionalOffer()", async function () { + context("✋ Threshold ERC20", async function () { + beforeEach(async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; - it("should not issue a new voucher on the clone", async function () { - // Get next exchange id - nextExchangeId = await exchangeHandler.connect(rando).getNextExchangeId(); + // Create Condition + condition = mockCondition({ tokenAddress: await foreign20.getAddress(), threshold: "50", maxCommits: "3" }); + expect(condition.isValid()).to.be.true; - // Voucher with nextExchangeId should not exist - await expect(bosonVoucher.ownerOf(nextExchangeId)).to.be.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; + await groupHandler.connect(assistant).createGroup(group, condition); + }); - // Commit to preminted offer, creating a new exchange - await bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + it("should emit BuyerCommitted and ConditionalCommitAuthorized events if user meets condition", async function () { + // mint enough tokens for the buyer + await foreign20.connect(buyer).mint(await buyer.getAddress(), condition.threshold); - // Voucher with nextExchangeId still should not exist - await expect(bosonVoucher.ownerOf(nextExchangeId)).to.be.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); - }); + // Commit to offer. + // We're only concerned that the event is emitted, indicating the condition was met + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - it("ERC2981: issued voucher should have royalty fees", async function () { - // Before voucher is transferred, it should already have royalty fee - let [receiver, royaltyAmount] = await bosonVoucher.connect(assistant).royaltyInfo(tokenId, offer.price); - assert.equal(receiver, treasury.address, "Recipient address is incorrect"); - assert.equal( - royaltyAmount.toString(), - applyPercentage(offer.price, royaltyPercentage1), - "Royalty amount is incorrect" - ); + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, 0, 1, condition.maxCommits); + }); - // Commit to preminted offer, creating a new exchange - await bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + it("should allow buyer to commit up to the max times for the group", async function () { + // mint enough tokens for the buyer + await foreign20.connect(buyer).mint(await buyer.getAddress(), condition.threshold); - // After voucher is transferred, it should have royalty fee - [receiver, royaltyAmount] = await bosonVoucher.connect(assistant).royaltyInfo(tokenId, offer.price); - assert.equal(receiver, await treasury.getAddress(), "Recipient address is incorrect"); - assert.equal( - royaltyAmount.toString(), - applyPercentage(offer.price, royaltyPercentage1), - "Royalty amount is incorrect" - ); - }); + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + // We're only concerned that the event is emitted, indicating the commit was allowed + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - it("Should not decrement quantityAvailable", async function () { - // Offer quantityAvailable should be decremented - let [, offer] = await offerHandler.connect(rando).getOffer(offerId); - const quantityAvailableBefore = offer.quantityAvailable; + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, 0, i + 1, condition.maxCommits); + } + }); - // Commit to preminted offer - await bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + context("💔 Revert Reasons", async function () { + it("buyer does not meet condition for commit", async function () { + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); + }); - // Offer quantityAvailable should be decremented - [, offer] = await offerHandler.connect(rando).getOffer(offerId); - assert.equal( - offer.quantityAvailable.toString(), - quantityAvailableBefore.toString(), - "Quantity available should not change" - ); - }); + it("buyer has exhausted allowable commits", async function () { + // mint a token for the buyer + await foreign20.connect(buyer).mint(await buyer.getAddress(), condition.threshold); - it("should still be possible to commit if offer is not fully preminted", async function () { - // Create a new offer - offerId = await offerHandler.getNextOfferId(); - const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + await exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); + } - // Create the offer - offer.quantityAvailable = "10"; - const rangeLength = "5"; - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Attempt to commit again after maximum commits has been reached + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); + }); - // Deposit seller funds so the commit will succeed - await fundsHandler - .connect(rando) - .depositFunds(seller.id, ZeroAddress, offer.sellerDeposit, { value: offer.sellerDeposit }); + it("Group doesn't exist", async function () { + // Create a new offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - // reserve half of the offer, so it's still possible to commit directly - await offerHandler.connect(assistant).reserveRange(offerId, rangeLength, await assistant.getAddress()); + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), ++offerId, 0, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_GROUP); + }); - // Commit to offer directly - await expect( - exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: offer.price }) - ).to.emit(exchangeHandler, "BuyerCommitted"); + it("Caller sends non-zero tokenId", async function () {}); + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 1, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_TOKEN_ID); + }); }); - context("Offer is part of a group", async function () { - let groupId; - let offerIds; - + context("✋ Threshold ERC721", async function () { beforeEach(async function () { // Required constructor params for Group groupId = "1"; offerIds = [offerId]; - }); - it("Offer is part of a group that has no condition", async function () { + // Create Condition condition = mockCondition({ - tokenAddress: ZeroAddress, - threshold: "0", - maxCommits: "0", - tokenType: TokenType.FungibleToken, - method: EvaluationMethod.None, + method: EvaluationMethod.Threshold, + tokenAddress: await foreign721.getAddress(), + threshold: "5", + maxCommits: "3", + tokenType: TokenType.NonFungibleToken, }); - expect(condition.isValid()).to.be.true; + // Create Group group = new Group(groupId, seller.id, offerIds); expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); - - await foreign721.connect(buyer).mint("123", 1); - - const tx = bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - - await expect(tx).to.not.emit(exchangeHandler, "ConditionalCommitAuthorized"); }); - it("Offer is part of a group with condition [ERC20, gating per address]", async function () { - // Create Condition - condition = mockCondition({ tokenAddress: await foreign20.getAddress(), threshold: "50", maxCommits: "3" }); - expect(condition.isValid()).to.be.true; - - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; - - await groupHandler.connect(assistant).createGroup(group, condition); - + it("should emit BuyerCommitted and ConditionalCommitAuthorized events if user meets condition", async function () { // mint enough tokens for the buyer - await foreign20.connect(buyer).mint(await buyer.getAddress(), condition.threshold); + await foreign721.connect(buyer).mint(condition.minTokenId, condition.threshold); - const tx = bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + // Commit to offer. + // We're only concerned that the event is emitted, indicating the condition was met + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); await expect(tx) @@ -1128,150 +1050,335 @@ describe("IBosonExchangeHandler", function () { .withArgs(offerId, condition.gating, buyer.address, 0, 1, condition.maxCommits); }); - it("Offer is part of a group with condition [ERC721, threshold, gating per address]", async function () { - condition = mockCondition({ - tokenAddress: await foreign721.getAddress(), - threshold: "1", - maxCommits: "3", - tokenType: TokenType.NonFungibleToken, - method: EvaluationMethod.Threshold, - }); + it("should allow buyer to commit up to the max times for the group", async function () { + // mint enough tokens for the buyer + await foreign721.connect(buyer).mint(condition.minTokenId, condition.threshold); - expect(condition.isValid()).to.be.true; + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + // We're only concerned that the event is emitted, indicating the commit was allowed + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, 0, i + 1, condition.maxCommits); + } + }); - await groupHandler.connect(assistant).createGroup(group, condition); + context("💔 Revert Reasons", async function () { + it("buyer does not meet condition for commit", async function () { + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); + }); - await foreign721.connect(buyer).mint("123", 1); + it("buyer has exhausted allowable commits", async function () { + // mint enough tokens for the buyer + await foreign721.connect(buyer).mint(condition.minTokenId, condition.threshold); - const tx = bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + await exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); + } - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + // Attempt to commit again after maximum commits has been reached + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); + }); - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, condition.minTokenId, 1, condition.maxCommits); + it("Caller sends non-zero tokenId", async function () { + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, 1, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_TOKEN_ID); + }); }); + }); - it("Offer is part of a group with condition [ERC721, specificToken, gating per address] with range length == 1", async function () { + context("✋ SpecificToken ERC721 per address", async function () { + let tokenId; + beforeEach(async function () { // Required constructor params for Group groupId = "1"; offerIds = [offerId]; + tokenId = "12"; + // Create Condition condition = mockCondition({ tokenAddress: await foreign721.getAddress(), threshold: "0", maxCommits: "3", tokenType: TokenType.NonFungibleToken, + minTokenId: tokenId, method: EvaluationMethod.SpecificToken, + maxTokenId: "22", gating: GatingType.PerAddress, }); - expect(condition.isValid()).to.be.true; // Create Group group = new Group(groupId, seller.id, offerIds); expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); - // mint enough tokens for the buyer - await foreign721.connect(buyer).mint(condition.minTokenId, 1); - - const tx = bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + // mint correct token for the buyer + await foreign721.connect(buyer).mint(tokenId, "1"); + }); + it("should emit BuyerCommitted and ConditionalCommitAuthorized event if user meets condition", async function () { + // Commit to offer. + // We're only concerned that the event is emitted, indicating the condition was met + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); await expect(tx) .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, condition.minTokenId, 1, condition.maxCommits); + .withArgs(offerId, condition.gating, buyer.address, tokenId, 1, condition.maxCommits); }); - it("Offer is part of a group with condition [ERC721, specificToken, gating per tokenid] with range length == 1", async function () { + it("should allow buyer to commit up to the max times for the group", async function () { + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + // We're only concerned that the event is emitted, indicating the commit was allowed + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, tokenId, i + 1, condition.maxCommits); + } + }); + + it("Allow any token from collection", async function () { + condition.minTokenId = "0"; + condition.maxTokenId = MaxUint256.toString(); + + await groupHandler.connect(assistant).setGroupCondition(group.id, condition); + + // mint any token for buyer + tokenId = "123"; + await foreign721.connect(buyer).mint(tokenId, "1"); + + // buyer can commit + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.emit(exchangeHandler, "BuyerCommitted"); + }); + + context("💔 Revert Reasons", async function () { + it("token id does not exist", async function () { + tokenId = "13"; + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); + }); + + it("buyer does not meet condition for commit", async function () { + // Send token to another user + await foreign721.connect(buyer).transferFrom(await buyer.getAddress(), rando.address, tokenId); + + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); + }); + + it("max commits per token id reached", async function () { + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + await exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); + } + + // Attempt to commit again after maximum commits has been reached + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); + }); + + it("token id not in condition range", async function () { + tokenId = "666"; + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.TOKEN_ID_NOT_IN_CONDITION_RANGE); + }); + }); + }); + + context("✋ SpecificToken ERC721 per token id", async function () { + let tokenId; + beforeEach(async function () { // Required constructor params for Group groupId = "1"; offerIds = [offerId]; + tokenId = "12"; + // Create Condition condition = mockCondition({ tokenAddress: await foreign721.getAddress(), threshold: "0", maxCommits: "3", tokenType: TokenType.NonFungibleToken, + minTokenId: tokenId, method: EvaluationMethod.SpecificToken, + maxTokenId: "22", gating: GatingType.PerTokenId, }); - expect(condition.isValid()).to.be.true; // Create Group group = new Group(groupId, seller.id, offerIds); expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); - // mint enough tokens for the buyer - await foreign721.connect(buyer).mint(condition.minTokenId, 1); + // mint correct token for the buyer + await foreign721.connect(buyer).mint(tokenId, "1"); + }); - const tx = bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + it("should emit BuyerCommitted and ConditionalCommitAuthorized event if user meets condition", async function () { + // Commit to offer. + // We're only concerned that the event is emitted, indicating the condition was met + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); await expect(tx) .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, condition.minTokenId, 1, condition.maxCommits); + .withArgs(offerId, condition.gating, buyer.address, tokenId, 1, condition.maxCommits); }); - it("Offer is part of a group with condition [ERC1155, gating per address] with range length == 1", async function () { - condition = mockCondition({ - tokenAddress: await foreign1155.getAddress(), - threshold: "2", - maxCommits: "3", - tokenType: TokenType.MultiToken, - method: EvaluationMethod.Threshold, - minTokenId: "123", - maxTokenId: "123", - gating: GatingType.PerAddress, - }); + it("should allow buyer to commit up to the max times for the group", async function () { + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + // We're only concerned that the event is emitted, indicating the commit was allowed + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - expect(condition.isValid()).to.be.true; + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, tokenId, i + 1, condition.maxCommits); + } + }); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; + it("Allow any token from collection", async function () { + condition.minTokenId = "0"; + condition.maxTokenId = MaxUint256.toString(); - await groupHandler.connect(assistant).createGroup(group, condition); + await groupHandler.connect(assistant).setGroupCondition(group.id, condition); - await foreign1155.connect(buyer).mint(condition.minTokenId, condition.threshold); + // mint any token for buyer + tokenId = "123"; + await foreign721.connect(buyer).mint(tokenId, "1"); - const tx = bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + // buyer can commit + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.emit(exchangeHandler, "BuyerCommitted"); + }); - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, condition.minTokenId, 1, condition.maxCommits); + context("💔 Revert Reasons", async function () { + it("token id does not exist", async function () { + tokenId = "13"; + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); + }); + + it("buyer does not meet condition for commit", async function () { + // Send token to another user + await foreign721.connect(buyer).transferFrom(await buyer.getAddress(), rando.address, tokenId); + + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); + }); + + it("max commits per token id reached", async function () { + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + await exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); + } + + // Attempt to commit again after maximum commits has been reached + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); + }); + + it("token id not in condition range", async function () { + tokenId = "666"; + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.TOKEN_ID_NOT_IN_CONDITION_RANGE); + }); }); + }); - it("Offer is part of a group with condition [ERC1155, gating per tokenId] with range length == 1", async function () { + context("✋ Threshold ERC1155 per address", async function () { + let tokenId; + beforeEach(async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; + + // Create Condition condition = mockCondition({ tokenAddress: await foreign1155.getAddress(), - threshold: "2", + threshold: "20", maxCommits: "3", tokenType: TokenType.MultiToken, method: EvaluationMethod.Threshold, minTokenId: "123", - maxTokenId: "123", - gating: GatingType.PerTokenId, + maxTokenId: "128", + gating: GatingType.PerAddress, }); expect(condition.isValid()).to.be.true; @@ -1279,147 +1386,272 @@ describe("IBosonExchangeHandler", function () { // Create Group group = new Group(groupId, seller.id, offerIds); expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); - await foreign1155.connect(buyer).mint(condition.minTokenId, condition.threshold); + // Set random token id + tokenId = "123"; + }); - const tx = bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + it("should emit BuyerCommitted and ConditionalCommitAuthorized events if user meets condition", async function () { + // mint enough tokens for the buyer + await foreign1155.connect(buyer).mint(tokenId, condition.threshold); + + // Commit to offer. + // We're only concerned that the event is emitted, indicating the condition was met + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); await expect(tx) .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, condition.minTokenId, 1, condition.maxCommits); + .withArgs(offerId, condition.gating, buyer.address, tokenId, 1, condition.maxCommits); }); - }); - - it("should work on an additional collection", async function () { - // Create a new collection - const externalId = `Brand1`; - voucherInitValues.collectionSalt = encodeBytes32String(externalId); - await accountHandler.connect(assistant).createNewCollection(externalId, voucherInitValues); - offer.collectionIndex = 1; - offer.id = await offerHandler.getNextOfferId(); - exchangeId = await exchangeHandler.getNextExchangeId(); - exchange.offerId = offer.id.toString(); - exchange.id = exchangeId.toString(); - const tokenId = deriveTokenId(offer.id, exchangeId); + it("should allow buyer to commit up to the max times for the group", async function () { + // mint enough tokens for the buyer + await foreign1155.connect(buyer).mint(tokenId, condition.threshold); - // Create the offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + // We're only concerned that the event is emitted, indicating the commit was allowed + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - // Reserve range - await offerHandler.connect(assistant).reserveRange(offer.id, offer.quantityAvailable, assistant.address); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - // expected address of the additional collection - const voucherCloneAddress = calculateCloneAddress( - await accountHandler.getAddress(), - beaconProxyAddress, - admin.address, - voucherInitValues.collectionSalt - ); - bosonVoucher = await getContractAt("BosonVoucher", voucherCloneAddress); - await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, tokenId, i + 1, condition.maxCommits); + } + }); - // Commit to preminted offer, retrieving the event - tx = await bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId); - txReceipt = await tx.wait(); - event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); + context("💔 Revert Reasons", async function () { + it("buyer does not meet condition for commit", async function () { + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); + }); - // Get the block timestamp of the confirmed tx - blockNumber = tx.blockNumber; - block = await provider.getBlock(blockNumber); + it("buyer has exhausted allowable commits", async function () { + // mint enough tokens for the buyer + await foreign1155.connect(buyer).mint(tokenId, condition.threshold); - // Update the committed date in the expected exchange struct with the block timestamp of the tx - voucher.committedDate = block.timestamp.toString(); + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + await exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); + } - // Update the validUntilDate date in the expected exchange struct - voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); + // Attempt to commit again after maximum commits has been reached + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); + }); + }); + }); - // Examine event - assert.equal(event.exchangeId.toString(), exchangeId, "Exchange id is incorrect"); - assert.equal(event.offerId.toString(), offer.id, "Offer id is incorrect"); - assert.equal(event.buyerId.toString(), buyerId, "Buyer id is incorrect"); + context("✋ Threshold ERC1155 per token id", async function () { + let tokenId; + beforeEach(async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; + tokenId = "12"; - // Examine the exchange struct - assert.equal( - Exchange.fromStruct(event.exchange).toString(), - exchange.toString(), - "Exchange struct is incorrect" - ); + // Create Condition + condition = mockCondition({ + tokenAddress: await foreign1155.getAddress(), + threshold: "1", + maxCommits: "3", + tokenType: TokenType.MultiToken, + minTokenId: tokenId, + method: EvaluationMethod.Threshold, + maxTokenId: "22", + }); - // Examine the voucher struct - assert.equal(Voucher.fromStruct(event.voucher).toString(), voucher.toString(), "Voucher struct is incorrect"); - }); + expect(condition.isValid()).to.be.true; - context("💔 Revert Reasons", async function () { - it("The exchanges region of protocol is paused", async function () { - // Pause the exchanges region of the protocol - await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; + await groupHandler.connect(assistant).createGroup(group, condition); - // Attempt to create an exchange, expecting revert - await expect( - bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); + // mint correct token for the buyer + await foreign1155.connect(buyer).mint(tokenId, "1"); }); - it("The buyers region of protocol is paused", async function () { - // Pause the buyers region of the protocol - await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); + it("should emit BuyerCommitted and ConditionalCommitAuthorized events if user meets condition", async function () { + // Commit to offer. + // We're only concerned that the event is emitted, indicating the condition was met + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - // Attempt to create a buyer, expecting revert - await expect( - bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, tokenId, 1, condition.maxCommits); }); - it("Caller is not the voucher contract, owned by the seller", async function () { - // Attempt to commit to preminted offer, expecting revert - await expect( + it("should allow buyer to commit up to the max times for the group", async function () { + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + // We're only concerned that the event is emitted, indicating the commit was allowed + const tx = exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, tokenId, i + 1, condition.maxCommits); + } + }); + + it("Allow any token from collection", async function () { + condition.minTokenId = "0"; + condition.maxTokenId = MaxUint256.toString(); + + await groupHandler.connect(assistant).setGroupCondition(group.id, condition); + + // mint any token for buyer + tokenId = "123"; + await foreign1155.connect(buyer).mint(tokenId, "1"); + + // buyer can commit + await expect( exchangeHandler - .connect(rando) - .onPremintedVoucherTransferred( - tokenId, - await buyer.getAddress(), - await assistant.getAddress(), - await assistant.getAddress() - ) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.emit(exchangeHandler, "BuyerCommitted"); }); - it("Exchange exists already", async function () { - // Commit to preminted offer, creating a new exchange - await bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + context("💔 Revert Reasons", async function () { + it("token id does not exist", async function () { + tokenId = "13"; - // impersonate voucher contract and give it some funds - const impersonatedBosonVoucher = await getImpersonatedSigner(await bosonVoucher.getAddress()); - await provider.send("hardhat_setBalance", [ - await impersonatedBosonVoucher.getAddress(), - toBeHex(parseEther("10")), - ]); + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); + }); - // Simulate a second commit with the same token id + it("buyer does not meet condition for commit", async function () { + // Attempt to commit, expecting revert + await expect( + exchangeHandler.connect(rando).commitToConditionalOffer(rando.address, offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); + }); + + it("max commits per token id reached", async function () { + // Commit to offer the maximum number of times + for (let i = 0; i < Number(condition.maxCommits); i++) { + await exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); + } + + // Attempt to commit again after maximum commits has been reached + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); + }); + + it("token id not in condition range", async function () { + tokenId = "666"; + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.TOKEN_ID_NOT_IN_CONDITION_RANGE); + }); + }); + }); + + context("💔 Revert Reasons", async function () { + let tokenId; + + beforeEach(async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; + tokenId = "12"; + + // Create Condition + condition = mockCondition({ + tokenAddress: await foreign721.getAddress(), + threshold: "0", + maxCommits: "3", + tokenType: TokenType.NonFungibleToken, + minTokenId: tokenId, + method: EvaluationMethod.SpecificToken, + maxTokenId: "22", + }); + expect(condition.isValid()).to.be.true; + + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; + await groupHandler.connect(assistant).createGroup(group, condition); + + // mint correct token for the buyer + await foreign721.connect(buyer).mint(tokenId, "1"); + }); + + it("The exchanges region of protocol is paused", async function () { + // Pause the exchanges region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + + // Attempt to create an exchange, expecting revert await expect( exchangeHandler - .connect(impersonatedBosonVoucher) - .onPremintedVoucherTransferred( - tokenId, - await buyer.getAddress(), - await assistant.getAddress(), - await assistant.getAddress() - ) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.EXCHANGE_ALREADY_EXISTS); + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); + }); + + it("The buyers region of protocol is paused", async function () { + // Pause the buyers region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); + + // Attempt to create a buyer, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); + }); + + it("await buyer.getAddress() is the zero address", async function () { + // Attempt to commit, expecting revert + await expect( + exchangeHandler.connect(buyer).commitToConditionalOffer(ZeroAddress, offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_ADDRESS); + }); + + it("offer id is invalid", async function () { + // An invalid offer id + offerId = "666"; + + // Attempt to commit, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_OFFER); }); it("offer is voided", async function () { @@ -1428,40 +1660,34 @@ describe("IBosonExchangeHandler", function () { // Attempt to commit to the voided offer, expecting revert await expect( - bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_HAS_BEEN_VOIDED); }); it("offer is not yet available for commits", async function () { // Create an offer with staring date in the future // get current block timestamp - const block = await provider.getBlock("latest"); + const block = await ethers.provider.getBlock("latest"); const now = block.timestamp.toString(); - // Get next offer id - offerId = await offerHandler.getNextOfferId(); // set validFrom date in the past - offerDates.validFrom = BigInt(now + oneMonth * 6n).toString(); // 6 months in the future - offerDates.validUntil = BigInt(offerDates.validFrom + 10n).toString(); // just after the valid from so it succeeds. + offerDates.validFrom = (BigInt(now) + BigInt(oneMonth) * 6n).toString(); // 6 months in the future + offerDates.validUntil = (BigInt(offerDates.validFrom) + 10n).toString(); // just after the valid from so it succeeds. await offerHandler .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - // Reserve a range and premint vouchers - exchangeId = await exchangeHandler.getNextExchangeId(); - await offerHandler.connect(assistant).reserveRange(offerId, "1", await assistant.getAddress()); - await bosonVoucher.connect(assistant).preMint(offerId, "1"); - - tokenId = deriveTokenId(offerId, exchangeId); + // add offer to group + await groupHandler.connect(assistant).addOffersToGroup(groupId, [++offerId]); - // Attempt to commit to the not available offer, expecting revert + // Attempt to commit to the not availabe offer, expecting revert await expect( - bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_NOT_AVAILABLE); }); @@ -1471,1129 +1697,631 @@ describe("IBosonExchangeHandler", function () { // Attempt to commit to the expired offer, expecting revert await expect( - bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_HAS_EXPIRED); }); - it("should not be able to commit directly if whole offer preminted", async function () { + it("offer sold", async function () { // Create an offer with only 1 item offer.quantityAvailable = "1"; await offerHandler .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + + // add offer to group + await groupHandler.connect(assistant).addOffersToGroup(groupId, [++offerId]); + // Commit to offer, so it's not available anymore - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), ++offerId, { value: price }); + await exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); // Attempt to commit to the sold out offer, expecting revert await expect( - exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }) + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_SOLD_OUT); }); - it("buyer does not meet condition for commit", async function () { + it("Group without condition", async function () { + let tokenId = "0"; + + // Create a new offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Required constructor params for Group groupId = "1"; - offerIds = [offerId]; - - condition = mockCondition({ - tokenAddress: await foreign721.getAddress(), - threshold: "1", - maxCommits: "3", - tokenType: TokenType.NonFungibleToken, - tokenId: "0", - method: EvaluationMethod.Threshold, - }); + offerIds = [(++offerId).toString()]; + // Create Condition + condition = mockCondition({ method: EvaluationMethod.None, threshold: "0", maxCommits: "0" }); expect(condition.isValid()).to.be.true; // Create Group group = new Group(groupId, seller.id, offerIds); expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); + // Commit to offer. await expect( - bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); + exchangeHandler + .connect(buyer) + .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.GROUP_HAS_NO_CONDITION); }); + }); + }); - it("Offer is part of a group with condition [ERC721, specificToken, gating per address] with length > 1", async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; + context("👉 completeExchange()", async function () { + beforeEach(async function () { + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + }); - condition = mockCondition({ - tokenAddress: await foreign721.getAddress(), - threshold: "0", - maxCommits: "3", - tokenType: TokenType.NonFungibleToken, // ERC721 - minTokenId: "0", - method: EvaluationMethod.SpecificToken, // per-token - maxTokenId: "12", - gating: GatingType.PerAddress, - }); + it("should emit an ExchangeCompleted event when buyer calls", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - expect(condition.isValid()).to.be.true; + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; + // Complete the exchange, expecting event + await expect(exchangeHandler.connect(buyer).completeExchange(exchange.id)) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchange.id, await buyer.getAddress()); + }); - await groupHandler.connect(assistant).createGroup(group, condition); + it("should update state", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - await expect( - bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - it("Offer is part of a group with condition [ERC721, specificToken, gating per tokenId] with length > 1", async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; + // Complete the exchange + await exchangeHandler.connect(buyer).completeExchange(exchange.id); - condition = mockCondition({ - tokenAddress: await foreign721.getAddress(), - threshold: "0", - maxCommits: "3", - tokenType: TokenType.NonFungibleToken, // ERC721 - minTokenId: "0", - method: EvaluationMethod.SpecificToken, // per-token - maxTokenId: "12", - gating: GatingType.PerTokenId, - }); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - expect(condition.isValid()).to.be.true; + // It should match ExchangeState.Completed + assert.equal(response, ExchangeState.Completed, "Exchange state is incorrect"); + }); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; + it("should emit an ExchangeCompleted event if assistant calls after dispute period", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - await groupHandler.connect(assistant).createGroup(group, condition); + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - await expect( - bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); + // Get the current block info + blockNumber = await provider.getBlockNumber(); + block = await provider.getBlock(blockNumber); - it("Offer is part of a group with condition [ERC1155, gating per address] with length > 1", async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; + // Set time forward to run out the dispute period + newTime = Number(BigInt(block.timestamp) + BigInt(disputePeriod) + 1n); + await setNextBlockTimestamp(newTime); - condition = mockCondition({ - tokenAddress: await foreign1155.getAddress(), - threshold: "2", - maxCommits: "3", - tokenType: TokenType.MultiToken, // ERC1155 - tokenId: "1", - method: EvaluationMethod.Threshold, // per-wallet - length: "2", - gating: GatingType.PerAddress, - }); + // Complete exchange + await expect(exchangeHandler.connect(assistant).completeExchange(exchange.id)) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchange.id, await assistant.getAddress()); + }); - expect(condition.isValid()).to.be.true; + it("should emit an ExchangeCompleted event if anyone calls after dispute period", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - await groupHandler.connect(assistant).createGroup(group, condition); + // Get the current block info + blockNumber = await provider.getBlockNumber(); + block = await provider.getBlock(blockNumber); - await expect( - bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); + // Set time forward to run out the dispute period + newTime = Number(BigInt(block.timestamp) + BigInt(disputePeriod) + 1n); + await setNextBlockTimestamp(newTime); - it("Offer is part of a group with condition [ERC1155, gating per tokenId] with length > 1", async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; + // Complete exchange + await expect(exchangeHandler.connect(rando).completeExchange(exchange.id)) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchange.id, await rando.getAddress()); + }); - condition = mockCondition({ - tokenAddress: await foreign1155.getAddress(), - threshold: "2", - maxCommits: "3", - tokenType: TokenType.MultiToken, // ERC1155 - tokenId: "1", - method: EvaluationMethod.Threshold, // per-wallet - length: "2", - gating: GatingType.PerTokenId, - }); + it("should emit an ExchangeCompleted event if another buyer calls after dispute period", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - expect(condition.isValid()).to.be.true; + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; + // Get the current block info + blockNumber = await provider.getBlockNumber(); + block = await provider.getBlock(blockNumber); - await groupHandler.connect(assistant).createGroup(group, condition); + // Set time forward to run out the dispute period + newTime = Number(BigInt(block.timestamp) + BigInt(disputePeriod) + 1n); + await setNextBlockTimestamp(newTime); - await expect( - bosonVoucher - .connect(assistant) - .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); + // Create a rando buyer account + await accountHandler.connect(rando).createBuyer(mockBuyer(await rando.getAddress())); + + // Complete exchange + await expect(exchangeHandler.connect(rando).completeExchange(exchange.id)) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchange.id, await rando.getAddress()); }); - }); - context("👉 commitToConditionalOffer()", async function () { - context("✋ Threshold ERC20", async function () { - beforeEach(async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; + context("💔 Revert Reasons", async function () { + it("The exchanges region of protocol is paused", async function () { + // Pause the exchanges region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); - // Create Condition - condition = mockCondition({ tokenAddress: await foreign20.getAddress(), threshold: "50", maxCommits: "3" }); - expect(condition.isValid()).to.be.true; + // Attempt to complete an exchange, expecting revert + await expect(exchangeHandler.connect(assistant).completeExchange(exchangeId)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.REGION_PAUSED + ); + }); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); + it("exchange id is invalid", async function () { + // An invalid exchange id + exchangeId = "666"; + + // Attempt to complete the exchange, expecting revert + await expect(exchangeHandler.connect(assistant).completeExchange(exchangeId)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.NO_SUCH_EXCHANGE + ); }); - it("should emit BuyerCommitted and ConditionalCommitAuthorized events if user meets condition", async function () { - // mint enough tokens for the buyer - await foreign20.connect(buyer).mint(await buyer.getAddress(), condition.threshold); + it("cannot complete an exchange when it is in the committed state", async function () { + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // Commit to offer. - // We're only concerned that the event is emitted, indicating the condition was met - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + // It should match ExchangeState.Committed + assert.equal(response, ExchangeState.Committed, "Exchange state is incorrect"); - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, 0, 1, condition.maxCommits); + // Attempt to complete the exchange, expecting revert + await expect(exchangeHandler.connect(assistant).completeExchange(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.INVALID_STATE + ); }); - it("should allow buyer to commit up to the max times for the group", async function () { - // mint enough tokens for the buyer - await foreign20.connect(buyer).mint(await buyer.getAddress(), condition.threshold); - - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - // We're only concerned that the event is emitted, indicating the commit was allowed - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + it("exchange is not in redeemed state", async function () { + // Cancel the voucher + await exchangeHandler.connect(buyer).cancelVoucher(exchange.id); - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, 0, i + 1, condition.maxCommits); - } + // Attempt to complete the exchange, expecting revert + await expect(exchangeHandler.connect(assistant).completeExchange(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.INVALID_STATE + ); }); - context("💔 Revert Reasons", async function () { - it("buyer does not meet condition for commit", async function () { - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); + it("caller is not buyer and offer dispute period has not elapsed", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - it("buyer has exhausted allowable commits", async function () { - // mint a token for the buyer - await foreign20.connect(buyer).mint(await buyer.getAddress(), condition.threshold); + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - await exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); - } + // Attempt to complete the exchange, expecting revert + await expect(exchangeHandler.connect(rando).completeExchange(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.DISPUTE_PERIOD_NOT_ELAPSED + ); + }); - // Attempt to commit again after maximum commits has been reached - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); - }); + it("caller is a buyer, but not the buyer of the exchange and offer dispute period has not elapsed", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - it("Group doesn't exist", async function () { - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), ++offerId, 0, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_GROUP); - }); + // Create a rando buyer account + await accountHandler.connect(rando).createBuyer(mockBuyer(await rando.getAddress())); - it("Caller sends non-zero tokenId", async function () {}); - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 1, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_TOKEN_ID); + // Attempt to complete the exchange, expecting revert + await expect(exchangeHandler.connect(rando).completeExchange(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.DISPUTE_PERIOD_NOT_ELAPSED + ); }); - }); - context("✋ Threshold ERC721", async function () { - beforeEach(async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; + it("caller is seller's assistant and offer dispute period has not elapsed", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Create Condition - condition = mockCondition({ - method: EvaluationMethod.Threshold, - tokenAddress: await foreign721.getAddress(), - threshold: "5", - maxCommits: "3", - tokenType: TokenType.NonFungibleToken, - }); - expect(condition.isValid()).to.be.true; + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); + // Attempt to complete the exchange, expecting revert + await expect(exchangeHandler.connect(assistant).completeExchange(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.DISPUTE_PERIOD_NOT_ELAPSED + ); }); + }); + }); - it("should emit BuyerCommitted and ConditionalCommitAuthorized events if user meets condition", async function () { - // mint enough tokens for the buyer - await foreign721.connect(buyer).mint(condition.minTokenId, condition.threshold); + context("👉 completeExchangeBatch()", async function () { + beforeEach(async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Commit to offer. - // We're only concerned that the event is emitted, indicating the condition was met - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + for (exchangeId = 1; exchangeId <= 5; exchangeId++) { + // Commit to offer, creating a new exchange + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, 0, 1, condition.maxCommits); - }); + // Redeem voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + } - it("should allow buyer to commit up to the max times for the group", async function () { - // mint enough tokens for the buyer - await foreign721.connect(buyer).mint(condition.minTokenId, condition.threshold); + exchangesToComplete = ["1", "2", "3", "4", "5"]; + }); - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - // We're only concerned that the event is emitted, indicating the commit was allowed - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + it("should emit a ExchangeCompleted event for all events", async function () { + // Complete the exchange, expecting event + const tx = await exchangeHandler.connect(buyer).completeExchangeBatch(exchangesToComplete); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[0], await buyer.getAddress()); - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, 0, i + 1, condition.maxCommits); - } - }); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[1], await buyer.getAddress()); - context("💔 Revert Reasons", async function () { - it("buyer does not meet condition for commit", async function () { - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[2], await buyer.getAddress()); - it("buyer has exhausted allowable commits", async function () { - // mint enough tokens for the buyer - await foreign721.connect(buyer).mint(condition.minTokenId, condition.threshold); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[3], await buyer.getAddress()); - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - await exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }); - } + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[4], await buyer.getAddress()); + }); - // Attempt to commit again after maximum commits has been reached - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 0, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); - }); + it("should update state", async function () { + // Complete the exchange + await exchangeHandler.connect(buyer).completeExchangeBatch(exchangesToComplete); - it("Caller sends non-zero tokenId", async function () { - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, 1, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_TOKEN_ID); - }); - }); + for (exchangeId = 1; exchangeId <= 5; exchangeId++) { + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchangeId); + + // It should match ExchangeState.Completed + assert.equal(response, ExchangeState.Completed, "Exchange state is incorrect"); + } }); - context("✋ SpecificToken ERC721 per address", async function () { - let tokenId; - beforeEach(async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; - tokenId = "12"; + it("should emit an ExchangeCompleted event if assistant calls after dispute period", async function () { + // Get the current block info + blockNumber = await provider.getBlockNumber(); + block = await provider.getBlock(blockNumber); - // Create Condition - condition = mockCondition({ - tokenAddress: await foreign721.getAddress(), - threshold: "0", - maxCommits: "3", - tokenType: TokenType.NonFungibleToken, - minTokenId: tokenId, - method: EvaluationMethod.SpecificToken, - maxTokenId: "22", - gating: GatingType.PerAddress, - }); - expect(condition.isValid()).to.be.true; + // Set time forward to run out the dispute period + newTime = Number(BigInt(block.timestamp) + BigInt(disputePeriod) + 1n); + await setNextBlockTimestamp(newTime); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); + // Complete exchange + const tx = await exchangeHandler.connect(assistant).completeExchangeBatch(exchangesToComplete); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[0], await assistant.getAddress()); - // mint correct token for the buyer - await foreign721.connect(buyer).mint(tokenId, "1"); - }); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[1], await assistant.getAddress()); - it("should emit BuyerCommitted and ConditionalCommitAuthorized event if user meets condition", async function () { - // Commit to offer. - // We're only concerned that the event is emitted, indicating the condition was met - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[2], await assistant.getAddress()); - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, tokenId, 1, condition.maxCommits); - }); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[3], await assistant.getAddress()); - it("should allow buyer to commit up to the max times for the group", async function () { - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - // We're only concerned that the event is emitted, indicating the commit was allowed - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[4], await assistant.getAddress()); + }); - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, tokenId, i + 1, condition.maxCommits); - } - }); + it("should emit an ExchangeCompleted event if anyone calls after dispute period", async function () { + // Get the current block info + blockNumber = await provider.getBlockNumber(); + block = await provider.getBlock(blockNumber); - it("Allow any token from collection", async function () { - condition.minTokenId = "0"; - condition.maxTokenId = MaxUint256.toString(); + // Set time forward to run out the dispute period + newTime = Number(BigInt(block.timestamp) + BigInt(disputePeriod) + 1n); + await setNextBlockTimestamp(newTime); - await groupHandler.connect(assistant).setGroupCondition(group.id, condition); + // Complete exchange + const tx = await exchangeHandler.connect(rando).completeExchangeBatch(exchangesToComplete); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[0], await rando.getAddress()); - // mint any token for buyer - tokenId = "123"; - await foreign721.connect(buyer).mint(tokenId, "1"); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[1], await rando.getAddress()); - // buyer can commit - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.emit(exchangeHandler, "BuyerCommitted"); - }); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[2], await rando.getAddress()); - context("💔 Revert Reasons", async function () { - it("token id does not exist", async function () { - tokenId = "13"; - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); - }); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[3], await rando.getAddress()); - it("buyer does not meet condition for commit", async function () { - // Send token to another user - await foreign721.connect(buyer).transferFrom(await buyer.getAddress(), rando.address, tokenId); + await expect(tx) + .to.emit(exchangeHandler, "ExchangeCompleted") + .withArgs(offerId, buyerId, exchangesToComplete[4], await rando.getAddress()); + }); - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); + context("💔 Revert Reasons", async function () { + it("The exchanges region of protocol is paused", async function () { + // Pause the exchanges region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); - it("max commits per token id reached", async function () { - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - await exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - } + // Attempt to complete an exchange, expecting revert + await expect( + exchangeHandler.connect(buyer).completeExchangeBatch(exchangesToComplete) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); + }); - // Attempt to commit again after maximum commits has been reached - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); - }); + it("exchange id is invalid", async function () { + // An invalid exchange id + exchangeId = "666"; - it("token id not in condition range", async function () { - tokenId = "666"; - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.TOKEN_ID_NOT_IN_CONDITION_RANGE); - }); + // Add new exchange id to the array + exchangesToComplete = [exchangeId, ...exchangesToComplete]; + + // Attempt to complete the exchange, expecting revert + await expect( + exchangeHandler.connect(assistant).completeExchangeBatch(exchangesToComplete) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_EXCHANGE); }); - }); - context("✋ SpecificToken ERC721 per token id", async function () { - let tokenId; - beforeEach(async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; - tokenId = "12"; + it("exchange is not in redeemed state", async function () { + // Create exchange with id 6 + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - // Create Condition - condition = mockCondition({ - tokenAddress: await foreign721.getAddress(), - threshold: "0", - maxCommits: "3", - tokenType: TokenType.NonFungibleToken, - minTokenId: tokenId, - method: EvaluationMethod.SpecificToken, - maxTokenId: "22", - gating: GatingType.PerTokenId, - }); - expect(condition.isValid()).to.be.true; + exchangeId = "6"; + // Cancel the voucher for any 1 exchange + await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); + // Add new exchange id to the array + exchangesToComplete = [exchangeId, ...exchangesToComplete]; - // mint correct token for the buyer - await foreign721.connect(buyer).mint(tokenId, "1"); + // Attempt to complete the exchange, expecting revert + await expect( + exchangeHandler.connect(assistant).completeExchangeBatch(exchangesToComplete) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_STATE); }); - it("should emit BuyerCommitted and ConditionalCommitAuthorized event if user meets condition", async function () { - // Commit to offer. - // We're only concerned that the event is emitted, indicating the condition was met - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); + it("caller is not buyer and offer dispute period has not elapsed", async function () { + // Create exchange with id 6 + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + exchangeId = "6"; - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, tokenId, 1, condition.maxCommits); - }); + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - it("should allow buyer to commit up to the max times for the group", async function () { - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - // We're only concerned that the event is emitted, indicating the commit was allowed - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + // Add new exchange id to the array + exchangesToComplete = [exchangeId, ...exchangesToComplete]; - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, tokenId, i + 1, condition.maxCommits); - } + // Attempt to complete the exchange, expecting revert + await expect( + exchangeHandler.connect(rando).completeExchangeBatch(exchangesToComplete) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.DISPUTE_PERIOD_NOT_ELAPSED); }); - it("Allow any token from collection", async function () { - condition.minTokenId = "0"; - condition.maxTokenId = MaxUint256.toString(); + it("caller is seller's assistant and offer dispute period has not elapsed", async function () { + // Create exchange with id 6 + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - await groupHandler.connect(assistant).setGroupCondition(group.id, condition); + exchangeId = "6"; - // mint any token for buyer - tokenId = "123"; - await foreign721.connect(buyer).mint(tokenId, "1"); + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // buyer can commit + // Attempt to complete the exchange, expecting revert await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.emit(exchangeHandler, "BuyerCommitted"); + exchangeHandler.connect(assistant).completeExchangeBatch(exchangesToComplete) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.DISPUTE_PERIOD_NOT_ELAPSED); }); + }); + }); - context("💔 Revert Reasons", async function () { - it("token id does not exist", async function () { - tokenId = "13"; - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); - }); + context("👉 revokeVoucher()", async function () { + beforeEach(async function () { + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + }); - it("buyer does not meet condition for commit", async function () { - // Send token to another user - await foreign721.connect(buyer).transferFrom(await buyer.getAddress(), rando.address, tokenId); + it("should emit an VoucherRevoked event when seller's assistant calls", async function () { + // Revoke the voucher, expecting event + await expect(exchangeHandler.connect(assistant).revokeVoucher(exchange.id)) + .to.emit(exchangeHandler, "VoucherRevoked") + .withArgs(offerId, exchange.id, await assistant.getAddress()); + }); - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); + it("should update state", async function () { + // Revoke the voucher + await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); - it("max commits per token id reached", async function () { - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - await exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - } + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // Attempt to commit again after maximum commits has been reached - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); - }); - - it("token id not in condition range", async function () { - tokenId = "666"; - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.TOKEN_ID_NOT_IN_CONDITION_RANGE); - }); - }); - }); - - context("✋ Threshold ERC1155 per address", async function () { - let tokenId; - beforeEach(async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; - - // Create Condition - condition = mockCondition({ - tokenAddress: await foreign1155.getAddress(), - threshold: "20", - maxCommits: "3", - tokenType: TokenType.MultiToken, - method: EvaluationMethod.Threshold, - minTokenId: "123", - maxTokenId: "128", - gating: GatingType.PerAddress, - }); - - expect(condition.isValid()).to.be.true; - - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); - - // Set random token id - tokenId = "123"; - }); - - it("should emit BuyerCommitted and ConditionalCommitAuthorized events if user meets condition", async function () { - // mint enough tokens for the buyer - await foreign1155.connect(buyer).mint(tokenId, condition.threshold); - - // Commit to offer. - // We're only concerned that the event is emitted, indicating the condition was met - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, tokenId, 1, condition.maxCommits); - }); - - it("should allow buyer to commit up to the max times for the group", async function () { - // mint enough tokens for the buyer - await foreign1155.connect(buyer).mint(tokenId, condition.threshold); - - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - // We're only concerned that the event is emitted, indicating the commit was allowed - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, tokenId, i + 1, condition.maxCommits); - } - }); - - context("💔 Revert Reasons", async function () { - it("buyer does not meet condition for commit", async function () { - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); - - it("buyer has exhausted allowable commits", async function () { - // mint enough tokens for the buyer - await foreign1155.connect(buyer).mint(tokenId, condition.threshold); - - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - await exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - } - - // Attempt to commit again after maximum commits has been reached - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); - }); - }); - }); - - context("✋ Threshold ERC1155 per token id", async function () { - let tokenId; - beforeEach(async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; - tokenId = "12"; - - // Create Condition - condition = mockCondition({ - tokenAddress: await foreign1155.getAddress(), - threshold: "1", - maxCommits: "3", - tokenType: TokenType.MultiToken, - minTokenId: tokenId, - method: EvaluationMethod.Threshold, - maxTokenId: "22", - }); - - expect(condition.isValid()).to.be.true; - - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); - - // mint correct token for the buyer - await foreign1155.connect(buyer).mint(tokenId, "1"); - }); - - it("should emit BuyerCommitted and ConditionalCommitAuthorized events if user meets condition", async function () { - // Commit to offer. - // We're only concerned that the event is emitted, indicating the condition was met - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, tokenId, 1, condition.maxCommits); - }); - - it("should allow buyer to commit up to the max times for the group", async function () { - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - // We're only concerned that the event is emitted, indicating the commit was allowed - const tx = exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - - await expect(tx) - .to.emit(exchangeHandler, "ConditionalCommitAuthorized") - .withArgs(offerId, condition.gating, buyer.address, tokenId, i + 1, condition.maxCommits); - } - }); - - it("Allow any token from collection", async function () { - condition.minTokenId = "0"; - condition.maxTokenId = MaxUint256.toString(); - - await groupHandler.connect(assistant).setGroupCondition(group.id, condition); - - // mint any token for buyer - tokenId = "123"; - await foreign1155.connect(buyer).mint(tokenId, "1"); - - // buyer can commit - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.emit(exchangeHandler, "BuyerCommitted"); - }); - - context("💔 Revert Reasons", async function () { - it("token id does not exist", async function () { - tokenId = "13"; - - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); - - it("buyer does not meet condition for commit", async function () { - // Attempt to commit, expecting revert - await expect( - exchangeHandler.connect(rando).commitToConditionalOffer(rando.address, offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); - }); - - it("max commits per token id reached", async function () { - // Commit to offer the maximum number of times - for (let i = 0; i < Number(condition.maxCommits); i++) { - await exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); - } - - // Attempt to commit again after maximum commits has been reached - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.MAX_COMMITS_REACHED); - }); - - it("token id not in condition range", async function () { - tokenId = "666"; - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.TOKEN_ID_NOT_IN_CONDITION_RANGE); - }); - }); - }); - - context("💔 Revert Reasons", async function () { - let tokenId; - - beforeEach(async function () { - // Required constructor params for Group - groupId = "1"; - offerIds = [offerId]; - tokenId = "12"; - - // Create Condition - condition = mockCondition({ - tokenAddress: await foreign721.getAddress(), - threshold: "0", - maxCommits: "3", - tokenType: TokenType.NonFungibleToken, - minTokenId: tokenId, - method: EvaluationMethod.SpecificToken, - maxTokenId: "22", - }); - expect(condition.isValid()).to.be.true; - - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); - - // mint correct token for the buyer - await foreign721.connect(buyer).mint(tokenId, "1"); - }); - - it("The exchanges region of protocol is paused", async function () { - // Pause the exchanges region of the protocol - await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); - - // Attempt to create an exchange, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); - }); - - it("The buyers region of protocol is paused", async function () { - // Pause the buyers region of the protocol - await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); - - // Attempt to create a buyer, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); - }); - - it("await buyer.getAddress() is the zero address", async function () { - // Attempt to commit, expecting revert - await expect( - exchangeHandler.connect(buyer).commitToConditionalOffer(ZeroAddress, offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_ADDRESS); - }); - - it("offer id is invalid", async function () { - // An invalid offer id - offerId = "666"; - - // Attempt to commit, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_OFFER); - }); - - it("offer is voided", async function () { - // Void the offer first - await offerHandler.connect(assistant).voidOffer(offerId); + // It should match ExchangeState.Revoked + assert.equal(response, ExchangeState.Revoked, "Exchange state is incorrect"); + }); - // Attempt to commit to the voided offer, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_HAS_BEEN_VOIDED); - }); + it("should work on an additional collection", async function () { + // Create a new collection + const externalId = `Brand1`; + voucherInitValues.collectionSalt = encodeBytes32String(externalId); + await accountHandler.connect(assistant).createNewCollection(externalId, voucherInitValues); - it("offer is not yet available for commits", async function () { - // Create an offer with staring date in the future - // get current block timestamp - const block = await ethers.provider.getBlock("latest"); - const now = block.timestamp.toString(); + offer.collectionIndex = 1; + offer.id = await offerHandler.getNextOfferId(); + exchange.id = await exchangeHandler.getNextExchangeId(); + const tokenId = deriveTokenId(offer.id, exchange.id); - // set validFrom date in the past - offerDates.validFrom = (BigInt(now) + BigInt(oneMonth) * 6n).toString(); // 6 months in the future - offerDates.validUntil = (BigInt(offerDates.validFrom) + 10n).toString(); // just after the valid from so it succeeds. + // Create the offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Commit to offer, creating a new exchange + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id, { value: price }); - // add offer to group - await groupHandler.connect(assistant).addOffersToGroup(groupId, [++offerId]); + // expected address of the first additional collection + const additionalCollectionAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + admin.address, + voucherInitValues.collectionSalt + ); + const additionalCollection = await getContractAt("BosonVoucher", additionalCollectionAddress); - // Attempt to commit to the not availabe offer, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_NOT_AVAILABLE); - }); + // Revoke the voucher, expecting event + await expect(exchangeHandler.connect(assistant).revokeVoucher(exchange.id)) + .to.emit(additionalCollection, "Transfer") + .withArgs(buyer.address, ZeroAddress, tokenId); + }); - it("offer has expired", async function () { - // Go past offer expiration date - await setNextBlockTimestamp(Number(offerDates.validUntil) + 1); + context("💔 Revert Reasons", async function () { + it("The exchanges region of protocol is paused", async function () { + // Pause the exchanges region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); - // Attempt to commit to the expired offer, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_HAS_EXPIRED); + // Attempt to complete an exchange, expecting revert + await expect(exchangeHandler.connect(assistant).revokeVoucher(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.REGION_PAUSED + ); }); - it("offer sold", async function () { - // Create an offer with only 1 item - offer.quantityAvailable = "1"; - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - - // add offer to group - await groupHandler.connect(assistant).addOffersToGroup(groupId, [++offerId]); - - // Commit to offer, so it's not available anymore - await exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }); + it("exchange id is invalid", async function () { + // An invalid exchange id + exchangeId = "666"; - // Attempt to commit to the sold out offer, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_SOLD_OUT); + // Attempt to revoke the voucher, expecting revert + await expect(exchangeHandler.connect(assistant).revokeVoucher(exchangeId)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.NO_SUCH_EXCHANGE + ); }); - it("Group without condition", async function () { - let tokenId = "0"; - - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - - // Required constructor params for Group - groupId = "1"; - offerIds = [(++offerId).toString()]; - - // Create Condition - condition = mockCondition({ method: EvaluationMethod.None, threshold: "0", maxCommits: "0" }); - expect(condition.isValid()).to.be.true; + it("exchange is not in committed state", async function () { + // Cancel the voucher + await exchangeHandler.connect(buyer).cancelVoucher(exchange.id); - // Create Group - group = new Group(groupId, seller.id, offerIds); - expect(group.isValid()).is.true; - await groupHandler.connect(assistant).createGroup(group, condition); + // Attempt to revoke the voucher, expecting revert + await expect(exchangeHandler.connect(assistant).revokeVoucher(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.INVALID_STATE + ); + }); - // Commit to offer. - await expect( - exchangeHandler - .connect(buyer) - .commitToConditionalOffer(await buyer.getAddress(), offerId, tokenId, { value: price }) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.GROUP_HAS_NO_CONDITION); + it("caller is not seller's assistant", async function () { + // Attempt to complete the exchange, expecting revert + await expect(exchangeHandler.connect(rando).revokeVoucher(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.NOT_ASSISTANT + ); }); }); }); - context("👉 completeExchange()", async function () { + context("👉 cancelVoucher()", async function () { beforeEach(async function () { - // Commit to offer + // Commit to offer, retrieving the event await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); }); - it("should emit an ExchangeCompleted event when buyer calls", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - - // Complete the exchange, expecting event - await expect(exchangeHandler.connect(buyer).completeExchange(exchange.id)) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchange.id, await buyer.getAddress()); + it("should emit an VoucherCanceled event when original buyer calls", async function () { + // Cancel the voucher, expecting event + await expect(exchangeHandler.connect(buyer).cancelVoucher(exchange.id)) + .to.emit(exchangeHandler, "VoucherCanceled") + .withArgs(offerId, exchange.id, await buyer.getAddress()); }); - it("should update state", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + it("should emit an VoucherCanceled event when new owner (not a buyer) calls", async function () { + // Transfer voucher to new owner + tokenId = deriveTokenId(offerId, exchange.id); + bosonVoucherCloneAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + admin.address + ); + bosonVoucherClone = await getContractAt("IBosonVoucher", bosonVoucherCloneAddress); + await bosonVoucherClone.connect(buyer).transferFrom(buyer.address, newOwner.address, tokenId); - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // Cancel the voucher, expecting event + await expect(exchangeHandler.connect(newOwner).cancelVoucher(exchange.id)) + .to.emit(exchangeHandler, "VoucherCanceled") + .withArgs(offerId, exchange.id, await newOwner.getAddress()); + }); - // Complete the exchange - await exchangeHandler.connect(buyer).completeExchange(exchange.id); + it("should update state when buyer calls", async function () { + // Cancel the voucher + await exchangeHandler.connect(buyer).cancelVoucher(exchange.id); // Get the exchange state [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // It should match ExchangeState.Completed - assert.equal(response, ExchangeState.Completed, "Exchange state is incorrect"); - }); - - it("should emit an ExchangeCompleted event if assistant calls after dispute period", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - - // Get the current block info - blockNumber = await provider.getBlockNumber(); - block = await provider.getBlock(blockNumber); - - // Set time forward to run out the dispute period - newTime = Number(BigInt(block.timestamp) + BigInt(disputePeriod) + 1n); - await setNextBlockTimestamp(newTime); - - // Complete exchange - await expect(exchangeHandler.connect(assistant).completeExchange(exchange.id)) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchange.id, await assistant.getAddress()); - }); - - it("should emit an ExchangeCompleted event if anyone calls after dispute period", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - - // Get the current block info - blockNumber = await provider.getBlockNumber(); - block = await provider.getBlock(blockNumber); - - // Set time forward to run out the dispute period - newTime = Number(BigInt(block.timestamp) + BigInt(disputePeriod) + 1n); - await setNextBlockTimestamp(newTime); - - // Complete exchange - await expect(exchangeHandler.connect(rando).completeExchange(exchange.id)) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchange.id, await rando.getAddress()); + // It should match ExchangeState.Canceled + assert.equal(response, ExchangeState.Canceled, "Exchange state is incorrect"); }); - it("should emit an ExchangeCompleted event if another buyer calls after dispute period", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + it("should work on an additional collection", async function () { + // Create a new collection + const externalId = `Brand1`; + voucherInitValues.collectionSalt = encodeBytes32String(externalId); + await accountHandler.connect(assistant).createNewCollection(externalId, voucherInitValues); - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + offer.collectionIndex = 1; + offer.id = await offerHandler.getNextOfferId(); + exchange.id = await exchangeHandler.getNextExchangeId(); + const tokenId = deriveTokenId(offer.id, exchange.id); - // Get the current block info - blockNumber = await provider.getBlockNumber(); - block = await provider.getBlock(blockNumber); + // Create the offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - // Set time forward to run out the dispute period - newTime = Number(BigInt(block.timestamp) + BigInt(disputePeriod) + 1n); - await setNextBlockTimestamp(newTime); + // Commit to offer, creating a new exchange + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id, { value: price }); - // Create a rando buyer account - await accountHandler.connect(rando).createBuyer(mockBuyer(await rando.getAddress())); + // expected address of the first additional collection + const additionalCollectionAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + admin.address, + voucherInitValues.collectionSalt + ); + const additionalCollection = await getContractAt("BosonVoucher", additionalCollectionAddress); - // Complete exchange - await expect(exchangeHandler.connect(rando).completeExchange(exchange.id)) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchange.id, await rando.getAddress()); + // Cancel the voucher, expecting event + await expect(exchangeHandler.connect(buyer).cancelVoucher(exchange.id)) + .to.emit(additionalCollection, "Transfer") + .withArgs(buyer.address, ZeroAddress, tokenId); }); context("💔 Revert Reasons", async function () { @@ -2602,7 +2330,7 @@ describe("IBosonExchangeHandler", function () { await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); // Attempt to complete an exchange, expecting revert - await expect(exchangeHandler.connect(assistant).completeExchange(exchangeId)).to.revertedWithCustomError( + await expect(exchangeHandler.connect(buyer).cancelVoucher(exchange.id)).to.revertedWithCustomError( bosonErrors, RevertReasons.REGION_PAUSED ); @@ -2612,200 +2340,112 @@ describe("IBosonExchangeHandler", function () { // An invalid exchange id exchangeId = "666"; - // Attempt to complete the exchange, expecting revert - await expect(exchangeHandler.connect(assistant).completeExchange(exchangeId)).to.revertedWithCustomError( + // Attempt to cancel the voucher, expecting revert + await expect(exchangeHandler.connect(buyer).cancelVoucher(exchangeId)).to.revertedWithCustomError( bosonErrors, RevertReasons.NO_SUCH_EXCHANGE ); }); - it("cannot complete an exchange when it is in the committed state", async function () { + it("cannot cancel when exchange is in Redeemed state", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // Redeem voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // Get the exchange state [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // It should match ExchangeState.Committed - assert.equal(response, ExchangeState.Committed, "Exchange state is incorrect"); + // It should match ExchangeState.Redeemed + assert.equal(response, ExchangeState.Redeemed, "Exchange state is incorrect"); - // Attempt to complete the exchange, expecting revert - await expect(exchangeHandler.connect(assistant).completeExchange(exchange.id)).to.revertedWithCustomError( + // Attempt to cancel the voucher, expecting revert + await expect(exchangeHandler.connect(buyer).cancelVoucher(exchange.id)).to.revertedWithCustomError( bosonErrors, RevertReasons.INVALID_STATE ); }); - it("exchange is not in redeemed state", async function () { - // Cancel the voucher - await exchangeHandler.connect(buyer).cancelVoucher(exchange.id); + it("exchange is not in committed state", async function () { + // Revoke the voucher + await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); - // Attempt to complete the exchange, expecting revert - await expect(exchangeHandler.connect(assistant).completeExchange(exchange.id)).to.revertedWithCustomError( + // Attempt to cancel the voucher, expecting revert + await expect(exchangeHandler.connect(buyer).cancelVoucher(exchange.id)).to.revertedWithCustomError( bosonErrors, RevertReasons.INVALID_STATE ); }); - it("caller is not buyer and offer dispute period has not elapsed", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - - // Attempt to complete the exchange, expecting revert - await expect(exchangeHandler.connect(rando).completeExchange(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.DISPUTE_PERIOD_NOT_ELAPSED - ); - }); - - it("caller is a buyer, but not the buyer of the exchange and offer dispute period has not elapsed", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - - // Create a rando buyer account - await accountHandler.connect(rando).createBuyer(mockBuyer(await rando.getAddress())); - - // Attempt to complete the exchange, expecting revert - await expect(exchangeHandler.connect(rando).completeExchange(exchange.id)).to.revertedWithCustomError( + it("caller does not own voucher", async function () { + // Attempt to cancel the voucher, expecting revert + await expect(exchangeHandler.connect(rando).cancelVoucher(exchange.id)).to.revertedWithCustomError( bosonErrors, - RevertReasons.DISPUTE_PERIOD_NOT_ELAPSED + RevertReasons.NOT_VOUCHER_HOLDER ); }); - it("caller is seller's assistant and offer dispute period has not elapsed", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + it("getCurrentSenderAddress() returns zero address and has isMetaTransaction set to true on chain", async function () { + await upgradeMetaTransactionsHandlerFacet(); - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + await mockMetaTransactionsHandler.setAsMetaTransactionAndCurrentSenderAs(ZeroAddress); - // Attempt to complete the exchange, expecting revert - await expect(exchangeHandler.connect(assistant).completeExchange(exchange.id)).to.revertedWithCustomError( + // Attempt to cancel the voucher, expecting revert + await expect(exchangeHandler.connect(rando).cancelVoucher(exchange.id)).to.revertedWithCustomError( bosonErrors, - RevertReasons.DISPUTE_PERIOD_NOT_ELAPSED + RevertReasons.INVALID_ADDRESS ); }); }); }); - context("👉 completeExchangeBatch()", async function () { + context("👉 expireVoucher()", async function () { beforeEach(async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - for (exchangeId = 1; exchangeId <= 5; exchangeId++) { - // Commit to offer, creating a new exchange - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - - // Redeem voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - } - - exchangesToComplete = ["1", "2", "3", "4", "5"]; - }); - - it("should emit a ExchangeCompleted event for all events", async function () { - // Complete the exchange, expecting event - const tx = await exchangeHandler.connect(buyer).completeExchangeBatch(exchangesToComplete); - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[0], await buyer.getAddress()); - - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[1], await buyer.getAddress()); - - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[2], await buyer.getAddress()); - - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[3], await buyer.getAddress()); - - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[4], await buyer.getAddress()); + // Commit to offer, retrieving the event + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); }); - it("should update state", async function () { - // Complete the exchange - await exchangeHandler.connect(buyer).completeExchangeBatch(exchangesToComplete); - - for (exchangeId = 1; exchangeId <= 5; exchangeId++) { - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchangeId); + it("should emit an VoucherExpired event when anyone calls and voucher has expired", async function () { + // Set time forward past the voucher's validUntilDate + await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); - // It should match ExchangeState.Completed - assert.equal(response, ExchangeState.Completed, "Exchange state is incorrect"); - } + // Expire the voucher, expecting event + await expect(exchangeHandler.connect(rando).expireVoucher(exchange.id)) + .to.emit(exchangeHandler, "VoucherExpired") + .withArgs(offerId, exchange.id, await rando.getAddress()); }); - it("should emit an ExchangeCompleted event if assistant calls after dispute period", async function () { - // Get the current block info - blockNumber = await provider.getBlockNumber(); - block = await provider.getBlock(blockNumber); - - // Set time forward to run out the dispute period - newTime = Number(BigInt(block.timestamp) + BigInt(disputePeriod) + 1n); - await setNextBlockTimestamp(newTime); - - // Complete exchange - const tx = await exchangeHandler.connect(assistant).completeExchangeBatch(exchangesToComplete); - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[0], await assistant.getAddress()); - - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[1], await assistant.getAddress()); + it("should update state when anyone calls and voucher has expired", async function () { + // Set time forward past the voucher's validUntilDate + await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[2], await assistant.getAddress()); + // Expire the voucher + await exchangeHandler.connect(rando).expireVoucher(exchange.id); - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[3], await assistant.getAddress()); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[4], await assistant.getAddress()); + // It should match ExchangeState.Canceled + assert.equal(response, ExchangeState.Canceled, "Exchange state is incorrect"); }); - it("should emit an ExchangeCompleted event if anyone calls after dispute period", async function () { - // Get the current block info - blockNumber = await provider.getBlockNumber(); - block = await provider.getBlock(blockNumber); - - // Set time forward to run out the dispute period - newTime = Number(BigInt(block.timestamp) + BigInt(disputePeriod) + 1n); - await setNextBlockTimestamp(newTime); - - // Complete exchange - const tx = await exchangeHandler.connect(rando).completeExchangeBatch(exchangesToComplete); - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[0], await rando.getAddress()); + it("should update voucher expired flag when anyone calls and voucher has expired", async function () { + // Set time forward past the voucher's validUntilDate + await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[1], await rando.getAddress()); + // Expire the voucher + await exchangeHandler.connect(rando).expireVoucher(exchange.id); - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[2], await rando.getAddress()); + // Get the voucher + [, , response] = await exchangeHandler.connect(rando).getExchange(exchange.id); - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[3], await rando.getAddress()); + // Marshal response to entity + voucher = Voucher.fromStruct(response); + expect(voucher.isValid()); - await expect(tx) - .to.emit(exchangeHandler, "ExchangeCompleted") - .withArgs(offerId, buyerId, exchangesToComplete[4], await rando.getAddress()); + // Exchange's voucher expired flag should be true + assert.isTrue(voucher.expired, "Voucher expired flag not set"); }); context("💔 Revert Reasons", async function () { @@ -2814,98 +2454,118 @@ describe("IBosonExchangeHandler", function () { await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); // Attempt to complete an exchange, expecting revert - await expect( - exchangeHandler.connect(buyer).completeExchangeBatch(exchangesToComplete) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); - }); - - it("exchange id is invalid", async function () { - // An invalid exchange id - exchangeId = "666"; - - // Add new exchange id to the array - exchangesToComplete = [exchangeId, ...exchangesToComplete]; - - // Attempt to complete the exchange, expecting revert - await expect( - exchangeHandler.connect(assistant).completeExchangeBatch(exchangesToComplete) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_EXCHANGE); + await expect(exchangeHandler.connect(buyer).expireVoucher(exchangeId)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.REGION_PAUSED + ); }); - it("exchange is not in redeemed state", async function () { - // Create exchange with id 6 - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - - exchangeId = "6"; - // Cancel the voucher for any 1 exchange - await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); + it("exchange id is invalid", async function () { + // Set time forward past the voucher's validUntilDate + await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); - // Add new exchange id to the array - exchangesToComplete = [exchangeId, ...exchangesToComplete]; + // An invalid exchange id + exchangeId = "666"; - // Attempt to complete the exchange, expecting revert - await expect( - exchangeHandler.connect(assistant).completeExchangeBatch(exchangesToComplete) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_STATE); + // Attempt to cancel the voucher, expecting revert + await expect(exchangeHandler.connect(buyer).expireVoucher(exchangeId)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.NO_SUCH_EXCHANGE + ); }); - it("caller is not buyer and offer dispute period has not elapsed", async function () { - // Create exchange with id 6 - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - - exchangeId = "6"; + it("cannot expire voucher when exchange is in Redeemed state", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // Add new exchange id to the array - exchangesToComplete = [exchangeId, ...exchangesToComplete]; + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // Attempt to complete the exchange, expecting revert - await expect( - exchangeHandler.connect(rando).completeExchangeBatch(exchangesToComplete) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.DISPUTE_PERIOD_NOT_ELAPSED); + // It should match ExchangeState.Redeemed + assert.equal(response, ExchangeState.Redeemed, "Exchange state is incorrect"); + + // Attempt to expire the voucher, expecting revert + await expect(exchangeHandler.connect(buyer).expireVoucher(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.INVALID_STATE + ); }); - it("caller is seller's assistant and offer dispute period has not elapsed", async function () { - // Create exchange with id 6 - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + it("exchange is not in committed state", async function () { + // Set time forward past the voucher's validUntilDate + await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); - exchangeId = "6"; + // Revoke the voucher + await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // Attempt to expire the voucher, expecting revert + await expect(exchangeHandler.connect(buyer).expireVoucher(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.INVALID_STATE + ); + }); - // Attempt to complete the exchange, expecting revert - await expect( - exchangeHandler.connect(assistant).completeExchangeBatch(exchangesToComplete) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.DISPUTE_PERIOD_NOT_ELAPSED); + it("Redemption period has not yet elapsed", async function () { + // Attempt to cancel the voucher, expecting revert + await expect(exchangeHandler.connect(rando).expireVoucher(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.VOUCHER_STILL_VALID + ); + + // Set time forward past the last valid timestamp + await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid)); + + // Attempt to cancel the voucher, expecting revert + await expect(exchangeHandler.connect(rando).expireVoucher(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.VOUCHER_STILL_VALID + ); }); }); }); - context("👉 revokeVoucher()", async function () { + context("👉 redeemVoucher()", async function () { beforeEach(async function () { // Commit to offer await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); }); - it("should emit an VoucherRevoked event when seller's assistant calls", async function () { - // Revoke the voucher, expecting event - await expect(exchangeHandler.connect(assistant).revokeVoucher(exchange.id)) - .to.emit(exchangeHandler, "VoucherRevoked") - .withArgs(offerId, exchange.id, await assistant.getAddress()); + it("should emit a VoucherRedeemed event when buyer calls", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // Redeem the voucher, expecting event + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)) + .to.emit(exchangeHandler, "VoucherRedeemed") + .withArgs(offerId, exchange.id, await buyer.getAddress()); }); it("should update state", async function () { - // Revoke the voucher - await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); // Get the exchange state [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // It should match ExchangeState.Revoked - assert.equal(response, ExchangeState.Revoked, "Exchange state is incorrect"); + // It should match ExchangeState.Redeemed + assert.equal(response, ExchangeState.Redeemed, "Exchange state is incorrect"); + }); + + it("It's possible to redeem at the the end of voucher validity period", async function () { + // Set time forward to the offer's validUntilDate + await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid)); + + // Redeem the voucher, expecting event + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)).to.emit( + exchangeHandler, + "VoucherRedeemed" + ); }); it("should work on an additional collection", async function () { @@ -2936,8 +2596,11 @@ describe("IBosonExchangeHandler", function () { ); const additionalCollection = await getContractAt("BosonVoucher", additionalCollectionAddress); - // Revoke the voucher, expecting event - await expect(exchangeHandler.connect(assistant).revokeVoucher(exchange.id)) + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // Redeem the voucher, expecting event + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)) .to.emit(additionalCollection, "Transfer") .withArgs(buyer.address, ZeroAddress, tokenId); }); @@ -2948,7 +2611,7 @@ describe("IBosonExchangeHandler", function () { await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); // Attempt to complete an exchange, expecting revert - await expect(exchangeHandler.connect(assistant).revokeVoucher(exchange.id)).to.revertedWithCustomError( + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)).to.revertedWithCustomError( bosonErrors, RevertReasons.REGION_PAUSED ); @@ -2958,625 +2621,615 @@ describe("IBosonExchangeHandler", function () { // An invalid exchange id exchangeId = "666"; - // Attempt to revoke the voucher, expecting revert - await expect(exchangeHandler.connect(assistant).revokeVoucher(exchangeId)).to.revertedWithCustomError( + // Attempt to redeem the voucher, expecting revert + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)).to.revertedWithCustomError( bosonErrors, RevertReasons.NO_SUCH_EXCHANGE ); }); it("exchange is not in committed state", async function () { - // Cancel the voucher - await exchangeHandler.connect(buyer).cancelVoucher(exchange.id); + // Revoke the voucher + await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); - // Attempt to revoke the voucher, expecting revert - await expect(exchangeHandler.connect(assistant).revokeVoucher(exchange.id)).to.revertedWithCustomError( + // Attempt to redeem the voucher, expecting revert + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)).to.revertedWithCustomError( bosonErrors, RevertReasons.INVALID_STATE ); }); - it("caller is not seller's assistant", async function () { - // Attempt to complete the exchange, expecting revert - await expect(exchangeHandler.connect(rando).revokeVoucher(exchange.id)).to.revertedWithCustomError( + it("caller does not own voucher", async function () { + // Attempt to redeem the voucher, expecting revert + await expect(exchangeHandler.connect(rando).redeemVoucher(exchange.id)).to.revertedWithCustomError( bosonErrors, - RevertReasons.NOT_ASSISTANT + RevertReasons.NOT_VOUCHER_HOLDER + ); + }); + + it("current time is prior to offer's voucherRedeemableFrom", async function () { + // Attempt to redeem the voucher, expecting revert + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.VOUCHER_NOT_REDEEMABLE + ); + }); + + it("current time is after to voucher's validUntilDate", async function () { + // Set time forward past the voucher's validUntilDate + await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + 1); + + // Attempt to redeem the voucher, expecting revert + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)).to.revertedWithCustomError( + bosonErrors, + RevertReasons.VOUCHER_NOT_REDEEMABLE ); }); }); }); - context("👉 cancelVoucher()", async function () { + context("👉 redeemVoucher() with bundle", async function () { beforeEach(async function () { - // Commit to offer, retrieving the event - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - }); + // Mint some tokens to be bundled + await foreign20.connect(assistant).mint(await assistant.getAddress(), "500"); + // Mint first two and last two tokens of range + await foreign721.connect(assistant).mint("1", "10"); + await foreign1155.connect(assistant).mint("1", "500"); - it("should emit an VoucherCanceled event when original buyer calls", async function () { - // Cancel the voucher, expecting event - await expect(exchangeHandler.connect(buyer).cancelVoucher(exchange.id)) - .to.emit(exchangeHandler, "VoucherCanceled") - .withArgs(offerId, exchange.id, await buyer.getAddress()); - }); + // Approve the protocol diamond to transfer seller's tokens + await foreign20.connect(assistant).approve(protocolDiamondAddress, "30"); + await foreign721.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + await foreign1155.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - it("should emit an VoucherCanceled event when new owner (not a buyer) calls", async function () { - // Transfer voucher to new owner - tokenId = deriveTokenId(offerId, exchange.id); - bosonVoucherCloneAddress = calculateCloneAddress( - await accountHandler.getAddress(), - beaconProxyAddress, - admin.address - ); - bosonVoucherClone = await getContractAt("IBosonVoucher", bosonVoucherCloneAddress); - await bosonVoucherClone.connect(buyer).transferFrom(buyer.address, newOwner.address, tokenId); + // Create an ERC20 twin + twin20 = mockTwin(await foreign20.getAddress()); + twin20.amount = "3"; + twin20.supplyAvailable = "30"; + expect(twin20.isValid()).is.true; - // Cancel the voucher, expecting event - await expect(exchangeHandler.connect(newOwner).cancelVoucher(exchange.id)) - .to.emit(exchangeHandler, "VoucherCanceled") - .withArgs(offerId, exchange.id, await newOwner.getAddress()); - }); + // Create an ERC721 twin + twin721 = mockTwin(await foreign721.getAddress(), TokenType.NonFungibleToken); + twin721.id = "2"; + twin721.amount = "0"; + twin721.supplyAvailable = "10"; + twin721.tokenId = "1"; + expect(twin721.isValid()).is.true; - it("should update state when buyer calls", async function () { - // Cancel the voucher - await exchangeHandler.connect(buyer).cancelVoucher(exchange.id); + // Create an ERC1155 twin + twin1155 = mockTwin(await foreign1155.getAddress(), TokenType.MultiToken); + twin1155.id = "3"; + twin1155.tokenId = "1"; + twin1155.amount = "1"; + twin1155.supplyAvailable = "10"; - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + expect(twin1155.isValid()).is.true; - // It should match ExchangeState.Canceled - assert.equal(response, ExchangeState.Canceled, "Exchange state is incorrect"); + // All the twin ids (for mixed bundle) + twinIds = [twin20.id, twin721.id, twin1155.id]; + + // Create twins + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); }); - it("should work on an additional collection", async function () { - // Create a new collection - const externalId = `Brand1`; - voucherInitValues.collectionSalt = encodeBytes32String(externalId); - await accountHandler.connect(assistant).createNewCollection(externalId, voucherInitValues); + context("📦 Offer bundled with ERC20 twin", async function () { + beforeEach(async function () { + // Create a new bundle + bundle = new Bundle("1", seller.id, [offerId], [twin20.id]); + expect(bundle.isValid()).is.true; + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + }); + + it("should transfer the twin", async function () { + // Check the buyer's balance of the ERC20 + balance = await foreign20.balanceOf(await buyer.getAddress()); + expect(balance).to.equal(0); + + // Redeem the voucher + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 600000 })) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs( + twin20.id, + twin20.tokenAddress, + exchange.id, + twin20.tokenId, + twin20.amount, + await buyer.getAddress() + ); + + // Check the buyer's balance of the ERC20 + balance = await foreign20.balanceOf(await buyer.getAddress()); + expect(balance).to.equal(3); + }); - offer.collectionIndex = 1; - offer.id = await offerHandler.getNextOfferId(); - exchange.id = await exchangeHandler.getNextExchangeId(); - const tokenId = deriveTokenId(offer.id, exchange.id); + it("Amount should be reduced from twin supplyAvailable", async function () { + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // Create the offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Check twin supplyAvailable + const [, twin] = await twinHandler.connect(assistant).getTwin(twin20.id); - // Commit to offer, creating a new exchange - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id, { value: price }); + expect(twin.supplyAvailable).to.equal(twin20.supplyAvailable - twin20.amount); + }); - // expected address of the first additional collection - const additionalCollectionAddress = calculateCloneAddress( - await accountHandler.getAddress(), - beaconProxyAddress, - admin.address, - voucherInitValues.collectionSalt - ); - const additionalCollection = await getContractAt("BosonVoucher", additionalCollectionAddress); + it("Should not decrease twin supplyAvailable if supply is unlimited", async function () { + // Change twin supply to unlimited + twin20.supplyAvailable = MaxUint256.toString(); + twin20.id = "4"; - // Cancel the voucher, expecting event - await expect(exchangeHandler.connect(buyer).cancelVoucher(exchange.id)) - .to.emit(additionalCollection, "Transfer") - .withArgs(buyer.address, ZeroAddress, tokenId); - }); + // Create a new twin + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); - context("💔 Revert Reasons", async function () { - it("The exchanges region of protocol is paused", async function () { - // Pause the exchanges region of the protocol - await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); + offer.quantityAvailable = "2"; + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; - // Attempt to complete an exchange, expecting revert - await expect(exchangeHandler.connect(buyer).cancelVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.REGION_PAUSED - ); - }); + // Create a new offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - it("exchange id is invalid", async function () { - // An invalid exchange id - exchangeId = "666"; + // Create a new bundle + bundle = new Bundle("1", seller.id, [++offerId], [twin20.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // Attempt to cancel the voucher, expecting revert - await expect(exchangeHandler.connect(buyer).cancelVoucher(exchangeId)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.NO_SUCH_EXCHANGE - ); - }); + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - it("cannot cancel when exchange is in Redeemed state", async function () { // Set time forward to the offer's voucherRedeemableFrom + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Redeem voucher + // Redeem the voucher await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Check the supplyAvailable of the twin + const [exists, twin] = await twinHandler.connect(assistant).getTwin(twin20.id); + expect(exists).to.be.true; + expect(twin.supplyAvailable).to.equal(twin20.supplyAvailable); + }); - // It should match ExchangeState.Redeemed - assert.equal(response, ExchangeState.Redeemed, "Exchange state is incorrect"); + it("Should transfer the twin even if supplyAvailable is equal to amount", async function () { + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); + offer.quantityAvailable = "1"; + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; - // Attempt to cancel the voucher, expecting revert - await expect(exchangeHandler.connect(buyer).cancelVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.INVALID_STATE - ); - }); + // Create a new offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - it("exchange is not in committed state", async function () { - // Revoke the voucher - await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); + twin20.supplyAvailable = "3"; + twin20.id = "4"; - // Attempt to cancel the voucher, expecting revert - await expect(exchangeHandler.connect(buyer).cancelVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.INVALID_STATE - ); - }); + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); - it("caller does not own voucher", async function () { - // Attempt to cancel the voucher, expecting revert - await expect(exchangeHandler.connect(rando).cancelVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.NOT_VOUCHER_HOLDER - ); - }); + // Create a new bundle + bundle = new Bundle("1", seller.id, [++offerId], [twin20.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - it("getCurrentSenderAddress() returns zero address and has isMetaTransaction set to true on chain", async function () { - await upgradeMetaTransactionsHandlerFacet(); + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - await mockMetaTransactionsHandler.setAsMetaTransactionAndCurrentSenderAs(ZeroAddress); + // Set time forward to the offer's voucherRedeemableFrom + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Attempt to cancel the voucher, expecting revert - await expect(exchangeHandler.connect(rando).cancelVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.INVALID_ADDRESS - ); + // Redeem the second voucher + await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchange.id)) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin20.id, twin20.tokenAddress, exchange.id, "0", twin20.amount, await buyer.getAddress()); + + // Check the buyer's balance + balance = await foreign20.balanceOf(await buyer.getAddress()); + expect(balance).to.equal(3); + + const [, twin] = await twinHandler.getTwin(twin20.id); + expect(twin.supplyAvailable).to.equal(0); }); - }); - }); - context("👉 expireVoucher()", async function () { - beforeEach(async function () { - // Commit to offer, retrieving the event - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - }); + context("Twin transfer fail", async function () { + it("should raise a dispute when buyer is an EOA", async function () { + // Remove the approval for the protocol to transfer the seller's tokens + await foreign20.connect(assistant).approve(protocolDiamondAddress, "0"); - it("should emit an VoucherExpired event when anyone calls and voucher has expired", async function () { - // Set time forward past the voucher's validUntilDate - await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); + const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // Expire the voucher, expecting event - await expect(exchangeHandler.connect(rando).expireVoucher(exchange.id)) - .to.emit(exchangeHandler, "VoucherExpired") - .withArgs(offerId, exchange.id, await rando.getAddress()); - }); + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchangeId, exchange.buyerId, seller.id, await buyer.getAddress()); - it("should update state when anyone calls and voucher has expired", async function () { - // Set time forward past the voucher's validUntilDate - await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs( + twin20.id, + twin20.tokenAddress, + exchange.id, + twin20.tokenId, + twin20.amount, + await buyer.getAddress() + ); - // Expire the voucher - await exchangeHandler.connect(rando).expireVoucher(exchange.id); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); - // It should match ExchangeState.Canceled - assert.equal(response, ExchangeState.Canceled, "Exchange state is incorrect"); - }); + it("should raise a dispute exchange when ERC20 contract transferFrom returns false", async function () { + const [foreign20ReturnFalse] = await deployMockTokens(["Foreign20TransferFromReturnFalse"]); - it("should update voucher expired flag when anyone calls and voucher has expired", async function () { - // Set time forward past the voucher's validUntilDate - await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); + await foreign20ReturnFalse.connect(assistant).mint(await assistant.getAddress(), "500"); + await foreign20ReturnFalse.connect(assistant).approve(protocolDiamondAddress, "100"); - // Expire the voucher - await exchangeHandler.connect(rando).expireVoucher(exchange.id); + // Create a new ERC20 twin + twin20 = mockTwin(await foreign20ReturnFalse.getAddress(), TokenType.FungibleToken); + twin20.id = "4"; - // Get the voucher - [, , response] = await exchangeHandler.connect(rando).getExchange(exchange.id); + // Create a new twin + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); - // Marshal response to entity - voucher = Voucher.fromStruct(response); - expect(voucher.isValid()); + // Create a new offer + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; - // Exchange's voucher expired flag should be true - assert.isTrue(voucher.expired, "Voucher expired flag not set"); - }); + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - context("💔 Revert Reasons", async function () { - it("The exchanges region of protocol is paused", async function () { - // Pause the exchanges region of the protocol - await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + // Set time forward to the offer's voucherRedeemableFrom + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Attempt to complete an exchange, expecting revert - await expect(exchangeHandler.connect(buyer).expireVoucher(exchangeId)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.REGION_PAUSED - ); - }); + // Create a new bundle + await bundleHandler.connect(assistant).createBundle(new Bundle("1", seller.id, [++offerId], [twin20.id])); + + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + + await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchange.id)) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin20.id, twin20.tokenAddress, exchange.id, "0", twin20.amount, await buyer.getAddress()); - it("exchange id is invalid", async function () { - // Set time forward past the voucher's validUntilDate - await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // An invalid exchange id - exchangeId = "666"; + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); - // Attempt to cancel the voucher, expecting revert - await expect(exchangeHandler.connect(buyer).expireVoucher(exchangeId)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.NO_SUCH_EXCHANGE - ); - }); + it("should raise a dispute when buyer account is a contract", async function () { + // Remove the approval for the protocol to transfer the seller's tokens + await foreign20.connect(assistant).approve(protocolDiamondAddress, "0"); - it("cannot expire voucher when exchange is in Redeemed state", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Deploy contract to test redeem called by another contract + let TestProtocolFunctionsFactory = await getContractFactory("TestProtocolFunctions"); + const testProtocolFunctions = await TestProtocolFunctionsFactory.deploy(protocolDiamondAddress); + await testProtocolFunctions.waitForDeployment(); - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + await testProtocolFunctions.commit(offerId, { value: price }); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + let exchangeId = ++exchange.id; + // Protocol should raised dispute automatically if transfer twin failed + const tx = await testProtocolFunctions.redeem(exchangeId); - // It should match ExchangeState.Redeemed - assert.equal(response, ExchangeState.Redeemed, "Exchange state is incorrect"); + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchangeId, ++exchange.buyerId, seller.id, await testProtocolFunctions.getAddress()); - // Attempt to expire the voucher, expecting revert - await expect(exchangeHandler.connect(buyer).expireVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.INVALID_STATE - ); - }); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs( + twin20.id, + twin20.tokenAddress, + exchangeId, + twin20.tokenId, + twin20.amount, + await testProtocolFunctions.getAddress() + ); - it("exchange is not in committed state", async function () { - // Set time forward past the voucher's validUntilDate - await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // Revoke the voucher - await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); - // Attempt to expire the voucher, expecting revert - await expect(exchangeHandler.connect(buyer).expireVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.INVALID_STATE - ); - }); + it("if twin transfers consume all available gas, redeem still succeeds, but exchange is disputed", async function () { + const [foreign20gt, foreign20gt_2] = await deployMockTokens(["Foreign20GasTheft", "Foreign20GasTheft"]); - it("Redemption period has not yet elapsed", async function () { - // Attempt to cancel the voucher, expecting revert - await expect(exchangeHandler.connect(rando).expireVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.VOUCHER_STILL_VALID - ); + // Approve the protocol diamond to transfer seller's tokens + await foreign20gt.connect(assistant).approve(protocolDiamondAddress, "100"); + await foreign20gt_2.connect(assistant).approve(protocolDiamondAddress, "100"); - // Set time forward past the last valid timestamp - await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid)); + // Create two ERC20 twins that will consume all available gas + twin20 = mockTwin(await foreign20gt.getAddress()); + twin20.amount = "1"; + twin20.supplyAvailable = "100"; + twin20.id = "4"; - // Attempt to cancel the voucher, expecting revert - await expect(exchangeHandler.connect(rando).expireVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.VOUCHER_STILL_VALID - ); - }); - }); - }); + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); - context("👉 redeemVoucher()", async function () { - beforeEach(async function () { - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - }); + const twin20_2 = twin20.clone(); + twin20_2.id = "5"; + twin20_2.tokenAddress = await foreign20gt_2.getAddress(); + await twinHandler.connect(assistant).createTwin(twin20_2.toStruct()); - it("should emit a VoucherRedeemed event when buyer calls", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Create a new offer and bundle + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin20.id, twin20_2.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // Redeem the voucher, expecting event - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)) - .to.emit(exchangeHandler, "VoucherRedeemed") - .withArgs(offerId, exchange.id, await buyer.getAddress()); - }); + // Commit to offer + const buyerAddress = await buyer.getAddress(); + await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); - it("should update state", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + exchange.id = Number(exchange.id) + 1; - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // Redeem the voucher + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 1000000 }); // limit gas to speed up test - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Dispute should be raised and both transfers should fail + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); - // It should match ExchangeState.Redeemed - assert.equal(response, ExchangeState.Redeemed, "Exchange state is incorrect"); - }); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin20.id, twin20.tokenAddress, exchange.id, twin20.tokenId, twin20.amount, buyerAddress); - it("It's possible to redeem at the the end of voucher validity period", async function () { - // Set time forward to the offer's validUntilDate - await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid)); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs( + twin20_2.id, + twin20_2.tokenAddress, + exchange.id, + twin20_2.tokenId, + twin20_2.amount, + await buyer.getAddress() + ); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // Redeem the voucher, expecting event - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)).to.emit( - exchangeHandler, - "VoucherRedeemed" - ); - }); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); - it("should work on an additional collection", async function () { - // Create a new collection - const externalId = `Brand1`; - voucherInitValues.collectionSalt = encodeBytes32String(externalId); - await accountHandler.connect(assistant).createNewCollection(externalId, voucherInitValues); + it("Too many twins", async function () { + await provider.send("evm_setBlockGasLimit", ["0x1c9c380"]); // 30,000,000. Need to set this limit, otherwise the coverage test will fail - offer.collectionIndex = 1; - offer.id = await offerHandler.getNextOfferId(); - exchange.id = await exchangeHandler.getNextExchangeId(); - const tokenId = deriveTokenId(offer.id, exchange.id); + const twinCount = 188; - // Create the offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Approve the protocol diamond to transfer seller's tokens + await foreign20.connect(assistant).approve(protocolDiamondAddress, twinCount * 10, { gasLimit: 30000000 }); - // Commit to offer, creating a new exchange - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id, { value: price }); + twin20 = mockTwin(await foreign20.getAddress()); + twin20.amount = "1"; + twin20.supplyAvailable = "1"; - // expected address of the first additional collection - const additionalCollectionAddress = calculateCloneAddress( - await accountHandler.getAddress(), - beaconProxyAddress, - admin.address, - voucherInitValues.collectionSalt - ); - const additionalCollection = await getContractAt("BosonVoucher", additionalCollectionAddress); + for (let i = 0; i < twinCount; i++) { + await twinHandler.connect(assistant).createTwin(twin20.toStruct(), { gasLimit: 30000000 }); + } - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Create a new offer and bundle + const twinIds = [...Array(twinCount + 4).keys()].slice(4); - // Redeem the voucher, expecting event - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)) - .to.emit(additionalCollection, "Transfer") - .withArgs(buyer.address, ZeroAddress, tokenId); - }); + offer.quantityAvailable = 1; + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit, { + gasLimit: 30000000, + }); + bundle = new Bundle("2", seller.id, [`${++offerId}`], twinIds); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct(), { gasLimit: 30000000 }); - context("💔 Revert Reasons", async function () { - it("The exchanges region of protocol is paused", async function () { - // Pause the exchanges region of the protocol - await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + // Commit to offer + const buyerAddress = await buyer.getAddress(); + await exchangeHandler + .connect(buyer) + .commitToOffer(buyerAddress, offerId, { value: price, gasLimit: 30000000 }); - // Attempt to complete an exchange, expecting revert - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.REGION_PAUSED - ); - }); + exchange.id = Number(exchange.id) + 1; - it("exchange id is invalid", async function () { - // An invalid exchange id - exchangeId = "666"; + // Redeem the voucher + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 30000000 }); - // Attempt to redeem the voucher, expecting revert - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.NO_SUCH_EXCHANGE - ); - }); + // Dispute should be raised and twin transfer should be skipped + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); - it("exchange is not in committed state", async function () { - // Revoke the voucher - await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferSkipped") + .withArgs(exchange.id, twinCount, buyerAddress); - // Attempt to redeem the voucher, expecting revert - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.INVALID_STATE - ); - }); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - it("caller does not own voucher", async function () { - // Attempt to redeem the voucher, expecting revert - await expect(exchangeHandler.connect(rando).redeemVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.NOT_VOUCHER_HOLDER - ); - }); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); - it("current time is prior to offer's voucherRedeemableFrom", async function () { - // Attempt to redeem the voucher, expecting revert - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.VOUCHER_NOT_REDEEMABLE - ); - }); + it("if twin returns a long return, redeem still succeeds, but exchange is disputed", async function () { + const [foreign20rb] = await deployMockTokens(["Foreign20ReturnBomb"]); - it("current time is after to voucher's validUntilDate", async function () { - // Set time forward past the voucher's validUntilDate - await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + 1); + // Approve the protocol diamond to transfer seller's tokens + await foreign20rb.connect(assistant).approve(protocolDiamondAddress, "100"); - // Attempt to redeem the voucher, expecting revert - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)).to.revertedWithCustomError( - bosonErrors, - RevertReasons.VOUCHER_NOT_REDEEMABLE - ); - }); - }); - }); + // Create two ERC20 twins that will consume all available gas + twin20 = mockTwin(await foreign20rb.getAddress()); + twin20.amount = "1"; + twin20.supplyAvailable = "100"; + twin20.id = "4"; - context("👉 redeemVoucher() with bundle", async function () { - beforeEach(async function () { - // Mint some tokens to be bundled - await foreign20.connect(assistant).mint(await assistant.getAddress(), "500"); - // Mint first two and last two tokens of range - await foreign721.connect(assistant).mint("1", "10"); - await foreign1155.connect(assistant).mint("1", "500"); + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); - // Approve the protocol diamond to transfer seller's tokens - await foreign20.connect(assistant).approve(protocolDiamondAddress, "30"); - await foreign721.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - await foreign1155.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + // Create a new offer and bundle + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin20.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // Create an ERC20 twin - twin20 = mockTwin(await foreign20.getAddress()); - twin20.amount = "3"; - twin20.supplyAvailable = "30"; - expect(twin20.isValid()).is.true; + // Commit to offer + const buyerAddress = await buyer.getAddress(); + await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); - // Create an ERC721 twin - twin721 = mockTwin(await foreign721.getAddress(), TokenType.NonFungibleToken); - twin721.id = "2"; - twin721.amount = "0"; - twin721.supplyAvailable = "10"; - twin721.tokenId = "1"; - expect(twin721.isValid()).is.true; + exchange.id = Number(exchange.id) + 1; - // Create an ERC1155 twin - twin1155 = mockTwin(await foreign1155.getAddress(), TokenType.MultiToken); - twin1155.id = "3"; - twin1155.tokenId = "1"; - twin1155.amount = "1"; - twin1155.supplyAvailable = "10"; + // Redeem the voucher + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - expect(twin1155.isValid()).is.true; + // Dispute should be raised and both transfers should fail + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); - // All the twin ids (for mixed bundle) - twinIds = [twin20.id, twin721.id, twin1155.id]; + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin20.id, twin20.tokenAddress, exchange.id, twin20.tokenId, twin20.amount, buyerAddress); - // Create twins - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); - await twinHandler.connect(assistant).createTwin(twin721.toStruct()); - await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); - }); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - context("📦 Offer bundled with ERC20 twin", async function () { - beforeEach(async function () { - // Create a new bundle - bundle = new Bundle("1", seller.id, [offerId], [twin20.id]); - expect(bundle.isValid()).is.true; - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + context("Malformed return", async function () { + const attackTypes = { + "too short": 0, + "too long": 1, + invalid: 2, + }; + let foreign20mr; - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - }); + beforeEach(async function () { + [foreign20mr] = await deployMockTokens(["Foreign20MalformedReturn"]); - it("should transfer the twin", async function () { - // Check the buyer's balance of the ERC20 - balance = await foreign20.balanceOf(await buyer.getAddress()); - expect(balance).to.equal(0); + // Approve the protocol diamond to transfer seller's tokens + await foreign20mr.connect(assistant).approve(protocolDiamondAddress, "100"); - // Redeem the voucher - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 600000 })) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs( - twin20.id, - twin20.tokenAddress, - exchange.id, - twin20.tokenId, - twin20.amount, - await buyer.getAddress() - ); + // Create two ERC20 twins that will consume all available gas + twin20 = mockTwin(await foreign20mr.getAddress()); + twin20.amount = "1"; + twin20.supplyAvailable = "100"; + twin20.id = "4"; - // Check the buyer's balance of the ERC20 - balance = await foreign20.balanceOf(await buyer.getAddress()); - expect(balance).to.equal(3); - }); + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); - it("Amount should be reduced from twin supplyAvailable", async function () { - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // Create a new offer and bundle + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin20.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // Check twin supplyAvailable - const [, twin] = await twinHandler.connect(assistant).getTwin(twin20.id); + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); - expect(twin.supplyAvailable).to.equal(twin20.supplyAvailable - twin20.amount); - }); + exchange.id = Number(exchange.id) + 1; + }); - it("Should not decrease twin supplyAvailable if supply is unlimited", async function () { - // Change twin supply to unlimited - twin20.supplyAvailable = MaxUint256.toString(); - twin20.id = "4"; + Object.entries(attackTypes).forEach((attackType) => { + const [type, enumType] = attackType; + it(`return value is ${type}, redeem still succeeds, but the exchange is disputed`, async function () { + await foreign20mr.setAttackType(enumType); - // Create a new twin - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + // Redeem the voucher + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); - offer.quantityAvailable = "2"; - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + // Dispute should be raised and both transfers should fail + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, buyer.address); - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin20.id, twin20.tokenAddress, exchange.id, twin20.tokenId, twin20.amount, buyer.address); - // Create a new bundle - bundle = new Bundle("1", seller.id, [++offerId], [twin20.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); + }); + }); - // Set time forward to the offer's voucherRedeemableFrom - voucherRedeemableFrom = offerDates.voucherRedeemableFrom; - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + it("If multiple transfers fail, a dispute is raised only once", async function () { + const [foreign20, foreign20_2] = await deployMockTokens(["Foreign20", "Foreign20"]); - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // Approve the protocol diamond to transfer seller's tokens + await foreign20.connect(assistant).approve(protocolDiamondAddress, "100"); + await foreign20_2.connect(assistant).approve(protocolDiamondAddress, "100"); - // Check the supplyAvailable of the twin - const [exists, twin] = await twinHandler.connect(assistant).getTwin(twin20.id); - expect(exists).to.be.true; - expect(twin.supplyAvailable).to.equal(twin20.supplyAvailable); - }); + // Create two ERC20 twins that will consume all available gas + twin20 = mockTwin(await foreign20.getAddress()); + twin20.amount = "1"; + twin20.supplyAvailable = "100"; + twin20.id = "4"; - it("Should transfer the twin even if supplyAvailable is equal to amount", async function () { - const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); - offer.quantityAvailable = "1"; - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + const twin20_2 = twin20.clone(); + twin20_2.id = "5"; + twin20_2.tokenAddress = await foreign20_2.getAddress(); + await twinHandler.connect(assistant).createTwin(twin20_2.toStruct()); - twin20.supplyAvailable = "3"; - twin20.id = "4"; + // Create a new offer and bundle + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin20.id, twin20_2.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + // Commit to offer + const buyerAddress = await buyer.getAddress(); + await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); - // Create a new bundle - bundle = new Bundle("1", seller.id, [++offerId], [twin20.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + exchange.id = Number(exchange.id) + 1; - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + await foreign20.connect(assistant).approve(protocolDiamondAddress, "0"); + await foreign20_2.connect(assistant).approve(protocolDiamondAddress, "0"); - // Set time forward to the offer's voucherRedeemableFrom - voucherRedeemableFrom = offerDates.voucherRedeemableFrom; - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Redeem the voucher + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 1000000 }); // limit gas to speed up test - // Redeem the second voucher - await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchange.id)) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin20.id, twin20.tokenAddress, exchange.id, "0", twin20.amount, await buyer.getAddress()); + const DisputeRaisedTopic = id("DisputeRaised(uint256,uint256,uint256,address)"); + const TwinTransferFailedTopic = id("TwinTransferFailed(uint256,address,uint256,uint256,uint256,address)"); - // Check the buyer's balance - balance = await foreign20.balanceOf(await buyer.getAddress()); - expect(balance).to.equal(3); + const logs = (await tx.wait()).logs; + let eventCountDR = 0; + let eventCountTTF = 0; + for (const l of logs) { + const topic = l.topics[0]; + if (topic === DisputeRaisedTopic) { + eventCountDR++; + } else if (topic === TwinTransferFailedTopic) { + eventCountTTF++; + } + } - const [, twin] = await twinHandler.getTwin(twin20.id); - expect(twin.supplyAvailable).to.equal(0); - }); + // There should be 1 DisputeRaised and 2 TwinTransferFailed events + expect(eventCountDR).to.equal(1, "DisputeRaised event count is incorrect"); + expect(eventCountTTF).to.equal(2, "TwinTransferFailed event count is incorrect"); + }); - context("Twin transfer fail", async function () { - it("should raise a dispute when buyer is an EOA", async function () { - // Remove the approval for the protocol to transfer the seller's tokens - await foreign20.connect(assistant).approve(protocolDiamondAddress, "0"); + it("should raise a dispute if ERC20 does not exist anymore", async function () { + // Destruct the ERC20 + await foreign20.destruct(); const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); @@ -3601,839 +3254,887 @@ describe("IBosonExchangeHandler", function () { // It should match ExchangeState.Disputed assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); }); + }); + }); - it("should raise a dispute exchange when ERC20 contract transferFrom returns false", async function () { - const [foreign20ReturnFalse] = await deployMockTokens(["Foreign20TransferFromReturnFalse"]); + context("📦 Offer bundled with ERC721 twin", async function () { + beforeEach(async function () { + // Create a new bundle + bundle = new Bundle("1", seller.id, [offerId], [twin721.id]); + expect(bundle.isValid()).is.true; + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - await foreign20ReturnFalse.connect(assistant).mint(await assistant.getAddress(), "500"); - await foreign20ReturnFalse.connect(assistant).approve(protocolDiamondAddress, "100"); + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - // Create a new ERC20 twin - twin20 = mockTwin(await foreign20ReturnFalse.getAddress(), TokenType.FungibleToken); - twin20.id = "4"; + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + }); - // Create a new twin - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + it("Should transfer the twin", async function () { + // Start with last id + let tokenId = "10"; - // Create a new offer - const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + // Check the assistant owns the last ERC721 of twin range + owner = await foreign721.ownerOf(tokenId); + expect(owner).to.equal(await assistant.getAddress()); + [exists, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Redeem the voucher + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, "0", await buyer.getAddress()); - // Set time forward to the offer's voucherRedeemableFrom - voucherRedeemableFrom = offerDates.voucherRedeemableFrom; - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Check the buyer owns the last ERC721 of twin range + owner = await foreign721.ownerOf(tokenId); + expect(owner).to.equal(await buyer.getAddress()); - // Create a new bundle - await bundleHandler.connect(assistant).createBundle(new Bundle("1", seller.id, [++offerId], [twin20.id])); + tokenId = "9"; + // Check the assistant owns the last ERC721 of twin range + owner = await foreign721.ownerOf(tokenId); + expect(owner).to.equal(await assistant.getAddress()); - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + // Commit to offer for the second time + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchange.id)) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin20.id, twin20.tokenAddress, exchange.id, "0", twin20.amount, await buyer.getAddress()); + // Redeem the second voucher for the second time / id = 2 + await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchange.id)) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, "0", await buyer.getAddress()); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Check the buyer owns the last ERC721 of twin range + owner = await foreign721.ownerOf(tokenId); + expect(owner).to.equal(await buyer.getAddress()); + }); - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); + it("1 should be reduced from twin supplyAvailable", async function () { + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - it("should raise a dispute when buyer account is a contract", async function () { - // Remove the approval for the protocol to transfer the seller's tokens - await foreign20.connect(assistant).approve(protocolDiamondAddress, "0"); + // Check twin supplyAvailable + const [, twin] = await twinHandler.connect(assistant).getTwin(twin721.id); - // Deploy contract to test redeem called by another contract - let TestProtocolFunctionsFactory = await getContractFactory("TestProtocolFunctions"); - const testProtocolFunctions = await TestProtocolFunctionsFactory.deploy(protocolDiamondAddress); - await testProtocolFunctions.waitForDeployment(); + expect(twin.supplyAvailable).to.equal(twin721.supplyAvailable - 1); + }); - await testProtocolFunctions.commit(offerId, { value: price }); + it("Should transfer the twin even if supplyAvailable is equal to 1", async function () { + await foreign721.connect(assistant).mint("11", "1"); - let exchangeId = ++exchange.id; - // Protocol should raised dispute automatically if transfer twin failed - const tx = await testProtocolFunctions.redeem(exchangeId); + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); + offer.quantityAvailable = "1"; + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchangeId, ++exchange.buyerId, seller.id, await testProtocolFunctions.getAddress()); + // Create a new offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs( - twin20.id, - twin20.tokenAddress, - exchangeId, - twin20.tokenId, - twin20.amount, - await testProtocolFunctions.getAddress() - ); + twin721.supplyAvailable = "1"; + twin721.tokenId = "11"; + twin721.id = "4"; - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Create a new twin + await twinHandler.connect(assistant).createTwin(twin721.toStruct()); - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); + // Create a new bundle + bundle = new Bundle("1", seller.id, [++offerId], [twin721.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - it("if twin transfers consume all available gas, redeem still succeeds, but exchange is disputed", async function () { - const [foreign20gt, foreign20gt_2] = await deployMockTokens(["Foreign20GasTheft", "Foreign20GasTheft"]); + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - // Approve the protocol diamond to transfer seller's tokens - await foreign20gt.connect(assistant).approve(protocolDiamondAddress, "100"); - await foreign20gt_2.connect(assistant).approve(protocolDiamondAddress, "100"); + // Set time forward to the offer's voucherRedeemableFrom + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Create two ERC20 twins that will consume all available gas - twin20 = mockTwin(await foreign20gt.getAddress()); - twin20.amount = "1"; - twin20.supplyAvailable = "100"; - twin20.id = "4"; + let tokenId = "11"; - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + // Redeem the second voucher + await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchange.id)) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, twin721.amount, await buyer.getAddress()); - const twin20_2 = twin20.clone(); - twin20_2.id = "5"; - twin20_2.tokenAddress = await foreign20gt_2.getAddress(); - await twinHandler.connect(assistant).createTwin(twin20_2.toStruct()); + // Check the buyer owns the first ERC721 in twin range + owner = await foreign721.ownerOf(tokenId); + expect(owner).to.equal(await buyer.getAddress()); - // Create a new offer and bundle - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin20.id, twin20_2.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + const [, twin] = await twinHandler.getTwin(twin721.id); + expect(twin.supplyAvailable).to.equal(0); + }); - // Commit to offer - const buyerAddress = await buyer.getAddress(); - await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); + context("Check twinRangesBySeller slot", async function () { + let sellerTwinRangesSlot, protocolLookupsSlotNumber; - exchange.id = Number(exchange.id) + 1; + beforeEach(async function () { + // starting slot + const protocolLookupsSlot = id("boson.protocol.lookups"); + protocolLookupsSlotNumber = BigInt(protocolLookupsSlot); - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 1000000 }); // limit gas to speed up test + // seller id mapping from twinRangesBySeller + const firstMappingSlot = BigInt( + getMappingStoragePosition(protocolLookupsSlotNumber + 22n, Number(seller.id), paddingType.START) + ); - // Dispute should be raised and both transfers should fail - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); + // token address mapping from twinRangesBySeller + const secondMappingSlot = getMappingStoragePosition( + firstMappingSlot, + twin721.tokenAddress.toLowerCase(), + paddingType.START + ); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin20.id, twin20.tokenAddress, exchange.id, twin20.tokenId, twin20.amount, buyerAddress); + sellerTwinRangesSlot = BigInt(keccak256(secondMappingSlot)); + }); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs( - twin20_2.id, - twin20_2.tokenAddress, - exchange.id, - twin20_2.tokenId, - twin20_2.amount, - await buyer.getAddress() - ); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + it("Should reduce end in twinRangesBySeller range", async function () { + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + + const start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot); + expect(start).to.equal(zeroPadValue(toHexString(BigInt("1")), 32)); - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + const end = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 1n); + expect(end).to.equal(zeroPadValue(toHexString(BigInt("9")), 32)); }); - it("Too many twins", async function () { - await provider.send("evm_setBlockGasLimit", ["0x1c9c380"]); // 30,000,000. Need to set this limit, otherwise the coverage test will fail + it("Should remove element from range when transferring last twin", async function () { + let exchangeId = 1; + let supply = 9; - const twinCount = 188; + // redeem first exchange and increase exchangeId + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId++); - // Approve the protocol diamond to transfer seller's tokens - await foreign20.connect(assistant).approve(protocolDiamondAddress, twinCount * 10, { gasLimit: 30000000 }); + while (exchangeId <= 10) { + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); - twin20 = mockTwin(await foreign20.getAddress()); - twin20.amount = "1"; - twin20.supplyAvailable = "1"; + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - for (let i = 0; i < twinCount; i++) { - await twinHandler.connect(assistant).createTwin(twin20.toStruct(), { gasLimit: 30000000 }); - } + let expectedStart; + let expectedEnd; + if (exchangeId == 10) { + // Last transfer should remove range + expectedStart = zeroPadValue(toHexString(BigInt("0")), 32); + expectedEnd = zeroPadValue(toHexString(BigInt("0")), 32); + } else { + expectedStart = zeroPadValue(toHexString(BigInt("1")), 32); + expectedEnd = zeroPadValue(toHexString(BigInt(--supply)), 32); + } + const start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot); + expect(start).to.equal(expectedStart); - // Create a new offer and bundle - const twinIds = [...Array(twinCount + 4).keys()].slice(4); + const end = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 1n); + expect(end).to.equal(expectedEnd); - offer.quantityAvailable = 1; - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit, { - gasLimit: 30000000, - }); - bundle = new Bundle("2", seller.id, [`${++offerId}`], twinIds); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct(), { gasLimit: 30000000 }); + exchangeId++; + } + }); - // Commit to offer - const buyerAddress = await buyer.getAddress(); - await exchangeHandler - .connect(buyer) - .commitToOffer(buyerAddress, offerId, { value: price, gasLimit: 30000000 }); + it("Should remove rangeIdByTwin when transferring last token from range", async () => { + const rangeIdByTwinMappingSlot = BigInt( + getMappingStoragePosition(protocolLookupsSlotNumber + 32n, Number(twin721.id), paddingType.START) + ); - exchange.id = Number(exchange.id) + 1; + let exchangeId = 1; - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 30000000 }); + // redeem first exchange and increase exchangeId + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId++); - // Dispute should be raised and twin transfer should be skipped - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); + while (exchangeId <= 10) { + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferSkipped") - .withArgs(exchange.id, twinCount, buyerAddress); + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + let expectedRangeId = BigInt("1"); + if (exchangeId == 10) { + expectedRangeId = BigInt("0"); + } - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); + const rangeId = await getStorageAt(protocolDiamondAddress, rangeIdByTwinMappingSlot); + expect(rangeId).to.equal(expectedRangeId); - it("if twin returns a long return, redeem still succeeds, but exchange is disputed", async function () { - const [foreign20rb] = await deployMockTokens(["Foreign20ReturnBomb"]); + exchangeId++; + } + }); - // Approve the protocol diamond to transfer seller's tokens - await foreign20rb.connect(assistant).approve(protocolDiamondAddress, "100"); + it("If seller has more than one range for the same token should remove correct range", async () => { + // Create a new twin with the same token addresses + twin721.id = "4"; + twin721.tokenId = "11"; - // Create two ERC20 twins that will consume all available gas - twin20 = mockTwin(await foreign20rb.getAddress()); - twin20.amount = "1"; - twin20.supplyAvailable = "100"; - twin20.id = "4"; + await twinHandler.connect(assistant).createTwin(twin721.toStruct()); - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + // Create a new offer + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); + offer.quantityAvailable = "10"; + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; - // Create a new offer and bundle await offerHandler .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin20.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // Commit to offer - const buyerAddress = await buyer.getAddress(); - await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); - - exchange.id = Number(exchange.id) + 1; + // Bundle offer with twin + bundle = new Bundle("2", seller.id, [++offerId], [twin721.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // First range + let range1Start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot); + let expectedRange1Start = zeroPadValue(toHexString(BigInt("1")), 32); + expect(range1Start).to.equal(expectedRange1Start); - // Dispute should be raised and both transfers should fail - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); + let range1End = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 1n); + let expectedRange1End = zeroPadValue(toHexString(BigInt("10")), 32); + expect(range1End).to.equal(expectedRange1End); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin20.id, twin20.tokenAddress, exchange.id, twin20.tokenId, twin20.amount, buyerAddress); + let range1twinId = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 2n); + let expectedRange1twinId = zeroPadValue(toHexString(BigInt("2")), 32); // first 721 twin has id 2 + expect(range1twinId).to.equal(expectedRange1twinId); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + let rangeIdByTwin1Slot = getMappingStoragePosition(protocolLookupsSlotNumber + 32n, "2", paddingType.START); + let rangeIdByTwin1 = await getStorageAt(protocolDiamondAddress, rangeIdByTwin1Slot); + expect(rangeIdByTwin1).to.equal(1n); // rangeIdByTwin maps to index + 1 - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); + // Second range + let range2Start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 3n); + let expectedRange2Start = zeroPadValue(toHexString(BigInt("11")), 32); + expect(range2Start).to.equal(expectedRange2Start); - context("Malformed return", async function () { - const attackTypes = { - "too short": 0, - "too long": 1, - invalid: 2, - }; - let foreign20mr; + let range2End = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 4n); + let expectedRange2End = zeroPadValue(toHexString(BigInt("20")), 32); + expect(range2End).to.equal(expectedRange2End); - beforeEach(async function () { - [foreign20mr] = await deployMockTokens(["Foreign20MalformedReturn"]); + let range2twinId = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 5n); + let expectedRange2twinId = zeroPadValue(toHexString(BigInt("4")), 32); // second 721 twin has id 4 + expect(range2twinId).to.equal(expectedRange2twinId); - // Approve the protocol diamond to transfer seller's tokens - await foreign20mr.connect(assistant).approve(protocolDiamondAddress, "100"); + let rangeIdByTwin2Slot = getMappingStoragePosition(protocolLookupsSlotNumber + 32n, "4", paddingType.START); + let rangeIdByTwin2 = await getStorageAt(protocolDiamondAddress, rangeIdByTwin2Slot); + expect(rangeIdByTwin2).to.equal(2n); - // Create two ERC20 twins that will consume all available gas - twin20 = mockTwin(await foreign20mr.getAddress()); - twin20.amount = "1"; - twin20.supplyAvailable = "100"; - twin20.id = "4"; + // Set time forward to the offer's voucherRedeemableFrom + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + let exchangeId = 1; + // Redeem all twins from first offer + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId++); - // Create a new offer and bundle - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin20.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + // Reduce offer id to commit to first offer + --offerId; - // Commit to offer + while (exchangeId <= 10) { await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); - exchange.id = Number(exchange.id) + 1; - }); - - Object.entries(attackTypes).forEach((attackType) => { - const [type, enumType] = attackType; - it(`return value is ${type}, redeem still succeeds, but the exchange is disputed`, async function () { - await foreign20mr.setAttackType(enumType); + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + exchangeId++; + } - // Dispute should be raised and both transfers should fail - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyer.address); + // First range now should be second range + range1Start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot); + expect(range1Start).to.equal(expectedRange2Start); + range1End = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 1n); + expect(range1End).to.equal(expectedRange2End); + range1twinId = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 2n); + expect(range1twinId).to.equal(expectedRange2twinId); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin20.id, twin20.tokenAddress, exchange.id, twin20.tokenId, twin20.amount, buyer.address); + // Second range should be empty + const slotEmpty = zeroPadBytes(toHexString(BigInt("0")), 32); + range2Start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 3n); + expect(range2Start).to.equal(slotEmpty); + range2End = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 4n); + expect(range2End).to.equal(slotEmpty); + range2twinId = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 5n); + expect(range2twinId).to.equal(slotEmpty); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // First twin should map to zero index + rangeIdByTwin1 = await getStorageAt(protocolDiamondAddress, rangeIdByTwin1Slot); + expect(rangeIdByTwin1).to.equal(0n); // zero indicates no range - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); - }); + // Second twin should map to first range + rangeIdByTwin2 = await getStorageAt(protocolDiamondAddress, rangeIdByTwin2Slot); + expect(rangeIdByTwin2).to.equal(1n); }); + }); - it("If multiple transfers fail, a dispute is raised only once", async function () { - const [foreign20, foreign20_2] = await deployMockTokens(["Foreign20", "Foreign20"]); - - // Approve the protocol diamond to transfer seller's tokens - await foreign20.connect(assistant).approve(protocolDiamondAddress, "100"); - await foreign20_2.connect(assistant).approve(protocolDiamondAddress, "100"); - - // Create two ERC20 twins that will consume all available gas - twin20 = mockTwin(await foreign20.getAddress()); - twin20.amount = "1"; - twin20.supplyAvailable = "100"; - twin20.id = "4"; + context("Unlimited supply", async function () { + let other721; + beforeEach(async function () { + // Deploy a new ERC721 token + let TokenContractFactory = await getContractFactory("Foreign721"); + other721 = await TokenContractFactory.connect(rando).deploy(); - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + // Mint enough tokens to cover the offer + await other721.connect(assistant).mint("1", "2"); - const twin20_2 = twin20.clone(); - twin20_2.id = "5"; - twin20_2.tokenAddress = await foreign20_2.getAddress(); - await twinHandler.connect(assistant).createTwin(twin20_2.toStruct()); + // Approve the protocol diamond to transfer seller's tokens + await other721.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - // Create a new offer and bundle + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); + offer.quantityAvailable = "2"; + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + + // Create a new offer await offerHandler .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin20.id, twin20_2.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - - // Commit to offer - const buyerAddress = await buyer.getAddress(); - await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); - exchange.id = Number(exchange.id) + 1; + // Change twin supply to unlimited and token address to the new token + twin721.supplyAvailable = MaxUint256.toString(); + twin721.tokenAddress = await other721.getAddress(); + twin721.id = "4"; + twin721.tokenId = "1"; - await foreign20.connect(assistant).approve(protocolDiamondAddress, "0"); - await foreign20_2.connect(assistant).approve(protocolDiamondAddress, "0"); + // Increase exchange id + exchange.id++; - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 1000000 }); // limit gas to speed up test + // Create a new twin with the new token address + await twinHandler.connect(assistant).createTwin(twin721.toStruct()); - const DisputeRaisedTopic = id("DisputeRaised(uint256,uint256,uint256,address)"); - const TwinTransferFailedTopic = id("TwinTransferFailed(uint256,address,uint256,uint256,uint256,address)"); + // Create a new bundle + bundle = new Bundle("1", seller.id, [++offerId], [twin721.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - const logs = (await tx.wait()).logs; - let eventCountDR = 0; - let eventCountTTF = 0; - for (const l of logs) { - const topic = l.topics[0]; - if (topic === DisputeRaisedTopic) { - eventCountDR++; - } else if (topic === TwinTransferFailedTopic) { - eventCountTTF++; - } - } + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - // There should be 1 DisputeRaised and 2 TwinTransferFailed events - expect(eventCountDR).to.equal(1, "DisputeRaised event count is incorrect"); - expect(eventCountTTF).to.equal(2, "TwinTransferFailed event count is incorrect"); + // Set time forward to the offer's voucherRedeemableFrom + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); }); - it("should raise a dispute if ERC20 does not exist anymore", async function () { - // Destruct the ERC20 - await foreign20.destruct(); + it("Should not decrease twin supplyAvailable if supply is unlimited", async function () { + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // Check the supplyAvailable of the twin + const [exists, twin] = await twinHandler.connect(assistant).getTwin(twin721.id); + expect(exists).to.be.true; + expect(twin.supplyAvailable).to.equal(twin721.supplyAvailable); + }); - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchangeId, exchange.buyerId, seller.id, await buyer.getAddress()); + it("Transfer token order must be ascending if twin supply is unlimited", async function () { + let exchangeId = exchange.id; - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs( - twin20.id, - twin20.tokenAddress, - exchange.id, - twin20.tokenId, - twin20.amount, - await buyer.getAddress() - ); + // tokenId transferred to the buyer is 1 + let expectedTokenId = "1"; - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Check the assistant owns the first ERC721 of twin range + owner = await other721.ownerOf(expectedTokenId); + expect(owner).to.equal(await assistant.getAddress()); - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); - }); - }); + // Redeem the voucher + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin721.id, twin721.tokenAddress, exchangeId, expectedTokenId, "0", await buyer.getAddress()); - context("📦 Offer bundled with ERC721 twin", async function () { - beforeEach(async function () { - // Create a new bundle - bundle = new Bundle("1", seller.id, [offerId], [twin721.id]); - expect(bundle.isValid()).is.true; - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + // Check the buyer owns the first ERC721 of twin range + owner = await other721.ownerOf(expectedTokenId); + expect(owner).to.equal(await buyer.getAddress()); - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + ++expectedTokenId; - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - }); + // Check the assistant owns the second ERC721 of twin range + owner = await other721.ownerOf(expectedTokenId); + expect(owner).to.equal(await assistant.getAddress()); - it("Should transfer the twin", async function () { - // Start with last id - let tokenId = "10"; + // Commit to offer for the second time + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - // Check the assistant owns the last ERC721 of twin range - owner = await foreign721.ownerOf(tokenId); - expect(owner).to.equal(await assistant.getAddress()); - [exists, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Redeem the voucher + // tokenId transferred to the buyer is 1 + await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchangeId)) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin721.id, twin721.tokenAddress, exchangeId, expectedTokenId, "0", await buyer.getAddress()); - // Redeem the voucher - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, "0", await buyer.getAddress()); + // Check the buyer owns the second ERC721 of twin range + owner = await other721.ownerOf(expectedTokenId); + expect(owner).to.equal(await buyer.getAddress()); + }); - // Check the buyer owns the last ERC721 of twin range - owner = await foreign721.ownerOf(tokenId); - expect(owner).to.equal(await buyer.getAddress()); + it("Should increase start in twinRangesBySeller range", async function () { + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - tokenId = "9"; - // Check the assistant owns the last ERC721 of twin range - owner = await foreign721.ownerOf(tokenId); - expect(owner).to.equal(await assistant.getAddress()); + // starting slot + const protocolLookupsSlot = id("boson.protocol.lookups"); + const protocolLookupsSlotNumber = BigInt(protocolLookupsSlot); - // Commit to offer for the second time - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + // seller id mapping from twinRangesBySeller + const firstMappingSlot = BigInt( + getMappingStoragePosition(protocolLookupsSlotNumber + 22n, Number(seller.id), paddingType.START) + ); - // Redeem the second voucher for the second time / id = 2 - await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchange.id)) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, "0", await buyer.getAddress()); + // token address mapping from twinRangesBySeller + const secondMappingSlot = getMappingStoragePosition( + firstMappingSlot, + twin721.tokenAddress.toLowerCase(), + paddingType.START + ); - // Check the buyer owns the last ERC721 of twin range - owner = await foreign721.ownerOf(tokenId); - expect(owner).to.equal(await buyer.getAddress()); + const range = {}; + const arrayStart = BigInt(keccak256(secondMappingSlot)); + (range.start = await getStorageAt(protocolDiamondAddress, arrayStart + 0n)), + (range.end = await getStorageAt(protocolDiamondAddress, arrayStart + 1n)); + + const expectedRange = { + start: zeroPadValue(toHexString(BigInt("2")), 32), + end: MaxUint256, + }; + expect(range).to.deep.equal(expectedRange); + }); }); - it("1 should be reduced from twin supplyAvailable", async function () { - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + context("Twin transfer fail", async function () { + it("should raise a dispute when buyer is an EOA", async function () { + // Remove the approval for the protocol to transfer the seller's tokens + await foreign721.connect(assistant).setApprovalForAll(protocolDiamondAddress, false); - // Check twin supplyAvailable - const [, twin] = await twinHandler.connect(assistant).getTwin(twin721.id); + const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - expect(twin.supplyAvailable).to.equal(twin721.supplyAvailable - 1); - }); + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, await buyer.getAddress()); - it("Should transfer the twin even if supplyAvailable is equal to 1", async function () { - await foreign721.connect(assistant).mint("11", "1"); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin721.id, twin721.tokenAddress, exchange.id, "10", "0", buyer.address); - const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); - offer.quantityAvailable = "1"; - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); - twin721.supplyAvailable = "1"; - twin721.tokenId = "11"; - twin721.id = "4"; + it("should raise a dispute when buyer account is a contract", async function () { + // Deploy contract to test redeem called by another contract + let TestProtocolFunctionsFactory = await getContractFactory("TestProtocolFunctions"); + const testProtocolFunctions = await TestProtocolFunctionsFactory.deploy(protocolDiamondAddress); + await testProtocolFunctions.waitForDeployment(); - // Create a new twin - await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + await testProtocolFunctions.commit(offerId, { value: price }); - // Create a new bundle - bundle = new Bundle("1", seller.id, [++offerId], [twin721.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + // Protocol should raised dispute automatically if transfer twin failed + const tx = await testProtocolFunctions.connect(buyer).redeem(++exchange.id); - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, ++exchange.buyerId, seller.id, await testProtocolFunctions.getAddress()); - // Set time forward to the offer's voucherRedeemableFrom - voucherRedeemableFrom = offerDates.voucherRedeemableFrom; - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs( + twin721.id, + twin721.tokenAddress, + exchange.id, + "10", + "0", + await testProtocolFunctions.getAddress() + ); + + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - let tokenId = "11"; + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); - // Redeem the second voucher - await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchange.id)) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, twin721.amount, await buyer.getAddress()); + it("if twin transfers consume all available gas, redeem still succeeds, but exchange is disputed", async function () { + const [foreign721gt, foreign721gt_2] = await deployMockTokens(["Foreign721GasTheft", "Foreign721GasTheft"]); - // Check the buyer owns the first ERC721 in twin range - owner = await foreign721.ownerOf(tokenId); - expect(owner).to.equal(await buyer.getAddress()); + // Approve the protocol diamond to transfer seller's tokens + await foreign721gt.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + await foreign721gt_2.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - const [, twin] = await twinHandler.getTwin(twin721.id); - expect(twin.supplyAvailable).to.equal(0); - }); + // Create two ERC721 twins that will consume all available gas + twin721 = mockTwin(await foreign721gt.getAddress(), TokenType.NonFungibleToken); + twin721.amount = "0"; + twin721.supplyAvailable = "10"; + twin721.id = "4"; - context("Check twinRangesBySeller slot", async function () { - let sellerTwinRangesSlot, protocolLookupsSlotNumber; + await twinHandler.connect(assistant).createTwin(twin721.toStruct()); - beforeEach(async function () { - // starting slot - const protocolLookupsSlot = id("boson.protocol.lookups"); - protocolLookupsSlotNumber = BigInt(protocolLookupsSlot); + const twin721_2 = twin721.clone(); + twin721_2.id = "5"; + twin721_2.tokenAddress = await foreign721gt_2.getAddress(); + await twinHandler.connect(assistant).createTwin(twin721_2.toStruct()); - // seller id mapping from twinRangesBySeller - const firstMappingSlot = BigInt( - getMappingStoragePosition(protocolLookupsSlotNumber + 22n, Number(seller.id), paddingType.START) - ); + // Create a new offer and bundle + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin721.id, twin721_2.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // token address mapping from twinRangesBySeller - const secondMappingSlot = getMappingStoragePosition( - firstMappingSlot, - twin721.tokenAddress.toLowerCase(), - paddingType.START - ); + // Commit to offer + const buyerAddress = await buyer.getAddress(); + await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); - sellerTwinRangesSlot = BigInt(keccak256(secondMappingSlot)); - }); + exchange.id = Number(exchange.id) + 1; - it("Should reduce end in twinRangesBySeller range", async function () { // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 1000000 }); // limit gas to speed up test - const start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot); - expect(start).to.equal(zeroPadValue(toHexString(BigInt("1")), 32)); + // Dispute should be raised and both transfers should fail + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); - const end = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 1n); - expect(end).to.equal(zeroPadValue(toHexString(BigInt("9")), 32)); - }); + let tokenId = "9"; + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, twin721.amount, buyerAddress); - it("Should remove element from range when transferring last twin", async function () { - let exchangeId = 1; - let supply = 9; + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin721_2.id, twin721_2.tokenAddress, exchange.id, tokenId, twin721_2.amount, buyerAddress); - // redeem first exchange and increase exchangeId - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId++); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - while (exchangeId <= 10) { - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + it("Too many twins", async function () { + await provider.send("evm_setBlockGasLimit", ["0x1c9c380"]); // 30,000,000. Need to set this limit, otherwise the coverage test will fail - let expectedStart; - let expectedEnd; - if (exchangeId == 10) { - // Last transfer should remove range - expectedStart = zeroPadValue(toHexString(BigInt("0")), 32); - expectedEnd = zeroPadValue(toHexString(BigInt("0")), 32); - } else { - expectedStart = zeroPadValue(toHexString(BigInt("1")), 32); - expectedEnd = zeroPadValue(toHexString(BigInt(--supply)), 32); - } - const start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot); - expect(start).to.equal(expectedStart); + const twinCount = 188; + const startTokenId = 100; - const end = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 1n); - expect(end).to.equal(expectedEnd); + // Approve the protocol diamond to transfer seller's tokens + await foreign721.connect(assistant).setApprovalForAll(protocolDiamondAddress, true, { gasLimit: 30000000 }); + twin721 = mockTwin(await foreign721.getAddress(), TokenType.NonFungibleToken); + twin721.amount = "0"; + twin721.supplyAvailable = "1"; - exchangeId++; + for (let i = startTokenId; i < startTokenId + twinCount; i++) { + twin721.tokenId = i; + await twinHandler.connect(assistant).createTwin(twin721.toStruct(), { gasLimit: 30000000 }); } - }); - it("Should remove rangeIdByTwin when transferring last token from range", async () => { - const rangeIdByTwinMappingSlot = BigInt( - getMappingStoragePosition(protocolLookupsSlotNumber + 32n, Number(twin721.id), paddingType.START) - ); + // Create a new offer and bundle + const twinIds = [...Array(twinCount + 4).keys()].slice(4); - let exchangeId = 1; + offer.quantityAvailable = 1; + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit, { + gasLimit: 30000000, + }); + bundle = new Bundle("2", seller.id, [`${++offerId}`], twinIds); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct(), { gasLimit: 30000000 }); - // redeem first exchange and increase exchangeId - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId++); + // Commit to offer + const buyerAddress = await buyer.getAddress(); + await exchangeHandler + .connect(buyer) + .commitToOffer(buyerAddress, offerId, { value: price, gasLimit: 30000000 }); - while (exchangeId <= 10) { - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); + exchange.id = Number(exchange.id) + 1; - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // Redeem the voucher + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 30000000 }); - let expectedRangeId = BigInt("1"); - if (exchangeId == 10) { - expectedRangeId = BigInt("0"); - } + // Dispute should be raised and twin transfer should be skipped + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); - const rangeId = await getStorageAt(protocolDiamondAddress, rangeIdByTwinMappingSlot); - expect(rangeId).to.equal(expectedRangeId); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferSkipped") + .withArgs(exchange.id, twinCount, buyerAddress); - exchangeId++; - } + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); }); - it("If seller has more than one range for the same token should remove correct range", async () => { - // Create a new twin with the same token addresses + it("if twin returns a long return, redeem still succeeds, but exchange is disputed", async function () { + const [foreign721rb] = await deployMockTokens(["Foreign721ReturnBomb"]); + + // Approve the protocol diamond to transfer seller's tokens + await foreign721rb.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + + // Create two ERC721 twins that will consume all available gas + twin721 = mockTwin(await foreign721rb.getAddress(), TokenType.NonFungibleToken); + twin721.amount = "0"; + twin721.supplyAvailable = "10"; twin721.id = "4"; - twin721.tokenId = "11"; await twinHandler.connect(assistant).createTwin(twin721.toStruct()); - // Create a new offer - const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); - offer.quantityAvailable = "10"; - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; - + // Create a new offer and bundle await offerHandler .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - - // Bundle offer with twin - bundle = new Bundle("2", seller.id, [++offerId], [twin721.id]); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin721.id]); await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // First range - let range1Start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot); - let expectedRange1Start = zeroPadValue(toHexString(BigInt("1")), 32); - expect(range1Start).to.equal(expectedRange1Start); + // Commit to offer + const buyerAddress = await buyer.getAddress(); + await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); - let range1End = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 1n); - let expectedRange1End = zeroPadValue(toHexString(BigInt("10")), 32); - expect(range1End).to.equal(expectedRange1End); + exchange.id = Number(exchange.id) + 1; - let range1twinId = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 2n); - let expectedRange1twinId = zeroPadValue(toHexString(BigInt("2")), 32); // first 721 twin has id 2 - expect(range1twinId).to.equal(expectedRange1twinId); + // Redeem the voucher + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - let rangeIdByTwin1Slot = getMappingStoragePosition(protocolLookupsSlotNumber + 32n, "2", paddingType.START); - let rangeIdByTwin1 = await getStorageAt(protocolDiamondAddress, rangeIdByTwin1Slot); - expect(rangeIdByTwin1).to.equal(1n); // rangeIdByTwin maps to index + 1 + // Dispute should be raised and both transfers should fail + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); - // Second range - let range2Start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 3n); - let expectedRange2Start = zeroPadValue(toHexString(BigInt("11")), 32); - expect(range2Start).to.equal(expectedRange2Start); + let tokenId = "9"; + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, twin721.amount, buyerAddress); + + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); + + context("Malformed return", async function () { + const attackTypes = { + "too short": 0, + "too long": 1, + invalid: 2, + }; + let foreign721mr; + + beforeEach(async function () { + [foreign721mr] = await deployMockTokens(["Foreign721MalformedReturn"]); + + // Approve the protocol diamond to transfer seller's tokens + await foreign721mr.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + + // Create two ERC721 twins that will consume all available gas + twin721 = mockTwin(await foreign721mr.getAddress(), TokenType.NonFungibleToken); + twin721.amount = "0"; + twin721.supplyAvailable = "10"; + twin721.id = "4"; + + await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + + // Create a new offer and bundle + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin721.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); - let range2End = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 4n); - let expectedRange2End = zeroPadValue(toHexString(BigInt("20")), 32); - expect(range2End).to.equal(expectedRange2End); + exchange.id = Number(exchange.id) + 1; + }); - let range2twinId = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 5n); - let expectedRange2twinId = zeroPadValue(toHexString(BigInt("4")), 32); // second 721 twin has id 4 - expect(range2twinId).to.equal(expectedRange2twinId); + Object.entries(attackTypes).forEach((attackType) => { + const [type, enumType] = attackType; + it(`return value is ${type}, redeem still succeeds, but the exchange is disputed`, async function () { + await foreign721mr.setAttackType(enumType); - let rangeIdByTwin2Slot = getMappingStoragePosition(protocolLookupsSlotNumber + 32n, "4", paddingType.START); - let rangeIdByTwin2 = await getStorageAt(protocolDiamondAddress, rangeIdByTwin2Slot); - expect(rangeIdByTwin2).to.equal(2n); + // Redeem the voucher + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // Set time forward to the offer's voucherRedeemableFrom - voucherRedeemableFrom = offerDates.voucherRedeemableFrom; - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Dispute should be raised and both transfers should fail + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, buyer.address); - let exchangeId = 1; - // Redeem all twins from first offer - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId++); + let tokenId = "9"; + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, twin721.amount, buyer.address); - // Reduce offer id to commit to first offer - --offerId; + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - while (exchangeId <= 10) { - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); + }); + }); - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + it("should raise a dispute if erc721 contract does not exist anymore", async function () { + // Destruct the ERC721 + await foreign721.destruct(); - exchangeId++; - } + const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // First range now should be second range - range1Start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot); - expect(range1Start).to.equal(expectedRange2Start); - range1End = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 1n); - expect(range1End).to.equal(expectedRange2End); - range1twinId = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 2n); - expect(range1twinId).to.equal(expectedRange2twinId); + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, await buyer.getAddress()); - // Second range should be empty - const slotEmpty = zeroPadBytes(toHexString(BigInt("0")), 32); - range2Start = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 3n); - expect(range2Start).to.equal(slotEmpty); - range2End = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 4n); - expect(range2End).to.equal(slotEmpty); - range2twinId = await getStorageAt(protocolDiamondAddress, sellerTwinRangesSlot + 5n); - expect(range2twinId).to.equal(slotEmpty); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin721.id, twin721.tokenAddress, exchange.id, "10", "0", await buyer.getAddress()); - // First twin should map to zero index - rangeIdByTwin1 = await getStorageAt(protocolDiamondAddress, rangeIdByTwin1Slot); - expect(rangeIdByTwin1).to.equal(0n); // zero indicates no range + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // Second twin should map to first range - rangeIdByTwin2 = await getStorageAt(protocolDiamondAddress, rangeIdByTwin2Slot); - expect(rangeIdByTwin2).to.equal(1n); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); }); }); + }); - context("Unlimited supply", async function () { - let other721; - beforeEach(async function () { - // Deploy a new ERC721 token - let TokenContractFactory = await getContractFactory("Foreign721"); - other721 = await TokenContractFactory.connect(rando).deploy(); + context("📦 Offer bundled with ERC1155 twin", async function () { + beforeEach(async function () { + // Create a new bundle + bundle = new Bundle("1", seller.id, [offerId], [twin1155.id]); + expect(bundle.isValid()).is.true; + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // Mint enough tokens to cover the offer - await other721.connect(assistant).mint("1", "2"); + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - // Approve the protocol diamond to transfer seller's tokens - await other721.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + }); - const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); - offer.quantityAvailable = "2"; - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + it("should transfer the twin", async function () { + let tokenId = "1"; - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Check the buyer's balance of the ERC1155 + balance = await foreign1155.balanceOf(await buyer.getAddress(), tokenId); + expect(balance).to.equal(0); - // Change twin supply to unlimited and token address to the new token - twin721.supplyAvailable = MaxUint256.toString(); - twin721.tokenAddress = await other721.getAddress(); - twin721.id = "4"; - twin721.tokenId = "1"; + // Redeem the voucher + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs( + twin1155.id, + twin1155.tokenAddress, + exchange.id, + tokenId, + twin1155.amount, + await buyer.getAddress() + ); - // Increase exchange id - exchange.id++; + // Check the buyer's balance of the ERC1155 + balance = await foreign1155.balanceOf(await buyer.getAddress(), tokenId); + expect(balance).to.equal(1); + }); - // Create a new twin with the new token address - await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + it("Amount should be reduced from twin supplyAvailable", async function () { + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - // Create a new bundle - bundle = new Bundle("1", seller.id, [++offerId], [twin721.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + // Check twin supplyAvailable + const [, twin] = await twinHandler.connect(assistant).getTwin(twin1155.id); - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + expect(twin.supplyAvailable).to.equal(twin1155.supplyAvailable - twin1155.amount); + }); - // Set time forward to the offer's voucherRedeemableFrom - voucherRedeemableFrom = offerDates.voucherRedeemableFrom; - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - }); + it("Should not decrease twin supplyAvailable if supply is unlimited", async function () { + // Change twin supply to unlimited + twin1155.supplyAvailable = MaxUint256.toString(); + twin1155.id = "4"; - it("Should not decrease twin supplyAvailable if supply is unlimited", async function () { - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // Create a new twin + await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); - // Check the supplyAvailable of the twin - const [exists, twin] = await twinHandler.connect(assistant).getTwin(twin721.id); - expect(exists).to.be.true; - expect(twin.supplyAvailable).to.equal(twin721.supplyAvailable); - }); + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); + offer.quantityAvailable = "2"; + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; - it("Transfer token order must be ascending if twin supply is unlimited", async function () { - let exchangeId = exchange.id; + // Create a new offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - // tokenId transferred to the buyer is 1 - let expectedTokenId = "1"; + // Create a new bundle + bundle = new Bundle("1", seller.id, [++offerId], [twin1155.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // Check the assistant owns the first ERC721 of twin range - owner = await other721.ownerOf(expectedTokenId); - expect(owner).to.equal(await assistant.getAddress()); + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - // Redeem the voucher - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin721.id, twin721.tokenAddress, exchangeId, expectedTokenId, "0", await buyer.getAddress()); + // Set time forward to the offer's voucherRedeemableFrom + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Check the buyer owns the first ERC721 of twin range - owner = await other721.ownerOf(expectedTokenId); - expect(owner).to.equal(await buyer.getAddress()); + // Redeem the voucher + await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - ++expectedTokenId; + // Check the supplyAvailable of the twin + const [exists, twin] = await twinHandler.connect(assistant).getTwin(twin1155.id); + expect(exists).to.be.true; + expect(twin.supplyAvailable).to.equal(twin1155.supplyAvailable); + }); - // Check the assistant owns the second ERC721 of twin range - owner = await other721.ownerOf(expectedTokenId); - expect(owner).to.equal(await assistant.getAddress()); + it("Should transfer the twin even if supplyAvailable is equal to amount", async function () { + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); + offer.quantityAvailable = "1"; + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; - // Commit to offer for the second time - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + // Create a new offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - // Redeem the voucher - // tokenId transferred to the buyer is 1 - await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchangeId)) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin721.id, twin721.tokenAddress, exchangeId, expectedTokenId, "0", await buyer.getAddress()); + twin1155.supplyAvailable = "1"; + twin1155.id = "4"; - // Check the buyer owns the second ERC721 of twin range - owner = await other721.ownerOf(expectedTokenId); - expect(owner).to.equal(await buyer.getAddress()); - }); + // Create a new twin + await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); - it("Should increase start in twinRangesBySeller range", async function () { - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // Create a new bundle + bundle = new Bundle("1", seller.id, [++offerId], [twin1155.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // starting slot - const protocolLookupsSlot = id("boson.protocol.lookups"); - const protocolLookupsSlotNumber = BigInt(protocolLookupsSlot); + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - // seller id mapping from twinRangesBySeller - const firstMappingSlot = BigInt( - getMappingStoragePosition(protocolLookupsSlotNumber + 22n, Number(seller.id), paddingType.START) - ); + // Set time forward to the offer's voucherRedeemableFrom + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // token address mapping from twinRangesBySeller - const secondMappingSlot = getMappingStoragePosition( - firstMappingSlot, - twin721.tokenAddress.toLowerCase(), - paddingType.START + // Redeem the second voucher + await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchange.id)) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs( + twin1155.id, + twin1155.tokenAddress, + exchange.id, + twin1155.tokenId, + twin1155.amount, + await buyer.getAddress() ); - const range = {}; - const arrayStart = BigInt(keccak256(secondMappingSlot)); - (range.start = await getStorageAt(protocolDiamondAddress, arrayStart + 0n)), - (range.end = await getStorageAt(protocolDiamondAddress, arrayStart + 1n)); + // Check the buyer's balance + balance = await foreign1155.balanceOf(await buyer.getAddress(), twin1155.tokenId); + expect(balance).to.equal(1); - const expectedRange = { - start: zeroPadValue(toHexString(BigInt("2")), 32), - end: MaxUint256, - }; - expect(range).to.deep.equal(expectedRange); - }); + const [, twin] = await twinHandler.getTwin(twin1155.id); + expect(twin.supplyAvailable).to.equal(0); }); context("Twin transfer fail", async function () { it("should raise a dispute when buyer is an EOA", async function () { // Remove the approval for the protocol to transfer the seller's tokens - await foreign721.connect(assistant).setApprovalForAll(protocolDiamondAddress, false); + await foreign1155.connect(assistant).setApprovalForAll(protocolDiamondAddress, false); const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - await expect(tx) .to.emit(disputeHandler, "DisputeRaised") .withArgs(exchange.id, exchange.buyerId, seller.id, await buyer.getAddress()); await expect(tx) .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin721.id, twin721.tokenAddress, exchange.id, "10", "0", buyer.address); + .withArgs( + twin1155.id, + twin1155.tokenAddress, + exchange.id, + twin1155.tokenId, + twin1155.amount, + await buyer.getAddress() + ); // Get the exchange state [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); @@ -4451,8 +4152,7 @@ describe("IBosonExchangeHandler", function () { await testProtocolFunctions.commit(offerId, { value: price }); // Protocol should raised dispute automatically if transfer twin failed - const tx = await testProtocolFunctions.connect(buyer).redeem(++exchange.id); - + const tx = await testProtocolFunctions.redeem(++exchange.id); await expect(tx) .to.emit(disputeHandler, "DisputeRaised") .withArgs(exchange.id, ++exchange.buyerId, seller.id, await testProtocolFunctions.getAddress()); @@ -4460,11 +4160,11 @@ describe("IBosonExchangeHandler", function () { await expect(tx) .to.emit(exchangeHandler, "TwinTransferFailed") .withArgs( - twin721.id, - twin721.tokenAddress, + twin1155.id, + twin1155.tokenAddress, exchange.id, - "10", - "0", + twin1155.tokenId, + twin1155.amount, await testProtocolFunctions.getAddress() ); @@ -4476,30 +4176,34 @@ describe("IBosonExchangeHandler", function () { }); it("if twin transfers consume all available gas, redeem still succeeds, but exchange is disputed", async function () { - const [foreign721gt, foreign721gt_2] = await deployMockTokens(["Foreign721GasTheft", "Foreign721GasTheft"]); + const [foreign1155gt, foreign1155gt_2] = await deployMockTokens([ + "Foreign1155GasTheft", + "Foreign1155GasTheft", + ]); // Approve the protocol diamond to transfer seller's tokens - await foreign721gt.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - await foreign721gt_2.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + await foreign1155gt.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + await foreign1155gt_2.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - // Create two ERC721 twins that will consume all available gas - twin721 = mockTwin(await foreign721gt.getAddress(), TokenType.NonFungibleToken); - twin721.amount = "0"; - twin721.supplyAvailable = "10"; - twin721.id = "4"; + // Create two ERC1155 twins that will consume all available gas + twin1155 = mockTwin(await foreign1155gt.getAddress(), TokenType.MultiToken); + twin1155.amount = "1"; + twin1155.tokenId = "1"; + twin1155.supplyAvailable = "10"; + twin1155.id = "4"; - await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); - const twin721_2 = twin721.clone(); - twin721_2.id = "5"; - twin721_2.tokenAddress = await foreign721gt_2.getAddress(); - await twinHandler.connect(assistant).createTwin(twin721_2.toStruct()); + const twin1155_2 = twin1155.clone(); + twin1155_2.id = "5"; + twin1155_2.tokenAddress = await foreign1155gt_2.getAddress(); + await twinHandler.connect(assistant).createTwin(twin1155_2.toStruct()); // Create a new offer and bundle await offerHandler .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin721.id, twin721_2.id]); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin1155.id, twin1155_2.id]); await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); // Commit to offer @@ -4514,16 +4218,29 @@ describe("IBosonExchangeHandler", function () { // Dispute should be raised and both transfers should fail await expect(tx) .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); + .withArgs(exchange.id, exchange.buyerId, seller.id, buyer.address); - let tokenId = "9"; await expect(tx) .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, twin721.amount, buyerAddress); + .withArgs( + twin1155.id, + twin1155.tokenAddress, + exchange.id, + twin1155.tokenId, + twin1155.amount, + buyerAddress + ); await expect(tx) .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin721_2.id, twin721_2.tokenAddress, exchange.id, tokenId, twin721_2.amount, buyerAddress); + .withArgs( + twin1155_2.id, + twin1155_2.tokenAddress, + exchange.id, + twin1155_2.tokenId, + twin1155_2.amount, + buyerAddress + ); // Get the exchange state [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); @@ -4536,17 +4253,18 @@ describe("IBosonExchangeHandler", function () { await provider.send("evm_setBlockGasLimit", ["0x1c9c380"]); // 30,000,000. Need to set this limit, otherwise the coverage test will fail const twinCount = 188; - const startTokenId = 100; // Approve the protocol diamond to transfer seller's tokens - await foreign721.connect(assistant).setApprovalForAll(protocolDiamondAddress, true, { gasLimit: 30000000 }); - twin721 = mockTwin(await foreign721.getAddress(), TokenType.NonFungibleToken); - twin721.amount = "0"; - twin721.supplyAvailable = "1"; + await foreign1155 + .connect(assistant) + .setApprovalForAll(protocolDiamondAddress, true, { gasLimit: 30000000 }); - for (let i = startTokenId; i < startTokenId + twinCount; i++) { - twin721.tokenId = i; - await twinHandler.connect(assistant).createTwin(twin721.toStruct(), { gasLimit: 30000000 }); + twin1155 = mockTwin(await foreign1155.getAddress(), TokenType.MultiToken); + twin1155.amount = "1"; + twin1155.supplyAvailable = "1"; + + for (let i = 0; i < twinCount; i++) { + await twinHandler.connect(assistant).createTwin(twin1155.toStruct(), { gasLimit: 30000000 }); } // Create a new offer and bundle @@ -4589,24 +4307,25 @@ describe("IBosonExchangeHandler", function () { }); it("if twin returns a long return, redeem still succeeds, but exchange is disputed", async function () { - const [foreign721rb] = await deployMockTokens(["Foreign721ReturnBomb"]); + const [foreign1155rb] = await deployMockTokens(["Foreign1155ReturnBomb"]); // Approve the protocol diamond to transfer seller's tokens - await foreign721rb.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + await foreign1155rb.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - // Create two ERC721 twins that will consume all available gas - twin721 = mockTwin(await foreign721rb.getAddress(), TokenType.NonFungibleToken); - twin721.amount = "0"; - twin721.supplyAvailable = "10"; - twin721.id = "4"; + // Create two ERC1155 twins that will consume all available gas + twin1155 = mockTwin(await foreign1155rb.getAddress(), TokenType.MultiToken); + twin1155.amount = "1"; + twin1155.tokenId = "1"; + twin1155.supplyAvailable = "10"; + twin1155.id = "4"; - await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); // Create a new offer and bundle await offerHandler .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin721.id]); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin1155.id]); await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); // Commit to offer @@ -4623,10 +4342,16 @@ describe("IBosonExchangeHandler", function () { .to.emit(disputeHandler, "DisputeRaised") .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); - let tokenId = "9"; await expect(tx) .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, twin721.amount, buyerAddress); + .withArgs( + twin1155.id, + twin1155.tokenAddress, + exchange.id, + twin1155.tokenId, + twin1155.amount, + buyerAddress + ); // Get the exchange state [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); @@ -4641,27 +4366,28 @@ describe("IBosonExchangeHandler", function () { "too long": 1, invalid: 2, }; - let foreign721mr; + let foreign1155mr; beforeEach(async function () { - [foreign721mr] = await deployMockTokens(["Foreign721MalformedReturn"]); + [foreign1155mr] = await deployMockTokens(["Foreign1155MalformedReturn"]); // Approve the protocol diamond to transfer seller's tokens - await foreign721mr.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + await foreign1155mr.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - // Create two ERC721 twins that will consume all available gas - twin721 = mockTwin(await foreign721mr.getAddress(), TokenType.NonFungibleToken); - twin721.amount = "0"; - twin721.supplyAvailable = "10"; - twin721.id = "4"; + // Create two ERC1155 twins that will consume all available gas + twin1155 = mockTwin(await foreign1155mr.getAddress(), TokenType.MultiToken); + twin1155.amount = "1"; + twin1155.tokenId = "1"; + twin1155.supplyAvailable = "10"; + twin1155.id = "4"; - await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); // Create a new offer and bundle await offerHandler .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin721.id]); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin1155.id]); await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); // Commit to offer @@ -4673,7 +4399,7 @@ describe("IBosonExchangeHandler", function () { Object.entries(attackTypes).forEach((attackType) => { const [type, enumType] = attackType; it(`return value is ${type}, redeem still succeeds, but the exchange is disputed`, async function () { - await foreign721mr.setAttackType(enumType); + await foreign1155mr.setAttackType(enumType); // Redeem the voucher tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); @@ -4683,10 +4409,16 @@ describe("IBosonExchangeHandler", function () { .to.emit(disputeHandler, "DisputeRaised") .withArgs(exchange.id, exchange.buyerId, seller.id, buyer.address); - let tokenId = "9"; await expect(tx) .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, twin721.amount, buyer.address); + .withArgs( + twin1155.id, + twin1155.tokenAddress, + exchange.id, + twin1155.tokenId, + twin1155.amount, + buyer.address + ); // Get the exchange state [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); @@ -4697,19 +4429,25 @@ describe("IBosonExchangeHandler", function () { }); }); - it("should raise a dispute if erc721 contract does not exist anymore", async function () { - // Destruct the ERC721 - await foreign721.destruct(); + it("should raise a dispute if erc1155 contract does not exist anymore", async function () { + // Destruct the ERC1155 contract + await foreign1155.destruct(); const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - await expect(tx) .to.emit(disputeHandler, "DisputeRaised") .withArgs(exchange.id, exchange.buyerId, seller.id, await buyer.getAddress()); await expect(tx) .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin721.id, twin721.tokenAddress, exchange.id, "10", "0", await buyer.getAddress()); + .withArgs( + twin1155.id, + twin1155.tokenAddress, + exchange.id, + twin1155.tokenId, + twin1155.amount, + await buyer.getAddress() + ); // Get the exchange state [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); @@ -4720,10 +4458,10 @@ describe("IBosonExchangeHandler", function () { }); }); - context("📦 Offer bundled with ERC1155 twin", async function () { + context("📦 Offer bundled with mixed twins", async function () { beforeEach(async function () { // Create a new bundle - bundle = new Bundle("1", seller.id, [offerId], [twin1155.id]); + bundle = new Bundle("1", seller.id, [offerId], twinIds); expect(bundle.isValid()).is.true; await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); @@ -4734,78 +4472,69 @@ describe("IBosonExchangeHandler", function () { await setNextBlockTimestamp(Number(voucherRedeemableFrom)); }); - it("should transfer the twin", async function () { - let tokenId = "1"; + it("should transfer the twins", async function () { + let tokenIdNonFungible = "10"; + let tokenIdMultiToken = "1"; - // Check the buyer's balance of the ERC1155 - balance = await foreign1155.balanceOf(await buyer.getAddress(), tokenId); + // Check the buyer's balance of the ERC20 + balance = await foreign20.balanceOf(await buyer.getAddress()); expect(balance).to.equal(0); - // Redeem the voucher - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchange.id)) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs( - twin1155.id, - twin1155.tokenAddress, - exchange.id, - tokenId, - twin1155.amount, - await buyer.getAddress() - ); + // Check the assistant owns the ERC721 + owner = await foreign721.ownerOf(tokenIdNonFungible); + expect(owner).to.equal(await assistant.getAddress()); // Check the buyer's balance of the ERC1155 - balance = await foreign1155.balanceOf(await buyer.getAddress(), tokenId); - expect(balance).to.equal(1); - }); - - it("Amount should be reduced from twin supplyAvailable", async function () { - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - - // Check twin supplyAvailable - const [, twin] = await twinHandler.connect(assistant).getTwin(twin1155.id); - - expect(twin.supplyAvailable).to.equal(twin1155.supplyAvailable - twin1155.amount); - }); - - it("Should not decrease twin supplyAvailable if supply is unlimited", async function () { - // Change twin supply to unlimited - twin1155.supplyAvailable = MaxUint256.toString(); - twin1155.id = "4"; - - // Create a new twin - await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); + balance = await foreign1155.balanceOf(await buyer.getAddress(), tokenIdMultiToken); + expect(balance).to.equal(0); - const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); - offer.quantityAvailable = "2"; - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + let exchangeId = exchange.id; - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Redeem the voucher + const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // Create a new bundle - bundle = new Bundle("1", seller.id, [++offerId], [twin1155.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs( + twin1155.id, + twin1155.tokenAddress, + exchangeId, + tokenIdMultiToken, + twin1155.amount, + await buyer.getAddress() + ); - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + await expect(tx) + .and.to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin20.id, twin20.tokenAddress, exchangeId, "0", twin20.amount, await buyer.getAddress()); - // Set time forward to the offer's voucherRedeemableFrom - voucherRedeemableFrom = offerDates.voucherRedeemableFrom; - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + await expect(tx) + .and.to.emit(exchangeHandler, "TwinTransferred") + .withArgs( + twin721.id, + twin721.tokenAddress, + exchangeId, + tokenIdNonFungible, + twin721.amount, + await buyer.getAddress() + ); - // Redeem the voucher - await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // Check the buyer's balance of the ERC20 + balance = await foreign20.balanceOf(await buyer.getAddress()); + expect(balance).to.equal(3); - // Check the supplyAvailable of the twin - const [exists, twin] = await twinHandler.connect(assistant).getTwin(twin1155.id); - expect(exists).to.be.true; - expect(twin.supplyAvailable).to.equal(twin1155.supplyAvailable); + // Check the buyer owns the ERC721 + owner = await foreign721.ownerOf(tokenIdNonFungible); + expect(owner).to.equal(await buyer.getAddress()); + + // Check the buyer's balance of the ERC1155 + balance = await foreign1155.balanceOf(await buyer.getAddress(), tokenIdMultiToken); + expect(balance).to.equal(1); }); it("Should transfer the twin even if supplyAvailable is equal to amount", async function () { + await foreign721.connect(assistant).mint("11", "1"); + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); offer.quantityAvailable = "1"; offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; @@ -4821,8 +4550,19 @@ describe("IBosonExchangeHandler", function () { // Create a new twin await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); + twin20.supplyAvailable = "3"; + twin20.id = "5"; + + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + + twin721.supplyAvailable = "1"; + twin721.tokenId = "11"; + twin721.id = "6"; + + await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + // Create a new bundle - bundle = new Bundle("1", seller.id, [++offerId], [twin1155.id]); + bundle = new Bundle("1", seller.id, [++offerId], [twin1155.id, twin20.id, twin721.id]); await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); // Commit to offer @@ -4833,7 +4573,9 @@ describe("IBosonExchangeHandler", function () { await setNextBlockTimestamp(Number(voucherRedeemableFrom)); // Redeem the second voucher - await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchange.id)) + const tx = await exchangeHandler.connect(buyer).redeemVoucher(++exchange.id); + + await expect(tx) .to.emit(exchangeHandler, "TwinTransferred") .withArgs( twin1155.id, @@ -4844,139 +4586,203 @@ describe("IBosonExchangeHandler", function () { await buyer.getAddress() ); + await expect(tx) + .and.to.emit(exchangeHandler, "TwinTransferred") + .withArgs( + twin721.id, + twin721.tokenAddress, + exchange.id, + twin721.tokenId, + twin721.amount, + await buyer.getAddress() + ); + + await expect(tx) + .and.to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin20.id, twin20.tokenAddress, exchange.id, "0", twin20.amount, await buyer.getAddress()); + // Check the buyer's balance balance = await foreign1155.balanceOf(await buyer.getAddress(), twin1155.tokenId); expect(balance).to.equal(1); - const [, twin] = await twinHandler.getTwin(twin1155.id); + balance = await foreign721.balanceOf(await buyer.getAddress()); + expect(balance).to.equal(1); + + balance = await foreign20.balanceOf(await buyer.getAddress()); + expect(balance).to.equal(3); + + let [, twin] = await twinHandler.getTwin(twin1155.id); + expect(twin.supplyAvailable).to.equal(0); + + [, twin] = await twinHandler.getTwin(twin721.id); + expect(twin.supplyAvailable).to.equal(0); + + [, twin] = await twinHandler.getTwin(twin20.id); expect(twin.supplyAvailable).to.equal(0); }); - context("Twin transfer fail", async function () { - it("should raise a dispute when buyer is an EOA", async function () { - // Remove the approval for the protocol to transfer the seller's tokens - await foreign1155.connect(assistant).setApprovalForAll(protocolDiamondAddress, false); + context("Unlimited supply", async function () { + let other721; - const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, await buyer.getAddress()); + beforeEach(async function () { + // Deploy a new ERC721 token + let TokenContractFactory = await getContractFactory("Foreign721"); + other721 = await TokenContractFactory.connect(rando).deploy(); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs( - twin1155.id, - twin1155.tokenAddress, - exchange.id, - twin1155.tokenId, - twin1155.amount, - await buyer.getAddress() - ); + // Mint enough tokens to cover the offer + await other721.connect(assistant).mint("1", "2"); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Approve the protocol diamond to transfer seller's tokens + await other721.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); + offer.quantityAvailable = "2"; + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + + // Create a new offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + + // Change twin supply to unlimited and token address to the new token + twin721.supplyAvailable = MaxUint256.toString(); + twin721.tokenAddress = await other721.getAddress(); + twin721.id = "4"; + // Create a new ERC721 twin with the new token address + await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + + twin20.supplyAvailable = MaxUint256.toString(); + twin20.id = "5"; + // Create a new ERC20 twin with the new token address + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + + twin1155.supplyAvailable = MaxUint256.toString(); + twin1155.id = "6"; + // Create a new ERC1155 twin with the new token address + await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); + + // Create a new bundle + bundle = new Bundle("1", seller.id, [++offerId], [twin721.id, twin20.id, twin1155.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + + // Commit to offer + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + + // Set time forward to the offer's voucherRedeemableFrom + voucherRedeemableFrom = offerDates.voucherRedeemableFrom; + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + ++exchange.id; }); - it("should raise a dispute when buyer account is a contract", async function () { - // Deploy contract to test redeem called by another contract - let TestProtocolFunctionsFactory = await getContractFactory("TestProtocolFunctions"); - const testProtocolFunctions = await TestProtocolFunctionsFactory.deploy(protocolDiamondAddress); - await testProtocolFunctions.waitForDeployment(); + it("Should not decrease twin supplyAvailable if supply is unlimited", async function () { + // Redeem the voucher + const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); - await testProtocolFunctions.commit(offerId, { value: price }); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin721.id, twin721.tokenAddress, exchange.id, "1", twin721.amount, buyer.address); - // Protocol should raised dispute automatically if transfer twin failed - const tx = await testProtocolFunctions.redeem(++exchange.id); await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, ++exchange.buyerId, seller.id, await testProtocolFunctions.getAddress()); + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs( + twin20.id, + twin20.tokenAddress, + exchange.id, + twin20.tokenId, + twin20.amount, + await buyer.getAddress() + ); await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") + .to.emit(exchangeHandler, "TwinTransferred") .withArgs( twin1155.id, twin1155.tokenAddress, exchange.id, twin1155.tokenId, twin1155.amount, - await testProtocolFunctions.getAddress() + await buyer.getAddress() ); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Check the supplyAvailable of each twin + let [, twin] = await twinHandler.connect(assistant).getTwin(twin721.id); + expect(twin.supplyAvailable).to.equal(twin721.supplyAvailable); - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + [, twin] = await twinHandler.connect(assistant).getTwin(twin20.id); + expect(twin.supplyAvailable).to.equal(twin20.supplyAvailable); + + [, twin] = await twinHandler.connect(assistant).getTwin(twin1155.id); + expect(twin.supplyAvailable).to.equal(twin1155.supplyAvailable); }); - it("if twin transfers consume all available gas, redeem still succeeds, but exchange is disputed", async function () { - const [foreign1155gt, foreign1155gt_2] = await deployMockTokens([ - "Foreign1155GasTheft", - "Foreign1155GasTheft", - ]); + it("Transfer token order must be ascending if twin supply is unlimited and token type is NonFungible", async function () { + let expectedTokenId = "1"; + let exchangeId = exchange.id; - // Approve the protocol diamond to transfer seller's tokens - await foreign1155gt.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - await foreign1155gt_2.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + // Check the assistant owns the first ERC721 of twin range + owner = await other721.ownerOf(expectedTokenId); + expect(owner).to.equal(await assistant.getAddress()); - // Create two ERC1155 twins that will consume all available gas - twin1155 = mockTwin(await foreign1155gt.getAddress(), TokenType.MultiToken); - twin1155.amount = "1"; - twin1155.tokenId = "1"; - twin1155.supplyAvailable = "10"; - twin1155.id = "4"; + // Redeem the voucher + await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin721.id, twin721.tokenAddress, exchangeId, expectedTokenId, "0", await buyer.getAddress()); - await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); + // Check the buyer owns the first ERC721 of twin range + owner = await other721.ownerOf(expectedTokenId); + expect(owner).to.equal(await buyer.getAddress()); - const twin1155_2 = twin1155.clone(); - twin1155_2.id = "5"; - twin1155_2.tokenAddress = await foreign1155gt_2.getAddress(); - await twinHandler.connect(assistant).createTwin(twin1155_2.toStruct()); + ++expectedTokenId; - // Create a new offer and bundle - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin1155.id, twin1155_2.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + // Check the assistant owns the second ERC721 of twin range + owner = await other721.ownerOf(expectedTokenId); + expect(owner).to.equal(await assistant.getAddress()); + + // Commit to offer for the second time + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + + // Redeem the voucher + // tokenId transferred to the buyer is 1 + await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchangeId)) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin721.id, twin721.tokenAddress, exchangeId, expectedTokenId, "0", await buyer.getAddress()); - // Commit to offer - const buyerAddress = await buyer.getAddress(); - await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); + // Check the buyer owns the second ERC721 of twin range + owner = await other721.ownerOf(expectedTokenId); + expect(owner).to.equal(await buyer.getAddress()); + }); + }); - exchange.id = Number(exchange.id) + 1; + context("Twin transfer fail", async function () { + it("should raise a dispute when buyer is an EOA", async function () { + // Remove the approval for the protocol to transfer the seller's tokens + await foreign20.connect(assistant).approve(protocolDiamondAddress, "0"); - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 1000000 }); // limit gas to speed up test + let exchangeId = exchange.id; + const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // Dispute should be raised and both transfers should fail await expect(tx) .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyer.address); + .withArgs(exchangeId, exchange.buyerId, seller.id, await buyer.getAddress()); await expect(tx) .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin20.id, twin20.tokenAddress, exchangeId, "0", twin20.amount, await buyer.getAddress()); + + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferred") + .withArgs(twin721.id, twin721.tokenAddress, exchangeId, "10", "0", buyer.address); + + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferred") .withArgs( twin1155.id, twin1155.tokenAddress, - exchange.id, + exchangeId, twin1155.tokenId, twin1155.amount, - buyerAddress - ); - - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs( - twin1155_2.id, - twin1155_2.tokenAddress, - exchange.id, - twin1155_2.tokenId, - twin1155_2.amount, - buyerAddress + await buyer.getAddress() ); // Get the exchange state @@ -4986,108 +4792,56 @@ describe("IBosonExchangeHandler", function () { assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); }); - it("Too many twins", async function () { - await provider.send("evm_setBlockGasLimit", ["0x1c9c380"]); // 30,000,000. Need to set this limit, otherwise the coverage test will fail - - const twinCount = 188; - - // Approve the protocol diamond to transfer seller's tokens - await foreign1155 - .connect(assistant) - .setApprovalForAll(protocolDiamondAddress, true, { gasLimit: 30000000 }); - - twin1155 = mockTwin(await foreign1155.getAddress(), TokenType.MultiToken); - twin1155.amount = "1"; - twin1155.supplyAvailable = "1"; - - for (let i = 0; i < twinCount; i++) { - await twinHandler.connect(assistant).createTwin(twin1155.toStruct(), { gasLimit: 30000000 }); - } - - // Create a new offer and bundle - const twinIds = [...Array(twinCount + 4).keys()].slice(4); - - offer.quantityAvailable = 1; - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit, { - gasLimit: 30000000, - }); - bundle = new Bundle("2", seller.id, [`${++offerId}`], twinIds); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct(), { gasLimit: 30000000 }); + it("should raise a dispute when buyer account is a contract", async function () { + // Remove the approval for the protocol to transfer the seller's tokens + await foreign20.connect(assistant).approve(protocolDiamondAddress, "0"); - // Commit to offer - const buyerAddress = await buyer.getAddress(); - await exchangeHandler - .connect(buyer) - .commitToOffer(buyerAddress, offerId, { value: price, gasLimit: 30000000 }); + // Deploy contract to test redeem called by another contract + let TestProtocolFunctionsFactory = await getContractFactory("TestProtocolFunctions"); + const testProtocolFunctions = await TestProtocolFunctionsFactory.deploy(protocolDiamondAddress); + await testProtocolFunctions.waitForDeployment(); - exchange.id = Number(exchange.id) + 1; + await testProtocolFunctions.commit(offerId, { value: price }); - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 30000000 }); + let exchangeId = ++exchange.id; - // Dispute should be raised and twin transfer should be skipped + // Protocol should raised dispute automatically if transfer twin failed + const tx = await testProtocolFunctions.redeem(exchangeId); await expect(tx) .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); + .withArgs(exchangeId, ++exchange.buyerId, seller.id, await testProtocolFunctions.getAddress()); await expect(tx) - .to.emit(exchangeHandler, "TwinTransferSkipped") - .withArgs(exchange.id, twinCount, buyerAddress); - - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); - - it("if twin returns a long return, redeem still succeeds, but exchange is disputed", async function () { - const [foreign1155rb] = await deployMockTokens(["Foreign1155ReturnBomb"]); - - // Approve the protocol diamond to transfer seller's tokens - await foreign1155rb.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - - // Create two ERC1155 twins that will consume all available gas - twin1155 = mockTwin(await foreign1155rb.getAddress(), TokenType.MultiToken); - twin1155.amount = "1"; - twin1155.tokenId = "1"; - twin1155.supplyAvailable = "10"; - twin1155.id = "4"; - - await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); - - // Create a new offer and bundle - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin1155.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - - // Commit to offer - const buyerAddress = await buyer.getAddress(); - await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); - - exchange.id = Number(exchange.id) + 1; - - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs( + twin20.id, + twin20.tokenAddress, + exchangeId, + "0", + twin20.amount, + await testProtocolFunctions.getAddress() + ); - // Dispute should be raised and both transfers should fail await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs( + twin721.id, + twin721.tokenAddress, + exchangeId, + "10", + "0", + await testProtocolFunctions.getAddress() + ); await expect(tx) .to.emit(exchangeHandler, "TwinTransferFailed") .withArgs( twin1155.id, twin1155.tokenAddress, - exchange.id, + exchangeId, twin1155.tokenId, twin1155.amount, - buyerAddress + await testProtocolFunctions.getAddress() ); // Get the exchange state @@ -5097,83 +4851,68 @@ describe("IBosonExchangeHandler", function () { assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); }); - context("Malformed return", async function () { - const attackTypes = { - "too short": 0, - "too long": 1, - invalid: 2, - }; - let foreign1155mr; - - beforeEach(async function () { - [foreign1155mr] = await deployMockTokens(["Foreign1155MalformedReturn"]); - - // Approve the protocol diamond to transfer seller's tokens - await foreign1155mr.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - - // Create two ERC1155 twins that will consume all available gas - twin1155 = mockTwin(await foreign1155mr.getAddress(), TokenType.MultiToken); - twin1155.amount = "1"; - twin1155.tokenId = "1"; - twin1155.supplyAvailable = "10"; - twin1155.id = "4"; - - await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); - - // Create a new offer and bundle - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin1155.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerId, { value: price }); + it("if twin transfers consume all available gas, redeem still succeeds, but exchange is disputed", async function () { + const [foreign20gt, foreign721gt, foreign1155gt] = await deployMockTokens([ + "Foreign20GasTheft", + "Foreign721GasTheft", + "Foreign1155GasTheft", + ]); - exchange.id = Number(exchange.id) + 1; - }); + // Approve the protocol diamond to transfer seller's tokens + await foreign20gt.connect(assistant).approve(protocolDiamondAddress, "100"); + await foreign721gt.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + await foreign1155gt.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - Object.entries(attackTypes).forEach((attackType) => { - const [type, enumType] = attackType; - it(`return value is ${type}, redeem still succeeds, but the exchange is disputed`, async function () { - await foreign1155mr.setAttackType(enumType); + // Create twins that will consume all available gas + twin20 = mockTwin(await foreign20gt.getAddress()); + twin20.amount = "1"; + twin20.supplyAvailable = "100"; + twin20.id = "4"; - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + twin721 = mockTwin(await foreign721gt.getAddress(), TokenType.NonFungibleToken); + twin721.amount = "0"; + twin721.supplyAvailable = "10"; + twin721.id = "5"; - // Dispute should be raised and both transfers should fail - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyer.address); + twin1155 = mockTwin(await foreign1155gt.getAddress(), TokenType.MultiToken); + twin1155.amount = "1"; + twin1155.tokenId = "1"; + twin1155.supplyAvailable = "10"; + twin1155.id = "6"; - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs( - twin1155.id, - twin1155.tokenAddress, - exchange.id, - twin1155.tokenId, - twin1155.amount, - buyer.address - ); + await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Create a new offer and bundle + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin20.id, twin721.id, twin1155.id]); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); - }); - }); + // Commit to offer + const buyerAddress = await buyer.getAddress(); + await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); - it("should raise a dispute if erc1155 contract does not exist anymore", async function () { - // Destruct the ERC1155 contract - await foreign1155.destruct(); + exchange.id = Number(exchange.id) + 1; - const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + // Redeem the voucher + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 1000000 }); // limit gas to speed up test + + // Dispute should be raised and both transfers should fail await expect(tx) .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, await buyer.getAddress()); + .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); + + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin20.id, twin20.tokenAddress, exchange.id, twin20.tokenId, twin20.amount, buyerAddress); + + let tokenId = "9"; + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferFailed") + .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, twin721.amount, buyerAddress); await expect(tx) .to.emit(exchangeHandler, "TwinTransferFailed") @@ -5183,7 +4922,7 @@ describe("IBosonExchangeHandler", function () { exchange.id, twin1155.tokenId, twin1155.amount, - await buyer.getAddress() + buyerAddress ); // Get the exchange state @@ -5192,772 +4931,802 @@ describe("IBosonExchangeHandler", function () { // It should match ExchangeState.Disputed assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); }); - }); - }); - - context("📦 Offer bundled with mixed twins", async function () { - beforeEach(async function () { - // Create a new bundle - bundle = new Bundle("1", seller.id, [offerId], twinIds); - expect(bundle.isValid()).is.true; - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + it("Too many twins", async function () { + await provider.send("evm_setBlockGasLimit", ["0x1c9c380"]); // 30,000,000. Need to set this limit, otherwise the coverage test will fail - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - }); + const twinCount = 189; - it("should transfer the twins", async function () { - let tokenIdNonFungible = "10"; - let tokenIdMultiToken = "1"; + // ERC20 twins + await foreign20.connect(assistant).approve(protocolDiamondAddress, twinCount * 10, { gasLimit: 30000000 }); + twin20 = mockTwin(await foreign20.getAddress()); + twin20.amount = "1"; + twin20.supplyAvailable = "1"; + for (let i = 0; i < twinCount / 3; i++) { + await twinHandler.connect(assistant).createTwin(twin20.toStruct(), { gasLimit: 30000000 }); + } - // Check the buyer's balance of the ERC20 - balance = await foreign20.balanceOf(await buyer.getAddress()); - expect(balance).to.equal(0); + // ERC721 twins + const startTokenId = 100; + await foreign721.connect(assistant).setApprovalForAll(protocolDiamondAddress, true, { gasLimit: 30000000 }); + twin721 = mockTwin(await foreign721.getAddress(), TokenType.NonFungibleToken); + twin721.amount = "0"; + twin721.supplyAvailable = "1"; + for (let i = startTokenId; i < startTokenId + twinCount / 3; i++) { + twin721.tokenId = i; + await twinHandler.connect(assistant).createTwin(twin721.toStruct(), { gasLimit: 30000000 }); + } - // Check the assistant owns the ERC721 - owner = await foreign721.ownerOf(tokenIdNonFungible); - expect(owner).to.equal(await assistant.getAddress()); + // Approve the protocol diamond to transfer seller's tokens + await foreign1155 + .connect(assistant) + .setApprovalForAll(protocolDiamondAddress, true, { gasLimit: 30000000 }); + twin1155 = mockTwin(await foreign1155.getAddress(), TokenType.MultiToken); + twin1155.amount = "1"; + twin1155.supplyAvailable = "1"; + for (let i = 0; i < twinCount / 3; i++) { + await twinHandler.connect(assistant).createTwin(twin1155.toStruct(), { gasLimit: 30000000 }); + } - // Check the buyer's balance of the ERC1155 - balance = await foreign1155.balanceOf(await buyer.getAddress(), tokenIdMultiToken); - expect(balance).to.equal(0); + // Create a new offer and bundle + const twinIds = [...Array(twinCount + 4).keys()].slice(4); - let exchangeId = exchange.id; + offer.quantityAvailable = 1; + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit, { + gasLimit: 30000000, + }); + bundle = new Bundle("2", seller.id, [`${++offerId}`], twinIds); + await bundleHandler.connect(assistant).createBundle(bundle.toStruct(), { gasLimit: 30000000 }); - // Redeem the voucher - const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // Commit to offer + const buyerAddress = await buyer.getAddress(); + await exchangeHandler + .connect(buyer) + .commitToOffer(buyerAddress, offerId, { value: price, gasLimit: 30000000 }); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs( - twin1155.id, - twin1155.tokenAddress, - exchangeId, - tokenIdMultiToken, - twin1155.amount, - await buyer.getAddress() - ); + exchange.id = Number(exchange.id) + 1; - await expect(tx) - .and.to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin20.id, twin20.tokenAddress, exchangeId, "0", twin20.amount, await buyer.getAddress()); + // Redeem the voucher + tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 30000000 }); - await expect(tx) - .and.to.emit(exchangeHandler, "TwinTransferred") - .withArgs( - twin721.id, - twin721.tokenAddress, - exchangeId, - tokenIdNonFungible, - twin721.amount, - await buyer.getAddress() - ); + // Dispute should be raised and twin transfer should be skipped + await expect(tx) + .to.emit(disputeHandler, "DisputeRaised") + .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); - // Check the buyer's balance of the ERC20 - balance = await foreign20.balanceOf(await buyer.getAddress()); - expect(balance).to.equal(3); + await expect(tx) + .to.emit(exchangeHandler, "TwinTransferSkipped") + .withArgs(exchange.id, twinCount, buyerAddress); - // Check the buyer owns the ERC721 - owner = await foreign721.ownerOf(tokenIdNonFungible); - expect(owner).to.equal(await buyer.getAddress()); + // Get the exchange state + [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); - // Check the buyer's balance of the ERC1155 - balance = await foreign1155.balanceOf(await buyer.getAddress(), tokenIdMultiToken); - expect(balance).to.equal(1); + // It should match ExchangeState.Disputed + assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); + }); }); + }); + }); - it("Should transfer the twin even if supplyAvailable is equal to amount", async function () { - await foreign721.connect(assistant).mint("11", "1"); + context("👉 extendVoucher()", async function () { + beforeEach(async function () { + // Commit to offer + tx = await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); - offer.quantityAvailable = "1"; - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + // Get the block timestamp of the confirmed tx + blockNumber = tx.blockNumber; + block = await provider.getBlock(blockNumber); - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Update the committed date in the expected exchange struct with the block timestamp of the tx + voucher.committedDate = block.timestamp.toString(); - twin1155.supplyAvailable = "1"; - twin1155.id = "4"; + // Update the validUntilDate date in the expected exchange struct + voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); - // Create a new twin - await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); + // New expiry date for extensions + validUntilDate = BigInt(voucher.validUntilDate) + oneMonth.toString(); + }); - twin20.supplyAvailable = "3"; - twin20.id = "5"; + it("should emit an VoucherExtended event when seller's assistant calls", async function () { + // Extend the voucher, expecting event + await expect(exchangeHandler.connect(assistant).extendVoucher(exchange.id, validUntilDate)) + .to.emit(exchangeHandler, "VoucherExtended") + .withArgs(offerId, exchange.id, validUntilDate, await assistant.getAddress()); + }); - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + it("should update state", async function () { + // Extend the voucher + await exchangeHandler.connect(assistant).extendVoucher(exchange.id, validUntilDate); - twin721.supplyAvailable = "1"; - twin721.tokenId = "11"; - twin721.id = "6"; + // Get the voucher + [, , response] = await exchangeHandler.connect(rando).getExchange(exchange.id); + voucher = Voucher.fromStruct(response); - await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + // It should match the new validUntilDate + assert.equal(voucher.validUntilDate, validUntilDate, "Voucher validUntilDate not updated"); + }); - // Create a new bundle - bundle = new Bundle("1", seller.id, [++offerId], [twin1155.id, twin20.id, twin721.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + context("💔 Revert Reasons", async function () { + it("The exchanges region of protocol is paused", async function () { + // Pause the exchanges region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + // Attempt to complete an exchange, expecting revert + await expect( + exchangeHandler.connect(assistant).extendVoucher(exchange.id, validUntilDate) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); + }); - // Set time forward to the offer's voucherRedeemableFrom - voucherRedeemableFrom = offerDates.voucherRedeemableFrom; - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + it("exchange id is invalid", async function () { + // An invalid exchange id + exchangeId = "666"; - // Redeem the second voucher - const tx = await exchangeHandler.connect(buyer).redeemVoucher(++exchange.id); + // Attempt to extend voucher, expecting revert + await expect( + exchangeHandler.connect(assistant).extendVoucher(exchangeId, validUntilDate) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_EXCHANGE); + }); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs( - twin1155.id, - twin1155.tokenAddress, - exchange.id, - twin1155.tokenId, - twin1155.amount, - await buyer.getAddress() - ); + it("exchange is not in committed state", async function () { + // Cancel the voucher + await exchangeHandler.connect(buyer).cancelVoucher(exchange.id); - await expect(tx) - .and.to.emit(exchangeHandler, "TwinTransferred") - .withArgs( - twin721.id, - twin721.tokenAddress, - exchange.id, - twin721.tokenId, - twin721.amount, - await buyer.getAddress() - ); + // Attempt to extend voucher, expecting revert + await expect( + exchangeHandler.connect(assistant).extendVoucher(exchange.id, validUntilDate) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_STATE); + }); + + it("caller is not seller's assistant", async function () { + // Attempt to extend voucher, expecting revert + await expect( + exchangeHandler.connect(rando).extendVoucher(exchange.id, validUntilDate) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NOT_ASSISTANT); + }); - await expect(tx) - .and.to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin20.id, twin20.tokenAddress, exchange.id, "0", twin20.amount, await buyer.getAddress()); + it("new date is not later than the current one", async function () { + // New expiry date is older than current + validUntilDate = BigInt(voucher.validUntilDate) - oneMonth; - // Check the buyer's balance - balance = await foreign1155.balanceOf(await buyer.getAddress(), twin1155.tokenId); - expect(balance).to.equal(1); + // Attempt to extend voucher, expecting revert + await expect( + exchangeHandler.connect(assistant).extendVoucher(exchange.id, validUntilDate) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.VOUCHER_EXTENSION_NOT_VALID); + }); + }); + }); - balance = await foreign721.balanceOf(await buyer.getAddress()); - expect(balance).to.equal(1); + context("👉 onVoucherTransferred()", async function () { + // majority of lines from onVoucherTransferred() are tested in indirectly in + // `commitToPremintedOffer()` - balance = await foreign20.balanceOf(await buyer.getAddress()); - expect(balance).to.equal(3); + beforeEach(async function () { + // Commit to offer, retrieving the event + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - let [, twin] = await twinHandler.getTwin(twin1155.id); - expect(twin.supplyAvailable).to.equal(0); + // Client used for tests + bosonVoucherCloneAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + admin.address + ); + bosonVoucherClone = await getContractAt("IBosonVoucher", bosonVoucherCloneAddress); - [, twin] = await twinHandler.getTwin(twin721.id); - expect(twin.supplyAvailable).to.equal(0); + tokenId = deriveTokenId(offerId, exchange.id); + }); - [, twin] = await twinHandler.getTwin(twin20.id); - expect(twin.supplyAvailable).to.equal(0); - }); + it("should emit an VoucherTransferred event when called by CLIENT-roled address", async function () { + // Get the next buyer id + nextAccountId = await accountHandler.connect(rando).getNextAccountId(); - context("Unlimited supply", async function () { - let other721; + // Call onVoucherTransferred, expecting event + await expect( + bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) + ) + .to.emit(exchangeHandler, "VoucherTransferred") + .withArgs(offerId, exchange.id, nextAccountId, await bosonVoucherClone.getAddress()); + }); - beforeEach(async function () { - // Deploy a new ERC721 token - let TokenContractFactory = await getContractFactory("Foreign721"); - other721 = await TokenContractFactory.connect(rando).deploy(); + it("should update exchange when new buyer (with existing, active account) is passed", async function () { + // Get the next buyer id + nextAccountId = await accountHandler.connect(rando).getNextAccountId(); - // Mint enough tokens to cover the offer - await other721.connect(assistant).mint("1", "2"); + // Create a buyer account for the new owner + await accountHandler.connect(newOwner).createBuyer(mockBuyer(await newOwner.getAddress())); - // Approve the protocol diamond to transfer seller's tokens - await other721.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + // Call onVoucherTransferred + await bosonVoucherClone + .connect(buyer) + .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId); - const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); - offer.quantityAvailable = "2"; - offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; + // Get the exchange + [exists, response] = await exchangeHandler.connect(rando).getExchange(exchange.id); - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Marshal response to entity + exchange = Exchange.fromStruct(response); + expect(exchange.isValid()); - // Change twin supply to unlimited and token address to the new token - twin721.supplyAvailable = MaxUint256.toString(); - twin721.tokenAddress = await other721.getAddress(); - twin721.id = "4"; - // Create a new ERC721 twin with the new token address - await twinHandler.connect(assistant).createTwin(twin721.toStruct()); + // Exchange's voucher expired flag should be true + assert.equal(exchange.buyerId, nextAccountId, "Exchange.buyerId not updated"); + }); - twin20.supplyAvailable = MaxUint256.toString(); - twin20.id = "5"; - // Create a new ERC20 twin with the new token address - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); + it("should update exchange when new buyer (no account) is passed", async function () { + // Get the next buyer id + nextAccountId = await accountHandler.connect(rando).getNextAccountId(); - twin1155.supplyAvailable = MaxUint256.toString(); - twin1155.id = "6"; - // Create a new ERC1155 twin with the new token address - await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); + // Call onVoucherTransferred + await bosonVoucherClone + .connect(buyer) + .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId); - // Create a new bundle - bundle = new Bundle("1", seller.id, [++offerId], [twin721.id, twin20.id, twin1155.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + // Get the exchange + [exists, response] = await exchangeHandler.connect(rando).getExchange(exchange.id); - // Commit to offer - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + // Marshal response to entity + exchange = Exchange.fromStruct(response); + expect(exchange.isValid()); - // Set time forward to the offer's voucherRedeemableFrom - voucherRedeemableFrom = offerDates.voucherRedeemableFrom; - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Exchange's voucher expired flag should be true + assert.equal(exchange.buyerId, nextAccountId, "Exchange.buyerId not updated"); + }); - ++exchange.id; - }); + it("should be triggered when a voucher is transferred", async function () { + // Transfer voucher, expecting event + await expect( + bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) + ).to.emit(exchangeHandler, "VoucherTransferred"); + }); - it("Should not decrease twin supplyAvailable if supply is unlimited", async function () { - // Redeem the voucher - const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id); + it("should not be triggered when a voucher is issued", async function () { + // Get the next exchange id + nextExchangeId = await exchangeHandler.getNextExchangeId(); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin721.id, twin721.tokenAddress, exchange.id, "1", twin721.amount, buyer.address); + // Create a buyer account + await accountHandler.connect(newOwner).createBuyer(mockBuyer(await newOwner.getAddress())); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs( - twin20.id, - twin20.tokenAddress, - exchange.id, - twin20.tokenId, - twin20.amount, - await buyer.getAddress() - ); + // Grant PROTOCOL role to EOA address for test + await accessController.grantRole(Role.PROTOCOL, await rando.getAddress()); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs( - twin1155.id, - twin1155.tokenAddress, - exchange.id, - twin1155.tokenId, - twin1155.amount, - await buyer.getAddress() - ); + // Issue voucher, expecting no event + await expect( + bosonVoucherClone.connect(rando).issueVoucher(nextExchangeId, await buyer.getAddress()) + ).to.not.emit(exchangeHandler, "VoucherTransferred"); + }); - // Check the supplyAvailable of each twin - let [, twin] = await twinHandler.connect(assistant).getTwin(twin721.id); - expect(twin.supplyAvailable).to.equal(twin721.supplyAvailable); + it("should not be triggered when a voucher is burned", async function () { + // Grant PROTOCOL role to EOA address for test + await accessController.grantRole(Role.PROTOCOL, await rando.getAddress()); - [, twin] = await twinHandler.connect(assistant).getTwin(twin20.id); - expect(twin.supplyAvailable).to.equal(twin20.supplyAvailable); + // Burn voucher, expecting no event + await expect(bosonVoucherClone.connect(rando).burnVoucher(tokenId)).to.not.emit( + exchangeHandler, + "VoucherTransferred" + ); + }); - [, twin] = await twinHandler.connect(assistant).getTwin(twin1155.id); - expect(twin.supplyAvailable).to.equal(twin1155.supplyAvailable); - }); + it("Should not be triggered when from and to addresses are the same", async function () { + // Transfer voucher, expecting event + await expect( + bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await buyer.getAddress(), tokenId) + ).to.not.emit(exchangeHandler, "VoucherTransferred"); + }); - it("Transfer token order must be ascending if twin supply is unlimited and token type is NonFungible", async function () { - let expectedTokenId = "1"; - let exchangeId = exchange.id; + it("Should not be triggered when first transfer of preminted voucher happens", async function () { + // Transfer voucher, expecting event + await expect( + bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await buyer.getAddress(), tokenId) + ).to.not.emit(exchangeHandler, "VoucherTransferred"); + }); - // Check the assistant owns the first ERC721 of twin range - owner = await other721.ownerOf(expectedTokenId); - expect(owner).to.equal(await assistant.getAddress()); + it("should work with additional collections", async function () { + // Create a new collection + const externalId = `Brand1`; + voucherInitValues.collectionSalt = encodeBytes32String(externalId); + await accountHandler.connect(assistant).createNewCollection(externalId, voucherInitValues); - // Redeem the voucher - await expect(exchangeHandler.connect(buyer).redeemVoucher(exchangeId)) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin721.id, twin721.tokenAddress, exchangeId, expectedTokenId, "0", await buyer.getAddress()); + offer.collectionIndex = 1; + offer.id = await offerHandler.getNextOfferId(); + exchange.id = await exchangeHandler.getNextExchangeId(); + bosonVoucherCloneAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + admin.address, + voucherInitValues.collectionSalt + ); + bosonVoucherClone = await getContractAt("IBosonVoucher", bosonVoucherCloneAddress); + const tokenId = deriveTokenId(offer.id, exchange.id); - // Check the buyer owns the first ERC721 of twin range - owner = await other721.ownerOf(expectedTokenId); - expect(owner).to.equal(await buyer.getAddress()); + // Create the offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - ++expectedTokenId; + // Commit to offer, creating a new exchange + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id, { value: price }); - // Check the assistant owns the second ERC721 of twin range - owner = await other721.ownerOf(expectedTokenId); - expect(owner).to.equal(await assistant.getAddress()); + // Get the next buyer id + nextAccountId = await accountHandler.connect(rando).getNextAccountId(); - // Commit to offer for the second time - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + // Call onVoucherTransferred, expecting event + await expect(bosonVoucherClone.connect(buyer).transferFrom(buyer.address, newOwner.address, tokenId)) + .to.emit(exchangeHandler, "VoucherTransferred") + .withArgs(offer.id, exchange.id, nextAccountId, await bosonVoucherClone.getAddress()); + }); - // Redeem the voucher - // tokenId transferred to the buyer is 1 - await expect(exchangeHandler.connect(buyer).redeemVoucher(++exchangeId)) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin721.id, twin721.tokenAddress, exchangeId, expectedTokenId, "0", await buyer.getAddress()); + context("💔 Revert Reasons", async function () { + it("The buyers region of protocol is paused", async function () { + // Pause the buyers region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); - // Check the buyer owns the second ERC721 of twin range - owner = await other721.ownerOf(expectedTokenId); - expect(owner).to.equal(await buyer.getAddress()); - }); + // Attempt to create a buyer, expecting revert + await expect( + bosonVoucherClone + .connect(buyer) + .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); }); - context("Twin transfer fail", async function () { - it("should raise a dispute when buyer is an EOA", async function () { - // Remove the approval for the protocol to transfer the seller's tokens - await foreign20.connect(assistant).approve(protocolDiamondAddress, "0"); + it("Caller is not a clone address", async function () { + // Attempt to call onVoucherTransferred, expecting revert + await expect( + exchangeHandler.connect(rando).onVoucherTransferred(exchange.id, await newOwner.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); + }); - let exchangeId = exchange.id; - const tx = await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + it("Caller is not a clone address associated with the seller", async function () { + // Create a new seller to get new clone + seller = mockSeller( + await rando.getAddress(), + await rando.getAddress(), + ZeroAddress, + await rando.getAddress() + ); + expect(seller.isValid()).is.true; - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchangeId, exchange.buyerId, seller.id, await buyer.getAddress()); + await accountHandler.connect(rando).createSeller(seller, emptyAuthToken, voucherInitValues); + expectedCloneAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + rando.address + ); + const bosonVoucherClone2 = await getContractAt("IBosonVoucher", expectedCloneAddress); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin20.id, twin20.tokenAddress, exchangeId, "0", twin20.amount, await buyer.getAddress()); + // For the sake of test, mint token on bv2 with the id of token on bv1 + // Temporarily grant PROTOCOL role to deployer account + await accessController.grantRole(Role.PROTOCOL, await deployer.getAddress()); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs(twin721.id, twin721.tokenAddress, exchangeId, "10", "0", buyer.address); + const newBuyer = mockBuyer(await buyer.getAddress()); + newBuyer.id = buyerId; + await bosonVoucherClone2.issueVoucher(exchange.id, newBuyer.wallet); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferred") - .withArgs( - twin1155.id, - twin1155.tokenAddress, - exchangeId, - twin1155.tokenId, - twin1155.amount, - await buyer.getAddress() - ); + // Attempt to call onVoucherTransferred, expecting revert + await expect( + bosonVoucherClone2 + .connect(buyer) + .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), exchange.id) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); + }); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + it("exchange id is invalid", async function () { + // An invalid exchange id + exchangeId = "666"; - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); + // Attempt to call onVoucherTransferred, expecting revert + await expect( + exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_EXCHANGE); + }); - it("should raise a dispute when buyer account is a contract", async function () { - // Remove the approval for the protocol to transfer the seller's tokens - await foreign20.connect(assistant).approve(protocolDiamondAddress, "0"); + it("exchange is not in committed state", async function () { + // Revoke the voucher + await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); - // Deploy contract to test redeem called by another contract - let TestProtocolFunctionsFactory = await getContractFactory("TestProtocolFunctions"); - const testProtocolFunctions = await TestProtocolFunctionsFactory.deploy(protocolDiamondAddress); - await testProtocolFunctions.waitForDeployment(); + // Attempt to call onVoucherTransferred, expecting revert + await expect( + exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_STATE); + }); - await testProtocolFunctions.commit(offerId, { value: price }); + it("Voucher has expired", async function () { + // Set time forward past the voucher's validUntilDate + await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); - let exchangeId = ++exchange.id; + // Attempt to call onVoucherTransferred, expecting revert + await expect( + exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.VOUCHER_HAS_EXPIRED); + }); + }); + }); - // Protocol should raised dispute automatically if transfer twin failed - const tx = await testProtocolFunctions.redeem(exchangeId); - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchangeId, ++exchange.buyerId, seller.id, await testProtocolFunctions.getAddress()); + context("👉 onPremintedVoucherTransferred()", async function () { + // These tests are mainly for preminted vouchers of fixed price offers + // The part of onPremintedVoucherTransferred that is specific to + // price discovery offers is indirectly tested in `PriceDiscoveryHandlerFacet.js` + let tokenId; + beforeEach(async function () { + // Reserve range + await offerHandler + .connect(assistant) + .reserveRange(offer.id, offer.quantityAvailable, await assistant.getAddress()); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs( - twin20.id, - twin20.tokenAddress, - exchangeId, - "0", - twin20.amount, - await testProtocolFunctions.getAddress() - ); + // expected address of the first clone + const voucherCloneAddress = calculateCloneAddress( + await accountHandler.getAddress(), + beaconProxyAddress, + admin.address + ); + bosonVoucher = await getContractAt("BosonVoucher", voucherCloneAddress); + await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs( - twin721.id, - twin721.tokenAddress, - exchangeId, - "10", - "0", - await testProtocolFunctions.getAddress() - ); + tokenId = deriveTokenId(offer.id, exchangeId); + }); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs( - twin1155.id, - twin1155.tokenAddress, - exchangeId, - twin1155.tokenId, - twin1155.amount, - await testProtocolFunctions.getAddress() - ); + it("should emit a BuyerCommitted event", async function () { + // Commit to preminted offer, retrieving the event + tx = await bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + txReceipt = await tx.wait(); + event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Get the block timestamp of the confirmed tx + blockNumber = tx.blockNumber; + block = await provider.getBlock(blockNumber); - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); + // Update the committed date in the expected exchange struct with the block timestamp of the tx + voucher.committedDate = block.timestamp.toString(); - it("if twin transfers consume all available gas, redeem still succeeds, but exchange is disputed", async function () { - const [foreign20gt, foreign721gt, foreign1155gt] = await deployMockTokens([ - "Foreign20GasTheft", - "Foreign721GasTheft", - "Foreign1155GasTheft", - ]); + // Update the validUntilDate date in the expected exchange struct + voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); - // Approve the protocol diamond to transfer seller's tokens - await foreign20gt.connect(assistant).approve(protocolDiamondAddress, "100"); - await foreign721gt.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); - await foreign1155gt.connect(assistant).setApprovalForAll(protocolDiamondAddress, true); + // Examine event + assert.equal(event.exchangeId.toString(), exchangeId, "Exchange id is incorrect"); + assert.equal(event.offerId.toString(), offerId, "Offer id is incorrect"); + assert.equal(event.buyerId.toString(), buyerId, "Buyer id is incorrect"); - // Create twins that will consume all available gas - twin20 = mockTwin(await foreign20gt.getAddress()); - twin20.amount = "1"; - twin20.supplyAvailable = "100"; - twin20.id = "4"; + // Examine the exchange struct + assert.equal( + Exchange.fromStruct(event.exchange).toString(), + exchange.toString(), + "Exchange struct is incorrect" + ); - twin721 = mockTwin(await foreign721gt.getAddress(), TokenType.NonFungibleToken); - twin721.amount = "0"; - twin721.supplyAvailable = "10"; - twin721.id = "5"; + // Examine the voucher struct + assert.equal(Voucher.fromStruct(event.voucher).toString(), voucher.toString(), "Voucher struct is incorrect"); + }); - twin1155 = mockTwin(await foreign1155gt.getAddress(), TokenType.MultiToken); - twin1155.amount = "1"; - twin1155.tokenId = "1"; - twin1155.supplyAvailable = "10"; - twin1155.id = "6"; + it("should not increment the next exchange id counter", async function () { + // Get the next exchange id + let nextExchangeIdBefore = await exchangeHandler.connect(rando).getNextExchangeId(); - await twinHandler.connect(assistant).createTwin(twin20.toStruct()); - await twinHandler.connect(assistant).createTwin(twin721.toStruct()); - await twinHandler.connect(assistant).createTwin(twin1155.toStruct()); + // Commit to preminted offer, creating a new exchange + await bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - // Create a new offer and bundle - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - bundle = new Bundle("2", seller.id, [`${++offerId}`], [twin20.id, twin721.id, twin1155.id]); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct()); + // Get the next exchange id and ensure it was incremented by the creation of the offer + nextExchangeId = await exchangeHandler.connect(rando).getNextExchangeId(); + expect(nextExchangeId).to.equal(nextExchangeIdBefore); + }); - // Commit to offer - const buyerAddress = await buyer.getAddress(); - await exchangeHandler.connect(buyer).commitToOffer(buyerAddress, offerId, { value: price }); + it("should not issue a new voucher on the clone", async function () { + // Get next exchange id + nextExchangeId = await exchangeHandler.connect(rando).getNextExchangeId(); - exchange.id = Number(exchange.id) + 1; + // Voucher with nextExchangeId should not exist + await expect(bosonVoucher.ownerOf(nextExchangeId)).to.be.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); + + // Commit to preminted offer, creating a new exchange + await bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 1000000 }); // limit gas to speed up test + // Voucher with nextExchangeId still should not exist + await expect(bosonVoucher.ownerOf(nextExchangeId)).to.be.revertedWith(RevertReasons.ERC721_INVALID_TOKEN_ID); + }); - // Dispute should be raised and both transfers should fail - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); + it("ERC2981: issued voucher should have royalty fees", async function () { + // Before voucher is transferred, it should already have royalty fee + let [receiver, royaltyAmount] = await bosonVoucher.connect(assistant).royaltyInfo(tokenId, offer.price); + assert.equal(receiver, treasury.address, "Recipient address is incorrect"); + assert.equal( + royaltyAmount.toString(), + applyPercentage(offer.price, royaltyPercentage1), + "Royalty amount is incorrect" + ); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin20.id, twin20.tokenAddress, exchange.id, twin20.tokenId, twin20.amount, buyerAddress); + // Commit to preminted offer, creating a new exchange + await bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - let tokenId = "9"; - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs(twin721.id, twin721.tokenAddress, exchange.id, tokenId, twin721.amount, buyerAddress); + // After voucher is transferred, it should have royalty fee + [receiver, royaltyAmount] = await bosonVoucher.connect(assistant).royaltyInfo(tokenId, offer.price); + assert.equal(receiver, await treasury.getAddress(), "Recipient address is incorrect"); + assert.equal( + royaltyAmount.toString(), + applyPercentage(offer.price, royaltyPercentage1), + "Royalty amount is incorrect" + ); + }); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferFailed") - .withArgs( - twin1155.id, - twin1155.tokenAddress, - exchange.id, - twin1155.tokenId, - twin1155.amount, - buyerAddress - ); + it("Should not decrement quantityAvailable", async function () { + // Offer quantityAvailable should be decremented + let [, offer] = await offerHandler.connect(rando).getOffer(offerId); + const quantityAvailableBefore = offer.quantityAvailable; - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + // Commit to preminted offer + await bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); + // Offer quantityAvailable should be decremented + [, offer] = await offerHandler.connect(rando).getOffer(offerId); + assert.equal( + offer.quantityAvailable.toString(), + quantityAvailableBefore.toString(), + "Quantity available should not change" + ); + }); - it("Too many twins", async function () { - await provider.send("evm_setBlockGasLimit", ["0x1c9c380"]); // 30,000,000. Need to set this limit, otherwise the coverage test will fail + it("should still be possible to commit if offer is not fully preminted", async function () { + // Create a new offer + offerId = await offerHandler.getNextOfferId(); + const { offer, offerDates, offerDurations, disputeResolverId } = await mockOffer(); + offer.royaltyInfo[0].bps[0] = voucherInitValues.royaltyPercentage; - const twinCount = 189; + // Create the offer + offer.quantityAvailable = "10"; + const rangeLength = "5"; + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - // ERC20 twins - await foreign20.connect(assistant).approve(protocolDiamondAddress, twinCount * 10, { gasLimit: 30000000 }); - twin20 = mockTwin(await foreign20.getAddress()); - twin20.amount = "1"; - twin20.supplyAvailable = "1"; - for (let i = 0; i < twinCount / 3; i++) { - await twinHandler.connect(assistant).createTwin(twin20.toStruct(), { gasLimit: 30000000 }); - } + // Deposit seller funds so the commit will succeed + await fundsHandler + .connect(rando) + .depositFunds(seller.id, ZeroAddress, offer.sellerDeposit, { value: offer.sellerDeposit }); - // ERC721 twins - const startTokenId = 100; - await foreign721.connect(assistant).setApprovalForAll(protocolDiamondAddress, true, { gasLimit: 30000000 }); - twin721 = mockTwin(await foreign721.getAddress(), TokenType.NonFungibleToken); - twin721.amount = "0"; - twin721.supplyAvailable = "1"; - for (let i = startTokenId; i < startTokenId + twinCount / 3; i++) { - twin721.tokenId = i; - await twinHandler.connect(assistant).createTwin(twin721.toStruct(), { gasLimit: 30000000 }); - } + // reserve half of the offer, so it's still possible to commit directly + await offerHandler.connect(assistant).reserveRange(offerId, rangeLength, await assistant.getAddress()); - // Approve the protocol diamond to transfer seller's tokens - await foreign1155 - .connect(assistant) - .setApprovalForAll(protocolDiamondAddress, true, { gasLimit: 30000000 }); - twin1155 = mockTwin(await foreign1155.getAddress(), TokenType.MultiToken); - twin1155.amount = "1"; - twin1155.supplyAvailable = "1"; - for (let i = 0; i < twinCount / 3; i++) { - await twinHandler.connect(assistant).createTwin(twin1155.toStruct(), { gasLimit: 30000000 }); - } + // Commit to offer directly + await expect( + exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: offer.price }) + ).to.emit(exchangeHandler, "BuyerCommitted"); + }); - // Create a new offer and bundle - const twinIds = [...Array(twinCount + 4).keys()].slice(4); + context("Offer is part of a group", async function () { + let groupId; + let offerIds; - offer.quantityAvailable = 1; - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit, { - gasLimit: 30000000, - }); - bundle = new Bundle("2", seller.id, [`${++offerId}`], twinIds); - await bundleHandler.connect(assistant).createBundle(bundle.toStruct(), { gasLimit: 30000000 }); + beforeEach(async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; + }); - // Commit to offer - const buyerAddress = await buyer.getAddress(); - await exchangeHandler - .connect(buyer) - .commitToOffer(buyerAddress, offerId, { value: price, gasLimit: 30000000 }); + it("Offer is part of a group that has no condition", async function () { + condition = mockCondition({ + tokenAddress: ZeroAddress, + threshold: "0", + maxCommits: "0", + tokenType: TokenType.FungibleToken, + method: EvaluationMethod.None, + }); - exchange.id = Number(exchange.id) + 1; + expect(condition.isValid()).to.be.true; - // Redeem the voucher - tx = await exchangeHandler.connect(buyer).redeemVoucher(exchange.id, { gasLimit: 30000000 }); + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - // Dispute should be raised and twin transfer should be skipped - await expect(tx) - .to.emit(disputeHandler, "DisputeRaised") - .withArgs(exchange.id, exchange.buyerId, seller.id, buyerAddress); + await groupHandler.connect(assistant).createGroup(group, condition); - await expect(tx) - .to.emit(exchangeHandler, "TwinTransferSkipped") - .withArgs(exchange.id, twinCount, buyerAddress); + await foreign721.connect(buyer).mint("123", 1); - // Get the exchange state - [, response] = await exchangeHandler.connect(rando).getExchangeState(exchange.id); + const tx = bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - // It should match ExchangeState.Disputed - assert.equal(response, ExchangeState.Disputed, "Exchange state is incorrect"); - }); + await expect(tx).to.not.emit(exchangeHandler, "ConditionalCommitAuthorized"); }); - }); - }); - context("👉 extendVoucher()", async function () { - beforeEach(async function () { - // Commit to offer - tx = await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + it("Offer is part of a group with condition [ERC20, gating per address]", async function () { + // Create Condition + condition = mockCondition({ tokenAddress: await foreign20.getAddress(), threshold: "50", maxCommits: "3" }); + expect(condition.isValid()).to.be.true; - // Get the block timestamp of the confirmed tx - blockNumber = tx.blockNumber; - block = await provider.getBlock(blockNumber); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - // Update the committed date in the expected exchange struct with the block timestamp of the tx - voucher.committedDate = block.timestamp.toString(); + await groupHandler.connect(assistant).createGroup(group, condition); - // Update the validUntilDate date in the expected exchange struct - voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); + // mint enough tokens for the buyer + await foreign20.connect(buyer).mint(await buyer.getAddress(), condition.threshold); - // New expiry date for extensions - validUntilDate = BigInt(voucher.validUntilDate) + oneMonth.toString(); - }); + const tx = bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - it("should emit an VoucherExtended event when seller's assistant calls", async function () { - // Extend the voucher, expecting event - await expect(exchangeHandler.connect(assistant).extendVoucher(exchange.id, validUntilDate)) - .to.emit(exchangeHandler, "VoucherExtended") - .withArgs(offerId, exchange.id, validUntilDate, await assistant.getAddress()); - }); + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, 0, 1, condition.maxCommits); + }); - it("should update state", async function () { - // Extend the voucher - await exchangeHandler.connect(assistant).extendVoucher(exchange.id, validUntilDate); + it("Offer is part of a group with condition [ERC721, threshold, gating per address]", async function () { + condition = mockCondition({ + tokenAddress: await foreign721.getAddress(), + threshold: "1", + maxCommits: "3", + tokenType: TokenType.NonFungibleToken, + method: EvaluationMethod.Threshold, + }); - // Get the voucher - [, , response] = await exchangeHandler.connect(rando).getExchange(exchange.id); - voucher = Voucher.fromStruct(response); + expect(condition.isValid()).to.be.true; - // It should match the new validUntilDate - assert.equal(voucher.validUntilDate, validUntilDate, "Voucher validUntilDate not updated"); - }); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - context("💔 Revert Reasons", async function () { - it("The exchanges region of protocol is paused", async function () { - // Pause the exchanges region of the protocol - await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + await groupHandler.connect(assistant).createGroup(group, condition); - // Attempt to complete an exchange, expecting revert - await expect( - exchangeHandler.connect(assistant).extendVoucher(exchange.id, validUntilDate) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); - }); + await foreign721.connect(buyer).mint("123", 1); - it("exchange id is invalid", async function () { - // An invalid exchange id - exchangeId = "666"; + const tx = bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - // Attempt to extend voucher, expecting revert - await expect( - exchangeHandler.connect(assistant).extendVoucher(exchangeId, validUntilDate) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_EXCHANGE); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, condition.minTokenId, 1, condition.maxCommits); }); - it("exchange is not in committed state", async function () { - // Cancel the voucher - await exchangeHandler.connect(buyer).cancelVoucher(exchange.id); + it("Offer is part of a group with condition [ERC721, specificToken, gating per address] with range length == 1", async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; - // Attempt to extend voucher, expecting revert - await expect( - exchangeHandler.connect(assistant).extendVoucher(exchange.id, validUntilDate) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_STATE); - }); + condition = mockCondition({ + tokenAddress: await foreign721.getAddress(), + threshold: "0", + maxCommits: "3", + tokenType: TokenType.NonFungibleToken, + method: EvaluationMethod.SpecificToken, + gating: GatingType.PerAddress, + }); - it("caller is not seller's assistant", async function () { - // Attempt to extend voucher, expecting revert - await expect( - exchangeHandler.connect(rando).extendVoucher(exchange.id, validUntilDate) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.NOT_ASSISTANT); - }); + expect(condition.isValid()).to.be.true; - it("new date is not later than the current one", async function () { - // New expiry date is older than current - validUntilDate = BigInt(voucher.validUntilDate) - oneMonth; + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - // Attempt to extend voucher, expecting revert - await expect( - exchangeHandler.connect(assistant).extendVoucher(exchange.id, validUntilDate) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.VOUCHER_EXTENSION_NOT_VALID); - }); - }); - }); + await groupHandler.connect(assistant).createGroup(group, condition); - context("👉 onVoucherTransferred()", async function () { - // majority of lines from onVoucherTransferred() are tested in indirectly in - // `commitToPremintedOffer()` + // mint enough tokens for the buyer + await foreign721.connect(buyer).mint(condition.minTokenId, 1); - beforeEach(async function () { - // Commit to offer, retrieving the event - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); + const tx = bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - // Client used for tests - bosonVoucherCloneAddress = calculateCloneAddress( - await accountHandler.getAddress(), - beaconProxyAddress, - admin.address - ); - bosonVoucherClone = await getContractAt("IBosonVoucher", bosonVoucherCloneAddress); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - tokenId = deriveTokenId(offerId, exchange.id); - }); + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, condition.minTokenId, 1, condition.maxCommits); + }); - it("should emit an VoucherTransferred event when called by CLIENT-roled address", async function () { - // Get the next buyer id - nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + it("Offer is part of a group with condition [ERC721, specificToken, gating per tokenid] with range length == 1", async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; - // Call onVoucherTransferred, expecting event - await expect( - bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) - ) - .to.emit(exchangeHandler, "VoucherTransferred") - .withArgs(offerId, exchange.id, nextAccountId, await bosonVoucherClone.getAddress()); - }); + condition = mockCondition({ + tokenAddress: await foreign721.getAddress(), + threshold: "0", + maxCommits: "3", + tokenType: TokenType.NonFungibleToken, + method: EvaluationMethod.SpecificToken, + gating: GatingType.PerTokenId, + }); - it("should update exchange when new buyer (with existing, active account) is passed", async function () { - // Get the next buyer id - nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + expect(condition.isValid()).to.be.true; - // Create a buyer account for the new owner - await accountHandler.connect(newOwner).createBuyer(mockBuyer(await newOwner.getAddress())); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - // Call onVoucherTransferred - await bosonVoucherClone - .connect(buyer) - .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId); + await groupHandler.connect(assistant).createGroup(group, condition); - // Get the exchange - [exists, response] = await exchangeHandler.connect(rando).getExchange(exchange.id); + // mint enough tokens for the buyer + await foreign721.connect(buyer).mint(condition.minTokenId, 1); - // Marshal response to entity - exchange = Exchange.fromStruct(response); - expect(exchange.isValid()); + const tx = bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - // Exchange's voucher expired flag should be true - assert.equal(exchange.buyerId, nextAccountId, "Exchange.buyerId not updated"); - }); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - it("should update exchange when new buyer (no account) is passed", async function () { - // Get the next buyer id - nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, condition.minTokenId, 1, condition.maxCommits); + }); - // Call onVoucherTransferred - await bosonVoucherClone - .connect(buyer) - .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId); + it("Offer is part of a group with condition [ERC1155, gating per address] with range length == 1", async function () { + condition = mockCondition({ + tokenAddress: await foreign1155.getAddress(), + threshold: "2", + maxCommits: "3", + tokenType: TokenType.MultiToken, + method: EvaluationMethod.Threshold, + minTokenId: "123", + maxTokenId: "123", + gating: GatingType.PerAddress, + }); - // Get the exchange - [exists, response] = await exchangeHandler.connect(rando).getExchange(exchange.id); + expect(condition.isValid()).to.be.true; - // Marshal response to entity - exchange = Exchange.fromStruct(response); - expect(exchange.isValid()); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - // Exchange's voucher expired flag should be true - assert.equal(exchange.buyerId, nextAccountId, "Exchange.buyerId not updated"); - }); + await groupHandler.connect(assistant).createGroup(group, condition); - it("should be triggered when a voucher is transferred", async function () { - // Transfer voucher, expecting event - await expect( - bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) - ).to.emit(exchangeHandler, "VoucherTransferred"); - }); + await foreign1155.connect(buyer).mint(condition.minTokenId, condition.threshold); - it("should not be triggered when a voucher is issued", async function () { - // Get the next exchange id - nextExchangeId = await exchangeHandler.getNextExchangeId(); + const tx = bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); - // Create a buyer account - await accountHandler.connect(newOwner).createBuyer(mockBuyer(await newOwner.getAddress())); + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, condition.minTokenId, 1, condition.maxCommits); + }); - // Grant PROTOCOL role to EOA address for test - await accessController.grantRole(Role.PROTOCOL, await rando.getAddress()); + it("Offer is part of a group with condition [ERC1155, gating per tokenId] with range length == 1", async function () { + condition = mockCondition({ + tokenAddress: await foreign1155.getAddress(), + threshold: "2", + maxCommits: "3", + tokenType: TokenType.MultiToken, + method: EvaluationMethod.Threshold, + minTokenId: "123", + maxTokenId: "123", + gating: GatingType.PerTokenId, + }); - // Issue voucher, expecting no event - await expect( - bosonVoucherClone.connect(rando).issueVoucher(nextExchangeId, await buyer.getAddress()) - ).to.not.emit(exchangeHandler, "VoucherTransferred"); - }); + expect(condition.isValid()).to.be.true; - it("should not be triggered when a voucher is burned", async function () { - // Grant PROTOCOL role to EOA address for test - await accessController.grantRole(Role.PROTOCOL, await rando.getAddress()); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - // Burn voucher, expecting no event - await expect(bosonVoucherClone.connect(rando).burnVoucher(tokenId)).to.not.emit( - exchangeHandler, - "VoucherTransferred" - ); - }); + await groupHandler.connect(assistant).createGroup(group, condition); - it("Should not be triggered when from and to addresses are the same", async function () { - // Transfer voucher, expecting event - await expect( - bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await buyer.getAddress(), tokenId) - ).to.not.emit(exchangeHandler, "VoucherTransferred"); - }); + await foreign1155.connect(buyer).mint(condition.minTokenId, condition.threshold); - it("Should not be triggered when first transfer of preminted voucher happens", async function () { - // Transfer voucher, expecting event - await expect( - bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await buyer.getAddress(), tokenId) - ).to.not.emit(exchangeHandler, "VoucherTransferred"); + const tx = bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); + await expect(tx).to.emit(exchangeHandler, "BuyerCommitted"); + + await expect(tx) + .to.emit(exchangeHandler, "ConditionalCommitAuthorized") + .withArgs(offerId, condition.gating, buyer.address, condition.minTokenId, 1, condition.maxCommits); + }); }); - it("should work with additional collections", async function () { + it("should work on an additional collection", async function () { // Create a new collection const externalId = `Brand1`; voucherInitValues.collectionSalt = encodeBytes32String(externalId); @@ -5965,355 +5734,346 @@ describe("IBosonExchangeHandler", function () { offer.collectionIndex = 1; offer.id = await offerHandler.getNextOfferId(); - exchange.id = await exchangeHandler.getNextExchangeId(); - bosonVoucherCloneAddress = calculateCloneAddress( + exchangeId = await exchangeHandler.getNextExchangeId(); + exchange.offerId = offer.id.toString(); + exchange.id = exchangeId.toString(); + const tokenId = deriveTokenId(offer.id, exchangeId); + + // Create the offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + + // Reserve range + await offerHandler.connect(assistant).reserveRange(offer.id, offer.quantityAvailable, assistant.address); + + // expected address of the additional collection + const voucherCloneAddress = calculateCloneAddress( await accountHandler.getAddress(), beaconProxyAddress, admin.address, voucherInitValues.collectionSalt ); - bosonVoucherClone = await getContractAt("IBosonVoucher", bosonVoucherCloneAddress); - const tokenId = deriveTokenId(offer.id, exchange.id); + bosonVoucher = await getContractAt("BosonVoucher", voucherCloneAddress); + await bosonVoucher.connect(assistant).preMint(offer.id, offer.quantityAvailable); - // Create the offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Commit to preminted offer, retrieving the event + tx = await bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId); + txReceipt = await tx.wait(); + event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); - // Commit to offer, creating a new exchange - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id, { value: price }); + // Get the block timestamp of the confirmed tx + blockNumber = tx.blockNumber; + block = await provider.getBlock(blockNumber); - // Get the next buyer id - nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + // Update the committed date in the expected exchange struct with the block timestamp of the tx + voucher.committedDate = block.timestamp.toString(); - // Call onVoucherTransferred, expecting event - await expect(bosonVoucherClone.connect(buyer).transferFrom(buyer.address, newOwner.address, tokenId)) - .to.emit(exchangeHandler, "VoucherTransferred") - .withArgs(offer.id, exchange.id, nextAccountId, await bosonVoucherClone.getAddress()); + // Update the validUntilDate date in the expected exchange struct + voucher.validUntilDate = calculateVoucherExpiry(block, voucherRedeemableFrom, voucherValid); + + // Examine event + assert.equal(event.exchangeId.toString(), exchangeId, "Exchange id is incorrect"); + assert.equal(event.offerId.toString(), offer.id, "Offer id is incorrect"); + assert.equal(event.buyerId.toString(), buyerId, "Buyer id is incorrect"); + + // Examine the exchange struct + assert.equal( + Exchange.fromStruct(event.exchange).toString(), + exchange.toString(), + "Exchange struct is incorrect" + ); + + // Examine the voucher struct + assert.equal(Voucher.fromStruct(event.voucher).toString(), voucher.toString(), "Voucher struct is incorrect"); }); context("💔 Revert Reasons", async function () { + it("The exchanges region of protocol is paused", async function () { + // Pause the exchanges region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Exchanges]); + + // Attempt to create an exchange, expecting revert + await expect( + bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); + }); + it("The buyers region of protocol is paused", async function () { // Pause the buyers region of the protocol await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); // Attempt to create a buyer, expecting revert await expect( - bosonVoucherClone - .connect(buyer) - .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) + bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); }); - it("Caller is not a clone address", async function () { - // Attempt to call onVoucherTransferred, expecting revert - await expect( - exchangeHandler.connect(rando).onVoucherTransferred(exchange.id, await newOwner.getAddress()) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); - }); - - it("Caller is not a clone address associated with the seller", async function () { - // Create a new seller to get new clone - seller = mockSeller( - await rando.getAddress(), - await rando.getAddress(), - ZeroAddress, - await rando.getAddress() - ); - expect(seller.isValid()).is.true; - - await accountHandler.connect(rando).createSeller(seller, emptyAuthToken, voucherInitValues); - expectedCloneAddress = calculateCloneAddress( - await accountHandler.getAddress(), - beaconProxyAddress, - rando.address - ); - const bosonVoucherClone2 = await getContractAt("IBosonVoucher", expectedCloneAddress); - - // For the sake of test, mint token on bv2 with the id of token on bv1 - // Temporarily grant PROTOCOL role to deployer account - await accessController.grantRole(Role.PROTOCOL, await deployer.getAddress()); - - const newBuyer = mockBuyer(await buyer.getAddress()); - newBuyer.id = buyerId; - await bosonVoucherClone2.issueVoucher(exchange.id, newBuyer.wallet); - - // Attempt to call onVoucherTransferred, expecting revert + it("Caller is not the voucher contract, owned by the seller", async function () { + // Attempt to commit to preminted offer, expecting revert await expect( - bosonVoucherClone2 - .connect(buyer) - .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), exchange.id) + exchangeHandler + .connect(rando) + .onPremintedVoucherTransferred( + tokenId, + await buyer.getAddress(), + await assistant.getAddress(), + await assistant.getAddress() + ) ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); }); - it("exchange id is invalid", async function () { - // An invalid exchange id - exchangeId = "666"; - - // Attempt to call onVoucherTransferred, expecting revert - await expect( - exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_EXCHANGE); - }); + it("Exchange exists already", async function () { + // Commit to preminted offer, creating a new exchange + await bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId); - it("exchange is not in committed state", async function () { - // Revoke the voucher - await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); + // impersonate voucher contract and give it some funds + const impersonatedBosonVoucher = await getImpersonatedSigner(await bosonVoucher.getAddress()); + await provider.send("hardhat_setBalance", [ + await impersonatedBosonVoucher.getAddress(), + toBeHex(parseEther("10")), + ]); - // Attempt to call onVoucherTransferred, expecting revert + // Simulate a second commit with the same token id await expect( - exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_STATE); + exchangeHandler + .connect(impersonatedBosonVoucher) + .onPremintedVoucherTransferred( + tokenId, + await buyer.getAddress(), + await assistant.getAddress(), + await assistant.getAddress() + ) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.EXCHANGE_ALREADY_EXISTS); }); - it("Voucher has expired", async function () { - // Set time forward past the voucher's validUntilDate - await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); + it("offer is voided", async function () { + // Void the offer first + await offerHandler.connect(assistant).voidOffer(offerId); - // Attempt to call onVoucherTransferred, expecting revert + // Attempt to commit to the voided offer, expecting revert await expect( - exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.VOUCHER_HAS_EXPIRED); + bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_HAS_BEEN_VOIDED); }); - }); - }); - - context("👉 onPremintedVoucherTransferred()", async function () { - beforeEach(async function () { - // Commit to offer, retrieving the event - await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }); - // Client used for tests - bosonVoucherCloneAddress = calculateCloneAddress( - await accountHandler.getAddress(), - beaconProxyAddress, - admin.address - ); - bosonVoucherClone = await getContractAt("IBosonVoucher", bosonVoucherCloneAddress); - - tokenId = deriveTokenId(offerId, exchange.id); - }); - - it("should emit an VoucherTransferred event when called by CLIENT-roled address", async function () { - // Get the next buyer id - nextAccountId = await accountHandler.connect(rando).getNextAccountId(); - - // Call onVoucherTransferred, expecting event - await expect( - bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) - ) - .to.emit(exchangeHandler, "VoucherTransferred") - .withArgs(offerId, exchange.id, nextAccountId, await bosonVoucherClone.getAddress()); - }); - - it("should update exchange when new buyer (with existing, active account) is passed", async function () { - // Get the next buyer id - nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + it("offer is not yet available for commits", async function () { + // Create an offer with staring date in the future + // get current block timestamp + const block = await provider.getBlock("latest"); + const now = block.timestamp.toString(); - // Create a buyer account for the new owner - await accountHandler.connect(newOwner).createBuyer(mockBuyer(await newOwner.getAddress())); + // Get next offer id + offerId = await offerHandler.getNextOfferId(); + // set validFrom date in the past + offerDates.validFrom = BigInt(now + oneMonth * 6n).toString(); // 6 months in the future + offerDates.validUntil = BigInt(offerDates.validFrom + 10n).toString(); // just after the valid from so it succeeds. - // Call onVoucherTransferred - await bosonVoucherClone - .connect(buyer) - .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId); + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); - // Get the exchange - [exists, response] = await exchangeHandler.connect(rando).getExchange(exchange.id); + // Reserve a range and premint vouchers + exchangeId = await exchangeHandler.getNextExchangeId(); + await offerHandler.connect(assistant).reserveRange(offerId, "1", await assistant.getAddress()); + await bosonVoucher.connect(assistant).preMint(offerId, "1"); - // Marshal response to entity - exchange = Exchange.fromStruct(response); - expect(exchange.isValid()); + tokenId = deriveTokenId(offerId, exchangeId); - // Exchange's voucher expired flag should be true - assert.equal(exchange.buyerId, nextAccountId, "Exchange.buyerId not updated"); - }); + // Attempt to commit to the not available offer, expecting revert + await expect( + bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_NOT_AVAILABLE); + }); - it("should update exchange when new buyer (no account) is passed", async function () { - // Get the next buyer id - nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + it("offer has expired", async function () { + // Go past offer expiration date + await setNextBlockTimestamp(Number(offerDates.validUntil) + 1); - // Call onVoucherTransferred - await bosonVoucherClone - .connect(buyer) - .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId); + // Attempt to commit to the expired offer, expecting revert + await expect( + bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_HAS_EXPIRED); + }); - // Get the exchange - [exists, response] = await exchangeHandler.connect(rando).getExchange(exchange.id); + it("should not be able to commit directly if whole offer preminted", async function () { + // Create an offer with only 1 item + offer.quantityAvailable = "1"; + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + // Commit to offer, so it's not available anymore + await exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), ++offerId, { value: price }); - // Marshal response to entity - exchange = Exchange.fromStruct(response); - expect(exchange.isValid()); + // Attempt to commit to the sold out offer, expecting revert + await expect( + exchangeHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId, { value: price }) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.OFFER_SOLD_OUT); + }); - // Exchange's voucher expired flag should be true - assert.equal(exchange.buyerId, nextAccountId, "Exchange.buyerId not updated"); - }); + it("buyer does not meet condition for commit", async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; - it("should be triggered when a voucher is transferred", async function () { - // Transfer voucher, expecting event - await expect( - bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) - ).to.emit(exchangeHandler, "VoucherTransferred"); - }); + condition = mockCondition({ + tokenAddress: await foreign721.getAddress(), + threshold: "1", + maxCommits: "3", + tokenType: TokenType.NonFungibleToken, + tokenId: "0", + method: EvaluationMethod.Threshold, + }); - it("should not be triggered when a voucher is issued", async function () { - // Get the next exchange id - nextExchangeId = await exchangeHandler.getNextExchangeId(); + expect(condition.isValid()).to.be.true; - // Create a buyer account - await accountHandler.connect(newOwner).createBuyer(mockBuyer(await newOwner.getAddress())); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - // Grant PROTOCOL role to EOA address for test - await accessController.grantRole(Role.PROTOCOL, await rando.getAddress()); + await groupHandler.connect(assistant).createGroup(group, condition); - // Issue voucher, expecting no event - await expect( - bosonVoucherClone.connect(rando).issueVoucher(nextExchangeId, await buyer.getAddress()) - ).to.not.emit(exchangeHandler, "VoucherTransferred"); - }); + await expect( + bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); + }); - it("should not be triggered when a voucher is burned", async function () { - // Grant PROTOCOL role to EOA address for test - await accessController.grantRole(Role.PROTOCOL, await rando.getAddress()); + it("Offer is part of a group with condition [ERC721, specificToken, gating per address] with length > 1", async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; - // Burn voucher, expecting no event - await expect(bosonVoucherClone.connect(rando).burnVoucher(tokenId)).to.not.emit( - exchangeHandler, - "VoucherTransferred" - ); - }); + condition = mockCondition({ + tokenAddress: await foreign721.getAddress(), + threshold: "0", + maxCommits: "3", + tokenType: TokenType.NonFungibleToken, // ERC721 + minTokenId: "0", + method: EvaluationMethod.SpecificToken, // per-token + maxTokenId: "12", + gating: GatingType.PerAddress, + }); - it("Should not be triggered when from and to addresses are the same", async function () { - // Transfer voucher, expecting event - await expect( - bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await buyer.getAddress(), tokenId) - ).to.not.emit(exchangeHandler, "VoucherTransferred"); - }); + expect(condition.isValid()).to.be.true; - it("Should not be triggered when first transfer of preminted voucher happens", async function () { - // Transfer voucher, expecting event - await expect( - bosonVoucherClone.connect(buyer).transferFrom(await buyer.getAddress(), await buyer.getAddress(), tokenId) - ).to.not.emit(exchangeHandler, "VoucherTransferred"); - }); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - it("should work with additional collections", async function () { - // Create a new collection - const externalId = `Brand1`; - voucherInitValues.collectionSalt = encodeBytes32String(externalId); - await accountHandler.connect(assistant).createNewCollection(externalId, voucherInitValues); + await groupHandler.connect(assistant).createGroup(group, condition); - offer.collectionIndex = 1; - offer.id = await offerHandler.getNextOfferId(); - exchange.id = await exchangeHandler.getNextExchangeId(); - bosonVoucherCloneAddress = calculateCloneAddress( - await accountHandler.getAddress(), - beaconProxyAddress, - admin.address, - voucherInitValues.collectionSalt - ); - bosonVoucherClone = await getContractAt("IBosonVoucher", bosonVoucherCloneAddress); - const tokenId = deriveTokenId(offer.id, exchange.id); + await expect( + bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); + }); - // Create the offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId, offerFeeLimit); + it("Offer is part of a group with condition [ERC721, specificToken, gating per tokenId] with length > 1", async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; - // Commit to offer, creating a new exchange - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id, { value: price }); + condition = mockCondition({ + tokenAddress: await foreign721.getAddress(), + threshold: "0", + maxCommits: "3", + tokenType: TokenType.NonFungibleToken, // ERC721 + minTokenId: "0", + method: EvaluationMethod.SpecificToken, // per-token + maxTokenId: "12", + gating: GatingType.PerTokenId, + }); - // Get the next buyer id - nextAccountId = await accountHandler.connect(rando).getNextAccountId(); + expect(condition.isValid()).to.be.true; - // Call onVoucherTransferred, expecting event - await expect(bosonVoucherClone.connect(buyer).transferFrom(buyer.address, newOwner.address, tokenId)) - .to.emit(exchangeHandler, "VoucherTransferred") - .withArgs(offer.id, exchange.id, nextAccountId, await bosonVoucherClone.getAddress()); - }); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - context("💔 Revert Reasons", async function () { - it("The buyers region of protocol is paused", async function () { - // Pause the buyers region of the protocol - await pauseHandler.connect(pauser).pause([PausableRegion.Buyers]); + await groupHandler.connect(assistant).createGroup(group, condition); - // Attempt to create a buyer, expecting revert await expect( - bosonVoucherClone - .connect(buyer) - .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), tokenId) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); + bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); }); - it("Caller is not a clone address", async function () { - // Attempt to call onVoucherTransferred, expecting revert - await expect( - exchangeHandler.connect(rando).onVoucherTransferred(exchange.id, await newOwner.getAddress()) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); - }); + it("Offer is part of a group with condition [ERC1155, gating per address] with length > 1", async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; - it("Caller is not a clone address associated with the seller", async function () { - // Create a new seller to get new clone - seller = mockSeller( - await rando.getAddress(), - await rando.getAddress(), - ZeroAddress, - await rando.getAddress() - ); - expect(seller.isValid()).is.true; + condition = mockCondition({ + tokenAddress: await foreign1155.getAddress(), + threshold: "2", + maxCommits: "3", + tokenType: TokenType.MultiToken, // ERC1155 + tokenId: "1", + method: EvaluationMethod.Threshold, // per-wallet + length: "2", + gating: GatingType.PerAddress, + }); - await accountHandler.connect(rando).createSeller(seller, emptyAuthToken, voucherInitValues); - expectedCloneAddress = calculateCloneAddress( - await accountHandler.getAddress(), - beaconProxyAddress, - rando.address - ); - const bosonVoucherClone2 = await getContractAt("IBosonVoucher", expectedCloneAddress); + expect(condition.isValid()).to.be.true; - // For the sake of test, mint token on bv2 with the id of token on bv1 - // Temporarily grant PROTOCOL role to deployer account - await accessController.grantRole(Role.PROTOCOL, await deployer.getAddress()); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - const newBuyer = mockBuyer(await buyer.getAddress()); - newBuyer.id = buyerId; - await bosonVoucherClone2.issueVoucher(exchange.id, newBuyer.wallet); + await groupHandler.connect(assistant).createGroup(group, condition); - // Attempt to call onVoucherTransferred, expecting revert await expect( - bosonVoucherClone2 - .connect(buyer) - .transferFrom(await buyer.getAddress(), await newOwner.getAddress(), exchange.id) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.ACCESS_DENIED); + bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); }); - it("exchange id is invalid", async function () { - // An invalid exchange id - exchangeId = "666"; + it("Offer is part of a group with condition [ERC1155, gating per tokenId] with length > 1", async function () { + // Required constructor params for Group + groupId = "1"; + offerIds = [offerId]; - // Attempt to call onVoucherTransferred, expecting revert - await expect( - exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_EXCHANGE); - }); + condition = mockCondition({ + tokenAddress: await foreign1155.getAddress(), + threshold: "2", + maxCommits: "3", + tokenType: TokenType.MultiToken, // ERC1155 + tokenId: "1", + method: EvaluationMethod.Threshold, // per-wallet + length: "2", + gating: GatingType.PerTokenId, + }); - it("exchange is not in committed state", async function () { - // Revoke the voucher - await exchangeHandler.connect(assistant).revokeVoucher(exchange.id); + expect(condition.isValid()).to.be.true; - // Attempt to call onVoucherTransferred, expecting revert - await expect( - exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_STATE); - }); + // Create Group + group = new Group(groupId, seller.id, offerIds); + expect(group.isValid()).is.true; - it("Voucher has expired", async function () { - // Set time forward past the voucher's validUntilDate - await setNextBlockTimestamp(Number(voucherRedeemableFrom) + Number(voucherValid) + Number(oneWeek)); + await groupHandler.connect(assistant).createGroup(group, condition); - // Attempt to call onVoucherTransferred, expecting revert await expect( - exchangeHandler.connect(fauxClient).onVoucherTransferred(exchangeId, await newOwner.getAddress()) - ).to.revertedWithCustomError(bosonErrors, RevertReasons.VOUCHER_HAS_EXPIRED); + bosonVoucher + .connect(assistant) + .transferFrom(await assistant.getAddress(), await buyer.getAddress(), tokenId) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.CANNOT_COMMIT); }); }); });