diff --git a/test/protocol/OfferHandlerTest.js b/test/protocol/OfferHandlerTest.js index 311d192d3..2d02370c1 100644 --- a/test/protocol/OfferHandlerTest.js +++ b/test/protocol/OfferHandlerTest.js @@ -24,6 +24,7 @@ const { revertToSnapshot, deriveTokenId, compareOfferStructs, + compareRoyaltyInfo, } = require("../util/utils.js"); const { oneWeek, oneMonth, oneDay } = require("../util/constants"); const { @@ -1454,6 +1455,164 @@ describe("IBosonOfferHandler", function () { }); }); + context("👉 updateOfferRoyaltyRecipients()", async function () { + let newRoyaltyInfo, expectedRoyaltyInfo; + beforeEach(async function () { + // Create an offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolver.id, agentId); + + // Register royalty recipients + const royaltyRecipientList = new RoyaltyRecipientList([ + new RoyaltyRecipient(other.address, "50", "other"), + new RoyaltyRecipient(other2.address, "50", "other2"), + new RoyaltyRecipient(rando.address, "50", "other3"), + ]); + await accountHandler.connect(admin).addRoyaltyRecipients(seller.id, royaltyRecipientList.toStruct()); + + const recipients = [other.address, other2.address, ZeroAddress, rando.address]; + const bps = ["100", "150", "500", "200"]; + newRoyaltyInfo = new RoyaltyInfo(recipients, bps); + + const expectedRecipients = [...recipients]; + expectedRecipients[2] = treasury.address; + expectedRoyaltyInfo = new RoyaltyInfo(recipients, bps).toStruct(); + }); + + it("should emit an OfferRoyaltyInfoUpdated event", async function () { + // Update the royalty recipients, testing for the event + await expect(offerHandler.connect(assistant).updateOfferRoyaltyRecipients(offer.id, newRoyaltyInfo)) + .to.emit(offerHandler, "OfferRoyaltyInfoUpdated") + .withArgs( + offer.id, + offer.sellerId, + compareRoyaltyInfo.bind(expectedRoyaltyInfo), + await assistant.getAddress() + ); + }); + + it("should update state", async function () { + // Update an offer + await offerHandler.connect(assistant).updateOfferRoyaltyRecipients(offer.id, newRoyaltyInfo); + + // Get the offer as a struct + [, offerStruct] = await offerHandler.connect(rando).getOffer(offer.id); + + // Parse into entity + const returnedOffer = Offer.fromStruct(offerStruct); + + // New values should be appended to the end of offer.royaltyInfo + offer.royaltyInfo = [new RoyaltyInfo([ZeroAddress], [voucherInitValues.royaltyPercentage]), newRoyaltyInfo]; + expect(returnedOffer).to.eql(offer); + }); + + context("💔 Revert Reasons", async function () { + it("The offers region of protocol is paused", async function () { + // Pause the offers region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Offers]); + + // Attempt to update the offer expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipients(offer.id, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); + }); + + it("Offer does not exist", async function () { + // Set invalid id + id = "444"; + + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipients(id, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_OFFER); + + // Set invalid id + id = "0"; + + // Attempt to void the offer, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipients(id, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_OFFER); + }); + + it("Caller is not seller", async function () { + // caller is not the assistant of any seller + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(rando).updateOfferRoyaltyRecipients(offer.id, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NOT_ASSISTANT); + + // caller is an assistant of another seller + // Create a valid seller, then set fields in tests directly + seller = mockSeller( + await rando.getAddress(), + await rando.getAddress(), + ZeroAddress, + await rando.getAddress() + ); + + // AuthToken + emptyAuthToken = mockAuthToken(); + expect(emptyAuthToken.isValid()).is.true; + await accountHandler.connect(rando).createSeller(seller, emptyAuthToken, voucherInitValues); + + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(rando).updateOfferRoyaltyRecipients(offer.id, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NOT_ASSISTANT); + }); + + it("Number of recipients and bps is different", async function () { + // Set invalid id + const recipients = [other.address, other2.address, ZeroAddress, rando.address]; + const bps = ["100", "150", "500"]; + newRoyaltyInfo = new RoyaltyInfo(recipients, bps); + + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipients(id, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.ARRAY_LENGTH_MISMATCH); + }); + + it("Royalty recipient is not approved", async function () { + // Set invalid id + const recipients = [other.address, other2.address, assistant.address, rando.address]; // assistant is not approved + const bps = ["100", "150", "500", "100"]; + newRoyaltyInfo = new RoyaltyInfo(recipients, bps); + + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipients(id, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_ROYALTY_RECIPIENT); + }); + + it("Royalties are below the minimum", async function () { + // Set invalid single bps + const recipients = [other.address, other2.address, ZeroAddress, rando.address]; + const bps = ["100", "150", "500", "40"]; // 40 bps is below the minimum, set by the seller admin + newRoyaltyInfo = new RoyaltyInfo(recipients, bps); + + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipients(id, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_ROYALTY_PERCENTAGE); + }); + + it("Total royalties are above the protocol maximum", async function () { + // Set bps so they are over protocol minimum (10%) + const recipients = [other.address, other2.address, ZeroAddress, rando.address]; + const bps = ["100", "150", "500", "400"]; + newRoyaltyInfo = new RoyaltyInfo(recipients, bps); + + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipients(id, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_ROYALTY_PERCENTAGE); + }); + }); + }); + context("👉 reserveRange()", async function () { let firstTokenId, lastTokenId, length, range; let bosonVoucher; @@ -2070,8 +2229,8 @@ describe("IBosonOfferHandler", function () { // offer with royalty recipients const royaltyRecipientList = new RoyaltyRecipientList([ - new RoyaltyRecipient(other.address, "100", "other"), - new RoyaltyRecipient(other2.address, "200", "other2"), + new RoyaltyRecipient(other.address, "50", "other"), + new RoyaltyRecipient(other2.address, "50", "other2"), ]); await accountHandler.connect(admin).addRoyaltyRecipients(seller.id, royaltyRecipientList.toStruct()); offers[3].royaltyInfo = [new RoyaltyInfo([other.address, ZeroAddress], ["150", "10"])]; @@ -2818,7 +2977,7 @@ describe("IBosonOfferHandler", function () { it("Royalty percentage is less that the value decided by the admin", async function () { // Add royalty info to the offer - offers[3].royaltyInfo = [new RoyaltyInfo([other.address, other2.address], ["90", "250"])]; + offers[3].royaltyInfo = [new RoyaltyInfo([other.address, other2.address], ["40", "250"])]; // Create an offer testing for the event await expect( @@ -3202,7 +3361,7 @@ describe("IBosonOfferHandler", function () { // Pause the offers region of the protocol await pauseHandler.connect(pauser).pause([PausableRegion.Offers]); - // Attempt to void offer batch, expecting revert + // Attempt to extend offer batch, expecting revert await expect( offerHandler.connect(assistant).extendOfferBatch(offersToExtend, newValidUntilDate) ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); @@ -3309,5 +3468,162 @@ describe("IBosonOfferHandler", function () { }); }); }); + + context("👉 updateOfferRoyaltyRecipientsBatch()", async function () { + let offersToUpdate, newRoyaltyInfo, expectedRoyaltyInfo; + beforeEach(async function () { + // Create an offer + await offerHandler + .connect(assistant) + .createOfferBatch(offers, offerDatesList, offerDurationsList, disputeResolverIds, agentIds); + + // Register royalty recipients + const royaltyRecipientList = new RoyaltyRecipientList([new RoyaltyRecipient(rando.address, "50", "other3")]); + await accountHandler.connect(admin).addRoyaltyRecipients(seller.id, royaltyRecipientList.toStruct()); + + offersToUpdate = ["1", "4", "5"]; + const recipients = [other.address, other2.address, ZeroAddress, rando.address]; + const bps = ["100", "200", "500", "200"]; + newRoyaltyInfo = new RoyaltyInfo(recipients, bps); + + const expectedRecipients = [...recipients]; + expectedRecipients[2] = treasury.address; + expectedRoyaltyInfo = new RoyaltyInfo(recipients, bps).toStruct(); + + for (const offerToUpdate of offersToUpdate) { + let i = offerToUpdate - 1; + offers[i].royaltyInfo.push(newRoyaltyInfo); + } + }); + + it("should emit OfferRoyaltyInfoUpdated events", async function () { + // Update the royalty info, testing for the event + const tx = await offerHandler + .connect(assistant) + .updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo); + await expect(tx) + .to.emit(offerHandler, "OfferRoyaltyInfoUpdated") + .withArgs(offersToUpdate[0], offer.sellerId, compareRoyaltyInfo.bind(expectedRoyaltyInfo), assistant.address); + + await expect(tx) + .to.emit(offerHandler, "OfferRoyaltyInfoUpdated") + .withArgs(offersToUpdate[1], offer.sellerId, compareRoyaltyInfo.bind(expectedRoyaltyInfo), assistant.address); + + await expect(tx) + .to.emit(offerHandler, "OfferRoyaltyInfoUpdated") + .withArgs(offersToUpdate[2], offer.sellerId, compareRoyaltyInfo.bind(expectedRoyaltyInfo), assistant.address); + }); + + it("should update state", async function () { + // Update offers + await offerHandler.connect(assistant).updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo); + + for (const id of offersToUpdate) { + // validUntilDate field should be updated + [, offerStruct] = await offerHandler.getOffer(id); + const returnedOffer = Offer.fromStruct(offerStruct); + expect(returnedOffer).to.eql(offers[id - 1]); + } + }); + + context("💔 Revert Reasons", async function () { + it("The offers region of protocol is paused", async function () { + // Pause the offers region of the protocol + await pauseHandler.connect(pauser).pause([PausableRegion.Offers]); + + // Attempt to update offer batch, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.REGION_PAUSED); + }); + + it("Offer does not exist", async function () { + // Set invalid id + offersToUpdate = ["1", "432", "2"]; + + // Attempt to update the offers, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_OFFER); + + // Set invalid id + offersToUpdate = ["1", "2", "0"]; + + // Attempt to update the offers, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NO_SUCH_OFFER); + }); + + it("Caller is not seller", async function () { + // caller is not the assistant of any seller + // Attempt to update the offers, expecting revert + await expect( + offerHandler.connect(rando).updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NOT_ASSISTANT); + + // caller is an assistant of another seller + seller = mockSeller(rando.address, rando.address, ZeroAddress, rando.address); + + // AuthToken + emptyAuthToken = mockAuthToken(); + expect(emptyAuthToken.isValid()).is.true; + await accountHandler.connect(rando).createSeller(seller, emptyAuthToken, voucherInitValues); + + // Attempt to extend the offers, expecting revert + await expect( + offerHandler.connect(rando).updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.NOT_ASSISTANT); + }); + + it("Number of recipients and bps is different", async function () { + // Set invalid id + const recipients = [other.address, other2.address, ZeroAddress, rando.address]; + const bps = ["100", "150", "500"]; + newRoyaltyInfo = new RoyaltyInfo(recipients, bps); + + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.ARRAY_LENGTH_MISMATCH); + }); + + it("Royalty recipient is not approved", async function () { + // Set invalid id + const recipients = [other.address, other2.address, assistant.address, rando.address]; // assistant is not approved + const bps = ["100", "150", "500", "100"]; + newRoyaltyInfo = new RoyaltyInfo(recipients, bps); + + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_ROYALTY_RECIPIENT); + }); + + it("Royalties are below the minimum", async function () { + // Set invalid single bps + const recipients = [other.address, other2.address, ZeroAddress, rando.address]; + const bps = ["100", "150", "500", "40"]; // 40 bps is below the minimum, set by the seller admin + newRoyaltyInfo = new RoyaltyInfo(recipients, bps); + + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_ROYALTY_PERCENTAGE); + }); + + it("Total royalties are above the protocol maximum", async function () { + // Set bps so they are over protocol minimum (10%) + const recipients = [other.address, other2.address, ZeroAddress, rando.address]; + const bps = ["100", "150", "500", "400"]; + newRoyaltyInfo = new RoyaltyInfo(recipients, bps); + + // Attempt to update the offer, expecting revert + await expect( + offerHandler.connect(assistant).updateOfferRoyaltyRecipientsBatch(offersToUpdate, newRoyaltyInfo) + ).to.revertedWithCustomError(bosonErrors, RevertReasons.INVALID_ROYALTY_PERCENTAGE); + }); + }); + }); }); }); diff --git a/test/util/utils.js b/test/util/utils.js index 8e575f199..d7da44abd 100644 --- a/test/util/utils.js +++ b/test/util/utils.js @@ -23,6 +23,7 @@ const { toHexString } = require("../../scripts/util/utils.js"); const { expect } = require("chai"); const Offer = require("../../scripts/domain/Offer"); const { RoyaltyRecipientList } = require("../../scripts/domain/RoyaltyRecipient.js"); +const { RoyaltyInfo } = require("../../scripts/domain/RoyaltyInfo.js"); function getEvent(receipt, factory, eventName) { let found = false; @@ -137,7 +138,7 @@ function compareOfferStructs(returnedOffer) { // ToDo: make a generic predicate for comparing structs /** Predicate to compare RoyaltyRecipientList in emitted events - * Bind Royalty Recipient List to this function and pass it to .withArgs() instead of the expected offer struct + * Bind Royalty Recipient List to this function and pass it to .withArgs() instead of the expected Royalty recipient list * If returned and expected Royalty Recipient Lists are equal, the test will pass, otherwise it raises an error * * Example @@ -154,6 +155,18 @@ function compareRoyaltyRecipientLists(returnedRoyaltyRecipientList) { return true; } +/** Predicate to compare RoyaltyInfo in emitted events + * Bind Royalty Info to this function and pass it to .withArgs() instead of the expected Royalty Info struct + * If returned and expected Royalty Infos are equal, the test will pass, otherwise it raises an error + * + * @param {*} returnedRoyaltyInfo + * @returns + */ +function compareRoyaltyInfo(returnedRoyaltyInfo) { + expect(RoyaltyInfo.fromStruct(returnedRoyaltyInfo).toStruct()).to.deep.equal(this); + return true; +} + async function setNextBlockTimestamp(timestamp, mine = false) { if (typeof timestamp == "string" && timestamp.startsWith("0x0") && timestamp.length > 3) timestamp = "0x" + timestamp.substring(3); @@ -548,3 +561,4 @@ exports.setupTestEnvironment = setupTestEnvironment; exports.getSnapshot = getSnapshot; exports.revertToSnapshot = revertToSnapshot; exports.getSellerSalt = getSellerSalt; +exports.compareRoyaltyInfo = compareRoyaltyInfo;