From a9b06fa869adc58e7fff606ac2d298d6627b06c7 Mon Sep 17 00:00:00 2001 From: zajck Date: Mon, 17 Apr 2023 13:08:11 +0200 Subject: [PATCH 01/33] remove zero DR fee restriction --- contracts/domain/BosonConstants.sol | 1 - .../handlers/IBosonAccountHandler.sol | 4 ---- .../facets/DisputeResolverHandlerFacet.sol | 9 --------- scripts/config/revert-reasons.js | 1 - test/protocol/DisputeResolverHandlerTest.js | 18 ------------------ 5 files changed, 33 deletions(-) diff --git a/contracts/domain/BosonConstants.sol b/contracts/domain/BosonConstants.sol index d97ee911b..c06c30660 100644 --- a/contracts/domain/BosonConstants.sol +++ b/contracts/domain/BosonConstants.sol @@ -60,7 +60,6 @@ string constant NO_SUCH_DISPUTE_RESOLVER = "No such dispute resolver"; string constant INVALID_ESCALATION_PERIOD = "Invalid escalation period"; string constant INVALID_AMOUNT_DISPUTE_RESOLVER_FEES = "Dispute resolver fees are not present or exceed maximum dispute resolver fees in a single transaction"; string constant DUPLICATE_DISPUTE_RESOLVER_FEES = "Duplicate dispute resolver fee"; -string constant FEE_AMOUNT_NOT_YET_SUPPORTED = "Non-zero dispute resolver fees not yet supported"; string constant DISPUTE_RESOLVER_FEE_NOT_FOUND = "Dispute resolver fee not found"; string constant SELLER_ALREADY_APPROVED = "Seller id is approved already"; string constant SELLER_NOT_APPROVED = "Seller id is not approved"; diff --git a/contracts/interfaces/handlers/IBosonAccountHandler.sol b/contracts/interfaces/handlers/IBosonAccountHandler.sol index 70a013f41..401854286 100644 --- a/contracts/interfaces/handlers/IBosonAccountHandler.sol +++ b/contracts/interfaces/handlers/IBosonAccountHandler.sol @@ -68,11 +68,9 @@ interface IBosonAccountHandler is IBosonAccountEvents { * - Some seller does not exist * - Some seller id is duplicated * - DisputeResolver is not active (if active == false) - * - Fee amount is a non-zero value. Protocol doesn't yet support fees for dispute resolvers * * @param _disputeResolver - the fully populated struct with dispute resolver id set to 0x0 * @param _disputeResolverFees - list of fees dispute resolver charges per token type. Zero address is native currency. See {BosonTypes.DisputeResolverFee} - * feeAmount will be ignored because protocol doesn't yet support fees yet but DR still needs to provide array of fees to choose supported tokens * @param _sellerAllowList - list of ids of sellers that can choose this dispute resolver. If empty, there are no restrictions on which seller can chose it. */ function createDisputeResolver( @@ -235,11 +233,9 @@ interface IBosonAccountHandler is IBosonAccountEvents { * - Number of DisputeResolverFee structs in array exceeds max * - Number of DisputeResolverFee structs in array is zero * - DisputeResolverFee array contains duplicates - * - Fee amount is a non-zero value. Protocol doesn't yet support fees for dispute resolvers * * @param _disputeResolverId - id of the dispute resolver * @param _disputeResolverFees - list of fees dispute resolver charges per token type. Zero address is native currency. See {BosonTypes.DisputeResolverFee} - * feeAmount will be ignored because protocol doesn't yet support fees yet but DR still needs to provide array of fees to choose supported tokens */ function addFeesToDisputeResolver( uint256 _disputeResolverId, diff --git a/contracts/protocol/facets/DisputeResolverHandlerFacet.sol b/contracts/protocol/facets/DisputeResolverHandlerFacet.sol index 90229c3b4..d638aa01f 100644 --- a/contracts/protocol/facets/DisputeResolverHandlerFacet.sol +++ b/contracts/protocol/facets/DisputeResolverHandlerFacet.sol @@ -37,11 +37,9 @@ contract DisputeResolverHandlerFacet is IBosonAccountEvents, ProtocolBase { * - Some seller does not exist * - Some seller id is duplicated * - DisputeResolver is not active (if active == false) - * - Fee amount is a non-zero value. Protocol doesn't yet support fees for dispute resolvers * * @param _disputeResolver - the fully populated struct with dispute resolver id set to 0x0 * @param _disputeResolverFees - list of fees dispute resolver charges per token type. Zero address is native currency. See {BosonTypes.DisputeResolverFee} - * feeAmount will be ignored because protocol doesn't yet support fees yet but DR still needs to provide array of fees to choose supported tokens * @param _sellerAllowList - list of ids of sellers that can choose this dispute resolver. If empty, there are no restrictions on which seller can chose it. */ function createDisputeResolver( @@ -136,9 +134,6 @@ contract DisputeResolverHandlerFacet is IBosonAccountEvents, ProtocolBase { DUPLICATE_DISPUTE_RESOLVER_FEES ); - // Protocol doesn't yet support DR fees - require(_disputeResolverFees[i].feeAmount == 0, FEE_AMOUNT_NOT_YET_SUPPORTED); - disputeResolverFees.push(_disputeResolverFees[i]); // Set index mapping. Should be index in disputeResolverFees array + 1 @@ -415,11 +410,9 @@ contract DisputeResolverHandlerFacet is IBosonAccountEvents, ProtocolBase { * - Number of DisputeResolverFee structs in array exceeds max * - Number of DisputeResolverFee structs in array is zero * - DisputeResolverFee array contains duplicates - * - Fee amount is a non-zero value. Protocol doesn't yet support fees for dispute resolvers * * @param _disputeResolverId - id of the dispute resolver * @param _disputeResolverFees - list of fees dispute resolver charges per token type. Zero address is native currency. See {BosonTypes.DisputeResolverFee} - * feeAmount will be ignored because protocol doesn't yet support fees yet but DR still needs to provide array of fees to choose supported tokens */ function addFeesToDisputeResolver(uint256 _disputeResolverId, DisputeResolverFee[] calldata _disputeResolverFees) external @@ -458,8 +451,6 @@ contract DisputeResolverHandlerFacet is IBosonAccountEvents, ProtocolBase { lookups.disputeResolverFeeTokenIndex[_disputeResolverId][_disputeResolverFees[i].tokenAddress] == 0, DUPLICATE_DISPUTE_RESOLVER_FEES ); - // Protocol doesn't yet support DR fees - require(_disputeResolverFees[i].feeAmount == 0, FEE_AMOUNT_NOT_YET_SUPPORTED); disputeResolverFees.push(_disputeResolverFees[i]); lookups.disputeResolverFeeTokenIndex[_disputeResolverId][ diff --git a/scripts/config/revert-reasons.js b/scripts/config/revert-reasons.js index ef7a07770..88c65e51a 100644 --- a/scripts/config/revert-reasons.js +++ b/scripts/config/revert-reasons.js @@ -80,7 +80,6 @@ exports.RevertReasons = { "Dispute resolver fees are not present or exceed maximum dispute resolver fees in a single transaction", DUPLICATE_DISPUTE_RESOLVER_FEES: "Duplicate dispute resolver fee", DISPUTE_RESOLVER_FEE_NOT_FOUND: "Dispute resolver fee not found", - FEE_AMOUNT_NOT_YET_SUPPORTED: "Non-zero dispute resolver fees not yet supported", INVALID_AUTH_TOKEN_TYPE: "Invalid AuthTokenType", ADMIN_OR_AUTH_TOKEN: "An admin address or an auth token is required", AUTH_TOKEN_MUST_BE_UNIQUE: "Auth token cannot be assigned to another entity of the same type", diff --git a/test/protocol/DisputeResolverHandlerTest.js b/test/protocol/DisputeResolverHandlerTest.js index d48f4f5ce..43c4e43e4 100644 --- a/test/protocol/DisputeResolverHandlerTest.js +++ b/test/protocol/DisputeResolverHandlerTest.js @@ -693,15 +693,6 @@ describe("DisputeResolverHandler", function () { accountHandler.connect(rando).createDisputeResolver(disputeResolver, disputeResolverFees, sellerAllowList) ).to.revertedWith(RevertReasons.MUST_BE_ACTIVE); }); - - it("Fee amount is not 0", async function () { - disputeResolverFees[0].feeAmount = "200"; - - // Attempt to Create a DR, expecting revert - await expect( - accountHandler.connect(admin).createDisputeResolver(disputeResolver, disputeResolverFees, sellerAllowList) - ).to.revertedWith(RevertReasons.FEE_AMOUNT_NOT_YET_SUPPORTED); - }); }); }); @@ -1641,15 +1632,6 @@ describe("DisputeResolverHandler", function () { accountHandler.connect(admin).addFeesToDisputeResolver(disputeResolver.id, disputeResolverFees) ).to.revertedWith(RevertReasons.DUPLICATE_DISPUTE_RESOLVER_FEES); }); - - it("Fee amount is not 0", async function () { - disputeResolverFees = [new DisputeResolverFee(other4.address, "MockToken4", "200")]; - - // Attempt to Create a DR, expecting revert - await expect( - accountHandler.connect(admin).addFeesToDisputeResolver(disputeResolver.id, disputeResolverFees) - ).to.revertedWith(RevertReasons.FEE_AMOUNT_NOT_YET_SUPPORTED); - }); }); }); From 70c5a04ac159cfcd7e750c5f7c4222ce2166f98e Mon Sep 17 00:00:00 2001 From: zajck Date: Mon, 17 Apr 2023 13:19:08 +0200 Subject: [PATCH 02/33] add feeMutualizer to Offer struct --- contracts/domain/BosonTypes.sol | 1 + .../handlers/IBosonOfferHandler.sol | 2 +- .../handlers/IBosonOrchestrationHandler.sol | 2 +- contracts/protocol/bases/OfferBase.sol | 1 + scripts/domain/Offer.js | 28 ++++++++++++-- test/domain/OfferTest.js | 38 +++++++++++++++++-- test/util/mock.js | 4 +- 7 files changed, 65 insertions(+), 11 deletions(-) diff --git a/contracts/domain/BosonTypes.sol b/contracts/domain/BosonTypes.sol index b5a2a9498..9f15cc90e 100644 --- a/contracts/domain/BosonTypes.sol +++ b/contracts/domain/BosonTypes.sol @@ -145,6 +145,7 @@ contract BosonTypes { string metadataUri; string metadataHash; bool voided; + address feeMutualizer; } struct OfferDates { diff --git a/contracts/interfaces/handlers/IBosonOfferHandler.sol b/contracts/interfaces/handlers/IBosonOfferHandler.sol index 6b240f14e..418340d5b 100644 --- a/contracts/interfaces/handlers/IBosonOfferHandler.sol +++ b/contracts/interfaces/handlers/IBosonOfferHandler.sol @@ -9,7 +9,7 @@ import { IBosonOfferEvents } from "../events/IBosonOfferEvents.sol"; * * @notice Handles creation, voiding, and querying of offers within the protocol. * - * The ERC-165 identifier for this interface is: 0xa1598d02 + * The ERC-165 identifier for this interface is: 0xea09657d */ interface IBosonOfferHandler is IBosonOfferEvents { /** diff --git a/contracts/interfaces/handlers/IBosonOrchestrationHandler.sol b/contracts/interfaces/handlers/IBosonOrchestrationHandler.sol index c5e6ab6ad..055f9b8e9 100644 --- a/contracts/interfaces/handlers/IBosonOrchestrationHandler.sol +++ b/contracts/interfaces/handlers/IBosonOrchestrationHandler.sol @@ -13,7 +13,7 @@ import { IBosonBundleEvents } from "../events/IBosonBundleEvents.sol"; * * @notice Combines creation of multiple entities (accounts, offers, groups, twins, bundles) in a single transaction * - * The ERC-165 identifier for this interface is: 0xa38bc2e7 + * The ERC-165 identifier for this interface is: 0xa8cb0ef7 */ interface IBosonOrchestrationHandler is IBosonAccountEvents, diff --git a/contracts/protocol/bases/OfferBase.sol b/contracts/protocol/bases/OfferBase.sol index 3330a64e0..4cf950f8d 100644 --- a/contracts/protocol/bases/OfferBase.sol +++ b/contracts/protocol/bases/OfferBase.sol @@ -243,6 +243,7 @@ contract OfferBase is ProtocolBase, IBosonOfferEvents { offer.exchangeToken = _offer.exchangeToken; offer.metadataUri = _offer.metadataUri; offer.metadataHash = _offer.metadataHash; + offer.feeMutualizer = _offer.feeMutualizer; // Get storage location for offer dates OfferDates storage offerDates = fetchOfferDates(_offer.id); diff --git a/scripts/domain/Offer.js b/scripts/domain/Offer.js index fdaddbc5a..cb1f44b72 100644 --- a/scripts/domain/Offer.js +++ b/scripts/domain/Offer.js @@ -18,6 +18,7 @@ class Offer { string metadataUri; string metadataHash; bool voided; + address feeMutualizer; } */ @@ -31,7 +32,8 @@ class Offer { exchangeToken, metadataUri, metadataHash, - voided + voided, + feeMutualizer ) { this.id = id; this.sellerId = sellerId; @@ -43,6 +45,7 @@ class Offer { this.metadataUri = metadataUri; this.metadataHash = metadataHash; this.voided = voided; + this.feeMutualizer = feeMutualizer; } /** @@ -62,6 +65,7 @@ class Offer { metadataUri, metadataHash, voided, + feeMutualizer, } = o; return new Offer( @@ -74,7 +78,8 @@ class Offer { exchangeToken, metadataUri, metadataHash, - voided + voided, + feeMutualizer ); } @@ -93,7 +98,8 @@ class Offer { exchangeToken, metadataUri, metadataHash, - voided; + voided, + feeMutualizer; // destructure struct [ @@ -107,6 +113,7 @@ class Offer { metadataUri, metadataHash, voided, + feeMutualizer, ] = struct; return Offer.fromObject({ @@ -120,6 +127,7 @@ class Offer { metadataUri, metadataHash, voided, + feeMutualizer, }); } @@ -155,6 +163,7 @@ class Offer { this.metadataUri, this.metadataHash, this.voided, + this.feeMutualizer, ]; } @@ -259,6 +268,16 @@ class Offer { return booleanIsValid(this.voided); } + /** + * Is this Offer instance's feeMutualizer field valid? + * Must be a eip55 compliant Ethereum address + * + * @returns {boolean} + */ + feeMutualizerIsValid() { + return addressIsValid(this.feeMutualizer); + } + /** * Is this Offer instance valid? * @returns {boolean} @@ -274,7 +293,8 @@ class Offer { this.exchangeTokenIsValid() && this.metadataUriIsValid() && this.metadataHashIsValid() && - this.voidedIsValid() + this.voidedIsValid() && + this.feeMutualizerIsValid() ); } } diff --git a/test/domain/OfferTest.js b/test/domain/OfferTest.js index d5e4c3064..782ce6ed7 100644 --- a/test/domain/OfferTest.js +++ b/test/domain/OfferTest.js @@ -19,7 +19,8 @@ describe("Offer", function () { exchangeToken, metadataUri, metadataHash, - voided; + voided, + feeMutualizer; beforeEach(async function () { // Get a list of accounts @@ -35,6 +36,7 @@ describe("Offer", function () { metadataHash = "QmYXc12ov6F2MZVZwPs5XeCBbf61cW3wKRk8h3D5NTYj4T"; // not an actual metadataHash, just some data for tests metadataUri = `https://ipfs.io/ipfs/${metadataHash}`; voided = false; + feeMutualizer = ethers.constants.AddressZero.toString(); // self mutualization }); context("📋 Constructor", async function () { @@ -50,7 +52,8 @@ describe("Offer", function () { exchangeToken, metadataUri, metadataHash, - voided + voided, + feeMutualizer ); expect(offer.idIsValid()).is.true; expect(offer.sellerIdIsValid()).is.true; @@ -62,6 +65,7 @@ describe("Offer", function () { expect(offer.metadataUriIsValid()).is.true; expect(offer.metadataHashIsValid()).is.true; expect(offer.voidedIsValid()).is.true; + expect(offer.feeMutualizerIsValid()).is.true; expect(offer.isValid()).is.true; }); }); @@ -79,7 +83,8 @@ describe("Offer", function () { exchangeToken, metadataUri, metadataHash, - voided + voided, + feeMutualizer ); expect(offer.isValid()).is.true; }); @@ -323,6 +328,28 @@ describe("Offer", function () { expect(offer.voidedIsValid()).is.true; expect(offer.isValid()).is.true; }); + + it("Always present, feeMutualizer must be a string representation of an EIP-55 compliant address", async function () { + // Invalid field value + offer.feeMutualizer = "0xASFADF"; + expect(offer.feeMutualizerIsValid()).is.false; + expect(offer.isValid()).is.false; + + // Invalid field value + offer.feeMutualizer = "zedzdeadbaby"; + expect(offer.feeMutualizerIsValid()).is.false; + expect(offer.isValid()).is.false; + + // Valid field value + offer.feeMutualizer = accounts[0].address; + expect(offer.feeMutualizerIsValid()).is.true; + expect(offer.isValid()).is.true; + + // Valid field value + offer.feeMutualizer = "0xec2fd5bd6fc7b576dae82c0b9640969d8de501a2"; + expect(offer.feeMutualizerIsValid()).is.true; + expect(offer.isValid()).is.true; + }); }); context("📋 Utility functions", async function () { @@ -341,7 +368,8 @@ describe("Offer", function () { exchangeToken, metadataUri, metadataHash, - voided + voided, + feeMutualizer ); expect(offer.isValid()).is.true; @@ -357,6 +385,7 @@ describe("Offer", function () { metadataUri, metadataHash, voided, + feeMutualizer, }; }); @@ -386,6 +415,7 @@ describe("Offer", function () { offer.metadataUri, offer.metadataHash, offer.voided, + offer.feeMutualizer, ]; // Get struct diff --git a/test/util/mock.js b/test/util/mock.js index fe9da9a4d..3de7e320c 100644 --- a/test/util/mock.js +++ b/test/util/mock.js @@ -78,6 +78,7 @@ async function mockOffer() { const metadataHash = "QmYXc12ov6F2MZVZwPs5XeCBbf61cW3wKRk8h3D5NTYj4T"; // not an actual metadataHash, just some data for tests const metadataUri = `https://ipfs.io/ipfs/${metadataHash}`; const voided = false; + const feeMutualizer = ethers.constants.AddressZero.toString(); // self mutualization // Create a valid offer, then set fields in tests directly let offer = new Offer( @@ -90,7 +91,8 @@ async function mockOffer() { exchangeToken, metadataUri, metadataHash, - voided + voided, + feeMutualizer ); const offerDates = await mockOfferDates(); From a32bca34082d7a5b11c4d5b09eafb61dfd9d0c74 Mon Sep 17 00:00:00 2001 From: zajck Date: Tue, 18 Apr 2023 08:52:49 +0200 Subject: [PATCH 03/33] encumber DR fee --- contracts/domain/BosonConstants.sol | 2 + .../interfaces/clients/IDRFeeMutualizer.sol | 74 +++++++++++++++++++ .../protocol/facets/ExchangeHandlerFacet.sol | 5 +- contracts/protocol/libs/FundsLib.sol | 69 ++++++++++++++++- contracts/protocol/libs/ProtocolLib.sol | 2 + 5 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 contracts/interfaces/clients/IDRFeeMutualizer.sol diff --git a/contracts/domain/BosonConstants.sol b/contracts/domain/BosonConstants.sol index c06c30660..6d447f9d1 100644 --- a/contracts/domain/BosonConstants.sol +++ b/contracts/domain/BosonConstants.sol @@ -147,6 +147,8 @@ string constant TOKEN_TRANSFER_FAILED = "Token transfer failed"; string constant INSUFFICIENT_VALUE_RECEIVED = "Insufficient value received"; string constant INSUFFICIENT_AVAILABLE_FUNDS = "Insufficient available funds"; string constant NATIVE_NOT_ALLOWED = "Transfer of native currency not allowed"; +string constant DR_FEE_NOT_RECEIVED = "DR fee not received"; +string constant SELLER_NOT_COVERED = "Seller not covered"; // Revert Reasons: Meta-Transactions related string constant NONCE_USED_ALREADY = "Nonce used already"; diff --git a/contracts/interfaces/clients/IDRFeeMutualizer.sol b/contracts/interfaces/clients/IDRFeeMutualizer.sol new file mode 100644 index 000000000..613e0d456 --- /dev/null +++ b/contracts/interfaces/clients/IDRFeeMutualizer.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.9; + +/** + * @title IDRFeeMutualizer + * + * @notice This is the interface for the Dispute Resolver fee mutualizers. + * + * The ERC-165 identifier for this interface is: 0x0db62fa8 + */ +interface IDRFeeMutualizer { + event DRFeeRequsted( + address indexed sellerAddress, + address _token, + uint256 feeAmount, + address feeRequester, + bytes context + ); + + /** + * @notice Tells if mutualizer will covert fee amount for a given seller and requrested by a given address. + * + * + * @param _sellerAddress - the seller address + * @param _token - the token address (use 0x0 for ETH) + * @param _feeAmount - amount to cover + * @param _feeRequester - address of the requester + * @param _context - additional data, describing the context + */ + function isSellerCovered( + address _sellerAddress, + address _token, + uint256 _feeAmount, + address _feeRequester, + bytes calldata _context + ) external view returns (bool); + + /** + * @notice Request the mutualizer to cover the fee amount. + * + * @dev Verify that seller is covered and send the fee amount to the msg.sender. + * Returned uuid can be used to track the status of the request. + * + * @param _sellerAddress - the seller address + * @param _token - the token address (use 0x0 for ETH) + * @param _feeAmount - amount to cover + * @param _context - additional data, describing the context + * @return isCovered - true if the seller is covered + * @return uuid - unique identifier of the request + */ + function requestDRFee( + address _sellerAddress, + address _token, + uint256 _feeAmount, + bytes calldata _context + ) external returns (bool isCovered, uint256 uuid); + + /** + * @notice Return fee to the mutualizer. + * + * @dev Returned amount can be between 0 and _feeAmount that was requested for the given uuid. + * + * @param _uuid - unique identifier of the request + * @param _token - the token address (use 0x0 for ETH) + * @param _feeAmount - returned amount + * @param _context - additional data, describing the context + */ + function returnDRFee( + uint256 _uuid, + address _token, + uint256 _feeAmount, + bytes calldata _context + ) external payable; +} diff --git a/contracts/protocol/facets/ExchangeHandlerFacet.sol b/contracts/protocol/facets/ExchangeHandlerFacet.sol index a23f0c90d..1775af360 100644 --- a/contracts/protocol/facets/ExchangeHandlerFacet.sol +++ b/contracts/protocol/facets/ExchangeHandlerFacet.sol @@ -55,6 +55,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { * - Calling transferFrom on token fails for some reason (e.g. protocol is not approved to transfer) * - Received ERC20 token amount differs from the expected value * - Seller has less funds available than sellerDeposit + * - Mutualizer does not provide enough funds to cover the DR fee * * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) * @param _offerId - the id of the offer to commit to @@ -97,6 +98,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { * - Buyer account is inactive * - Buyer is token-gated (conditional commit requirements not met or already used) * - Seller has less funds available than sellerDeposit and price + * - Mutualizer does not provide enough funds to cover the DR fee * * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) * @param _offerId - the id of the offer to commit to @@ -141,6 +143,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { * - Received ERC20 token amount differs from the expected value * - Seller has less funds available than sellerDeposit * - Seller has less funds available than sellerDeposit and price for preminted offers + * - Mutualizer does not provide enough funds to cover the DR fee * * @param _buyer - the buyer's address (caller can commit on behalf of a buyer) * @param _offer - storage pointer to the offer @@ -175,7 +178,7 @@ contract ExchangeHandlerFacet is IBosonExchangeHandler, BuyerBase, DisputeBase { uint256 buyerId = getValidBuyer(_buyer); // Encumber funds before creating the exchange - FundsLib.encumberFunds(_offerId, buyerId, _isPreminted); + FundsLib.encumberFunds(_offerId, _exchangeId, buyerId, _isPreminted); // Create and store a new exchange Exchange storage exchange = protocolEntities().exchanges[_exchangeId]; diff --git a/contracts/protocol/libs/FundsLib.sol b/contracts/protocol/libs/FundsLib.sol index cf594b9f2..b5f44fcee 100644 --- a/contracts/protocol/libs/FundsLib.sol +++ b/contracts/protocol/libs/FundsLib.sol @@ -7,6 +7,7 @@ import { EIP712Lib } from "../libs/EIP712Lib.sol"; import { ProtocolLib } from "../libs/ProtocolLib.sol"; import { IERC20 } from "../../interfaces/IERC20.sol"; import { SafeERC20 } from "../../ext_libs/SafeERC20.sol"; +import { IDRFeeMutualizer } from "../../interfaces/clients/IDRFeeMutualizer.sol"; /** * @title FundsLib @@ -59,11 +60,13 @@ library FundsLib { * - Received ERC20 token amount differs from the expected value * * @param _offerId - id of the offer with the details + * @param _exchangeId - id of the exchange * @param _buyerId - id of the buyer * @param _isPreminted - flag indicating if the offer is preminted */ function encumberFunds( uint256 _offerId, + uint256 _exchangeId, uint256 _buyerId, bool _isPreminted ) internal { @@ -78,16 +81,29 @@ library FundsLib { BosonTypes.Offer storage offer = pe.offers[_offerId]; address exchangeToken = offer.exchangeToken; uint256 price = offer.price; + uint256 sellerFundsEncumbered = offer.sellerDeposit; // minimal that is encumbered for seller - // if offer is non-preminted, validate incoming payment - if (!_isPreminted) { + if (_isPreminted) { + // for preminted offer, encumber also price from seller's available funds + sellerFundsEncumbered += price; + } else { + // if offer is non-preminted, validate incoming payment validateIncomingPayment(exchangeToken, price); emit FundsEncumbered(_buyerId, exchangeToken, price, sender); } - // decrease available funds + // encumber DR fee uint256 sellerId = offer.sellerId; - uint256 sellerFundsEncumbered = offer.sellerDeposit + (_isPreminted ? price : 0); // for preminted offer, encumber also price from seller's available funds + uint256 drFee = pe.disputeResolutionTerms[_offerId].feeAmount; + address mutualizer = offer.feeMutualizer; + if (mutualizer == address(0)) { + // if mutualizer is not set, encumber DR fee from seller's funds + sellerFundsEncumbered += drFee; + } else { + requestDRFee(_exchangeId, mutualizer, pe.sellers[sellerId].assistant, exchangeToken, drFee); + } + + // decrease seller's available funds decreaseAvailableFunds(sellerId, exchangeToken, sellerFundsEncumbered); // notify external observers @@ -294,6 +310,51 @@ library FundsLib { emit FundsWithdrawn(_entityId, _to, _tokenAddress, _amount, EIP712Lib.msgSender()); } + /** + * @notice Requests the DR fee from the mutualizer, validates it was really sent and store UUID + * + * Reverts if: + * - Mutualizer does not cover the seller + * - Mutualizer does not send the fee to the protocol + * - Call to mutualizer fails + * + * @param _exchangeId - the exchange id + * @param _mutualizer - address of the mutualizer + * @param _sellerAddress - the seller address + * @param _exchangeToken - the token address (use 0x0 for ETH) + * @param _drFee - the DR fee + */ + function requestDRFee( + uint256 _exchangeId, + address _mutualizer, + address _sellerAddress, + address _exchangeToken, + uint256 _drFee + ) internal { + // protocol balance before the request // maybe reuse `getBalance` function from https://github.com/bosonprotocol/boson-protocol-contracts/pull/578 + uint256 protocolTokenBalanceBefore = _exchangeToken == address(0) + ? address(this).balance + : IERC20(_exchangeToken).balanceOf(address(this)); + + // reqest DR fee from mutualizer + (bool isCovered, uint256 mutualizerUUID) = IDRFeeMutualizer(_mutualizer).requestDRFee( + _sellerAddress, + _exchangeToken, + _drFee, + "" + ); + require(isCovered, SELLER_NOT_COVERED); + ProtocolLib.protocolLookups().mutualizerUUIDByExchange[_exchangeId] = mutualizerUUID; + + // protocol balance after the request + uint256 protocolTokenBalanceAfter = _exchangeToken == address(0) + ? address(this).balance + : IERC20(_exchangeToken).balanceOf(address(this)); + + // check if mutualizer sent the fee to the protocol + require(protocolTokenBalanceAfter - protocolTokenBalanceBefore == _drFee, DR_FEE_NOT_RECEIVED); + } + /** * @notice Increases the amount, available to withdraw or use as a seller deposit. * diff --git a/contracts/protocol/libs/ProtocolLib.sol b/contracts/protocol/libs/ProtocolLib.sol index dd3f18d02..2c036b0d7 100644 --- a/contracts/protocol/libs/ProtocolLib.sol +++ b/contracts/protocol/libs/ProtocolLib.sol @@ -182,6 +182,8 @@ library ProtocolLib { mapping(uint256 => BosonTypes.AuthToken) pendingAuthTokenUpdatesBySeller; // dispute resolver id => DisputeResolver mapping(uint256 => BosonTypes.DisputeResolver) pendingAddressUpdatesByDisputeResolver; + // exchange id => mutualizer UUID + mapping(uint256 => uint256) mutualizerUUIDByExchange; } // Incrementing id counters From daa7f6104adaaae5c372efa36ce1f3837d708817 Mon Sep 17 00:00:00 2001 From: zajck Date: Tue, 18 Apr 2023 16:43:42 +0200 Subject: [PATCH 04/33] Release DR fee --- .../interfaces/clients/IDRFeeMutualizer.sol | 2 +- contracts/protocol/libs/FundsLib.sol | 144 ++++++++++++------ 2 files changed, 102 insertions(+), 44 deletions(-) diff --git a/contracts/interfaces/clients/IDRFeeMutualizer.sol b/contracts/interfaces/clients/IDRFeeMutualizer.sol index 613e0d456..ea6b44c42 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizer.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizer.sol @@ -6,7 +6,7 @@ pragma solidity 0.8.9; * * @notice This is the interface for the Dispute Resolver fee mutualizers. * - * The ERC-165 identifier for this interface is: 0x0db62fa8 + * The ERC-165 identifier for this interface is: 0x47f05774 */ interface IDRFeeMutualizer { event DRFeeRequsted( diff --git a/contracts/protocol/libs/FundsLib.sol b/contracts/protocol/libs/FundsLib.sol index b5f44fcee..4fab7e448 100644 --- a/contracts/protocol/libs/FundsLib.sol +++ b/contracts/protocol/libs/FundsLib.sol @@ -96,11 +96,13 @@ library FundsLib { uint256 sellerId = offer.sellerId; uint256 drFee = pe.disputeResolutionTerms[_offerId].feeAmount; address mutualizer = offer.feeMutualizer; - if (mutualizer == address(0)) { - // if mutualizer is not set, encumber DR fee from seller's funds - sellerFundsEncumbered += drFee; - } else { - requestDRFee(_exchangeId, mutualizer, pe.sellers[sellerId].assistant, exchangeToken, drFee); + if (drFee > 0) { + if (mutualizer == address(0)) { + // if mutualizer is not set, encumber DR fee from seller's funds + sellerFundsEncumbered += drFee; + } else { + requestDRFee(_exchangeId, mutualizer, pe.sellers[sellerId].assistant, exchangeToken, drFee); + } } // decrease seller's available funds @@ -139,6 +141,15 @@ library FundsLib { } } + struct PayOff { + uint256 seller; + uint256 buyer; + uint256 protocol; + uint256 agent; + uint256 disputeResolver; + uint256 feeMutualizer; + } + /** * @notice Takes in the exchange id and releases the funds to buyer and seller, depending on the state of the exchange. * It is called only from finalizeExchange and finalizeDispute. @@ -155,17 +166,17 @@ library FundsLib { // Since this should be called only from certain functions from exchangeHandler and disputeHandler // exchange must exist and be in a completed state, so that's not checked explicitly BosonTypes.Exchange storage exchange = pe.exchanges[_exchangeId]; + uint256 offerId = exchange.offerId; // Get offer from storage to get the details about sellerDeposit, price, sellerId, exchangeToken and buyerCancelPenalty - BosonTypes.Offer storage offer = pe.offers[exchange.offerId]; - // calculate the payoffs depending on state exchange is in - uint256 sellerPayoff; - uint256 buyerPayoff; - uint256 protocolFee; - uint256 agentFee; + BosonTypes.Offer storage offer = pe.offers[offerId]; - BosonTypes.OfferFees storage offerFee = pe.offerFees[exchange.offerId]; + // Get the dispute resolution terms for the offer + BosonTypes.DisputeResolutionTerms storage disputeResolutionTerms = pe.disputeResolutionTerms[offerId]; + // calculate the payoffs depending on state exchange is in + PayOff memory payOff; + uint256 disputeResolverFee = disputeResolutionTerms.feeAmount; { // scope to avoid stack too deep errors BosonTypes.ExchangeState exchangeState = exchange.state; @@ -174,26 +185,29 @@ library FundsLib { if (exchangeState == BosonTypes.ExchangeState.Completed) { // COMPLETED - protocolFee = offerFee.protocolFee; + BosonTypes.OfferFees storage offerFee = pe.offerFees[offerId]; + payOff.protocol = offerFee.protocolFee; // buyerPayoff is 0 - agentFee = offerFee.agentFee; - sellerPayoff = price + sellerDeposit - protocolFee - agentFee; + payOff.agent = offerFee.agentFee; + payOff.seller = price + sellerDeposit - payOff.protocol - payOff.agent; } else if (exchangeState == BosonTypes.ExchangeState.Revoked) { // REVOKED // sellerPayoff is 0 - buyerPayoff = price + sellerDeposit; + payOff.buyer = price + sellerDeposit; } else if (exchangeState == BosonTypes.ExchangeState.Canceled) { // CANCELED uint256 buyerCancelPenalty = offer.buyerCancelPenalty; - sellerPayoff = sellerDeposit + buyerCancelPenalty; - buyerPayoff = price - buyerCancelPenalty; + payOff.seller = sellerDeposit + buyerCancelPenalty; + payOff.buyer = price - buyerCancelPenalty; } else if (exchangeState == BosonTypes.ExchangeState.Disputed) { // DISPUTED // determine if buyerEscalationDeposit was encumbered or not // if dispute was escalated, disputeDates.escalated is populated - uint256 buyerEscalationDeposit = pe.disputeDates[_exchangeId].escalated > 0 - ? pe.disputeResolutionTerms[exchange.offerId].buyerEscalationDeposit - : 0; + uint256 buyerEscalationDeposit; + if (pe.disputeDates[_exchangeId].escalated > 0) { + buyerEscalationDeposit = disputeResolutionTerms.buyerEscalationDeposit; + payOff.disputeResolver = disputeResolverFee; // If REFUSED, this is later set to 0 + } // get the information about the dispute, which must exist BosonTypes.Dispute storage dispute = pe.disputes[_exchangeId]; @@ -201,46 +215,90 @@ library FundsLib { if (disputeState == BosonTypes.DisputeState.Retracted) { // RETRACTED - same as "COMPLETED" - protocolFee = offerFee.protocolFee; - agentFee = offerFee.agentFee; + BosonTypes.OfferFees storage offerFee = pe.offerFees[offerId]; + payOff.protocol = offerFee.protocolFee; + payOff.agent = offerFee.agentFee; // buyerPayoff is 0 - sellerPayoff = price + sellerDeposit - protocolFee - agentFee + buyerEscalationDeposit; + payOff.seller = price + sellerDeposit - payOff.protocol - payOff.agent + buyerEscalationDeposit; } else if (disputeState == BosonTypes.DisputeState.Refused) { // REFUSED - sellerPayoff = sellerDeposit; - buyerPayoff = price + buyerEscalationDeposit; + payOff.seller = sellerDeposit; + payOff.buyer = price + buyerEscalationDeposit; + payOff.disputeResolver = 0; } else { // RESOLVED or DECIDED uint256 pot = price + sellerDeposit + buyerEscalationDeposit; - buyerPayoff = (pot * dispute.buyerPercent) / 10000; - sellerPayoff = pot - buyerPayoff; + payOff.buyer = (pot * dispute.buyerPercent) / 10000; + payOff.seller = pot - payOff.buyer; } } + // Mutualizer payoff is always the difference between the DR fee and what is paid to the dispute resolver + payOff.feeMutualizer = disputeResolverFee - payOff.disputeResolver; } // Store payoffs to availablefunds and notify the external observers address exchangeToken = offer.exchangeToken; - uint256 sellerId = offer.sellerId; - uint256 buyerId = exchange.buyerId; address sender = EIP712Lib.msgSender(); - if (sellerPayoff > 0) { - increaseAvailableFunds(sellerId, exchangeToken, sellerPayoff); - emit FundsReleased(_exchangeId, sellerId, exchangeToken, sellerPayoff, sender); + // ToDo: use `increaseAvailableFundsAndEmitEvent` from https://github.com/bosonprotocol/boson-protocol-contracts/pull/569 + if (payOff.seller > 0) { + uint256 sellerId = offer.sellerId; + increaseAvailableFunds(sellerId, exchangeToken, payOff.seller); + emit FundsReleased(_exchangeId, sellerId, exchangeToken, payOff.seller, sender); } - if (buyerPayoff > 0) { - increaseAvailableFunds(buyerId, exchangeToken, buyerPayoff); - emit FundsReleased(_exchangeId, buyerId, exchangeToken, buyerPayoff, sender); + if (payOff.buyer > 0) { + uint256 buyerId = exchange.buyerId; + increaseAvailableFunds(buyerId, exchangeToken, payOff.buyer); + emit FundsReleased(_exchangeId, buyerId, exchangeToken, payOff.buyer, sender); } - if (protocolFee > 0) { - increaseAvailableFunds(0, exchangeToken, protocolFee); - emit ProtocolFeeCollected(_exchangeId, exchangeToken, protocolFee, sender); + if (payOff.protocol > 0) { + increaseAvailableFunds(0, exchangeToken, payOff.protocol); + emit ProtocolFeeCollected(_exchangeId, exchangeToken, payOff.protocol, sender); } - if (agentFee > 0) { + if (payOff.agent > 0) { // Get the agent for offer - uint256 agentId = ProtocolLib.protocolLookups().agentIdByOffer[exchange.offerId]; - increaseAvailableFunds(agentId, exchangeToken, agentFee); - emit FundsReleased(_exchangeId, agentId, exchangeToken, agentFee, sender); + uint256 agentId = ProtocolLib.protocolLookups().agentIdByOffer[offerId]; + increaseAvailableFunds(agentId, exchangeToken, payOff.agent); + emit FundsReleased(_exchangeId, agentId, exchangeToken, payOff.agent, sender); + } + if (payOff.disputeResolver > 0) { + // Get the dispute resolver for offer + uint256 disputeResolveId = disputeResolutionTerms.disputeResolverId; + increaseAvailableFunds(disputeResolveId, exchangeToken, payOff.disputeResolver); + emit FundsReleased(_exchangeId, disputeResolveId, exchangeToken, payOff.disputeResolver, sender); + } + + // always make call to mutualizer, even if payoff is 0 + returnFeeToMutualizer(offer.feeMutualizer, _exchangeId, exchangeToken, payOff.feeMutualizer); + + IDRFeeMutualizer(offer.feeMutualizer).returnDRFee( + ProtocolLib.protocolLookups().mutualizerUUIDByExchange[_exchangeId], + exchangeToken, + payOff.feeMutualizer, + "" + ); + } + + function returnFeeToMutualizer( + address _feeMutualizer, + uint256 _exchangeId, + address _token, + uint256 _feeAmount + ) internal { + uint256 nativePayoff; + if (_feeAmount > 0 && _token != address(0)) { + // Approve the mutualizer to withdraw the tokens + IERC20(_token).approve(_feeMutualizer, _feeAmount); + } else { + // Even if _feeAmount == 0, this is still true + nativePayoff = _feeAmount; } + + IDRFeeMutualizer(_feeMutualizer).returnDRFee( + ProtocolLib.protocolLookups().mutualizerUUIDByExchange[_exchangeId], + _token, + _feeAmount, + "" + ); } /** From 17d43290dfc2bc3484fdcc6392013a1698edd435 Mon Sep 17 00:00:00 2001 From: zajck Date: Thu, 20 Apr 2023 12:04:09 +0200 Subject: [PATCH 05/33] add events --- .../interfaces/events/IBosonFundsEvents.sol | 16 +++ contracts/protocol/libs/FundsLib.sol | 105 +++++++++++------- 2 files changed, 81 insertions(+), 40 deletions(-) diff --git a/contracts/interfaces/events/IBosonFundsEvents.sol b/contracts/interfaces/events/IBosonFundsEvents.sol index 3687ee30b..fcf9751df 100644 --- a/contracts/interfaces/events/IBosonFundsEvents.sol +++ b/contracts/interfaces/events/IBosonFundsEvents.sol @@ -44,4 +44,20 @@ interface IBosonFundsLibEvents { uint256 amount, address executedBy ); + event DRFeeEncumbered( + address indexed feeMutualizer, + uint256 indexed uuid, + uint256 indexed exchangeId, + address tokenAddress, + uint256 feeAmount, + address executedBy + ); + event DRFeeReturned( + address indexed feeMutualizer, + uint256 indexed uuid, + uint256 indexed exchangeId, + address tokenAddress, + uint256 feeAmount, + address executedBy + ); } diff --git a/contracts/protocol/libs/FundsLib.sol b/contracts/protocol/libs/FundsLib.sol index 4fab7e448..2352eba9a 100644 --- a/contracts/protocol/libs/FundsLib.sol +++ b/contracts/protocol/libs/FundsLib.sol @@ -43,6 +43,32 @@ library FundsLib { uint256 amount, address executedBy ); + event DRFeeEncumbered( + address indexed feeMutualizer, + uint256 indexed uuid, + uint256 indexed exchangeId, + address tokenAddress, + uint256 feeAmount, + address executedBy + ); + event DRFeeReturned( + address indexed feeMutualizer, + uint256 indexed uuid, + uint256 indexed exchangeId, + address tokenAddress, + uint256 feeAmount, + address executedBy + ); + + // This struct is not defined in BosonTypes because it is used only in this library + struct PayOff { + uint256 seller; + uint256 buyer; + uint256 protocol; + uint256 agent; + uint256 disputeResolver; + uint256 feeMutualizer; + } /** * @notice Takes in the offer id and buyer id and encumbers buyer's and seller's funds during the commitToOffer. @@ -141,15 +167,6 @@ library FundsLib { } } - struct PayOff { - uint256 seller; - uint256 buyer; - uint256 protocol; - uint256 agent; - uint256 disputeResolver; - uint256 feeMutualizer; - } - /** * @notice Takes in the exchange id and releases the funds to buyer and seller, depending on the state of the exchange. * It is called only from finalizeExchange and finalizeDispute. @@ -267,38 +284,8 @@ library FundsLib { emit FundsReleased(_exchangeId, disputeResolveId, exchangeToken, payOff.disputeResolver, sender); } - // always make call to mutualizer, even if payoff is 0 + // always make call to mutualizer, even if the payoff is 0 returnFeeToMutualizer(offer.feeMutualizer, _exchangeId, exchangeToken, payOff.feeMutualizer); - - IDRFeeMutualizer(offer.feeMutualizer).returnDRFee( - ProtocolLib.protocolLookups().mutualizerUUIDByExchange[_exchangeId], - exchangeToken, - payOff.feeMutualizer, - "" - ); - } - - function returnFeeToMutualizer( - address _feeMutualizer, - uint256 _exchangeId, - address _token, - uint256 _feeAmount - ) internal { - uint256 nativePayoff; - if (_feeAmount > 0 && _token != address(0)) { - // Approve the mutualizer to withdraw the tokens - IERC20(_token).approve(_feeMutualizer, _feeAmount); - } else { - // Even if _feeAmount == 0, this is still true - nativePayoff = _feeAmount; - } - - IDRFeeMutualizer(_feeMutualizer).returnDRFee( - ProtocolLib.protocolLookups().mutualizerUUIDByExchange[_exchangeId], - _token, - _feeAmount, - "" - ); } /** @@ -371,6 +358,8 @@ library FundsLib { /** * @notice Requests the DR fee from the mutualizer, validates it was really sent and store UUID * + * Emits DRFeeEncumbered event if successful. + * * Reverts if: * - Mutualizer does not cover the seller * - Mutualizer does not send the fee to the protocol @@ -411,6 +400,42 @@ library FundsLib { // check if mutualizer sent the fee to the protocol require(protocolTokenBalanceAfter - protocolTokenBalanceBefore == _drFee, DR_FEE_NOT_RECEIVED); + + emit DRFeeEncumbered(_mutualizer, mutualizerUUID, _exchangeId, _exchangeToken, _drFee, EIP712Lib.msgSender()); + } + + /** + * @notice Makes a call to the mutualizer to return the fee to the mutualizer. + * + * Emits DRFeeReturned event. + * + * Even if the call to the mutualizer fails, the protocol will still continue, otherwise the exchange would be stuck. + * + * @param _exchangeId - exchange id + */ + function returnFeeToMutualizer( + address _feeMutualizer, + uint256 _exchangeId, + address _exchangeToken, + uint256 _feeAmount + ) internal { + uint256 nativePayoff; + if (_feeAmount > 0 && _exchangeToken != address(0)) { + // Approve the mutualizer to withdraw the tokens + IERC20(_exchangeToken).approve(_feeMutualizer, _feeAmount); + } else { + // Even if _feeAmount == 0, this is still true + nativePayoff = _feeAmount; + } + + // Call the mutualizer to return the fee + // Even if the call fails, the protocol will still be able to continue + uint256 uuid = ProtocolLib.protocolLookups().mutualizerUUIDByExchange[_exchangeId]; + try + IDRFeeMutualizer(_feeMutualizer).returnDRFee{ value: nativePayoff }(uuid, _exchangeToken, _feeAmount, "") + {} catch {} + + emit DRFeeReturned(_feeMutualizer, uuid, _exchangeId, _exchangeToken, _feeAmount, EIP712Lib.msgSender()); } /** From 7590fe7f0a5c67fbe2713d216d9b9e33a9b1e1c8 Mon Sep 17 00:00:00 2001 From: zajck Date: Fri, 21 Apr 2023 13:09:21 +0200 Subject: [PATCH 06/33] MVP mutualizer --- contracts/domain/BosonTypes.sol | 1 + .../interfaces/clients/IDRFeeMutualizer.sol | 4 +- contracts/protocol/bases/OfferBase.sol | 1 + .../clients/feeMutualizer/DRFeeMutualizer.sol | 212 ++++++++++++++++++ contracts/protocol/libs/FundsLib.sol | 19 +- 5 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol diff --git a/contracts/domain/BosonTypes.sol b/contracts/domain/BosonTypes.sol index 9f15cc90e..6f7880af1 100644 --- a/contracts/domain/BosonTypes.sol +++ b/contracts/domain/BosonTypes.sol @@ -132,6 +132,7 @@ contract BosonTypes { uint256 escalationResponsePeriod; uint256 feeAmount; uint256 buyerEscalationDeposit; + address feeMutualizer; } struct Offer { diff --git a/contracts/interfaces/clients/IDRFeeMutualizer.sol b/contracts/interfaces/clients/IDRFeeMutualizer.sol index ea6b44c42..a6300ee4b 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizer.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizer.sol @@ -6,7 +6,7 @@ pragma solidity 0.8.9; * * @notice This is the interface for the Dispute Resolver fee mutualizers. * - * The ERC-165 identifier for this interface is: 0x47f05774 + * The ERC-165 identifier for this interface is: 0x41283543 */ interface IDRFeeMutualizer { event DRFeeRequsted( @@ -61,13 +61,11 @@ interface IDRFeeMutualizer { * @dev Returned amount can be between 0 and _feeAmount that was requested for the given uuid. * * @param _uuid - unique identifier of the request - * @param _token - the token address (use 0x0 for ETH) * @param _feeAmount - returned amount * @param _context - additional data, describing the context */ function returnDRFee( uint256 _uuid, - address _token, uint256 _feeAmount, bytes calldata _context ) external payable; diff --git a/contracts/protocol/bases/OfferBase.sol b/contracts/protocol/bases/OfferBase.sol index 4cf950f8d..ec8721ce6 100644 --- a/contracts/protocol/bases/OfferBase.sol +++ b/contracts/protocol/bases/OfferBase.sol @@ -187,6 +187,7 @@ contract OfferBase is ProtocolBase, IBosonOfferEvents { disputeResolutionTerms.buyerEscalationDeposit = (feeAmount * protocolFees().buyerEscalationDepositPercentage) / 10000; + disputeResolutionTerms.feeMutualizer = _offer.feeMutualizer; protocolEntities().disputeResolutionTerms[_offer.id] = disputeResolutionTerms; } diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol new file mode 100644 index 000000000..2cffc3da3 --- /dev/null +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.9; +import { IDRFeeMutualizer } from "../../../interfaces/clients/IDRFeeMutualizer.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +// import { ClientBase } from "../../bases/ClientBase.sol"; + +/** + * @title DRFeeMutualizer + * @notice This is a reference implementation of Dispute resolver fee mutualizer. + * + */ +contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { + using SafeERC20 for IERC20; + + address private immutable protocolAddress; + + struct Agreement { + address sellerAddress; + address token; + uint256 maxMutualizedAmountPerTransaction; + uint256 maxTotalMutualizedAmount; + uint256 premium; + uint128 startTimestamp; + uint128 endTimestamp; + bool refundOnCancel; + bool voided; + } + + Agreement[] private agreements; + mapping(address => mapping(address => uint256)) private agreementBySellerAndToken; + mapping(uint256 => uint256) private outstandingExchaganes; + mapping(uint256 => uint256) private totalMutualizedAmount; + mapping(uint256 => uint256) private agreementByUuid; + uint256 private agreementCounter; + + event AgreementCreated(address indexed sellerAddress, uint256 indexed agreementId, Agreement agreement); + event AgreementConfirmed(address indexed sellerAddress, uint256 indexed agreementId); + event DRFeeReturned(uint256 indexed uuid, uint256 feeAmount, bytes context); + + constructor(address _protocolAddress) { + protocolAddress = _protocolAddress; + Agreement memory emptyAgreement; + agreements.push(emptyAgreement); // add empty agreement to fill index 0, otherwise we need to manipulate indices otherwise + } + + /** + * @notice Tells if mutualizer will covert fee amount for a given seller and requrested by a given address. + * + * + * @param _sellerAddress - the seller address + * @param _token - the token address (use 0x0 for ETH) + * @param _feeAmount - amount to cover + * @param _feeRequester - address of the requester + * @param _context - additional data, describing the context + */ + function isSellerCovered( + address _sellerAddress, + address _token, + uint256 _feeAmount, + address _feeRequester, + bytes calldata _context + ) external view returns (bool) { + uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; + Agreement storage agreement = agreements[agreementId]; + + // instead of returning, we could also revert with a reason + return (msg.sender == protocolAddress && + agreement.startTimestamp <= block.timestamp && + agreement.endTimestamp >= block.timestamp && + !agreement.voided && + agreement.maxMutualizedAmountPerTransaction >= _feeAmount && + agreement.maxTotalMutualizedAmount + _feeAmount >= totalMutualizedAmount[agreementId]); + } + + /** + * @notice Request the mutualizer to cover the fee amount. + * + * @dev Verify that seller is covered and send the fee amount to the msg.sender. + * Returned uuid can be used to track the status of the request. + * + * @param _sellerAddress - the seller address + * @param _token - the token address (use 0x0 for ETH) + * @param _feeAmount - amount to cover + * @param _context - additional data, describing the context + * @return isCovered - true if the seller is covered + * @return uuid - unique identifier of the request + */ + function requestDRFee( + address _sellerAddress, + address _token, + uint256 _feeAmount, + bytes calldata _context + ) external returns (bool isCovered, uint256 uuid) { + require(msg.sender == protocolAddress, "Only protocol can call this function"); + uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; + Agreement storage agreement = agreements[agreementId]; + + require(agreement.startTimestamp <= block.timestamp, "Agreement not started yet"); + require(agreement.endTimestamp >= block.timestamp, "Agreement expired"); + require(!agreement.voided, "Agreement voided"); + require( + agreement.maxMutualizedAmountPerTransaction >= _feeAmount, + "Fee amount exceeds max mutualized amount per transaction" + ); + + totalMutualizedAmount[agreementId] += _feeAmount; + require( + agreement.maxTotalMutualizedAmount >= totalMutualizedAmount[agreementId], + "Fee amount exceeds max total mutualized amount" + ); + + outstandingExchaganes[agreementId]++; + + agreementByUuid[++agreementCounter] = agreementId; + if (agreement.token == address(0)) { + payable(msg.sender).transfer(_feeAmount); + } else { + IERC20 token = IERC20(agreement.token); + token.safeTransfer(msg.sender, _feeAmount); + } + + return (true, agreementCounter); + } + + /** + * @notice Return fee to the mutualizer. + * + * @dev Returned amount can be between 0 and _feeAmount that was requested for the given uuid. + * + * @param _uuid - unique identifier of the request + * @param _feeAmount - returned amount + * @param _context - additional data, describing the context + */ + function returnDRFee( + uint256 _uuid, + uint256 _feeAmount, + bytes calldata _context + ) external payable { + uint256 agreementId = agreementByUuid[_uuid]; + require(agreementId != 0, "Invalid uuid"); + if (_feeAmount > 0) { + Agreement storage agreement = agreements[agreementId]; + + transferFundsToMutualizer(agreement.token, _feeAmount); + + if (_feeAmount < totalMutualizedAmount[agreementId]) { + // not necessary if we restrict call to the protocol only + totalMutualizedAmount[agreementId] -= _feeAmount; + } else { + totalMutualizedAmount[agreementId] = 0; + } + } + + outstandingExchaganes[agreementId]--; + delete agreementByUuid[_uuid]; // prevent using the same uuid twice + emit DRFeeReturned(_uuid, _feeAmount, _context); + } + + function newAgreement(Agreement calldata _agreement) external onlyOwner { + agreements.push(_agreement); + uint256 agreementId = agreements.length - 1; + + emit AgreementCreated(_agreement.sellerAddress, agreementId, _agreement); + } + + function payPremium(uint256 _agreementId) external payable { + Agreement storage agreement = agreements[_agreementId]; + + require(agreement.sellerAddress == msg.sender, "Invalid seller address"); + require(!agreement.voided, "Agreement voided"); + + transferFundsToMutualizer(agreement.token, agreement.premium); + + // even if agreementBySellerAndToken[_agreement.sellerAddress][_agreement.token] exists, seller can overwrite it + agreementBySellerAndToken[agreement.sellerAddress][agreement.token] = _agreementId; + + emit AgreementConfirmed(agreement.sellerAddress, _agreementId); + } + + function getAgreement(uint256 _agreementId) external view returns (Agreement memory) { + return agreements[_agreementId]; + } + + function voidAgreement(uint256 _agreementId) external { + Agreement storage agreement = agreements[_agreementId]; + + require(msg.sender == owner() || msg.sender == agreement.sellerAddress, "Invalid sender address"); + + agreement.voided = true; + + if (agreement.refundOnCancel) { + // calculate unused premium + // what with the outstanding requests? + } + } + + function transferFundsToMutualizer(address _tokenAddress, uint256 _amount) internal { + if (_tokenAddress == address(0)) { + require(msg.value == _amount, "Invalid incoming amount"); + } else { + require(msg.value == 0, "Invalid incoming amount"); + IERC20 token = IERC20(_tokenAddress); + uint256 balanceBefore = token.balanceOf(address(this)); + token.safeTransferFrom(msg.sender, address(this), _amount); + uint256 balanceAfter = token.balanceOf(address(this)); + require(balanceAfter - balanceBefore == _amount, "Invalid incoming amount"); + } + } +} diff --git a/contracts/protocol/libs/FundsLib.sol b/contracts/protocol/libs/FundsLib.sol index 2352eba9a..b28c8fa70 100644 --- a/contracts/protocol/libs/FundsLib.sol +++ b/contracts/protocol/libs/FundsLib.sol @@ -253,8 +253,18 @@ library FundsLib { payOff.feeMutualizer = disputeResolverFee - payOff.disputeResolver; } - // Store payoffs to availablefunds and notify the external observers + // Handle DR fee address exchangeToken = offer.exchangeToken; + address _feeMutualizer = disputeResolutionTerms.feeMutualizer; + if (_feeMutualizer != address(0)) { + // always make call to mutualizer, even if the payoff is 0 + returnFeeToMutualizer(_feeMutualizer, _exchangeId, exchangeToken, payOff.feeMutualizer); + } else { + // Self mutualization + payOff.seller += payOff.feeMutualizer; + } + + // Store payoffs to availablefunds and notify the external observers address sender = EIP712Lib.msgSender(); // ToDo: use `increaseAvailableFundsAndEmitEvent` from https://github.com/bosonprotocol/boson-protocol-contracts/pull/569 if (payOff.seller > 0) { @@ -283,9 +293,6 @@ library FundsLib { increaseAvailableFunds(disputeResolveId, exchangeToken, payOff.disputeResolver); emit FundsReleased(_exchangeId, disputeResolveId, exchangeToken, payOff.disputeResolver, sender); } - - // always make call to mutualizer, even if the payoff is 0 - returnFeeToMutualizer(offer.feeMutualizer, _exchangeId, exchangeToken, payOff.feeMutualizer); } /** @@ -431,9 +438,7 @@ library FundsLib { // Call the mutualizer to return the fee // Even if the call fails, the protocol will still be able to continue uint256 uuid = ProtocolLib.protocolLookups().mutualizerUUIDByExchange[_exchangeId]; - try - IDRFeeMutualizer(_feeMutualizer).returnDRFee{ value: nativePayoff }(uuid, _exchangeToken, _feeAmount, "") - {} catch {} + try IDRFeeMutualizer(_feeMutualizer).returnDRFee{ value: nativePayoff }(uuid, _feeAmount, "") {} catch {} emit DRFeeReturned(_feeMutualizer, uuid, _exchangeId, _exchangeToken, _feeAmount, EIP712Lib.msgSender()); } From 1f5ab34b8537c31491f1b35cb4d64dd617309070 Mon Sep 17 00:00:00 2001 From: zajck Date: Thu, 27 Apr 2023 08:34:37 +0200 Subject: [PATCH 07/33] Disputeresolver handler tests --- test/protocol/DisputeResolverHandlerTest.js | 37 ++++++++------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/test/protocol/DisputeResolverHandlerTest.js b/test/protocol/DisputeResolverHandlerTest.js index e1bb487f6..d05e8e3ff 100644 --- a/test/protocol/DisputeResolverHandlerTest.js +++ b/test/protocol/DisputeResolverHandlerTest.js @@ -161,9 +161,9 @@ describe("DisputeResolverHandler", function () { //Create DisputeResolverFee array disputeResolverFees = [ - new DisputeResolverFee(other1.address, "MockToken1", "0"), + new DisputeResolverFee(other1.address, "MockToken1", "100"), new DisputeResolverFee(other2.address, "MockToken2", "0"), - new DisputeResolverFee(other3.address, "MockToken3", "0"), + new DisputeResolverFee(other3.address, "MockToken3", "50"), ]; disputeResolverFeeList = new DisputeResolverFeeList(disputeResolverFees); @@ -516,8 +516,8 @@ describe("DisputeResolverHandler", function () { //Create new DisputeResolverFee array disputeResolverFees2 = [ new DisputeResolverFee(other1.address, "MockToken1", "0"), - new DisputeResolverFee(other3.address, "MockToken3", "0"), - new DisputeResolverFee(other2.address, "MockToken2", "0"), + new DisputeResolverFee(other3.address, "MockToken3", "10"), + new DisputeResolverFee(other2.address, "MockToken2", "30"), new DisputeResolverFee(other2.address, "MockToken2", "0"), ]; @@ -601,9 +601,9 @@ describe("DisputeResolverHandler", function () { beforeEach(async function () { //Create DisputeResolverFee array disputeResolverFees = [ - new DisputeResolverFee(other1.address, "MockToken1", "0"), + new DisputeResolverFee(other1.address, "MockToken1", "50"), new DisputeResolverFee(other2.address, "MockToken2", "0"), - new DisputeResolverFee(other3.address, "MockToken3", "0"), + new DisputeResolverFee(other3.address, "MockToken3", "123"), ]; sellerAllowList = ["1"]; @@ -1370,7 +1370,7 @@ describe("DisputeResolverHandler", function () { it("should emit a DisputeResolverFeesAdded event", async function () { const disputeResolverFeesToAdd = [ new DisputeResolverFee(other4.address, "MockToken4", "0"), - new DisputeResolverFee(other5.address, "MockToken5", "0"), + new DisputeResolverFee(other5.address, "MockToken5", "50"), ]; const addedDisputeResolverFeeList = new DisputeResolverFeeList(disputeResolverFeesToAdd); @@ -1396,17 +1396,12 @@ describe("DisputeResolverHandler", function () { it("should update DisputeResolverFee state only", async function () { const disputeResolverFeesToAdd = [ new DisputeResolverFee(other4.address, "MockToken4", "0"), - new DisputeResolverFee(other5.address, "MockToken5", "0"), + new DisputeResolverFee(other5.address, "MockToken5", "50"), ]; - const expectedDisputeResovlerFees = (disputeResolverFees = [ - new DisputeResolverFee(other1.address, "MockToken1", "0"), - new DisputeResolverFee(other2.address, "MockToken2", "0"), - new DisputeResolverFee(other3.address, "MockToken3", "0"), - new DisputeResolverFee(other4.address, "MockToken4", "0"), - new DisputeResolverFee(other5.address, "MockToken5", "0"), - ]); - const expectedDisputeResolverFeeList = new DisputeResolverFeeList(expectedDisputeResovlerFees); + const expectedDisputeResolverFees = [...disputeResolverFees, ...disputeResolverFeesToAdd]; + + const expectedDisputeResolverFeeList = new DisputeResolverFeeList(expectedDisputeResolverFees); // Add fees to dispute resolver await accountHandler.connect(admin).addFeesToDisputeResolver(disputeResolver.id, disputeResolverFeesToAdd); @@ -1556,10 +1551,7 @@ describe("DisputeResolverHandler", function () { expect(JSON.stringify(returnedDisputeResolver[key]) === JSON.stringify(value)).is.true; } - const expectedDisputeResolverFees = [ - new DisputeResolverFee(other3.address, "MockToken3", "0"), - new DisputeResolverFee(other2.address, "MockToken2", "0"), - ]; + const expectedDisputeResolverFees = [disputeResolverFees[2], disputeResolverFees[1]]; const expectedDisputeResolverFeeList = new DisputeResolverFeeList(expectedDisputeResolverFees); assert.equal( @@ -1593,10 +1585,7 @@ describe("DisputeResolverHandler", function () { expect(JSON.stringify(returnedDisputeResolver[key]) === JSON.stringify(value)).is.true; } - const expectedDisputeResolverFees = [ - new DisputeResolverFee(other1.address, "MockToken1", "0"), - new DisputeResolverFee(other2.address, "MockToken2", "0"), - ]; + const expectedDisputeResolverFees = [disputeResolverFees[0], disputeResolverFees[1]]; const expectedDisputeResolverFeeList = new DisputeResolverFeeList(expectedDisputeResolverFees); assert.equal( From 743f9c83f6b1b7eda3d831deb0ab5cbcd24d8754 Mon Sep 17 00:00:00 2001 From: zajck Date: Thu, 27 Apr 2023 16:44:21 +0200 Subject: [PATCH 08/33] Encumber funds tests --- .../clients/feeMutualizer/DRFeeMutualizer.sol | 14 + test/protocol/FundsHandlerTest.js | 1286 ++++++++++------- test/protocol/OfferHandlerTest.js | 6 +- 3 files changed, 746 insertions(+), 560 deletions(-) diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index a6b8929cf..4d41c6723 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -156,6 +156,7 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { } function newAgreement(Agreement calldata _agreement) external onlyOwner { + require(!_agreement.voided, "Agreement voided"); agreements.push(_agreement); uint256 agreementId = agreements.length - 1; @@ -193,6 +194,19 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { } } + function deposit(address _tokenAddress, uint256 _amount) external payable { + transferFundsToMutualizer(_tokenAddress, _amount); + } + + function withdraw(address _tokenAddress, uint256 _amount) external onlyOwner { + if (_tokenAddress == address(0)) { + payable(owner()).transfer(_amount); + } else { + IERC20 token = IERC20(_tokenAddress); + token.safeTransfer(owner(), _amount); + } + } + function transferFundsToMutualizer(address _tokenAddress, uint256 _amount) internal { if (_tokenAddress == address(0)) { require(msg.value == _amount, "Invalid incoming amount"); diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index dd5d1585b..bd57cf25d 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -1,6 +1,8 @@ const { ethers } = require("hardhat"); +const BN = ethers.BigNumber.from; const { expect, assert } = require("chai"); const Role = require("../../scripts/domain/Role"); +const Agreement = require("../../scripts/domain/Agreement"); const { Funds, FundsList } = require("../../scripts/domain/Funds"); const { DisputeResolverFee } = require("../../scripts/domain/DisputeResolverFee"); const PausableRegion = require("../../scripts/domain/PausableRegion.js"); @@ -29,6 +31,8 @@ const { mockBuyer, accountId, } = require("../util/mock"); +// const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); +const { oneMonth } = require("../util/constants"); /** * Test the Boson Funds Handler interface @@ -93,7 +97,7 @@ describe("IBosonFundsHandler", function () { agentOfferProtocolFee, expectedAgentAvailableFunds, agentAvailableFunds; - let DRFee, buyerEscalationDeposit; + let DRFeeToken, DRFeeNative, buyerEscalationDeposit; let protocolDiamondAddress; let snapshotId; @@ -459,10 +463,10 @@ describe("IBosonFundsHandler", function () { // expected payoffs - they are the same for token and native currency // buyer: price - buyerCancelPenalty - buyerPayoff = ethers.BigNumber.from(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(); + buyerPayoff = BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(); // seller: sellerDeposit + buyerCancelPenalty - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit).add(offerToken.buyerCancelPenalty).toString(); + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.buyerCancelPenalty).toString(); }); it("should emit a FundsWithdrawn event", async function () { @@ -472,8 +476,8 @@ describe("IBosonFundsHandler", function () { tokenListBuyer = [ethers.constants.AddressZero, mockToken.address]; // Withdraw amounts - tokenAmountsSeller = [sellerPayoff, ethers.BigNumber.from(sellerPayoff).div("2").toString()]; - tokenAmountsBuyer = [buyerPayoff, ethers.BigNumber.from(buyerPayoff).div("5").toString()]; + tokenAmountsSeller = [sellerPayoff, BN(sellerPayoff).div("2").toString()]; + tokenAmountsBuyer = [buyerPayoff, BN(buyerPayoff).div("5").toString()]; // seller withdrawal const tx = await fundsHandler.connect(clerk).withdrawFunds(seller.id, tokenListSeller, tokenAmountsSeller); @@ -483,25 +487,13 @@ describe("IBosonFundsHandler", function () { await expect(tx) .to.emit(fundsHandler, "FundsWithdrawn") - .withArgs( - seller.id, - treasury.address, - ethers.constants.Zero, - ethers.BigNumber.from(sellerPayoff).div("2"), - clerk.address - ); + .withArgs(seller.id, treasury.address, ethers.constants.Zero, BN(sellerPayoff).div("2"), clerk.address); // buyer withdrawal const tx2 = await fundsHandler.connect(buyer).withdrawFunds(buyerId, tokenListBuyer, tokenAmountsBuyer); await expect(tx2) .to.emit(fundsHandler, "FundsWithdrawn", buyer.address) - .withArgs( - buyerId, - buyer.address, - mockToken.address, - ethers.BigNumber.from(buyerPayoff).div("5"), - buyer.address - ); + .withArgs(buyerId, buyer.address, mockToken.address, BN(buyerPayoff).div("5"), buyer.address); await expect(tx2) .to.emit(fundsHandler, "FundsWithdrawn") @@ -526,9 +518,7 @@ describe("IBosonFundsHandler", function () { ); // withdraw funds - const withdrawAmount = ethers.BigNumber.from(sellerPayoff) - .sub(ethers.utils.parseUnits("0.1", "ether")) - .toString(); + const withdrawAmount = BN(sellerPayoff).sub(ethers.utils.parseUnits("0.1", "ether")).toString(); await fundsHandler.connect(clerk).withdrawFunds(seller.id, [ethers.constants.AddressZero], [withdrawAmount]); // Read on chain state @@ -540,7 +530,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[1] = new Funds( ethers.constants.AddressZero, "Native currency", - ethers.BigNumber.from(sellerPayoff).sub(withdrawAmount).toString() + BN(sellerPayoff).sub(withdrawAmount).toString() ); expect(sellersAvailableFunds).to.eql( expectedSellerAvailableFunds, @@ -701,7 +691,7 @@ describe("IBosonFundsHandler", function () { let reduction = ethers.utils.parseUnits("0.1", "ether").toString(); // Withdraw token tokenListSeller = [mockToken.address, mockToken.address]; - tokenAmountsSeller = [ethers.BigNumber.from(sellerPayoff).sub(reduction).toString(), reduction]; + tokenAmountsSeller = [BN(sellerPayoff).sub(reduction).toString(), reduction]; // seller withdrawal const tx = await fundsHandler.connect(clerk).withdrawFunds(seller.id, tokenListSeller, tokenAmountsSeller); @@ -711,7 +701,7 @@ describe("IBosonFundsHandler", function () { seller.id, treasury.address, mockToken.address, - ethers.BigNumber.from(sellerPayoff).sub(reduction).toString(), + BN(sellerPayoff).sub(reduction).toString(), clerk.address ); @@ -786,9 +776,7 @@ describe("IBosonFundsHandler", function () { const feeCollectorNativeBalanceAfter = await mockToken.balanceOf(agent.wallet); // Expected balance - const expectedFeeCollectorNativeBalanceAfter = ethers.BigNumber.from(feeCollectorNativeBalanceBefore).add( - agentPayoff - ); + const expectedFeeCollectorNativeBalanceAfter = BN(feeCollectorNativeBalanceBefore).add(agentPayoff); // Check agent wallet balance and verify the transfer really happened. expect(feeCollectorNativeBalanceAfter).to.eql( @@ -804,7 +792,7 @@ describe("IBosonFundsHandler", function () { // retract from the dispute await disputeHandler.connect(buyer).retractDispute(exchangeId); - agentPayoff = ethers.BigNumber.from(agentOffer.price).mul(agent.feePercentage).div("10000").toString(); + agentPayoff = BN(agentOffer.price).mul(agent.feePercentage).div("10000").toString(); // Check the balance BEFORE withdrawFunds() const feeCollectorNativeBalanceBefore = await mockToken.balanceOf(agent.wallet); @@ -817,9 +805,7 @@ describe("IBosonFundsHandler", function () { const feeCollectorNativeBalanceAfter = await mockToken.balanceOf(agent.wallet); // Expected balance - const expectedFeeCollectorNativeBalanceAfter = ethers.BigNumber.from(feeCollectorNativeBalanceBefore).add( - agentPayoff - ); + const expectedFeeCollectorNativeBalanceAfter = BN(feeCollectorNativeBalanceBefore).add(agentPayoff); // Check agent wallet balance and verify the transfer really happened. expect(feeCollectorNativeBalanceAfter).to.eql( @@ -835,7 +821,7 @@ describe("IBosonFundsHandler", function () { tokenListBuyer = [ethers.constants.AddressZero, mockToken.address]; // Withdraw amounts - tokenAmountsBuyer = [buyerPayoff, ethers.BigNumber.from(buyerPayoff).div("5").toString()]; + tokenAmountsBuyer = [buyerPayoff, BN(buyerPayoff).div("5").toString()]; // Pause the funds region of the protocol await pauseHandler.connect(pauser).pause([PausableRegion.Funds]); @@ -887,7 +873,7 @@ describe("IBosonFundsHandler", function () { it("Caller tries to withdraw more than they have in the available funds", async function () { // Withdraw token tokenList = [mockToken.address]; - tokenAmounts = [ethers.BigNumber.from(sellerPayoff).mul("2")]; + tokenAmounts = [BN(sellerPayoff).mul("2")]; // Attempt to withdraw the funds, expecting revert await expect(fundsHandler.connect(clerk).withdrawFunds(seller.id, tokenList, tokenAmounts)).to.revertedWith( @@ -1028,7 +1014,7 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // seller: sellerDeposit + offerToken.price - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit).add(offerToken.price).toString(); + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).toString(); // protocol: protocolFee protocolPayoff = offerTokenProtocolFee; @@ -1079,9 +1065,7 @@ describe("IBosonFundsHandler", function () { ); // withdraw funds - const partialFeeWithdrawAmount = ethers.BigNumber.from(protocolPayoff) - .sub(ethers.utils.parseUnits("0.01", "ether")) - .toString(); + const partialFeeWithdrawAmount = BN(protocolPayoff).sub(ethers.utils.parseUnits("0.01", "ether")).toString(); tx = await fundsHandler .connect(feeCollector) @@ -1106,7 +1090,7 @@ describe("IBosonFundsHandler", function () { new Funds( ethers.constants.AddressZero, "Native currency", - ethers.BigNumber.from(protocolPayoff).sub(partialFeeWithdrawAmount).toString() + BN(protocolPayoff).sub(partialFeeWithdrawAmount).toString() ), ]); @@ -1252,7 +1236,7 @@ describe("IBosonFundsHandler", function () { let reduction = ethers.utils.parseUnits("0.01", "ether").toString(); // Withdraw token tokenList = [mockToken.address, mockToken.address]; - tokenAmounts = [ethers.BigNumber.from(protocolPayoff).sub(reduction).toString(), reduction]; + tokenAmounts = [BN(protocolPayoff).sub(reduction).toString(), reduction]; // protocol fee withdrawal const tx = await fundsHandler.connect(feeCollector).withdrawProtocolFees(tokenList, tokenAmounts); @@ -1262,7 +1246,7 @@ describe("IBosonFundsHandler", function () { protocolId, protocolTreasury.address, mockToken.address, - ethers.BigNumber.from(protocolPayoff).sub(reduction).toString(), + BN(protocolPayoff).sub(reduction).toString(), feeCollector.address ); @@ -1317,7 +1301,7 @@ describe("IBosonFundsHandler", function () { it("Caller tries to withdraw more than they have in the available funds", async function () { // Withdraw token tokenList = [mockToken.address]; - tokenAmounts = [ethers.BigNumber.from(offerTokenProtocolFee).mul("2")]; + tokenAmounts = [BN(offerTokenProtocolFee).mul("2")]; // Attempt to withdraw the funds, expecting revert await expect( @@ -1464,15 +1448,16 @@ describe("IBosonFundsHandler", function () { expect(disputeResolver.isValid()).is.true; //Create DisputeResolverFee array so offer creation will succeed - DRFee = ethers.utils.parseUnits("0", "ether").toString(); + DRFeeToken = ethers.utils.parseUnits("0.1", "ether").toString(); + DRFeeNative = ethers.utils.parseUnits("0.2", "ether").toString(); disputeResolverFees = [ - new DisputeResolverFee(ethers.constants.AddressZero, "Native", "0"), - new DisputeResolverFee(mockToken.address, "mockToken", DRFee), + new DisputeResolverFee(ethers.constants.AddressZero, "Native", DRFeeNative), + new DisputeResolverFee(mockToken.address, "mockToken", DRFeeToken), ]; // Make empty seller list, so every seller is allowed const sellerAllowList = []; - buyerEscalationDeposit = applyPercentage(DRFee, buyerEscalationDepositPercentage); + buyerEscalationDeposit = applyPercentage(DRFeeToken, buyerEscalationDepositPercentage); // Register the dispute resolver await accountHandler @@ -1497,13 +1482,6 @@ describe("IBosonFundsHandler", function () { disputeResolverId = mo.disputeResolverId; agentId = "0"; // agent id is optional while creating an offer - // Create both offers - await Promise.all([ - offerHandler - .connect(assistant) - .createOffer(offerNative, offerDates, offerDurations, disputeResolverId, agentId), - offerHandler.connect(assistant).createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId), - ]); // Set used variables price = offerToken.price; @@ -1552,437 +1530,278 @@ describe("IBosonFundsHandler", function () { }); context("👉 encumberFunds()", async function () { - it("should emit a FundsEncumbered event", async function () { - let buyerId = "4"; // 1: seller, 2: disputeResolver, 3: agent, 4: buyer - - // Commit to an offer with erc20 token, test for FundsEncumbered event - const tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - await expect(tx) - .to.emit(exchangeHandler, "FundsEncumbered") - .withArgs(buyerId, mockToken.address, price, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "FundsEncumbered") - .withArgs(seller.id, mockToken.address, sellerDeposit, buyer.address); - - // Commit to an offer with native currency, test for FundsEncumbered event - const tx2 = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); - await expect(tx2) - .to.emit(exchangeHandler, "FundsEncumbered") - .withArgs(buyerId, ethers.constants.AddressZero, price, buyer.address); - - await expect(tx2) - .to.emit(exchangeHandler, "FundsEncumbered") - .withArgs(seller.id, ethers.constants.AddressZero, sellerDeposit, buyer.address); - }); - - it("should update state", async function () { - // contract token value - const contractTokenBalanceBefore = await mockToken.balanceOf(protocolDiamondAddress); - // contract native token balance - const contractNativeBalanceBefore = await ethers.provider.getBalance(protocolDiamondAddress); - // seller's available funds - const sellersAvailableFundsBefore = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - - // Commit to an offer with erc20 token - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + context("Self mutualization", async function () { + beforeEach(async function () { + // Create both offers + await Promise.all([ + offerHandler + .connect(assistant) + .createOffer(offerNative, offerDates, offerDurations, disputeResolverId, agentId), + offerHandler + .connect(assistant) + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId), + ]); - // Check that token balance increased - const contractTokenBalanceAfter = await mockToken.balanceOf(protocolDiamondAddress); - // contract token balance should increase for the incoming price - // seller's deposit was already held in the contract's pool before - expect(contractTokenBalanceAfter.sub(contractTokenBalanceBefore).toString()).to.eql( - price, - "Token wrong balance increase" - ); + // Seller must deposit enough to cover DR fees + const sellerPoolToken = BN(DRFeeToken).mul(2); + const sellerPoolNative = BN(DRFeeNative).mul(2); + await mockToken.mint(assistant.address, sellerPoolToken); - // Check that seller's pool balance was reduced - let sellersAvailableFundsAfter = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - // token is the first on the list of the available funds and the amount should be decreased for the sellerDeposit - expect( - ethers.BigNumber.from(sellersAvailableFundsBefore.funds[0].availableAmount) - .sub(ethers.BigNumber.from(sellersAvailableFundsAfter.funds[0].availableAmount)) - .toString() - ).to.eql(sellerDeposit, "Token seller available funds mismatch"); - - // Commit to an offer with native currency - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); - - // check that native currency balance increased - const contractNativeBalanceAfter = await ethers.provider.getBalance(protocolDiamondAddress); - // contract token balance should increase for the incoming price - // seller's deposit was already held in the contract's pool before - expect(contractNativeBalanceAfter.sub(contractNativeBalanceBefore).toString()).to.eql( - price, - "Native currency wrong balance increase" - ); + // approve protocol to transfer the tokens + await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerPoolToken); - // Check that seller's pool balance was reduced - sellersAvailableFundsAfter = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - // native currency is the second on the list of the available funds and the amount should be decreased for the sellerDeposit - expect( - ethers.BigNumber.from(sellersAvailableFundsBefore.funds[1].availableAmount) - .sub(ethers.BigNumber.from(sellersAvailableFundsAfter.funds[1].availableAmount)) - .toString() - ).to.eql(sellerDeposit, "Native currency seller available funds mismatch"); - }); + // deposit to seller's pool + await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerPoolToken); + await fundsHandler + .connect(assistant) + .depositFunds(seller.id, ethers.constants.AddressZero, sellerPoolNative, { + value: sellerPoolNative, + }); + }); - context("seller's available funds drop to 0", async function () { - it("token should be removed from the tokenList", async function () { - // seller's available funds - let sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - expect(sellersAvailableFunds.funds.length).to.eql(2, "Funds length mismatch"); - expect(sellersAvailableFunds.funds[0].tokenAddress).to.eql( - mockToken.address, - "Token contract address mismatch" - ); - expect(sellersAvailableFunds.funds[1].tokenAddress).to.eql( - ethers.constants.AddressZero, - "Native currency address mismatch" - ); + it("should emit a FundsEncumbered event", async function () { + let buyerId = "4"; // 1: seller, 2: disputeResolver, 3: agent, 4: buyer - // Commit to offer with token twice to empty the seller's pool - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + // Commit to an offer with erc20 token, test for FundsEncumbered event + const tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + await expect(tx) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(buyerId, mockToken.address, price, buyer.address); - // Token address should be removed and have only native currency in the list - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - expect(sellersAvailableFunds.funds.length).to.eql(1, "Funds length mismatch"); - expect(sellersAvailableFunds.funds[0].tokenAddress).to.eql( - ethers.constants.AddressZero, - "Native currency address mismatch" - ); + await expect(tx) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(seller.id, mockToken.address, BN(sellerDeposit).add(DRFeeToken), buyer.address); - // Commit to offer with token twice to empty the seller's pool - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); + // Commit to an offer with native currency, test for FundsEncumbered event + const tx2 = await exchangeHandler + .connect(buyer) + .commitToOffer(buyer.address, offerNative.id, { value: price }); + await expect(tx2) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(buyerId, ethers.constants.AddressZero, price, buyer.address); - // Seller available funds must be empty - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - expect(sellersAvailableFunds.funds.length).to.eql(0, "Funds length mismatch"); + await expect(tx2) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(seller.id, ethers.constants.AddressZero, BN(sellerDeposit).add(DRFeeNative), buyer.address); }); - it("token should be removed from the token list even when list length - 1 is different from index", async function () { - // length - 1 is different from index when index isn't the first or last element in the list - // Deploy a new mock token - let TokenContractFactory = await ethers.getContractFactory("Foreign20"); - const otherToken = await TokenContractFactory.deploy(); - await otherToken.deployed(); - - // Add otherToken to DR fees - await accountHandler - .connect(adminDR) - .addFeesToDisputeResolver(disputeResolver.id, [ - new DisputeResolverFee(otherToken.address, "Other Token", "0"), - ]); - - // top up seller's and buyer's account - await otherToken.mint(assistant.address, sellerDeposit); - - // approve protocol to transfer the tokens - await otherToken.connect(assistant).approve(protocolDiamondAddress, sellerDeposit); + it("should update state", async function () { + // contract token value + const contractTokenBalanceBefore = await mockToken.balanceOf(protocolDiamondAddress); + // contract native token balance + const contractNativeBalanceBefore = await ethers.provider.getBalance(protocolDiamondAddress); + // seller's available funds + const sellersAvailableFundsBefore = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - // deposit to seller's pool - await fundsHandler.connect(assistant).depositFunds(seller.id, otherToken.address, sellerDeposit); + // Commit to an offer with erc20 token + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - // seller's available funds - let sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - expect(sellersAvailableFunds.funds.length).to.eql(3, "Funds length mismatch"); - expect(sellersAvailableFunds.funds[0].tokenAddress).to.eql( - mockToken.address, - "Token contract address mismatch" - ); - expect(sellersAvailableFunds.funds[1].tokenAddress).to.eql( - ethers.constants.AddressZero, - "Native currency address mismatch" - ); - expect(sellersAvailableFunds.funds[2].tokenAddress).to.eql( - otherToken.address, - "Boson token address mismatch" + // Check that token balance increased + const contractTokenBalanceAfter = await mockToken.balanceOf(protocolDiamondAddress); + // contract token balance should increase for the incoming price + // seller's deposit was already held in the contract's pool before + expect(contractTokenBalanceAfter.sub(contractTokenBalanceBefore).toString()).to.eql( + price, + "Token wrong balance increase" ); - // Commit to offer with token twice to empty the seller's pool - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); + // Check that seller's pool balance was reduced + let sellersAvailableFundsAfter = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + // token is the first on the list of the available funds and the amount should be decreased for the sellerDeposit and DRFee + expect( + BN(sellersAvailableFundsBefore.funds[0].availableAmount) + .sub(BN(sellersAvailableFundsAfter.funds[0].availableAmount)) + .toString() + ).to.eql(BN(sellerDeposit).add(DRFeeToken).toString(), "Token seller available funds mismatch"); + + // Commit to an offer with native currency await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); - // Native currency address should be removed and have only mock token and other token in the list - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - expect(sellersAvailableFunds.funds.length).to.eql(2, "Funds length mismatch"); - expect(sellersAvailableFunds.funds[0].tokenAddress).to.eql( - mockToken.address, - "Token contract address mismatch" - ); - expect(sellersAvailableFunds.funds[1].tokenAddress).to.eql( - otherToken.address, - "Other token address mismatch" + // check that native currency balance increased + const contractNativeBalanceAfter = await ethers.provider.getBalance(protocolDiamondAddress); + // contract token balance should increase for the incoming price + // seller's deposit was already held in the contract's pool before + expect(contractNativeBalanceAfter.sub(contractNativeBalanceBefore).toString()).to.eql( + price, + "Native currency wrong balance increase" ); + + // Check that seller's pool balance was reduced + sellersAvailableFundsAfter = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + // native currency is the second on the list of the available funds and the amount should be decreased for the sellerDeposit + expect( + BN(sellersAvailableFundsBefore.funds[1].availableAmount) + .sub(BN(sellersAvailableFundsAfter.funds[1].availableAmount)) + .toString() + ).to.eql(BN(sellerDeposit).add(DRFeeNative).toString(), "Native currency seller available funds mismatch"); }); - }); - it("when someone else deposits on buyer's behalf, callers funds are transferred", async function () { - // buyer will commit to an offer on rando's behalf - // get token balance before the commit - const buyerTokenBalanceBefore = await mockToken.balanceOf(buyer.address); - const randoTokenBalanceBefore = await mockToken.balanceOf(rando.address); + context("seller's available funds drop to 0", async function () { + it("token should be removed from the tokenList", async function () { + // seller's available funds + let sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + expect(sellersAvailableFunds.funds.length).to.eql(2, "Funds length mismatch"); + expect(sellersAvailableFunds.funds[0].tokenAddress).to.eql( + mockToken.address, + "Token contract address mismatch" + ); + expect(sellersAvailableFunds.funds[1].tokenAddress).to.eql( + ethers.constants.AddressZero, + "Native currency address mismatch" + ); - // commit to an offer with token on rando's behalf - await exchangeHandler.connect(buyer).commitToOffer(rando.address, offerToken.id); + // Commit to offer with token twice to empty the seller's pool + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + + // Token address should be removed and have only native currency in the list + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + expect(sellersAvailableFunds.funds.length).to.eql(1, "Funds length mismatch"); + expect(sellersAvailableFunds.funds[0].tokenAddress).to.eql( + ethers.constants.AddressZero, + "Native currency address mismatch" + ); - // get token balance after the commit - const buyerTokenBalanceAfter = await mockToken.balanceOf(buyer.address); - const randoTokenBalanceAfter = await mockToken.balanceOf(rando.address); + // Commit to offer with token twice to empty the seller's pool + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); - // buyer's balance should decrease, rando's should remain - expect(buyerTokenBalanceBefore.sub(buyerTokenBalanceAfter).toString()).to.eql( - price, - "Buyer's token balance should decrease for a price" - ); - expect(randoTokenBalanceAfter.toString()).to.eql( - randoTokenBalanceBefore.toString(), - "Rando's token balance should remain the same" - ); - // make sure that rando is actually the buyer of the exchange - let exchange; - [, exchange] = await exchangeHandler.getExchange("1"); - expect(exchange.buyerId.toString()).to.eql(randoBuyerId, "Wrong buyer id"); - - // get native currency balance before the commit - const buyerNativeBalanceBefore = await ethers.provider.getBalance(buyer.address); - const randoNativeBalanceBefore = await ethers.provider.getBalance(rando.address); - - // commit to an offer with native currency on rando's behalf - tx = await exchangeHandler.connect(buyer).commitToOffer(rando.address, offerNative.id, { value: price }); - txReceipt = await tx.wait(); - txCost = tx.gasPrice.mul(txReceipt.gasUsed); - - // get token balance after the commit - const buyerNativeBalanceAfter = await ethers.provider.getBalance(buyer.address); - const randoNativeBalanceAfter = await ethers.provider.getBalance(rando.address); - - // buyer's balance should decrease, rando's should remain - expect(buyerNativeBalanceBefore.sub(buyerNativeBalanceAfter).sub(txCost).toString()).to.eql( - price, - "Buyer's native balance should decrease for a price" - ); - expect(randoNativeBalanceAfter.toString()).to.eql( - randoNativeBalanceBefore.toString(), - "Rando's native balance should remain the same" - ); - // make sure that rando is actually the buyer of the exchange - [, exchange] = await exchangeHandler.getExchange("2"); - expect(exchange.buyerId.toString()).to.eql(randoBuyerId, "Wrong buyer id"); + // Seller available funds must be empty + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + expect(sellersAvailableFunds.funds.length).to.eql(0, "Funds length mismatch"); + }); - // make sure that randoBuyerId actually belongs to rando address - let [, buyerStruct] = await accountHandler.getBuyer(randoBuyerId); - expect(buyerStruct.wallet).to.eql(rando.address, "Wrong buyer address"); - }); + it("token should be removed from the token list even when list length - 1 is different from index", async function () { + // length - 1 is different from index when index isn't the first or last element in the list + // Deploy a new mock token + let TokenContractFactory = await ethers.getContractFactory("Foreign20"); + const otherToken = await TokenContractFactory.deploy(); + await otherToken.deployed(); + + // Add otherToken to DR fees + await accountHandler + .connect(adminDR) + .addFeesToDisputeResolver(disputeResolver.id, [ + new DisputeResolverFee(otherToken.address, "Other Token", "0"), + ]); - it("if offer is preminted, only sellers funds are encumbered", async function () { - // deposit to seller's pool to cover for the price - const buyerId = mockBuyer().id; - await mockToken.mint(assistant.address, `${2 * price}`); - await mockToken.connect(assistant).approve(protocolDiamondAddress, `${2 * price}`); - await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, `${2 * price}`); - await fundsHandler.connect(assistant).depositFunds(seller.id, ethers.constants.AddressZero, `${2 * price}`, { - value: `${2 * price}`, - }); + // top up seller's and buyer's account + await otherToken.mint(assistant.address, sellerDeposit); - // get token balance before the commit - const buyerTokenBalanceBefore = await mockToken.balanceOf(buyer.address); - - const sellersAvailableFundsBefore = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - - // reserve a range and premint vouchers - await offerHandler - .connect(assistant) - .reserveRange(offerToken.id, offerToken.quantityAvailable, assistant.address); - const voucherCloneAddress = calculateContractAddress(accountHandler.address, "1"); - const bosonVoucher = await ethers.getContractAt("BosonVoucher", voucherCloneAddress); - await bosonVoucher.connect(assistant).preMint(offerToken.id, offerToken.quantityAvailable); - - // commit to an offer via preminted voucher - let exchangeId = "1"; - let tokenId = deriveTokenId(offerToken.id, exchangeId); - tx = await bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId); - - // it should emit FundsEncumbered event with amount equal to sellerDeposit + price - let encumberedFunds = ethers.BigNumber.from(sellerDeposit).add(price); - await expect(tx) - .to.emit(exchangeHandler, "FundsEncumbered") - .withArgs(seller.id, mockToken.address, encumberedFunds, bosonVoucher.address); - - // Check that seller's pool balance was reduced - let sellersAvailableFundsAfter = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - // token is the first on the list of the available funds and the amount should be decreased for the sellerDeposit and price - expect( - ethers.BigNumber.from(sellersAvailableFundsBefore.funds[0].availableAmount) - .sub(ethers.BigNumber.from(sellersAvailableFundsAfter.funds[0].availableAmount)) - .toString() - ).to.eql(encumberedFunds.toString(), "Token seller available funds mismatch"); - - // buyer's token balance should stay the same - const buyerTokenBalanceAfter = await mockToken.balanceOf(buyer.address); - expect(buyerTokenBalanceBefore.toString()).to.eql( - buyerTokenBalanceAfter.toString(), - "Buyer's token balance should remain the same" - ); + // approve protocol to transfer the tokens + await otherToken.connect(assistant).approve(protocolDiamondAddress, sellerDeposit); - // make sure that buyer is actually the buyer of the exchange - let exchange; - [, exchange] = await exchangeHandler.getExchange(exchangeId); - expect(exchange.buyerId.toString()).to.eql(buyerId, "Wrong buyer id"); - - // get native currency balance before the commit - const buyerNativeBalanceBefore = await ethers.provider.getBalance(buyer.address); - - // reserve a range and premint vouchers - exchangeId = await exchangeHandler.getNextExchangeId(); - tokenId = deriveTokenId(offerNative.id, exchangeId); - await offerHandler - .connect(assistant) - .reserveRange(offerNative.id, offerNative.quantityAvailable, assistant.address); - await bosonVoucher.connect(assistant).preMint(offerNative.id, offerNative.quantityAvailable); - - // commit to an offer via preminted voucher - tx = await bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId); - - // it should emit FundsEncumbered event with amount equal to sellerDeposit + price - encumberedFunds = ethers.BigNumber.from(sellerDeposit).add(price); - await expect(tx) - .to.emit(exchangeHandler, "FundsEncumbered") - .withArgs(seller.id, ethers.constants.AddressZero, encumberedFunds, bosonVoucher.address); - - // buyer's balance should remain the same - const buyerNativeBalanceAfter = await ethers.provider.getBalance(buyer.address); - expect(buyerNativeBalanceBefore.toString()).to.eql( - buyerNativeBalanceAfter.toString(), - "Buyer's native balance should remain the same" - ); + // deposit to seller's pool + await fundsHandler.connect(assistant).depositFunds(seller.id, otherToken.address, sellerDeposit); - // Check that seller's pool balance was reduced - sellersAvailableFundsAfter = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - // native currency the second on the list of the available funds and the amount should be decreased for the sellerDeposit and price - expect( - ethers.BigNumber.from(sellersAvailableFundsBefore.funds[1].availableAmount) - .sub(ethers.BigNumber.from(sellersAvailableFundsAfter.funds[1].availableAmount)) - .toString() - ).to.eql(encumberedFunds.toString(), "Native currency seller available funds mismatch"); - - // make sure that buyer is actually the buyer of the exchange - [, exchange] = await exchangeHandler.getExchange(exchangeId); - expect(exchange.buyerId.toString()).to.eql(buyerId, "Wrong buyer id"); - }); + // seller's available funds + let sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + expect(sellersAvailableFunds.funds.length).to.eql(3, "Funds length mismatch"); + expect(sellersAvailableFunds.funds[0].tokenAddress).to.eql( + mockToken.address, + "Token contract address mismatch" + ); + expect(sellersAvailableFunds.funds[1].tokenAddress).to.eql( + ethers.constants.AddressZero, + "Native currency address mismatch" + ); + expect(sellersAvailableFunds.funds[2].tokenAddress).to.eql( + otherToken.address, + "Boson token address mismatch" + ); - context("💔 Revert Reasons", async function () { - it("Insufficient native currency sent", async function () { - // Attempt to commit to an offer, expecting revert - await expect( - exchangeHandler - .connect(buyer) - .commitToOffer(buyer.address, offerNative.id, { value: ethers.BigNumber.from(price).sub("1").toString() }) - ).to.revertedWith(RevertReasons.INSUFFICIENT_VALUE_RECEIVED); - }); + // Commit to offer with token twice to empty the seller's pool + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); - it("Native currency sent together with ERC20 token transfer", async function () { - // Attempt to commit to an offer, expecting revert - await expect( - exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id, { value: price }) - ).to.revertedWith(RevertReasons.NATIVE_NOT_ALLOWED); + // Native currency address should be removed and have only mock token and other token in the list + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + expect(sellersAvailableFunds.funds.length).to.eql(2, "Funds length mismatch"); + expect(sellersAvailableFunds.funds[0].tokenAddress).to.eql( + mockToken.address, + "Token contract address mismatch" + ); + expect(sellersAvailableFunds.funds[1].tokenAddress).to.eql( + otherToken.address, + "Other token address mismatch" + ); + }); }); - it("Token address contract does not support transferFrom", async function () { - // Deploy a contract without the transferFrom - [bosonToken] = await deployMockTokens(["BosonToken"]); + it("when someone else deposits on buyer's behalf, callers funds are transferred", async function () { + // buyer will commit to an offer on rando's behalf + // get token balance before the commit + const buyerTokenBalanceBefore = await mockToken.balanceOf(buyer.address); + const randoTokenBalanceBefore = await mockToken.balanceOf(rando.address); - // create an offer with a bad token contrat - offerToken.exchangeToken = bosonToken.address; - offerToken.id = "3"; + // commit to an offer with token on rando's behalf + await exchangeHandler.connect(buyer).commitToOffer(rando.address, offerToken.id); - // add to DR fees - await accountHandler - .connect(adminDR) - .addFeesToDisputeResolver(disputeResolver.id, [ - new DisputeResolverFee(offerToken.exchangeToken, "BadContract", "0"), - ]); - await offerHandler - .connect(assistant) - .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId); + // get token balance after the commit + const buyerTokenBalanceAfter = await mockToken.balanceOf(buyer.address); + const randoTokenBalanceAfter = await mockToken.balanceOf(rando.address); - // Attempt to commit to an offer, expecting revert - await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( - RevertReasons.SAFE_ERC20_LOW_LEVEL_CALL + // buyer's balance should decrease, rando's should remain + expect(buyerTokenBalanceBefore.sub(buyerTokenBalanceAfter).toString()).to.eql( + price, + "Buyer's token balance should decrease for a price" ); - }); + expect(randoTokenBalanceAfter.toString()).to.eql( + randoTokenBalanceBefore.toString(), + "Rando's token balance should remain the same" + ); + // make sure that rando is actually the buyer of the exchange + let exchange; + [, exchange] = await exchangeHandler.getExchange("1"); + expect(exchange.buyerId.toString()).to.eql(randoBuyerId, "Wrong buyer id"); - it("Token address is not a contract", async function () { - // create an offer with a bad token contrat - offerToken.exchangeToken = admin.address; - offerToken.id = "3"; - - // add to DR fees - await accountHandler - .connect(adminDR) - .addFeesToDisputeResolver(disputeResolver.id, [ - new DisputeResolverFee(offerToken.exchangeToken, "NotAContract", "0"), - ]); + // get native currency balance before the commit + const buyerNativeBalanceBefore = await ethers.provider.getBalance(buyer.address); + const randoNativeBalanceBefore = await ethers.provider.getBalance(rando.address); - await offerHandler - .connect(assistant) - .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId); + // commit to an offer with native currency on rando's behalf + tx = await exchangeHandler.connect(buyer).commitToOffer(rando.address, offerNative.id, { value: price }); + txReceipt = await tx.wait(); + txCost = tx.gasPrice.mul(txReceipt.gasUsed); - // Attempt to commit to an offer, expecting revert - await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( - RevertReasons.EOA_FUNCTION_CALL - ); - }); + // get token balance after the commit + const buyerNativeBalanceAfter = await ethers.provider.getBalance(buyer.address); + const randoNativeBalanceAfter = await ethers.provider.getBalance(rando.address); - it("Token contract revert for another reason", async function () { - // insufficient funds - // approve more than account actually have - await mockToken.connect(rando).approve(protocolDiamondAddress, price); - // Attempt to commit to an offer, expecting revert - await expect(exchangeHandler.connect(rando).commitToOffer(rando.address, offerToken.id)).to.revertedWith( - RevertReasons.ERC20_EXCEEDS_BALANCE + // buyer's balance should decrease, rando's should remain + expect(buyerNativeBalanceBefore.sub(buyerNativeBalanceAfter).sub(txCost).toString()).to.eql( + price, + "Buyer's native balance should decrease for a price" ); - - // not approved - await mockToken - .connect(rando) - .approve(protocolDiamondAddress, ethers.BigNumber.from(price).sub("1").toString()); - // Attempt to commit to an offer, expecting revert - await expect(exchangeHandler.connect(rando).commitToOffer(rando.address, offerToken.id)).to.revertedWith( - RevertReasons.ERC20_INSUFFICIENT_ALLOWANCE + expect(randoNativeBalanceAfter.toString()).to.eql( + randoNativeBalanceBefore.toString(), + "Rando's native balance should remain the same" ); - }); + // make sure that rando is actually the buyer of the exchange + [, exchange] = await exchangeHandler.getExchange("2"); + expect(exchange.buyerId.toString()).to.eql(randoBuyerId, "Wrong buyer id"); - it("Seller'a availableFunds is less than the required sellerDeposit", async function () { - // create an offer with token with higher seller deposit - offerToken.sellerDeposit = ethers.BigNumber.from(offerToken.sellerDeposit).mul("4"); - offerToken.id = "3"; - await offerHandler - .connect(assistant) - .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId); + // make sure that randoBuyerId actually belongs to rando address + let [, buyerStruct] = await accountHandler.getBuyer(randoBuyerId); + expect(buyerStruct.wallet).to.eql(rando.address, "Wrong buyer address"); + }); - // Attempt to commit to an offer, expecting revert - await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( - RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS - ); + it("if offer is preminted, only sellers funds are encumbered", async function () { + // deposit to seller's pool to cover for the price + const buyerId = mockBuyer().id; + await mockToken.mint(assistant.address, `${2 * price}`); + await mockToken.connect(assistant).approve(protocolDiamondAddress, `${2 * price}`); + await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, `${2 * price}`); + await fundsHandler.connect(assistant).depositFunds(seller.id, ethers.constants.AddressZero, `${2 * price}`, { + value: `${2 * price}`, + }); - // create an offer with native currency with higher seller deposit - offerNative.sellerDeposit = ethers.BigNumber.from(offerNative.sellerDeposit).mul("4"); - offerNative.id = "4"; - await offerHandler - .connect(assistant) - .createOffer(offerNative, offerDates, offerDurations, disputeResolverId, agentId); + // get token balance before the commit + const buyerTokenBalanceBefore = await mockToken.balanceOf(buyer.address); - // Attempt to commit to an offer, expecting revert - await expect( - exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }) - ).to.revertedWith(RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS); - }); + const sellersAvailableFundsBefore = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - it("Seller'a availableFunds is less than the required sellerDeposit + price for preminted offer", async function () { - // reserve a range and premint vouchers for offer in tokens + // reserve a range and premint vouchers await offerHandler .connect(assistant) .reserveRange(offerToken.id, offerToken.quantityAvailable, assistant.address); @@ -1990,16 +1809,42 @@ describe("IBosonFundsHandler", function () { const bosonVoucher = await ethers.getContractAt("BosonVoucher", voucherCloneAddress); await bosonVoucher.connect(assistant).preMint(offerToken.id, offerToken.quantityAvailable); - // Seller's availableFunds is 2*sellerDeposit which is less than sellerDeposit + price. - // Add the check in case if the sellerDeposit is changed in the future - assert.isBelow(Number(sellerDeposit), Number(price), "Seller's availableFunds is not less than price"); - // Attempt to commit to an offer via preminted voucher, expecting revert - let tokenId = deriveTokenId(offerToken.id, "1"); - await expect( - bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId) - ).to.revertedWith(RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS); + // commit to an offer via preminted voucher + let exchangeId = "1"; + let tokenId = deriveTokenId(offerToken.id, exchangeId); + tx = await bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId); - // reserve a range and premint vouchers for offer in native currency + // it should emit FundsEncumbered event with amount equal to sellerDeposit + price + DRfee + let encumberedFunds = BN(sellerDeposit).add(price).add(DRFeeToken); + await expect(tx) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(seller.id, mockToken.address, encumberedFunds, bosonVoucher.address); + + // Check that seller's pool balance was reduced + let sellersAvailableFundsAfter = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + // token is the first on the list of the available funds and the amount should be decreased for the sellerDeposit and price + expect( + BN(sellersAvailableFundsBefore.funds[0].availableAmount) + .sub(BN(sellersAvailableFundsAfter.funds[0].availableAmount)) + .toString() + ).to.eql(encumberedFunds.toString(), "Token seller available funds mismatch"); + + // buyer's token balance should stay the same + const buyerTokenBalanceAfter = await mockToken.balanceOf(buyer.address); + expect(buyerTokenBalanceBefore.toString()).to.eql( + buyerTokenBalanceAfter.toString(), + "Buyer's token balance should remain the same" + ); + + // make sure that buyer is actually the buyer of the exchange + let exchange; + [, exchange] = await exchangeHandler.getExchange(exchangeId); + expect(exchange.buyerId.toString()).to.eql(buyerId, "Wrong buyer id"); + + // get native currency balance before the commit + const buyerNativeBalanceBefore = await ethers.provider.getBalance(buyer.address); + + // reserve a range and premint vouchers exchangeId = await exchangeHandler.getNextExchangeId(); tokenId = deriveTokenId(offerNative.id, exchangeId); await offerHandler @@ -2007,43 +1852,390 @@ describe("IBosonFundsHandler", function () { .reserveRange(offerNative.id, offerNative.quantityAvailable, assistant.address); await bosonVoucher.connect(assistant).preMint(offerNative.id, offerNative.quantityAvailable); - // Attempt to commit to an offer, expecting revert - await expect( - bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId) - ).to.revertedWith(RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS); + // commit to an offer via preminted voucher + tx = await bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId); + + // it should emit FundsEncumbered event with amount equal to sellerDeposit + price + DRfee + encumberedFunds = BN(sellerDeposit).add(price).add(DRFeeNative); + await expect(tx) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(seller.id, ethers.constants.AddressZero, encumberedFunds, bosonVoucher.address); + + // buyer's balance should remain the same + const buyerNativeBalanceAfter = await ethers.provider.getBalance(buyer.address); + expect(buyerNativeBalanceBefore.toString()).to.eql( + buyerNativeBalanceAfter.toString(), + "Buyer's native balance should remain the same" + ); + + // Check that seller's pool balance was reduced + sellersAvailableFundsAfter = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + // native currency the second on the list of the available funds and the amount should be decreased for the sellerDeposit and price + expect( + BN(sellersAvailableFundsBefore.funds[1].availableAmount) + .sub(BN(sellersAvailableFundsAfter.funds[1].availableAmount)) + .toString() + ).to.eql(encumberedFunds.toString(), "Native currency seller available funds mismatch"); + + // make sure that buyer is actually the buyer of the exchange + [, exchange] = await exchangeHandler.getExchange(exchangeId); + expect(exchange.buyerId.toString()).to.eql(buyerId, "Wrong buyer id"); }); - it("Received ERC20 token amount differs from the expected value", async function () { - // Deploy ERC20 with fees - const [Foreign20WithFee] = await deployMockTokens(["Foreign20WithFee"]); + context("💔 Revert Reasons", async function () { + it("Insufficient native currency sent", async function () { + // Attempt to commit to an offer, expecting revert + await expect( + exchangeHandler + .connect(buyer) + .commitToOffer(buyer.address, offerNative.id, { value: BN(price).sub("1").toString() }) + ).to.revertedWith(RevertReasons.INSUFFICIENT_VALUE_RECEIVED); + }); - // add to DR fees - DRFee = ethers.utils.parseUnits("0", "ether").toString(); - await accountHandler - .connect(adminDR) - .addFeesToDisputeResolver(disputeResolverId, [ - new DisputeResolverFee(Foreign20WithFee.address, "Foreign20WithFee", DRFee), - ]); + it("Native currency sent together with ERC20 token transfer", async function () { + // Attempt to commit to an offer, expecting revert + await expect( + exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id, { value: price }) + ).to.revertedWith(RevertReasons.NATIVE_NOT_ALLOWED); + }); - // Create an offer with ERC20 with fees - // Prepare an absolute zero offer - offerToken.exchangeToken = Foreign20WithFee.address; - offerToken.sellerDeposit = "0"; - offerToken.id++; + it("Token address contract does not support transferFrom", async function () { + // Deploy a contract without the transferFrom + [bosonToken] = await deployMockTokens(["BosonToken"]); - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId); + // create an offer with a bad token contrat + offerToken.exchangeToken = bosonToken.address; + offerToken.id = "3"; - // mint tokens and approve - await Foreign20WithFee.mint(buyer.address, offerToken.price); - await Foreign20WithFee.connect(buyer).approve(protocolDiamondAddress, offerToken.price); + // add to DR fees + await accountHandler + .connect(adminDR) + .addFeesToDisputeResolver(disputeResolver.id, [ + new DisputeResolverFee(offerToken.exchangeToken, "BadContract", "0"), + ]); + await offerHandler + .connect(assistant) + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId); + + // Attempt to commit to an offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.SAFE_ERC20_LOW_LEVEL_CALL + ); + }); + + it("Token address is not a contract", async function () { + // create an offer with a bad token contrat + offerToken.exchangeToken = admin.address; + offerToken.id = "3"; + + // add to DR fees + await accountHandler + .connect(adminDR) + .addFeesToDisputeResolver(disputeResolver.id, [ + new DisputeResolverFee(offerToken.exchangeToken, "NotAContract", "0"), + ]); + + await offerHandler + .connect(assistant) + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId); + + // Attempt to commit to an offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.EOA_FUNCTION_CALL + ); + }); + + it("Token contract revert for another reason", async function () { + // insufficient funds + // approve more than account actually have + await mockToken.connect(rando).approve(protocolDiamondAddress, price); + // Attempt to commit to an offer, expecting revert + await expect(exchangeHandler.connect(rando).commitToOffer(rando.address, offerToken.id)).to.revertedWith( + RevertReasons.ERC20_EXCEEDS_BALANCE + ); + + // not approved + await mockToken.connect(rando).approve(protocolDiamondAddress, BN(price).sub("1").toString()); + // Attempt to commit to an offer, expecting revert + await expect(exchangeHandler.connect(rando).commitToOffer(rando.address, offerToken.id)).to.revertedWith( + RevertReasons.ERC20_INSUFFICIENT_ALLOWANCE + ); + }); - // Attempt to commit to offer, expecting revert - await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( - RevertReasons.INSUFFICIENT_VALUE_RECEIVED + it("Seller'a availableFunds is less than the required sellerDeposit", async function () { + // create an offer with token with higher seller deposit + offerToken.sellerDeposit = BN(offerToken.sellerDeposit).mul("4"); + offerToken.id = "3"; + await offerHandler + .connect(assistant) + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId); + + // Attempt to commit to an offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS + ); + + // create an offer with native currency with higher seller deposit + offerNative.sellerDeposit = BN(offerNative.sellerDeposit).mul("4"); + offerNative.id = "4"; + await offerHandler + .connect(assistant) + .createOffer(offerNative, offerDates, offerDurations, disputeResolverId, agentId); + + // Attempt to commit to an offer, expecting revert + await expect( + exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }) + ).to.revertedWith(RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS); + }); + + it("Seller'a availableFunds is less than the required sellerDeposit + price for preminted offer", async function () { + // reserve a range and premint vouchers for offer in tokens + await offerHandler + .connect(assistant) + .reserveRange(offerToken.id, offerToken.quantityAvailable, assistant.address); + const voucherCloneAddress = calculateContractAddress(accountHandler.address, "1"); + const bosonVoucher = await ethers.getContractAt("BosonVoucher", voucherCloneAddress); + await bosonVoucher.connect(assistant).preMint(offerToken.id, offerToken.quantityAvailable); + + // Seller's availableFunds is 2*sellerDeposit which is less than sellerDeposit + price. + // Add the check in case if the sellerDeposit is changed in the future + assert.isBelow(Number(sellerDeposit), Number(price), "Seller's availableFunds is not less than price"); + // Attempt to commit to an offer via preminted voucher, expecting revert + let tokenId = deriveTokenId(offerToken.id, "1"); + await expect( + bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId) + ).to.revertedWith(RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS); + + // reserve a range and premint vouchers for offer in native currency + exchangeId = await exchangeHandler.getNextExchangeId(); + tokenId = deriveTokenId(offerNative.id, exchangeId); + await offerHandler + .connect(assistant) + .reserveRange(offerNative.id, offerNative.quantityAvailable, assistant.address); + await bosonVoucher.connect(assistant).preMint(offerNative.id, offerNative.quantityAvailable); + + // Attempt to commit to an offer, expecting revert + await expect( + bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId) + ).to.revertedWith(RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS); + }); + + it("Received ERC20 token amount differs from the expected value", async function () { + // Deploy ERC20 with fees + const [Foreign20WithFee] = await deployMockTokens(["Foreign20WithFee"]); + + // add to DR fees + DRFeeToken = ethers.utils.parseUnits("0", "ether").toString(); + await accountHandler + .connect(adminDR) + .addFeesToDisputeResolver(disputeResolverId, [ + new DisputeResolverFee(Foreign20WithFee.address, "Foreign20WithFee", DRFeeToken), + ]); + + // Create an offer with ERC20 with fees + // Prepare an absolute zero offer + offerToken.exchangeToken = Foreign20WithFee.address; + offerToken.sellerDeposit = "0"; + offerToken.id++; + + // Create a new offer + await offerHandler + .connect(assistant) + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId); + + // mint tokens and approve + await Foreign20WithFee.mint(buyer.address, offerToken.price); + await Foreign20WithFee.connect(buyer).approve(protocolDiamondAddress, offerToken.price); + + // Attempt to commit to offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.INSUFFICIENT_VALUE_RECEIVED + ); + }); + }); + }); + + context("External mutualizer", async function () { + let mutualizer; + + beforeEach(async function () { + // Deploy mutualizer + const poolOwner = rando; + const mutualizerFactory = await ethers.getContractFactory("DRFeeMutualizer"); + mutualizer = await mutualizerFactory.connect(poolOwner).deploy(fundsHandler.address); + + offerNative.feeMutualizer = offerToken.feeMutualizer = mutualizer.address; + + // Create both offers + await Promise.all([ + offerHandler + .connect(assistant) + .createOffer(offerNative, offerDates, offerDurations, disputeResolverId, agentId), + offerHandler + .connect(assistant) + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId), + ]); + + // Seller must deposit enough to cover DR fees + const poolToken = BN(DRFeeToken).mul(2); + const poolNative = BN(DRFeeNative).mul(2); + await mockToken.mint(poolOwner.address, poolToken); + + // approve protocol to transfer the tokens + await mockToken.connect(poolOwner).approve(mutualizer.address, poolToken); + + // deposit to mutualizer + await mutualizer.connect(poolOwner).deposit(mockToken.address, poolToken); + await mutualizer.connect(poolOwner).deposit(ethers.constants.AddressZero, poolNative, { + value: poolNative, + }); + + // Create new agreements + const startTimestamp = BN(Date.now()).div(1000); // valid from now + const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days + const agreementToken = new Agreement( + assistant.address, + mockToken.address, + ethers.utils.parseUnits("1", "ether"), + ethers.utils.parseUnits("1", "ether"), + "0", + startTimestamp.toString(), + endTimestamp.toString(), + false, + false ); + const agreementNative = agreementToken.clone(); + agreementNative.token = ethers.constants.AddressZero; + await Promise.all([ + mutualizer.connect(poolOwner).newAgreement(agreementToken), + mutualizer.connect(poolOwner).newAgreement(agreementNative), + ]); + + // Confirm agreements + const agreementIdToken = "1"; + const agreementIdNative = "2"; + + await Promise.all([ + mutualizer.connect(assistant).payPremium(agreementIdToken), + mutualizer.connect(assistant).payPremium(agreementIdNative), + ]); + }); + + it("should emit a FundsEncumbered event", async function () { + let buyerId = "4"; // 1: seller, 2: disputeResolver, 3: agent, 4: buyer + + // Commit to an offer with erc20 token, test for FundsEncumbered event + const tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + await expect(tx) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(buyerId, mockToken.address, price, buyer.address); + + await expect(tx) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(seller.id, mockToken.address, sellerDeposit, buyer.address); + + await expect(tx) + .to.emit(exchangeHandler, "DRFeeEncumbered") + .withArgs(mutualizer.address, "1", "1", mockToken.address, DRFeeToken, buyer.address); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + + // Commit to an offer with native currency, test for FundsEncumbered event + const tx2 = await exchangeHandler + .connect(buyer) + .commitToOffer(buyer.address, offerNative.id, { value: price }); + await expect(tx2) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(buyerId, ethers.constants.AddressZero, price, buyer.address); + + await expect(tx2) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(seller.id, ethers.constants.AddressZero, sellerDeposit, buyer.address); + + await expect(tx2) + .to.emit(exchangeHandler, "DRFeeEncumbered") + .withArgs(mutualizer.address, "2", "2", ethers.constants.AddressZero, DRFeeNative, buyer.address); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + }); + + it("should update state", async function () { + // balances before + const [ + protocolTokenBalanceBefore, + protocolNativeBalanceBefore, + sellersAvailableFundsBefore, + mutualizerTokenBalanceBefore, + mutualizerNativeBalanceBefore, + ] = await Promise.all([ + mockToken.balanceOf(protocolDiamondAddress), + ethers.provider.getBalance(protocolDiamondAddress), + FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)), + mockToken.balanceOf(mutualizer.address), + ethers.provider.getBalance(mutualizer.address), + ]); + + // Commit to an offer with erc20 token + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + + // Check that token balance increased + const [protocolTokenBalanceAfter, mutualizerTokenBalanceAfter] = await Promise.all([ + mockToken.balanceOf(protocolDiamondAddress), + mockToken.balanceOf(mutualizer.address), + ]); + // contract token balance should increase for the incoming price and DRFee + // seller's deposit was already held in the contract's pool before + expect(protocolTokenBalanceAfter.sub(protocolTokenBalanceBefore).toString()).to.eql( + BN(price).add(DRFeeToken).toString(), + "Token wrong balance increase" + ); + expect(mutualizerTokenBalanceBefore.sub(mutualizerTokenBalanceAfter).toString()).to.eql( + DRFeeToken, + "Token wrong balance decrease" + ); + + // Check that seller's pool balance was reduced + let sellersAvailableFundsAfter = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + // token is the first on the list of the available funds and the amount should be decreased for the sellerDeposit and DRFee + expect( + BN(sellersAvailableFundsBefore.funds[0].availableAmount) + .sub(BN(sellersAvailableFundsAfter.funds[0].availableAmount)) + .toString() + ).to.eql(sellerDeposit, "Token seller available funds mismatch"); + + // Commit to an offer with native currency + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); + + // check that native currency balance increased + const [protocolNativeBalanceAfter, mutualizerNativeBalanceAfter] = await Promise.all([ + ethers.provider.getBalance(protocolDiamondAddress), + ethers.provider.getBalance(mutualizer.address), + ]); + // contract token balance should increase for the incoming price and DRFee + // seller's deposit was already held in the contract's pool before + expect(protocolNativeBalanceAfter.sub(protocolNativeBalanceBefore).toString()).to.eql( + BN(price).add(DRFeeNative).toString(), + "Native currency wrong balance increase" + ); + expect(mutualizerNativeBalanceBefore.sub(mutualizerNativeBalanceAfter).toString()).to.eql( + DRFeeNative, + "Native currency wrong balance decrease" + ); + + // Check that seller's pool balance was reduced + sellersAvailableFundsAfter = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + // native currency is the second on the list of the available funds and the amount should be decreased for the sellerDeposit + expect( + BN(sellersAvailableFundsBefore.funds[1].availableAmount) + .sub(BN(sellersAvailableFundsAfter.funds[1].availableAmount)) + .toString() + ).to.eql(sellerDeposit, "Native currency seller available funds mismatch"); + }); + + context("💔 Revert Reasons", async function () { + it.skip("Mutualizer contract declines the request", async function () { + // ToDo: implement when mutualizer can be updated in the protocol, since it will be easier to mock this behavior + }); + + it.skip("Mutualizer contract sends less than requested", async function () { + // ToDo: implement when mutualizer can be updated in the protocol, since it will be easier to mock this behavior + }); }); }); }); @@ -2072,10 +2264,7 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // seller: sellerDeposit + price - protocolFee - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .toString(); + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); // protocol: protocolFee protocolPayoff = offerTokenProtocolFee; @@ -2146,12 +2335,12 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[1] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerPayoff).mul(2).toString() + BN(sellerPayoff).mul(2).toString() ); expectedProtocolAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(protocolPayoff).mul(2).toString() + BN(protocolPayoff).mul(2).toString() ); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); @@ -2178,11 +2367,11 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // agentPayoff: agentFee - agentFee = ethers.BigNumber.from(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); agentPayoff = agentFee; // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = ethers.BigNumber.from(agentOffer.sellerDeposit) + sellerPayoff = BN(agentOffer.sellerDeposit) .add(agentOffer.price) .sub(agentOfferProtocolFee) .sub(agentFee) @@ -2256,7 +2445,7 @@ describe("IBosonFundsHandler", function () { beforeEach(async function () { // expected payoffs // buyer: sellerDeposit + price - buyerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit).add(offerToken.price).toString(); + buyerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).toString(); // seller: 0 sellerPayoff = 0; @@ -2325,7 +2514,7 @@ describe("IBosonFundsHandler", function () { expectedBuyerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(buyerPayoff).mul(2).toString() + BN(buyerPayoff).mul(2).toString() ); expectedSellerAvailableFunds = new FundsList([ new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), @@ -2363,7 +2552,7 @@ describe("IBosonFundsHandler", function () { // expected payoffs // buyer: sellerDeposit + price - buyerPayoff = ethers.BigNumber.from(agentOffer.sellerDeposit).add(agentOffer.price).toString(); + buyerPayoff = BN(agentOffer.sellerDeposit).add(agentOffer.price).toString(); // seller: 0 sellerPayoff = 0; @@ -2430,7 +2619,7 @@ describe("IBosonFundsHandler", function () { expectedBuyerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(buyerPayoff).mul(2).toString() + BN(buyerPayoff).mul(2).toString() ); expectedSellerAvailableFunds = new FundsList([ new Funds(mockToken.address, "Foreign20", `${sellerDeposit}`), @@ -2452,10 +2641,10 @@ describe("IBosonFundsHandler", function () { beforeEach(async function () { // expected payoffs // buyer: price - buyerCancelPenalty - buyerPayoff = ethers.BigNumber.from(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(); + buyerPayoff = BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(); // seller: sellerDeposit + buyerCancelPenalty - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit).add(offerToken.buyerCancelPenalty).toString(); + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.buyerCancelPenalty).toString(); // protocol: 0 protocolPayoff = 0; @@ -2506,7 +2695,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).toString() ); expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -2542,12 +2731,10 @@ describe("IBosonFundsHandler", function () { // expected payoffs // buyer: price - buyerCancelPenalty - buyerPayoff = ethers.BigNumber.from(agentOffer.price).sub(agentOffer.buyerCancelPenalty).toString(); + buyerPayoff = BN(agentOffer.price).sub(agentOffer.buyerCancelPenalty).toString(); // seller: sellerDeposit + buyerCancelPenalty - sellerPayoff = ethers.BigNumber.from(agentOffer.sellerDeposit) - .add(agentOffer.buyerCancelPenalty) - .toString(); + sellerPayoff = BN(agentOffer.sellerDeposit).add(agentOffer.buyerCancelPenalty).toString(); // protocol: 0 protocolPayoff = 0; @@ -2589,7 +2776,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).toString() ); expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -2619,7 +2806,7 @@ describe("IBosonFundsHandler", function () { blockNumber = tx.blockNumber; block = await ethers.provider.getBlock(blockNumber); disputedDate = block.timestamp.toString(); - timeout = ethers.BigNumber.from(disputedDate).add(resolutionPeriod).toString(); + timeout = BN(disputedDate).add(resolutionPeriod).toString(); }); context("Final state DISPUTED - RETRACTED", async function () { @@ -2629,10 +2816,7 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // seller: sellerDeposit + price - protocolFee - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .toString(); + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); // protocol: 0 protocolPayoff = offerTokenProtocolFee; @@ -2693,7 +2877,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).toString() ); expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -2713,11 +2897,11 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // agentPayoff: agentFee - agentFee = ethers.BigNumber.from(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); agentPayoff = agentFee; // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = ethers.BigNumber.from(agentOffer.sellerDeposit) + sellerPayoff = BN(agentOffer.sellerDeposit) .add(agentOffer.price) .sub(agentOfferProtocolFee) .sub(agentFee) @@ -2785,7 +2969,7 @@ describe("IBosonFundsHandler", function () { // protocol: protocolFee // agent: agentFee expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", ethers.BigNumber.from(sellerPayoff).toString()) + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) ); expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); @@ -2808,10 +2992,7 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // seller: sellerDeposit + price - protocolFee - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .toString(); + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); // protocol: protocolFee protocolPayoff = offerTokenProtocolFee; @@ -2873,7 +3054,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).toString() ); expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -2901,11 +3082,11 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // agentPayoff: agentFee - agentFee = ethers.BigNumber.from(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); agentPayoff = agentFee; // seller: sellerDeposit + price - protocolFee - agent fee - sellerPayoff = ethers.BigNumber.from(agentOffer.sellerDeposit) + sellerPayoff = BN(agentOffer.sellerDeposit) .add(agentOffer.price) .sub(agentOfferProtocolFee) .sub(agentFee) @@ -2927,7 +3108,7 @@ describe("IBosonFundsHandler", function () { blockNumber = tx.blockNumber; block = await ethers.provider.getBlock(blockNumber); disputedDate = block.timestamp.toString(); - timeout = ethers.BigNumber.from(disputedDate).add(resolutionPeriod).toString(); + timeout = BN(disputedDate).add(resolutionPeriod).toString(); await setNextBlockTimestamp(Number(timeout)); }); @@ -3002,17 +3183,14 @@ describe("IBosonFundsHandler", function () { // expected payoffs // buyer: (price + sellerDeposit)*buyerPercentage - buyerPayoff = ethers.BigNumber.from(offerToken.price) + buyerPayoff = BN(offerToken.price) .add(offerToken.sellerDeposit) .mul(buyerPercentBasisPoints) .div("10000") .toString(); // seller: (price + sellerDeposit)*(1-buyerPercentage) - sellerPayoff = ethers.BigNumber.from(offerToken.price) - .add(offerToken.sellerDeposit) - .sub(buyerPayoff) - .toString(); + sellerPayoff = BN(offerToken.price).add(offerToken.sellerDeposit).sub(buyerPayoff).toString(); // protocol: 0 protocolPayoff = 0; @@ -3089,7 +3267,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).toString() ); expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -3125,17 +3303,14 @@ describe("IBosonFundsHandler", function () { // expected payoffs // buyer: (price + sellerDeposit)*buyerPercentage - buyerPayoff = ethers.BigNumber.from(agentOffer.price) + buyerPayoff = BN(agentOffer.price) .add(agentOffer.sellerDeposit) .mul(buyerPercentBasisPoints) .div("10000") .toString(); // seller: (price + sellerDeposit)*(1-buyerPercentage) - sellerPayoff = ethers.BigNumber.from(agentOffer.price) - .add(agentOffer.sellerDeposit) - .sub(buyerPayoff) - .toString(); + sellerPayoff = BN(agentOffer.price).add(agentOffer.sellerDeposit).sub(buyerPayoff).toString(); // protocol: 0 protocolPayoff = 0; @@ -3193,7 +3368,7 @@ describe("IBosonFundsHandler", function () { // protocol: 0 // agent: 0 expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", ethers.BigNumber.from(sellerPayoff).toString()) + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) ); expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -3216,7 +3391,7 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // seller: sellerDeposit + price - protocolFee + buyerEscalationDeposit - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit) + sellerPayoff = BN(offerToken.sellerDeposit) .add(offerToken.price) .sub(offerTokenProtocolFee) .add(buyerEscalationDeposit) @@ -3284,7 +3459,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).toString() ); expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -3304,11 +3479,11 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // agentPayoff: agentFee - agentFee = ethers.BigNumber.from(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); agentPayoff = agentFee; // seller: sellerDeposit + price - protocolFee - agentFee + buyerEscalationDeposit - sellerPayoff = ethers.BigNumber.from(agentOffer.sellerDeposit) + sellerPayoff = BN(agentOffer.sellerDeposit) .add(agentOffer.price) .sub(agentOfferProtocolFee) .sub(agentFee) @@ -3369,7 +3544,7 @@ describe("IBosonFundsHandler", function () { // protocol: protocolFee // agent: agentFee expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", ethers.BigNumber.from(sellerPayoff).toString()) + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) ); expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); @@ -3391,7 +3566,7 @@ describe("IBosonFundsHandler", function () { // expected payoffs // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = ethers.BigNumber.from(offerToken.price) + buyerPayoff = BN(offerToken.price) .add(offerToken.sellerDeposit) .add(buyerEscalationDeposit) .mul(buyerPercentBasisPoints) @@ -3399,7 +3574,7 @@ describe("IBosonFundsHandler", function () { .toString(); // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = ethers.BigNumber.from(offerToken.price) + sellerPayoff = BN(offerToken.price) .add(offerToken.sellerDeposit) .add(buyerEscalationDeposit) .sub(buyerPayoff) @@ -3484,7 +3659,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).toString() ); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); @@ -3522,7 +3697,7 @@ describe("IBosonFundsHandler", function () { // expected payoffs // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = ethers.BigNumber.from(agentOffer.price) + buyerPayoff = BN(agentOffer.price) .add(agentOffer.sellerDeposit) .add(buyerEscalationDeposit) .mul(buyerPercentBasisPoints) @@ -3530,7 +3705,7 @@ describe("IBosonFundsHandler", function () { .toString(); // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = ethers.BigNumber.from(agentOffer.price) + sellerPayoff = BN(agentOffer.price) .add(agentOffer.sellerDeposit) .add(buyerEscalationDeposit) .sub(buyerPayoff) @@ -3597,7 +3772,7 @@ describe("IBosonFundsHandler", function () { // protocol: 0 // agent: 0 expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", ethers.BigNumber.from(sellerPayoff).toString()) + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) ); expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -3619,7 +3794,7 @@ describe("IBosonFundsHandler", function () { // expected payoffs // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = ethers.BigNumber.from(offerToken.price) + buyerPayoff = BN(offerToken.price) .add(offerToken.sellerDeposit) .add(buyerEscalationDeposit) .mul(buyerPercentBasisPoints) @@ -3627,7 +3802,7 @@ describe("IBosonFundsHandler", function () { .toString(); // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = ethers.BigNumber.from(offerToken.price) + sellerPayoff = BN(offerToken.price) .add(offerToken.sellerDeposit) .add(buyerEscalationDeposit) .sub(buyerPayoff) @@ -3686,7 +3861,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).toString() ); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); @@ -3724,13 +3899,13 @@ describe("IBosonFundsHandler", function () { blockNumber = tx.blockNumber; block = await ethers.provider.getBlock(blockNumber); disputedDate = block.timestamp.toString(); - timeout = ethers.BigNumber.from(disputedDate).add(resolutionPeriod).toString(); + timeout = BN(disputedDate).add(resolutionPeriod).toString(); buyerPercentBasisPoints = "5566"; // 55.66% // expected payoffs // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = ethers.BigNumber.from(agentOffer.price) + buyerPayoff = BN(agentOffer.price) .add(agentOffer.sellerDeposit) .add(buyerEscalationDeposit) .mul(buyerPercentBasisPoints) @@ -3738,7 +3913,7 @@ describe("IBosonFundsHandler", function () { .toString(); // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = ethers.BigNumber.from(agentOffer.price) + sellerPayoff = BN(agentOffer.price) .add(agentOffer.sellerDeposit) .add(buyerEscalationDeposit) .sub(buyerPayoff) @@ -3781,7 +3956,7 @@ describe("IBosonFundsHandler", function () { // protocol: 0 // agent: 0 expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", ethers.BigNumber.from(sellerPayoff).toString()) + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) ); expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -3802,7 +3977,7 @@ describe("IBosonFundsHandler", function () { beforeEach(async function () { // expected payoffs // buyer: price + buyerEscalationDeposit - buyerPayoff = ethers.BigNumber.from(offerToken.price).add(buyerEscalationDeposit).toString(); + buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); // seller: sellerDeposit sellerPayoff = offerToken.sellerDeposit; @@ -3866,7 +4041,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).toString() ); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); @@ -3902,7 +4077,7 @@ describe("IBosonFundsHandler", function () { // expected payoffs // buyer: price + buyerEscalationDeposit - buyerPayoff = ethers.BigNumber.from(offerToken.price).add(buyerEscalationDeposit).toString(); + buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); // seller: sellerDeposit sellerPayoff = offerToken.sellerDeposit; @@ -3952,7 +4127,7 @@ describe("IBosonFundsHandler", function () { // agent: 0 expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", ethers.BigNumber.from(sellerPayoff).toString()) + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) ); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); @@ -3973,7 +4148,7 @@ describe("IBosonFundsHandler", function () { beforeEach(async function () { // expected payoffs // buyer: price + buyerEscalationDeposit - buyerPayoff = ethers.BigNumber.from(offerToken.price).add(buyerEscalationDeposit).toString(); + buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); // seller: sellerDeposit sellerPayoff = offerToken.sellerDeposit; @@ -4043,7 +4218,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).toString() ); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); @@ -4079,7 +4254,7 @@ describe("IBosonFundsHandler", function () { // expected payoffs // buyer: price + buyerEscalationDeposit - buyerPayoff = ethers.BigNumber.from(offerToken.price).add(buyerEscalationDeposit).toString(); + buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); // seller: sellerDeposit sellerPayoff = offerToken.sellerDeposit; @@ -4122,7 +4297,7 @@ describe("IBosonFundsHandler", function () { // agent: 0 expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", ethers.BigNumber.from(sellerPayoff).toString()) + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) ); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); @@ -4148,10 +4323,7 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // seller: sellerDeposit + price - protocolFee - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .toString(); + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); }); it("Protocol fee for existing exchanges should be the same as at the offer creation", async function () { @@ -4218,11 +4390,11 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // agentPayoff: agentFee - agentFee = ethers.BigNumber.from(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); agentPayoff = agentFee; // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = ethers.BigNumber.from(agentOffer.sellerDeposit) + sellerPayoff = BN(agentOffer.sellerDeposit) .add(agentOffer.price) .sub(agentOfferProtocolFee) .sub(agentFee) diff --git a/test/protocol/OfferHandlerTest.js b/test/protocol/OfferHandlerTest.js index 31b8858c0..8fa0970ef 100644 --- a/test/protocol/OfferHandlerTest.js +++ b/test/protocol/OfferHandlerTest.js @@ -185,8 +185,8 @@ describe("IBosonOfferHandler", function () { expect(disputeResolver.isValid()).is.true; //Create DisputeResolverFee array so offer creation will succeed - DRFeeNative = "0"; - DRFeeToken = "0"; + DRFeeNative = "100"; + DRFeeToken = "50"; disputeResolverFees = [ new DisputeResolverFee(ethers.constants.AddressZero, "Native", DRFeeNative), new DisputeResolverFee(bosonToken.address, "Boson", DRFeeToken), @@ -1275,7 +1275,7 @@ describe("IBosonOfferHandler", function () { it("it's possible to reserve range even if somebody already committed to", async function () { // Deposit seller funds so the commit will succeed - const sellerPool = ethers.BigNumber.from(offer.sellerDeposit).mul(2); + const sellerPool = ethers.BigNumber.from(offer.sellerDeposit).add(DRFeeNative).mul(2); await fundsHandler .connect(assistant) .depositFunds(seller.id, ethers.constants.AddressZero, sellerPool, { value: sellerPool }); From 98ace089306ec2a20fe929838f8c576381896933 Mon Sep 17 00:00:00 2001 From: zajck Date: Fri, 28 Apr 2023 12:54:21 +0200 Subject: [PATCH 09/33] tests with mock mutualizer --- .../interfaces/events/IBosonOfferEvents.sol | 6 + .../handlers/IBosonOfferHandler.sol | 35 +- contracts/mock/MockDRFeeMutualizer.sol | 67 +++ .../protocol/facets/OfferHandlerFacet.sol | 54 +++ scripts/config/revert-reasons.js | 3 + scripts/domain/Agreement.js | 262 +++++++++++ test/domain/Agreement.js | 421 ++++++++++++++++++ test/protocol/FundsHandlerTest.js | 49 +- 8 files changed, 892 insertions(+), 5 deletions(-) create mode 100644 contracts/mock/MockDRFeeMutualizer.sol create mode 100644 scripts/domain/Agreement.js create mode 100644 test/domain/Agreement.js diff --git a/contracts/interfaces/events/IBosonOfferEvents.sol b/contracts/interfaces/events/IBosonOfferEvents.sol index c58283f94..99e76f446 100644 --- a/contracts/interfaces/events/IBosonOfferEvents.sol +++ b/contracts/interfaces/events/IBosonOfferEvents.sol @@ -35,4 +35,10 @@ interface IBosonOfferEvents { address owner, address indexed executedBy ); + event OfferMutualizerChanged( + uint256 indexed offerId, + uint256 indexed sellerId, + address indexed feeMutualizer, + address executedBy + ); } diff --git a/contracts/interfaces/handlers/IBosonOfferHandler.sol b/contracts/interfaces/handlers/IBosonOfferHandler.sol index 0bb781db9..57342940d 100644 --- a/contracts/interfaces/handlers/IBosonOfferHandler.sol +++ b/contracts/interfaces/handlers/IBosonOfferHandler.sol @@ -9,7 +9,7 @@ import { IBosonOfferEvents } from "../events/IBosonOfferEvents.sol"; * * @notice Handles creation, voiding, and querying of offers within the protocol. * - * The ERC-165 identifier for this interface is: 0xea09657d + * The ERC-165 identifier for this interface is: 0xebc1b5a3 */ interface IBosonOfferHandler is IBosonOfferEvents { /** @@ -190,6 +190,39 @@ interface IBosonOfferHandler is IBosonOfferEvents { */ function extendOfferBatch(uint256[] calldata _offerIds, uint256 _validUntilDate) external; + /** + * @notice Changes the mutualizer for a given offer. + * Existing exchanges are not affected. + * + * Emits an OfferMutualizerChanged event if successful. + * + * Reverts if: + * - The offers region of protocol is paused + * - Offer id is invalid + * - Caller is not the assistant of the offer + * + * @param _offerId - the id of the offer to void + * @param _feeMutualizer - the new mutualizer address + */ + function changeOfferMutualizer(uint256 _offerId, address _feeMutualizer) external; + + /** + * @notice Changes the mutualizers for a batch of offers. + * Existing exchanges are not affected. + * + * Emits an OfferMutualizerChanged event for every offer if successful. + * + * Reverts if, for any offer: + * - The offers region of protocol is paused + * - Number of offers exceeds maximum allowed number per batch + * - Offer id is invalid + * - Caller is not the assistant of the offer + * + * @param _offerIds - list of ids of offers to change the mutualizer for + * @param _feeMutualizer - the new mutualizers address + */ + function changeOfferMutualizerBatch(uint256[] calldata _offerIds, address _feeMutualizer) external; + /** * @notice Gets the details about a given offer. * diff --git a/contracts/mock/MockDRFeeMutualizer.sol b/contracts/mock/MockDRFeeMutualizer.sol new file mode 100644 index 000000000..76cfa2920 --- /dev/null +++ b/contracts/mock/MockDRFeeMutualizer.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.9; +import { IDRFeeMutualizer } from "../interfaces/clients/IDRFeeMutualizer.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title MockDRFeeMutualizer + * @notice Mock contract for testing purposes + * + */ +contract MockDRFeeMutualizer is IDRFeeMutualizer { + enum Mode { + Revert, + Decline, + SendLess + } + + Mode private mode; + + /** + * @notice set the desired outcome of `requestDRFee` function + */ + function setMode(Mode _mode) external { + mode = _mode; + } + + /** + * @notice Mock function that always returns true. + * + */ + function isSellerCovered(address, address, uint256, address, bytes calldata) external pure returns (bool) { + true; + } + + /** + * @notice Mock function that does not return expected value + * It either reverts, or returns false, or returns less than expected + */ + function requestDRFee( + address, + address _token, + uint256 _feeAmount, + bytes calldata + ) external returns (bool isCovered, uint256 uuid) { + if (mode == Mode.Revert) { + revert("MockDRFeeMutualizer: revert"); + } else if (mode == Mode.Decline) { + return (false, 0); + } else if (mode == Mode.SendLess) { + if (_token == address(0)) { + payable(msg.sender).transfer(_feeAmount - 1); + } else { + IERC20(_token).transfer(msg.sender, _feeAmount - 1); + } + return (true, 1); + } + } + + /** + * @notice Mock function that does not accept payment from the protocol. + */ + function returnDRFee(uint256 _uuid, uint256 _feeAmount, bytes calldata _context) external payable { + revert("MockDRFeeMutualizer: revert"); + } + + receive() external payable {} +} diff --git a/contracts/protocol/facets/OfferHandlerFacet.sol b/contracts/protocol/facets/OfferHandlerFacet.sol index 0b565fd3e..2124304f5 100644 --- a/contracts/protocol/facets/OfferHandlerFacet.sol +++ b/contracts/protocol/facets/OfferHandlerFacet.sol @@ -259,6 +259,60 @@ contract OfferHandlerFacet is IBosonOfferHandler, OfferBase { } } + /** + * @notice Changes the mutualizer for a given offer. + * Existing exchanges are not affected. + * + * Emits an OfferMutualizerChanged event if successful. + * + * Reverts if: + * - The offers region of protocol is paused + * - Offer id is invalid + * - Caller is not the assistant of the offer + * + * @param _offerId - the id of the offer to void + * @param _feeMutualizer - the new mutualizer address + */ + function changeOfferMutualizer( + uint256 _offerId, + address _feeMutualizer + ) public override offersNotPaused nonReentrant { + // Get offer, make sure the caller is the assistant + Offer storage offer = getValidOffer(_offerId); + + // Change the DR fee mutualizer + offer.feeMutualizer = _feeMutualizer; + + // Notify listeners of state change + emit OfferMutualizerChanged(_offerId, offer.sellerId, _feeMutualizer, msgSender()); + } + + /** + * @notice Changes the mutualizers for a batch of offers. + * Existing exchanges are not affected. + * + * Emits an OfferMutualizerChanged event for every offer if successful. + * + * Reverts if, for any offer: + * - The offers region of protocol is paused + * - Number of offers exceeds maximum allowed number per batch + * - Offer id is invalid + * - Caller is not the assistant of the offer + * + * @param _offerIds - list of ids of offers to change the mutualizer for + * @param _feeMutualizer - the new mutualizers address + */ + function changeOfferMutualizerBatch( + uint256[] calldata _offerIds, + address _feeMutualizer + ) external override offersNotPaused { + // limit maximum number of offers to avoid running into block gas limit in a loop + require(_offerIds.length <= protocolLimits().maxOffersPerBatch, TOO_MANY_OFFERS); + for (uint256 i = 0; i < _offerIds.length; i++) { + changeOfferMutualizer(_offerIds[i], _feeMutualizer); + } + } + /** * @notice Gets the details about a given offer. * diff --git a/scripts/config/revert-reasons.js b/scripts/config/revert-reasons.js index 12d022dd3..7e26a7103 100644 --- a/scripts/config/revert-reasons.js +++ b/scripts/config/revert-reasons.js @@ -154,6 +154,8 @@ exports.RevertReasons = { TOKEN_AMOUNT_MISMATCH: "Number of amounts should match number of tokens", NOTHING_TO_WITHDRAW: "Nothing to withdraw", NOT_AUTHORIZED: "Not authorized to withdraw", + DR_FEE_NOT_RECEIVED: "DR fee not received", + SELLER_NOT_COVERED: "Seller not covered", // Outside the protocol revert reasons ERC20_EXCEEDS_BALANCE: "ERC20: transfer amount exceeds balance", @@ -168,6 +170,7 @@ exports.RevertReasons = { SAFE_ERC20_LOW_LEVEL_CALL: "SafeERC20: low-level call failed", SAFE_ERC20_NOT_SUCCEEDED: "SafeERC20: ERC20 operation did not succeed", INITIALIZABLE_ALREADY_INITIALIZED: "Initializable: contract is already initialized", + MUTUALIZER_REVERT: "MockDRFeeMutualizer: revert", // Meta-Transactions related NONCE_USED_ALREADY: "Nonce used already", diff --git a/scripts/domain/Agreement.js b/scripts/domain/Agreement.js new file mode 100644 index 000000000..3f1e7d603 --- /dev/null +++ b/scripts/domain/Agreement.js @@ -0,0 +1,262 @@ +const { bigNumberIsValid, addressIsValid, booleanIsValid } = require("../util/validations.js"); + +/** + * DR Fee Mutualizer Entity: Agreement + * + * See: {DRFeeMutualizer.Agreement} + */ +class Agreement { + /* + struct Agreement { + address sellerAddress; + address token; + uint256 maxMutualizedAmountPerTransaction; + uint256 maxTotalMutualizedAmount; + uint256 premium; + uint128 startTimestamp; + uint128 endTimestamp; + bool refundOnCancel; + bool voided; + } + */ + + constructor( + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + voided + ) { + this.sellerAddress = sellerAddress; + this.token = token; + this.maxMutualizedAmountPerTransaction = maxMutualizedAmountPerTransaction; + this.maxTotalMutualizedAmount = maxTotalMutualizedAmount; + this.premium = premium; + this.startTimestamp = startTimestamp; + this.endTimestamp = endTimestamp; + this.refundOnCancel = refundOnCancel; + this.voided = voided; + } + + /** + * Get a new Agreement instance from a pojo representation + * @param o + * @returns {Agreement} + */ + static fromObject(o) { + const { + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + voided, + } = o; + + return new Agreement( + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + voided + ); + } + + /** + * Get a new Agreement instance from a returned struct representation + * @param struct + * @returns {*} + */ + static fromStruct(struct) { + let sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + voided; + + // destructure struct + [ + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + voided, + ] = struct; + + return Agreement.fromObject({ + sellerAddress, + token, + maxMutualizedAmountPerTransaction: maxMutualizedAmountPerTransaction.toString(), + maxTotalMutualizedAmount: maxTotalMutualizedAmount.toString(), + premium: premium.toString(), + startTimestamp: startTimestamp.toString(), + endTimestamp: endTimestamp.toString(), + refundOnCancel, + voided, + }); + } + + /** + * Get a database representation of this Agreement instance + * @returns {object} + */ + toObject() { + return JSON.parse(this.toString()); + } + + /** + * Get a string representation of this Agreement instance + * @returns {string} + */ + toString() { + return JSON.stringify(this); + } + + /** + * Get a struct representation of this Agreement instance + * @returns {string} + */ + toStruct() { + return [ + this.sellerAddress, + this.token, + this.maxMutualizedAmountPerTransaction, + this.maxTotalMutualizedAmount, + this.premium, + this.startTimestamp, + this.endTimestamp, + this.refundOnCancel, + this.voided, + ]; + } + + /** + * Clone this Agreement + * @returns {Agreement} + */ + clone() { + return Agreement.fromObject(this.toObject()); + } + + /** + * Is this Agreement instance's sellerAddress field valid? + * Must be a eip55 compliant Ethereum address + * @returns {boolean} + */ + sellerAddressIsValid() { + return addressIsValid(this.sellerAddress); + } + + /** + * Is this Agreement instance's token field valid? + * Must be a eip55 compliant Ethereum address + * @returns {boolean} + */ + tokenIsValid() { + return addressIsValid(this.token); + } + + /** + * Is this Agreement instance's maxMutualizedAmountPerTransaction field valid? + * Must be a string representation of a big number + * @returns {boolean} + */ + maxMutualizedAmountPerTransactionIsValid() { + return bigNumberIsValid(this.maxMutualizedAmountPerTransaction); + } + + /** + * Is this Agreement instance's maxTotalMutualizedAmount field valid? + * Must be a string representation of a big number + * @returns {boolean} + */ + maxTotalMutualizedAmountIsValid() { + return bigNumberIsValid(this.maxTotalMutualizedAmount); + } + + /** + * Is this Agreement instance's premium field valid? + * Must be a string representation of a big number + * @returns {boolean} + */ + premiumIsValid() { + return bigNumberIsValid(this.premium); + } + + /** + * Is this Agreement instance's startTimestamp field valid? + * Must be a string representation of a big number + * @returns {boolean} + */ + startTimestampIsValid() { + return bigNumberIsValid(this.startTimestamp); + } + + /** + * Is this Agreement instance's endTimestamp field valid? + * Must be a string representation of a big number + * + * @returns {boolean} + */ + endTimestampIsValid() { + return bigNumberIsValid(this.endTimestamp); + } + + /** + * Is this Agreement instance's refundOnCancel field valid? + * Always present, must be a boolean + * + * @returns {boolean} + */ + refundOnCancelIsValid() { + return booleanIsValid(this.refundOnCancel); + } + + /** + * Is this Agreement instance's voided field valid? + * @returns {boolean} + */ + voidedIsValid() { + return booleanIsValid(this.voided); + } + + /** + * Is this Agreement instance valid? + * @returns {boolean} + */ + isValid() { + return ( + this.sellerAddressIsValid() && + this.tokenIsValid() && + this.maxMutualizedAmountPerTransactionIsValid() && + this.maxTotalMutualizedAmountIsValid() && + this.premiumIsValid() && + this.startTimestampIsValid() && + this.endTimestampIsValid() && + this.refundOnCancelIsValid() && + this.voidedIsValid() + ); + } +} + +// Export +module.exports = Agreement; diff --git a/test/domain/Agreement.js b/test/domain/Agreement.js new file mode 100644 index 000000000..c476a9ca4 --- /dev/null +++ b/test/domain/Agreement.js @@ -0,0 +1,421 @@ +const { ethers } = require("hardhat"); +const { expect } = require("chai"); +const Agreement = require("../../scripts/domain/Agreement"); + +/** + * Test the Agreement domain entity + */ +describe("Agreement", function () { + // Suite-wide scope + let agreement, object, promoted, clone, dehydrated, rehydrated, key, value, struct; + let accounts; + let sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + voided; + + beforeEach(async function () { + // Get a list of accounts + accounts = await ethers.getSigners(); + + // Required constructor params + [sellerAddress, token] = accounts.map((a) => a.address); + maxMutualizedAmountPerTransaction = ethers.utils.parseUnits("1.5", "ether").toString(); + maxTotalMutualizedAmount = ethers.utils.parseUnits("0.25", "ether").toString(); + premium = ethers.utils.parseUnits("0.05", "ether").toString(); + startTimestamp = "123456789"; + endTimestamp = "987654321"; + refundOnCancel = true; + voided = false; + }); + + context("📋 Constructor", async function () { + it("Should allow creation of valid, fully populated Agreement instance", async function () { + // Create a valid agreement, then set fields in tests directly + agreement = new Agreement( + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + voided + ); + expect(agreement.sellerAddressIsValid()).is.true; + expect(agreement.tokenIsValid()).is.true; + expect(agreement.maxMutualizedAmountPerTransactionIsValid()).is.true; + expect(agreement.maxTotalMutualizedAmountIsValid()).is.true; + expect(agreement.premiumIsValid()).is.true; + expect(agreement.startTimestampIsValid()).is.true; + expect(agreement.endTimestampIsValid()).is.true; + expect(agreement.refundOnCancelIsValid()).is.true; + expect(agreement.voidedIsValid()).is.true; + expect(agreement.isValid()).is.true; + }); + }); + + context("📋 Field validations", async function () { + beforeEach(async function () { + // Create a valid agreement, then set fields in tests directly + agreement = new Agreement( + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + voided + ); + expect(agreement.isValid()).is.true; + }); + + it("Always present, sellerAddress must be a string representation of an EIP-55 compliant address", async function () { + // Invalid field value + agreement.sellerAddress = "0xASFADF"; + expect(agreement.sellerAddressIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.sellerAddress = "zedzdeadbaby"; + expect(agreement.sellerAddressIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Valid field value + agreement.sellerAddress = accounts[0].address; + expect(agreement.sellerAddressIsValid()).is.true; + expect(agreement.isValid()).is.true; + + // Valid field value + agreement.sellerAddress = "0xec2fd5bd6fc7b576dae82c0b9640969d8de501a2"; + expect(agreement.sellerAddressIsValid()).is.true; + expect(agreement.isValid()).is.true; + }); + + it("Always present, token must be a string representation of an EIP-55 compliant address", async function () { + // Invalid field value + agreement.token = "0xASFADF"; + expect(agreement.tokenIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.token = "zedzdeadbaby"; + expect(agreement.tokenIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Valid field value + agreement.token = accounts[0].address; + expect(agreement.tokenIsValid()).is.true; + expect(agreement.isValid()).is.true; + + // Valid field value + agreement.token = "0xec2fd5bd6fc7b576dae82c0b9640969d8de501a2"; + expect(agreement.tokenIsValid()).is.true; + expect(agreement.isValid()).is.true; + }); + + it("Always present, maxMutualizedAmountPerTransaction must be the string representation of a BigNumber", async function () { + // Invalid field value + agreement.maxMutualizedAmountPerTransaction = "zedzdeadbaby"; + expect(agreement.maxMutualizedAmountPerTransactionIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.maxMutualizedAmountPerTransaction = new Date(); + expect(agreement.maxMutualizedAmountPerTransactionIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.maxMutualizedAmountPerTransaction = 12; + expect(agreement.maxMutualizedAmountPerTransactionIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Valid field value + agreement.maxMutualizedAmountPerTransaction = "0"; + expect(agreement.maxMutualizedAmountPerTransactionIsValid()).is.true; + expect(agreement.isValid()).is.true; + + // Valid field value + agreement.maxMutualizedAmountPerTransaction = "126"; + expect(agreement.maxMutualizedAmountPerTransactionIsValid()).is.true; + expect(agreement.isValid()).is.true; + }); + + it("Always present, maxTotalMutualizedAmount must be the string representation of a BigNumber", async function () { + // Invalid field value + agreement.maxTotalMutualizedAmount = "zedzdeadbaby"; + expect(agreement.maxTotalMutualizedAmountIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.maxTotalMutualizedAmount = new Date(); + expect(agreement.maxTotalMutualizedAmountIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.maxTotalMutualizedAmount = 12; + expect(agreement.maxTotalMutualizedAmountIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Valid field value + agreement.maxTotalMutualizedAmount = "0"; + expect(agreement.maxTotalMutualizedAmountIsValid()).is.true; + expect(agreement.isValid()).is.true; + + // Valid field value + agreement.maxTotalMutualizedAmount = "126"; + expect(agreement.maxTotalMutualizedAmountIsValid()).is.true; + expect(agreement.isValid()).is.true; + }); + + it("Always present, premium must be the string representation of a BigNumber", async function () { + // Invalid field value + agreement.premium = "zedzdeadbaby"; + expect(agreement.premiumIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.premium = new Date(); + expect(agreement.premiumIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.premium = 12; + expect(agreement.premiumIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Valid field value + agreement.premium = "0"; + expect(agreement.premiumIsValid()).is.true; + expect(agreement.isValid()).is.true; + + // Valid field value + agreement.premium = "126"; + expect(agreement.premiumIsValid()).is.true; + expect(agreement.isValid()).is.true; + }); + + it("Always present, startTimestamp must be the string representation of a BigNumber", async function () { + // Invalid field value + agreement.startTimestamp = "zedzdeadbaby"; + expect(agreement.startTimestampIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.startTimestamp = new Date(); + expect(agreement.startTimestampIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.startTimestamp = 12; + expect(agreement.startTimestampIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Valid field value + agreement.startTimestamp = "0"; + expect(agreement.startTimestampIsValid()).is.true; + expect(agreement.isValid()).is.true; + + // Valid field value + agreement.startTimestamp = "126"; + expect(agreement.startTimestampIsValid()).is.true; + expect(agreement.isValid()).is.true; + }); + + it("Always present, endTimestamp must be the string representation of a BigNumber", async function () { + // Invalid field value + agreement.endTimestamp = "zedzdeadbaby"; + expect(agreement.endTimestampIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.endTimestamp = new Date(); + expect(agreement.endTimestampIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.endTimestamp = 12; + expect(agreement.endTimestampIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Valid field value + agreement.endTimestamp = "0"; + expect(agreement.endTimestampIsValid()).is.true; + expect(agreement.isValid()).is.true; + + // Valid field value + agreement.endTimestamp = "126"; + expect(agreement.endTimestampIsValid()).is.true; + expect(agreement.isValid()).is.true; + }); + + it("Always present, refundOnCancel must be a boolean", async function () { + // Invalid field value + agreement.refundOnCancel = 12; + expect(agreement.refundOnCancelIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.refundOnCancel = "zedzdeadbaby"; + expect(agreement.refundOnCancelIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Valid field value + agreement.refundOnCancel = false; + expect(agreement.refundOnCancelIsValid()).is.true; + expect(agreement.isValid()).is.true; + + // Valid field value + agreement.refundOnCancel = true; + expect(agreement.refundOnCancelIsValid()).is.true; + expect(agreement.isValid()).is.true; + }); + + it("Always present, voided must be a boolean", async function () { + // Invalid field value + agreement.voided = 12; + expect(agreement.voidedIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Invalid field value + agreement.voided = "zedzdeadbaby"; + expect(agreement.voidedIsValid()).is.false; + expect(agreement.isValid()).is.false; + + // Valid field value + agreement.voided = false; + expect(agreement.voidedIsValid()).is.true; + expect(agreement.isValid()).is.true; + + // Valid field value + agreement.voided = true; + expect(agreement.voidedIsValid()).is.true; + expect(agreement.isValid()).is.true; + }); + }); + + context("📋 Utility functions", async function () { + beforeEach(async function () { + // Required constructor params + [sellerAddress, token] = accounts.map((a) => a.address); + + // Create a valid agreement, then set fields in tests directly + agreement = new Agreement( + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + voided + ); + expect(agreement.isValid()).is.true; + + // Create plain object + object = { + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + voided, + }; + }); + + context("👉 Static", async function () { + it("Agreement.fromObject() should return a Agreement instance with the same values as the given plain object", async function () { + // Promote to instance + promoted = Agreement.fromObject(object); + + // Is a Agreement instance + expect(promoted instanceof Agreement).is.true; + + // Key values all match + for ([key, value] of Object.entries(agreement)) { + expect(JSON.stringify(promoted[key]) === JSON.stringify(value)).is.true; + } + }); + + it("Agreement.fromStruct() should return a Agreement instance with the same values as the given struct", async function () { + struct = [ + agreement.sellerAddress, + agreement.token, + agreement.maxMutualizedAmountPerTransaction, + agreement.maxTotalMutualizedAmount, + agreement.premium, + agreement.startTimestamp, + agreement.endTimestamp, + agreement.refundOnCancel, + agreement.voided, + ]; + + // Get struct + agreement = Agreement.fromStruct(struct); + + // Ensure it marshals back to a valid agreement + expect(agreement.isValid()).to.be.true; + }); + }); + + context("👉 Instance", async function () { + it("instance.toString() should return a JSON string representation of the Agreement instance", async function () { + dehydrated = agreement.toString(); + rehydrated = JSON.parse(dehydrated); + + for ([key, value] of Object.entries(agreement)) { + expect(JSON.stringify(rehydrated[key]) === JSON.stringify(value)).is.true; + } + }); + + it("instance.toObject() should return a plain object representation of the Agreement instance", async function () { + // Get plain object + object = agreement.toObject(); + + // Not an Agreement instance + expect(object instanceof Agreement).is.false; + + // Key values all match + for ([key, value] of Object.entries(agreement)) { + expect(JSON.stringify(object[key]) === JSON.stringify(value)).is.true; + } + }); + + it("Agreement.toStruct() should return a struct representation of the Agreement instance", async function () { + // Get struct from agreement + struct = agreement.toStruct(); + + // Marshal back to an agreement instance + agreement = Agreement.fromStruct(struct); + + // Ensure it marshals back to a valid agreement + expect(agreement.isValid()).to.be.true; + }); + + it("instance.clone() should return another Agreement instance with the same property values", async function () { + // Get plain object + clone = agreement.clone(); + + // Is an Agreement instance + expect(clone instanceof Agreement).is.true; + + // Key values all match + for ([key, value] of Object.entries(agreement)) { + expect(JSON.stringify(clone[key]) === JSON.stringify(value)).is.true; + } + }); + }); + }); +}); diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index bd57cf25d..e9693d19f 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -2229,12 +2229,53 @@ describe("IBosonFundsHandler", function () { }); context("💔 Revert Reasons", async function () { - it.skip("Mutualizer contract declines the request", async function () { - // ToDo: implement when mutualizer can be updated in the protocol, since it will be easier to mock this behavior + const Mode = { Revert: 0, Decline: 1, SendLess: 2 }; + let mockMutualizer; + beforeEach(async function () { + // Deploy mock mutualizer and set it to the offer + const mockMutualizerFactory = await ethers.getContractFactory("MockDRFeeMutualizer"); + mockMutualizer = await mockMutualizerFactory.deploy(); + + await offerHandler.connect(assistant).changeOfferMutualizer(offerToken.id, mockMutualizer.address); + }); + + it("Mutualizer contract reverts on the call", async function () { + await mockMutualizer.setMode(Mode.Revert); + + // Attempt to commit to offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.MUTUALIZER_REVERT + ); + }); + + it("Mutualizer contract declines the request", async function () { + await mockMutualizer.setMode(Mode.Decline); + + // Attempt to commit to offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.SELLER_NOT_COVERED + ); + }); + + it("Mutualizer contract sends less than requested - ERC20", async function () { + await mockMutualizer.setMode(Mode.SendLess); + await mockToken.mint(mockMutualizer.address, DRFeeToken); + + // Attempt to commit to offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.DR_FEE_NOT_RECEIVED + ); }); - it.skip("Mutualizer contract sends less than requested", async function () { - // ToDo: implement when mutualizer can be updated in the protocol, since it will be easier to mock this behavior + it("Mutualizer contract sends less than requested - native", async function () { + await offerHandler.connect(assistant).changeOfferMutualizer(offerNative.id, mockMutualizer.address); + await mockMutualizer.setMode(Mode.SendLess); + await rando.sendTransaction({ to: mockMutualizer.address, value: DRFeeNative }); + + // Attempt to commit to offer, expecting revert + await expect( + exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }) + ).to.revertedWith(RevertReasons.DR_FEE_NOT_RECEIVED); }); }); }); From 66f2af0774ebf5dcbedd2afc3fe6c1ba536d101d Mon Sep 17 00:00:00 2001 From: zajck Date: Tue, 9 May 2023 08:42:46 +0200 Subject: [PATCH 10/33] release funds [no dispute] --- test/protocol/FundsHandlerTest.js | 3018 +++++++++++++++-------------- 1 file changed, 1595 insertions(+), 1423 deletions(-) diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index e9693d19f..2c85a9308 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -53,6 +53,7 @@ describe("IBosonFundsHandler", function () { clerkDR, treasuryDR, other, + mutualizerOwner, protocolTreasury; let erc165, accessController, @@ -73,13 +74,24 @@ describe("IBosonFundsHandler", function () { let resolutionPeriod, offerDurations; let protocolFeePercentage, buyerEscalationDepositPercentage; let block, blockNumber; - let protocolId, exchangeId, buyerId, randoBuyerId, sellerPayoff, buyerPayoff, protocolPayoff; + let protocolId, + exchangeId, + buyerId, + randoBuyerId, + sellerPayoff, + buyerPayoff, + protocolPayoff, + disputeResolverPayoff, + mutualizerPayoff; let sellersAvailableFunds, buyerAvailableFunds, protocolAvailableFunds, expectedSellerAvailableFunds, expectedBuyerAvailableFunds, - expectedProtocolAvailableFunds; + expectedProtocolAvailableFunds, + expectedAgentAvailableFunds, + expectedDRAvailableFunds; + let mutualizerTokenBalanceBefore, mutualizerTokenBalanceAfter; let tokenListSeller, tokenListBuyer, tokenAmountsSeller, tokenAmountsBuyer, tokenList, tokenAmounts; let tx, txReceipt, txCost, event; let disputeResolverFees, disputeResolver, disputeResolverId; @@ -95,11 +107,12 @@ describe("IBosonFundsHandler", function () { agentPayoff, agentOffer, agentOfferProtocolFee, - expectedAgentAvailableFunds, - agentAvailableFunds; + agentAvailableFunds, + DRAvailableFunds; let DRFeeToken, DRFeeNative, buyerEscalationDeposit; let protocolDiamondAddress; let snapshotId; + let mutualizer; before(async function () { accountId.next(true); @@ -120,7 +133,7 @@ describe("IBosonFundsHandler", function () { }; ({ - signers: [pauser, admin, treasury, rando, buyer, feeCollector, adminDR, treasuryDR, other], + signers: [pauser, admin, treasury, rando, buyer, feeCollector, adminDR, treasuryDR, other, mutualizerOwner], contractInstances: { erc165, accountHandler, @@ -145,6 +158,10 @@ describe("IBosonFundsHandler", function () { // Deploy the mock token [mockToken] = await deployMockTokens(["Foreign20"]); + // Deploy mutualizer + const mutualizerFactory = await ethers.getContractFactory("DRFeeMutualizer"); + mutualizer = await mutualizerFactory.connect(mutualizerOwner).deploy(fundsHandler.address); + // Get snapshot id snapshotId = await getSnapshot(); }); @@ -1508,7 +1525,6 @@ describe("IBosonFundsHandler", function () { // Agents // Create a valid agent, - agentId = "3"; agentFeePercentage = "500"; //5% agent = mockAgent(other.address); @@ -2056,14 +2072,7 @@ describe("IBosonFundsHandler", function () { }); context("External mutualizer", async function () { - let mutualizer; - beforeEach(async function () { - // Deploy mutualizer - const poolOwner = rando; - const mutualizerFactory = await ethers.getContractFactory("DRFeeMutualizer"); - mutualizer = await mutualizerFactory.connect(poolOwner).deploy(fundsHandler.address); - offerNative.feeMutualizer = offerToken.feeMutualizer = mutualizer.address; // Create both offers @@ -2079,14 +2088,14 @@ describe("IBosonFundsHandler", function () { // Seller must deposit enough to cover DR fees const poolToken = BN(DRFeeToken).mul(2); const poolNative = BN(DRFeeNative).mul(2); - await mockToken.mint(poolOwner.address, poolToken); + await mockToken.mint(mutualizerOwner.address, poolToken); // approve protocol to transfer the tokens - await mockToken.connect(poolOwner).approve(mutualizer.address, poolToken); + await mockToken.connect(mutualizerOwner).approve(mutualizer.address, poolToken); // deposit to mutualizer - await mutualizer.connect(poolOwner).deposit(mockToken.address, poolToken); - await mutualizer.connect(poolOwner).deposit(ethers.constants.AddressZero, poolNative, { + await mutualizer.connect(mutualizerOwner).deposit(mockToken.address, poolToken); + await mutualizer.connect(mutualizerOwner).deposit(ethers.constants.AddressZero, poolNative, { value: poolNative, }); @@ -2107,8 +2116,8 @@ describe("IBosonFundsHandler", function () { const agreementNative = agreementToken.clone(); agreementNative.token = ethers.constants.AddressZero; await Promise.all([ - mutualizer.connect(poolOwner).newAgreement(agreementToken), - mutualizer.connect(poolOwner).newAgreement(agreementNative), + mutualizer.connect(mutualizerOwner).newAgreement(agreementToken), + mutualizer.connect(mutualizerOwner).newAgreement(agreementNative), ]); // Confirm agreements @@ -2281,171 +2290,142 @@ describe("IBosonFundsHandler", function () { }); }); - context("👉 releaseFunds()", async function () { - beforeEach(async function () { - // ids - protocolId = "0"; - buyerId = "4"; - exchangeId = "1"; - - // commit to offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - }); + let DRFeeToSeller, DRFeeToMutualizer; - context("Final state COMPLETED", async function () { + ["self-mutualized", "external-mutualized"].forEach((mutualizationType) => { + context(`👉 releaseFunds() [${mutualizationType}]`, async function () { beforeEach(async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // seller: sellerDeposit + price - protocolFee - sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); - - // protocol: protocolFee - protocolPayoff = offerTokenProtocolFee; - }); - - it("should emit a FundsReleased event", async function () { - // Complete the exchange, expecting event - const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + // ids + protocolId = "0"; + buyerId = "4"; + exchangeId = "1"; - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + // Amounts that are returned if DR is not involved + if (mutualizationType === "self-mutualized") { + DRFeeToSeller = DRFeeToken; + DRFeeToMutualizer = 0; + offerToken.feeMutualizer = ethers.constants.AddressZero; - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); - }); + // Seller must deposit enough to cover DR fees + const sellerPoolToken = BN(DRFeeToken).mul(2); + await mockToken.mint(assistant.address, sellerPoolToken); - it("should update state", async function () { - // commit again, so seller has nothing in available funds - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + // approve protocol to transfer the tokens + await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerPoolToken); - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + // deposit to seller's pool + await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerPoolToken); + } else { + DRFeeToSeller = 0; + DRFeeToMutualizer = DRFeeToken; + offerToken.feeMutualizer = mutualizer.address; - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // Seller must deposit enough to cover DR fees + const poolToken = BN(DRFeeToken).mul(2); + await mockToken.mint(mutualizerOwner.address, poolToken); - // Complete the exchange so the funds are released - await exchangeHandler.connect(buyer).completeExchange(exchangeId); + // approve protocol to transfer the tokens + await mockToken.connect(mutualizerOwner).approve(mutualizer.address, poolToken); - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocolFee - agentFee - // protocol: protocolFee - // agent: 0 - expectedSellerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", sellerPayoff)); - expectedProtocolAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", offerTokenProtocolFee)); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // deposit to mutualizer + await mutualizer.connect(mutualizerOwner).deposit(mockToken.address, poolToken); - // complete another exchange so we test funds are only updated, no new entry is created - await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); - await exchangeHandler.connect(buyer).completeExchange(exchangeId); + // Create new agreement + const startTimestamp = BN(Date.now()).div(1000); // valid from now + const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days + const agreementToken = new Agreement( + assistant.address, + mockToken.address, + ethers.utils.parseUnits("1", "ether"), + ethers.utils.parseUnits("1", "ether"), + "0", + startTimestamp.toString(), + endTimestamp.toString(), + false, + false + ); + await mutualizer.connect(mutualizerOwner).newAgreement(agreementToken); + const agreementIdToken = "1"; + await mutualizer.connect(assistant).payPremium(agreementIdToken); + } - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expectedSellerAvailableFunds.funds[1] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerPayoff).mul(2).toString() - ); - expectedProtocolAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(protocolPayoff).mul(2).toString() - ); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // create offer + offerToken.id = "1"; + await offerHandler + .connect(assistant) + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId), + // commit to offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); }); - context("Offer has an agent", async function () { + context("Final state COMPLETED", async function () { beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // succesfully redeem exchange - exchangeId = "2"; + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // expected payoffs // buyer: 0 buyerPayoff = 0; - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) + // seller: sellerDeposit + price - protocolFee + (if self-mutualized: DRFeeToSeller) + sellerPayoff = BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .add(DRFeeToSeller) .toString(); // protocol: protocolFee - protocolPayoff = agentOfferProtocolFee; + protocolPayoff = offerTokenProtocolFee; + + // mutualizer: 0 or DRFee + mutualizerPayoff = DRFeeToMutualizer; + + // DR: 0 + disputeResolverPayoff = 0; }); it("should emit a FundsReleased event", async function () { // Complete the exchange, expecting event const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - // Complete the exchange, expecting event await expect(tx) .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); await expect(tx) .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); + .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + buyer.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { + // commit again, so seller has nothing in available funds + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + // Read on chain state sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ @@ -2454,10 +2434,12 @@ describe("IBosonFundsHandler", function () { expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Complete the exchange so the funds are released await exchangeHandler.connect(buyer).completeExchange(exchangeId); @@ -2466,145 +2448,184 @@ describe("IBosonFundsHandler", function () { // buyer: 0 // seller: sellerDeposit + price - protocolFee - agentFee // protocol: protocolFee - // agent: agentFee + // agent: 0 expectedSellerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", sellerPayoff)); - expectedProtocolAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentOfferProtocolFee)); - expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); + expectedProtocolAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", offerTokenProtocolFee)); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); + + // complete another exchange so we test funds are only updated, no new entry is created + await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); + await exchangeHandler.connect(buyer).completeExchange(exchangeId); + + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + expectedSellerAvailableFunds.funds[1] = new Funds( + mockToken.address, + "Foreign20", + BN(sellerPayoff).mul(2).toString() + ); + expectedProtocolAvailableFunds.funds[0] = new Funds( + mockToken.address, + "Foreign20", + BN(protocolPayoff).mul(2).toString() + ); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedAgentAvailableFunds); }); - }); - }); - context("Final state REVOKED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: sellerDeposit + price - buyerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).toString(); + context("Offer has an agent", async function () { + beforeEach(async function () { + // Create Agent offer + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - // seller: 0 - sellerPayoff = 0; + // Commit to Offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - // protocol: 0 - protocolPayoff = 0; - }); + // succesfully redeem exchange + exchangeId = "2"; + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - it("should emit a FundsReleased event", async function () { - // Revoke the voucher, expecting event - await expect(exchangeHandler.connect(assistant).revokeVoucher(exchangeId)) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); - }); + // expected payoffs + // buyer: 0 + buyerPayoff = 0; - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + // agentPayoff: agentFee + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentPayoff = agentFee; - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Revoke the voucher so the funds are released - await exchangeHandler.connect(assistant).revokeVoucher(exchangeId); - - // Available funds should be increased for - // buyer: sellerDeposit + price - // seller: 0 - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Test that if buyer has some funds available, and gets more, the funds are only updated - // Commit again - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + // seller: sellerDeposit + price - protocolFee - agentFee + sellerPayoff = BN(agentOffer.sellerDeposit) + .add(agentOffer.price) + .sub(agentOfferProtocolFee) + .sub(agentFee) + .toString(); - // Revoke another voucher - await exchangeHandler.connect(assistant).revokeVoucher(++exchangeId); + // protocol: protocolFee + protocolPayoff = agentOfferProtocolFee; + }); - // Available funds should be increased for - // buyer: sellerDeposit + price - // seller: 0; but during the commitToOffer, sellerDeposit is encumbered - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(buyerPayoff).mul(2).toString() - ); - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); + it("should emit a FundsReleased event", async function () { + // Complete the exchange, expecting event + const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + // Complete the exchange, expecting event + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); - // top up seller's and buyer's account - await mockToken.mint(assistant.address, `${2 * sellerDeposit}`); - await mockToken.mint(buyer.address, `${2 * price}`); + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); - // approve protocol to transfer the tokens - await mockToken.connect(assistant).approve(protocolDiamondAddress, `${2 * sellerDeposit}`); - await mockToken.connect(buyer).approve(protocolDiamondAddress, `${2 * price}`); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, agent.Id, agentOffer.exchangeToken, agentPayoff, buyer.address); + }); - // deposit to seller's pool - await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, `${2 * sellerDeposit}`); + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.Id)); - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + + // Complete the exchange so the funds are released + await exchangeHandler.connect(buyer).completeExchange(exchangeId); + + // Available funds should be increased for + // buyer: 0 + // seller: sellerDeposit + price - protocolFee - agentFee + // protocol: protocolFee + // agent: agentFee + expectedSellerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", sellerPayoff)); + expectedProtocolAvailableFunds.funds.push( + new Funds(mockToken.address, "Foreign20", agentOfferProtocolFee) + ); + expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.Id)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); + }); + }); + context("Final state REVOKED", async function () { + beforeEach(async function () { // expected payoffs // buyer: sellerDeposit + price - buyerPayoff = BN(agentOffer.sellerDeposit).add(agentOffer.price).toString(); + buyerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).toString(); - // seller: 0 - sellerPayoff = 0; + // seller: 0 or DRFee is self-mutualized + sellerPayoff = DRFeeToSeller; // protocol: 0 protocolPayoff = 0; - // agent: 0 - agentPayoff = 0; + // mutualizer: 0 or DRFee + mutualizerPayoff = DRFeeToMutualizer; + + // DR: 0 + disputeResolverPayoff = 0; + }); + + it("should emit a FundsReleased event", async function () { + // Revoke the voucher, expecting event + const tx = await exchangeHandler.connect(assistant).revokeVoucher(exchangeId); + + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); - exchangeId = "2"; + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + assistant.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { @@ -2613,19 +2634,23 @@ describe("IBosonFundsHandler", function () { buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", `${2 * sellerDeposit}`), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(BN(DRFeeToSeller)).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Revoke the voucher so the funds are released await exchangeHandler.connect(assistant).revokeVoucher(exchangeId); @@ -2636,18 +2661,26 @@ describe("IBosonFundsHandler", function () { // protocol: 0 // agent: 0 expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); + expectedSellerAvailableFunds = new FundsList([ + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(BN(DRFeeToSeller).mul(2)).toString()), + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); // Test that if buyer has some funds available, and gets more, the funds are only updated // Commit again - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); // Revoke another voucher await exchangeHandler.connect(assistant).revokeVoucher(++exchangeId); @@ -2663,127 +2696,180 @@ describe("IBosonFundsHandler", function () { BN(buyerPayoff).mul(2).toString() ); expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", `${sellerDeposit}`), + ...(DRFeeToSeller == 0 + ? [] + : [new Funds(mockToken.address, "Foreign20", BN(DRFeeToSeller).mul(2).toString())]), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedAgentAvailableFunds); }); - }); - }); - context("Final state CANCELED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: price - buyerCancelPenalty - buyerPayoff = BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(); + context("Offer has an agent", async function () { + beforeEach(async function () { + // Create Agent offer + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - // seller: sellerDeposit + buyerCancelPenalty - sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.buyerCancelPenalty).toString(); + // top up seller's and buyer's account + await mockToken.mint(assistant.address, `${2 * sellerDeposit}`); + await mockToken.mint(buyer.address, `${2 * price}`); - // protocol: 0 - protocolPayoff = 0; - }); + // approve protocol to transfer the tokens + await mockToken.connect(assistant).approve(protocolDiamondAddress, `${2 * sellerDeposit}`); + await mockToken.connect(buyer).approve(protocolDiamondAddress, `${2 * price}`); - it("should emit a FundsReleased event", async function () { - // Cancel the voucher, expecting event - const tx = await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + // deposit to seller's pool + await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, `${2 * sellerDeposit}`); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, buyer.address); + // Commit to Offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); - }); + // expected payoffs + // buyer: sellerDeposit + price + buyerPayoff = BN(agentOffer.sellerDeposit).add(agentOffer.price).toString(); - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + // seller: 0 + sellerPayoff = 0; - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // protocol: 0 + protocolPayoff = 0; - // Cancel the voucher, so the funds are released - await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); + // agent: 0 + agentPayoff = 0; - // Available funds should be increased for - // buyer: price - buyerCancelPenalty - // seller: sellerDeposit + buyerCancelPenalty; note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() - ); - expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); + exchangeId = "2"; + }); - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - // top up seller's and buyer's account - await mockToken.mint(assistant.address, `${2 * sellerDeposit}`); - await mockToken.mint(buyer.address, `${2 * price}`); + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(mockToken.address, "Foreign20", `${2 * sellerDeposit}`), + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // approve protocol to transfer the tokens - await mockToken.connect(assistant).approve(protocolDiamondAddress, `${2 * sellerDeposit}`); - await mockToken.connect(buyer).approve(protocolDiamondAddress, `${2 * price}`); + // Revoke the voucher so the funds are released + await exchangeHandler.connect(assistant).revokeVoucher(exchangeId); - // deposit to seller's pool - await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, `${sellerDeposit}`); + // Available funds should be increased for + // buyer: sellerDeposit + price + // seller: 0 + // protocol: 0 + // agent: 0 + expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + // Test that if buyer has some funds available, and gets more, the funds are only updated + // Commit again + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + + // Revoke another voucher + await exchangeHandler.connect(assistant).revokeVoucher(++exchangeId); + + // Available funds should be increased for + // buyer: sellerDeposit + price + // seller: 0; but during the commitToOffer, sellerDeposit is encumbered + // protocol: 0 + // agent: 0 + expectedBuyerAvailableFunds.funds[0] = new Funds( + mockToken.address, + "Foreign20", + BN(buyerPayoff).mul(2).toString() + ); + expectedSellerAvailableFunds = new FundsList([ + new Funds(mockToken.address, "Foreign20", `${sellerDeposit}`), + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); + }); + }); + context("Final state CANCELED", async function () { + beforeEach(async function () { // expected payoffs // buyer: price - buyerCancelPenalty - buyerPayoff = BN(agentOffer.price).sub(agentOffer.buyerCancelPenalty).toString(); + buyerPayoff = BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(); - // seller: sellerDeposit + buyerCancelPenalty - sellerPayoff = BN(agentOffer.sellerDeposit).add(agentOffer.buyerCancelPenalty).toString(); + // seller: sellerDeposit + buyerCancelPenalty + (if self-mutualized: DRFeeToSeller) + sellerPayoff = BN(offerToken.sellerDeposit) + .add(offerToken.buyerCancelPenalty) + .add(DRFeeToSeller) + .toString(); // protocol: 0 protocolPayoff = 0; - // agent: 0 - agentPayoff = 0; + // mutualizer: 0 or DRFee + mutualizerPayoff = DRFeeToMutualizer; + + // DR: 0 + disputeResolverPayoff = 0; + }); + + it("should emit a FundsReleased event", async function () { + // Cancel the voucher, expecting event + const tx = await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, buyer.address); + + await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); - exchangeId = "2"; + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + buyer.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { @@ -2791,20 +2877,24 @@ describe("IBosonFundsHandler", function () { sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(BN(DRFeeToSeller)).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Cancel the voucher, so the funds are released await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); @@ -2817,7 +2907,7 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() ); expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -2829,140 +2919,119 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); }); - }); - }); - context("Final state DISPUTED", async function () { - beforeEach(async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + context("Offer has an agent", async function () { + beforeEach(async function () { + // Create Agent offer + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // top up seller's and buyer's account + await mockToken.mint(assistant.address, `${2 * sellerDeposit}`); + await mockToken.mint(buyer.address, `${2 * price}`); - // raise the dispute - tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); + // approve protocol to transfer the tokens + await mockToken.connect(assistant).approve(protocolDiamondAddress, `${2 * sellerDeposit}`); + await mockToken.connect(buyer).approve(protocolDiamondAddress, `${2 * price}`); - // Get the block timestamp of the confirmed tx and set disputedDate - blockNumber = tx.blockNumber; - block = await ethers.provider.getBlock(blockNumber); - disputedDate = block.timestamp.toString(); - timeout = BN(disputedDate).add(resolutionPeriod).toString(); - }); + // deposit to seller's pool + await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, `${sellerDeposit}`); - context("Final state DISPUTED - RETRACTED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; + // Commit to Offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - // seller: sellerDeposit + price - protocolFee - sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); + // expected payoffs + // buyer: price - buyerCancelPenalty + buyerPayoff = BN(agentOffer.price).sub(agentOffer.buyerCancelPenalty).toString(); - // protocol: 0 - protocolPayoff = offerTokenProtocolFee; - }); + // seller: sellerDeposit + buyerCancelPenalty + sellerPayoff = BN(agentOffer.sellerDeposit).add(agentOffer.buyerCancelPenalty).toString(); - it("should emit a FundsReleased event", async function () { - // Retract from the dispute, expecting event - const tx = await disputeHandler.connect(buyer).retractDispute(exchangeId); + // protocol: 0 + protocolPayoff = 0; - await expect(tx) - .to.emit(disputeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); + // agent: 0 + agentPayoff = 0; - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + exchangeId = "2"; + }); - //check that FundsReleased event was NOT emitted with buyer Id - const txReceipt = await tx.wait(); - const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ - exchangeId, - buyerId, - offerToken.exchangeToken, - buyerPayoff, - buyer.address, - ]); - expect(match).to.be.false; + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + + // Cancel the voucher, so the funds are released + await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); + + // Available funds should be increased for + // buyer: price - buyerCancelPenalty + // seller: sellerDeposit + buyerCancelPenalty; note that seller has sellerDeposit in availableFunds from before + // protocol: 0 + // agent: 0 + expectedSellerAvailableFunds.funds[0] = new Funds( + mockToken.address, + "Foreign20", + BN(sellerDeposit).add(sellerPayoff).toString() + ); + expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); }); + }); - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + context("Final state DISPUTED", async function () { + beforeEach(async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // succesfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // Retract from the dispute, so the funds are released - await disputeHandler.connect(buyer).retractDispute(exchangeId); + // raise the dispute + tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocol fee; note that seller has sellerDeposit in availableFunds from before - // protocol: protocolFee - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() - ); - expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // Get the block timestamp of the confirmed tx and set disputedDate + blockNumber = tx.blockNumber; + block = await ethers.provider.getBlock(blockNumber); + disputedDate = block.timestamp.toString(); + timeout = BN(disputedDate).add(resolutionPeriod).toString(); }); - context("Offer has an agent", async function () { + context("Final state DISPUTED - RETRACTED", async function () { beforeEach(async function () { // expected payoffs // buyer: 0 buyerPayoff = 0; - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .toString(); + // seller: sellerDeposit + price - protocolFee + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); // protocol: 0 - protocolPayoff = agentOfferProtocolFee; - - // Exchange id - exchangeId = "2"; - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); + protocolPayoff = offerTokenProtocolFee; }); it("should emit a FundsReleased event", async function () { @@ -2977,9 +3046,16 @@ describe("IBosonFundsHandler", function () { .to.emit(disputeHandler, "FundsReleased") .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + //check that FundsReleased event was NOT emitted with buyer Id + const txReceipt = await tx.wait(); + const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ + exchangeId, + buyerId, + offerToken.exchangeToken, + buyerPayoff, + buyer.address, + ]); + expect(match).to.be.false; }); it("should update state", async function () { @@ -2991,6 +3067,7 @@ describe("IBosonFundsHandler", function () { // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ + new Funds(mockToken.address, "Foreign20", sellerDeposit), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); @@ -3006,14 +3083,15 @@ describe("IBosonFundsHandler", function () { // Available funds should be increased for // buyer: 0 - // seller: sellerDeposit + price - protocol fee - agentFee; + // seller: sellerDeposit + price - protocol fee; note that seller has sellerDeposit in availableFunds from before // protocol: protocolFee - // agent: agentFee - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + // agent: 0 + expectedSellerAvailableFunds.funds[0] = new Funds( + mockToken.address, + "Foreign20", + BN(sellerDeposit).add(sellerPayoff).toString() ); expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); @@ -3023,538 +3101,138 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); }); - }); - }); - - context("Final state DISPUTED - RETRACTED via expireDispute", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - // seller: sellerDeposit + price - protocolFee - sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); + context("Offer has an agent", async function () { + beforeEach(async function () { + // expected payoffs + // buyer: 0 + buyerPayoff = 0; - // protocol: protocolFee - protocolPayoff = offerTokenProtocolFee; + // agentPayoff: agentFee + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentPayoff = agentFee; - await setNextBlockTimestamp(Number(timeout)); - }); + // seller: sellerDeposit + price - protocolFee - agentFee + sellerPayoff = BN(agentOffer.sellerDeposit) + .add(agentOffer.price) + .sub(agentOfferProtocolFee) + .sub(agentFee) + .toString(); - it("should emit a FundsReleased event", async function () { - // Expire the dispute, expecting event - const tx = await disputeHandler.connect(rando).expireDispute(exchangeId); - await expect(tx) - .to.emit(disputeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, rando.address); + // protocol: 0 + protocolPayoff = agentOfferProtocolFee; - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, rando.address); - - //check that FundsReleased event was NOT emitted with buyer Id - const txReceipt = await tx.wait(); - const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ - exchangeId, - buyerId, - offerToken.exchangeToken, - buyerPayoff, - rando.address, - ]); - expect(match).to.be.false; - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Expire the dispute, so the funds are released - await disputeHandler.connect(rando).expireDispute(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocol fee; note that seller has sellerDeposit in availableFunds from before - // protocol: protocolFee - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() - ); - expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agent fee - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .toString(); - - // protocol: protocolFee - protocolPayoff = agentOfferProtocolFee; - - // Exchange id - exchangeId = "2"; - - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - // Get the block timestamp of the confirmed tx and set disputedDate - blockNumber = tx.blockNumber; - block = await ethers.provider.getBlock(blockNumber); - disputedDate = block.timestamp.toString(); - timeout = BN(disputedDate).add(resolutionPeriod).toString(); - - await setNextBlockTimestamp(Number(timeout)); - }); - - it("should emit a FundsReleased event", async function () { - // Expire the dispute, expecting event - const tx = await disputeHandler.connect(rando).expireDispute(exchangeId); - - // Complete the exchange, expecting event - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, rando.address); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, rando.address); - - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, rando.address); - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Expire the dispute, so the funds are released - await disputeHandler.connect(rando).expireDispute(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocol fee - agent fee; - // protocol: protocolFee - // agent: agent fee - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - new Funds(mockToken.address, "Foreign20", sellerPayoff), - ]); - - expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - expectedAgentAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", agentPayoff); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - }); - - context("Final state DISPUTED - RESOLVED", async function () { - beforeEach(async function () { - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit)*buyerPercentage - buyerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit)*(1-buyerPercentage) - sellerPayoff = BN(offerToken.price).add(offerToken.sellerDeposit).sub(buyerPayoff).toString(); - - // protocol: 0 - protocolPayoff = 0; - - // Set the message Type, needed for signature - resolutionType = [ - { name: "exchangeId", type: "uint256" }, - { name: "buyerPercentBasisPoints", type: "uint256" }, - ]; - - customSignatureType = { - Resolution: resolutionType, - }; - - message = { - exchangeId: exchangeId, - buyerPercentBasisPoints, - }; - - // Collect the signature components - ({ r, s, v } = await prepareDataSignatureParameters( - buyer, // Assistant is the caller, seller should be the signer. - customSignatureType, - "Resolution", - message, - disputeHandler.address - )); - }); - - it("should emit a FundsReleased event", async function () { - // Resolve the dispute, expecting event - const tx = await disputeHandler - .connect(assistant) - .resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistant.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); - - await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Resolve the dispute, so the funds are released - await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); - - // Available funds should be increased for - // buyer: (price + sellerDeposit)*buyerPercentage - // seller: (price + sellerDeposit)*(1-buyerPercentage); note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() - ); - expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - exchangeId = "2"; - - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit)*buyerPercentage - buyerPayoff = BN(agentOffer.price) - .add(agentOffer.sellerDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); + // Exchange id + exchangeId = "2"; + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - // seller: (price + sellerDeposit)*(1-buyerPercentage) - sellerPayoff = BN(agentOffer.price).add(agentOffer.sellerDeposit).sub(buyerPayoff).toString(); + // succesfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // protocol: 0 - protocolPayoff = 0; + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); + }); - // Set the message Type, needed for signature - resolutionType = [ - { name: "exchangeId", type: "uint256" }, - { name: "buyerPercentBasisPoints", type: "uint256" }, - ]; + it("should emit a FundsReleased event", async function () { + // Retract from the dispute, expecting event + const tx = await disputeHandler.connect(buyer).retractDispute(exchangeId); - customSignatureType = { - Resolution: resolutionType, - }; + await expect(tx) + .to.emit(disputeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); - message = { - exchangeId: exchangeId, - buyerPercentBasisPoints, - }; + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); - // Collect the signature components - ({ r, s, v } = await prepareDataSignatureParameters( - buyer, // Assistant is the caller, seller should be the signer. - customSignatureType, - "Resolution", - message, - disputeHandler.address - )); - }); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + }); - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // Resolve the dispute, so the funds are released - await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); + // Retract from the dispute, so the funds are released + await disputeHandler.connect(buyer).retractDispute(exchangeId); - // Available funds should be increased for - // buyer: (price + sellerDeposit)*buyerPercentage - // seller: (price + sellerDeposit)*(1-buyerPercentage); - // protocol: 0 - // agent: 0 - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) - ); - expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // Available funds should be increased for + // buyer: 0 + // seller: sellerDeposit + price - protocol fee - agentFee; + // protocol: protocolFee + // agent: agentFee + expectedSellerAvailableFunds.funds.push( + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + ); + expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); + expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); }); }); - }); - - context("Final state DISPUTED - ESCALATED - RETRACTED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // seller: sellerDeposit + price - protocolFee + buyerEscalationDeposit - sellerPayoff = BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .add(buyerEscalationDeposit) - .toString(); - - // protocol: 0 - protocolPayoff = offerTokenProtocolFee; - - // Escalate the dispute - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); - - it("should emit a FundsReleased event", async function () { - // Retract from the dispute, expecting event - const tx = await disputeHandler.connect(buyer).retractDispute(exchangeId); - - await expect(tx) - .to.emit(disputeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); - - //check that FundsReleased event was NOT emitted with buyer Id - const txReceipt = await tx.wait(); - const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ - exchangeId, - buyerId, - offerToken.exchangeToken, - buyerPayoff, - buyer.address, - ]); - expect(match).to.be.false; - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Retract from the dispute, so the funds are released - await disputeHandler.connect(buyer).retractDispute(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocol fee + buyerEscalationDeposit; note that seller has sellerDeposit in availableFunds from before - // protocol: protocolFee - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() - ); - expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - context("Offer has an agent", async function () { + context("Final state DISPUTED - RETRACTED via expireDispute", async function () { beforeEach(async function () { // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee + buyerEscalationDeposit - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .add(buyerEscalationDeposit) - .toString(); + // buyer: 0 + buyerPayoff = 0; - // protocol: 0 - protocolPayoff = agentOfferProtocolFee; + // seller: sellerDeposit + price - protocolFee + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); - // Exchange id - exchangeId = "2"; - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + // protocol: protocolFee + protocolPayoff = offerTokenProtocolFee; - // approve protocol to transfer the tokens - await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); - await mockToken.mint(buyer.address, agentOffer.price); - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + await setNextBlockTimestamp(Number(timeout)); + }); - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + it("should emit a FundsReleased event", async function () { + // Expire the dispute, expecting event + const tx = await disputeHandler.connect(rando).expireDispute(exchangeId); + await expect(tx) + .to.emit(disputeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, rando.address); - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, rando.address); - // escalate the dispute - await mockToken.mint(buyer.address, buyerEscalationDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); - await disputeHandler.connect(buyer).escalateDispute(exchangeId); + //check that FundsReleased event was NOT emitted with buyer Id + const txReceipt = await tx.wait(); + const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ + exchangeId, + buyerId, + offerToken.exchangeToken, + buyerPayoff, + rando.address, + ]); + expect(match).to.be.false; }); it("should update state", async function () { @@ -3566,6 +3244,7 @@ describe("IBosonFundsHandler", function () { // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ + new Funds(mockToken.address, "Foreign20", sellerDeposit), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); @@ -3576,19 +3255,20 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // Retract from the dispute, so the funds are released - await disputeHandler.connect(buyer).retractDispute(exchangeId); + // Expire the dispute, so the funds are released + await disputeHandler.connect(rando).expireDispute(exchangeId); // Available funds should be increased for // buyer: 0 - // seller: sellerDeposit + price - protocol fee - agentFee + buyerEscalationDeposit; + // seller: sellerDeposit + price - protocol fee; note that seller has sellerDeposit in availableFunds from before // protocol: protocolFee - // agent: agentFee - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + // agent: 0 + expectedSellerAvailableFunds.funds[0] = new Funds( + mockToken.address, + "Foreign20", + BN(sellerDeposit).add(sellerPayoff).toString() ); expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); @@ -3598,159 +3278,131 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); }); - }); - }); - context("Final state DISPUTED - ESCALATED - RESOLVED", async function () { - beforeEach(async function () { - buyerPercentBasisPoints = "5566"; // 55.66% + context("Offer has an agent", async function () { + beforeEach(async function () { + // Create Agent offer + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); + // Commit to Offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoff) - .toString(); + // expected payoffs + // buyer: 0 + buyerPayoff = 0; - // protocol: 0 - protocolPayoff = 0; + // agentPayoff: agentFee + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentPayoff = agentFee; - // Set the message Type, needed for signature - resolutionType = [ - { name: "exchangeId", type: "uint256" }, - { name: "buyerPercentBasisPoints", type: "uint256" }, - ]; - - customSignatureType = { - Resolution: resolutionType, - }; - - message = { - exchangeId: exchangeId, - buyerPercentBasisPoints, - }; - - // Collect the signature components - ({ r, s, v } = await prepareDataSignatureParameters( - buyer, // Assistant is the caller, seller should be the signer. - customSignatureType, - "Resolution", - message, - disputeHandler.address - )); - - // Escalate the dispute - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); + // seller: sellerDeposit + price - protocolFee - agent fee + sellerPayoff = BN(agentOffer.sellerDeposit) + .add(agentOffer.price) + .sub(agentOfferProtocolFee) + .sub(agentFee) + .toString(); - it("should emit a FundsReleased event", async function () { - // Resolve the dispute, expecting event - const tx = await disputeHandler - .connect(assistant) - .resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistant.address); + // protocol: protocolFee + protocolPayoff = agentOfferProtocolFee; - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); + // Exchange id + exchangeId = "2"; - await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); - }); + // succesfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + // raise the dispute + tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // Get the block timestamp of the confirmed tx and set disputedDate + blockNumber = tx.blockNumber; + block = await ethers.provider.getBlock(blockNumber); + disputedDate = block.timestamp.toString(); + timeout = BN(disputedDate).add(resolutionPeriod).toString(); - // Resolve the dispute, so the funds are released - await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); + await setNextBlockTimestamp(Number(timeout)); + }); - // Available funds should be increased for - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() - ); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); + it("should emit a FundsReleased event", async function () { + // Expire the dispute, expecting event + const tx = await disputeHandler.connect(rando).expireDispute(exchangeId); - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + // Complete the exchange, expecting event + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, rando.address); - // approve protocol to transfer the tokens - await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); - await mockToken.mint(buyer.address, agentOffer.price); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, rando.address); - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, rando.address); + }); - exchangeId = "2"; + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + + // Expire the dispute, so the funds are released + await disputeHandler.connect(rando).expireDispute(exchangeId); + + // Available funds should be increased for + // buyer: 0 + // seller: sellerDeposit + price - protocol fee - agent fee; + // protocol: protocolFee + // agent: agent fee + expectedSellerAvailableFunds = new FundsList([ + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + new Funds(mockToken.address, "Foreign20", sellerPayoff), + ]); - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); + expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); + expectedAgentAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", agentPayoff); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); + }); + }); + context("Final state DISPUTED - RESOLVED", async function () { + beforeEach(async function () { buyerPercentBasisPoints = "5566"; // 55.66% // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = BN(agentOffer.price) - .add(agentOffer.sellerDeposit) - .add(buyerEscalationDeposit) + // buyer: (price + sellerDeposit)*buyerPercentage + buyerPayoff = BN(offerToken.price) + .add(offerToken.sellerDeposit) .mul(buyerPercentBasisPoints) .div("10000") .toString(); - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = BN(agentOffer.price) - .add(agentOffer.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoff) - .toString(); + // seller: (price + sellerDeposit)*(1-buyerPercentage) + sellerPayoff = BN(offerToken.price).add(offerToken.sellerDeposit).sub(buyerPayoff).toString(); // protocol: 0 protocolPayoff = 0; @@ -3778,11 +3430,22 @@ describe("IBosonFundsHandler", function () { message, disputeHandler.address )); + }); - // escalate the dispute - await mockToken.mint(buyer.address, buyerEscalationDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); - await disputeHandler.connect(buyer).escalateDispute(exchangeId); + it("should emit a FundsReleased event", async function () { + // Resolve the dispute, expecting event + const tx = await disputeHandler + .connect(assistant) + .resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistant.address); + + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); + + await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); }); it("should update state", async function () { @@ -3794,6 +3457,7 @@ describe("IBosonFundsHandler", function () { // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ + new Funds(mockToken.address, "Foreign20", sellerDeposit), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); @@ -3808,12 +3472,14 @@ describe("IBosonFundsHandler", function () { await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); // Available funds should be increased for - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); + // buyer: (price + sellerDeposit)*buyerPercentage + // seller: (price + sellerDeposit)*(1-buyerPercentage); note that seller has sellerDeposit in availableFunds from before // protocol: 0 // agent: 0 - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + expectedSellerAvailableFunds.funds[0] = new Funds( + mockToken.address, + "Foreign20", + BN(sellerDeposit).add(sellerPayoff).toString() ); expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -3826,149 +3492,154 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); }); - }); - }); - - context("Final state DISPUTED - ESCALATED - DECIDED", async function () { - beforeEach(async function () { - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoff) - .toString(); - - // protocol: 0 - protocolPayoff = 0; - - // escalate the dispute - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); - - it("should emit a FundsReleased event", async function () { - // Decide the dispute, expecting event - const tx = await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistantDR.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); - await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); - }); + context("Offer has an agent", async function () { + beforeEach(async function () { + // Create Agent offer + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + // Commit to Offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + exchangeId = "2"; - // Decide the dispute, so the funds are released - await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); + // succesfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // Available funds should be increased for - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() - ); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + buyerPercentBasisPoints = "5566"; // 55.66% - // approve protocol to transfer the tokens - await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); - await mockToken.mint(buyer.address, agentOffer.price); + // expected payoffs + // buyer: (price + sellerDeposit)*buyerPercentage + buyerPayoff = BN(agentOffer.price) + .add(agentOffer.sellerDeposit) + .mul(buyerPercentBasisPoints) + .div("10000") + .toString(); - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + // seller: (price + sellerDeposit)*(1-buyerPercentage) + sellerPayoff = BN(agentOffer.price).add(agentOffer.sellerDeposit).sub(buyerPayoff).toString(); - exchangeId = "2"; + // protocol: 0 + protocolPayoff = 0; - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // Set the message Type, needed for signature + resolutionType = [ + { name: "exchangeId", type: "uint256" }, + { name: "buyerPercentBasisPoints", type: "uint256" }, + ]; + + customSignatureType = { + Resolution: resolutionType, + }; + + message = { + exchangeId: exchangeId, + buyerPercentBasisPoints, + }; + + // Collect the signature components + ({ r, s, v } = await prepareDataSignatureParameters( + buyer, // Assistant is the caller, seller should be the signer. + customSignatureType, + "Resolution", + message, + disputeHandler.address + )); + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - // raise the dispute - tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // Get the block timestamp of the confirmed tx and set disputedDate - blockNumber = tx.blockNumber; - block = await ethers.provider.getBlock(blockNumber); - disputedDate = block.timestamp.toString(); - timeout = BN(disputedDate).add(resolutionPeriod).toString(); + // Resolve the dispute, so the funds are released + await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); - buyerPercentBasisPoints = "5566"; // 55.66% + // Available funds should be increased for + // buyer: (price + sellerDeposit)*buyerPercentage + // seller: (price + sellerDeposit)*(1-buyerPercentage); + // protocol: 0 + // agent: 0 + expectedSellerAvailableFunds.funds.push( + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + ); + expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); + }); + }); + context("Final state DISPUTED - ESCALATED - RETRACTED", async function () { + beforeEach(async function () { // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = BN(agentOffer.price) - .add(agentOffer.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); + // buyer: 0 + buyerPayoff = 0; - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = BN(agentOffer.price) - .add(agentOffer.sellerDeposit) + // seller: sellerDeposit + price - protocolFee + buyerEscalationDeposit + sellerPayoff = BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) .add(buyerEscalationDeposit) - .sub(buyerPayoff) .toString(); // protocol: 0 - protocolPayoff = 0; + protocolPayoff = offerTokenProtocolFee; - // escalate the dispute - await mockToken.mint(buyer.address, buyerEscalationDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); + // Escalate the dispute await disputeHandler.connect(buyer).escalateDispute(exchangeId); }); + it("should emit a FundsReleased event", async function () { + // Retract from the dispute, expecting event + const tx = await disputeHandler.connect(buyer).retractDispute(exchangeId); + + await expect(tx) + .to.emit(disputeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); + + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + + //check that FundsReleased event was NOT emitted with buyer Id + const txReceipt = await tx.wait(); + const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ + exchangeId, + buyerId, + offerToken.exchangeToken, + buyerPayoff, + buyer.address, + ]); + expect(match).to.be.false; + }); + it("should update state", async function () { // Read on chain state sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); @@ -3978,6 +3649,7 @@ describe("IBosonFundsHandler", function () { // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ + new Funds(mockToken.address, "Foreign20", sellerDeposit), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); @@ -3988,18 +3660,20 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // Decide the dispute, so the funds are released - await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); + // Retract from the dispute, so the funds are released + await disputeHandler.connect(buyer).retractDispute(exchangeId); // Available funds should be increased for - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); - // protocol: 0 + // buyer: 0 + // seller: sellerDeposit + price - protocol fee + buyerEscalationDeposit; note that seller has sellerDeposit in availableFunds from before + // protocol: protocolFee // agent: 0 - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + expectedSellerAvailableFunds.funds[0] = new Funds( + mockToken.address, + "Foreign20", + BN(sellerDeposit).add(sellerPayoff).toString() ); - expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); + expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); @@ -4009,43 +3683,158 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); }); + + context("Offer has an agent", async function () { + beforeEach(async function () { + // expected payoffs + // buyer: 0 + buyerPayoff = 0; + + // agentPayoff: agentFee + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentPayoff = agentFee; + + // seller: sellerDeposit + price - protocolFee - agentFee + buyerEscalationDeposit + sellerPayoff = BN(agentOffer.sellerDeposit) + .add(agentOffer.price) + .sub(agentOfferProtocolFee) + .sub(agentFee) + .add(buyerEscalationDeposit) + .toString(); + + // protocol: 0 + protocolPayoff = agentOfferProtocolFee; + + // Exchange id + exchangeId = "2"; + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + + // approve protocol to transfer the tokens + await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); + await mockToken.mint(buyer.address, agentOffer.price); + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + + // succesfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); + + // escalate the dispute + await mockToken.mint(buyer.address, buyerEscalationDeposit); + await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); + await disputeHandler.connect(buyer).escalateDispute(exchangeId); + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + + // Retract from the dispute, so the funds are released + await disputeHandler.connect(buyer).retractDispute(exchangeId); + + // Available funds should be increased for + // buyer: 0 + // seller: sellerDeposit + price - protocol fee - agentFee + buyerEscalationDeposit; + // protocol: protocolFee + // agent: agentFee + expectedSellerAvailableFunds.funds.push( + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + ); + expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); + expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); + }); }); - }); - context( - "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)", - async function () { + context("Final state DISPUTED - ESCALATED - RESOLVED", async function () { beforeEach(async function () { + buyerPercentBasisPoints = "5566"; // 55.66% + // expected payoffs - // buyer: price + buyerEscalationDeposit - buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); + // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage + buyerPayoff = BN(offerToken.price) + .add(offerToken.sellerDeposit) + .add(buyerEscalationDeposit) + .mul(buyerPercentBasisPoints) + .div("10000") + .toString(); - // seller: sellerDeposit - sellerPayoff = offerToken.sellerDeposit; + // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) + sellerPayoff = BN(offerToken.price) + .add(offerToken.sellerDeposit) + .add(buyerEscalationDeposit) + .sub(buyerPayoff) + .toString(); // protocol: 0 protocolPayoff = 0; - // Escalate the dispute - tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); + // Set the message Type, needed for signature + resolutionType = [ + { name: "exchangeId", type: "uint256" }, + { name: "buyerPercentBasisPoints", type: "uint256" }, + ]; + + customSignatureType = { + Resolution: resolutionType, + }; + + message = { + exchangeId: exchangeId, + buyerPercentBasisPoints, + }; - // Get the block timestamp of the confirmed tx and set escalatedDate - blockNumber = tx.blockNumber; - block = await ethers.provider.getBlock(blockNumber); - escalatedDate = block.timestamp.toString(); + // Collect the signature components + ({ r, s, v } = await prepareDataSignatureParameters( + buyer, // Assistant is the caller, seller should be the signer. + customSignatureType, + "Resolution", + message, + disputeHandler.address + )); - await setNextBlockTimestamp(Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod)); + // Escalate the dispute + await disputeHandler.connect(buyer).escalateDispute(exchangeId); }); it("should emit a FundsReleased event", async function () { - // Expire the dispute, expecting event - const tx = await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); + // Resolve the dispute, expecting event + const tx = await disputeHandler + .connect(assistant) + .resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); await expect(tx) .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, rando.address); + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistant.address); + await expect(tx) .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, rando.address); + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); }); @@ -4070,12 +3859,12 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // Expire the escalated dispute, so the funds are released - await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); + // Resolve the dispute, so the funds are released + await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); // Available funds should be increased for - // buyer: price + buyerEscalationDeposit - // seller: sellerDeposit; note that seller has sellerDeposit in availableFunds from before + // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage + // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); note that seller has sellerDeposit in availableFunds from before // protocol: 0 // agent: 0 expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); @@ -4114,29 +3903,57 @@ describe("IBosonFundsHandler", function () { await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute - tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); + await disputeHandler.connect(buyer).raiseDispute(exchangeId); - // expected payoffs - // buyer: price + buyerEscalationDeposit - buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); + buyerPercentBasisPoints = "5566"; // 55.66% - // seller: sellerDeposit - sellerPayoff = offerToken.sellerDeposit; + // expected payoffs + // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage + buyerPayoff = BN(agentOffer.price) + .add(agentOffer.sellerDeposit) + .add(buyerEscalationDeposit) + .mul(buyerPercentBasisPoints) + .div("10000") + .toString(); + + // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) + sellerPayoff = BN(agentOffer.price) + .add(agentOffer.sellerDeposit) + .add(buyerEscalationDeposit) + .sub(buyerPayoff) + .toString(); // protocol: 0 protocolPayoff = 0; - // Escalate the dispute + // Set the message Type, needed for signature + resolutionType = [ + { name: "exchangeId", type: "uint256" }, + { name: "buyerPercentBasisPoints", type: "uint256" }, + ]; + + customSignatureType = { + Resolution: resolutionType, + }; + + message = { + exchangeId: exchangeId, + buyerPercentBasisPoints, + }; + + // Collect the signature components + ({ r, s, v } = await prepareDataSignatureParameters( + buyer, // Assistant is the caller, seller should be the signer. + customSignatureType, + "Resolution", + message, + disputeHandler.address + )); + + // escalate the dispute await mockToken.mint(buyer.address, buyerEscalationDeposit); await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); - tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); - - // Get the block timestamp of the confirmed tx and set escalatedDate - blockNumber = tx.blockNumber; - block = await ethers.provider.getBlock(blockNumber); - escalatedDate = block.timestamp.toString(); - - await setNextBlockTimestamp(Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod)); + await disputeHandler.connect(buyer).escalateDispute(exchangeId); }); it("should update state", async function () { @@ -4158,73 +3975,70 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // Expire the escalated dispute, so the funds are released - await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); + // Resolve the dispute, so the funds are released + await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); // Available funds should be increased for - // buyer: price + buyerEscalationDeposit - // seller: sellerDeposit; + // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage + // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); // protocol: 0 // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); expectedSellerAvailableFunds.funds.push( new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) ); + expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); }); }); - } - ); + }); - context( - "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)", - async function () { + context("Final state DISPUTED - ESCALATED - DECIDED", async function () { beforeEach(async function () { + buyerPercentBasisPoints = "5566"; // 55.66% + // expected payoffs - // buyer: price + buyerEscalationDeposit - buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); + // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage + buyerPayoff = BN(offerToken.price) + .add(offerToken.sellerDeposit) + .add(buyerEscalationDeposit) + .mul(buyerPercentBasisPoints) + .div("10000") + .toString(); - // seller: sellerDeposit - sellerPayoff = offerToken.sellerDeposit; + // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) + sellerPayoff = BN(offerToken.price) + .add(offerToken.sellerDeposit) + .add(buyerEscalationDeposit) + .sub(buyerPayoff) + .toString(); // protocol: 0 protocolPayoff = 0; - // Escalate the dispute - tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); + // escalate the dispute + await disputeHandler.connect(buyer).escalateDispute(exchangeId); }); it("should emit a FundsReleased event", async function () { - // Expire the dispute, expecting event - const tx = await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); - + // Decide the dispute, expecting event + const tx = await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); await expect(tx) .to.emit(disputeHandler, "FundsReleased") .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistantDR.address); await expect(tx) .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); - - await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); - - //check that FundsReleased event was NOT emitted with rando address - const txReceipt = await tx.wait(); - const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ - exchangeId, - seller.id, - offerToken.exchangeToken, - sellerPayoff, - rando.address, - ]); - expect(match).to.be.false; + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); + + await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); }); it("should update state", async function () { @@ -4247,12 +4061,12 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // Expire the escalated dispute, so the funds are released - await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); + // Decide the dispute, so the funds are released + await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); // Available funds should be increased for - // buyer: price + buyerEscalationDeposit - // seller: sellerDeposit; note that seller has sellerDeposit in availableFunds from before + // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage + // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); note that seller has sellerDeposit in availableFunds from before // protocol: 0 // agent: 0 expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); @@ -4291,8 +4105,88 @@ describe("IBosonFundsHandler", function () { await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); + tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); + + // Get the block timestamp of the confirmed tx and set disputedDate + blockNumber = tx.blockNumber; + block = await ethers.provider.getBlock(blockNumber); + disputedDate = block.timestamp.toString(); + timeout = BN(disputedDate).add(resolutionPeriod).toString(); + + buyerPercentBasisPoints = "5566"; // 55.66% + + // expected payoffs + // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage + buyerPayoff = BN(agentOffer.price) + .add(agentOffer.sellerDeposit) + .add(buyerEscalationDeposit) + .mul(buyerPercentBasisPoints) + .div("10000") + .toString(); + + // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) + sellerPayoff = BN(agentOffer.price) + .add(agentOffer.sellerDeposit) + .add(buyerEscalationDeposit) + .sub(buyerPayoff) + .toString(); + + // protocol: 0 + protocolPayoff = 0; + + // escalate the dispute + await mockToken.mint(buyer.address, buyerEscalationDeposit); + await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); + await disputeHandler.connect(buyer).escalateDispute(exchangeId); + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + + // Decide the dispute, so the funds are released + await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); + + // Available funds should be increased for + // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage + // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); + // protocol: 0 + // agent: 0 + expectedSellerAvailableFunds.funds.push( + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + ); + expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); + }); + }); + context( + "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)", + async function () { + beforeEach(async function () { // expected payoffs // buyer: price + buyerEscalationDeposit buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); @@ -4304,9 +4198,27 @@ describe("IBosonFundsHandler", function () { protocolPayoff = 0; // Escalate the dispute - await mockToken.mint(buyer.address, buyerEscalationDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); - await disputeHandler.connect(buyer).escalateDispute(exchangeId); + tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); + + // Get the block timestamp of the confirmed tx and set escalatedDate + blockNumber = tx.blockNumber; + block = await ethers.provider.getBlock(blockNumber); + escalatedDate = block.timestamp.toString(); + + await setNextBlockTimestamp(Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod)); + }); + + it("should emit a FundsReleased event", async function () { + // Expire the dispute, expecting event + const tx = await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, rando.address); + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, rando.address); + + await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); }); it("should update state", async function () { @@ -4318,6 +4230,7 @@ describe("IBosonFundsHandler", function () { // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ + new Funds(mockToken.address, "Foreign20", sellerDeposit), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); @@ -4329,16 +4242,18 @@ describe("IBosonFundsHandler", function () { expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); // Expire the escalated dispute, so the funds are released - await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); + await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); // Available funds should be increased for // buyer: price + buyerEscalationDeposit - // seller: sellerDeposit; + // seller: sellerDeposit; note that seller has sellerDeposit in availableFunds from before // protocol: 0 // agent: 0 expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + expectedSellerAvailableFunds.funds[0] = new Funds( + mockToken.address, + "Foreign20", + BN(sellerDeposit).add(sellerPayoff).toString() ); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); @@ -4349,80 +4264,269 @@ describe("IBosonFundsHandler", function () { expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); }); - }); - } - ); - }); - context("Changing the protocol fee", async function () { - beforeEach(async function () { - // Cast Diamond to IBosonConfigHandler - configHandler = await ethers.getContractAt("IBosonConfigHandler", protocolDiamondAddress); + context("Offer has an agent", async function () { + beforeEach(async function () { + // Create Agent offer + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + + // approve protocol to transfer the tokens + await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); + await mockToken.mint(buyer.address, agentOffer.price); + + // Commit to Offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + + exchangeId = "2"; + + // succesfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + + // raise the dispute + tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); + + // expected payoffs + // buyer: price + buyerEscalationDeposit + buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); + + // seller: sellerDeposit + sellerPayoff = offerToken.sellerDeposit; + + // protocol: 0 + protocolPayoff = 0; + + // Escalate the dispute + await mockToken.mint(buyer.address, buyerEscalationDeposit); + await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); + tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); + + // Get the block timestamp of the confirmed tx and set escalatedDate + blockNumber = tx.blockNumber; + block = await ethers.provider.getBlock(blockNumber); + escalatedDate = block.timestamp.toString(); + + await setNextBlockTimestamp(Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod)); + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + + // Expire the escalated dispute, so the funds are released + await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); + + // Available funds should be increased for + // buyer: price + buyerEscalationDeposit + // seller: sellerDeposit; + // protocol: 0 + // agent: 0 + expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); + expectedSellerAvailableFunds.funds.push( + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + ); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); + }); + } + ); - // expected payoffs - // buyer: 0 - buyerPayoff = 0; + context( + "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)", + async function () { + beforeEach(async function () { + // expected payoffs + // buyer: price + buyerEscalationDeposit + buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); - // seller: sellerDeposit + price - protocolFee - sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); - }); + // seller: sellerDeposit + sellerPayoff = offerToken.sellerDeposit; - it("Protocol fee for existing exchanges should be the same as at the offer creation", async function () { - // set the new procol fee - protocolFeePercentage = "300"; // 3% - await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); + // protocol: 0 + protocolPayoff = 0; - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Escalate the dispute + tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); + }); - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + it("should emit a FundsReleased event", async function () { + // Expire the dispute, expecting event + const tx = await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); - // Complete the exchange, expecting event - const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistantDR.address); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.address); - }); + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); - it("Protocol fee for new exchanges should be the same as at the offer creation", async function () { - // set the new procol fee - protocolFeePercentage = "300"; // 3% - await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); + await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); - // similar as teste before, excpet the commit to offer is done after the procol fee change + //check that FundsReleased event was NOT emitted with rando address + const txReceipt = await tx.wait(); + const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ + exchangeId, + seller.id, + offerToken.exchangeToken, + sellerPayoff, + rando.address, + ]); + expect(match).to.be.false; + }); - // commit to offer and get the correct exchangeId - tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - txReceipt = await tx.wait(); - event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); - exchangeId = event.exchangeId.toString(); + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // Expire the escalated dispute, so the funds are released + await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); - // Complete the exchange, expecting event - tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + // Available funds should be increased for + // buyer: price + buyerEscalationDeposit + // seller: sellerDeposit; note that seller has sellerDeposit in availableFunds from before + // protocol: 0 + // agent: 0 + expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); + expectedSellerAvailableFunds.funds[0] = new Funds( + mockToken.address, + "Foreign20", + BN(sellerDeposit).add(sellerPayoff).toString() + ); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.address); + context("Offer has an agent", async function () { + beforeEach(async function () { + // Create Agent offer + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + + // approve protocol to transfer the tokens + await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); + await mockToken.mint(buyer.address, agentOffer.price); + + // Commit to Offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + + exchangeId = "2"; + + // succesfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); + + // expected payoffs + // buyer: price + buyerEscalationDeposit + buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); + + // seller: sellerDeposit + sellerPayoff = offerToken.sellerDeposit; + + // protocol: 0 + protocolPayoff = 0; + + // Escalate the dispute + await mockToken.mint(buyer.address, buyerEscalationDeposit); + await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); + await disputeHandler.connect(buyer).escalateDispute(exchangeId); + }); + + it("should update state", async function () { + // Read on chain state + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + + // Chain state should match the expected available funds + expectedSellerAvailableFunds = new FundsList([ + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedBuyerAvailableFunds = new FundsList([]); + expectedProtocolAvailableFunds = new FundsList([]); + expectedAgentAvailableFunds = new FundsList([]); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + + // Expire the escalated dispute, so the funds are released + await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); + + // Available funds should be increased for + // buyer: price + buyerEscalationDeposit + // seller: sellerDeposit; + // protocol: 0 + // agent: 0 + expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); + expectedSellerAvailableFunds.funds.push( + new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) + ); + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + }); + }); + } + ); }); - context("Offer has an agent", async function () { + context("Changing the protocol fee", async function () { beforeEach(async function () { - exchangeId = "2"; - // Cast Diamond to IBosonConfigHandler configHandler = await ethers.getContractAt("IBosonConfigHandler", protocolDiamondAddress); @@ -4430,34 +4534,15 @@ describe("IBosonFundsHandler", function () { // buyer: 0 buyerPayoff = 0; - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .toString(); - - // protocol: protocolFee - protocolPayoff = agentOfferProtocolFee; - - // Create Agent Offer before setting new protocol fee as 3% - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // Commit to Agent Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + // seller: sellerDeposit + price - protocolFee + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); + }); + it("Protocol fee for existing exchanges should be the same as at the offer creation", async function () { // set the new procol fee protocolFeePercentage = "300"; // 3% await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); - }); - it("Protocol fee for existing exchanges should be the same as at the agent offer creation", async function () { // Set time forward to the offer's voucherRedeemableFrom await setNextBlockTimestamp(Number(voucherRedeemableFrom)); @@ -4466,36 +4551,24 @@ describe("IBosonFundsHandler", function () { // Complete the exchange, expecting event const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - await expect(tx) .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); await expect(tx) .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.address); }); - it("Protocol fee for new exchanges should be the same as at the agent offer creation", async function () { - // similar as tests before, excpet the commit to offer is done after the protocol fee change - - // top up seller's and buyer's account - await mockToken.mint(assistant.address, sellerDeposit); - await mockToken.mint(buyer.address, price); - - // approve protocol to transfer the tokens - await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, price); + it("Protocol fee for new exchanges should be the same as at the offer creation", async function () { + // set the new procol fee + protocolFeePercentage = "300"; // 3% + await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); - // deposit to seller's pool - await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerDeposit); + // similar as teste before, excpet the commit to offer is done after the procol fee change // commit to offer and get the correct exchangeId - tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); txReceipt = await tx.wait(); event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); exchangeId = event.exchangeId.toString(); @@ -4508,19 +4581,118 @@ describe("IBosonFundsHandler", function () { // Complete the exchange, expecting event tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - - // Complete the exchange, expecting event await expect(tx) .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); await expect(tx) .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); + .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.address); + }); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + context("Offer has an agent", async function () { + beforeEach(async function () { + exchangeId = "2"; + + // Cast Diamond to IBosonConfigHandler + configHandler = await ethers.getContractAt("IBosonConfigHandler", protocolDiamondAddress); + + // expected payoffs + // buyer: 0 + buyerPayoff = 0; + + // agentPayoff: agentFee + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentPayoff = agentFee; + + // seller: sellerDeposit + price - protocolFee - agentFee + sellerPayoff = BN(agentOffer.sellerDeposit) + .add(agentOffer.price) + .sub(agentOfferProtocolFee) + .sub(agentFee) + .toString(); + + // protocol: protocolFee + protocolPayoff = agentOfferProtocolFee; + + // Create Agent Offer before setting new protocol fee as 3% + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + + // Commit to Agent Offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + + // set the new procol fee + protocolFeePercentage = "300"; // 3% + await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); + }); + + it("Protocol fee for existing exchanges should be the same as at the agent offer creation", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // succesfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + + // Complete the exchange, expecting event + const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); + + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); + + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + }); + + it("Protocol fee for new exchanges should be the same as at the agent offer creation", async function () { + // similar as tests before, excpet the commit to offer is done after the protocol fee change + + // top up seller's and buyer's account + await mockToken.mint(assistant.address, sellerDeposit); + await mockToken.mint(buyer.address, price); + + // approve protocol to transfer the tokens + await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerDeposit); + await mockToken.connect(buyer).approve(protocolDiamondAddress, price); + + // deposit to seller's pool + await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerDeposit); + + // commit to offer and get the correct exchangeId + tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + txReceipt = await tx.wait(); + event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); + exchangeId = event.exchangeId.toString(); + + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // succesfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + + // Complete the exchange, expecting event + tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + + // Complete the exchange, expecting event + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); + + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); + + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + }); }); }); }); From 94a661661f4e261daf7b249c26f13351d0cd1c3a Mon Sep 17 00:00:00 2001 From: zajck Date: Tue, 9 May 2023 09:51:04 +0200 Subject: [PATCH 11/33] release funds all tests --- test/protocol/FundsHandlerTest.js | 402 +++++++++++++++++++++++++----- 1 file changed, 340 insertions(+), 62 deletions(-) diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index 2c85a9308..2a53ccbec 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -772,7 +772,7 @@ describe("IBosonFundsHandler", function () { // Set time forward to the offer's voucherRedeemableFrom await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); }); @@ -1019,7 +1019,7 @@ describe("IBosonFundsHandler", function () { const tokenExchangeId = exchangeId; const nativeExchangeId = ++exchangeId; - // succesfully finalize the exchange so the protocol gets some fees + // successfully finalize the exchange so the protocol gets some fees await setNextBlockTimestamp(Number(voucherRedeemableFrom)); await exchangeHandler.connect(buyer).redeemVoucher(tokenExchangeId); await exchangeHandler.connect(buyer).redeemVoucher(nativeExchangeId); @@ -2292,7 +2292,7 @@ describe("IBosonFundsHandler", function () { let DRFeeToSeller, DRFeeToMutualizer; - ["self-mutualized", "external-mutualized"].forEach((mutualizationType) => { + ["self-mutualized", "external-mutualizer"].forEach((mutualizationType) => { context(`👉 releaseFunds() [${mutualizationType}]`, async function () { beforeEach(async function () { // ids @@ -2461,8 +2461,8 @@ describe("IBosonFundsHandler", function () { expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(mutualizerPayoff)); // complete another exchange so we test funds are only updated, no new entry is created await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); @@ -2487,7 +2487,7 @@ describe("IBosonFundsHandler", function () { expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); }); context("Offer has an agent", async function () { @@ -2500,7 +2500,7 @@ describe("IBosonFundsHandler", function () { // Commit to Offer await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - // succesfully redeem exchange + // successfully redeem exchange exchangeId = "2"; await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); @@ -2639,7 +2639,7 @@ describe("IBosonFundsHandler", function () { // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(BN(DRFeeToSeller)).toString()), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); @@ -2675,8 +2675,8 @@ describe("IBosonFundsHandler", function () { expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(mutualizerPayoff)); // Test that if buyer has some funds available, and gets more, the funds are only updated // Commit again @@ -2710,7 +2710,7 @@ describe("IBosonFundsHandler", function () { expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); }); context("Offer has an agent", async function () { @@ -2914,10 +2914,12 @@ describe("IBosonFundsHandler", function () { buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); }); context("Offer has an agent", async function () { @@ -3008,7 +3010,7 @@ describe("IBosonFundsHandler", function () { // Set time forward to the offer's voucherRedeemableFrom await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute @@ -3028,10 +3030,20 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // seller: sellerDeposit + price - protocolFee - sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); + sellerPayoff = BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .add(DRFeeToSeller) + .toString(); // protocol: 0 protocolPayoff = offerTokenProtocolFee; + + // mutualizer: 0 or DRFee + mutualizerPayoff = DRFeeToMutualizer; + + // DR: 0 + disputeResolverPayoff = 0; }); it("should emit a FundsReleased event", async function () { @@ -3056,6 +3068,21 @@ describe("IBosonFundsHandler", function () { buyer.address, ]); expect(match).to.be.false; + + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + buyer.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { @@ -3063,20 +3090,24 @@ describe("IBosonFundsHandler", function () { sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Retract from the dispute, so the funds are released await disputeHandler.connect(buyer).retractDispute(exchangeId); @@ -3089,17 +3120,21 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() ); expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); }); context("Offer has an agent", async function () { @@ -3129,7 +3164,7 @@ describe("IBosonFundsHandler", function () { .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute @@ -3204,11 +3239,21 @@ describe("IBosonFundsHandler", function () { buyerPayoff = 0; // seller: sellerDeposit + price - protocolFee - sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); + sellerPayoff = BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .add(DRFeeToSeller) + .toString(); // protocol: protocolFee protocolPayoff = offerTokenProtocolFee; + // mutualizer: 0 or DRFee + mutualizerPayoff = DRFeeToMutualizer; + + // DR: 0 + disputeResolverPayoff = 0; + await setNextBlockTimestamp(Number(timeout)); }); @@ -3233,6 +3278,21 @@ describe("IBosonFundsHandler", function () { rando.address, ]); expect(match).to.be.false; + + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + rando.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { @@ -3240,20 +3300,24 @@ describe("IBosonFundsHandler", function () { sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Expire the dispute, so the funds are released await disputeHandler.connect(rando).expireDispute(exchangeId); @@ -3266,17 +3330,21 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() ); expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); }); context("Offer has an agent", async function () { @@ -3310,7 +3378,7 @@ describe("IBosonFundsHandler", function () { // Exchange id exchangeId = "2"; - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute @@ -3402,11 +3470,21 @@ describe("IBosonFundsHandler", function () { .toString(); // seller: (price + sellerDeposit)*(1-buyerPercentage) - sellerPayoff = BN(offerToken.price).add(offerToken.sellerDeposit).sub(buyerPayoff).toString(); + sellerPayoff = BN(offerToken.price) + .add(offerToken.sellerDeposit) + .sub(buyerPayoff) + .add(DRFeeToSeller) + .toString(); // protocol: 0 protocolPayoff = 0; + // mutualizer: 0 or DRFee + mutualizerPayoff = DRFeeToMutualizer; + + // DR: 0 + disputeResolverPayoff = 0; + // Set the message Type, needed for signature resolutionType = [ { name: "exchangeId", type: "uint256" }, @@ -3446,6 +3524,21 @@ describe("IBosonFundsHandler", function () { .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); + + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + assistant.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { @@ -3453,20 +3546,24 @@ describe("IBosonFundsHandler", function () { sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Resolve the dispute, so the funds are released await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); @@ -3479,18 +3576,21 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() ); expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); }); context("Offer has an agent", async function () { @@ -3505,7 +3605,7 @@ describe("IBosonFundsHandler", function () { exchangeId = "2"; - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute @@ -3612,6 +3712,12 @@ describe("IBosonFundsHandler", function () { // protocol: 0 protocolPayoff = offerTokenProtocolFee; + // mutualizer: 0 + mutualizerPayoff = 0; + + // DR: DRFee + disputeResolverPayoff = DRFeeToken; + // Escalate the dispute await disputeHandler.connect(buyer).escalateDispute(exchangeId); }); @@ -3628,6 +3734,16 @@ describe("IBosonFundsHandler", function () { .to.emit(disputeHandler, "FundsReleased") .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs( + exchangeId, + disputeResolver.id, + offerToken.exchangeToken, + disputeResolverPayoff, + buyer.address + ); + //check that FundsReleased event was NOT emitted with buyer Id const txReceipt = await tx.wait(); const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ @@ -3638,6 +3754,21 @@ describe("IBosonFundsHandler", function () { buyer.address, ]); expect(match).to.be.false; + + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + buyer.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { @@ -3645,20 +3776,24 @@ describe("IBosonFundsHandler", function () { sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Retract from the dispute, so the funds are released await disputeHandler.connect(buyer).retractDispute(exchangeId); @@ -3671,17 +3806,22 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() ); expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); + expectedDRAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", disputeResolverPayoff); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore); }); context("Offer has an agent", async function () { @@ -3716,7 +3856,7 @@ describe("IBosonFundsHandler", function () { await mockToken.mint(buyer.address, agentOffer.price); await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute @@ -3795,6 +3935,12 @@ describe("IBosonFundsHandler", function () { // protocol: 0 protocolPayoff = 0; + // mutualizer: 0 + mutualizerPayoff = 0; + + // DR: DRFee + disputeResolverPayoff = DRFeeToken; + // Set the message Type, needed for signature resolutionType = [ { name: "exchangeId", type: "uint256" }, @@ -3836,7 +3982,32 @@ describe("IBosonFundsHandler", function () { .to.emit(disputeHandler, "FundsReleased") .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs( + exchangeId, + disputeResolver.id, + offerToken.exchangeToken, + disputeResolverPayoff, + assistant.address + ); + await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); + + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + assistant.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { @@ -3844,20 +4015,24 @@ describe("IBosonFundsHandler", function () { sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Resolve the dispute, so the funds are released await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); @@ -3871,16 +4046,21 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() ); + expectedDRAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", disputeResolverPayoff); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore); }); context("Offer has an agent", async function () { @@ -3899,7 +4079,7 @@ describe("IBosonFundsHandler", function () { exchangeId = "2"; - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute @@ -4023,6 +4203,12 @@ describe("IBosonFundsHandler", function () { // protocol: 0 protocolPayoff = 0; + // mutualizer: 0 + mutualizerPayoff = 0; + + // DR: DRFee + disputeResolverPayoff = DRFeeToken; + // escalate the dispute await disputeHandler.connect(buyer).escalateDispute(exchangeId); }); @@ -4038,7 +4224,32 @@ describe("IBosonFundsHandler", function () { .to.emit(disputeHandler, "FundsReleased") .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); + await expect(tx) + .to.emit(disputeHandler, "FundsReleased") + .withArgs( + exchangeId, + disputeResolver.id, + offerToken.exchangeToken, + disputeResolverPayoff, + assistantDR.address + ); + await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); + + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + assistantDR.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { @@ -4046,20 +4257,24 @@ describe("IBosonFundsHandler", function () { sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Decide the dispute, so the funds are released await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); @@ -4073,16 +4288,21 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() ); + expectedDRAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", disputeResolverPayoff); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore); }); context("Offer has an agent", async function () { @@ -4101,7 +4321,7 @@ describe("IBosonFundsHandler", function () { exchangeId = "2"; - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute @@ -4192,11 +4412,17 @@ describe("IBosonFundsHandler", function () { buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); // seller: sellerDeposit - sellerPayoff = offerToken.sellerDeposit; + sellerPayoff = BN(offerToken.sellerDeposit).add(DRFeeToSeller).toString(); // protocol: 0 protocolPayoff = 0; + // mutualizer: 0 or DRFee + mutualizerPayoff = DRFeeToMutualizer; + + // DR: 0 + disputeResolverPayoff = 0; + // Escalate the dispute tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); @@ -4219,6 +4445,21 @@ describe("IBosonFundsHandler", function () { .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, rando.address); await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); + + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + rando.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { @@ -4226,20 +4467,24 @@ describe("IBosonFundsHandler", function () { sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Expire the escalated dispute, so the funds are released await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); @@ -4253,16 +4498,20 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() ); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); }); context("Offer has an agent", async function () { @@ -4281,7 +4530,7 @@ describe("IBosonFundsHandler", function () { exchangeId = "2"; - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute @@ -4363,11 +4612,17 @@ describe("IBosonFundsHandler", function () { buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); // seller: sellerDeposit - sellerPayoff = offerToken.sellerDeposit; + sellerPayoff = BN(offerToken.sellerDeposit).add(DRFeeToSeller).toString(); // protocol: 0 protocolPayoff = 0; + // mutualizer: 0 or DRFee + mutualizerPayoff = DRFeeToMutualizer; + + // DR: 0 + disputeResolverPayoff = 0; + // Escalate the dispute tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); }); @@ -4396,6 +4651,21 @@ describe("IBosonFundsHandler", function () { rando.address, ]); expect(match).to.be.false; + + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + mutualizerPayoff, + assistantDR.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); it("should update state", async function () { @@ -4403,20 +4673,24 @@ describe("IBosonFundsHandler", function () { sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); // Chain state should match the expected available funds expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); expectedBuyerAvailableFunds = new FundsList([]); expectedProtocolAvailableFunds = new FundsList([]); expectedAgentAvailableFunds = new FundsList([]); + expectedDRAvailableFunds = new FundsList([]); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); // Expire the escalated dispute, so the funds are released await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); @@ -4430,16 +4704,20 @@ describe("IBosonFundsHandler", function () { expectedSellerAvailableFunds.funds[0] = new Funds( mockToken.address, "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() + BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() ); sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); }); context("Offer has an agent", async function () { @@ -4458,7 +4736,7 @@ describe("IBosonFundsHandler", function () { exchangeId = "2"; - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute @@ -4546,7 +4824,7 @@ describe("IBosonFundsHandler", function () { // Set time forward to the offer's voucherRedeemableFrom await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // Complete the exchange, expecting event @@ -4576,7 +4854,7 @@ describe("IBosonFundsHandler", function () { // Set time forward to the offer's voucherRedeemableFrom await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // Complete the exchange, expecting event @@ -4632,7 +4910,7 @@ describe("IBosonFundsHandler", function () { // Set time forward to the offer's voucherRedeemableFrom await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // Complete the exchange, expecting event @@ -4674,7 +4952,7 @@ describe("IBosonFundsHandler", function () { // Set time forward to the offer's voucherRedeemableFrom await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // succesfully redeem exchange + // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // Complete the exchange, expecting event From 2747781c71d44418c1e241acb323a0eef095d314 Mon Sep 17 00:00:00 2001 From: zajck Date: Wed, 10 May 2023 17:56:25 +0200 Subject: [PATCH 12/33] refactor-1 --- test/protocol/FundsHandlerTest.js | 352 +++++++++++++++++++++++++++++- test/util/utils.js | 42 ++-- 2 files changed, 366 insertions(+), 28 deletions(-) diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index 2a53ccbec..c4908ba00 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -2291,6 +2291,7 @@ describe("IBosonFundsHandler", function () { }); let DRFeeToSeller, DRFeeToMutualizer; + let tests; ["self-mutualized", "external-mutualizer"].forEach((mutualizationType) => { context(`👉 releaseFunds() [${mutualizationType}]`, async function () { @@ -2299,12 +2300,13 @@ describe("IBosonFundsHandler", function () { protocolId = "0"; buyerId = "4"; exchangeId = "1"; + agentOffer.id = "2"; // Amounts that are returned if DR is not involved if (mutualizationType === "self-mutualized") { DRFeeToSeller = DRFeeToken; - DRFeeToMutualizer = 0; - offerToken.feeMutualizer = ethers.constants.AddressZero; + DRFeeToMutualizer = "0"; + offerToken.feeMutualizer = agentOffer.feeMutualizer = ethers.constants.AddressZero; // Seller must deposit enough to cover DR fees const sellerPoolToken = BN(DRFeeToken).mul(2); @@ -2316,9 +2318,9 @@ describe("IBosonFundsHandler", function () { // deposit to seller's pool await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerPoolToken); } else { - DRFeeToSeller = 0; + DRFeeToSeller = "0"; DRFeeToMutualizer = DRFeeToken; - offerToken.feeMutualizer = mutualizer.address; + offerToken.feeMutualizer = agentOffer.feeMutualizer = mutualizer.address; // Seller must deposit enough to cover DR fees const poolToken = BN(DRFeeToken).mul(2); @@ -2356,6 +2358,345 @@ describe("IBosonFundsHandler", function () { .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId), // commit to offer await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + + buyerPercentBasisPoints = "5566"; // 55.66% + const buyerPayoffSplit = BN(offerToken.price) + .add(offerToken.sellerDeposit) + .add(buyerEscalationDeposit) + .mul(buyerPercentBasisPoints) + .div("10000") + .toString(); + + // TODO: move + resolutionType = [ + { name: "exchangeId", type: "uint256" }, + { name: "buyerPercentBasisPoints", type: "uint256" }, + ]; + + customSignatureType = { + Resolution: resolutionType, + }; + + message = { + exchangeId: exchangeId, + buyerPercentBasisPoints, + }; + + // Collect the signature components + ({ r, s, v } = await prepareDataSignatureParameters( + buyer, // Assistant is the caller, seller should be the signer. + customSignatureType, + "Resolution", + message, + disputeHandler.address + )); + + // probably switch case can be used + tests = { + COMPLETED: { + payoffs: { + buyer: "0", + seller: BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .add(DRFeeToSeller) + .toString(), + protocol: offerTokenProtocolFee, + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }, + finalAction: { + handler: exchangeHandler, + method: "completeExchange", + caller: buyer, + }, + }, + REVOKED: { + payoffs: { + buyer: BN(offerToken.sellerDeposit).add(offerToken.price).toString(), + seller: DRFeeToSeller, + protocol: "0", + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }, + finalAction: { + handler: exchangeHandler, + method: "revokeVoucher", + caller: assistant, + }, + }, + CANCELED: { + payoffs: { + buyer: BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(), + seller: BN(offerToken.sellerDeposit).add(offerToken.buyerCancelPenalty).add(DRFeeToSeller).toString(), + protocol: "0", + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }, + finalAction: { + handler: exchangeHandler, + method: "cancelVoucher", + caller: buyer, + }, + }, + "DISPUTED - RETRACTED": { + finalAction: { + handler: disputeHandler, + method: "retractDispute", + caller: buyer, + }, + }, + "DISPUTED - ESCALATED - RETRACTED": { + payoffs: { + buyer: "0", + seller: BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .add(buyerEscalationDeposit) + .toString(), + protocol: offerTokenProtocolFee, + mutualizer: "0", + disputeResolver: DRFeeToken, + agent: "0", + }, + finalAction: { + handler: disputeHandler, + method: "retractDispute", + caller: buyer, + }, + }, + "DISPUTED - ESCALATED - RESOLVED": { + payoffs: { + buyer: buyerPayoffSplit, + seller: BN(offerToken.price) + .add(offerToken.sellerDeposit) + .add(buyerEscalationDeposit) + .sub(buyerPayoffSplit) + .toString(), + protocol: "0", + mutualizer: "0", + disputeResolver: DRFeeToken, + agent: "0", + }, + finalAction: { + handler: disputeHandler, + method: "resolveDispute", + caller: assistant, + additionalArgs: [buyerPercentBasisPoints, r, s, v], + }, + }, + "DISPUTED - ESCALATED - DECIDED": { + finalAction: { + handler: disputeHandler, + method: "decideDispute", + caller: assistantDR, + additionalArgs: [buyerPercentBasisPoints], + }, + }, + "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": { + payoffs: { + buyer: BN(offerToken.price).add(buyerEscalationDeposit).toString(), + seller: BN(offerToken.sellerDeposit).add(DRFeeToSeller).toString(), + protocol: "0", + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }, + finalAction: { + handler: disputeHandler, + method: "expireEscalatedDispute", + caller: rando, + }, + }, + "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)": { + finalAction: { + handler: disputeHandler, + method: "refuseEscalatedDispute", + caller: assistantDR, + }, + }, + }; + + // Duplicates + tests["DISPUTED - RETRACTED"].payoffs = tests["COMPLETED"].payoffs; + tests["DISPUTED - RETRACTED via expireDispute"] = tests["DISPUTED - RETRACTED"]; + tests["DISPUTED - ESCALATED - DECIDED"].payoffs = tests["DISPUTED - ESCALATED - RESOLVED"].payoffs; + tests["Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)"].payoffs = + tests["Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)"].payoffs; + }); + + let finalStates = [ + "COMPLETED", + "REVOKED", + "CANCELED", + "DISPUTED - RETRACTED", + "DISPUTED - RETRACTED via expireDispute", + "DISPUTED - ESCALATED - RETRACTED", + "DISPUTED - ESCALATED - RESOLVED", + "DISPUTED - ESCALATED - DECIDED", + "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)", + "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)", + ]; + + // only for states that need some setup before calling the final action + let stateSetup = { + COMPLETED: async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + }, + DISPUTED: async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); + }, + "DISPUTED - RETRACTED via expireDispute": async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + + // raise the dispute + tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); + + // Get the block timestamp of the confirmed tx and set disputedDate + blockNumber = tx.blockNumber; + block = await ethers.provider.getBlock(blockNumber); + disputedDate = block.timestamp.toString(); + timeout = BN(disputedDate).add(resolutionPeriod).toString(); + + await setNextBlockTimestamp(Number(timeout)); + }, + "DISPUTED - ESCALATED": async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); + + // Escalate the dispute + await disputeHandler.connect(buyer).escalateDispute(exchangeId); + }, + "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); + + // Escalate the dispute + tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); + + // Get the block timestamp of the confirmed tx and set escalatedDate + blockNumber = tx.blockNumber; + block = await ethers.provider.getBlock(blockNumber); + escalatedDate = block.timestamp.toString(); + + await setNextBlockTimestamp(Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod)); + }, + }; + + stateSetup["DISPUTED - RETRACTED"] = stateSetup["DISPUTED"]; + stateSetup["DISPUTED - ESCALATED - RETRACTED"] = stateSetup["DISPUTED - ESCALATED"]; + stateSetup["DISPUTED - ESCALATED - RESOLVED"] = stateSetup["DISPUTED - ESCALATED"]; + stateSetup["DISPUTED - ESCALATED - DECIDED"] = stateSetup["DISPUTED - ESCALATED"]; + stateSetup["Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)"] = + stateSetup["DISPUTED - ESCALATED"]; + + finalStates.forEach((finalState) => { + context(`Final state ${finalState}`, async function () { + beforeEach(stateSetup[finalState] || (async () => {})); + + it("should emit a FundsReleased event", async function () { + const test = tests[finalState]; + const { finalAction, payoffs } = test; + const { handler, caller, method, additionalArgs } = finalAction; + const tx = await handler.connect(caller)[method](exchangeId, ...(additionalArgs || [])); + const txReceipt = await tx.wait(); + + // Buyer + let match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + buyerId, + offerToken.exchangeToken, + payoffs.buyer, + caller.address, + ]); + expect(match).to.equal(payoffs.buyer !== "0"); + + // Seller + match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + seller.id, + offerToken.exchangeToken, + payoffs.seller, + caller.address, + ]); + expect(match).to.equal(payoffs.seller !== "0"); + + // Agent + match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + agent.id, + offerToken.exchangeToken, + payoffs.buyer, + caller.address, + ]); + expect(match).to.equal(payoffs.agent !== "0"); + + // Dispute resolver + match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + disputeResolver.id, + offerToken.exchangeToken, + payoffs.disputeResolver, + caller.address, + ]); + expect(match).to.equal(payoffs.disputeResolver !== "0"); + + // Protocol fee + match = eventEmittedWithArgs(txReceipt, fundsHandler, "ProtocolFeeCollected", [ + exchangeId, + offerToken.exchangeToken, + payoffs.protocol, + caller.address, + ]); + expect(match).to.equal(payoffs.protocol !== "0"); + + // Mutualizer + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + payoffs.mutualizer, + caller.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } + }); + }); }); context("Final state COMPLETED", async function () { @@ -2517,6 +2858,7 @@ describe("IBosonFundsHandler", function () { .add(agentOffer.price) .sub(agentOfferProtocolFee) .sub(agentFee) + .add(DRFeeToSeller) .toString(); // protocol: protocolFee @@ -2538,7 +2880,7 @@ describe("IBosonFundsHandler", function () { await expect(tx) .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agent.Id, agentOffer.exchangeToken, agentPayoff, buyer.address); + .withArgs(exchangeId, agent.id, agentOffer.exchangeToken, agentPayoff, buyer.address); }); it("should update state", async function () { diff --git a/test/util/utils.js b/test/util/utils.js index 0447b5562..dbbae7a62 100644 --- a/test/util/utils.js +++ b/test/util/utils.js @@ -48,33 +48,29 @@ function eventEmittedWithArgs(receipt, factory, eventName, args) { const eventFragment = factory.interface.fragments.filter((e) => e.name == eventName); const iface = new utils.Interface(eventFragment); - for (const log in receipt.logs) { - const topics = receipt.logs[log].topics; - - for (const index in topics) { - const encodedTopic = topics[index]; - - try { - // CHECK IF TOPIC CORRESPONDS TO THE EVENT GIVEN TO FN - const event = iface.getEvent(encodedTopic); - - if (event.name == eventName) { - found = true; - const eventArgs = iface.parseLog(receipt.logs[log]).args; - match = compareArgs(eventArgs, args); - return match; - } - } catch (e) { - if (e.message.includes("no matching event")) continue; - console.log("event error: ", e); - throw new Error(e); + for (const log of receipt.logs) { + const { + topics: [eventTopic], + } = log; + + try { + // CHECK IF TOPIC CORRESPONDS TO THE EVENT GIVEN TO FN + const event = iface.getEvent(eventTopic); + + if (event.name == eventName) { + found = true; + const { args: eventArgs } = iface.parseLog(log); + match = compareArgs(eventArgs, args); + if (match) return match; } + } catch (e) { + if (e.message.includes("no matching event")) continue; + console.log("event error: ", e); + throw new Error(e); } } - if (!found) { - throw new Error(`Event with name ${eventName} was not emitted!`); - } + return found && match; } function compareArgs(eventArgs, args) { From 8143986c4a820aee6629223035351dcbafd7091a Mon Sep 17 00:00:00 2001 From: zajck Date: Thu, 11 May 2023 13:01:25 +0200 Subject: [PATCH 13/33] refactor-3 --- test/protocol/FundsHandlerTest.js | 481 +++++++++++++++++++----------- 1 file changed, 304 insertions(+), 177 deletions(-) diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index c4908ba00..faa85d89e 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -2291,7 +2291,6 @@ describe("IBosonFundsHandler", function () { }); let DRFeeToSeller, DRFeeToMutualizer; - let tests; ["self-mutualized", "external-mutualizer"].forEach((mutualizationType) => { context(`👉 releaseFunds() [${mutualizationType}]`, async function () { @@ -2358,174 +2357,6 @@ describe("IBosonFundsHandler", function () { .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId), // commit to offer await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - - buyerPercentBasisPoints = "5566"; // 55.66% - const buyerPayoffSplit = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // TODO: move - resolutionType = [ - { name: "exchangeId", type: "uint256" }, - { name: "buyerPercentBasisPoints", type: "uint256" }, - ]; - - customSignatureType = { - Resolution: resolutionType, - }; - - message = { - exchangeId: exchangeId, - buyerPercentBasisPoints, - }; - - // Collect the signature components - ({ r, s, v } = await prepareDataSignatureParameters( - buyer, // Assistant is the caller, seller should be the signer. - customSignatureType, - "Resolution", - message, - disputeHandler.address - )); - - // probably switch case can be used - tests = { - COMPLETED: { - payoffs: { - buyer: "0", - seller: BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .add(DRFeeToSeller) - .toString(), - protocol: offerTokenProtocolFee, - mutualizer: DRFeeToMutualizer, - disputeResolver: "0", - agent: "0", - }, - finalAction: { - handler: exchangeHandler, - method: "completeExchange", - caller: buyer, - }, - }, - REVOKED: { - payoffs: { - buyer: BN(offerToken.sellerDeposit).add(offerToken.price).toString(), - seller: DRFeeToSeller, - protocol: "0", - mutualizer: DRFeeToMutualizer, - disputeResolver: "0", - agent: "0", - }, - finalAction: { - handler: exchangeHandler, - method: "revokeVoucher", - caller: assistant, - }, - }, - CANCELED: { - payoffs: { - buyer: BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(), - seller: BN(offerToken.sellerDeposit).add(offerToken.buyerCancelPenalty).add(DRFeeToSeller).toString(), - protocol: "0", - mutualizer: DRFeeToMutualizer, - disputeResolver: "0", - agent: "0", - }, - finalAction: { - handler: exchangeHandler, - method: "cancelVoucher", - caller: buyer, - }, - }, - "DISPUTED - RETRACTED": { - finalAction: { - handler: disputeHandler, - method: "retractDispute", - caller: buyer, - }, - }, - "DISPUTED - ESCALATED - RETRACTED": { - payoffs: { - buyer: "0", - seller: BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .add(buyerEscalationDeposit) - .toString(), - protocol: offerTokenProtocolFee, - mutualizer: "0", - disputeResolver: DRFeeToken, - agent: "0", - }, - finalAction: { - handler: disputeHandler, - method: "retractDispute", - caller: buyer, - }, - }, - "DISPUTED - ESCALATED - RESOLVED": { - payoffs: { - buyer: buyerPayoffSplit, - seller: BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoffSplit) - .toString(), - protocol: "0", - mutualizer: "0", - disputeResolver: DRFeeToken, - agent: "0", - }, - finalAction: { - handler: disputeHandler, - method: "resolveDispute", - caller: assistant, - additionalArgs: [buyerPercentBasisPoints, r, s, v], - }, - }, - "DISPUTED - ESCALATED - DECIDED": { - finalAction: { - handler: disputeHandler, - method: "decideDispute", - caller: assistantDR, - additionalArgs: [buyerPercentBasisPoints], - }, - }, - "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": { - payoffs: { - buyer: BN(offerToken.price).add(buyerEscalationDeposit).toString(), - seller: BN(offerToken.sellerDeposit).add(DRFeeToSeller).toString(), - protocol: "0", - mutualizer: DRFeeToMutualizer, - disputeResolver: "0", - agent: "0", - }, - finalAction: { - handler: disputeHandler, - method: "expireEscalatedDispute", - caller: rando, - }, - }, - "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)": { - finalAction: { - handler: disputeHandler, - method: "refuseEscalatedDispute", - caller: assistantDR, - }, - }, - }; - - // Duplicates - tests["DISPUTED - RETRACTED"].payoffs = tests["COMPLETED"].payoffs; - tests["DISPUTED - RETRACTED via expireDispute"] = tests["DISPUTED - RETRACTED"]; - tests["DISPUTED - ESCALATED - DECIDED"].payoffs = tests["DISPUTED - ESCALATED - RESOLVED"].payoffs; - tests["Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)"].payoffs = - tests["Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)"].payoffs; }); let finalStates = [ @@ -2614,19 +2445,212 @@ describe("IBosonFundsHandler", function () { }; stateSetup["DISPUTED - RETRACTED"] = stateSetup["DISPUTED"]; - stateSetup["DISPUTED - ESCALATED - RETRACTED"] = stateSetup["DISPUTED - ESCALATED"]; - stateSetup["DISPUTED - ESCALATED - RESOLVED"] = stateSetup["DISPUTED - ESCALATED"]; - stateSetup["DISPUTED - ESCALATED - DECIDED"] = stateSetup["DISPUTED - ESCALATED"]; - stateSetup["Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)"] = - stateSetup["DISPUTED - ESCALATED"]; + stateSetup["DISPUTED - ESCALATED - RETRACTED"] = + stateSetup["DISPUTED - ESCALATED - RESOLVED"] = + stateSetup["DISPUTED - ESCALATED - DECIDED"] = + stateSetup["Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)"] = + stateSetup["DISPUTED - ESCALATED"]; finalStates.forEach((finalState) => { context(`Final state ${finalState}`, async function () { - beforeEach(stateSetup[finalState] || (async () => {})); + let payoffs, finalAction; + + beforeEach(async function () { + await (stateSetup[finalState] || (() => {}))(); + + // Set the payoffs + switch (finalState) { + case "COMPLETED": + case "DISPUTED - RETRACTED": + case "DISPUTED - RETRACTED via expireDispute": + payoffs = { + buyer: "0", + seller: BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .add(DRFeeToSeller) + .toString(), + protocol: offerTokenProtocolFee, + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }; + break; + case "REVOKED": + payoffs = { + buyer: BN(offerToken.sellerDeposit).add(offerToken.price).toString(), + seller: DRFeeToSeller, + protocol: "0", + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }; + break; + case "CANCELED": + payoffs = { + buyer: BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(), + seller: BN(offerToken.sellerDeposit) + .add(offerToken.buyerCancelPenalty) + .add(DRFeeToSeller) + .toString(), + protocol: "0", + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }; + break; + case "DISPUTED - ESCALATED - RETRACTED": + payoffs = { + buyer: "0", + seller: BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .add(buyerEscalationDeposit) + .toString(), + protocol: offerTokenProtocolFee, + mutualizer: "0", + disputeResolver: DRFeeToken, + agent: "0", + }; + break; + case "DISPUTED - ESCALATED - RESOLVED": + case "DISPUTED - ESCALATED - DECIDED": { + buyerPercentBasisPoints = "5566"; // 55.66% + const buyerPayoffSplit = BN(offerToken.price) + .add(offerToken.sellerDeposit) + .add(buyerEscalationDeposit) + .mul(buyerPercentBasisPoints) + .div("10000") + .toString(); + + payoffs = { + buyer: buyerPayoffSplit, + seller: BN(offerToken.price) + .add(offerToken.sellerDeposit) + .add(buyerEscalationDeposit) + .sub(buyerPayoffSplit) + .toString(), + protocol: "0", + mutualizer: "0", + disputeResolver: DRFeeToken, + agent: "0", + }; + break; + } + case "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": + case "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)": + payoffs = { + buyer: BN(offerToken.price).add(buyerEscalationDeposit).toString(), + seller: BN(offerToken.sellerDeposit).add(DRFeeToSeller).toString(), + protocol: "0", + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }; + break; + } + + // Set the final actions + switch (finalState) { + case "COMPLETED": + finalAction = { + handler: exchangeHandler, + method: "completeExchange", + caller: buyer, + }; + break; + case "REVOKED": + finalAction = { + handler: exchangeHandler, + method: "revokeVoucher", + caller: assistant, + }; + break; + case "CANCELED": + finalAction = { + handler: exchangeHandler, + method: "cancelVoucher", + caller: buyer, + }; + break; + case "DISPUTED - RETRACTED": + finalAction = { + handler: disputeHandler, + method: "retractDispute", + caller: buyer, + }; + break; + case "DISPUTED - RETRACTED via expireDispute": + finalAction = { + handler: disputeHandler, + method: "expireDispute", + caller: rando, + }; + break; + case "DISPUTED - ESCALATED - RETRACTED": + finalAction = { + handler: disputeHandler, + method: "retractDispute", + caller: buyer, + }; + break; + case "DISPUTED - ESCALATED - RESOLVED": + resolutionType = [ + { name: "exchangeId", type: "uint256" }, + { name: "buyerPercentBasisPoints", type: "uint256" }, + ]; + + customSignatureType = { + Resolution: resolutionType, + }; + + message = { + exchangeId: exchangeId, + buyerPercentBasisPoints, + }; + + // Collect the signature components + ({ r, s, v } = await prepareDataSignatureParameters( + buyer, // Assistant is the caller, seller should be the signer. + customSignatureType, + "Resolution", + message, + disputeHandler.address + )); + + finalAction = { + handler: disputeHandler, + method: "resolveDispute", + caller: assistant, + additionalArgs: [buyerPercentBasisPoints, r, s, v], + }; + break; + case "DISPUTED - ESCALATED - DECIDED": + finalAction = { + handler: disputeHandler, + method: "decideDispute", + caller: assistantDR, + additionalArgs: [buyerPercentBasisPoints], + }; + break; + case "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": + finalAction = { + handler: disputeHandler, + method: "expireEscalatedDispute", + caller: rando, + }; + break; + case "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)": + finalAction = { + handler: disputeHandler, + method: "refuseEscalatedDispute", + caller: assistantDR, + }; + break; + } + }); it("should emit a FundsReleased event", async function () { - const test = tests[finalState]; - const { finalAction, payoffs } = test; const { handler, caller, method, additionalArgs } = finalAction; const tx = await handler.connect(caller)[method](exchangeId, ...(additionalArgs || [])); const txReceipt = await tx.wait(); @@ -2696,9 +2720,112 @@ describe("IBosonFundsHandler", function () { ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field } }); + + async function getAllAvailableFunds() { + const availableFunds = {}; + let mutualizerTokenBalance; + [ + ...{ + 0: availableFunds.seller, + 1: availableFunds.buyer, + 2: availableFunds.protocol, + 3: availableFunds.agent, + 4: availableFunds.disputeResolver, + 5: mutualizerTokenBalance, + } + ] = await Promise.all([ + fundsHandler.getAvailableFunds(seller.id), + fundsHandler.getAvailableFunds(buyerId), + fundsHandler.getAvailableFunds(protocolId), + fundsHandler.getAvailableFunds(agent.id), + fundsHandler.getAvailableFunds(disputeResolver.id), + mockToken.balanceOf(mutualizer.address), + ]); + + return { availableFunds, mutualizerTokenBalance }; + } + + it("should update state", async function () { + // Read on chain state + let { availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceBefore } = + await getAllAvailableFunds(); + + // Chain state should match the expected available funds + let expectedAvailableFunds = {}; + expectedAvailableFunds.seller = new FundsList([ + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedAvailableFunds.buyer = new FundsList([]); + expectedAvailableFunds.protocol = new FundsList([]); + expectedAvailableFunds.agent = new FundsList([]); + expectedAvailableFunds.disputeResolver = new FundsList([]); + + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + } + + // Execute the final action so the funds are released + const { handler, caller, method, additionalArgs } = finalAction; + await handler.connect(caller)[method](exchangeId, ...(additionalArgs || [])); + + // Increase available funds + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + if (payoffs[key] !== "0") { + if (value.funds[0]) { + // If funds are non empty, mockToken is the first entry + value.funds[0].availableAmount = BN(value.funds[0].availableAmount).add(payoffs[key]).toString(); + } else { + value.funds.push(new Funds(mockToken.address, "Foreign20", payoffs[key])); + } + } + } + + // Read on chain state + let mutualizerTokenBalanceAfter; + ({ availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceAfter } = await getAllAvailableFunds()); + + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + } + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(payoffs.mutualizer)); + }); }); }); + it.skip("no new entry is created when multiple exchanges are finalizer", async function () { + // ToDo: implement + // complete another exchange so we test funds are only updated, no new entry is created + // await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + // await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); + // await exchangeHandler.connect(buyer).completeExchange(exchangeId); + // ({ availableFunds } = await getAllAvailableFunds()); + // availableFunds.seller.funds[0].availableAmount = BN(value.funds[0].availableAmount).add(payoffs[key]).toString(); + // for (let [key, value] of Object.entries(expectedAvailableFunds)) { + // expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + // } + // sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + // buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + // protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + // agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + // DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + // expectedSellerAvailableFunds.funds[1] = new Funds( + // mockToken.address, + // "Foreign20", + // BN(sellerPayoff).mul(2).toString() + // ); + // expectedProtocolAvailableFunds.funds[0] = new Funds( + // mockToken.address, + // "Foreign20", + // BN(protocolPayoff).mul(2).toString() + // ); + // expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + // expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + // expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + // expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + }); + context("Final state COMPLETED", async function () { beforeEach(async function () { // Set time forward to the offer's voucherRedeemableFrom From 35116875a2052fe86c7942cb2990070711127779 Mon Sep 17 00:00:00 2001 From: zajck Date: Thu, 11 May 2023 13:38:47 +0200 Subject: [PATCH 14/33] refactor-4 --- test/protocol/FundsHandlerTest.js | 3667 +++++------------------------ 1 file changed, 611 insertions(+), 3056 deletions(-) diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index faa85d89e..4224cec39 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -74,24 +74,13 @@ describe("IBosonFundsHandler", function () { let resolutionPeriod, offerDurations; let protocolFeePercentage, buyerEscalationDepositPercentage; let block, blockNumber; - let protocolId, - exchangeId, - buyerId, - randoBuyerId, - sellerPayoff, - buyerPayoff, - protocolPayoff, - disputeResolverPayoff, - mutualizerPayoff; + let protocolId, exchangeId, buyerId, randoBuyerId, sellerPayoff, buyerPayoff, protocolPayoff; let sellersAvailableFunds, buyerAvailableFunds, protocolAvailableFunds, expectedSellerAvailableFunds, expectedBuyerAvailableFunds, - expectedProtocolAvailableFunds, - expectedAgentAvailableFunds, - expectedDRAvailableFunds; - let mutualizerTokenBalanceBefore, mutualizerTokenBalanceAfter; + expectedProtocolAvailableFunds; let tokenListSeller, tokenListBuyer, tokenAmountsSeller, tokenAmountsBuyer, tokenList, tokenAmounts; let tx, txReceipt, txCost, event; let disputeResolverFees, disputeResolver, disputeResolverId; @@ -100,15 +89,7 @@ describe("IBosonFundsHandler", function () { let disputedDate, escalatedDate, timeout; let voucherInitValues; let emptyAuthToken; - let agent, - agentId, - agentFeePercentage, - agentFee, - agentPayoff, - agentOffer, - agentOfferProtocolFee, - agentAvailableFunds, - DRAvailableFunds; + let agent, agentId, agentFeePercentage, agentFee, agentPayoff, agentOffer, agentOfferProtocolFee; let DRFeeToken, DRFeeNative, buyerEscalationDeposit; let protocolDiamondAddress; let snapshotId; @@ -1524,9 +1505,9 @@ describe("IBosonFundsHandler", function () { }); // Agents - // Create a valid agent, - agentFeePercentage = "500"; //5% + // Create a valid agent agent = mockAgent(other.address); + agentFeePercentage = agent.feePercentage; // 5% (default) expect(agent.isValid()).is.true; @@ -2294,2501 +2275,108 @@ describe("IBosonFundsHandler", function () { ["self-mutualized", "external-mutualizer"].forEach((mutualizationType) => { context(`👉 releaseFunds() [${mutualizationType}]`, async function () { - beforeEach(async function () { - // ids - protocolId = "0"; - buyerId = "4"; - exchangeId = "1"; - agentOffer.id = "2"; - - // Amounts that are returned if DR is not involved - if (mutualizationType === "self-mutualized") { - DRFeeToSeller = DRFeeToken; - DRFeeToMutualizer = "0"; - offerToken.feeMutualizer = agentOffer.feeMutualizer = ethers.constants.AddressZero; - - // Seller must deposit enough to cover DR fees - const sellerPoolToken = BN(DRFeeToken).mul(2); - await mockToken.mint(assistant.address, sellerPoolToken); - - // approve protocol to transfer the tokens - await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerPoolToken); - - // deposit to seller's pool - await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerPoolToken); - } else { - DRFeeToSeller = "0"; - DRFeeToMutualizer = DRFeeToken; - offerToken.feeMutualizer = agentOffer.feeMutualizer = mutualizer.address; - - // Seller must deposit enough to cover DR fees - const poolToken = BN(DRFeeToken).mul(2); - await mockToken.mint(mutualizerOwner.address, poolToken); - - // approve protocol to transfer the tokens - await mockToken.connect(mutualizerOwner).approve(mutualizer.address, poolToken); - - // deposit to mutualizer - await mutualizer.connect(mutualizerOwner).deposit(mockToken.address, poolToken); - - // Create new agreement - const startTimestamp = BN(Date.now()).div(1000); // valid from now - const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days - const agreementToken = new Agreement( - assistant.address, - mockToken.address, - ethers.utils.parseUnits("1", "ether"), - ethers.utils.parseUnits("1", "ether"), - "0", - startTimestamp.toString(), - endTimestamp.toString(), - false, - false - ); - await mutualizer.connect(mutualizerOwner).newAgreement(agreementToken); - const agreementIdToken = "1"; - await mutualizer.connect(assistant).payPremium(agreementIdToken); - } - - // create offer - offerToken.id = "1"; - await offerHandler - .connect(assistant) - .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId), - // commit to offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - }); - - let finalStates = [ - "COMPLETED", - "REVOKED", - "CANCELED", - "DISPUTED - RETRACTED", - "DISPUTED - RETRACTED via expireDispute", - "DISPUTED - ESCALATED - RETRACTED", - "DISPUTED - ESCALATED - RESOLVED", - "DISPUTED - ESCALATED - DECIDED", - "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)", - "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)", - ]; - - // only for states that need some setup before calling the final action - let stateSetup = { - COMPLETED: async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - }, - DISPUTED: async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); - }, - "DISPUTED - RETRACTED via expireDispute": async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - // Get the block timestamp of the confirmed tx and set disputedDate - blockNumber = tx.blockNumber; - block = await ethers.provider.getBlock(blockNumber); - disputedDate = block.timestamp.toString(); - timeout = BN(disputedDate).add(resolutionPeriod).toString(); - - await setNextBlockTimestamp(Number(timeout)); - }, - "DISPUTED - ESCALATED": async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - // Escalate the dispute - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }, - "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - // Escalate the dispute - tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); - - // Get the block timestamp of the confirmed tx and set escalatedDate - blockNumber = tx.blockNumber; - block = await ethers.provider.getBlock(blockNumber); - escalatedDate = block.timestamp.toString(); - - await setNextBlockTimestamp(Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod)); - }, - }; - - stateSetup["DISPUTED - RETRACTED"] = stateSetup["DISPUTED"]; - stateSetup["DISPUTED - ESCALATED - RETRACTED"] = - stateSetup["DISPUTED - ESCALATED - RESOLVED"] = - stateSetup["DISPUTED - ESCALATED - DECIDED"] = - stateSetup["Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)"] = - stateSetup["DISPUTED - ESCALATED"]; - - finalStates.forEach((finalState) => { - context(`Final state ${finalState}`, async function () { - let payoffs, finalAction; - - beforeEach(async function () { - await (stateSetup[finalState] || (() => {}))(); - - // Set the payoffs - switch (finalState) { - case "COMPLETED": - case "DISPUTED - RETRACTED": - case "DISPUTED - RETRACTED via expireDispute": - payoffs = { - buyer: "0", - seller: BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .add(DRFeeToSeller) - .toString(), - protocol: offerTokenProtocolFee, - mutualizer: DRFeeToMutualizer, - disputeResolver: "0", - agent: "0", - }; - break; - case "REVOKED": - payoffs = { - buyer: BN(offerToken.sellerDeposit).add(offerToken.price).toString(), - seller: DRFeeToSeller, - protocol: "0", - mutualizer: DRFeeToMutualizer, - disputeResolver: "0", - agent: "0", - }; - break; - case "CANCELED": - payoffs = { - buyer: BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(), - seller: BN(offerToken.sellerDeposit) - .add(offerToken.buyerCancelPenalty) - .add(DRFeeToSeller) - .toString(), - protocol: "0", - mutualizer: DRFeeToMutualizer, - disputeResolver: "0", - agent: "0", - }; - break; - case "DISPUTED - ESCALATED - RETRACTED": - payoffs = { - buyer: "0", - seller: BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .add(buyerEscalationDeposit) - .toString(), - protocol: offerTokenProtocolFee, - mutualizer: "0", - disputeResolver: DRFeeToken, - agent: "0", - }; - break; - case "DISPUTED - ESCALATED - RESOLVED": - case "DISPUTED - ESCALATED - DECIDED": { - buyerPercentBasisPoints = "5566"; // 55.66% - const buyerPayoffSplit = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - payoffs = { - buyer: buyerPayoffSplit, - seller: BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoffSplit) - .toString(), - protocol: "0", - mutualizer: "0", - disputeResolver: DRFeeToken, - agent: "0", - }; - break; - } - case "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": - case "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)": - payoffs = { - buyer: BN(offerToken.price).add(buyerEscalationDeposit).toString(), - seller: BN(offerToken.sellerDeposit).add(DRFeeToSeller).toString(), - protocol: "0", - mutualizer: DRFeeToMutualizer, - disputeResolver: "0", - agent: "0", - }; - break; - } - - // Set the final actions - switch (finalState) { - case "COMPLETED": - finalAction = { - handler: exchangeHandler, - method: "completeExchange", - caller: buyer, - }; - break; - case "REVOKED": - finalAction = { - handler: exchangeHandler, - method: "revokeVoucher", - caller: assistant, - }; - break; - case "CANCELED": - finalAction = { - handler: exchangeHandler, - method: "cancelVoucher", - caller: buyer, - }; - break; - case "DISPUTED - RETRACTED": - finalAction = { - handler: disputeHandler, - method: "retractDispute", - caller: buyer, - }; - break; - case "DISPUTED - RETRACTED via expireDispute": - finalAction = { - handler: disputeHandler, - method: "expireDispute", - caller: rando, - }; - break; - case "DISPUTED - ESCALATED - RETRACTED": - finalAction = { - handler: disputeHandler, - method: "retractDispute", - caller: buyer, - }; - break; - case "DISPUTED - ESCALATED - RESOLVED": - resolutionType = [ - { name: "exchangeId", type: "uint256" }, - { name: "buyerPercentBasisPoints", type: "uint256" }, - ]; - - customSignatureType = { - Resolution: resolutionType, - }; - - message = { - exchangeId: exchangeId, - buyerPercentBasisPoints, - }; - - // Collect the signature components - ({ r, s, v } = await prepareDataSignatureParameters( - buyer, // Assistant is the caller, seller should be the signer. - customSignatureType, - "Resolution", - message, - disputeHandler.address - )); - - finalAction = { - handler: disputeHandler, - method: "resolveDispute", - caller: assistant, - additionalArgs: [buyerPercentBasisPoints, r, s, v], - }; - break; - case "DISPUTED - ESCALATED - DECIDED": - finalAction = { - handler: disputeHandler, - method: "decideDispute", - caller: assistantDR, - additionalArgs: [buyerPercentBasisPoints], - }; - break; - case "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": - finalAction = { - handler: disputeHandler, - method: "expireEscalatedDispute", - caller: rando, - }; - break; - case "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)": - finalAction = { - handler: disputeHandler, - method: "refuseEscalatedDispute", - caller: assistantDR, - }; - break; - } - }); - - it("should emit a FundsReleased event", async function () { - const { handler, caller, method, additionalArgs } = finalAction; - const tx = await handler.connect(caller)[method](exchangeId, ...(additionalArgs || [])); - const txReceipt = await tx.wait(); - - // Buyer - let match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ - exchangeId, - buyerId, - offerToken.exchangeToken, - payoffs.buyer, - caller.address, - ]); - expect(match).to.equal(payoffs.buyer !== "0"); - - // Seller - match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ - exchangeId, - seller.id, - offerToken.exchangeToken, - payoffs.seller, - caller.address, - ]); - expect(match).to.equal(payoffs.seller !== "0"); - - // Agent - match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ - exchangeId, - agent.id, - offerToken.exchangeToken, - payoffs.buyer, - caller.address, - ]); - expect(match).to.equal(payoffs.agent !== "0"); - - // Dispute resolver - match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ - exchangeId, - disputeResolver.id, - offerToken.exchangeToken, - payoffs.disputeResolver, - caller.address, - ]); - expect(match).to.equal(payoffs.disputeResolver !== "0"); - - // Protocol fee - match = eventEmittedWithArgs(txReceipt, fundsHandler, "ProtocolFeeCollected", [ - exchangeId, - offerToken.exchangeToken, - payoffs.protocol, - caller.address, - ]); - expect(match).to.equal(payoffs.protocol !== "0"); - - // Mutualizer - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - payoffs.mutualizer, - caller.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); - - async function getAllAvailableFunds() { - const availableFunds = {}; - let mutualizerTokenBalance; - [ - ...{ - 0: availableFunds.seller, - 1: availableFunds.buyer, - 2: availableFunds.protocol, - 3: availableFunds.agent, - 4: availableFunds.disputeResolver, - 5: mutualizerTokenBalance, - } - ] = await Promise.all([ - fundsHandler.getAvailableFunds(seller.id), - fundsHandler.getAvailableFunds(buyerId), - fundsHandler.getAvailableFunds(protocolId), - fundsHandler.getAvailableFunds(agent.id), - fundsHandler.getAvailableFunds(disputeResolver.id), - mockToken.balanceOf(mutualizer.address), - ]); - - return { availableFunds, mutualizerTokenBalance }; - } - - it("should update state", async function () { - // Read on chain state - let { availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceBefore } = - await getAllAvailableFunds(); - - // Chain state should match the expected available funds - let expectedAvailableFunds = {}; - expectedAvailableFunds.seller = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedAvailableFunds.buyer = new FundsList([]); - expectedAvailableFunds.protocol = new FundsList([]); - expectedAvailableFunds.agent = new FundsList([]); - expectedAvailableFunds.disputeResolver = new FundsList([]); - - for (let [key, value] of Object.entries(expectedAvailableFunds)) { - expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); - } - - // Execute the final action so the funds are released - const { handler, caller, method, additionalArgs } = finalAction; - await handler.connect(caller)[method](exchangeId, ...(additionalArgs || [])); - - // Increase available funds - for (let [key, value] of Object.entries(expectedAvailableFunds)) { - if (payoffs[key] !== "0") { - if (value.funds[0]) { - // If funds are non empty, mockToken is the first entry - value.funds[0].availableAmount = BN(value.funds[0].availableAmount).add(payoffs[key]).toString(); - } else { - value.funds.push(new Funds(mockToken.address, "Foreign20", payoffs[key])); - } - } - } - - // Read on chain state - let mutualizerTokenBalanceAfter; - ({ availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceAfter } = await getAllAvailableFunds()); - - for (let [key, value] of Object.entries(expectedAvailableFunds)) { - expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); - } - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(payoffs.mutualizer)); - }); - }); - }); - - it.skip("no new entry is created when multiple exchanges are finalizer", async function () { - // ToDo: implement - // complete another exchange so we test funds are only updated, no new entry is created - // await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - // await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); - // await exchangeHandler.connect(buyer).completeExchange(exchangeId); - // ({ availableFunds } = await getAllAvailableFunds()); - // availableFunds.seller.funds[0].availableAmount = BN(value.funds[0].availableAmount).add(payoffs[key]).toString(); - // for (let [key, value] of Object.entries(expectedAvailableFunds)) { - // expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); - // } - // sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - // buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - // protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - // agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - // DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - // expectedSellerAvailableFunds.funds[1] = new Funds( - // mockToken.address, - // "Foreign20", - // BN(sellerPayoff).mul(2).toString() - // ); - // expectedProtocolAvailableFunds.funds[0] = new Funds( - // mockToken.address, - // "Foreign20", - // BN(protocolPayoff).mul(2).toString() - // ); - // expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - // expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - // expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - // expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - }); - - context("Final state COMPLETED", async function () { - beforeEach(async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // seller: sellerDeposit + price - protocolFee + (if self-mutualized: DRFeeToSeller) - sellerPayoff = BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .add(DRFeeToSeller) - .toString(); - - // protocol: protocolFee - protocolPayoff = offerTokenProtocolFee; - - // mutualizer: 0 or DRFee - mutualizerPayoff = DRFeeToMutualizer; - - // DR: 0 - disputeResolverPayoff = 0; - }); - - it("should emit a FundsReleased event", async function () { - // Complete the exchange, expecting event - const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); - - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - buyer.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); - - it("should update state", async function () { - // commit again, so seller has nothing in available funds - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Complete the exchange so the funds are released - await exchangeHandler.connect(buyer).completeExchange(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocolFee - agentFee - // protocol: protocolFee - // agent: 0 - expectedSellerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", sellerPayoff)); - expectedProtocolAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", offerTokenProtocolFee)); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(mutualizerPayoff)); - - // complete another exchange so we test funds are only updated, no new entry is created - await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); - await exchangeHandler.connect(buyer).completeExchange(exchangeId); - - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - expectedSellerAvailableFunds.funds[1] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerPayoff).mul(2).toString() - ); - expectedProtocolAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(protocolPayoff).mul(2).toString() - ); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - // successfully redeem exchange - exchangeId = "2"; - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .add(DRFeeToSeller) - .toString(); - - // protocol: protocolFee - protocolPayoff = agentOfferProtocolFee; - }); - - it("should emit a FundsReleased event", async function () { - // Complete the exchange, expecting event - const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - - // Complete the exchange, expecting event - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agent.id, agentOffer.exchangeToken, agentPayoff, buyer.address); - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.Id)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Complete the exchange so the funds are released - await exchangeHandler.connect(buyer).completeExchange(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocolFee - agentFee - // protocol: protocolFee - // agent: agentFee - expectedSellerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", sellerPayoff)); - expectedProtocolAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", agentOfferProtocolFee) - ); - expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.Id)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - }); - - context("Final state REVOKED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: sellerDeposit + price - buyerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).toString(); - - // seller: 0 or DRFee is self-mutualized - sellerPayoff = DRFeeToSeller; - - // protocol: 0 - protocolPayoff = 0; - - // mutualizer: 0 or DRFee - mutualizerPayoff = DRFeeToMutualizer; - - // DR: 0 - disputeResolverPayoff = 0; - }); - - it("should emit a FundsReleased event", async function () { - // Revoke the voucher, expecting event - const tx = await exchangeHandler.connect(assistant).revokeVoucher(exchangeId); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); - - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - assistant.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Revoke the voucher so the funds are released - await exchangeHandler.connect(assistant).revokeVoucher(exchangeId); - - // Available funds should be increased for - // buyer: sellerDeposit + price - // seller: 0 - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(BN(DRFeeToSeller).mul(2)).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(mutualizerPayoff)); - - // Test that if buyer has some funds available, and gets more, the funds are only updated - // Commit again - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - - // Revoke another voucher - await exchangeHandler.connect(assistant).revokeVoucher(++exchangeId); - - // Available funds should be increased for - // buyer: sellerDeposit + price - // seller: 0; but during the commitToOffer, sellerDeposit is encumbered - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(buyerPayoff).mul(2).toString() - ); - expectedSellerAvailableFunds = new FundsList([ - ...(DRFeeToSeller == 0 - ? [] - : [new Funds(mockToken.address, "Foreign20", BN(DRFeeToSeller).mul(2).toString())]), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // top up seller's and buyer's account - await mockToken.mint(assistant.address, `${2 * sellerDeposit}`); - await mockToken.mint(buyer.address, `${2 * price}`); - - // approve protocol to transfer the tokens - await mockToken.connect(assistant).approve(protocolDiamondAddress, `${2 * sellerDeposit}`); - await mockToken.connect(buyer).approve(protocolDiamondAddress, `${2 * price}`); - - // deposit to seller's pool - await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, `${2 * sellerDeposit}`); - - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - // expected payoffs - // buyer: sellerDeposit + price - buyerPayoff = BN(agentOffer.sellerDeposit).add(agentOffer.price).toString(); - - // seller: 0 - sellerPayoff = 0; - - // protocol: 0 - protocolPayoff = 0; - - // agent: 0 - agentPayoff = 0; - - exchangeId = "2"; - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", `${2 * sellerDeposit}`), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Revoke the voucher so the funds are released - await exchangeHandler.connect(assistant).revokeVoucher(exchangeId); - - // Available funds should be increased for - // buyer: sellerDeposit + price - // seller: 0 - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Test that if buyer has some funds available, and gets more, the funds are only updated - // Commit again - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - // Revoke another voucher - await exchangeHandler.connect(assistant).revokeVoucher(++exchangeId); - - // Available funds should be increased for - // buyer: sellerDeposit + price - // seller: 0; but during the commitToOffer, sellerDeposit is encumbered - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(buyerPayoff).mul(2).toString() - ); - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", `${sellerDeposit}`), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - }); - - context("Final state CANCELED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: price - buyerCancelPenalty - buyerPayoff = BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(); - - // seller: sellerDeposit + buyerCancelPenalty + (if self-mutualized: DRFeeToSeller) - sellerPayoff = BN(offerToken.sellerDeposit) - .add(offerToken.buyerCancelPenalty) - .add(DRFeeToSeller) - .toString(); - - // protocol: 0 - protocolPayoff = 0; - - // mutualizer: 0 or DRFee - mutualizerPayoff = DRFeeToMutualizer; - - // DR: 0 - disputeResolverPayoff = 0; - }); - - it("should emit a FundsReleased event", async function () { - // Cancel the voucher, expecting event - const tx = await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, buyer.address); - - await expect(tx).to.not.emit(exchangeHandler, "ProtocolFeeCollected"); - - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - buyer.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(BN(DRFeeToSeller)).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Cancel the voucher, so the funds are released - await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); - - // Available funds should be increased for - // buyer: price - buyerCancelPenalty - // seller: sellerDeposit + buyerCancelPenalty; note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() - ); - expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // top up seller's and buyer's account - await mockToken.mint(assistant.address, `${2 * sellerDeposit}`); - await mockToken.mint(buyer.address, `${2 * price}`); - - // approve protocol to transfer the tokens - await mockToken.connect(assistant).approve(protocolDiamondAddress, `${2 * sellerDeposit}`); - await mockToken.connect(buyer).approve(protocolDiamondAddress, `${2 * price}`); - - // deposit to seller's pool - await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, `${sellerDeposit}`); - - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - // expected payoffs - // buyer: price - buyerCancelPenalty - buyerPayoff = BN(agentOffer.price).sub(agentOffer.buyerCancelPenalty).toString(); - - // seller: sellerDeposit + buyerCancelPenalty - sellerPayoff = BN(agentOffer.sellerDeposit).add(agentOffer.buyerCancelPenalty).toString(); - - // protocol: 0 - protocolPayoff = 0; - - // agent: 0 - agentPayoff = 0; - - exchangeId = "2"; - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", sellerDeposit), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Cancel the voucher, so the funds are released - await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); - - // Available funds should be increased for - // buyer: price - buyerCancelPenalty - // seller: sellerDeposit + buyerCancelPenalty; note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).toString() - ); - expectedBuyerAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", buyerPayoff)); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - }); - - context("Final state DISPUTED", async function () { - beforeEach(async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - // Get the block timestamp of the confirmed tx and set disputedDate - blockNumber = tx.blockNumber; - block = await ethers.provider.getBlock(blockNumber); - disputedDate = block.timestamp.toString(); - timeout = BN(disputedDate).add(resolutionPeriod).toString(); - }); - - context("Final state DISPUTED - RETRACTED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // seller: sellerDeposit + price - protocolFee - sellerPayoff = BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .add(DRFeeToSeller) - .toString(); - - // protocol: 0 - protocolPayoff = offerTokenProtocolFee; - - // mutualizer: 0 or DRFee - mutualizerPayoff = DRFeeToMutualizer; - - // DR: 0 - disputeResolverPayoff = 0; - }); - - it("should emit a FundsReleased event", async function () { - // Retract from the dispute, expecting event - const tx = await disputeHandler.connect(buyer).retractDispute(exchangeId); - - await expect(tx) - .to.emit(disputeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); - - //check that FundsReleased event was NOT emitted with buyer Id - const txReceipt = await tx.wait(); - const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ - exchangeId, - buyerId, - offerToken.exchangeToken, - buyerPayoff, - buyer.address, - ]); - expect(match).to.be.false; - - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - buyer.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Retract from the dispute, so the funds are released - await disputeHandler.connect(buyer).retractDispute(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocol fee; note that seller has sellerDeposit in availableFunds from before - // protocol: protocolFee - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() - ); - expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .toString(); - - // protocol: 0 - protocolPayoff = agentOfferProtocolFee; - - // Exchange id - exchangeId = "2"; - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); - }); - - it("should emit a FundsReleased event", async function () { - // Retract from the dispute, expecting event - const tx = await disputeHandler.connect(buyer).retractDispute(exchangeId); - - await expect(tx) - .to.emit(disputeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Retract from the dispute, so the funds are released - await disputeHandler.connect(buyer).retractDispute(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocol fee - agentFee; - // protocol: protocolFee - // agent: agentFee - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) - ); - expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - }); - - context("Final state DISPUTED - RETRACTED via expireDispute", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // seller: sellerDeposit + price - protocolFee - sellerPayoff = BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .add(DRFeeToSeller) - .toString(); - - // protocol: protocolFee - protocolPayoff = offerTokenProtocolFee; - - // mutualizer: 0 or DRFee - mutualizerPayoff = DRFeeToMutualizer; - - // DR: 0 - disputeResolverPayoff = 0; - - await setNextBlockTimestamp(Number(timeout)); - }); - - it("should emit a FundsReleased event", async function () { - // Expire the dispute, expecting event - const tx = await disputeHandler.connect(rando).expireDispute(exchangeId); - await expect(tx) - .to.emit(disputeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, rando.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, rando.address); - - //check that FundsReleased event was NOT emitted with buyer Id - const txReceipt = await tx.wait(); - const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ - exchangeId, - buyerId, - offerToken.exchangeToken, - buyerPayoff, - rando.address, - ]); - expect(match).to.be.false; - - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - rando.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Expire the dispute, so the funds are released - await disputeHandler.connect(rando).expireDispute(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocol fee; note that seller has sellerDeposit in availableFunds from before - // protocol: protocolFee - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() - ); - expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agent fee - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .toString(); - - // protocol: protocolFee - protocolPayoff = agentOfferProtocolFee; - - // Exchange id - exchangeId = "2"; - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - // Get the block timestamp of the confirmed tx and set disputedDate - blockNumber = tx.blockNumber; - block = await ethers.provider.getBlock(blockNumber); - disputedDate = block.timestamp.toString(); - timeout = BN(disputedDate).add(resolutionPeriod).toString(); - - await setNextBlockTimestamp(Number(timeout)); - }); - - it("should emit a FundsReleased event", async function () { - // Expire the dispute, expecting event - const tx = await disputeHandler.connect(rando).expireDispute(exchangeId); - - // Complete the exchange, expecting event - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, rando.address); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, rando.address); - - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, rando.address); - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Expire the dispute, so the funds are released - await disputeHandler.connect(rando).expireDispute(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocol fee - agent fee; - // protocol: protocolFee - // agent: agent fee - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - new Funds(mockToken.address, "Foreign20", sellerPayoff), - ]); - - expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - expectedAgentAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", agentPayoff); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - }); - - context("Final state DISPUTED - RESOLVED", async function () { - beforeEach(async function () { - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit)*buyerPercentage - buyerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit)*(1-buyerPercentage) - sellerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .sub(buyerPayoff) - .add(DRFeeToSeller) - .toString(); - - // protocol: 0 - protocolPayoff = 0; - - // mutualizer: 0 or DRFee - mutualizerPayoff = DRFeeToMutualizer; - - // DR: 0 - disputeResolverPayoff = 0; - - // Set the message Type, needed for signature - resolutionType = [ - { name: "exchangeId", type: "uint256" }, - { name: "buyerPercentBasisPoints", type: "uint256" }, - ]; - - customSignatureType = { - Resolution: resolutionType, - }; - - message = { - exchangeId: exchangeId, - buyerPercentBasisPoints, - }; - - // Collect the signature components - ({ r, s, v } = await prepareDataSignatureParameters( - buyer, // Assistant is the caller, seller should be the signer. - customSignatureType, - "Resolution", - message, - disputeHandler.address - )); - }); - - it("should emit a FundsReleased event", async function () { - // Resolve the dispute, expecting event - const tx = await disputeHandler - .connect(assistant) - .resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistant.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); - - await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); - - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - assistant.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Resolve the dispute, so the funds are released - await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); - - // Available funds should be increased for - // buyer: (price + sellerDeposit)*buyerPercentage - // seller: (price + sellerDeposit)*(1-buyerPercentage); note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() - ); - expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - exchangeId = "2"; - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit)*buyerPercentage - buyerPayoff = BN(agentOffer.price) - .add(agentOffer.sellerDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit)*(1-buyerPercentage) - sellerPayoff = BN(agentOffer.price).add(agentOffer.sellerDeposit).sub(buyerPayoff).toString(); - - // protocol: 0 - protocolPayoff = 0; - - // Set the message Type, needed for signature - resolutionType = [ - { name: "exchangeId", type: "uint256" }, - { name: "buyerPercentBasisPoints", type: "uint256" }, - ]; - - customSignatureType = { - Resolution: resolutionType, - }; - - message = { - exchangeId: exchangeId, - buyerPercentBasisPoints, - }; - - // Collect the signature components - ({ r, s, v } = await prepareDataSignatureParameters( - buyer, // Assistant is the caller, seller should be the signer. - customSignatureType, - "Resolution", - message, - disputeHandler.address - )); - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Resolve the dispute, so the funds are released - await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); - - // Available funds should be increased for - // buyer: (price + sellerDeposit)*buyerPercentage - // seller: (price + sellerDeposit)*(1-buyerPercentage); - // protocol: 0 - // agent: 0 - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) - ); - expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - }); - - context("Final state DISPUTED - ESCALATED - RETRACTED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // seller: sellerDeposit + price - protocolFee + buyerEscalationDeposit - sellerPayoff = BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .add(buyerEscalationDeposit) - .toString(); - - // protocol: 0 - protocolPayoff = offerTokenProtocolFee; - - // mutualizer: 0 - mutualizerPayoff = 0; - - // DR: DRFee - disputeResolverPayoff = DRFeeToken; - - // Escalate the dispute - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); - - it("should emit a FundsReleased event", async function () { - // Retract from the dispute, expecting event - const tx = await disputeHandler.connect(buyer).retractDispute(exchangeId); - - await expect(tx) - .to.emit(disputeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs( - exchangeId, - disputeResolver.id, - offerToken.exchangeToken, - disputeResolverPayoff, - buyer.address - ); - - //check that FundsReleased event was NOT emitted with buyer Id - const txReceipt = await tx.wait(); - const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ - exchangeId, - buyerId, - offerToken.exchangeToken, - buyerPayoff, - buyer.address, - ]); - expect(match).to.be.false; - - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - buyer.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Retract from the dispute, so the funds are released - await disputeHandler.connect(buyer).retractDispute(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocol fee + buyerEscalationDeposit; note that seller has sellerDeposit in availableFunds from before - // protocol: protocolFee - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() - ); - expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - expectedDRAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", disputeResolverPayoff); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee + buyerEscalationDeposit - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .add(buyerEscalationDeposit) - .toString(); - - // protocol: 0 - protocolPayoff = agentOfferProtocolFee; - - // Exchange id - exchangeId = "2"; - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // approve protocol to transfer the tokens - await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); - await mockToken.mint(buyer.address, agentOffer.price); - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - // escalate the dispute - await mockToken.mint(buyer.address, buyerEscalationDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Retract from the dispute, so the funds are released - await disputeHandler.connect(buyer).retractDispute(exchangeId); - - // Available funds should be increased for - // buyer: 0 - // seller: sellerDeposit + price - protocol fee - agentFee + buyerEscalationDeposit; - // protocol: protocolFee - // agent: agentFee - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) - ); - expectedProtocolAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", protocolPayoff); - expectedAgentAvailableFunds.funds.push(new Funds(mockToken.address, "Foreign20", agentPayoff)); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - }); - - context("Final state DISPUTED - ESCALATED - RESOLVED", async function () { + ["no-agent", "with-agent"].forEach((agentType) => { + context(`👉 ${agentType}`, async function () { beforeEach(async function () { - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoff) - .toString(); - - // protocol: 0 - protocolPayoff = 0; - - // mutualizer: 0 - mutualizerPayoff = 0; - - // DR: DRFee - disputeResolverPayoff = DRFeeToken; - - // Set the message Type, needed for signature - resolutionType = [ - { name: "exchangeId", type: "uint256" }, - { name: "buyerPercentBasisPoints", type: "uint256" }, - ]; - - customSignatureType = { - Resolution: resolutionType, - }; - - message = { - exchangeId: exchangeId, - buyerPercentBasisPoints, - }; - - // Collect the signature components - ({ r, s, v } = await prepareDataSignatureParameters( - buyer, // Assistant is the caller, seller should be the signer. - customSignatureType, - "Resolution", - message, - disputeHandler.address - )); - - // Escalate the dispute - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); - - it("should emit a FundsReleased event", async function () { - // Resolve the dispute, expecting event - const tx = await disputeHandler - .connect(assistant) - .resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistant.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistant.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs( - exchangeId, - disputeResolver.id, - offerToken.exchangeToken, - disputeResolverPayoff, - assistant.address - ); - - await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); + // ids + protocolId = "0"; + buyerId = "4"; + exchangeId = "1"; + // Amounts that are returned if DR is not involved if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - assistant.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Resolve the dispute, so the funds are released - await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); - - // Available funds should be increased for - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() - ); - expectedDRAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", disputeResolverPayoff); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore); - }); + DRFeeToSeller = DRFeeToken; + DRFeeToMutualizer = "0"; + offerToken.feeMutualizer = ethers.constants.AddressZero; - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + // Seller must deposit enough to cover DR fees + const sellerPoolToken = BN(DRFeeToken).mul(2); + await mockToken.mint(assistant.address, sellerPoolToken); // approve protocol to transfer the tokens - await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); - await mockToken.mint(buyer.address, agentOffer.price); - - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerPoolToken); - exchangeId = "2"; - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - buyerPercentBasisPoints = "5566"; // 55.66% + // deposit to seller's pool + await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerPoolToken); + } else { + DRFeeToSeller = "0"; + DRFeeToMutualizer = DRFeeToken; + offerToken.feeMutualizer = mutualizer.address; - // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = BN(agentOffer.price) - .add(agentOffer.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = BN(agentOffer.price) - .add(agentOffer.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoff) - .toString(); - - // protocol: 0 - protocolPayoff = 0; - - // Set the message Type, needed for signature - resolutionType = [ - { name: "exchangeId", type: "uint256" }, - { name: "buyerPercentBasisPoints", type: "uint256" }, - ]; - - customSignatureType = { - Resolution: resolutionType, - }; - - message = { - exchangeId: exchangeId, - buyerPercentBasisPoints, - }; - - // Collect the signature components - ({ r, s, v } = await prepareDataSignatureParameters( - buyer, // Assistant is the caller, seller should be the signer. - customSignatureType, - "Resolution", - message, - disputeHandler.address - )); - - // escalate the dispute - await mockToken.mint(buyer.address, buyerEscalationDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); + // Seller must deposit enough to cover DR fees + const poolToken = BN(DRFeeToken).mul(2); + await mockToken.mint(mutualizerOwner.address, poolToken); - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Resolve the dispute, so the funds are released - await disputeHandler.connect(assistant).resolveDispute(exchangeId, buyerPercentBasisPoints, r, s, v); - - // Available funds should be increased for - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); - // protocol: 0 - // agent: 0 - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) - ); - expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - }); + // approve protocol to transfer the tokens + await mockToken.connect(mutualizerOwner).approve(mutualizer.address, poolToken); - context("Final state DISPUTED - ESCALATED - DECIDED", async function () { - beforeEach(async function () { - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoff) - .toString(); - - // protocol: 0 - protocolPayoff = 0; - - // mutualizer: 0 - mutualizerPayoff = 0; - - // DR: DRFee - disputeResolverPayoff = DRFeeToken; - - // escalate the dispute - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); + // deposit to mutualizer + await mutualizer.connect(mutualizerOwner).deposit(mockToken.address, poolToken); - it("should emit a FundsReleased event", async function () { - // Decide the dispute, expecting event - const tx = await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistantDR.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs( - exchangeId, - disputeResolver.id, - offerToken.exchangeToken, - disputeResolverPayoff, - assistantDR.address + // Create new agreement + const startTimestamp = BN(Date.now()).div(1000); // valid from now + const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days + const agreementToken = new Agreement( + assistant.address, + mockToken.address, + ethers.utils.parseUnits("1", "ether"), + ethers.utils.parseUnits("1", "ether"), + "0", + startTimestamp.toString(), + endTimestamp.toString(), + false, + false ); - - await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); - - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - assistantDR.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + await mutualizer.connect(mutualizerOwner).newAgreement(agreementToken); + const agreementIdToken = "1"; + await mutualizer.connect(assistant).payPremium(agreementIdToken); } - }); - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Decide the dispute, so the funds are released - await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); - - // Available funds should be increased for - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() - ); - expectedDRAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", disputeResolverPayoff); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore); + // create offer + offerToken.id = "1"; + agentId = agentType === "no-agent" ? "0" : agent.id; + await offerHandler + .connect(assistant) + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId), + // commit to offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); }); - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + let finalStates = [ + "COMPLETED", + "REVOKED", + "CANCELED", + "DISPUTED - RETRACTED", + "DISPUTED - RETRACTED via expireDispute", + "DISPUTED - ESCALATED - RETRACTED", + "DISPUTED - ESCALATED - RESOLVED", + "DISPUTED - ESCALATED - DECIDED", + "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)", + "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)", + ]; + + // only for states that need some setup before calling the final action + let stateSetup = { + COMPLETED: async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // approve protocol to transfer the tokens - await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); - await mockToken.mint(buyer.address, agentOffer.price); + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + }, + DISPUTED: async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - exchangeId = "2"; + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); + }, + "DISPUTED - RETRACTED via expireDispute": async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); @@ -4802,222 +2390,33 @@ describe("IBosonFundsHandler", function () { disputedDate = block.timestamp.toString(); timeout = BN(disputedDate).add(resolutionPeriod).toString(); - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = BN(agentOffer.price) - .add(agentOffer.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = BN(agentOffer.price) - .add(agentOffer.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoff) - .toString(); - - // protocol: 0 - protocolPayoff = 0; - - // escalate the dispute - await mockToken.mint(buyer.address, buyerEscalationDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Decide the dispute, so the funds are released - await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); - - // Available funds should be increased for - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage); - // protocol: 0 - // agent: 0 - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) - ); - expectedBuyerAvailableFunds = new FundsList([new Funds(mockToken.address, "Foreign20", buyerPayoff)]); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - }); - - context( - "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)", - async function () { - beforeEach(async function () { - // expected payoffs - // buyer: price + buyerEscalationDeposit - buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); - - // seller: sellerDeposit - sellerPayoff = BN(offerToken.sellerDeposit).add(DRFeeToSeller).toString(); - - // protocol: 0 - protocolPayoff = 0; + await setNextBlockTimestamp(Number(timeout)); + }, + "DISPUTED - ESCALATED": async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // mutualizer: 0 or DRFee - mutualizerPayoff = DRFeeToMutualizer; + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // DR: 0 - disputeResolverPayoff = 0; + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); // Escalate the dispute - tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); - - // Get the block timestamp of the confirmed tx and set escalatedDate - blockNumber = tx.blockNumber; - block = await ethers.provider.getBlock(blockNumber); - escalatedDate = block.timestamp.toString(); - - await setNextBlockTimestamp(Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod)); - }); - - it("should emit a FundsReleased event", async function () { - // Expire the dispute, expecting event - const tx = await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, rando.address); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, rando.address); - - await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); - - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - rando.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Expire the escalated dispute, so the funds are released - await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); - - // Available funds should be increased for - // buyer: price + buyerEscalationDeposit - // seller: sellerDeposit; note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() - ); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // approve protocol to transfer the tokens - await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); - await mockToken.mint(buyer.address, agentOffer.price); - - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - exchangeId = "2"; + await disputeHandler.connect(buyer).escalateDispute(exchangeId); + }, + "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": + async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); // successfully redeem exchange await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // raise the dispute - tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); - - // expected payoffs - // buyer: price + buyerEscalationDeposit - buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); - - // seller: sellerDeposit - sellerPayoff = offerToken.sellerDeposit; - - // protocol: 0 - protocolPayoff = 0; + await disputeHandler.connect(buyer).raiseDispute(exchangeId); // Escalate the dispute - await mockToken.mint(buyer.address, buyerEscalationDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); // Get the block timestamp of the confirmed tx and set escalatedDate @@ -5026,419 +2425,575 @@ describe("IBosonFundsHandler", function () { escalatedDate = block.timestamp.toString(); await setNextBlockTimestamp(Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod)); - }); - - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Expire the escalated dispute, so the funds are released - await disputeHandler.connect(rando).expireEscalatedDispute(exchangeId); - - // Available funds should be increased for - // buyer: price + buyerEscalationDeposit - // seller: sellerDeposit; - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) - ); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); - }); - } - ); - - context( - "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)", - async function () { - beforeEach(async function () { - // expected payoffs - // buyer: price + buyerEscalationDeposit - buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); - - // seller: sellerDeposit - sellerPayoff = BN(offerToken.sellerDeposit).add(DRFeeToSeller).toString(); - - // protocol: 0 - protocolPayoff = 0; - - // mutualizer: 0 or DRFee - mutualizerPayoff = DRFeeToMutualizer; - - // DR: 0 - disputeResolverPayoff = 0; - - // Escalate the dispute - tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); - - it("should emit a FundsReleased event", async function () { - // Expire the dispute, expecting event - const tx = await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); - - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistantDR.address); + }, + }; - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); - - await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); - - //check that FundsReleased event was NOT emitted with rando address - const txReceipt = await tx.wait(); - const match = eventEmittedWithArgs(txReceipt, disputeHandler, "FundsReleased", [ - exchangeId, - seller.id, - offerToken.exchangeToken, - sellerPayoff, - rando.address, - ]); - expect(match).to.be.false; - - if (mutualizationType === "self-mutualized") { - await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); - } else { - await expect(tx) - .to.emit(exchangeHandler, "DRFeeReturned") - .withArgs( - mutualizer.address, - "1", - exchangeId, - offerToken.exchangeToken, - mutualizerPayoff, - assistantDR.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field - } - }); + stateSetup["DISPUTED - RETRACTED"] = stateSetup["DISPUTED"]; + stateSetup["DISPUTED - ESCALATED - RETRACTED"] = + stateSetup["DISPUTED - ESCALATED - RESOLVED"] = + stateSetup["DISPUTED - ESCALATED - DECIDED"] = + stateSetup["Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)"] = + stateSetup["DISPUTED - ESCALATED"]; - it("should update state", async function () { - // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - - // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expectedDRAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - - // Expire the escalated dispute, so the funds are released - await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); - - // Available funds should be increased for - // buyer: price + buyerEscalationDeposit - // seller: sellerDeposit; note that seller has sellerDeposit in availableFunds from before - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - BN(sellerDeposit).add(sellerPayoff).add(DRFeeToSeller).toString() - ); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - mutualizerTokenBalanceAfter = await mockToken.balanceOf(mutualizer.address); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(DRFeeToMutualizer)); - }); + finalStates.forEach((finalState) => { + context(`Final state ${finalState}`, async function () { + let payoffs, finalAction; - context("Offer has an agent", async function () { beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // approve protocol to transfer the tokens - await mockToken.connect(buyer).approve(protocolDiamondAddress, agentOffer.price); - await mockToken.mint(buyer.address, agentOffer.price); + await (stateSetup[finalState] || (() => {}))(); + + // Set the payoffs + switch (finalState) { + case "COMPLETED": + case "DISPUTED - RETRACTED": + case "DISPUTED - RETRACTED via expireDispute": + agentFee = + agentType === "no-agent" + ? "0" + : BN(offerToken.price).mul(agentFeePercentage).div("10000").toString(); + + payoffs = { + buyer: "0", + seller: BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .sub(agentFee) + .add(DRFeeToSeller) + .toString(), + protocol: offerTokenProtocolFee, + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: agentFee, + }; + break; + case "REVOKED": + payoffs = { + buyer: BN(offerToken.sellerDeposit).add(offerToken.price).toString(), + seller: DRFeeToSeller, + protocol: "0", + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }; + break; + case "CANCELED": + payoffs = { + buyer: BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(), + seller: BN(offerToken.sellerDeposit) + .add(offerToken.buyerCancelPenalty) + .add(DRFeeToSeller) + .toString(), + protocol: "0", + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }; + break; + case "DISPUTED - ESCALATED - RETRACTED": + agentFee = + agentType === "no-agent" + ? "0" + : BN(offerToken.price).mul(agentFeePercentage).div("10000").toString(); + + payoffs = { + buyer: "0", + seller: BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .sub(agentFee) + .add(buyerEscalationDeposit) + .toString(), + protocol: offerTokenProtocolFee, + mutualizer: "0", + disputeResolver: DRFeeToken, + agent: agentFee, + }; + break; + case "DISPUTED - ESCALATED - RESOLVED": + case "DISPUTED - ESCALATED - DECIDED": { + buyerPercentBasisPoints = "5566"; // 55.66% + const buyerPayoffSplit = BN(offerToken.price) + .add(offerToken.sellerDeposit) + .add(buyerEscalationDeposit) + .mul(buyerPercentBasisPoints) + .div("10000") + .toString(); + + payoffs = { + buyer: buyerPayoffSplit, + seller: BN(offerToken.price) + .add(offerToken.sellerDeposit) + .add(buyerEscalationDeposit) + .sub(buyerPayoffSplit) + .toString(), + protocol: "0", + mutualizer: "0", + disputeResolver: DRFeeToken, + agent: "0", + }; + break; + } + case "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": + case "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)": + payoffs = { + buyer: BN(offerToken.price).add(buyerEscalationDeposit).toString(), + seller: BN(offerToken.sellerDeposit).add(DRFeeToSeller).toString(), + protocol: "0", + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: "0", + }; + break; + } - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + // Set the final actions + switch (finalState) { + case "COMPLETED": + finalAction = { + handler: exchangeHandler, + method: "completeExchange", + caller: buyer, + }; + break; + case "REVOKED": + finalAction = { + handler: exchangeHandler, + method: "revokeVoucher", + caller: assistant, + }; + break; + case "CANCELED": + finalAction = { + handler: exchangeHandler, + method: "cancelVoucher", + caller: buyer, + }; + break; + case "DISPUTED - RETRACTED": + finalAction = { + handler: disputeHandler, + method: "retractDispute", + caller: buyer, + }; + break; + case "DISPUTED - RETRACTED via expireDispute": + finalAction = { + handler: disputeHandler, + method: "expireDispute", + caller: rando, + }; + break; + case "DISPUTED - ESCALATED - RETRACTED": + finalAction = { + handler: disputeHandler, + method: "retractDispute", + caller: buyer, + }; + break; + case "DISPUTED - ESCALATED - RESOLVED": + resolutionType = [ + { name: "exchangeId", type: "uint256" }, + { name: "buyerPercentBasisPoints", type: "uint256" }, + ]; + + customSignatureType = { + Resolution: resolutionType, + }; + + message = { + exchangeId: exchangeId, + buyerPercentBasisPoints, + }; + + // Collect the signature components + ({ r, s, v } = await prepareDataSignatureParameters( + buyer, // Assistant is the caller, seller should be the signer. + customSignatureType, + "Resolution", + message, + disputeHandler.address + )); + + finalAction = { + handler: disputeHandler, + method: "resolveDispute", + caller: assistant, + additionalArgs: [buyerPercentBasisPoints, r, s, v], + }; + break; + case "DISPUTED - ESCALATED - DECIDED": + finalAction = { + handler: disputeHandler, + method: "decideDispute", + caller: assistantDR, + additionalArgs: [buyerPercentBasisPoints], + }; + break; + case "Final state DISPUTED - ESCALATED - REFUSED via expireEscalatedDispute (fail to resolve)": + finalAction = { + handler: disputeHandler, + method: "expireEscalatedDispute", + caller: rando, + }; + break; + case "Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)": + finalAction = { + handler: disputeHandler, + method: "refuseEscalatedDispute", + caller: assistantDR, + }; + break; + } + }); - exchangeId = "2"; + it("should emit a FundsReleased event", async function () { + const { handler, caller, method, additionalArgs } = finalAction; + const tx = await handler.connect(caller)[method](exchangeId, ...(additionalArgs || [])); + const txReceipt = await tx.wait(); - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // Buyer + let match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + buyerId, + offerToken.exchangeToken, + payoffs.buyer, + caller.address, + ]); + expect(match).to.equal(payoffs.buyer !== "0"); - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); + // Seller + match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + seller.id, + offerToken.exchangeToken, + payoffs.seller, + caller.address, + ]); + expect(match).to.equal(payoffs.seller !== "0"); - // expected payoffs - // buyer: price + buyerEscalationDeposit - buyerPayoff = BN(offerToken.price).add(buyerEscalationDeposit).toString(); + // Agent + match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + agent.id, + offerToken.exchangeToken, + payoffs.agent, + caller.address, + ]); + expect(match).to.equal(payoffs.agent !== "0"); - // seller: sellerDeposit - sellerPayoff = offerToken.sellerDeposit; + // Dispute resolver + match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + disputeResolver.id, + offerToken.exchangeToken, + payoffs.disputeResolver, + caller.address, + ]); + expect(match).to.equal(payoffs.disputeResolver !== "0"); - // protocol: 0 - protocolPayoff = 0; + // Protocol fee + match = eventEmittedWithArgs(txReceipt, fundsHandler, "ProtocolFeeCollected", [ + exchangeId, + offerToken.exchangeToken, + payoffs.protocol, + caller.address, + ]); + expect(match).to.equal(payoffs.protocol !== "0"); - // Escalate the dispute - await mockToken.mint(buyer.address, buyerEscalationDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, buyerEscalationDeposit); - await disputeHandler.connect(buyer).escalateDispute(exchangeId); + // Mutualizer + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + payoffs.mutualizer, + caller.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } }); + async function getAllAvailableFunds() { + const availableFunds = {}; + let mutualizerTokenBalance; + [ + ...{ + 0: availableFunds.seller, + 1: availableFunds.buyer, + 2: availableFunds.protocol, + 3: availableFunds.agent, + 4: availableFunds.disputeResolver, + 5: mutualizerTokenBalance, + } + ] = await Promise.all([ + fundsHandler.getAvailableFunds(seller.id), + fundsHandler.getAvailableFunds(buyerId), + fundsHandler.getAvailableFunds(protocolId), + fundsHandler.getAvailableFunds(agent.id), + fundsHandler.getAvailableFunds(disputeResolver.id), + mockToken.balanceOf(mutualizer.address), + ]); + + return { availableFunds, mutualizerTokenBalance }; + } + it("should update state", async function () { // Read on chain state - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); + let { availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceBefore } = + await getAllAvailableFunds(); // Chain state should match the expected available funds - expectedSellerAvailableFunds = new FundsList([ + let expectedAvailableFunds = {}; + expectedAvailableFunds.seller = new FundsList([ + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), ]); - expectedBuyerAvailableFunds = new FundsList([]); - expectedProtocolAvailableFunds = new FundsList([]); - expectedAgentAvailableFunds = new FundsList([]); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - - // Expire the escalated dispute, so the funds are released - await disputeHandler.connect(assistantDR).refuseEscalatedDispute(exchangeId); - - // Available funds should be increased for - // buyer: price + buyerEscalationDeposit - // seller: sellerDeposit; - // protocol: 0 - // agent: 0 - expectedBuyerAvailableFunds.funds[0] = new Funds(mockToken.address, "Foreign20", buyerPayoff); - expectedSellerAvailableFunds.funds.push( - new Funds(mockToken.address, "Foreign20", BN(sellerPayoff).toString()) - ); - sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agentId)); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + expectedAvailableFunds.buyer = new FundsList([]); + expectedAvailableFunds.protocol = new FundsList([]); + expectedAvailableFunds.agent = new FundsList([]); + expectedAvailableFunds.disputeResolver = new FundsList([]); + + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + } + + // Execute the final action so the funds are released + const { handler, caller, method, additionalArgs } = finalAction; + await handler.connect(caller)[method](exchangeId, ...(additionalArgs || [])); + + // Increase available funds + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + if (payoffs[key] !== "0") { + if (value.funds[0]) { + // If funds are non empty, mockToken is the first entry + value.funds[0].availableAmount = BN(value.funds[0].availableAmount) + .add(payoffs[key]) + .toString(); + } else { + value.funds.push(new Funds(mockToken.address, "Foreign20", payoffs[key])); + } + } + } + + // Read on chain state + let mutualizerTokenBalanceAfter; + ({ availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceAfter } = + await getAllAvailableFunds()); + + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + } + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(payoffs.mutualizer)); }); }); - } - ); - }); - - context("Changing the protocol fee", async function () { - beforeEach(async function () { - // Cast Diamond to IBosonConfigHandler - configHandler = await ethers.getContractAt("IBosonConfigHandler", protocolDiamondAddress); + }); - // expected payoffs - // buyer: 0 - buyerPayoff = 0; + it.skip("no new entry is created when multiple exchanges are finalizer", async function () { + // ToDo: implement + // complete another exchange so we test funds are only updated, no new entry is created + // await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + // await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); + // await exchangeHandler.connect(buyer).completeExchange(exchangeId); + // ({ availableFunds } = await getAllAvailableFunds()); + // availableFunds.seller.funds[0].availableAmount = BN(value.funds[0].availableAmount).add(payoffs[key]).toString(); + // for (let [key, value] of Object.entries(expectedAvailableFunds)) { + // expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + // } + // sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + // buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); + // protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); + // agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); + // DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); + // expectedSellerAvailableFunds.funds[1] = new Funds( + // mockToken.address, + // "Foreign20", + // BN(sellerPayoff).mul(2).toString() + // ); + // expectedProtocolAvailableFunds.funds[0] = new Funds( + // mockToken.address, + // "Foreign20", + // BN(protocolPayoff).mul(2).toString() + // ); + // expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); + // expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); + // expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); + // expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); + // expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + }); - // seller: sellerDeposit + price - protocolFee - sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); - }); + context("Changing the protocol fee", async function () { + beforeEach(async function () { + // Cast Diamond to IBosonConfigHandler + configHandler = await ethers.getContractAt("IBosonConfigHandler", protocolDiamondAddress); - it("Protocol fee for existing exchanges should be the same as at the offer creation", async function () { - // set the new procol fee - protocolFeePercentage = "300"; // 3% - await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); + // expected payoffs + // buyer: 0 + buyerPayoff = 0; - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // seller: sellerDeposit + price - protocolFee + sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); + }); - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + it("Protocol fee for existing exchanges should be the same as at the offer creation", async function () { + // set the new procol fee + protocolFeePercentage = "300"; // 3% + await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); - // Complete the exchange, expecting event - const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.address); - }); + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - it("Protocol fee for new exchanges should be the same as at the offer creation", async function () { - // set the new procol fee - protocolFeePercentage = "300"; // 3% - await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); + // Complete the exchange, expecting event + const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); - // similar as teste before, excpet the commit to offer is done after the procol fee change + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.address); + }); - // commit to offer and get the correct exchangeId - tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - txReceipt = await tx.wait(); - event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); - exchangeId = event.exchangeId.toString(); + it("Protocol fee for new exchanges should be the same as at the offer creation", async function () { + // set the new procol fee + protocolFeePercentage = "300"; // 3% + await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // similar as teste before, excpet the commit to offer is done after the procol fee change - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // commit to offer and get the correct exchangeId + tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + txReceipt = await tx.wait(); + event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); + exchangeId = event.exchangeId.toString(); - // Complete the exchange, expecting event - tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.address); - }); + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - context("Offer has an agent", async function () { - beforeEach(async function () { - exchangeId = "2"; + // Complete the exchange, expecting event + tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); - // Cast Diamond to IBosonConfigHandler - configHandler = await ethers.getContractAt("IBosonConfigHandler", protocolDiamondAddress); + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.address); + }); - // expected payoffs - // buyer: 0 - buyerPayoff = 0; + context("Offer has an agent", async function () { + beforeEach(async function () { + exchangeId = "2"; - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; + // Cast Diamond to IBosonConfigHandler + configHandler = await ethers.getContractAt("IBosonConfigHandler", protocolDiamondAddress); - // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .toString(); + // expected payoffs + // buyer: 0 + buyerPayoff = 0; + + // agentPayoff: agentFee + agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); + agentPayoff = agentFee; + + // seller: sellerDeposit + price - protocolFee - agentFee + sellerPayoff = BN(agentOffer.sellerDeposit) + .add(agentOffer.price) + .sub(agentOfferProtocolFee) + .sub(agentFee) + .toString(); - // protocol: protocolFee - protocolPayoff = agentOfferProtocolFee; + // protocol: protocolFee + protocolPayoff = agentOfferProtocolFee; - // Create Agent Offer before setting new protocol fee as 3% - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + // Create Agent Offer before setting new protocol fee as 3% + await offerHandler + .connect(assistant) + .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - // Commit to Agent Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + // Commit to Agent Offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - // set the new procol fee - protocolFeePercentage = "300"; // 3% - await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); - }); + // set the new procol fee + protocolFeePercentage = "300"; // 3% + await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); + }); - it("Protocol fee for existing exchanges should be the same as at the agent offer creation", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + it("Protocol fee for existing exchanges should be the same as at the agent offer creation", async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // Complete the exchange, expecting event - const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + // Complete the exchange, expecting event + const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); - }); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + }); - it("Protocol fee for new exchanges should be the same as at the agent offer creation", async function () { - // similar as tests before, excpet the commit to offer is done after the protocol fee change + it("Protocol fee for new exchanges should be the same as at the agent offer creation", async function () { + // similar as tests before, excpet the commit to offer is done after the protocol fee change - // top up seller's and buyer's account - await mockToken.mint(assistant.address, sellerDeposit); - await mockToken.mint(buyer.address, price); + // top up seller's and buyer's account + await mockToken.mint(assistant.address, sellerDeposit); + await mockToken.mint(buyer.address, price); - // approve protocol to transfer the tokens - await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, price); + // approve protocol to transfer the tokens + await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerDeposit); + await mockToken.connect(buyer).approve(protocolDiamondAddress, price); - // deposit to seller's pool - await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerDeposit); + // deposit to seller's pool + await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerDeposit); - // commit to offer and get the correct exchangeId - tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - txReceipt = await tx.wait(); - event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); - exchangeId = event.exchangeId.toString(); + // commit to offer and get the correct exchangeId + tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + txReceipt = await tx.wait(); + event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); + exchangeId = event.exchangeId.toString(); - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // Complete the exchange, expecting event - tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + // Complete the exchange, expecting event + tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - // Complete the exchange, expecting event - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); + // Complete the exchange, expecting event + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + }); + }); }); }); }); From 373d7a199370cbe98f46f8f6e41850a1a2bf0dad Mon Sep 17 00:00:00 2001 From: zajck Date: Wed, 17 May 2023 17:05:13 +0200 Subject: [PATCH 15/33] Withdraw funds - support DR withdrawals --- contracts/domain/BosonConstants.sol | 1 + contracts/mock/MockDRFeeMutualizer.sol | 2 +- .../protocol/facets/FundsHandlerFacet.sol | 64 +++++++++++-------- 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/contracts/domain/BosonConstants.sol b/contracts/domain/BosonConstants.sol index 88755d5ab..30189b726 100644 --- a/contracts/domain/BosonConstants.sol +++ b/contracts/domain/BosonConstants.sol @@ -150,6 +150,7 @@ string constant INSUFFICIENT_AVAILABLE_FUNDS = "Insufficient available funds"; string constant NATIVE_NOT_ALLOWED = "Transfer of native currency not allowed"; string constant DR_FEE_NOT_RECEIVED = "DR fee not received"; string constant SELLER_NOT_COVERED = "Seller not covered"; +string constant INVALID_ENTITY_ID = "Invalid entity id"; // Revert Reasons: Meta-Transactions related string constant NONCE_USED_ALREADY = "Nonce used already"; diff --git a/contracts/mock/MockDRFeeMutualizer.sol b/contracts/mock/MockDRFeeMutualizer.sol index 76cfa2920..1e4c899f0 100644 --- a/contracts/mock/MockDRFeeMutualizer.sol +++ b/contracts/mock/MockDRFeeMutualizer.sol @@ -29,7 +29,7 @@ contract MockDRFeeMutualizer is IDRFeeMutualizer { * */ function isSellerCovered(address, address, uint256, address, bytes calldata) external pure returns (bool) { - true; + return true; } /** diff --git a/contracts/protocol/facets/FundsHandlerFacet.sol b/contracts/protocol/facets/FundsHandlerFacet.sol index bb313842b..43539910d 100644 --- a/contracts/protocol/facets/FundsHandlerFacet.sol +++ b/contracts/protocol/facets/FundsHandlerFacet.sol @@ -92,34 +92,8 @@ contract FundsHandlerFacet is IBosonFundsHandler, ProtocolBase { address[] calldata _tokenList, uint256[] calldata _tokenAmounts ) external override fundsNotPaused nonReentrant { - address payable sender = payable(msgSender()); - // Address that will receive the funds - address payable destinationAddress; - - // First check if the caller is a buyer - (bool exists, uint256 callerId) = getBuyerIdByWallet(sender); - if (exists && callerId == _entityId) { - // Caller is a buyer - destinationAddress = sender; - } else { - // Check if the caller is a clerk - (exists, callerId) = getSellerIdByClerk(sender); - if (exists && callerId == _entityId) { - // Caller is a clerk. In this case funds are transferred to the treasury address - (, Seller storage seller, ) = fetchSeller(callerId); - destinationAddress = seller.treasury; - } else { - (exists, callerId) = getAgentIdByWallet(sender); - if (exists && callerId == _entityId) { - // Caller is an agent - destinationAddress = sender; - } else { - // In this branch, caller is neither buyer, clerk or agent or does not match the _entityId - revert(NOT_AUTHORIZED); - } - } - } + address payable destinationAddress = getDestinationAddress(_entityId); withdrawFundsInternal(destinationAddress, _entityId, _tokenList, _tokenAmounts); } @@ -257,4 +231,40 @@ contract FundsHandlerFacet is IBosonFundsHandler, ProtocolBase { } } } + + /** + * @notice Checks if message sender is associated with the entity id and returns the address that will receive the funds. + * + * Reverts if: + * - Entity id is 0 + * - Caller is not associated with the entity id + * + * @param _entityId - id of entity for which funds should be withdrawn + */ + function getDestinationAddress(uint256 _entityId) internal view returns (address payable _destinationAddress) { + require(_entityId != 0, INVALID_ENTITY_ID); + + address payable sender = payable(msgSender()); + uint256 callerId; + + (, callerId) = getBuyerIdByWallet(sender); + if (callerId == _entityId) return sender; + + (, callerId) = getSellerIdByClerk(sender); + if (callerId == _entityId) { + (, Seller storage seller, ) = fetchSeller(callerId); + return seller.treasury; + } + + (, callerId) = getDisputeResolverIdByAssistant(sender); + if (callerId == _entityId) { + (, DisputeResolver storage disputeResolver, ) = fetchDisputeResolver(callerId); + return disputeResolver.treasury; + } + + (, callerId) = getAgentIdByWallet(sender); + if (callerId == _entityId) return sender; + + revert(NOT_AUTHORIZED); + } } From f8f36983e8918f1f2465d2679604d92ad616b15a Mon Sep 17 00:00:00 2001 From: zajck Date: Thu, 18 May 2023 08:04:46 +0200 Subject: [PATCH 16/33] changeMutualizer tests --- test/protocol/OfferHandlerTest.js | 204 ++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/test/protocol/OfferHandlerTest.js b/test/protocol/OfferHandlerTest.js index 8fa0970ef..4eaf005f3 100644 --- a/test/protocol/OfferHandlerTest.js +++ b/test/protocol/OfferHandlerTest.js @@ -1499,6 +1499,97 @@ describe("IBosonOfferHandler", function () { }); }); + context("👉 changeOfferMutualizer()", async function () { + let newMutualizer; + + beforeEach(async function () { + // Create an offer + await offerHandler + .connect(assistant) + .createOffer(offer, offerDates, offerDurations, disputeResolver.id, agentId); + + // id of the current offer and increment nextOfferId + id = nextOfferId++; + + newMutualizer = rando.address; + }); + + it("should emit an OfferMutualizerChanged event", async function () { + // call getOffer with offerId to check the seller id in the event + [, offerStruct] = await offerHandler.getOffer(id); + + // Change the mutualizer, testing for the event + await expect(offerHandler.connect(assistant).changeOfferMutualizer(id, newMutualizer)) + .to.emit(offerHandler, "OfferMutualizerChanged") + .withArgs(id, offerStruct.sellerId, newMutualizer, assistant.address); + }); + + it("should update state", async function () { + // Original mutualizer should be the 0 address + [, offerStruct] = await offerHandler.getOffer(id); + expect(offerStruct.feeMutualizer).eql(ethers.constants.AddressZero); + + // Void the offer + await offerHandler.connect(assistant).changeOfferMutualizer(id, newMutualizer); + + // Mutualizer field should be updated + [, offerStruct] = await offerHandler.getOffer(id); + expect(offerStruct.feeMutualizer).eql(newMutualizer); + }); + + 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 change the mutualizer, expecting revert + await expect(offerHandler.connect(assistant).changeOfferMutualizer(id, newMutualizer)).to.revertedWith( + RevertReasons.REGION_PAUSED + ); + }); + + it("Offer does not exist", async function () { + // Set invalid id + id = "444"; + + // Attempt to change the mutualizer, expecting revert + await expect(offerHandler.connect(assistant).changeOfferMutualizer(id, newMutualizer)).to.revertedWith( + RevertReasons.NO_SUCH_OFFER + ); + + // Set invalid id + id = "0"; + + // Attempt to change the mutualizer, expecting revert + await expect(offerHandler.connect(assistant).changeOfferMutualizer(id, newMutualizer)).to.revertedWith( + RevertReasons.NO_SUCH_OFFER + ); + }); + + it("Caller is not seller", async function () { + // caller is not the assistant of any seller + // Attempt to change the mutualizer, expecting revert + await expect(offerHandler.connect(rando).changeOfferMutualizer(id, newMutualizer)).to.revertedWith( + RevertReasons.NOT_ASSISTANT + ); + + // caller is an assistant of another seller + // Create a valid seller, then set fields in tests directly + seller = mockSeller(rando.address, rando.address, rando.address, rando.address); + + // AuthToken + emptyAuthToken = mockAuthToken(); + expect(emptyAuthToken.isValid()).is.true; + await accountHandler.connect(rando).createSeller(seller, emptyAuthToken, voucherInitValues); + + // Attempt to change the mutualizer, expecting revert + await expect(offerHandler.connect(rando).changeOfferMutualizer(id, newMutualizer)).to.revertedWith( + RevertReasons.NOT_ASSISTANT + ); + }); + }); + }); + context("👉 getOffer()", async function () { beforeEach(async function () { // Create an offer @@ -2976,5 +3067,118 @@ describe("IBosonOfferHandler", function () { }); }); }); + + context("👉 changeOfferMutualizerBatch()", async function () { + let offersToUpdate, newMutualizer; + beforeEach(async function () { + sellerId = "1"; + + // Create an offer + await offerHandler + .connect(assistant) + .createOfferBatch(offers, offerDatesList, offerDurationsList, disputeResolverIds, agentIds); + + offersToUpdate = ["1", "3", "5"]; + newMutualizer = rando.address; + }); + + it("should emit OfferMutualizerChanged events", async function () { + // call getOffer with offerId to check the seller id in the event + [, offerStruct] = await offerHandler.getOffer(offersToUpdate[0]); + + // Change mutualizers, testing for the event + const tx = await offerHandler.connect(assistant).changeOfferMutualizerBatch(offersToUpdate, newMutualizer); + await expect(tx) + .to.emit(offerHandler, "OfferMutualizerChanged") + .withArgs(offersToUpdate[0], offerStruct.sellerId, newMutualizer, assistant.address); + + await expect(tx) + .to.emit(offerHandler, "OfferMutualizerChanged") + .withArgs(offersToUpdate[1], offerStruct.sellerId, newMutualizer, assistant.address); + + await expect(tx) + .to.emit(offerHandler, "OfferMutualizerChanged") + .withArgs(offersToUpdate[2], offerStruct.sellerId, newMutualizer, assistant.address); + }); + + it("should update state", async function () { + // Original mutualizer should be the 0 address + for (const id of offersToUpdate) { + [, offerStruct] = await offerHandler.getOffer(id); + expect(offerStruct.feeMutualizer).eql(ethers.constants.AddressZero); + } + + // Change the mutualizers + await offerHandler.connect(assistant).changeOfferMutualizerBatch(offersToUpdate, newMutualizer); + + for (const id of offersToUpdate) { + // Mutualizer field should be updated + [, offerStruct] = await offerHandler.getOffer(id); + expect(offerStruct.feeMutualizer).eql(newMutualizer); + } + }); + + 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 change the mutualizers, expecting revert + await expect( + offerHandler.connect(assistant).changeOfferMutualizerBatch(offersToUpdate, newMutualizer) + ).to.revertedWith(RevertReasons.REGION_PAUSED); + }); + + it("Offer does not exist", async function () { + // Set invalid id + offersToUpdate = ["1", "432", "2"]; + + // Attempt to change the mutualizers, expecting revert + await expect( + offerHandler.connect(assistant).changeOfferMutualizerBatch(offersToUpdate, newMutualizer) + ).to.revertedWith(RevertReasons.NO_SUCH_OFFER); + + // Set invalid id + offersToUpdate = ["1", "2", "0"]; + + // Attempt to change the mutualizers, expecting revert + await expect( + offerHandler.connect(assistant).changeOfferMutualizerBatch(offersToUpdate, newMutualizer) + ).to.revertedWith(RevertReasons.NO_SUCH_OFFER); + }); + + it("Caller is not seller", async function () { + // caller is not the assistant of any seller + // Attempt to change the mutualizers, expecting revert + await expect( + offerHandler.connect(rando).changeOfferMutualizerBatch(offersToUpdate, newMutualizer) + ).to.revertedWith(RevertReasons.NOT_ASSISTANT); + + // caller is an assistant of another seller + seller = mockSeller(rando.address, rando.address, rando.address, rando.address); + + // AuthToken + emptyAuthToken = mockAuthToken(); + expect(emptyAuthToken.isValid()).is.true; + + await accountHandler.connect(rando).createSeller(seller, emptyAuthToken, voucherInitValues); + + // Attempt to change the mutualizers, expecting revert + await expect( + offerHandler.connect(rando).changeOfferMutualizerBatch(offersToUpdate, newMutualizer) + ).to.revertedWith(RevertReasons.NOT_ASSISTANT); + }); + + it("Changing too many offers", async function () { + // Try to void the more than 100 offers + offersToUpdate = [...Array(101).keys()]; + + // Attempt to void the offers, expecting revert + await expect( + offerHandler.connect(assistant).changeOfferMutualizerBatch(offersToUpdate, newMutualizer) + ).to.revertedWith(RevertReasons.TOO_MANY_OFFERS); + }); + }); + }); }); }); From bf22979acd035111924e4966d3a6483873f48c71 Mon Sep 17 00:00:00 2001 From: zajck Date: Thu, 18 May 2023 09:02:01 +0200 Subject: [PATCH 17/33] refactor - 5 --- test/protocol/FundsHandlerTest.js | 348 +++++++++++++++--------------- 1 file changed, 173 insertions(+), 175 deletions(-) diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index 4224cec39..4eeabba66 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -2435,6 +2435,30 @@ describe("IBosonFundsHandler", function () { stateSetup["Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)"] = stateSetup["DISPUTED - ESCALATED"]; + async function getAllAvailableFunds() { + const availableFunds = {}; + let mutualizerTokenBalance; + [ + ...{ + 0: availableFunds.seller, + 1: availableFunds.buyer, + 2: availableFunds.protocol, + 3: availableFunds.agent, + 4: availableFunds.disputeResolver, + 5: mutualizerTokenBalance, + } + ] = await Promise.all([ + fundsHandler.getAvailableFunds(seller.id), + fundsHandler.getAvailableFunds(buyerId), + fundsHandler.getAvailableFunds(protocolId), + fundsHandler.getAvailableFunds(agent.id), + fundsHandler.getAvailableFunds(disputeResolver.id), + mockToken.balanceOf(mutualizer.address), + ]); + + return { availableFunds, mutualizerTokenBalance }; + } + finalStates.forEach((finalState) => { context(`Final state ${finalState}`, async function () { let payoffs, finalAction; @@ -2450,7 +2474,7 @@ describe("IBosonFundsHandler", function () { agentFee = agentType === "no-agent" ? "0" - : BN(offerToken.price).mul(agentFeePercentage).div("10000").toString(); + : applyPercentage(offerToken.price,agentFeePercentage); payoffs = { buyer: "0", @@ -2493,7 +2517,7 @@ describe("IBosonFundsHandler", function () { agentFee = agentType === "no-agent" ? "0" - : BN(offerToken.price).mul(agentFeePercentage).div("10000").toString(); + : applyPercentage(offerToken.price,agentFeePercentage); payoffs = { buyer: "0", @@ -2717,30 +2741,6 @@ describe("IBosonFundsHandler", function () { } }); - async function getAllAvailableFunds() { - const availableFunds = {}; - let mutualizerTokenBalance; - [ - ...{ - 0: availableFunds.seller, - 1: availableFunds.buyer, - 2: availableFunds.protocol, - 3: availableFunds.agent, - 4: availableFunds.disputeResolver, - 5: mutualizerTokenBalance, - } - ] = await Promise.all([ - fundsHandler.getAvailableFunds(seller.id), - fundsHandler.getAvailableFunds(buyerId), - fundsHandler.getAvailableFunds(protocolId), - fundsHandler.getAvailableFunds(agent.id), - fundsHandler.getAvailableFunds(disputeResolver.id), - mockToken.balanceOf(mutualizer.address), - ]); - - return { availableFunds, mutualizerTokenBalance }; - } - it("should update state", async function () { // Read on chain state let { availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceBefore } = @@ -2792,54 +2792,135 @@ describe("IBosonFundsHandler", function () { }); }); - it.skip("no new entry is created when multiple exchanges are finalizer", async function () { - // ToDo: implement + + it("no new entry is created when multiple exchanges are finalized", async function () { + // expected payoffs + agentFee = + agentType === "no-agent" + ? "0" + : applyPercentage(offerToken.price,agentFeePercentage); + + payoffs = { + buyer: "0", + seller: BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .sub(agentFee) + .add(DRFeeToSeller) + .toString(), + protocol: offerTokenProtocolFee, + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: agentFee, + }; + + // Read on chain state + let { availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceBefore } = + await getAllAvailableFunds(); + + // Chain state should match the expected available funds + let expectedAvailableFunds = {}; + expectedAvailableFunds.seller = new FundsList([ + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedAvailableFunds.buyer = new FundsList([]); + expectedAvailableFunds.protocol = new FundsList([]); + expectedAvailableFunds.agent = new FundsList([]); + expectedAvailableFunds.disputeResolver = new FundsList([]); + + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + } + + // successfully redeem exchange + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + await exchangeHandler.connect(buyer).completeExchange(exchangeId); + + // Increase available funds + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + if (payoffs[key] !== "0") { + if (value.funds[0]) { + // If funds are non empty, mockToken is the first entry + value.funds[0].availableAmount = BN(value.funds[0].availableAmount) + .add(payoffs[key]) + .toString(); + } else { + value.funds.push(new Funds(mockToken.address, "Foreign20", payoffs[key])); + } + } + } + + // Read on chain state + let mutualizerTokenBalanceAfter; + ({ availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceAfter } = + await getAllAvailableFunds()); + + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + } + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(payoffs.mutualizer)); + // complete another exchange so we test funds are only updated, no new entry is created - // await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - // await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); - // await exchangeHandler.connect(buyer).completeExchange(exchangeId); - // ({ availableFunds } = await getAllAvailableFunds()); - // availableFunds.seller.funds[0].availableAmount = BN(value.funds[0].availableAmount).add(payoffs[key]).toString(); - // for (let [key, value] of Object.entries(expectedAvailableFunds)) { - // expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); - // } - // sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); - // buyerAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(buyerId)); - // protocolAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(protocolId)); - // agentAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(agent.id)); - // DRAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(disputeResolver.id)); - // expectedSellerAvailableFunds.funds[1] = new Funds( - // mockToken.address, - // "Foreign20", - // BN(sellerPayoff).mul(2).toString() - // ); - // expectedProtocolAvailableFunds.funds[0] = new Funds( - // mockToken.address, - // "Foreign20", - // BN(protocolPayoff).mul(2).toString() - // ); - // expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - // expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - // expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - // expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - // expect(DRAvailableFunds).to.eql(expectedDRAvailableFunds); + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); + await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); + await exchangeHandler.connect(buyer).completeExchange(exchangeId); + + // Increase available funds + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + if (payoffs[key] !== "0") { + if (value.funds[0]) { + // If funds are non empty, mockToken is the first entry + value.funds[0].availableAmount = BN(value.funds[0].availableAmount) + .add(payoffs[key]) + .toString(); + } else { + value.funds.push(new Funds(mockToken.address, "Foreign20", payoffs[key])); + } + } + } + // sellers available funds should be decreased by the seller deposit and DR fee, because commitToOffer reduced it + expectedAvailableFunds.seller.funds[0].availableAmount = BN(expectedAvailableFunds.seller.funds[0].availableAmount).sub(sellerDeposit).sub(DRFeeToSeller).toString(); + + // Read on chain state + ({ availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceAfter } = + await getAllAvailableFunds()); + + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + } + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(payoffs.mutualizer)); }); context("Changing the protocol fee", async function () { - beforeEach(async function () { - // Cast Diamond to IBosonConfigHandler - configHandler = await ethers.getContractAt("IBosonConfigHandler", protocolDiamondAddress); + let payoffs; + beforeEach(async function () { // expected payoffs - // buyer: 0 - buyerPayoff = 0; + agentFee = + agentType === "no-agent" + ? "0" + : applyPercentage(offerToken.price,agentFeePercentage); - // seller: sellerDeposit + price - protocolFee - sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.price).sub(offerTokenProtocolFee).toString(); + payoffs = { + buyer: "0", + seller: BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .sub(agentFee) + .add(DRFeeToSeller) + .toString(), + protocol: offerTokenProtocolFee, + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: agentFee, + }; }); it("Protocol fee for existing exchanges should be the same as at the offer creation", async function () { - // set the new procol fee + // set the new protocol fee protocolFeePercentage = "300"; // 3% await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); @@ -2853,19 +2934,30 @@ describe("IBosonFundsHandler", function () { const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); await expect(tx) .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, payoffs.seller, buyer.address); await expect(tx) .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.address); + .withArgs(exchangeId, offerToken.exchangeToken, payoffs.protocol, buyer.address); + + // Agent + txReceipt = await tx.wait(); + match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + agent.id, + offerToken.exchangeToken, + payoffs.agent, + buyer.address, + ]); + expect(match).to.equal(payoffs.agent !== "0"); }); it("Protocol fee for new exchanges should be the same as at the offer creation", async function () { - // set the new procol fee + // set the new protocol fee protocolFeePercentage = "300"; // 3% await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); - // similar as teste before, excpet the commit to offer is done after the procol fee change + // similar as test before, except the commit to offer is done after the protocol fee change // commit to offer and get the correct exchangeId tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); @@ -2883,116 +2975,22 @@ describe("IBosonFundsHandler", function () { tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); await expect(tx) .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, payoffs.seller, buyer.address); await expect(tx) .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.address); - }); - - context("Offer has an agent", async function () { - beforeEach(async function () { - exchangeId = "2"; - - // Cast Diamond to IBosonConfigHandler - configHandler = await ethers.getContractAt("IBosonConfigHandler", protocolDiamondAddress); - - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // agentPayoff: agentFee - agentFee = BN(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = BN(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .toString(); - - // protocol: protocolFee - protocolPayoff = agentOfferProtocolFee; - - // Create Agent Offer before setting new protocol fee as 3% - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); - - // Commit to Agent Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - - // set the new procol fee - protocolFeePercentage = "300"; // 3% - await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); - }); - - it("Protocol fee for existing exchanges should be the same as at the agent offer creation", async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // Complete the exchange, expecting event - const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); - }); - - it("Protocol fee for new exchanges should be the same as at the agent offer creation", async function () { - // similar as tests before, excpet the commit to offer is done after the protocol fee change - - // top up seller's and buyer's account - await mockToken.mint(assistant.address, sellerDeposit); - await mockToken.mint(buyer.address, price); - - // approve protocol to transfer the tokens - await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, price); - - // deposit to seller's pool - await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerDeposit); - - // commit to offer and get the correct exchangeId - tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); - txReceipt = await tx.wait(); - event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); - exchangeId = event.exchangeId.toString(); - - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // Complete the exchange, expecting event - tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - - // Complete the exchange, expecting event - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); - }); + .withArgs(exchangeId, offerToken.exchangeToken, payoffs.protocol, buyer.address); + + // Agent + txReceipt = await tx.wait(); + match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + agent.id, + offerToken.exchangeToken, + payoffs.agent, + buyer.address, + ]); + expect(match).to.equal(payoffs.agent !== "0"); }); }); }); From 3b830a64bd524e6d9fa6a930a51007368c5df91a Mon Sep 17 00:00:00 2001 From: zajck Date: Thu, 18 May 2023 11:57:26 +0200 Subject: [PATCH 18/33] Release funds to correct mutualizer --- test/protocol/FundsHandlerTest.js | 400 ++++++++++++++++-------------- 1 file changed, 207 insertions(+), 193 deletions(-) diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index 4eeabba66..2446b8043 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -89,7 +89,7 @@ describe("IBosonFundsHandler", function () { let disputedDate, escalatedDate, timeout; let voucherInitValues; let emptyAuthToken; - let agent, agentId, agentFeePercentage, agentFee, agentPayoff, agentOffer, agentOfferProtocolFee; + let agent, agentId, agentFeePercentage, agentFee, agentPayoff, agentOffer; let DRFeeToken, DRFeeNative, buyerEscalationDeposit; let protocolDiamondAddress; let snapshotId; @@ -1516,7 +1516,6 @@ describe("IBosonFundsHandler", function () { agentOffer = offerToken.clone(); agentOffer.id = "3"; - agentOfferProtocolFee = mo.offerFees.protocolFee; randoBuyerId = "4"; // 1: seller, 2: disputeResolver, 3: agent, 4: rando }); @@ -2435,29 +2434,29 @@ describe("IBosonFundsHandler", function () { stateSetup["Final state DISPUTED - ESCALATED - REFUSED via refuseEscalatedDispute (explicit refusal)"] = stateSetup["DISPUTED - ESCALATED"]; - async function getAllAvailableFunds() { - const availableFunds = {}; - let mutualizerTokenBalance; - [ - ...{ - 0: availableFunds.seller, - 1: availableFunds.buyer, - 2: availableFunds.protocol, - 3: availableFunds.agent, - 4: availableFunds.disputeResolver, - 5: mutualizerTokenBalance, - } - ] = await Promise.all([ - fundsHandler.getAvailableFunds(seller.id), - fundsHandler.getAvailableFunds(buyerId), - fundsHandler.getAvailableFunds(protocolId), - fundsHandler.getAvailableFunds(agent.id), - fundsHandler.getAvailableFunds(disputeResolver.id), - mockToken.balanceOf(mutualizer.address), - ]); - - return { availableFunds, mutualizerTokenBalance }; + async function getAllAvailableFunds() { + const availableFunds = {}; + let mutualizerTokenBalance; + [ + ...{ + 0: availableFunds.seller, + 1: availableFunds.buyer, + 2: availableFunds.protocol, + 3: availableFunds.agent, + 4: availableFunds.disputeResolver, + 5: mutualizerTokenBalance, } + ] = await Promise.all([ + fundsHandler.getAvailableFunds(seller.id), + fundsHandler.getAvailableFunds(buyerId), + fundsHandler.getAvailableFunds(protocolId), + fundsHandler.getAvailableFunds(agent.id), + fundsHandler.getAvailableFunds(disputeResolver.id), + mockToken.balanceOf(mutualizer.address), + ]); + + return { availableFunds, mutualizerTokenBalance }; + } finalStates.forEach((finalState) => { context(`Final state ${finalState}`, async function () { @@ -2471,10 +2470,7 @@ describe("IBosonFundsHandler", function () { case "COMPLETED": case "DISPUTED - RETRACTED": case "DISPUTED - RETRACTED via expireDispute": - agentFee = - agentType === "no-agent" - ? "0" - : applyPercentage(offerToken.price,agentFeePercentage); + agentFee = agentType === "no-agent" ? "0" : applyPercentage(offerToken.price, agentFeePercentage); payoffs = { buyer: "0", @@ -2514,10 +2510,7 @@ describe("IBosonFundsHandler", function () { }; break; case "DISPUTED - ESCALATED - RETRACTED": - agentFee = - agentType === "no-agent" - ? "0" - : applyPercentage(offerToken.price,agentFeePercentage); + agentFee = agentType === "no-agent" ? "0" : applyPercentage(offerToken.price, agentFeePercentage); payoffs = { buyer: "0", @@ -2792,137 +2785,120 @@ describe("IBosonFundsHandler", function () { }); }); + context("special cases", function () { + let payoffs; - it("no new entry is created when multiple exchanges are finalized", async function () { - // expected payoffs - agentFee = - agentType === "no-agent" - ? "0" - : applyPercentage(offerToken.price,agentFeePercentage); - - payoffs = { - buyer: "0", - seller: BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .sub(agentFee) - .add(DRFeeToSeller) - .toString(), - protocol: offerTokenProtocolFee, - mutualizer: DRFeeToMutualizer, - disputeResolver: "0", - agent: agentFee, - }; - - // Read on chain state - let { availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceBefore } = - await getAllAvailableFunds(); - - // Chain state should match the expected available funds - let expectedAvailableFunds = {}; - expectedAvailableFunds.seller = new FundsList([ - new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), - ]); - expectedAvailableFunds.buyer = new FundsList([]); - expectedAvailableFunds.protocol = new FundsList([]); - expectedAvailableFunds.agent = new FundsList([]); - expectedAvailableFunds.disputeResolver = new FundsList([]); - - for (let [key, value] of Object.entries(expectedAvailableFunds)) { - expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); - } + beforeEach(async function () { + // expected payoffs + agentFee = agentType === "no-agent" ? "0" : applyPercentage(offerToken.price, agentFeePercentage); + + payoffs = { + buyer: "0", + seller: BN(offerToken.sellerDeposit) + .add(offerToken.price) + .sub(offerTokenProtocolFee) + .sub(agentFee) + .add(DRFeeToSeller) + .toString(), + protocol: offerTokenProtocolFee, + mutualizer: DRFeeToMutualizer, + disputeResolver: "0", + agent: agentFee, + }; + }); - // successfully redeem exchange - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - await exchangeHandler.connect(buyer).completeExchange(exchangeId); - - // Increase available funds - for (let [key, value] of Object.entries(expectedAvailableFunds)) { - if (payoffs[key] !== "0") { - if (value.funds[0]) { - // If funds are non empty, mockToken is the first entry - value.funds[0].availableAmount = BN(value.funds[0].availableAmount) - .add(payoffs[key]) - .toString(); - } else { - value.funds.push(new Funds(mockToken.address, "Foreign20", payoffs[key])); - } + it("No new entry is created when multiple exchanges are finalized", async function () { + // Read on chain state + let { availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceBefore } = + await getAllAvailableFunds(); + + // Chain state should match the expected available funds + let expectedAvailableFunds = {}; + expectedAvailableFunds.seller = new FundsList([ + new Funds(mockToken.address, "Foreign20", BN(sellerDeposit).add(DRFeeToSeller).toString()), + new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + ]); + expectedAvailableFunds.buyer = new FundsList([]); + expectedAvailableFunds.protocol = new FundsList([]); + expectedAvailableFunds.agent = new FundsList([]); + expectedAvailableFunds.disputeResolver = new FundsList([]); + + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); } - } - - // Read on chain state - let mutualizerTokenBalanceAfter; - ({ availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceAfter } = - await getAllAvailableFunds()); - - for (let [key, value] of Object.entries(expectedAvailableFunds)) { - expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); - } - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(payoffs.mutualizer)); - - // complete another exchange so we test funds are only updated, no new entry is created - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); - await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); - await exchangeHandler.connect(buyer).completeExchange(exchangeId); - - // Increase available funds - for (let [key, value] of Object.entries(expectedAvailableFunds)) { - if (payoffs[key] !== "0") { - if (value.funds[0]) { - // If funds are non empty, mockToken is the first entry - value.funds[0].availableAmount = BN(value.funds[0].availableAmount) - .add(payoffs[key]) - .toString(); - } else { - value.funds.push(new Funds(mockToken.address, "Foreign20", payoffs[key])); + + // successfully redeem exchange + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + await exchangeHandler.connect(buyer).completeExchange(exchangeId); + + // Increase available funds + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + if (payoffs[key] !== "0") { + if (value.funds[0]) { + // If funds are non empty, mockToken is the first entry + value.funds[0].availableAmount = BN(value.funds[0].availableAmount).add(payoffs[key]).toString(); + } else { + value.funds.push(new Funds(mockToken.address, "Foreign20", payoffs[key])); + } } } - } - // sellers available funds should be decreased by the seller deposit and DR fee, because commitToOffer reduced it - expectedAvailableFunds.seller.funds[0].availableAmount = BN(expectedAvailableFunds.seller.funds[0].availableAmount).sub(sellerDeposit).sub(DRFeeToSeller).toString(); - - // Read on chain state - ({ availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceAfter } = - await getAllAvailableFunds()); - - for (let [key, value] of Object.entries(expectedAvailableFunds)) { - expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); - } - expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(payoffs.mutualizer)); - }); - context("Changing the protocol fee", async function () { - let payoffs; + // Read on chain state + let mutualizerTokenBalanceAfter; + ({ availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceAfter } = + await getAllAvailableFunds()); - beforeEach(async function () { - // expected payoffs - agentFee = - agentType === "no-agent" - ? "0" - : applyPercentage(offerToken.price,agentFeePercentage); + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + } + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(payoffs.mutualizer)); - payoffs = { - buyer: "0", - seller: BN(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .sub(agentFee) - .add(DRFeeToSeller) - .toString(), - protocol: offerTokenProtocolFee, - mutualizer: DRFeeToMutualizer, - disputeResolver: "0", - agent: agentFee, - }; + // complete another exchange so we test funds are only updated, no new entry is created + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + mutualizerTokenBalanceBefore = await mockToken.balanceOf(mutualizer.address); + await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); + await exchangeHandler.connect(buyer).completeExchange(exchangeId); + + // Increase available funds + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + if (payoffs[key] !== "0") { + if (value.funds[0]) { + // If funds are non empty, mockToken is the first entry + value.funds[0].availableAmount = BN(value.funds[0].availableAmount).add(payoffs[key]).toString(); + } else { + value.funds.push(new Funds(mockToken.address, "Foreign20", payoffs[key])); + } + } + } + // sellers available funds should be decreased by the seller deposit and DR fee, because commitToOffer reduced it + expectedAvailableFunds.seller.funds[0].availableAmount = BN( + expectedAvailableFunds.seller.funds[0].availableAmount + ) + .sub(sellerDeposit) + .sub(DRFeeToSeller) + .toString(); + + // Read on chain state + ({ availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceAfter } = + await getAllAvailableFunds()); + + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + } + expect(mutualizerTokenBalanceAfter).to.eql(mutualizerTokenBalanceBefore.add(payoffs.mutualizer)); }); - it("Protocol fee for existing exchanges should be the same as at the offer creation", async function () { - // set the new protocol fee - protocolFeePercentage = "300"; // 3% - await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); + it("Changing the mutualizer", async function () { + let newMutualizer; + if (mutualizationType === "self-mutualized") { + newMutualizer = mutualizer.address; + } else { + newMutualizer = ethers.constants.AddressZero; + } + + // Change the mutualizer + await offerHandler.connect(assistant).changeOfferMutualizer(offerToken.id, newMutualizer); // Set time forward to the offer's voucherRedeemableFrom await setNextBlockTimestamp(Number(voucherRedeemableFrom)); @@ -2932,17 +2908,54 @@ describe("IBosonFundsHandler", function () { // Complete the exchange, expecting event const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + + // Check that seller gets the correct payoff depending on the mutualization type await expect(tx) .to.emit(exchangeHandler, "FundsReleased") .withArgs(exchangeId, seller.id, offerToken.exchangeToken, payoffs.seller, buyer.address); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, payoffs.protocol, buyer.address); + // Even if the mutualizer is changed, the DR fee should be returned to the old mutualizer + if (mutualizationType === "self-mutualized") { + await expect(tx).to.not.emit(exchangeHandler, "DRFeeReturned"); + } else { + await expect(tx) + .to.emit(exchangeHandler, "DRFeeReturned") + .withArgs( + mutualizer.address, + "1", + exchangeId, + offerToken.exchangeToken, + payoffs.mutualizer, + buyer.address + ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + } + }); + + context("Changing the protocol fee", async function () { + it("Protocol fee for existing exchanges should be the same as at the offer creation", async function () { + // set the new protocol fee + protocolFeePercentage = "300"; // 3% + await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); - // Agent - txReceipt = await tx.wait(); - match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + + // Complete the exchange, expecting event + const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, payoffs.seller, buyer.address); + + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offerToken.exchangeToken, payoffs.protocol, buyer.address); + + // Agent + txReceipt = await tx.wait(); + const match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ exchangeId, agent.id, offerToken.exchangeToken, @@ -2950,47 +2963,48 @@ describe("IBosonFundsHandler", function () { buyer.address, ]); expect(match).to.equal(payoffs.agent !== "0"); - }); + }); - it("Protocol fee for new exchanges should be the same as at the offer creation", async function () { - // set the new protocol fee - protocolFeePercentage = "300"; // 3% - await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); + it("Protocol fee for new exchanges should be the same as at the offer creation", async function () { + // set the new protocol fee + protocolFeePercentage = "300"; // 3% + await configHandler.connect(deployer).setProtocolFeePercentage(protocolFeePercentage); - // similar as test before, except the commit to offer is done after the protocol fee change + // similar as test before, except the commit to offer is done after the protocol fee change - // commit to offer and get the correct exchangeId - tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - txReceipt = await tx.wait(); - event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); - exchangeId = event.exchangeId.toString(); + // commit to offer and get the correct exchangeId + tx = await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + txReceipt = await tx.wait(); + event = getEvent(txReceipt, exchangeHandler, "BuyerCommitted"); + exchangeId = event.exchangeId.toString(); - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // successfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // Complete the exchange, expecting event - tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, payoffs.seller, buyer.address); + // Complete the exchange, expecting event + tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + await expect(tx) + .to.emit(exchangeHandler, "FundsReleased") + .withArgs(exchangeId, seller.id, offerToken.exchangeToken, payoffs.seller, buyer.address); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, payoffs.protocol, buyer.address); - - // Agent - txReceipt = await tx.wait(); - match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ - exchangeId, - agent.id, - offerToken.exchangeToken, - payoffs.agent, - buyer.address, - ]); - expect(match).to.equal(payoffs.agent !== "0"); + await expect(tx) + .to.emit(exchangeHandler, "ProtocolFeeCollected") + .withArgs(exchangeId, offerToken.exchangeToken, payoffs.protocol, buyer.address); + + // Agent + txReceipt = await tx.wait(); + const match = eventEmittedWithArgs(txReceipt, fundsHandler, "FundsReleased", [ + exchangeId, + agent.id, + offerToken.exchangeToken, + payoffs.agent, + buyer.address, + ]); + expect(match).to.equal(payoffs.agent !== "0"); + }); }); }); }); From 3974ea5e40b6c4ba8c49bbf1193ccd5757e03ea9 Mon Sep 17 00:00:00 2001 From: zajck Date: Thu, 18 May 2023 13:31:31 +0200 Subject: [PATCH 19/33] withdraw DR funds --- test/protocol/FundsHandlerTest.js | 109 +++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 31 deletions(-) diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index 2446b8043..2c7f54213 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -63,7 +63,8 @@ describe("IBosonFundsHandler", function () { offerHandler, configHandler, disputeHandler, - pauseHandler; + pauseHandler, + orchestrationHandler; let support; let seller; let buyer, offerToken, offerNative; @@ -74,14 +75,21 @@ describe("IBosonFundsHandler", function () { let resolutionPeriod, offerDurations; let protocolFeePercentage, buyerEscalationDepositPercentage; let block, blockNumber; - let protocolId, exchangeId, buyerId, randoBuyerId, sellerPayoff, buyerPayoff, protocolPayoff; + let protocolId, exchangeId, buyerId, randoBuyerId, sellerPayoff, buyerPayoff, protocolPayoff, disputeResolverPayoff; let sellersAvailableFunds, buyerAvailableFunds, protocolAvailableFunds, expectedSellerAvailableFunds, expectedBuyerAvailableFunds, expectedProtocolAvailableFunds; - let tokenListSeller, tokenListBuyer, tokenAmountsSeller, tokenAmountsBuyer, tokenList, tokenAmounts; + let tokenListSeller, + tokenListBuyer, + tokenListDR, + tokenAmountsSeller, + tokenAmountsBuyer, + tokenAmountsDR, + tokenList, + tokenAmounts; let tx, txReceipt, txCost, event; let disputeResolverFees, disputeResolver, disputeResolverId; let buyerPercentBasisPoints; @@ -111,6 +119,7 @@ describe("IBosonFundsHandler", function () { configHandler: "IBosonConfigHandler", pauseHandler: "IBosonPauseHandler", disputeHandler: "IBosonDisputeHandler", + orchestrationHandler: "IBosonOrchestrationHandler", }; ({ @@ -124,6 +133,7 @@ describe("IBosonFundsHandler", function () { configHandler, pauseHandler, disputeHandler, + orchestrationHandler, }, protocolConfig: [, , { percentage: protocolFeePercentage, buyerEscalationDepositPercentage }], diamondAddress: protocolDiamondAddress, @@ -377,9 +387,10 @@ describe("IBosonFundsHandler", function () { expect(disputeResolver.isValid()).is.true; //Create DisputeResolverFee array so offer creation will succeed + DRFeeToken = DRFeeNative = ethers.utils.parseUnits("0.1", "ether").toString(); disputeResolverFees = [ - new DisputeResolverFee(ethers.constants.AddressZero, "Native", "0"), - new DisputeResolverFee(mockToken.address, "mockToken", "0"), + new DisputeResolverFee(ethers.constants.AddressZero, "Native", DRFeeNative), + new DisputeResolverFee(mockToken.address, "mockToken", DRFeeToken), ]; // Make empty seller list, so every seller is allowed @@ -391,7 +402,7 @@ describe("IBosonFundsHandler", function () { .createDisputeResolver(disputeResolver, disputeResolverFees, sellerAllowList); // Mock offer - const { offer, offerDates, offerDurations, disputeResolverId, offerFees } = await mockOffer(); + const { offer, offerDates, offerDurations, offerFees } = await mockOffer(); offer.quantityAvailable = "2"; offerNative = offer; @@ -413,24 +424,28 @@ describe("IBosonFundsHandler", function () { await Promise.all([ offerHandler .connect(assistant) - .createOffer(offerNative, offerDates, offerDurations, disputeResolverId, agentId), + .createOffer(offerNative, offerDates, offerDurations, disputeResolver.id, agentId), offerHandler .connect(assistant) - .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId), + .createOffer(offerToken, offerDates, offerDurations, disputeResolver.id, agentId), ]); // Set used variables - price = offerToken.price; - sellerDeposit = offerToken.sellerDeposit; + buyerEscalationDeposit = applyPercentage(DRFeeToken, buyerEscalationDepositPercentage); + const buyerTokens = BN(offerToken.price).add(buyerEscalationDeposit); + sellerDeposit = BN(offerToken.sellerDeposit).add(DRFeeToken); offerTokenProtocolFee = offerNativeProtocolFee = offerFees.protocolFee; // top up seller's and buyer's account - await Promise.all([mockToken.mint(assistant.address, sellerDeposit), mockToken.mint(buyer.address, price)]); + await Promise.all([ + mockToken.mint(assistant.address, sellerDeposit), + mockToken.mint(buyer.address, buyerTokens), + ]); // approve protocol to transfer the tokens await Promise.all([ mockToken.connect(assistant).approve(protocolDiamondAddress, sellerDeposit), - mockToken.connect(buyer).approve(protocolDiamondAddress, price), + mockToken.connect(buyer).approve(protocolDiamondAddress, buyerTokens), ]); // deposit to seller's pool @@ -455,16 +470,30 @@ describe("IBosonFundsHandler", function () { context("👉 withdrawFunds()", async function () { beforeEach(async function () { - // cancel the voucher, so both seller and buyer have something to withdraw - await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); // canceling the voucher in tokens - await exchangeHandler.connect(buyer).cancelVoucher(++exchangeId); // canceling the voucher in the native currency + // decide a dispute so seller, buyer and dispute resolver have something to withdraw + buyerPercentBasisPoints = "5566"; // 55.66% + + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); // voucher in tokens + await orchestrationHandler.connect(buyer).raiseAndEscalateDispute(exchangeId); + await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); + + await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); // voucher in the native currency + await orchestrationHandler + .connect(buyer) + .raiseAndEscalateDispute(exchangeId, { value: buyerEscalationDeposit }); + await disputeHandler.connect(assistantDR).decideDispute(exchangeId, buyerPercentBasisPoints); // expected payoffs - they are the same for token and native currency - // buyer: price - buyerCancelPenalty - buyerPayoff = BN(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(); + // buyer: + const pot = BN(offerToken.price).add(offerToken.sellerDeposit).add(buyerEscalationDeposit); + buyerPayoff = applyPercentage(pot, buyerPercentBasisPoints); + + // seller: + sellerPayoff = pot.sub(buyerPayoff).toString(); - // seller: sellerDeposit + buyerCancelPenalty - sellerPayoff = BN(offerToken.sellerDeposit).add(offerToken.buyerCancelPenalty).toString(); + // dispute resolver: + disputeResolverPayoff = DRFeeToken; }); it("should emit a FundsWithdrawn event", async function () { @@ -472,10 +501,12 @@ describe("IBosonFundsHandler", function () { // Withdraw tokens tokenListSeller = [mockToken.address, ethers.constants.AddressZero]; tokenListBuyer = [ethers.constants.AddressZero, mockToken.address]; + tokenListDR = [mockToken.address, ethers.constants.AddressZero]; // Withdraw amounts tokenAmountsSeller = [sellerPayoff, BN(sellerPayoff).div("2").toString()]; tokenAmountsBuyer = [buyerPayoff, BN(buyerPayoff).div("5").toString()]; + tokenAmountsDR = [disputeResolverPayoff, BN(disputeResolverPayoff).div("3").toString()]; // seller withdrawal const tx = await fundsHandler.connect(clerk).withdrawFunds(seller.id, tokenListSeller, tokenAmountsSeller); @@ -490,12 +521,36 @@ describe("IBosonFundsHandler", function () { // buyer withdrawal const tx2 = await fundsHandler.connect(buyer).withdrawFunds(buyerId, tokenListBuyer, tokenAmountsBuyer); await expect(tx2) - .to.emit(fundsHandler, "FundsWithdrawn", buyer.address) + .to.emit(fundsHandler, "FundsWithdrawn") .withArgs(buyerId, buyer.address, mockToken.address, BN(buyerPayoff).div("5"), buyer.address); await expect(tx2) .to.emit(fundsHandler, "FundsWithdrawn") .withArgs(buyerId, buyer.address, ethers.constants.Zero, buyerPayoff, buyer.address); + + // DR withdrawal + const tx3 = await fundsHandler + .connect(assistantDR) + .withdrawFunds(disputeResolver.id, tokenListDR, tokenAmountsDR); + await expect(tx3) + .to.emit(fundsHandler, "FundsWithdrawn") + .withArgs( + disputeResolver.id, + treasuryDR.address, + mockToken.address, + disputeResolverPayoff, + assistantDR.address + ); + + await expect(tx3) + .to.emit(fundsHandler, "FundsWithdrawn") + .withArgs( + disputeResolver.id, + treasuryDR.address, + ethers.constants.Zero, + BN(disputeResolverPayoff).div("3"), + assistantDR.address + ); }); it("should update state", async function () { @@ -2529,20 +2584,12 @@ describe("IBosonFundsHandler", function () { case "DISPUTED - ESCALATED - RESOLVED": case "DISPUTED - ESCALATED - DECIDED": { buyerPercentBasisPoints = "5566"; // 55.66% - const buyerPayoffSplit = BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); + const pot = BN(offerToken.price).add(offerToken.sellerDeposit).add(buyerEscalationDeposit); + const buyerPayoffSplit = applyPercentage(pot, buyerPercentBasisPoints); payoffs = { buyer: buyerPayoffSplit, - seller: BN(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoffSplit) - .toString(), + seller: pot.sub(buyerPayoffSplit).toString(), protocol: "0", mutualizer: "0", disputeResolver: DRFeeToken, From 8e7bb71c80a1594d9ca00dbfcf377ae453706f18 Mon Sep 17 00:00:00 2001 From: zajck Date: Thu, 18 May 2023 13:56:26 +0200 Subject: [PATCH 20/33] Fix missing tests --- scripts/domain/DisputeResolutionTerms.js | 42 ++++++++--- test/domain/DisputeResolutionTermsTest.js | 43 ++++++++++-- test/protocol/MetaTransactionsHandlerTest.js | 2 +- test/protocol/OfferHandlerTest.js | 25 +++++-- test/protocol/OrchestrationHandlerTest.js | 73 +++++++++++++++----- test/protocol/clients/BosonVoucherTest.js | 14 ++-- 6 files changed, 155 insertions(+), 44 deletions(-) diff --git a/scripts/domain/DisputeResolutionTerms.js b/scripts/domain/DisputeResolutionTerms.js index 57077fd0e..4af8a168a 100644 --- a/scripts/domain/DisputeResolutionTerms.js +++ b/scripts/domain/DisputeResolutionTerms.js @@ -1,4 +1,4 @@ -const { bigNumberIsValid } = require("../util/validations.js"); +const { bigNumberIsValid, addressIsValid } = require("../util/validations.js"); /** * Boson Protocol Domain Entity: DisputeResolutionTerms @@ -12,14 +12,16 @@ class DisputeResolutionTerms { uint256 escalationResponsePeriod; uint256 feeAmount; uint256 buyerEscalationDeposit; + address feeMutualizer; } */ - constructor(disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit) { + constructor(disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit, feeMutualizer) { this.disputeResolverId = disputeResolverId; this.escalationResponsePeriod = escalationResponsePeriod; this.feeAmount = feeAmount; this.buyerEscalationDeposit = buyerEscalationDeposit; + this.feeMutualizer = feeMutualizer; } /** @@ -28,8 +30,14 @@ class DisputeResolutionTerms { * @returns {DisputeResolutionTerms} */ static fromObject(o) { - const { disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit } = o; - return new DisputeResolutionTerms(disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit); + const { disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit, feeMutualizer } = o; + return new DisputeResolutionTerms( + disputeResolverId, + escalationResponsePeriod, + feeAmount, + buyerEscalationDeposit, + feeMutualizer + ); } /** @@ -38,16 +46,17 @@ class DisputeResolutionTerms { * @returns {*} */ static fromStruct(struct) { - let disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit; + let disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit, feeMutualizer; // destructure struct - [disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit] = struct; + [disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit, feeMutualizer] = struct; return DisputeResolutionTerms.fromObject({ disputeResolverId: disputeResolverId.toString(), escalationResponsePeriod: escalationResponsePeriod.toString(), feeAmount: feeAmount.toString(), buyerEscalationDeposit: buyerEscalationDeposit.toString(), + feeMutualizer, }); } @@ -72,7 +81,13 @@ class DisputeResolutionTerms { * @returns {string} */ toStruct() { - return [this.disputeResolverId, this.escalationResponsePeriod, this.feeAmount, this.buyerEscalationDeposit]; + return [ + this.disputeResolverId, + this.escalationResponsePeriod, + this.feeAmount, + this.buyerEscalationDeposit, + this.feeMutualizer, + ]; } /** @@ -120,6 +135,16 @@ class DisputeResolutionTerms { return bigNumberIsValid(this.buyerEscalationDeposit); } + /** + * Is this DisputeResolutionTerms instance's feeMutualizer field valid? + * Must be a eip55 compliant Ethereum address + * + * @returns {boolean} + */ + feeMutualizerIsValid() { + return addressIsValid(this.feeMutualizer); + } + /** * Is this DisputeResolutionTerms instance valid? * @returns {boolean} @@ -129,7 +154,8 @@ class DisputeResolutionTerms { this.disputeResolverIdIsValid() && this.escalationResponsePeriodIsValid() && this.feeAmountIsValid() && - this.buyerEscalationDepositIsValid() + this.buyerEscalationDepositIsValid() && + this.feeMutualizerIsValid() ); } } diff --git a/test/domain/DisputeResolutionTermsTest.js b/test/domain/DisputeResolutionTermsTest.js index 14ca7e8fc..ab382bd14 100644 --- a/test/domain/DisputeResolutionTermsTest.js +++ b/test/domain/DisputeResolutionTermsTest.js @@ -1,3 +1,4 @@ +const { ethers } = require("hardhat"); const { expect } = require("chai"); const DisputeResolutionTerms = require("../../scripts/domain/DisputeResolutionTerms"); const { oneMonth } = require("../util/constants"); @@ -8,14 +9,19 @@ const { oneMonth } = require("../util/constants"); describe("DisputeResolutionTerms", function () { // Suite-wide scope let disputeResolutionTerms, object, promoted, clone, dehydrated, rehydrated, key, value, struct; - let disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit; + let disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit, feeMutualizer; + let accounts; beforeEach(async function () { + // Get a list of accounts + accounts = await ethers.getSigners(); + // Required constructor params disputeResolverId = "2"; escalationResponsePeriod = oneMonth.toString(); feeAmount = "50"; buyerEscalationDeposit = "12345"; + feeMutualizer = ethers.constants.AddressZero.toString(); }); context("📋 Constructor", async function () { @@ -24,12 +30,14 @@ describe("DisputeResolutionTerms", function () { disputeResolverId, escalationResponsePeriod, feeAmount, - buyerEscalationDeposit + buyerEscalationDeposit, + feeMutualizer ); expect(disputeResolutionTerms.disputeResolverIdIsValid()).is.true; expect(disputeResolutionTerms.escalationResponsePeriodIsValid()).is.true; expect(disputeResolutionTerms.feeAmountIsValid()).is.true; expect(disputeResolutionTerms.buyerEscalationDepositIsValid()).is.true; + expect(disputeResolutionTerms.feeMutualizerIsValid()).is.true; expect(disputeResolutionTerms.isValid()).is.true; }); }); @@ -41,7 +49,8 @@ describe("DisputeResolutionTerms", function () { disputeResolverId, escalationResponsePeriod, feeAmount, - buyerEscalationDeposit + buyerEscalationDeposit, + feeMutualizer ); expect(disputeResolutionTerms.isValid()).is.true; }); @@ -158,6 +167,28 @@ describe("DisputeResolutionTerms", function () { expect(disputeResolutionTerms.buyerEscalationDepositIsValid()).is.true; expect(disputeResolutionTerms.isValid()).is.true; }); + + it("Always present, feeMutualizer must be a string representation of an EIP-55 compliant address", async function () { + // Invalid field value + disputeResolutionTerms.feeMutualizer = "0xASFADF"; + expect(disputeResolutionTerms.feeMutualizerIsValid()).is.false; + expect(disputeResolutionTerms.isValid()).is.false; + + // Invalid field value + disputeResolutionTerms.feeMutualizer = "zedzdeadbaby"; + expect(disputeResolutionTerms.feeMutualizerIsValid()).is.false; + expect(disputeResolutionTerms.isValid()).is.false; + + // Valid field value + disputeResolutionTerms.feeMutualizer = accounts[0].address; + expect(disputeResolutionTerms.feeMutualizerIsValid()).is.true; + expect(disputeResolutionTerms.isValid()).is.true; + + // Valid field value + disputeResolutionTerms.feeMutualizer = "0xec2fd5bd6fc7b576dae82c0b9640969d8de501a2"; + expect(disputeResolutionTerms.feeMutualizerIsValid()).is.true; + expect(disputeResolutionTerms.isValid()).is.true; + }); }); context("📋 Utility functions", async function () { @@ -167,7 +198,8 @@ describe("DisputeResolutionTerms", function () { disputeResolverId, escalationResponsePeriod, feeAmount, - buyerEscalationDeposit + buyerEscalationDeposit, + feeMutualizer ); expect(disputeResolutionTerms.isValid()).is.true; @@ -177,10 +209,11 @@ describe("DisputeResolutionTerms", function () { escalationResponsePeriod, feeAmount, buyerEscalationDeposit, + feeMutualizer, }; // Struct representation - struct = [disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit]; + struct = [disputeResolverId, escalationResponsePeriod, feeAmount, buyerEscalationDeposit, feeMutualizer]; }); context("👉 Static", async function () { diff --git a/test/protocol/MetaTransactionsHandlerTest.js b/test/protocol/MetaTransactionsHandlerTest.js index 93e97eb3c..f6d0c58c2 100644 --- a/test/protocol/MetaTransactionsHandlerTest.js +++ b/test/protocol/MetaTransactionsHandlerTest.js @@ -3055,7 +3055,7 @@ describe("IBosonMetaTransactionsHandler", function () { message.from = assistant.address; message.contractAddress = offerHandler.address; message.functionName = - "createOffer((uint256,uint256,uint256,uint256,uint256,uint256,address,string,string,bool),(uint256,uint256,uint256,uint256),(uint256,uint256,uint256),uint256,uint256)"; + "createOffer((uint256,uint256,uint256,uint256,uint256,uint256,address,string,string,bool,address),(uint256,uint256,uint256,uint256),(uint256,uint256,uint256),uint256,uint256)"; message.functionSignature = functionSignature; }); diff --git a/test/protocol/OfferHandlerTest.js b/test/protocol/OfferHandlerTest.js index 4eaf005f3..98dd287c9 100644 --- a/test/protocol/OfferHandlerTest.js +++ b/test/protocol/OfferHandlerTest.js @@ -228,7 +228,8 @@ describe("IBosonOfferHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeNative, - applyPercentage(DRFeeNative, buyerEscalationDepositPercentage) + applyPercentage(DRFeeNative, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ); disputeResolutionTermsStruct = disputeResolutionTerms.toStruct(); @@ -385,7 +386,8 @@ describe("IBosonOfferHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -412,7 +414,13 @@ describe("IBosonOfferHandler", function () { // Prepare an absolute zero offer offer.price = offer.sellerDeposit = offer.buyerCancelPenalty = offerFees.protocolFee = offerFees.agentFee = "0"; disputeResolver.id = "0"; - disputeResolutionTermsStruct = new DisputeResolutionTerms("0", "0", "0", "0").toStruct(); + disputeResolutionTermsStruct = new DisputeResolutionTerms( + "0", + "0", + "0", + "0", + ethers.constants.AddressZero + ).toStruct(); offerFeesStruct = offerFees.toStruct(); // Create a new offer @@ -480,7 +488,8 @@ describe("IBosonOfferHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -1858,7 +1867,8 @@ describe("IBosonOfferHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeNative, - applyPercentage(DRFeeNative, buyerEscalationDepositPercentage) + applyPercentage(DRFeeNative, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ); disputeResolutionTermsList.push(disputeResolutionTerms); disputeResolutionTermsStructs.push(disputeResolutionTerms.toStruct()); @@ -1878,7 +1888,8 @@ describe("IBosonOfferHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ); disputeResolutionTermsStructs[2] = disputeResolutionTermsList[2].toStruct(); @@ -1891,7 +1902,7 @@ describe("IBosonOfferHandler", function () { "0"; offerStructs[4] = offers[4].toStruct(); disputeResolverIds[4] = "0"; - disputeResolutionTermsList[4] = new DisputeResolutionTerms("0", "0", "0", "0"); + disputeResolutionTermsList[4] = new DisputeResolutionTerms("0", "0", "0", "0", ethers.constants.AddressZero); disputeResolutionTermsStructs[4] = disputeResolutionTermsList[4].toStruct(); offerFeesStructs[4] = offerFeesList[4].toStruct(); }); diff --git a/test/protocol/OrchestrationHandlerTest.js b/test/protocol/OrchestrationHandlerTest.js index f42978d28..003a313fd 100644 --- a/test/protocol/OrchestrationHandlerTest.js +++ b/test/protocol/OrchestrationHandlerTest.js @@ -268,7 +268,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeNative, - applyPercentage(DRFeeNative, buyerEscalationDepositPercentage) + applyPercentage(DRFeeNative, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ); disputeResolutionTermsStruct = disputeResolutionTerms.toStruct(); @@ -974,7 +975,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -1012,7 +1014,13 @@ describe("IBosonOrchestrationHandler", function () { // Prepare an absolute zero offer offer.price = offer.sellerDeposit = offer.buyerCancelPenalty = offerFees.protocolFee = "0"; disputeResolver.id = "0"; - disputeResolutionTermsStruct = new DisputeResolutionTerms("0", "0", "0", "0").toStruct(); + disputeResolutionTermsStruct = new DisputeResolutionTerms( + "0", + "0", + "0", + "0", + ethers.constants.AddressZero + ).toStruct(); offerFeesStruct = offerFees.toStruct(); // Create a seller and an offer, testing for the event @@ -1116,7 +1124,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -2382,7 +2391,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -2411,7 +2421,13 @@ describe("IBosonOrchestrationHandler", function () { // Prepare an absolute zero offer offer.price = offer.sellerDeposit = offer.buyerCancelPenalty = offerFees.protocolFee = "0"; disputeResolver.id = "0"; - disputeResolutionTermsStruct = new DisputeResolutionTerms("0", "0", "0", "0").toStruct(); + disputeResolutionTermsStruct = new DisputeResolutionTerms( + "0", + "0", + "0", + "0", + ethers.constants.AddressZero + ).toStruct(); offerFeesStruct = offerFees.toStruct(); // Create an offer with condition, testing for the events @@ -2485,7 +2501,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -3072,7 +3089,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -3101,7 +3119,13 @@ describe("IBosonOrchestrationHandler", function () { // Prepare an absolute zero offer offer.price = offer.sellerDeposit = offer.buyerCancelPenalty = offerFees.protocolFee = "0"; disputeResolver.id = "0"; - disputeResolutionTermsStruct = new DisputeResolutionTerms("0", "0", "0", "0").toStruct(); + disputeResolutionTermsStruct = new DisputeResolutionTerms( + "0", + "0", + "0", + "0", + ethers.constants.AddressZero + ).toStruct(); offerFeesStruct = offerFees.toStruct(); // Create an offer, add it to the group, testing for the events @@ -3175,7 +3199,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -3741,7 +3766,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -3770,7 +3796,13 @@ describe("IBosonOrchestrationHandler", function () { // Prepare an absolute zero offer offer.price = offer.sellerDeposit = offer.buyerCancelPenalty = offerFees.protocolFee = "0"; disputeResolver.id = "0"; - disputeResolutionTermsStruct = new DisputeResolutionTerms("0", "0", "0", "0").toStruct(); + disputeResolutionTermsStruct = new DisputeResolutionTerms( + "0", + "0", + "0", + "0", + ethers.constants.AddressZero + ).toStruct(); offerFeesStruct = offerFees.toStruct(); // Create an offer, a twin and a bundle, testing for the events @@ -3846,7 +3878,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -4582,7 +4615,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); @@ -4619,7 +4653,13 @@ describe("IBosonOrchestrationHandler", function () { // Prepare an absolute zero offer offer.price = offer.sellerDeposit = offer.buyerCancelPenalty = offerFees.protocolFee = "0"; disputeResolver.id = "0"; - disputeResolutionTermsStruct = new DisputeResolutionTerms("0", "0", "0", "0").toStruct(); + disputeResolutionTermsStruct = new DisputeResolutionTerms( + "0", + "0", + "0", + "0", + ethers.constants.AddressZero + ).toStruct(); offerFeesStruct = offerFees.toStruct(); // Create an offer with condition, twin and bundle testing for the events @@ -4719,7 +4759,8 @@ describe("IBosonOrchestrationHandler", function () { disputeResolver.id, disputeResolver.escalationResponsePeriod, DRFeeToken, - applyPercentage(DRFeeToken, buyerEscalationDepositPercentage) + applyPercentage(DRFeeToken, buyerEscalationDepositPercentage), + ethers.constants.AddressZero ).toStruct(); offerFees.protocolFee = protocolFeeFlatBoson; offerFeesStruct = offerFees.toStruct(); diff --git a/test/protocol/clients/BosonVoucherTest.js b/test/protocol/clients/BosonVoucherTest.js index 7071c5db7..368be3076 100644 --- a/test/protocol/clients/BosonVoucherTest.js +++ b/test/protocol/clients/BosonVoucherTest.js @@ -275,7 +275,7 @@ describe("IBosonVoucher", function () { // Mock getOffer call, otherwise getAvailablePreMints will return 0 const mockProtocol = await deployMockProtocol(); const { offer, offerDates, offerDurations, offerFees } = await mockOffer(); - const disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0"); + const disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0", ethers.constants.AddressZero); await mockProtocol.mock.getMaxPremintedVouchers.returns("1000"); await mockProtocol.mock.getOffer.returns( true, @@ -314,7 +314,7 @@ describe("IBosonVoucher", function () { // Mock getOffer call, otherwise getAvailablePreMints will return 0 const mockProtocol = await deployMockProtocol(); const { offer, offerDates, offerDurations, offerFees } = await mockOffer(); - const disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0"); + const disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0", ethers.constants.AddressZero); await mockProtocol.mock.getMaxPremintedVouchers.returns("1000"); await mockProtocol.mock.getOffer.returns( true, @@ -398,7 +398,7 @@ describe("IBosonVoucher", function () { beforeEach(async function () { mockProtocol = await deployMockProtocol(); ({ offer, offerDates, offerDurations, offerFees } = await mockOffer()); - disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0"); + disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0", ethers.constants.AddressZero); await mockProtocol.mock.getMaxPremintedVouchers.returns("1000"); await mockProtocol.mock.getOffer.returns( true, @@ -656,7 +656,7 @@ describe("IBosonVoucher", function () { mockProtocol = await deployMockProtocol(); ({ offer, offerDates, offerDurations, offerFees } = await mockOffer()); - disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0"); + disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0", ethers.constants.AddressZero); await mockProtocol.mock.getMaxPremintedVouchers.returns(maxPremintedVouchers); await mockProtocol.mock.getOffer .withArgs(offerId) @@ -999,7 +999,7 @@ describe("IBosonVoucher", function () { mockProtocol = await deployMockProtocol(); ({ offer, offerDates, offerDurations, offerFees } = await mockOffer()); - disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0"); + disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0", ethers.constants.AddressZero); await mockProtocol.mock.getMaxPremintedVouchers.returns("1000"); await mockProtocol.mock.getOffer.returns( true, @@ -1098,7 +1098,7 @@ describe("IBosonVoucher", function () { const mockProtocol = await deployMockProtocol(); const { offer, offerDates, offerDurations, offerFees } = await mockOffer(); - const disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0"); + const disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0", ethers.constants.AddressZero); await mockProtocol.mock.getMaxPremintedVouchers.returns("1000"); await mockProtocol.mock.getOffer.returns( true, @@ -1168,7 +1168,7 @@ describe("IBosonVoucher", function () { mockProtocol = await deployMockProtocol(); ({ offer, offerDates, offerDurations, offerFees } = await mockOffer()); - disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0"); + disputeResolutionTerms = new DisputeResolutionTerms("0", "0", "0", "0", ethers.constants.AddressZero); await mockProtocol.mock.getMaxPremintedVouchers.returns("1000"); await mockProtocol.mock.getOffer.returns( true, From a98c0ccc38e95e0497b75cb99bf2078c78cac71f Mon Sep 17 00:00:00 2001 From: zajck Date: Tue, 30 May 2023 09:24:09 +0200 Subject: [PATCH 21/33] DRfee mutualizer tests --- contracts/domain/BosonConstants.sol | 11 ++ .../interfaces/clients/IDRFeeMutualizer.sol | 61 +++++++- .../clients/feeMutualizer/DRFeeMutualizer.sol | 103 +++++++------ scripts/config/revert-reasons.js | 11 ++ test/domain/OfferTest.js | 3 +- test/protocol/clients/DRFeeMutualizerTest.js | 140 ++++++++++++++++++ 6 files changed, 277 insertions(+), 52 deletions(-) create mode 100644 test/protocol/clients/DRFeeMutualizerTest.js diff --git a/contracts/domain/BosonConstants.sol b/contracts/domain/BosonConstants.sol index 30189b726..5cb43b0f3 100644 --- a/contracts/domain/BosonConstants.sol +++ b/contracts/domain/BosonConstants.sol @@ -199,6 +199,17 @@ string constant NOT_COMMITTABLE = "Token not committable"; string constant INVALID_TO_ADDRESS = "Tokens can only be pre-mined to the contract or contract owner address"; string constant EXTERNAL_CALL_FAILED = "External call failed"; +// DRFeeMutualizer +string constant ONLY_PROTOCOL = "Only protocol can call this function"; +string constant AGREEMENT_NOT_STARTED = "Agreement not started yet"; +string constant AGREEMENT_EXPIRED = "Agreement expired"; +string constant AGREEMENT_VOIDED = "Agreement voided"; +string constant EXCEEDED_SINGLE_FEE = "Fee amount exceeds max mutualized amount per transaction"; +string constant EXCEEDED_TOTAL_FEE = "Fee amount exceeds max total mutualized amount"; +string constant INVALID_UUID = "Invalid UUID"; +string constant INVALID_SELLER_ADDRESS = "Invalid seller address"; +string constant INVALID_AGREEMENT = "Invalid agreement"; + // Meta Transactions - Structs bytes32 constant META_TRANSACTION_TYPEHASH = keccak256( bytes( diff --git a/contracts/interfaces/clients/IDRFeeMutualizer.sol b/contracts/interfaces/clients/IDRFeeMutualizer.sol index 893a9d3de..08d41d702 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizer.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizer.sol @@ -6,7 +6,9 @@ pragma solidity 0.8.9; * * @notice This is the interface for the Dispute Resolver fee mutualizers. * - * The ERC-165 identifier for this interface is: 0x41283543 + * ToDo: should this be split into two interfaces? Minimal interface for the protocol and full interface for the clients? + * + * The ERC-165 identifier for this interface is: 0xb13f055e */ interface IDRFeeMutualizer { event DRFeeRequsted( @@ -16,10 +18,10 @@ interface IDRFeeMutualizer { address feeRequester, bytes context ); + event DRFeeReturned(uint256 indexed uuid, uint256 feeAmount, bytes context); /** - * @notice Tells if mutualizer will covert fee amount for a given seller and requrested by a given address. - * + * @notice Tells if mutualizer will cover the fee amount for a given seller and requrested by a given address. * * @param _sellerAddress - the seller address * @param _token - the token address (use 0x0 for ETH) @@ -66,3 +68,56 @@ interface IDRFeeMutualizer { */ function returnDRFee(uint256 _uuid, uint256 _feeAmount, bytes calldata _context) external payable; } + + +/** + * @title IDRFeeMutualizerClient + * + * @notice This is the interface for the Dispute Resolver fee mutualizers. + * + * The ERC-165 identifier for this interface is: 0x41283543 + */ +interface IDRFeeMutualizerClient is IDRFeeMutualizer { + struct Agreement { + address sellerAddress; + address token; + uint256 maxMutualizedAmountPerTransaction; + uint256 maxTotalMutualizedAmount; + uint256 premium; + uint128 startTimestamp; + uint128 endTimestamp; + bool refundOnCancel; + bool voided; + } + + event AgreementCreated(address indexed sellerAddress, uint256 indexed agreementId, Agreement agreement); + event AgreementConfirmed(address indexed sellerAddress, uint256 indexed agreementId); + + /** + * @notice Stores a new agreement between mutualizer and seller. Only contract owner can submint an agreement, + * however it becomes valid only after seller confirms it by calling payPremium. + * + * Emits AgreementCreated event if successful. + * + * Reverts if: + * - caller is not the contract owner + * - parameter "voided" is set to true + * - max mutualized amount per transaction is greater than max total mutualized amount + * - max mutualized amount per transaction is 0 + * - end timestamp is not greater than start timestamp + * - end timestamp is not greater than current block timestamp + * + * @param _agreement - a fully populated agreement object + */ + function newAgreement(Agreement calldata _agreement) external; + + function payPremium(uint256 _agreementId) external payable; + + function voidAgreement(uint256 _agreementId) external; + + function deposit(address _tokenAddress, uint256 _amount) external payable; + + function withdraw(address _tokenAddress, uint256 _amount) external; + + function getAgreement(uint256 _agreementId) external view returns (Agreement memory); +} \ No newline at end of file diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index 4d41c6723..3b9610afc 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -1,34 +1,22 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.9; -import { IDRFeeMutualizer } from "../../../interfaces/clients/IDRFeeMutualizer.sol"; +import "../../../domain/BosonConstants.sol"; +import { IDRFeeMutualizerClient } from "../../../interfaces/clients/IDRFeeMutualizer.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -// import { ClientBase } from "../../bases/ClientBase.sol"; +import { ERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; /** * @title DRFeeMutualizer * @notice This is a reference implementation of Dispute resolver fee mutualizer. * */ -contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { +contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { using SafeERC20 for IERC20; address private immutable protocolAddress; - struct Agreement { - address sellerAddress; - address token; - uint256 maxMutualizedAmountPerTransaction; - uint256 maxTotalMutualizedAmount; - uint256 premium; - uint128 startTimestamp; - uint128 endTimestamp; - bool refundOnCancel; - bool voided; - } - Agreement[] private agreements; mapping(address => mapping(address => uint256)) private agreementBySellerAndToken; mapping(uint256 => uint256) private outstandingExchaganes; @@ -36,10 +24,6 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { mapping(uint256 => uint256) private agreementByUuid; uint256 private agreementCounter; - event AgreementCreated(address indexed sellerAddress, uint256 indexed agreementId, Agreement agreement); - event AgreementConfirmed(address indexed sellerAddress, uint256 indexed agreementId); - event DRFeeReturned(uint256 indexed uuid, uint256 feeAmount, bytes context); - constructor(address _protocolAddress) { protocolAddress = _protocolAddress; Agreement memory emptyAgreement; @@ -47,8 +31,7 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { } /** - * @notice Tells if mutualizer will covert fee amount for a given seller and requrested by a given address. - * + * @notice Tells if mutualizer will cover the fee amount for a given seller and requrested by a given address. * * @param _sellerAddress - the seller address * @param _token - the token address (use 0x0 for ETH) @@ -66,7 +49,6 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; Agreement storage agreement = agreements[agreementId]; - // instead of returning, we could also revert with a reason return (msg.sender == protocolAddress && agreement.startTimestamp <= block.timestamp && agreement.endTimestamp >= block.timestamp && @@ -94,23 +76,17 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { uint256 _feeAmount, bytes calldata _context ) external returns (bool isCovered, uint256 uuid) { - require(msg.sender == protocolAddress, "Only protocol can call this function"); + require(msg.sender == protocolAddress, ONLY_PROTOCOL); uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; Agreement storage agreement = agreements[agreementId]; - require(agreement.startTimestamp <= block.timestamp, "Agreement not started yet"); - require(agreement.endTimestamp >= block.timestamp, "Agreement expired"); - require(!agreement.voided, "Agreement voided"); - require( - agreement.maxMutualizedAmountPerTransaction >= _feeAmount, - "Fee amount exceeds max mutualized amount per transaction" - ); + require(agreement.startTimestamp <= block.timestamp, AGREEMENT_NOT_STARTED); + require(agreement.endTimestamp >= block.timestamp, AGREEMENT_EXPIRED); + require(!agreement.voided, AGREEMENT_VOIDED); + require(agreement.maxMutualizedAmountPerTransaction >= _feeAmount, EXCEEDED_SINGLE_FEE); totalMutualizedAmount[agreementId] += _feeAmount; - require( - agreement.maxTotalMutualizedAmount >= totalMutualizedAmount[agreementId], - "Fee amount exceeds max total mutualized amount" - ); + require(agreement.maxTotalMutualizedAmount >= totalMutualizedAmount[agreementId], EXCEEDED_TOTAL_FEE); outstandingExchaganes[agreementId]++; @@ -136,7 +112,7 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { */ function returnDRFee(uint256 _uuid, uint256 _feeAmount, bytes calldata _context) external payable { uint256 agreementId = agreementByUuid[_uuid]; - require(agreementId != 0, "Invalid uuid"); + require(agreementId != 0, INVALID_UUID); if (_feeAmount > 0) { Agreement storage agreement = agreements[agreementId]; @@ -155,8 +131,29 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { emit DRFeeReturned(_uuid, _feeAmount, _context); } + /** + * @notice Stores a new agreement between mutualizer and seller. Only contract owner can submint an agreement, + * however it becomes valid only after seller confirms it by calling payPremium. + * + * Emits AgreementCreated event if successful. + * + * Reverts if: + * - caller is not the contract owner + * - parameter "voided" is set to true + * - max mutualized amount per transaction is greater than max total mutualized amount + * - max mutualized amount per transaction is 0 + * - end timestamp is not greater than start timestamp + * - end timestamp is not greater than current block timestamp + * + * @param _agreement - a fully populated agreement object + */ function newAgreement(Agreement calldata _agreement) external onlyOwner { - require(!_agreement.voided, "Agreement voided"); + require(!_agreement.voided, INVALID_AGREEMENT); + require(_agreement.maxMutualizedAmountPerTransaction <= _agreement.maxTotalMutualizedAmount, INVALID_AGREEMENT); + require(_agreement.maxMutualizedAmountPerTransaction > 0, INVALID_AGREEMENT); + require(_agreement.endTimestamp > _agreement.startTimestamp, INVALID_AGREEMENT); + require(_agreement.endTimestamp > block.timestamp, INVALID_AGREEMENT); + agreements.push(_agreement); uint256 agreementId = agreements.length - 1; @@ -166,8 +163,8 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { function payPremium(uint256 _agreementId) external payable { Agreement storage agreement = agreements[_agreementId]; - require(agreement.sellerAddress == msg.sender, "Invalid seller address"); - require(!agreement.voided, "Agreement voided"); + require(agreement.sellerAddress == msg.sender, INVALID_SELLER_ADDRESS); + require(!agreement.voided, AGREEMENT_VOIDED); transferFundsToMutualizer(agreement.token, agreement.premium); @@ -177,14 +174,10 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { emit AgreementConfirmed(agreement.sellerAddress, _agreementId); } - function getAgreement(uint256 _agreementId) external view returns (Agreement memory) { - return agreements[_agreementId]; - } - function voidAgreement(uint256 _agreementId) external { Agreement storage agreement = agreements[_agreementId]; - require(msg.sender == owner() || msg.sender == agreement.sellerAddress, "Invalid sender address"); + require(msg.sender == owner() || msg.sender == agreement.sellerAddress, INVALID_SELLER_ADDRESS); agreement.voided = true; @@ -207,16 +200,32 @@ contract DRFeeMutualizer is IDRFeeMutualizer, Ownable { } } + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[EIP section] + * to learn more about how these ids are created. + * + * This function call must use less than 30 000 gas. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IDRFeeMutualizerClient).interfaceId || super.supportsInterface(interfaceId); + } + + function getAgreement(uint256 _agreementId) external view returns (Agreement memory) { + return agreements[_agreementId]; + } + function transferFundsToMutualizer(address _tokenAddress, uint256 _amount) internal { if (_tokenAddress == address(0)) { - require(msg.value == _amount, "Invalid incoming amount"); + require(msg.value == _amount, INSUFFICIENT_VALUE_RECEIVED); } else { - require(msg.value == 0, "Invalid incoming amount"); + require(msg.value == 0, INSUFFICIENT_VALUE_RECEIVED); IERC20 token = IERC20(_tokenAddress); uint256 balanceBefore = token.balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(this), _amount); uint256 balanceAfter = token.balanceOf(address(this)); - require(balanceAfter - balanceBefore == _amount, "Invalid incoming amount"); + require(balanceAfter - balanceBefore == _amount, INSUFFICIENT_VALUE_RECEIVED); } } } diff --git a/scripts/config/revert-reasons.js b/scripts/config/revert-reasons.js index 7e26a7103..3fce5f863 100644 --- a/scripts/config/revert-reasons.js +++ b/scripts/config/revert-reasons.js @@ -212,4 +212,15 @@ exports.RevertReasons = { INIT_ZERO_ADDRESS_NON_EMPTY_CALLDATA: "LibDiamondCut: _init is address(0) but _calldata is not empty", INIT_EMPTY_CALLDATA_NON_ZERO_ADDRESS: "LibDiamondCut: _calldata is empty but _init is not address(0)", INIT_ADDRESS_WITH_NO_CODE: "LibDiamondCut: _init address has no code", + + // DRFeeMutualizer + ONLY_PROTOCOL: "Only protocol can call this function", + AGREEMENT_NOT_STARTED: "Agreement not started yet", + AGREEMENT_EXPIRED: "Agreement expired", + AGREEMENT_VOIDED: "Agreement voided", + EXCEEDED_SINGLE_FEE: "Fee amount exceeds max mutualized amount per transaction", + EXCEEDED_TOTAL_FEE: "Fee amount exceeds max total mutualized amount", + INVALID_UUID: "Invalid UUID", + INVALID_SELLER_ADDRESS: "Invalid seller address", + INVALID_AGREEMENT: "Invalid agreement", }; diff --git a/test/domain/OfferTest.js b/test/domain/OfferTest.js index 782ce6ed7..5538c874b 100644 --- a/test/domain/OfferTest.js +++ b/test/domain/OfferTest.js @@ -1,5 +1,4 @@ -const hre = require("hardhat"); -const ethers = hre.ethers; +const { ethers } = require("hardhat"); const { expect } = require("chai"); const Offer = require("../../scripts/domain/Offer"); diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js new file mode 100644 index 000000000..4df2d65b5 --- /dev/null +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -0,0 +1,140 @@ +const { ethers } = require("hardhat"); +const { getInterfaceIds } = require("../../../scripts/config/supported-interfaces.js"); +const Agreement = require("../../../scripts/domain/Agreement"); + +const { expect } = require("chai"); +const { RevertReasons } = require("../../../scripts/config/revert-reasons"); +const { getSnapshot, revertToSnapshot } = require("../../util/utils.js"); +const { oneMonth } = require("../../util/constants"); + +describe("IDRFeeMutualizer", function () { + let interfaceIds; + let protocol, mutualizerOwner, rando, assistant; + let snapshotId; + let mutualizer; + + before(async function () { + // Get interface id + interfaceIds = await getInterfaceIds(); + + const mutualizerFactory = await ethers.getContractFactory("DRFeeMutualizer"); + mutualizer = await mutualizerFactory.connect(mutualizerOwner).deploy(protocol.address); + + // Get snapshot id + snapshotId = await getSnapshot(); + }); + + afterEach(async function () { + await revertToSnapshot(snapshotId); + snapshotId = await getSnapshot(); + }); + + // Interface support + context("📋 Interfaces", async function () { + context("👉 supportsInterface()", async function () { + it("should indicate support for IDRFeeMutualizer", async function () { + // IBosonVoucher interface + let support = await mutualizer.supportsInterface(interfaceIds["IDRFeeMutualizer"]); + expect(support, "IDRFeeMutualizer interface not supported").is.true; + }); + }); + }); + + context("📋 DRMutualizer methods", async function () { + context("👉 newAgreement()", function () { + let agreement; + + beforeEach(function () { + const startTimestamp = ethers.BigNumber.from(Date.now()).div(1000); // valid from now + const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days + agreement = new Agreement( + assistant.address, + ethers.constants.AddressZero, + ethers.utils.parseUnits("1", "ether").toString(), + ethers.utils.parseUnits("1", "ether").toString(), + "0", + startTimestamp.toString(), + endTimestamp.toString(), + false, + false + ); + }); + + it("should emit an AgreementCreated event", async function () { + // Create a new agreement, test for event + await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)) + .to.emit(mutualizer, "AgreementCreated") + .withArgs(assistant.address, "1", agreement.toStruct()); + }); + + it("should update state", async function () { + await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)).to.emit( + mutualizer, + "AgreementCreated" + ); + + // Get agreement object from contract + const returnedAgreement = Agreement.fromStruct(await mutualizer.getAgreement("1")); + + // Values should match + expect(returnedAgreement.toString()).eq(agreement.toString()); + }); + + context("💔 Revert Reasons", async function () { + it("caller is not the contract owner", async function () { + // Expect revert if random user attempts to issue voucher + await expect(mutualizer.connect(rando).newAgreement(agreement)).to.be.revertedWith( + RevertReasons.OWNABLE_NOT_OWNER + ); + }); + + it("voided is set to true", async function () { + agreement.voided = true; + + // Expect revert if voided is true + await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)).to.be.revertedWith( + RevertReasons.INVALID_AGREEMENT + ); + }); + + it("max mutualized amount per transaction is greater than max total mutualized amount", async function () { + agreement.maxMutualizedAmountPerTransaction = ethers.BigNumber.from(agreement.maxTotalMutualizedAmount) + .add(1) + .toString(); + + // Expect revert if max mutualized amount per transaction is greater than max total mutualized amount + await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)).to.be.revertedWith( + RevertReasons.INVALID_AGREEMENT + ); + }); + + it("max mutualized amount per transaction is 0", async function () { + agreement.maxMutualizedAmountPerTransaction = "0"; + + // Expect revert if max mutualized amount per transaction is 0 + await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)).to.be.revertedWith( + RevertReasons.INVALID_AGREEMENT + ); + }); + + it("end timestamp is not greater than start timestamp", async function () { + agreement.endTimestamp = ethers.BigNumber.from(agreement.startTimestamp).sub(1).toString(); + + // Expect revert if the end timestamp is not greater than start timestamp + await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)).to.be.revertedWith( + RevertReasons.INVALID_AGREEMENT + ); + }); + + it("end timestamp is not greater than current block timestamp", async function () { + agreement.endTimestamp = ethers.BigNumber.from(Date.now()).div(1000).sub(1).toString(); + + // Expect revert if the end timestamp is not greater than current block timestamp + await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)).to.be.revertedWith( + RevertReasons.INVALID_AGREEMENT + ); + }); + }); + }); + }); +}); From 95e4a0aba2b7cce3fd0ef3fae70f668bebb8e99d Mon Sep 17 00:00:00 2001 From: zajck Date: Tue, 30 May 2023 10:45:48 +0200 Subject: [PATCH 22/33] DRfee mutualizer tests - payPremium --- contracts/domain/BosonConstants.sol | 1 + .../interfaces/clients/IDRFeeMutualizer.sol | 55 +----- .../clients/IDRFeeMutualizerClient.sol | 73 +++++++ .../clients/feeMutualizer/DRFeeMutualizer.sol | 75 +++++-- scripts/config/revert-reasons.js | 1 + scripts/config/supported-interfaces.js | 2 + test/protocol/clients/DRFeeMutualizerTest.js | 187 +++++++++++++++--- 7 files changed, 299 insertions(+), 95 deletions(-) create mode 100644 contracts/interfaces/clients/IDRFeeMutualizerClient.sol diff --git a/contracts/domain/BosonConstants.sol b/contracts/domain/BosonConstants.sol index 5cb43b0f3..196327797 100644 --- a/contracts/domain/BosonConstants.sol +++ b/contracts/domain/BosonConstants.sol @@ -209,6 +209,7 @@ string constant EXCEEDED_TOTAL_FEE = "Fee amount exceeds max total mutualized am string constant INVALID_UUID = "Invalid UUID"; string constant INVALID_SELLER_ADDRESS = "Invalid seller address"; string constant INVALID_AGREEMENT = "Invalid agreement"; +string constant AGREEMENT_ALREADY_CONFIRMED = "Agreement already confirmed"; // Meta Transactions - Structs bytes32 constant META_TRANSACTION_TYPEHASH = keccak256( diff --git a/contracts/interfaces/clients/IDRFeeMutualizer.sol b/contracts/interfaces/clients/IDRFeeMutualizer.sol index 08d41d702..611202590 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizer.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizer.sol @@ -8,7 +8,7 @@ pragma solidity 0.8.9; * * ToDo: should this be split into two interfaces? Minimal interface for the protocol and full interface for the clients? * - * The ERC-165 identifier for this interface is: 0xb13f055e + * The ERC-165 identifier for this interface is: 0x41283543 */ interface IDRFeeMutualizer { event DRFeeRequsted( @@ -68,56 +68,3 @@ interface IDRFeeMutualizer { */ function returnDRFee(uint256 _uuid, uint256 _feeAmount, bytes calldata _context) external payable; } - - -/** - * @title IDRFeeMutualizerClient - * - * @notice This is the interface for the Dispute Resolver fee mutualizers. - * - * The ERC-165 identifier for this interface is: 0x41283543 - */ -interface IDRFeeMutualizerClient is IDRFeeMutualizer { - struct Agreement { - address sellerAddress; - address token; - uint256 maxMutualizedAmountPerTransaction; - uint256 maxTotalMutualizedAmount; - uint256 premium; - uint128 startTimestamp; - uint128 endTimestamp; - bool refundOnCancel; - bool voided; - } - - event AgreementCreated(address indexed sellerAddress, uint256 indexed agreementId, Agreement agreement); - event AgreementConfirmed(address indexed sellerAddress, uint256 indexed agreementId); - - /** - * @notice Stores a new agreement between mutualizer and seller. Only contract owner can submint an agreement, - * however it becomes valid only after seller confirms it by calling payPremium. - * - * Emits AgreementCreated event if successful. - * - * Reverts if: - * - caller is not the contract owner - * - parameter "voided" is set to true - * - max mutualized amount per transaction is greater than max total mutualized amount - * - max mutualized amount per transaction is 0 - * - end timestamp is not greater than start timestamp - * - end timestamp is not greater than current block timestamp - * - * @param _agreement - a fully populated agreement object - */ - function newAgreement(Agreement calldata _agreement) external; - - function payPremium(uint256 _agreementId) external payable; - - function voidAgreement(uint256 _agreementId) external; - - function deposit(address _tokenAddress, uint256 _amount) external payable; - - function withdraw(address _tokenAddress, uint256 _amount) external; - - function getAgreement(uint256 _agreementId) external view returns (Agreement memory); -} \ No newline at end of file diff --git a/contracts/interfaces/clients/IDRFeeMutualizerClient.sol b/contracts/interfaces/clients/IDRFeeMutualizerClient.sol new file mode 100644 index 000000000..3b8ed260b --- /dev/null +++ b/contracts/interfaces/clients/IDRFeeMutualizerClient.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.9; +import "./IDRFeeMutualizer.sol"; + +/** + * @title IDRFeeMutualizerClient + * + * @notice This is the interface for the Dispute Resolver fee mutualizers. + * + * The ERC-165 identifier for this interface is: 0x3ac29309 + */ +interface IDRFeeMutualizerClient is IDRFeeMutualizer { + struct Agreement { + address sellerAddress; + address token; + uint256 maxMutualizedAmountPerTransaction; + uint256 maxTotalMutualizedAmount; + uint256 premium; + uint128 startTimestamp; + uint128 endTimestamp; + bool refundOnCancel; + bool voided; + } + + event AgreementCreated(address indexed sellerAddress, uint256 indexed agreementId, Agreement agreement); + event AgreementConfirmed(address indexed sellerAddress, uint256 indexed agreementId); + + /** + * @notice Stores a new agreement between mutualizer and seller. Only contract owner can submit an agreement, + * however it becomes valid only after seller confirms it by calling payPremium. + * + * Emits AgreementCreated event if successful. + * + * Reverts if: + * - caller is not the contract owner + * - parameter "voided" is set to true + * - max mutualized amount per transaction is greater than max total mutualized amount + * - max mutualized amount per transaction is 0 + * - end timestamp is not greater than start timestamp + * - end timestamp is not greater than current block timestamp + * + * @param _agreement - a fully populated agreement object + */ + function newAgreement(Agreement calldata _agreement) external; + + /** + * @notice Pay the premium for the agreement and confirm it. + * + * Emits AgreementConfirmed event if successful. + * + * Reverts if: + * - agreement does not exist + * - agreement is already confirmed + * - agreement is voided + * - agreement expired + * + * @param _agreementId - a unique identifier of the agreement + */ + function payPremium(uint256 _agreementId) external payable; + + function voidAgreement(uint256 _agreementId) external; + + function deposit(address _tokenAddress, uint256 _amount) external payable; + + function withdraw(address _tokenAddress, uint256 _amount) external; + + function getAgreement(uint256 _agreementId) external view returns (Agreement memory); + + function getAgreementBySellerAndToken( + address _seller, + address _token + ) external view returns (uint256 agreementId, Agreement memory aggreement); +} diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index 3b9610afc..e4d8b1864 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -1,7 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.9; import "../../../domain/BosonConstants.sol"; -import { IDRFeeMutualizerClient } from "../../../interfaces/clients/IDRFeeMutualizer.sol"; +import { IDRFeeMutualizer } from "../../../interfaces/clients/IDRFeeMutualizer.sol"; +import { IDRFeeMutualizerClient } from "../../../interfaces/clients/IDRFeeMutualizerClient.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -15,14 +16,21 @@ import { ERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { using SafeERC20 for IERC20; + struct AgreementStatus { + bool confirmed; + uint256 outstandingExchanges; + uint256 totalMutualizedAmount; + } + address private immutable protocolAddress; Agreement[] private agreements; mapping(address => mapping(address => uint256)) private agreementBySellerAndToken; - mapping(uint256 => uint256) private outstandingExchaganes; - mapping(uint256 => uint256) private totalMutualizedAmount; + // mapping(uint256 => uint256) private totalMutualizedAmount; + mapping(uint256 => AgreementStatus) private agreementStatus; + mapping(uint256 => uint256) private agreementByUuid; - uint256 private agreementCounter; + uint256 private uuidCounter; constructor(address _protocolAddress) { protocolAddress = _protocolAddress; @@ -54,7 +62,7 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { agreement.endTimestamp >= block.timestamp && !agreement.voided && agreement.maxMutualizedAmountPerTransaction >= _feeAmount && - agreement.maxTotalMutualizedAmount + _feeAmount >= totalMutualizedAmount[agreementId]); + agreement.maxTotalMutualizedAmount + _feeAmount >= agreementStatus[agreementId].totalMutualizedAmount); } /** @@ -85,12 +93,13 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { require(!agreement.voided, AGREEMENT_VOIDED); require(agreement.maxMutualizedAmountPerTransaction >= _feeAmount, EXCEEDED_SINGLE_FEE); - totalMutualizedAmount[agreementId] += _feeAmount; - require(agreement.maxTotalMutualizedAmount >= totalMutualizedAmount[agreementId], EXCEEDED_TOTAL_FEE); + AgreementStatus storage status = agreementStatus[agreementId]; + status.totalMutualizedAmount += _feeAmount; + require(agreement.maxTotalMutualizedAmount >= status.totalMutualizedAmount, EXCEEDED_TOTAL_FEE); - outstandingExchaganes[agreementId]++; + status.outstandingExchanges++; - agreementByUuid[++agreementCounter] = agreementId; + agreementByUuid[++uuidCounter] = agreementId; if (agreement.token == address(0)) { payable(msg.sender).transfer(_feeAmount); } else { @@ -98,7 +107,7 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { token.safeTransfer(msg.sender, _feeAmount); } - return (true, agreementCounter); + return (true, uuidCounter); } /** @@ -113,20 +122,23 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { function returnDRFee(uint256 _uuid, uint256 _feeAmount, bytes calldata _context) external payable { uint256 agreementId = agreementByUuid[_uuid]; require(agreementId != 0, INVALID_UUID); + + AgreementStatus storage status = agreementStatus[agreementId]; if (_feeAmount > 0) { Agreement storage agreement = agreements[agreementId]; transferFundsToMutualizer(agreement.token, _feeAmount); - if (_feeAmount < totalMutualizedAmount[agreementId]) { + if (_feeAmount < status.totalMutualizedAmount) { // not necessary if we restrict call to the protocol only - totalMutualizedAmount[agreementId] -= _feeAmount; + status.totalMutualizedAmount -= _feeAmount; } else { - totalMutualizedAmount[agreementId] = 0; + status.totalMutualizedAmount = 0; } } - outstandingExchaganes[agreementId]--; + status.outstandingExchanges--; + delete agreementByUuid[_uuid]; // prevent using the same uuid twice emit DRFeeReturned(_uuid, _feeAmount, _context); } @@ -160,16 +172,34 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { emit AgreementCreated(_agreement.sellerAddress, agreementId, _agreement); } + /** + * @notice Pay the premium for the agreement and confirm it. + * + * Emits AgreementConfirmed event if successful. + * + * Reverts if: + * - agreement does not exist + * - agreement is already confirmed + * - agreement is voided + * - agreement expired + * + * @param _agreementId - a unique identifier of the agreement + */ function payPremium(uint256 _agreementId) external payable { - Agreement storage agreement = agreements[_agreementId]; + require(_agreementId > 0 && _agreementId < agreements.length, INVALID_AGREEMENT); - require(agreement.sellerAddress == msg.sender, INVALID_SELLER_ADDRESS); + AgreementStatus storage status = agreementStatus[_agreementId]; + require(!status.confirmed, AGREEMENT_ALREADY_CONFIRMED); + + Agreement storage agreement = agreements[_agreementId]; require(!agreement.voided, AGREEMENT_VOIDED); + require(agreement.endTimestamp > block.timestamp, AGREEMENT_EXPIRED); transferFundsToMutualizer(agreement.token, agreement.premium); // even if agreementBySellerAndToken[_agreement.sellerAddress][_agreement.token] exists, seller can overwrite it agreementBySellerAndToken[agreement.sellerAddress][agreement.token] = _agreementId; + status.confirmed = true; emit AgreementConfirmed(agreement.sellerAddress, _agreementId); } @@ -209,13 +239,24 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * This function call must use less than 30 000 gas. */ function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { - return interfaceId == type(IDRFeeMutualizerClient).interfaceId || super.supportsInterface(interfaceId); + return + (interfaceId == type(IDRFeeMutualizer).interfaceId) || + (interfaceId == type(IDRFeeMutualizerClient).interfaceId) || + super.supportsInterface(interfaceId); } function getAgreement(uint256 _agreementId) external view returns (Agreement memory) { return agreements[_agreementId]; } + function getAgreementBySellerAndToken( + address _seller, + address _token + ) external view returns (uint256 agreementId, Agreement memory aggreement) { + agreementId = agreementBySellerAndToken[_seller][_token]; + aggreement = agreements[agreementId]; + } + function transferFundsToMutualizer(address _tokenAddress, uint256 _amount) internal { if (_tokenAddress == address(0)) { require(msg.value == _amount, INSUFFICIENT_VALUE_RECEIVED); diff --git a/scripts/config/revert-reasons.js b/scripts/config/revert-reasons.js index 3fce5f863..2f198a629 100644 --- a/scripts/config/revert-reasons.js +++ b/scripts/config/revert-reasons.js @@ -223,4 +223,5 @@ exports.RevertReasons = { INVALID_UUID: "Invalid UUID", INVALID_SELLER_ADDRESS: "Invalid seller address", INVALID_AGREEMENT: "Invalid agreement", + AGREEMENT_ALREADY_CONFIRMED: "Agreement already confirmed", }; diff --git a/scripts/config/supported-interfaces.js b/scripts/config/supported-interfaces.js index 2b2cc535a..07ab8d3aa 100644 --- a/scripts/config/supported-interfaces.js +++ b/scripts/config/supported-interfaces.js @@ -53,6 +53,8 @@ async function getInterfaceIds(useCache = true) { "contracts/interfaces/IERC721.sol:IERC721", "contracts/interfaces/IERC2981.sol:IERC2981", "IAccessControl", + // "IDRFeeMutualizer", + "IDRFeeMutualizerClient", ].forEach((iFace) => { skipBaseCheck[iFace] = false; }); diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js index 4df2d65b5..76920d633 100644 --- a/test/protocol/clients/DRFeeMutualizerTest.js +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -4,22 +4,28 @@ const Agreement = require("../../../scripts/domain/Agreement"); const { expect } = require("chai"); const { RevertReasons } = require("../../../scripts/config/revert-reasons"); -const { getSnapshot, revertToSnapshot } = require("../../util/utils.js"); +const { getSnapshot, revertToSnapshot, setNextBlockTimestamp } = require("../../util/utils.js"); const { oneMonth } = require("../../util/constants"); +const { deployMockTokens } = require("../../../scripts/util/deploy-mock-tokens"); describe("IDRFeeMutualizer", function () { let interfaceIds; let protocol, mutualizerOwner, rando, assistant; let snapshotId; let mutualizer; + let foreign20; before(async function () { // Get interface id interfaceIds = await getInterfaceIds(); + [protocol, mutualizerOwner, rando, assistant] = await ethers.getSigners(); + const mutualizerFactory = await ethers.getContractFactory("DRFeeMutualizer"); mutualizer = await mutualizerFactory.connect(mutualizerOwner).deploy(protocol.address); + [foreign20] = await deployMockTokens(["Foreign20", "BosonToken"]); + // Get snapshot id snapshotId = await getSnapshot(); }); @@ -32,34 +38,38 @@ describe("IDRFeeMutualizer", function () { // Interface support context("📋 Interfaces", async function () { context("👉 supportsInterface()", async function () { - it("should indicate support for IDRFeeMutualizer", async function () { - // IBosonVoucher interface + it("should indicate support for IDRFeeMutualizer and IDRFeeMutualizerClient", async function () { + // IDRFeeMutualizer interface let support = await mutualizer.supportsInterface(interfaceIds["IDRFeeMutualizer"]); expect(support, "IDRFeeMutualizer interface not supported").is.true; + + // IDRFeeMutualizerClient interface + support = await mutualizer.supportsInterface(interfaceIds["IDRFeeMutualizerClient"]); + expect(support, "IDRFeeMutualizerClient interface not supported").is.true; }); }); }); context("📋 DRMutualizer methods", async function () { - context("👉 newAgreement()", function () { - let agreement; + let agreement; - beforeEach(function () { - const startTimestamp = ethers.BigNumber.from(Date.now()).div(1000); // valid from now - const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days - agreement = new Agreement( - assistant.address, - ethers.constants.AddressZero, - ethers.utils.parseUnits("1", "ether").toString(), - ethers.utils.parseUnits("1", "ether").toString(), - "0", - startTimestamp.toString(), - endTimestamp.toString(), - false, - false - ); - }); + beforeEach(function () { + const startTimestamp = ethers.BigNumber.from(Date.now()).div(1000); // valid from now + const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days + agreement = new Agreement( + assistant.address, + ethers.constants.AddressZero, + ethers.utils.parseUnits("1", "ether").toString(), + ethers.utils.parseUnits("1", "ether").toString(), + ethers.utils.parseUnits("0.001", "ether").toString(), + startTimestamp.toString(), + endTimestamp.toString(), + false, + false + ); + }); + context("👉 newAgreement()", function () { it("should emit an AgreementCreated event", async function () { // Create a new agreement, test for event await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)) @@ -68,10 +78,7 @@ describe("IDRFeeMutualizer", function () { }); it("should update state", async function () { - await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)).to.emit( - mutualizer, - "AgreementCreated" - ); + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); // Get agreement object from contract const returnedAgreement = Agreement.fromStruct(await mutualizer.getAgreement("1")); @@ -136,5 +143,137 @@ describe("IDRFeeMutualizer", function () { }); }); }); + + context("👉 payPremium()", function () { + let agreementId; + + beforeEach(async function () { + // Create a new agreement + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + + agreementId = "1"; + }); + + it("should emit an AgreementConfirmed event", async function () { + // Pay the premium, test for event + await expect(mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium })) + .to.emit(mutualizer, "AgreementConfirmed") + .withArgs(assistant.address, agreementId); + }); + + it("should update state", async function () { + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + + // Get agreement id and agreement object from contract + const [returnedAgreementId, returnedAgreement] = await mutualizer.getAgreementBySellerAndToken( + assistant.address, + ethers.constants.AddressZero + ); + const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); + + // Values should match + expect(returnedAgreementStruct.toString()).eq(agreement.toString()); + expect(returnedAgreementId.toString()).eq(agreementId); + }); + + it("anyone can pay premium on seller's behalf", async function () { + // Pay the premium, test for event + await expect(mutualizer.connect(rando).payPremium(agreementId, { value: agreement.premium })) + .to.emit(mutualizer, "AgreementConfirmed") + .withArgs(assistant.address, agreementId); + }); + + it("premium in ERC20 tokens", async function () { + agreement.token = foreign20.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + agreementId = "2"; + + await foreign20.connect(assistant).mint(assistant.address, agreement.premium); + await foreign20.connect(assistant).approve(mutualizer.address, agreement.premium); + + // Pay the premium, test for event + await expect(mutualizer.connect(assistant).payPremium(agreementId)) + .to.emit(mutualizer, "AgreementConfirmed") + .withArgs(assistant.address, agreementId); + }); + + it("it is possible to substitute an agreement", async function () { + // Agreement is confirmed + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + + // Create a new agreement for the same seller and token + const startTimestamp = ethers.BigNumber.from(Date.now()).div(1000); // valid from now + const endTimestamp = startTimestamp.add(oneMonth * 2); // valid for 30 days + agreement = new Agreement( + assistant.address, + ethers.constants.AddressZero, + ethers.utils.parseUnits("2", "ether").toString(), + ethers.utils.parseUnits("2", "ether").toString(), + ethers.utils.parseUnits("0.001", "ether").toString(), + startTimestamp.toString(), + endTimestamp.toString(), + false, + false + ); + + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + agreementId = "2"; + + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + + // Get agreement id and agreement object from contract + const [returnedAgreementId, returnedAgreement] = await mutualizer.getAgreementBySellerAndToken( + assistant.address, + ethers.constants.AddressZero + ); + const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); + + // Values should match + expect(returnedAgreementStruct.toString()).eq(agreement.toString()); + expect(returnedAgreementId.toString()).eq(agreementId); + }); + + context("💔 Revert Reasons", async function () { + it("agreement does not exist", async function () { + // Expect revert if agreement id is out of bound + agreementId = "100"; + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + + agreementId = "0"; + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + }); + + it("agreement is already confirmed", async function () { + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + + // Expect revert if already confirmed + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.AGREEMENT_ALREADY_CONFIRMED); + }); + + it("agreement is already confirmed", async function () { + await mutualizer.connect(mutualizerOwner).voidAgreement(agreementId); + + // Expect revert if voided + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.AGREEMENT_VOIDED); + }); + + it("agreement expired", async function () { + await setNextBlockTimestamp(ethers.BigNumber.from(agreement.endTimestamp).add(1).toHexString()); + + // Expect revert if expired + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.AGREEMENT_EXPIRED); + }); + }); + }); }); }); From 21a3327512efd1f474c29c9b40ea94915725cb06 Mon Sep 17 00:00:00 2001 From: zajck Date: Tue, 30 May 2023 13:13:45 +0200 Subject: [PATCH 23/33] DRfee mutualizer tests - voidAgreement --- contracts/domain/BosonConstants.sol | 1 + .../clients/IDRFeeMutualizerClient.sol | 14 ++++ .../clients/feeMutualizer/DRFeeMutualizer.sol | 56 ++++++++++--- scripts/config/revert-reasons.js | 1 + test/protocol/clients/DRFeeMutualizerTest.js | 79 ++++++++++++++++++- 5 files changed, 137 insertions(+), 14 deletions(-) diff --git a/contracts/domain/BosonConstants.sol b/contracts/domain/BosonConstants.sol index 196327797..744a55339 100644 --- a/contracts/domain/BosonConstants.sol +++ b/contracts/domain/BosonConstants.sol @@ -210,6 +210,7 @@ string constant INVALID_UUID = "Invalid UUID"; string constant INVALID_SELLER_ADDRESS = "Invalid seller address"; string constant INVALID_AGREEMENT = "Invalid agreement"; string constant AGREEMENT_ALREADY_CONFIRMED = "Agreement already confirmed"; +string constant NOT_OWNER_OR_SELLER = "Not owner or seller"; // Meta Transactions - Structs bytes32 constant META_TRANSACTION_TYPEHASH = keccak256( diff --git a/contracts/interfaces/clients/IDRFeeMutualizerClient.sol b/contracts/interfaces/clients/IDRFeeMutualizerClient.sol index 3b8ed260b..1625e03f3 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizerClient.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizerClient.sol @@ -24,6 +24,7 @@ interface IDRFeeMutualizerClient is IDRFeeMutualizer { event AgreementCreated(address indexed sellerAddress, uint256 indexed agreementId, Agreement agreement); event AgreementConfirmed(address indexed sellerAddress, uint256 indexed agreementId); + event AgreementVoided(address indexed sellerAddress, uint256 indexed agreementId); /** * @notice Stores a new agreement between mutualizer and seller. Only contract owner can submit an agreement, @@ -58,6 +59,19 @@ interface IDRFeeMutualizerClient is IDRFeeMutualizer { */ function payPremium(uint256 _agreementId) external payable; + /** + * @notice Void the agreement. + * + * Emits AgreementVoided event if successful. + * + * Reverts if: + * - agreement does not exist + * - caller is not the contract owner or the seller + * - agreement is voided already + * - agreement expired + * + * @param _agreementId - a unique identifier of the agreement + */ function voidAgreement(uint256 _agreementId) external; function deposit(address _tokenAddress, uint256 _amount) external payable; diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index e4d8b1864..4f2c9cab7 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -45,19 +45,19 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * @param _token - the token address (use 0x0 for ETH) * @param _feeAmount - amount to cover * @param _feeRequester - address of the requester - * @param _context - additional data, describing the context + * @param /_context - additional data, describing the context */ function isSellerCovered( address _sellerAddress, address _token, uint256 _feeAmount, address _feeRequester, - bytes calldata _context + bytes calldata /*_context*/ ) external view returns (bool) { uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; Agreement storage agreement = agreements[agreementId]; - return (msg.sender == protocolAddress && + return (_feeRequester == protocolAddress && agreement.startTimestamp <= block.timestamp && agreement.endTimestamp >= block.timestamp && !agreement.voided && @@ -74,7 +74,7 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * @param _sellerAddress - the seller address * @param _token - the token address (use 0x0 for ETH) * @param _feeAmount - amount to cover - * @param _context - additional data, describing the context + * @param /_context - additional data, describing the context * @return isCovered - true if the seller is covered * @return uuid - unique identifier of the request */ @@ -82,7 +82,7 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { address _sellerAddress, address _token, uint256 _feeAmount, - bytes calldata _context + bytes calldata /*_context*/ ) external returns (bool isCovered, uint256 uuid) { require(msg.sender == protocolAddress, ONLY_PROTOCOL); uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; @@ -186,15 +186,11 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * @param _agreementId - a unique identifier of the agreement */ function payPremium(uint256 _agreementId) external payable { - require(_agreementId > 0 && _agreementId < agreements.length, INVALID_AGREEMENT); + Agreement storage agreement = getValidAgreement(_agreementId); AgreementStatus storage status = agreementStatus[_agreementId]; require(!status.confirmed, AGREEMENT_ALREADY_CONFIRMED); - Agreement storage agreement = agreements[_agreementId]; - require(!agreement.voided, AGREEMENT_VOIDED); - require(agreement.endTimestamp > block.timestamp, AGREEMENT_EXPIRED); - transferFundsToMutualizer(agreement.token, agreement.premium); // even if agreementBySellerAndToken[_agreement.sellerAddress][_agreement.token] exists, seller can overwrite it @@ -204,17 +200,34 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { emit AgreementConfirmed(agreement.sellerAddress, _agreementId); } + /** + * @notice Void the agreement. + * + * Emits AgreementVoided event if successful. + * + * Reverts if: + * - agreement does not exist + * - caller is not the contract owner or the seller + * - agreement is voided already + * - agreement expired + * + * @param _agreementId - a unique identifier of the agreement + */ function voidAgreement(uint256 _agreementId) external { - Agreement storage agreement = agreements[_agreementId]; + Agreement storage agreement = getValidAgreement(_agreementId); - require(msg.sender == owner() || msg.sender == agreement.sellerAddress, INVALID_SELLER_ADDRESS); + require(msg.sender == owner() || msg.sender == agreement.sellerAddress, NOT_OWNER_OR_SELLER); agreement.voided = true; if (agreement.refundOnCancel) { // calculate unused premium + // ToDo: what is the business logic here? // what with the outstanding requests? + // uint256 unusedPremium = agreement.premium*(agreement.endTimestamp-block.timestamp)/(agreement.endTimestamp-agreement.startTimestamp); // potential overflow } + + emit AgreementVoided(agreement.sellerAddress, _agreementId); } function deposit(address _tokenAddress, uint256 _amount) external payable { @@ -269,4 +282,23 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { require(balanceAfter - balanceBefore == _amount, INSUFFICIENT_VALUE_RECEIVED); } } + + /** + * @notice Gets the agreement from the storage and verifies that it is valid. + * + * Reverts if: + * - agreement does not exist + * - agreement is voided + * - agreement expired + * + * @param _agreementId - a unique identifier of the agreement + */ + function getValidAgreement(uint256 _agreementId) internal view returns (Agreement storage agreement) { + require(_agreementId > 0 && _agreementId < agreements.length, INVALID_AGREEMENT); + + agreement = agreements[_agreementId]; + + require(!agreement.voided, AGREEMENT_VOIDED); + require(agreement.endTimestamp > block.timestamp, AGREEMENT_EXPIRED); + } } diff --git a/scripts/config/revert-reasons.js b/scripts/config/revert-reasons.js index 2f198a629..85528efb4 100644 --- a/scripts/config/revert-reasons.js +++ b/scripts/config/revert-reasons.js @@ -224,4 +224,5 @@ exports.RevertReasons = { INVALID_SELLER_ADDRESS: "Invalid seller address", INVALID_AGREEMENT: "Invalid agreement", AGREEMENT_ALREADY_CONFIRMED: "Agreement already confirmed", + NOT_OWNER_OR_SELLER: "Not owner or seller", }; diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js index 76920d633..d6a08d4c2 100644 --- a/test/protocol/clients/DRFeeMutualizerTest.js +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -8,7 +8,7 @@ const { getSnapshot, revertToSnapshot, setNextBlockTimestamp } = require("../../ const { oneMonth } = require("../../util/constants"); const { deployMockTokens } = require("../../../scripts/util/deploy-mock-tokens"); -describe("IDRFeeMutualizer", function () { +describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { let interfaceIds; let protocol, mutualizerOwner, rando, assistant; let snapshotId; @@ -256,7 +256,7 @@ describe("IDRFeeMutualizer", function () { ).to.be.revertedWith(RevertReasons.AGREEMENT_ALREADY_CONFIRMED); }); - it("agreement is already confirmed", async function () { + it("agreement is voided", async function () { await mutualizer.connect(mutualizerOwner).voidAgreement(agreementId); // Expect revert if voided @@ -275,5 +275,80 @@ describe("IDRFeeMutualizer", function () { }); }); }); + + context("👉 voidAgreement()", function () { + let agreementId; + + beforeEach(async function () { + // Create a new agreement + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + + agreementId = "1"; + }); + + it("should emit an AgreementVoided event", async function () { + // Void the agreement, test for event + await expect(mutualizer.connect(mutualizerOwner).voidAgreement(agreementId)) + .to.emit(mutualizer, "AgreementVoided") + .withArgs(assistant.address, agreementId); + }); + + it("should update state", async function () { + await mutualizer.connect(mutualizerOwner).voidAgreement(agreementId); + + // Get agreement object from contract + const returnedAgreement = Agreement.fromStruct(await mutualizer.getAgreement("1")); + + // Values should match + expect(returnedAgreement.voided).to.be.true; + }); + + it("seller can void the agreement", async function () { + // Void the agreement, test for event + await expect(mutualizer.connect(assistant).voidAgreement(agreementId)) + .to.emit(mutualizer, "AgreementVoided") + .withArgs(assistant.address, agreementId); + }); + + context("💔 Revert Reasons", async function () { + it("agreement does not exist", async function () { + // Expect revert if agreement id is out of bound + agreementId = "100"; + await expect(mutualizer.connect(mutualizerOwner).voidAgreement(agreementId)).to.be.revertedWith( + RevertReasons.INVALID_AGREEMENT + ); + + agreementId = "0"; + await expect(mutualizer.connect(mutualizerOwner).voidAgreement(agreementId)).to.be.revertedWith( + RevertReasons.INVALID_AGREEMENT + ); + }); + + it("caller is not the contract owner or the seller", async function () { + // Expect revert if rando calls + await expect(mutualizer.connect(rando).voidAgreement(agreementId)).to.be.revertedWith( + RevertReasons.NOT_OWNER_OR_SELLER + ); + }); + + it("agreement is voided already", async function () { + await mutualizer.connect(mutualizerOwner).voidAgreement(agreementId); + + // Expect revert if voided + await expect(mutualizer.connect(mutualizerOwner).voidAgreement(agreementId)).to.be.revertedWith( + RevertReasons.AGREEMENT_VOIDED + ); + }); + + it("agreement expired", async function () { + await setNextBlockTimestamp(ethers.BigNumber.from(agreement.endTimestamp).add(1).toHexString()); + + // Expect revert if expired + await expect(mutualizer.connect(mutualizerOwner).voidAgreement(agreementId)).to.be.revertedWith( + RevertReasons.AGREEMENT_EXPIRED + ); + }); + }); + }); }); }); From b10224327b61107e52eb02399f75e97c8144a32f Mon Sep 17 00:00:00 2001 From: zajck Date: Tue, 30 May 2023 16:41:20 +0200 Subject: [PATCH 24/33] deposit + withdraw tests --- .../clients/IDRFeeMutualizerClient.sol | 33 + contracts/mock/FallbackError.sol | 12 + .../clients/feeMutualizer/DRFeeMutualizer.sol | 58 +- test/protocol/FundsHandlerTest.js | 2 +- test/protocol/clients/DRFeeMutualizerTest.js | 573 ++++++++++++++---- 5 files changed, 570 insertions(+), 108 deletions(-) diff --git a/contracts/interfaces/clients/IDRFeeMutualizerClient.sol b/contracts/interfaces/clients/IDRFeeMutualizerClient.sol index 1625e03f3..70341b7f9 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizerClient.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizerClient.sol @@ -25,6 +25,8 @@ interface IDRFeeMutualizerClient is IDRFeeMutualizer { event AgreementCreated(address indexed sellerAddress, uint256 indexed agreementId, Agreement agreement); event AgreementConfirmed(address indexed sellerAddress, uint256 indexed agreementId); event AgreementVoided(address indexed sellerAddress, uint256 indexed agreementId); + event FundsDeposited(address indexed tokenAddress, uint256 amount, address indexed depositor); + event FundsWithdrawn(address indexed tokenAddress, uint256 amount); /** * @notice Stores a new agreement between mutualizer and seller. Only contract owner can submit an agreement, @@ -54,6 +56,10 @@ interface IDRFeeMutualizerClient is IDRFeeMutualizer { * - agreement is already confirmed * - agreement is voided * - agreement expired + * - token is native and sent value is not equal to the agreement premium + * - token is ERC20, but some native value is sent + * - token is ERC20 and sent value is not equal to the agreement premium + * - token is ERC20 and transferFrom fails * * @param _agreementId - a unique identifier of the agreement */ @@ -74,8 +80,35 @@ interface IDRFeeMutualizerClient is IDRFeeMutualizer { */ function voidAgreement(uint256 _agreementId) external; + /** + * @notice Deposit funds to the mutualizer. Funds are used to cover the DR fees. + * + * Emits FundsDeposited event if successful. + * + * Reverts if: + * - token is native and sent value is not equal to _amount + * - token is ERC20, but some native value is sent + * - token is ERC20 and sent value is not equal to _amount + * - token is ERC20 and transferFrom fails + * + * @param _tokenAddress - the token address (use 0x0 for native token) + * @param _amount - amount to transfer + */ function deposit(address _tokenAddress, uint256 _amount) external payable; + /** + * @notice Withdraw funds from the mutualizer. + * + * Emits FundsWithdrawn event if successful. + * + * Reverts if: + * - caller is not the mutualizer owner + * - amount exceeds available balance + * - token is ERC20 and transferFrom fails + * + * @param _tokenAddress - the token address (use 0x0 for native token) + * @param _amount - amount to transfer + */ function withdraw(address _tokenAddress, uint256 _amount) external; function getAgreement(uint256 _agreementId) external view returns (Agreement memory); diff --git a/contracts/mock/FallbackError.sol b/contracts/mock/FallbackError.sol index e92497286..2a01e4a7c 100644 --- a/contracts/mock/FallbackError.sol +++ b/contracts/mock/FallbackError.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.9; import { IBosonFundsHandler } from "../interfaces/handlers/IBosonFundsHandler.sol"; +import { IDRFeeMutualizerClient } from "../interfaces/clients/IDRFeeMutualizerClient.sol"; /** * @title WithoutFallbackError @@ -25,6 +26,17 @@ contract WithoutFallbackError { ) external { IBosonFundsHandler(_fundsHandlerAddress).withdrawFunds(_buyerId, _tokenList, _tokenAmounts); } + + /** + * @notice Function to call withdraw on mutualizer, contract being the owner + * + * @param _mutualizerAddress - mutualizer address + * @param _tokenAddress - the token address (use 0x0 for native token) + * @param _amount - amount to transfer + */ + function withdrawMutualizerFunds(address _mutualizerAddress, address _tokenAddress, uint256 _amount) external { + IDRFeeMutualizerClient(_mutualizerAddress).withdraw(_tokenAddress, _amount); + } } /** diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index 4f2c9cab7..aa65726ea 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -182,6 +182,10 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * - agreement is already confirmed * - agreement is voided * - agreement expired + * - token is native and sent value is not equal to the agreement premium + * - token is ERC20, but some native value is sent + * - token is ERC20 and sent value is not equal to the agreement premium + * - token is ERC20 and transferFrom fails * * @param _agreementId - a unique identifier of the agreement */ @@ -230,17 +234,55 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { emit AgreementVoided(agreement.sellerAddress, _agreementId); } + /** + * @notice Deposit funds to the mutualizer. Funds are used to cover the DR fees. + * + * Emits FundsDeposited event if successful. + * + * Reverts if: + * - token is native and sent value is not equal to _amount + * - token is ERC20, but some native value is sent + * - token is ERC20 and sent value is not equal to _amount + * - token is ERC20 and transferFrom fails + * + * @param _tokenAddress - the token address (use 0x0 for native token) + * @param _amount - amount to transfer + */ function deposit(address _tokenAddress, uint256 _amount) external payable { transferFundsToMutualizer(_tokenAddress, _amount); + emit FundsDeposited(_tokenAddress, _amount, msg.sender); } + /** + * @notice Withdraw funds from the mutualizer. + * + * Emits FundsWithdrawn event if successful. + * + * Reverts if: + * - caller is not the mutualizer owner + * - amount exceeds available balance + * - token is ERC20 and transferFrom fails + * + * @param _tokenAddress - the token address (use 0x0 for native token) + * @param _amount - amount to transfer + */ function withdraw(address _tokenAddress, uint256 _amount) external onlyOwner { + uint256 mutualizerBalance = _tokenAddress == address(0) + ? address(this).balance + : IERC20(_tokenAddress).balanceOf(address(this)); + + require(mutualizerBalance >= _amount, INSUFFICIENT_AVAILABLE_FUNDS); + if (_tokenAddress == address(0)) { - payable(owner()).transfer(_amount); + // payable(owner()).transfer(_amount); + (bool success, ) = owner().call{ value: _amount }(""); + require(success, TOKEN_TRANSFER_FAILED); } else { IERC20 token = IERC20(_tokenAddress); token.safeTransfer(owner(), _amount); } + + emit FundsWithdrawn(_tokenAddress, _amount); } /** @@ -270,11 +312,23 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { aggreement = agreements[agreementId]; } + /** + * @notice Internal function to handle incoming funds. + * + * Reverts if: + * - token is native and sent value is not equal to _amount + * - token is ERC20, but some native value is sent + * - token is ERC20 and sent value is not equal to _amount + * - token is ERC20 and transferFrom fails + * + * @param _tokenAddress - the token address (use 0x0 for native token) + * @param _amount - amount to transfer + */ function transferFundsToMutualizer(address _tokenAddress, uint256 _amount) internal { if (_tokenAddress == address(0)) { require(msg.value == _amount, INSUFFICIENT_VALUE_RECEIVED); } else { - require(msg.value == 0, INSUFFICIENT_VALUE_RECEIVED); + require(msg.value == 0, NATIVE_NOT_ALLOWED); IERC20 token = IERC20(_tokenAddress); uint256 balanceBefore = token.balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(this), _amount); diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index 2c7f54213..81c928472 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -1026,7 +1026,7 @@ describe("IBosonFundsHandler", function () { ); }); - it("Transfer of funds failed - revert durin ERC20 transfer", async function () { + it("Transfer of funds failed - revert during ERC20 transfer", async function () { // pause mockToken await mockToken.pause(); diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js index d6a08d4c2..be1b2e068 100644 --- a/test/protocol/clients/DRFeeMutualizerTest.js +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -147,131 +147,212 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { context("👉 payPremium()", function () { let agreementId; - beforeEach(async function () { - // Create a new agreement - await mutualizer.connect(mutualizerOwner).newAgreement(agreement); - - agreementId = "1"; - }); - - it("should emit an AgreementConfirmed event", async function () { - // Pay the premium, test for event - await expect(mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium })) - .to.emit(mutualizer, "AgreementConfirmed") - .withArgs(assistant.address, agreementId); - }); - - it("should update state", async function () { - await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + context("💰 Native Token", function () { + beforeEach(async function () { + // Create a new agreement + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); - // Get agreement id and agreement object from contract - const [returnedAgreementId, returnedAgreement] = await mutualizer.getAgreementBySellerAndToken( - assistant.address, - ethers.constants.AddressZero - ); - const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); - - // Values should match - expect(returnedAgreementStruct.toString()).eq(agreement.toString()); - expect(returnedAgreementId.toString()).eq(agreementId); - }); + agreementId = "1"; + }); - it("anyone can pay premium on seller's behalf", async function () { - // Pay the premium, test for event - await expect(mutualizer.connect(rando).payPremium(agreementId, { value: agreement.premium })) - .to.emit(mutualizer, "AgreementConfirmed") - .withArgs(assistant.address, agreementId); - }); + it("should emit an AgreementConfirmed event", async function () { + // Pay the premium, test for event + await expect(mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium })) + .to.emit(mutualizer, "AgreementConfirmed") + .withArgs(assistant.address, agreementId); + }); - it("premium in ERC20 tokens", async function () { - agreement.token = foreign20.address; - await mutualizer.connect(mutualizerOwner).newAgreement(agreement); - agreementId = "2"; + it("should update state", async function () { + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); - await foreign20.connect(assistant).mint(assistant.address, agreement.premium); - await foreign20.connect(assistant).approve(mutualizer.address, agreement.premium); + // Get agreement id and agreement object from contract + const [returnedAgreementId, returnedAgreement] = await mutualizer.getAgreementBySellerAndToken( + assistant.address, + ethers.constants.AddressZero + ); + const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); - // Pay the premium, test for event - await expect(mutualizer.connect(assistant).payPremium(agreementId)) - .to.emit(mutualizer, "AgreementConfirmed") - .withArgs(assistant.address, agreementId); - }); + // Values should match + expect(returnedAgreementStruct.toString()).eq(agreement.toString()); + expect(returnedAgreementId.toString()).eq(agreementId); + }); - it("it is possible to substitute an agreement", async function () { - // Agreement is confirmed - await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); - - // Create a new agreement for the same seller and token - const startTimestamp = ethers.BigNumber.from(Date.now()).div(1000); // valid from now - const endTimestamp = startTimestamp.add(oneMonth * 2); // valid for 30 days - agreement = new Agreement( - assistant.address, - ethers.constants.AddressZero, - ethers.utils.parseUnits("2", "ether").toString(), - ethers.utils.parseUnits("2", "ether").toString(), - ethers.utils.parseUnits("0.001", "ether").toString(), - startTimestamp.toString(), - endTimestamp.toString(), - false, - false - ); + it("anyone can pay premium on seller's behalf", async function () { + // Pay the premium, test for event + await expect(mutualizer.connect(rando).payPremium(agreementId, { value: agreement.premium })) + .to.emit(mutualizer, "AgreementConfirmed") + .withArgs(assistant.address, agreementId); + }); - await mutualizer.connect(mutualizerOwner).newAgreement(agreement); - agreementId = "2"; + it("it is possible to substitute an agreement", async function () { + // Agreement is confirmed + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); - await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + // Create a new agreement for the same seller and token + const startTimestamp = ethers.BigNumber.from(Date.now()).div(1000); // valid from now + const endTimestamp = startTimestamp.add(oneMonth * 2); // valid for 30 days + agreement = new Agreement( + assistant.address, + ethers.constants.AddressZero, + ethers.utils.parseUnits("2", "ether").toString(), + ethers.utils.parseUnits("2", "ether").toString(), + ethers.utils.parseUnits("0.001", "ether").toString(), + startTimestamp.toString(), + endTimestamp.toString(), + false, + false + ); - // Get agreement id and agreement object from contract - const [returnedAgreementId, returnedAgreement] = await mutualizer.getAgreementBySellerAndToken( - assistant.address, - ethers.constants.AddressZero - ); - const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + agreementId = "2"; - // Values should match - expect(returnedAgreementStruct.toString()).eq(agreement.toString()); - expect(returnedAgreementId.toString()).eq(agreementId); - }); + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); - context("💔 Revert Reasons", async function () { - it("agreement does not exist", async function () { - // Expect revert if agreement id is out of bound - agreementId = "100"; - await expect( - mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) - ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + // Get agreement id and agreement object from contract + const [returnedAgreementId, returnedAgreement] = await mutualizer.getAgreementBySellerAndToken( + assistant.address, + ethers.constants.AddressZero + ); + const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); - agreementId = "0"; - await expect( - mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) - ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + // Values should match + expect(returnedAgreementStruct.toString()).eq(agreement.toString()); + expect(returnedAgreementId.toString()).eq(agreementId); }); - it("agreement is already confirmed", async function () { - await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); - - // Expect revert if already confirmed - await expect( - mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) - ).to.be.revertedWith(RevertReasons.AGREEMENT_ALREADY_CONFIRMED); + context("💔 Revert Reasons", async function () { + it("agreement does not exist", async function () { + // Expect revert if agreement id is out of bound + agreementId = "100"; + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + + agreementId = "0"; + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + }); + + it("agreement is already confirmed", async function () { + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + + // Expect revert if already confirmed + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.AGREEMENT_ALREADY_CONFIRMED); + }); + + it("agreement is voided", async function () { + await mutualizer.connect(mutualizerOwner).voidAgreement(agreementId); + + // Expect revert if voided + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.AGREEMENT_VOIDED); + }); + + it("agreement expired", async function () { + await setNextBlockTimestamp(ethers.BigNumber.from(agreement.endTimestamp).add(1).toHexString()); + + // Expect revert if expired + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.AGREEMENT_EXPIRED); + }); + + it("token is native and sent value is not equal to the agreement premium", async function () { + // Expect revert if sent less than amount + const value = ethers.BigNumber.from(agreement.premium).sub(1); + await expect(mutualizer.connect(assistant).payPremium(agreementId, { value })).to.be.revertedWith( + RevertReasons.INSUFFICIENT_VALUE_RECEIVED + ); + }); }); + }); - it("agreement is voided", async function () { - await mutualizer.connect(mutualizerOwner).voidAgreement(agreementId); + context("💰 ERC20 tokens", function () { + beforeEach(async function () { + agreement.token = foreign20.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + agreementId = "1"; - // Expect revert if voided - await expect( - mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) - ).to.be.revertedWith(RevertReasons.AGREEMENT_VOIDED); + await foreign20.connect(assistant).mint(assistant.address, agreement.premium); + await foreign20.connect(assistant).approve(mutualizer.address, agreement.premium); }); - it("agreement expired", async function () { - await setNextBlockTimestamp(ethers.BigNumber.from(agreement.endTimestamp).add(1).toHexString()); + it("should emit an AgreementConfirmed event", async function () { + // Pay the premium, test for event + await expect(mutualizer.connect(assistant).payPremium(agreementId)) + .to.emit(mutualizer, "AgreementConfirmed") + .withArgs(assistant.address, agreementId); + }); - // Expect revert if expired - await expect( - mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) - ).to.be.revertedWith(RevertReasons.AGREEMENT_EXPIRED); + context("💔 Revert Reasons", async function () { + it("native token is sent along", async function () { + // Expect revert if native token is sent along + await expect( + mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }) + ).to.be.revertedWith(RevertReasons.NATIVE_NOT_ALLOWED); + }); + + it("transferFrom fails", async function () { + await foreign20.connect(assistant).transfer(rando.address, "1"); // transfer to reduce balance + + // Expect revert if premium higher than token balance + await expect(mutualizer.connect(assistant).payPremium(agreementId)).to.be.revertedWith( + RevertReasons.ERC20_EXCEEDS_BALANCE + ); + + const reducedAllowance = ethers.BigNumber.from(agreement.premium).sub(1); + await foreign20.connect(assistant).approve(mutualizer.address, reducedAllowance); + + // Expect revert if premium higher than allowance + await expect(mutualizer.connect(assistant).payPremium(agreementId)).to.be.revertedWith( + RevertReasons.ERC20_INSUFFICIENT_ALLOWANCE + ); + }); + + it("sent value is not equal to the agreement premium", async function () { + // Deploy ERC20 with fees + const [foreign20WithFee] = await deployMockTokens(["Foreign20WithFee"]); + + // mint tokens and approve + await foreign20WithFee.mint(assistant.address, agreement.premium); + await foreign20WithFee.connect(assistant).approve(mutualizer.address, agreement.premium); + + agreement.token = foreign20WithFee.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + agreementId = "2"; + + // Expect revert if received value does not match the premium + await expect(mutualizer.connect(assistant).payPremium(agreementId)).to.be.revertedWith( + RevertReasons.INSUFFICIENT_VALUE_RECEIVED + ); + }); + + it("Token address contract does not support transferFrom", async function () { + // Deploy a contract without the transferFrom + const [bosonToken] = await deployMockTokens(["BosonToken"]); + + agreement.token = bosonToken.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + agreementId = "2"; + + // Expect revert if token does not support transferFrom + await expect(mutualizer.connect(assistant).payPremium(agreementId)).to.be.revertedWith( + RevertReasons.SAFE_ERC20_LOW_LEVEL_CALL + ); + }); + + it("Token address is not a contract", async function () { + agreement.token = mutualizer.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + agreementId = "2"; + + // Expect revert if token address is not a contract + await expect(mutualizer.connect(assistant).payPremium(agreementId)).to.be.revertedWith(""); + }); }); }); }); @@ -350,5 +431,287 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { }); }); }); + + context("👉 deposit()", function () { + let amount; + + beforeEach(async function () { + amount = ethers.utils.parseUnits("1", "ether"); + }); + + context("💰 Native Token", function () { + it("should emit an FundsDeposited event", async function () { + // Deposit native token, test for event + await expect( + mutualizer.connect(mutualizerOwner).deposit(ethers.constants.AddressZero, amount, { value: amount }) + ) + .to.emit(mutualizer, "FundsDeposited") + .withArgs(ethers.constants.AddressZero, amount, mutualizerOwner.address); + }); + + it("should update state", async function () { + await expect(() => + mutualizer.connect(mutualizerOwner).deposit(ethers.constants.AddressZero, amount, { value: amount }) + ).to.changeEtherBalances([mutualizerOwner, mutualizer], [amount.mul(-1), amount]); + }); + + context("💔 Revert Reasons", async function () { + it("value is not equal to _amount", async function () { + // Expect revert if sent less than amount + await expect( + mutualizer + .connect(mutualizerOwner) + .deposit(ethers.constants.AddressZero, amount, { value: amount.sub(1) }) + ).to.be.revertedWith(RevertReasons.INSUFFICIENT_VALUE_RECEIVED); + }); + }); + }); + + context("💰 ERC20", function () { + beforeEach(async function () { + await foreign20.connect(mutualizerOwner).mint(mutualizerOwner.address, amount); + await foreign20.connect(mutualizerOwner).approve(mutualizer.address, amount); + }); + + it("should emit an FundsDeposited event", async function () { + // Deposit ERC20 token, test for event + await expect(mutualizer.connect(mutualizerOwner).deposit(foreign20.address, amount)) + .to.emit(mutualizer, "FundsDeposited") + .withArgs(foreign20.address, amount, mutualizerOwner.address); + }); + + it("should update state", async function () { + await expect(() => + mutualizer.connect(mutualizerOwner).deposit(foreign20.address, amount) + ).to.changeTokenBalances(foreign20, [mutualizerOwner, mutualizer], [amount.mul(-1), amount]); + }); + + context("💔 Revert Reasons", async function () { + it("native token is sent along", async function () { + // Expect revert if native token is sent along + await expect( + mutualizer.connect(mutualizerOwner).deposit(foreign20.address, amount, { value: 1 }) + ).to.be.revertedWith(RevertReasons.NATIVE_NOT_ALLOWED); + }); + + it("transferFrom fails", async function () { + amount = amount.add(1); + await foreign20.connect(mutualizerOwner).approve(mutualizer.address, amount); + + // Expect revert if amount higher than token balance + await expect(mutualizer.connect(mutualizerOwner).deposit(foreign20.address, amount)).to.be.revertedWith( + RevertReasons.ERC20_EXCEEDS_BALANCE + ); + + // Expect revert if amount higher than allowance + await expect( + mutualizer.connect(mutualizerOwner).deposit(foreign20.address, amount.add(1)) + ).to.be.revertedWith(RevertReasons.ERC20_INSUFFICIENT_ALLOWANCE); + }); + + it("value is not equal to _amount", async function () { + // Deploy ERC20 with fees + const [foreign20WithFee] = await deployMockTokens(["Foreign20WithFee"]); + + // mint tokens and approve + await foreign20WithFee.mint(mutualizerOwner.address, amount); + await foreign20WithFee.connect(mutualizerOwner).approve(mutualizer.address, amount); + + // Expect revert if value does not match amount + await expect( + mutualizer.connect(mutualizerOwner).deposit(foreign20WithFee.address, amount) + ).to.be.revertedWith(RevertReasons.INSUFFICIENT_VALUE_RECEIVED); + }); + + it("Token address contract does not support transferFrom", async function () { + // Deploy a contract without the transferFrom + const [bosonToken] = await deployMockTokens(["BosonToken"]); + + // Attempt to deposit the funds, expecting revert + await expect(mutualizer.connect(mutualizerOwner).deposit(bosonToken.address, amount)).to.be.revertedWith( + RevertReasons.SAFE_ERC20_LOW_LEVEL_CALL + ); + }); + + it("Token address is not a contract", async function () { + // Attempt to deposit the funds, expecting revert + await expect(mutualizer.connect(mutualizerOwner).deposit(assistant.address, amount)).to.be.revertedWith(""); + }); + }); + }); + + it("anyone can deposit on mutualizer owner's behalf", async function () { + // Deposit native token, test for event + await expect(mutualizer.connect(rando).deposit(ethers.constants.AddressZero, amount, { value: amount })) + .to.emit(mutualizer, "FundsDeposited") + .withArgs(ethers.constants.AddressZero, amount, rando.address); + }); + }); + + context("👉 withdraw()", function () { + let amount, amountToWithdraw; + + beforeEach(async function () { + amount = ethers.utils.parseUnits("1", "ether"); + await mutualizer.connect(mutualizerOwner).deposit(ethers.constants.AddressZero, amount, { value: amount }); + + amountToWithdraw = amount.div(2); + }); + + context("💰 Native Token", function () { + it("should emit an FundsWithdrawn event", async function () { + // Withdraw native token, test for event + await expect(mutualizer.connect(mutualizerOwner).withdraw(ethers.constants.AddressZero, amountToWithdraw)) + .to.emit(mutualizer, "FundsWithdrawn") + .withArgs(ethers.constants.AddressZero, amountToWithdraw); + }); + + it("should update state", async function () { + await expect(() => + mutualizer.connect(mutualizerOwner).withdraw(ethers.constants.AddressZero, amountToWithdraw) + ).to.changeEtherBalances([mutualizerOwner, mutualizer], [amountToWithdraw, amountToWithdraw.mul(-1)]); + }); + + it("it is possible to withdraw the full amount", async function () { + amountToWithdraw = amount; + + // Withdraw native token, test for event + await expect(mutualizer.connect(mutualizerOwner).withdraw(ethers.constants.AddressZero, amountToWithdraw)) + .to.emit(mutualizer, "FundsWithdrawn") + .withArgs(ethers.constants.AddressZero, amountToWithdraw); + }); + + context("💔 Revert Reasons", async function () { + it("caller is not the owner", async function () { + // Expect revert if caller is not the mutualizer owner + await expect( + mutualizer.connect(rando).withdraw(ethers.constants.AddressZero, amountToWithdraw) + ).to.be.revertedWith(RevertReasons.OWNABLE_NOT_OWNER); + }); + + it("amount exceeds available balance", async function () { + amountToWithdraw = amount.add(1); + + // Expect revert if trying to withdraw more than available balance + await expect( + mutualizer.connect(mutualizerOwner).withdraw(ethers.constants.AddressZero, amountToWithdraw) + ).to.be.revertedWith(RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS); + }); + + it("Transfer of funds failed - no payable fallback or receive", async function () { + // deploy a contract that cannot receive funds + const [fallbackErrorContract] = await deployMockTokens(["WithoutFallbackError"]); + + // transfer ownership + await mutualizer.connect(mutualizerOwner).transferOwnership(fallbackErrorContract.address); + + // Expect revert if mutualizer owner cannot receive funds + await expect( + fallbackErrorContract.withdrawMutualizerFunds( + mutualizer.address, + ethers.constants.AddressZero, + amountToWithdraw + ) + ).to.be.revertedWith(RevertReasons.TOKEN_TRANSFER_FAILED); + }); + + it("Transfer of funds failed - revert in fallback", async function () { + // deploy a contract that cannot receive funds + const [fallbackErrorContract] = await deployMockTokens(["FallbackError"]); + + // transfer ownership + await mutualizer.connect(mutualizerOwner).transferOwnership(fallbackErrorContract.address); + + // Expect revert if mutualizer owner cannot receive funds + await expect( + fallbackErrorContract.withdrawMutualizerFunds( + mutualizer.address, + ethers.constants.AddressZero, + amountToWithdraw + ) + ).to.be.revertedWith(RevertReasons.TOKEN_TRANSFER_FAILED); + }); + }); + }); + + context("💰 ERC20", function () { + beforeEach(async function () { + await foreign20.connect(mutualizerOwner).mint(mutualizerOwner.address, amount); + await foreign20.connect(mutualizerOwner).approve(mutualizer.address, amount); + + await mutualizer.connect(mutualizerOwner).deposit(foreign20.address, amount); + + amountToWithdraw = amount.div(2); + }); + + it("should emit an FundsWithdrawn event", async function () { + // Withdraw ERC20 token, test for event + await expect(mutualizer.connect(mutualizerOwner).withdraw(foreign20.address, amountToWithdraw)) + .to.emit(mutualizer, "FundsWithdrawn") + .withArgs(foreign20.address, amountToWithdraw); + }); + + it("should update state", async function () { + await expect(() => + mutualizer.connect(mutualizerOwner).withdraw(foreign20.address, amountToWithdraw) + ).to.changeTokenBalances( + foreign20, + [mutualizerOwner, mutualizer], + [amountToWithdraw, amountToWithdraw.mul(-1)] + ); + }); + + context("💔 Revert Reasons", async function () { + it("amount exceeds available balance", async function () { + amountToWithdraw = amount.add(1); + + // Expect revert if trying to withdraw more than available balance + await expect( + mutualizer.connect(mutualizerOwner).withdraw(foreign20.address, amountToWithdraw) + ).to.be.revertedWith(RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS); + }); + + it("Transfer of funds failed - ERC20 token does not exist anymore", async function () { + // destruct foreign20 + await foreign20.destruct(); + + // Expect revert if ERC20 does not exist anymore + await expect( + mutualizer.connect(mutualizerOwner).withdraw(foreign20.address, amountToWithdraw) + ).to.be.revertedWith(RevertReasons.EOA_FUNCTION_CALL); + }); + + it("Transfer of funds failed - revert during ERC20 transfer", async function () { + // foreign20 mockToken + await foreign20.pause(); + + // Expect revert if ERC20 reverts during transfer + await expect( + mutualizer.connect(mutualizerOwner).withdraw(foreign20.address, amountToWithdraw) + ).to.be.revertedWith(RevertReasons.ERC20_PAUSED); + }); + + it("Transfer of funds failed - revert during ERC20 transfer", async function () { + const [foreign20ReturnFalse] = await deployMockTokens(["Foreign20TransferReturnFalse"]); + + await foreign20ReturnFalse.connect(mutualizerOwner).mint(mutualizerOwner.address, amount); + await foreign20ReturnFalse.connect(mutualizerOwner).approve(mutualizer.address, amount); + await mutualizer.connect(mutualizerOwner).deposit(foreign20ReturnFalse.address, amount); + + // Expect revert if ERC20 returns false during transfer + await expect( + mutualizer.connect(mutualizerOwner).withdraw(foreign20ReturnFalse.address, amountToWithdraw) + ).to.be.revertedWith(RevertReasons.SAFE_ERC20_NOT_SUCCEEDED); + }); + }); + }); + + it("anyone can deposit on mutualizer owner's behalf", async function () { + // Deposit native token, test for event + await expect(mutualizer.connect(rando).deposit(ethers.constants.AddressZero, amount, { value: amount })) + .to.emit(mutualizer, "FundsDeposited") + .withArgs(ethers.constants.AddressZero, amount, rando.address); + }); + }); }); }); From 4770e3b77e47520bd16599f04f5dcb6b95311c4c Mon Sep 17 00:00:00 2001 From: zajck Date: Wed, 31 May 2023 09:26:38 +0200 Subject: [PATCH 25/33] DR client getters + voided moved to status --- .../clients/IDRFeeMutualizerClient.sol | 40 ++- .../clients/feeMutualizer/DRFeeMutualizer.sol | 69 ++++-- scripts/domain/Agreement.js | 26 +- scripts/domain/AgreementStatus.js | 138 +++++++++++ test/domain/Agreement.js | 38 +-- test/domain/AgreementStatus.js | 232 ++++++++++++++++++ test/protocol/clients/DRFeeMutualizerTest.js | 120 +++++++-- 7 files changed, 558 insertions(+), 105 deletions(-) create mode 100644 scripts/domain/AgreementStatus.js create mode 100644 test/domain/AgreementStatus.js diff --git a/contracts/interfaces/clients/IDRFeeMutualizerClient.sol b/contracts/interfaces/clients/IDRFeeMutualizerClient.sol index 70341b7f9..481e4e7f6 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizerClient.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizerClient.sol @@ -7,7 +7,7 @@ import "./IDRFeeMutualizer.sol"; * * @notice This is the interface for the Dispute Resolver fee mutualizers. * - * The ERC-165 identifier for this interface is: 0x3ac29309 + * The ERC-165 identifier for this interface is: 0x391b17cd */ interface IDRFeeMutualizerClient is IDRFeeMutualizer { struct Agreement { @@ -19,7 +19,13 @@ interface IDRFeeMutualizerClient is IDRFeeMutualizer { uint128 startTimestamp; uint128 endTimestamp; bool refundOnCancel; + } + + struct AgreementStatus { + bool confirmed; bool voided; + uint256 outstandingExchanges; + uint256 totalMutualizedAmount; } event AgreementCreated(address indexed sellerAddress, uint256 indexed agreementId, Agreement agreement); @@ -36,7 +42,6 @@ interface IDRFeeMutualizerClient is IDRFeeMutualizer { * * Reverts if: * - caller is not the contract owner - * - parameter "voided" is set to true * - max mutualized amount per transaction is greater than max total mutualized amount * - max mutualized amount per transaction is 0 * - end timestamp is not greater than start timestamp @@ -111,10 +116,35 @@ interface IDRFeeMutualizerClient is IDRFeeMutualizer { */ function withdraw(address _tokenAddress, uint256 _amount) external; - function getAgreement(uint256 _agreementId) external view returns (Agreement memory); + /** + * @notice Returns agreement details and status for a given agreement id. + * + * Reverts if: + * - agreement does not exist + * + * @param _agreementId - a unique identifier of the agreement + * @return agreement - agreement details + * @return status - agreement status + */ + function getAgreement( + uint256 _agreementId + ) external view returns (Agreement memory agreement, AgreementStatus memory status); - function getAgreementBySellerAndToken( + /** + * @notice Returns agreement id, agreement details and status for given seller and token. + * + * Reverts if: + * - agreement does not exist + * - agreement is not confirmed yet + * + * @param _seller - the seller address + * @param _token - the token address (use 0x0 for native token) + * @return agreementId - a unique identifier of the agreement + * @return agreement - agreement details + * @return status - agreement status + */ + function getConfirmedAgreementBySellerAndToken( address _seller, address _token - ) external view returns (uint256 agreementId, Agreement memory aggreement); + ) external view returns (uint256 agreementId, Agreement memory agreement, AgreementStatus memory status); } diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index aa65726ea..da5a55494 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -16,17 +16,10 @@ import { ERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { using SafeERC20 for IERC20; - struct AgreementStatus { - bool confirmed; - uint256 outstandingExchanges; - uint256 totalMutualizedAmount; - } - address private immutable protocolAddress; Agreement[] private agreements; mapping(address => mapping(address => uint256)) private agreementBySellerAndToken; - // mapping(uint256 => uint256) private totalMutualizedAmount; mapping(uint256 => AgreementStatus) private agreementStatus; mapping(uint256 => uint256) private agreementByUuid; @@ -56,11 +49,12 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { ) external view returns (bool) { uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; Agreement storage agreement = agreements[agreementId]; + AgreementStatus storage status = agreementStatus[agreementId]; return (_feeRequester == protocolAddress && agreement.startTimestamp <= block.timestamp && agreement.endTimestamp >= block.timestamp && - !agreement.voided && + !status.voided && agreement.maxMutualizedAmountPerTransaction >= _feeAmount && agreement.maxTotalMutualizedAmount + _feeAmount >= agreementStatus[agreementId].totalMutualizedAmount); } @@ -90,10 +84,11 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { require(agreement.startTimestamp <= block.timestamp, AGREEMENT_NOT_STARTED); require(agreement.endTimestamp >= block.timestamp, AGREEMENT_EXPIRED); - require(!agreement.voided, AGREEMENT_VOIDED); require(agreement.maxMutualizedAmountPerTransaction >= _feeAmount, EXCEEDED_SINGLE_FEE); AgreementStatus storage status = agreementStatus[agreementId]; + require(!status.voided, AGREEMENT_VOIDED); + status.totalMutualizedAmount += _feeAmount; require(agreement.maxTotalMutualizedAmount >= status.totalMutualizedAmount, EXCEEDED_TOTAL_FEE); @@ -151,7 +146,6 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * * Reverts if: * - caller is not the contract owner - * - parameter "voided" is set to true * - max mutualized amount per transaction is greater than max total mutualized amount * - max mutualized amount per transaction is 0 * - end timestamp is not greater than start timestamp @@ -160,7 +154,6 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * @param _agreement - a fully populated agreement object */ function newAgreement(Agreement calldata _agreement) external onlyOwner { - require(!_agreement.voided, INVALID_AGREEMENT); require(_agreement.maxMutualizedAmountPerTransaction <= _agreement.maxTotalMutualizedAmount, INVALID_AGREEMENT); require(_agreement.maxMutualizedAmountPerTransaction > 0, INVALID_AGREEMENT); require(_agreement.endTimestamp > _agreement.startTimestamp, INVALID_AGREEMENT); @@ -190,9 +183,8 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * @param _agreementId - a unique identifier of the agreement */ function payPremium(uint256 _agreementId) external payable { - Agreement storage agreement = getValidAgreement(_agreementId); + (Agreement storage agreement, AgreementStatus storage status) = getValidAgreement(_agreementId); - AgreementStatus storage status = agreementStatus[_agreementId]; require(!status.confirmed, AGREEMENT_ALREADY_CONFIRMED); transferFundsToMutualizer(agreement.token, agreement.premium); @@ -218,11 +210,11 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * @param _agreementId - a unique identifier of the agreement */ function voidAgreement(uint256 _agreementId) external { - Agreement storage agreement = getValidAgreement(_agreementId); + (Agreement storage agreement, AgreementStatus storage status) = getValidAgreement(_agreementId); require(msg.sender == owner() || msg.sender == agreement.sellerAddress, NOT_OWNER_OR_SELLER); - agreement.voided = true; + status.voided = true; if (agreement.refundOnCancel) { // calculate unused premium @@ -300,16 +292,44 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { super.supportsInterface(interfaceId); } - function getAgreement(uint256 _agreementId) external view returns (Agreement memory) { - return agreements[_agreementId]; + /** + * @notice Returns agreement details and status for a given agreement id. + * + * Reverts if: + * - agreement does not exist + * + * @param _agreementId - a unique identifier of the agreement + * @return agreement - agreement details + * @return status - agreement status + */ + function getAgreement( + uint256 _agreementId + ) public view returns (Agreement memory agreement, AgreementStatus memory status) { + require(_agreementId > 0 && _agreementId < agreements.length, INVALID_AGREEMENT); + + agreement = agreements[_agreementId]; + status = agreementStatus[_agreementId]; } - function getAgreementBySellerAndToken( + /** + * @notice Returns agreement id, agreement details and status for given seller and token. + * + * Reverts if: + * - agreement does not exist + * - agreement is not confirmed yet + * + * @param _seller - the seller address + * @param _token - the token address (use 0x0 for native token) + * @return agreementId - a unique identifier of the agreement + * @return agreement - agreement details + * @return status - agreement status + */ + function getConfirmedAgreementBySellerAndToken( address _seller, address _token - ) external view returns (uint256 agreementId, Agreement memory aggreement) { + ) external view returns (uint256 agreementId, Agreement memory agreement, AgreementStatus memory status) { agreementId = agreementBySellerAndToken[_seller][_token]; - aggreement = agreements[agreementId]; + (agreement, status) = getAgreement(agreementId); } /** @@ -347,12 +367,15 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * * @param _agreementId - a unique identifier of the agreement */ - function getValidAgreement(uint256 _agreementId) internal view returns (Agreement storage agreement) { + function getValidAgreement( + uint256 _agreementId + ) internal view returns (Agreement storage agreement, AgreementStatus storage status) { require(_agreementId > 0 && _agreementId < agreements.length, INVALID_AGREEMENT); - agreement = agreements[_agreementId]; + status = agreementStatus[_agreementId]; + require(!status.voided, AGREEMENT_VOIDED); - require(!agreement.voided, AGREEMENT_VOIDED); + agreement = agreements[_agreementId]; require(agreement.endTimestamp > block.timestamp, AGREEMENT_EXPIRED); } } diff --git a/scripts/domain/Agreement.js b/scripts/domain/Agreement.js index 3f1e7d603..202f0ff6f 100644 --- a/scripts/domain/Agreement.js +++ b/scripts/domain/Agreement.js @@ -16,7 +16,6 @@ class Agreement { uint128 startTimestamp; uint128 endTimestamp; bool refundOnCancel; - bool voided; } */ @@ -28,8 +27,7 @@ class Agreement { premium, startTimestamp, endTimestamp, - refundOnCancel, - voided + refundOnCancel ) { this.sellerAddress = sellerAddress; this.token = token; @@ -39,7 +37,6 @@ class Agreement { this.startTimestamp = startTimestamp; this.endTimestamp = endTimestamp; this.refundOnCancel = refundOnCancel; - this.voided = voided; } /** @@ -57,7 +54,6 @@ class Agreement { startTimestamp, endTimestamp, refundOnCancel, - voided, } = o; return new Agreement( @@ -68,8 +64,7 @@ class Agreement { premium, startTimestamp, endTimestamp, - refundOnCancel, - voided + refundOnCancel ); } @@ -86,8 +81,7 @@ class Agreement { premium, startTimestamp, endTimestamp, - refundOnCancel, - voided; + refundOnCancel; // destructure struct [ @@ -99,7 +93,6 @@ class Agreement { startTimestamp, endTimestamp, refundOnCancel, - voided, ] = struct; return Agreement.fromObject({ @@ -111,7 +104,6 @@ class Agreement { startTimestamp: startTimestamp.toString(), endTimestamp: endTimestamp.toString(), refundOnCancel, - voided, }); } @@ -145,7 +137,6 @@ class Agreement { this.startTimestamp, this.endTimestamp, this.refundOnCancel, - this.voided, ]; } @@ -231,14 +222,6 @@ class Agreement { return booleanIsValid(this.refundOnCancel); } - /** - * Is this Agreement instance's voided field valid? - * @returns {boolean} - */ - voidedIsValid() { - return booleanIsValid(this.voided); - } - /** * Is this Agreement instance valid? * @returns {boolean} @@ -252,8 +235,7 @@ class Agreement { this.premiumIsValid() && this.startTimestampIsValid() && this.endTimestampIsValid() && - this.refundOnCancelIsValid() && - this.voidedIsValid() + this.refundOnCancelIsValid() ); } } diff --git a/scripts/domain/AgreementStatus.js b/scripts/domain/AgreementStatus.js new file mode 100644 index 000000000..a16715c0b --- /dev/null +++ b/scripts/domain/AgreementStatus.js @@ -0,0 +1,138 @@ +const { bigNumberIsValid, booleanIsValid } = require("../util/validations.js"); + +/** + * DR Fee Mutualizer Entity: AgreementStatus + * + * See: {DRFeeMutualizer.AgreementStatus} + */ +class AgreementStatus { + /* + struct AgreementStatus { + bool confirmed; + bool voided; + uint256 outstandingExchanges; + uint256 totalMutualizedAmount; + } + */ + + constructor(confirmed, voided, outstandingExchanges, totalMutualizedAmount) { + this.confirmed = confirmed; + this.voided = voided; + this.outstandingExchanges = outstandingExchanges; + this.totalMutualizedAmount = totalMutualizedAmount; + } + + /** + * Get a new AgreementStatus instance from a pojo representation + * @param o + * @returns {AgreementStatus} + */ + static fromObject(o) { + const { confirmed, voided, outstandingExchanges, totalMutualizedAmount } = o; + + return new AgreementStatus(confirmed, voided, outstandingExchanges, totalMutualizedAmount); + } + + /** + * Get a new AgreementStatus instance from a returned struct representation + * @param struct + * @returns {*} + */ + static fromStruct(struct) { + let confirmed, voided, outstandingExchanges, totalMutualizedAmount; + + // destructure struct + [confirmed, voided, outstandingExchanges, totalMutualizedAmount] = struct; + + return AgreementStatus.fromObject({ + confirmed, + voided, + outstandingExchanges: outstandingExchanges.toString(), + totalMutualizedAmount: totalMutualizedAmount.toString(), + }); + } + + /** + * Get a database representation of this AgreementStatus instance + * @returns {object} + */ + toObject() { + return JSON.parse(this.toString()); + } + + /** + * Get a string representation of this AgreementStatus instance + * @returns {string} + */ + toString() { + return JSON.stringify(this); + } + + /** + * Get a struct representation of this AgreementStatus instance + * @returns {string} + */ + toStruct() { + return [this.confirmed, this.voided, this.outstandingExchanges, this.totalMutualizedAmount]; + } + + /** + * Clone this AgreementStatus + * @returns {AgreementStatus} + */ + clone() { + return AgreementStatus.fromObject(this.toObject()); + } + + /** + * Is this AgreementStatus instance's confirmed field valid? + * Always present, must be a boolean + * @returns {boolean} + */ + confirmedIsValid() { + return booleanIsValid(this.confirmed); + } + + /** + * Is this AgreementStatus instance's voided field valid? + * Always present, must be a boolean + * @returns {boolean} + */ + voidedIsValid() { + return booleanIsValid(this.voided); + } + + /** + * Is this AgreementStatus instance's outstandingExchanges field valid? + * Must be a string representation of a big number + * @returns {boolean} + */ + outstandingExchangesIsValid() { + return bigNumberIsValid(this.outstandingExchanges); + } + + /** + * Is this AgreementStatus instance's totalMutualizedAmount field valid? + * Must be a string representation of a big number + * @returns {boolean} + */ + totalMutualizedAmountIsValid() { + return bigNumberIsValid(this.totalMutualizedAmount); + } + + /** + * Is this AgreementStatus instance valid? + * @returns {boolean} + */ + isValid() { + return ( + this.confirmedIsValid() && + this.voidedIsValid() && + this.outstandingExchangesIsValid() && + this.totalMutualizedAmountIsValid() + ); + } +} + +// Export +module.exports = AgreementStatus; diff --git a/test/domain/Agreement.js b/test/domain/Agreement.js index c476a9ca4..f472b168d 100644 --- a/test/domain/Agreement.js +++ b/test/domain/Agreement.js @@ -16,8 +16,7 @@ describe("Agreement", function () { premium, startTimestamp, endTimestamp, - refundOnCancel, - voided; + refundOnCancel; beforeEach(async function () { // Get a list of accounts @@ -31,7 +30,6 @@ describe("Agreement", function () { startTimestamp = "123456789"; endTimestamp = "987654321"; refundOnCancel = true; - voided = false; }); context("📋 Constructor", async function () { @@ -45,8 +43,7 @@ describe("Agreement", function () { premium, startTimestamp, endTimestamp, - refundOnCancel, - voided + refundOnCancel ); expect(agreement.sellerAddressIsValid()).is.true; expect(agreement.tokenIsValid()).is.true; @@ -56,7 +53,6 @@ describe("Agreement", function () { expect(agreement.startTimestampIsValid()).is.true; expect(agreement.endTimestampIsValid()).is.true; expect(agreement.refundOnCancelIsValid()).is.true; - expect(agreement.voidedIsValid()).is.true; expect(agreement.isValid()).is.true; }); }); @@ -72,8 +68,7 @@ describe("Agreement", function () { premium, startTimestamp, endTimestamp, - refundOnCancel, - voided + refundOnCancel ); expect(agreement.isValid()).is.true; }); @@ -278,28 +273,6 @@ describe("Agreement", function () { expect(agreement.refundOnCancelIsValid()).is.true; expect(agreement.isValid()).is.true; }); - - it("Always present, voided must be a boolean", async function () { - // Invalid field value - agreement.voided = 12; - expect(agreement.voidedIsValid()).is.false; - expect(agreement.isValid()).is.false; - - // Invalid field value - agreement.voided = "zedzdeadbaby"; - expect(agreement.voidedIsValid()).is.false; - expect(agreement.isValid()).is.false; - - // Valid field value - agreement.voided = false; - expect(agreement.voidedIsValid()).is.true; - expect(agreement.isValid()).is.true; - - // Valid field value - agreement.voided = true; - expect(agreement.voidedIsValid()).is.true; - expect(agreement.isValid()).is.true; - }); }); context("📋 Utility functions", async function () { @@ -316,8 +289,7 @@ describe("Agreement", function () { premium, startTimestamp, endTimestamp, - refundOnCancel, - voided + refundOnCancel ); expect(agreement.isValid()).is.true; @@ -331,7 +303,6 @@ describe("Agreement", function () { startTimestamp, endTimestamp, refundOnCancel, - voided, }; }); @@ -359,7 +330,6 @@ describe("Agreement", function () { agreement.startTimestamp, agreement.endTimestamp, agreement.refundOnCancel, - agreement.voided, ]; // Get struct diff --git a/test/domain/AgreementStatus.js b/test/domain/AgreementStatus.js new file mode 100644 index 000000000..e0ad2f773 --- /dev/null +++ b/test/domain/AgreementStatus.js @@ -0,0 +1,232 @@ +const { ethers } = require("hardhat"); +const { expect } = require("chai"); +const AgreementStatus = require("../../scripts/domain/AgreementStatus"); + +/** + * Test the AgreementStatus domain entity + */ +describe("AgreementStatus", function () { + // Suite-wide scope + let agreementStatus, object, promoted, clone, dehydrated, rehydrated, key, value, struct; + let confirmed, voided, outstandingExchanges, totalMutualizedAmount; + + beforeEach(async function () { + // Required constructor params + confirmed = true; + voided = false; + outstandingExchanges = "2"; + totalMutualizedAmount = ethers.utils.parseUnits("0.25", "ether").toString(); + }); + + context("📋 Constructor", async function () { + it("Should allow creation of valid, fully populated AgreementStatus instance", async function () { + // Create a valid agreementStatus, then set fields in tests directly + agreementStatus = new AgreementStatus(confirmed, voided, outstandingExchanges, totalMutualizedAmount); + expect(agreementStatus.confirmedIsValid()).is.true; + expect(agreementStatus.voidedIsValid()).is.true; + expect(agreementStatus.outstandingExchangesIsValid()).is.true; + expect(agreementStatus.totalMutualizedAmountIsValid()).is.true; + expect(agreementStatus.isValid()).is.true; + }); + }); + + context("📋 Field validations", async function () { + beforeEach(async function () { + // Create a valid agreementStatus, then set fields in tests directly + agreementStatus = new AgreementStatus(confirmed, voided, outstandingExchanges, totalMutualizedAmount); + expect(agreementStatus.isValid()).is.true; + }); + + it("Always present, confirmed must be a boolean", async function () { + // Invalid field value + agreementStatus.confirmed = 12; + expect(agreementStatus.confirmedIsValid()).is.false; + expect(agreementStatus.isValid()).is.false; + + // Invalid field value + agreementStatus.confirmed = "zedzdeadbaby"; + expect(agreementStatus.confirmedIsValid()).is.false; + expect(agreementStatus.isValid()).is.false; + + // Valid field value + agreementStatus.confirmed = false; + expect(agreementStatus.confirmedIsValid()).is.true; + expect(agreementStatus.isValid()).is.true; + + // Valid field value + agreementStatus.confirmed = true; + expect(agreementStatus.confirmedIsValid()).is.true; + expect(agreementStatus.isValid()).is.true; + }); + + it("Always present, voided must be a boolean", async function () { + // Invalid field value + agreementStatus.voided = 12; + expect(agreementStatus.voidedIsValid()).is.false; + expect(agreementStatus.isValid()).is.false; + + // Invalid field value + agreementStatus.voided = "zedzdeadbaby"; + expect(agreementStatus.voidedIsValid()).is.false; + expect(agreementStatus.isValid()).is.false; + + // Valid field value + agreementStatus.voided = false; + expect(agreementStatus.voidedIsValid()).is.true; + expect(agreementStatus.isValid()).is.true; + + // Valid field value + agreementStatus.voided = true; + expect(agreementStatus.voidedIsValid()).is.true; + expect(agreementStatus.isValid()).is.true; + }); + + it("Always present, outstandingExchanges must be the string representation of a BigNumber", async function () { + // Invalid field value + agreementStatus.outstandingExchanges = "zedzdeadbaby"; + expect(agreementStatus.outstandingExchangesIsValid()).is.false; + expect(agreementStatus.isValid()).is.false; + + // Invalid field value + agreementStatus.outstandingExchanges = new Date(); + expect(agreementStatus.outstandingExchangesIsValid()).is.false; + expect(agreementStatus.isValid()).is.false; + + // Invalid field value + agreementStatus.outstandingExchanges = 12; + expect(agreementStatus.outstandingExchangesIsValid()).is.false; + expect(agreementStatus.isValid()).is.false; + + // Valid field value + agreementStatus.outstandingExchanges = "0"; + expect(agreementStatus.outstandingExchangesIsValid()).is.true; + expect(agreementStatus.isValid()).is.true; + + // Valid field value + agreementStatus.outstandingExchanges = "126"; + expect(agreementStatus.outstandingExchangesIsValid()).is.true; + expect(agreementStatus.isValid()).is.true; + }); + + it("Always present, totalMutualizedAmount must be the string representation of a BigNumber", async function () { + // Invalid field value + agreementStatus.totalMutualizedAmount = "zedzdeadbaby"; + expect(agreementStatus.totalMutualizedAmountIsValid()).is.false; + expect(agreementStatus.isValid()).is.false; + + // Invalid field value + agreementStatus.totalMutualizedAmount = new Date(); + expect(agreementStatus.totalMutualizedAmountIsValid()).is.false; + expect(agreementStatus.isValid()).is.false; + + // Invalid field value + agreementStatus.totalMutualizedAmount = 12; + expect(agreementStatus.totalMutualizedAmountIsValid()).is.false; + expect(agreementStatus.isValid()).is.false; + + // Valid field value + agreementStatus.totalMutualizedAmount = "0"; + expect(agreementStatus.totalMutualizedAmountIsValid()).is.true; + expect(agreementStatus.isValid()).is.true; + + // Valid field value + agreementStatus.totalMutualizedAmount = "126"; + expect(agreementStatus.totalMutualizedAmountIsValid()).is.true; + expect(agreementStatus.isValid()).is.true; + }); + }); + + context("📋 Utility functions", async function () { + beforeEach(async function () { + // Create a valid agreementStatus, then set fields in tests directly + agreementStatus = new AgreementStatus(confirmed, voided, outstandingExchanges, totalMutualizedAmount); + expect(agreementStatus.isValid()).is.true; + + // Create plain object + object = { + confirmed, + voided, + outstandingExchanges, + totalMutualizedAmount, + }; + }); + + context("👉 Static", async function () { + it("AgreementStatus.fromObject() should return a AgreementStatus instance with the same values as the given plain object", async function () { + // Promote to instance + promoted = AgreementStatus.fromObject(object); + + // Is a AgreementStatus instance + expect(promoted instanceof AgreementStatus).is.true; + + // Key values all match + for ([key, value] of Object.entries(agreementStatus)) { + expect(JSON.stringify(promoted[key]) === JSON.stringify(value)).is.true; + } + }); + + it("AgreementStatus.fromStruct() should return a AgreementStatus instance with the same values as the given struct", async function () { + struct = [ + agreementStatus.confirmed, + agreementStatus.voided, + agreementStatus.outstandingExchanges, + agreementStatus.totalMutualizedAmount, + ]; + + // Get struct + agreementStatus = AgreementStatus.fromStruct(struct); + + // Ensure it marshals back to a valid agreementStatus + expect(agreementStatus.isValid()).to.be.true; + }); + }); + + context("👉 Instance", async function () { + it("instance.toString() should return a JSON string representation of the AgreementStatus instance", async function () { + dehydrated = agreementStatus.toString(); + rehydrated = JSON.parse(dehydrated); + + for ([key, value] of Object.entries(agreementStatus)) { + expect(JSON.stringify(rehydrated[key]) === JSON.stringify(value)).is.true; + } + }); + + it("instance.toObject() should return a plain object representation of the AgreementStatus instance", async function () { + // Get plain object + object = agreementStatus.toObject(); + + // Not an AgreementStatus instance + expect(object instanceof AgreementStatus).is.false; + + // Key values all match + for ([key, value] of Object.entries(agreementStatus)) { + expect(JSON.stringify(object[key]) === JSON.stringify(value)).is.true; + } + }); + + it("AgreementStatus.toStruct() should return a struct representation of the AgreementStatus instance", async function () { + // Get struct from agreementStatus + struct = agreementStatus.toStruct(); + + // Marshal back to an agreementStatus instance + agreementStatus = AgreementStatus.fromStruct(struct); + + // Ensure it marshals back to a valid agreementStatus + expect(agreementStatus.isValid()).to.be.true; + }); + + it("instance.clone() should return another AgreementStatus instance with the same property values", async function () { + // Get plain object + clone = agreementStatus.clone(); + + // Is an AgreementStatus instance + expect(clone instanceof AgreementStatus).is.true; + + // Key values all match + for ([key, value] of Object.entries(agreementStatus)) { + expect(JSON.stringify(clone[key]) === JSON.stringify(value)).is.true; + } + }); + }); + }); +}); diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js index be1b2e068..84fe7d939 100644 --- a/test/protocol/clients/DRFeeMutualizerTest.js +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -1,6 +1,7 @@ const { ethers } = require("hardhat"); const { getInterfaceIds } = require("../../../scripts/config/supported-interfaces.js"); const Agreement = require("../../../scripts/domain/Agreement"); +const AgreementStatus = require("../../../scripts/domain/AgreementStatus"); const { expect } = require("chai"); const { RevertReasons } = require("../../../scripts/config/revert-reasons"); @@ -78,13 +79,18 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { }); it("should update state", async function () { + let expectedAgreementStatus = new AgreementStatus(false, false, "0", "0"); + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); // Get agreement object from contract - const returnedAgreement = Agreement.fromStruct(await mutualizer.getAgreement("1")); + const [returnedAgreement, returnedAgreementStatus] = await mutualizer.getAgreement("1"); + const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); + const returnedAgreementStatusStruct = AgreementStatus.fromStruct(returnedAgreementStatus); // Values should match - expect(returnedAgreement.toString()).eq(agreement.toString()); + expect(returnedAgreementStruct.toString()).eq(agreement.toString()); + expect(returnedAgreementStatusStruct.toString()).eq(expectedAgreementStatus.toString()); }); context("💔 Revert Reasons", async function () { @@ -95,15 +101,6 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { ); }); - it("voided is set to true", async function () { - agreement.voided = true; - - // Expect revert if voided is true - await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)).to.be.revertedWith( - RevertReasons.INVALID_AGREEMENT - ); - }); - it("max mutualized amount per transaction is greater than max total mutualized amount", async function () { agreement.maxMutualizedAmountPerTransaction = ethers.BigNumber.from(agreement.maxTotalMutualizedAmount) .add(1) @@ -163,17 +160,19 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { }); it("should update state", async function () { + let expectedAgreementStatus = new AgreementStatus(true, false, "0", "0"); + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); // Get agreement id and agreement object from contract - const [returnedAgreementId, returnedAgreement] = await mutualizer.getAgreementBySellerAndToken( - assistant.address, - ethers.constants.AddressZero - ); + const [returnedAgreementId, returnedAgreement, returnedAgreementStatus] = + await mutualizer.getConfirmedAgreementBySellerAndToken(agreement.sellerAddress, agreement.token); const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); + const returnedAgreementStatusStruct = AgreementStatus.fromStruct(returnedAgreementStatus); // Values should match expect(returnedAgreementStruct.toString()).eq(agreement.toString()); + expect(returnedAgreementStatusStruct.toString()).eq(expectedAgreementStatus.toString()); expect(returnedAgreementId.toString()).eq(agreementId); }); @@ -185,6 +184,8 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { }); it("it is possible to substitute an agreement", async function () { + let expectedAgreementStatus = new AgreementStatus(true, false, "0", "0"); + // Agreement is confirmed await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); @@ -209,14 +210,14 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); // Get agreement id and agreement object from contract - const [returnedAgreementId, returnedAgreement] = await mutualizer.getAgreementBySellerAndToken( - assistant.address, - ethers.constants.AddressZero - ); + const [returnedAgreementId, returnedAgreement, returnedAgreementStatus] = + await mutualizer.getConfirmedAgreementBySellerAndToken(agreement.sellerAddress, agreement.token); const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); + const returnedAgreementStatusStruct = AgreementStatus.fromStruct(returnedAgreementStatus); // Values should match expect(returnedAgreementStruct.toString()).eq(agreement.toString()); + expect(returnedAgreementStatusStruct.toString()).eq(expectedAgreementStatus.toString()); expect(returnedAgreementId.toString()).eq(agreementId); }); @@ -378,10 +379,10 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { await mutualizer.connect(mutualizerOwner).voidAgreement(agreementId); // Get agreement object from contract - const returnedAgreement = Agreement.fromStruct(await mutualizer.getAgreement("1")); + const [, returnedStatus] = await mutualizer.getAgreement("1"); // Values should match - expect(returnedAgreement.voided).to.be.true; + expect(returnedStatus.voided).to.be.true; }); it("seller can void the agreement", async function () { @@ -713,5 +714,82 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { .withArgs(ethers.constants.AddressZero, amount, rando.address); }); }); + + context("👉 getAgreement()", function () { + it("returns the correct agreement", async function () { + let expectedAgreementStatus = new AgreementStatus(false, false, "0", "0"); + + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + + // Get agreement object from contract + const [returnedAgreement, returnedAgreementStatus] = await mutualizer.getAgreement("1"); + const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); + const returnedAgreementStatusStruct = AgreementStatus.fromStruct(returnedAgreementStatus); + + // Values should match + expect(returnedAgreementStruct.toString()).eq(agreement.toString()); + expect(returnedAgreementStatusStruct.toString()).eq(expectedAgreementStatus.toString()); + }); + + context("💔 Revert Reasons", async function () { + it("agreement does not exist", async function () { + // Index out of bound + await expect(mutualizer.getAgreement("0")).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + + await expect(mutualizer.getAgreement("10")).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + }); + }); + }); + + context("👉 getConfirmedAgreementBySellerAndToken()", function () { + let agreementId; + + beforeEach(async function () { + agreementId = "1"; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + }); + + it("returns the correct agreement", async function () { + let expectedAgreementStatus = new AgreementStatus(true, false, "0", "0"); + // Get agreement id and agreement object from contract + const [returnedAgreementId, returnedAgreement, returnedAgreementStatus] = + await mutualizer.getConfirmedAgreementBySellerAndToken(agreement.sellerAddress, agreement.token); + const returnedAgreementStruct = Agreement.fromStruct(returnedAgreement); + const returnedAgreementStatusStruct = AgreementStatus.fromStruct(returnedAgreementStatus); + + // Values should match + expect(returnedAgreementStruct.toString()).eq(agreement.toString()); + expect(returnedAgreementStatusStruct.toString()).eq(expectedAgreementStatus.toString()); + expect(returnedAgreementId.toString()).eq(agreementId); + }); + + context("💔 Revert Reasons", async function () { + it("agreement does not exist - no agreement for the token", async function () { + // Seller has no agreement for the token + await expect( + mutualizer.getConfirmedAgreementBySellerAndToken(assistant.address, foreign20.address) + ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + }); + + it("agreement does not exist - no agreement for the seller", async function () { + // Rando has no agreement + await expect( + mutualizer.getConfirmedAgreementBySellerAndToken(rando.address, ethers.constants.AddressZero) + ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + }); + + it("agreement not confirmed yet", async function () { + // Create a new agreement, but don't confirm it + agreement.token = foreign20.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + + // Seller has no agreement for the token + await expect( + mutualizer.getConfirmedAgreementBySellerAndToken(assistant.address, foreign20.address) + ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + }); + }); + }); }); }); From 2423816a69222d537315e5653d9e68728f991858 Mon Sep 17 00:00:00 2001 From: zajck Date: Wed, 31 May 2023 11:48:31 +0200 Subject: [PATCH 26/33] requestDRFee tests --- .../interfaces/clients/IDRFeeMutualizer.sol | 15 + contracts/mock/MockDRFeeMutualizer.sol | 2 +- .../clients/feeMutualizer/DRFeeMutualizer.sol | 81 +++-- test/protocol/clients/DRFeeMutualizerTest.js | 315 +++++++++++++++++- 4 files changed, 384 insertions(+), 29 deletions(-) diff --git a/contracts/interfaces/clients/IDRFeeMutualizer.sol b/contracts/interfaces/clients/IDRFeeMutualizer.sol index 611202590..2f0f6cde3 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizer.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizer.sol @@ -18,6 +18,8 @@ interface IDRFeeMutualizer { address feeRequester, bytes context ); + + event DRFeeSent(address indexed feeRequester, address token, uint256 feeAmount, uint256 indexed uuid); event DRFeeReturned(uint256 indexed uuid, uint256 feeAmount, bytes context); /** @@ -43,6 +45,19 @@ interface IDRFeeMutualizer { * @dev Verify that seller is covered and send the fee amount to the msg.sender. * Returned uuid can be used to track the status of the request. * + * Reverts if: + * - caller is not the protocol + * - agreement does not exist + * - agreement is not confirmed yet + * - agreement is voided + * - agreement has not started yet + * - agreement expired + * - fee amount exceeds max mutualized amount per transaction + * - fee amount exceeds max total mutualized amount + * - amount exceeds available balance + * - token is native and transfer fails + * - token is ERC20 and transferFrom fails + * * @param _sellerAddress - the seller address * @param _token - the token address (use 0x0 for ETH) * @param _feeAmount - amount to cover diff --git a/contracts/mock/MockDRFeeMutualizer.sol b/contracts/mock/MockDRFeeMutualizer.sol index 1e4c899f0..73f02ca82 100644 --- a/contracts/mock/MockDRFeeMutualizer.sol +++ b/contracts/mock/MockDRFeeMutualizer.sol @@ -59,7 +59,7 @@ contract MockDRFeeMutualizer is IDRFeeMutualizer { /** * @notice Mock function that does not accept payment from the protocol. */ - function returnDRFee(uint256 _uuid, uint256 _feeAmount, bytes calldata _context) external payable { + function returnDRFee(uint256, uint256, bytes calldata) external payable { revert("MockDRFeeMutualizer: revert"); } diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index da5a55494..e2aaa4ea7 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -65,6 +65,21 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * @dev Verify that seller is covered and send the fee amount to the msg.sender. * Returned uuid can be used to track the status of the request. * + * Emits DRFeeSent event if successful. + * + * Reverts if: + * - caller is not the protocol + * - agreement does not exist + * - agreement is not confirmed yet + * - agreement is voided + * - agreement has not started yet + * - agreement expired + * - fee amount exceeds max mutualized amount per transaction + * - fee amount exceeds max total mutualized amount + * - amount exceeds available balance + * - token is native and transfer fails + * - token is ERC20 and transferFrom fails + * * @param _sellerAddress - the seller address * @param _token - the token address (use 0x0 for ETH) * @param _feeAmount - amount to cover @@ -79,28 +94,26 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { bytes calldata /*_context*/ ) external returns (bool isCovered, uint256 uuid) { require(msg.sender == protocolAddress, ONLY_PROTOCOL); - uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; - Agreement storage agreement = agreements[agreementId]; + // Make sure agreement is valid + uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; + (Agreement storage agreement, AgreementStatus storage status) = getValidAgreement(agreementId); require(agreement.startTimestamp <= block.timestamp, AGREEMENT_NOT_STARTED); - require(agreement.endTimestamp >= block.timestamp, AGREEMENT_EXPIRED); require(agreement.maxMutualizedAmountPerTransaction >= _feeAmount, EXCEEDED_SINGLE_FEE); - AgreementStatus storage status = agreementStatus[agreementId]; - require(!status.voided, AGREEMENT_VOIDED); - + // Increase total mutualized amount status.totalMutualizedAmount += _feeAmount; require(agreement.maxTotalMutualizedAmount >= status.totalMutualizedAmount, EXCEEDED_TOTAL_FEE); + // Increase number of exchanges status.outstandingExchanges++; agreementByUuid[++uuidCounter] = agreementId; - if (agreement.token == address(0)) { - payable(msg.sender).transfer(_feeAmount); - } else { - IERC20 token = IERC20(agreement.token); - token.safeTransfer(msg.sender, _feeAmount); - } + + address token = agreement.token; + transferFundsFromMutualizer(token, _feeAmount); + + emit DRFeeSent(msg.sender, token, _feeAmount, uuidCounter); return (true, uuidCounter); } @@ -253,26 +266,14 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * Reverts if: * - caller is not the mutualizer owner * - amount exceeds available balance + * - token is native and transfer fails * - token is ERC20 and transferFrom fails * * @param _tokenAddress - the token address (use 0x0 for native token) * @param _amount - amount to transfer */ function withdraw(address _tokenAddress, uint256 _amount) external onlyOwner { - uint256 mutualizerBalance = _tokenAddress == address(0) - ? address(this).balance - : IERC20(_tokenAddress).balanceOf(address(this)); - - require(mutualizerBalance >= _amount, INSUFFICIENT_AVAILABLE_FUNDS); - - if (_tokenAddress == address(0)) { - // payable(owner()).transfer(_amount); - (bool success, ) = owner().call{ value: _amount }(""); - require(success, TOKEN_TRANSFER_FAILED); - } else { - IERC20 token = IERC20(_tokenAddress); - token.safeTransfer(owner(), _amount); - } + transferFundsFromMutualizer(_tokenAddress, _amount); // msg.sender is mutualizer owner emit FundsWithdrawn(_tokenAddress, _amount); } @@ -357,6 +358,34 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { } } + /** + * @notice Internal function to handle outcoming funds. + * It always sends them to msg.sender, which is either the mutualizer owner or the protocol. + * + * Reverts if: + * - amount exceeds available balance + * - token is native and transfer fails + * - token is ERC20 and transferFrom fails + * + * @param _tokenAddress - the token address (use 0x0 for native token) + * @param _amount - amount to transfer + */ + function transferFundsFromMutualizer(address _tokenAddress, uint256 _amount) internal { + uint256 mutualizerBalance = _tokenAddress == address(0) + ? address(this).balance + : IERC20(_tokenAddress).balanceOf(address(this)); + + require(mutualizerBalance >= _amount, INSUFFICIENT_AVAILABLE_FUNDS); + + if (_tokenAddress == address(0)) { + (bool success, ) = msg.sender.call{ value: _amount }(""); + require(success, TOKEN_TRANSFER_FAILED); + } else { + IERC20 token = IERC20(_tokenAddress); + token.safeTransfer(msg.sender, _amount); + } + } + /** * @notice Gets the agreement from the storage and verifies that it is valid. * diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js index 84fe7d939..a5d081188 100644 --- a/test/protocol/clients/DRFeeMutualizerTest.js +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -51,7 +51,7 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { }); }); - context("📋 DRMutualizer methods", async function () { + context("📋 DRMutualizer client methods", async function () { let agreement; beforeEach(function () { @@ -692,7 +692,7 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { ).to.be.revertedWith(RevertReasons.ERC20_PAUSED); }); - it("Transfer of funds failed - revert during ERC20 transfer", async function () { + it("Transfer of funds failed - ERC20 returns false", async function () { const [foreign20ReturnFalse] = await deployMockTokens(["Foreign20TransferReturnFalse"]); await foreign20ReturnFalse.connect(mutualizerOwner).mint(mutualizerOwner.address, amount); @@ -792,4 +792,315 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { }); }); }); + + context("📋 DRMutualizer protocol methods", async function () { + let agreement; + + beforeEach(function () { + const startTimestamp = ethers.BigNumber.from(Date.now()).div(1000); // valid from now + const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days + agreement = new Agreement( + assistant.address, + ethers.constants.AddressZero, + ethers.utils.parseUnits("1", "ether").toString(), + ethers.utils.parseUnits("2", "ether").toString(), + ethers.utils.parseUnits("0.001", "ether").toString(), + startTimestamp.toString(), + endTimestamp.toString(), + false, + false + ); + }); + + context("👉 requestDRFee()", function () { + let amount, amountToRequest; + + context("💰 Native Token", function () { + let agreementId; + + beforeEach(async function () { + agreementId = "1"; + + amount = agreement.maxTotalMutualizedAmount; + await mutualizer.connect(mutualizerOwner).deposit(ethers.constants.AddressZero, amount, { value: amount }); + + // Create a new agreement + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + + amountToRequest = ethers.BigNumber.from(agreement.maxMutualizedAmountPerTransaction).div(2); + }); + + it("should emit a DRFeeSent event", async function () { + // Request DR fee, test for event + await expect( + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ) + .to.emit(mutualizer, "DRFeeSent") + .withArgs(protocol.address, ethers.constants.AddressZero, amountToRequest, "1"); + }); + + it("should return correct values", async function () { + // Request DR fee, get return values + const [isCovered, uuid] = await mutualizer + .connect(protocol) + .callStatic.requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x"); + + expect(isCovered).to.be.true; + expect(uuid).to.be.equal("1"); + }); + + it("should transfer funds", async function () { + await expect(() => + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ).to.changeEtherBalances([protocol, mutualizer], [amountToRequest, amountToRequest.mul(-1)]); + }); + + it("should update state", async function () { + await mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x"); + + let expectedAgreementStatus = new AgreementStatus(true, false, "1", amountToRequest.toString()); + + // Get agreement object from contract + const [, returnedAgreementStatus] = await mutualizer.getAgreement("1"); + const returnedAgreementStatusStruct = AgreementStatus.fromStruct(returnedAgreementStatus); + expect(returnedAgreementStatusStruct.toString()).eq(expectedAgreementStatus.toString()); + }); + + it("it is possible to request max mutualized amount per transaction", async function () { + amountToRequest = agreement.maxMutualizedAmountPerTransaction; + + // Request DR fee, test for event + await expect( + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ) + .to.emit(mutualizer, "DRFeeSent") + .withArgs(protocol.address, ethers.constants.AddressZero, amountToRequest, "1"); + }); + + it("it is possible to request max total mutualized amount", async function () { + amountToRequest = agreement.maxMutualizedAmountPerTransaction; + + // Request twice to reach max total mutualized amount + await expect( + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ) + .to.emit(mutualizer, "DRFeeSent") + .withArgs(protocol.address, ethers.constants.AddressZero, amountToRequest, "1"); + + await expect( + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ) + .to.emit(mutualizer, "DRFeeSent") + .withArgs(protocol.address, ethers.constants.AddressZero, amountToRequest, "2"); + }); + + context("💔 Revert Reasons", async function () { + it("caller is not the protocol", async function () { + // Expect revert if caller is not the protocol + await expect( + mutualizer + .connect(rando) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.ONLY_PROTOCOL); + }); + + it("agreement does not exist - no agreement for the token", async function () { + // Seller has no agreement for the token + await expect( + mutualizer.connect(protocol).requestDRFee(assistant.address, foreign20.address, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + }); + + it("agreement does not exist - no agreement for the seller", async function () { + // Rando has no agreement + await expect( + mutualizer + .connect(protocol) + .requestDRFee(rando.address, ethers.constants.AddressZero, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + }); + + it("agreement not confirmed yet", async function () { + // Create a new agreement, but don't confirm it + agreement.token = foreign20.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + + // Seller has no agreement for the token + await expect( + mutualizer.connect(protocol).requestDRFee(assistant.address, foreign20.address, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.INVALID_AGREEMENT); + }); + + it("agreement is voided", async function () { + await mutualizer.connect(mutualizerOwner).voidAgreement(agreementId); + + // Agreement is voided + await expect( + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.AGREEMENT_VOIDED); + }); + + it("agreement has not started yet", async function () { + // Create a new agreement with start date in the future + const startTimestamp = ethers.BigNumber.from(Date.now()) + .div(1000) + .add(oneMonth / 2); // valid in the future + agreement.startTimestamp = startTimestamp.toString(); + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + await mutualizer.connect(assistant).payPremium(++agreementId, { value: agreement.premium }); + + // Agreement has not started yet + await expect( + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.AGREEMENT_NOT_STARTED); + }); + + it("agreement expired", async function () { + await setNextBlockTimestamp(ethers.BigNumber.from(agreement.endTimestamp).add(1).toHexString()); + + // Agreement expired + await expect( + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.AGREEMENT_EXPIRED); + }); + + it("fee amount exceeds max mutualized amount per transaction", async function () { + amountToRequest = ethers.BigNumber.from(agreement.maxMutualizedAmountPerTransaction).add(1); + + // Expect revert if trying to withdraw more than max mutualized amount per transaction + await expect( + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.EXCEEDED_SINGLE_FEE); + }); + + it("fee amount exceeds max total mutualized amount", async function () { + amountToRequest = agreement.maxMutualizedAmountPerTransaction; + + // Request twice to reach max total mutualized amount + await mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x"); + await mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x"); + + // Expect revert if requested more than max mutualized amount per transaction + amountToRequest = "1"; + await expect( + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.EXCEEDED_TOTAL_FEE); + }); + + it("amount exceeds available balance", async function () { + const amountToWithdraw = ethers.BigNumber.from(agreement.maxTotalMutualizedAmount) + .add(agreement.premium) + .sub(amountToRequest) + .add(1); + await mutualizer.connect(mutualizerOwner).withdraw(ethers.constants.AddressZero, amountToWithdraw); + + // Expect revert if requested more than available balance + await expect( + mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS); + }); + }); + }); + + context("💰 ERC20", function () { + beforeEach(async function () { + let agreementId = "1"; + + amount = agreement.maxTotalMutualizedAmount; + await foreign20.connect(mutualizerOwner).mint(mutualizerOwner.address, amount); + await foreign20.connect(mutualizerOwner).approve(mutualizer.address, amount); + await mutualizer.connect(mutualizerOwner).deposit(foreign20.address, amount); + + // Create a new agreement + agreement.token = foreign20.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + // Confirm the agreement + await foreign20.connect(assistant).mint(assistant.address, agreement.premium); + await foreign20.connect(assistant).approve(mutualizer.address, agreement.premium); + await mutualizer.connect(assistant).payPremium(agreementId); + + amountToRequest = ethers.BigNumber.from(agreement.maxMutualizedAmountPerTransaction).div(2); + }); + + it("should emit a DRFeeSent event", async function () { + // Request DR fee, test for event + await expect( + mutualizer.connect(protocol).requestDRFee(assistant.address, foreign20.address, amountToRequest, "0x") + ) + .to.emit(mutualizer, "DRFeeSent") + .withArgs(protocol.address, foreign20.address, amountToRequest, "1"); + }); + + it("should transfer funds", async function () { + await expect(() => + mutualizer.connect(protocol).requestDRFee(assistant.address, foreign20.address, amountToRequest, "0x") + ).to.changeTokenBalances(foreign20, [protocol, mutualizer], [amountToRequest, amountToRequest.mul(-1)]); + }); + + context("💔 Revert Reasons", async function () { + it("amount exceeds available balance", async function () { + const amountToWithdraw = ethers.BigNumber.from(agreement.maxTotalMutualizedAmount) + .add(agreement.premium) + .sub(amountToRequest) + .add(1); + await mutualizer.connect(mutualizerOwner).withdraw(foreign20.address, amountToWithdraw); + + // Expect revert if requested more than available balance + await expect( + mutualizer.connect(protocol).requestDRFee(assistant.address, foreign20.address, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS); + }); + + it("Transfer of funds failed - ERC20 token does not exist anymore", async function () { + // destruct foreign20 + await foreign20.destruct(); + + // Expect revert if ERC20 does not exist anymore + await expect( + mutualizer.connect(protocol).requestDRFee(assistant.address, foreign20.address, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.EOA_FUNCTION_CALL); + }); + + it("Transfer of funds failed - revert during ERC20 transfer", async function () { + // foreign20 mockToken + await foreign20.pause(); + + // Expect revert if ERC20 reverts during transfer + await expect( + mutualizer.connect(protocol).requestDRFee(assistant.address, foreign20.address, amountToRequest, "0x") + ).to.be.revertedWith(RevertReasons.ERC20_PAUSED); + }); + }); + }); + }); + }); }); From 91f2773aad36472742b865b1c3d9ce4ba4a41232 Mon Sep 17 00:00:00 2001 From: zajck Date: Wed, 31 May 2023 13:02:39 +0200 Subject: [PATCH 27/33] returnDRFee tests --- .../interfaces/clients/IDRFeeMutualizer.sol | 10 +- .../clients/feeMutualizer/DRFeeMutualizer.sol | 31 ++- test/protocol/clients/DRFeeMutualizerTest.js | 182 ++++++++++++++++++ 3 files changed, 213 insertions(+), 10 deletions(-) diff --git a/contracts/interfaces/clients/IDRFeeMutualizer.sol b/contracts/interfaces/clients/IDRFeeMutualizer.sol index 2f0f6cde3..3cde1dbef 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizer.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizer.sol @@ -20,7 +20,7 @@ interface IDRFeeMutualizer { ); event DRFeeSent(address indexed feeRequester, address token, uint256 feeAmount, uint256 indexed uuid); - event DRFeeReturned(uint256 indexed uuid, uint256 feeAmount, bytes context); + event DRFeeReturned(uint256 indexed uuid, address indexed token, uint256 feeAmount, bytes context); /** * @notice Tells if mutualizer will cover the fee amount for a given seller and requrested by a given address. @@ -77,6 +77,14 @@ interface IDRFeeMutualizer { * * @dev Returned amount can be between 0 and _feeAmount that was requested for the given uuid. * + * - caller is not the protocol + * - uuid does not exist + * - same uuid is used twice + * - token is native and sent value is not equal to _feeAmount + * - token is ERC20, but some native value is sent + * - token is ERC20 and sent value is not equal to _feeAmount + * - token is ERC20 and transferFrom fails + * * @param _uuid - unique identifier of the request * @param _feeAmount - returned amount * @param _context - additional data, describing the context diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index e2aaa4ea7..13d299538 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -92,9 +92,7 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { address _token, uint256 _feeAmount, bytes calldata /*_context*/ - ) external returns (bool isCovered, uint256 uuid) { - require(msg.sender == protocolAddress, ONLY_PROTOCOL); - + ) external onlyProtocol returns (bool isCovered, uint256 uuid) { // Make sure agreement is valid uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; (Agreement storage agreement, AgreementStatus storage status) = getValidAgreement(agreementId); @@ -123,22 +121,31 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { * * @dev Returned amount can be between 0 and _feeAmount that was requested for the given uuid. * + * Reverts if: + * - caller is not the protocol + * - uuid does not exist + * - same uuid is used twice + * - token is native and sent value is not equal to _feeAmount + * - token is ERC20, but some native value is sent + * - token is ERC20 and sent value is not equal to _feeAmount + * - token is ERC20 and transferFrom fails + * * @param _uuid - unique identifier of the request * @param _feeAmount - returned amount * @param _context - additional data, describing the context */ - function returnDRFee(uint256 _uuid, uint256 _feeAmount, bytes calldata _context) external payable { + function returnDRFee(uint256 _uuid, uint256 _feeAmount, bytes calldata _context) external payable onlyProtocol { uint256 agreementId = agreementByUuid[_uuid]; require(agreementId != 0, INVALID_UUID); AgreementStatus storage status = agreementStatus[agreementId]; + Agreement storage agreement = agreements[agreementId]; + address token = agreement.token; if (_feeAmount > 0) { - Agreement storage agreement = agreements[agreementId]; - - transferFundsToMutualizer(agreement.token, _feeAmount); + transferFundsToMutualizer(token, _feeAmount); + // Protocol should not return more than it has received, but we handle this case if behavior changes in the future if (_feeAmount < status.totalMutualizedAmount) { - // not necessary if we restrict call to the protocol only status.totalMutualizedAmount -= _feeAmount; } else { status.totalMutualizedAmount = 0; @@ -148,7 +155,8 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { status.outstandingExchanges--; delete agreementByUuid[_uuid]; // prevent using the same uuid twice - emit DRFeeReturned(_uuid, _feeAmount, _context); + + emit DRFeeReturned(_uuid, token, _feeAmount, _context); } /** @@ -407,4 +415,9 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { agreement = agreements[_agreementId]; require(agreement.endTimestamp > block.timestamp, AGREEMENT_EXPIRED); } + + modifier onlyProtocol() { + require(msg.sender == protocolAddress, ONLY_PROTOCOL); + _; + } } diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js index a5d081188..46763c55b 100644 --- a/test/protocol/clients/DRFeeMutualizerTest.js +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -1102,5 +1102,187 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { }); }); }); + + context("👉 returnDRFee()", function () { + let amount, DRFee; + + context("💰 Native Token", function () { + let uuid; + + beforeEach(async function () { + const agreementId = "1"; + uuid = "1"; + + amount = agreement.maxTotalMutualizedAmount; + await mutualizer.connect(mutualizerOwner).deposit(ethers.constants.AddressZero, amount, { value: amount }); + + // Create a new agreement + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + + // Request the DR fee + DRFee = ethers.BigNumber.from(agreement.maxMutualizedAmountPerTransaction).div(2); + await mutualizer.connect(protocol).requestDRFee(assistant.address, ethers.constants.AddressZero, DRFee, "0x"); + }); + + it("should emit a DRFeeReturned event", async function () { + // Return DR fee, test for event + await expect(mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x", { value: DRFee })) + .to.emit(mutualizer, "DRFeeReturned") + .withArgs(uuid, ethers.constants.AddressZero, DRFee, "0x"); + }); + + it("should transfer funds", async function () { + await expect(() => + mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x", { value: DRFee }) + ).to.changeEtherBalances([protocol, mutualizer], [DRFee.mul(-1), DRFee]); + }); + + it("should update state", async function () { + let returnedDRFee = DRFee.div(10).mul(9); + await mutualizer.connect(protocol).returnDRFee(uuid, returnedDRFee, "0x", { value: returnedDRFee }); + + let expectedAgreementStatus = new AgreementStatus(true, false, "0", DRFee.sub(returnedDRFee).toString()); + + // Get agreement object from contract + const [, returnedAgreementStatus] = await mutualizer.getAgreement("1"); + const returnedAgreementStatusStruct = AgreementStatus.fromStruct(returnedAgreementStatus); + expect(returnedAgreementStatusStruct.toString()).eq(expectedAgreementStatus.toString()); + }); + + it("It is possible to return 0 fee", async function () { + DRFee = "0"; + // Return DR fee, test for event + await expect(mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x", { value: DRFee })) + .to.emit(mutualizer, "DRFeeReturned") + .withArgs(uuid, ethers.constants.AddressZero, DRFee, "0x"); + }); + + it("It is possible to return more than it received", async function () { + let returnedDRFee = DRFee.div(10).mul(11); + await mutualizer.connect(protocol).returnDRFee(uuid, returnedDRFee, "0x", { value: returnedDRFee }); + + let expectedAgreementStatus = new AgreementStatus(true, false, "0", "0"); + + // Get agreement object from contract + const [, returnedAgreementStatus] = await mutualizer.getAgreement("1"); + const returnedAgreementStatusStruct = AgreementStatus.fromStruct(returnedAgreementStatus); + expect(returnedAgreementStatusStruct.toString()).eq(expectedAgreementStatus.toString()); + }); + + context("💔 Revert Reasons", async function () { + it("caller is not the protocol", async function () { + // Expect revert if caller is not the protocol + await expect(mutualizer.connect(rando).returnDRFee(uuid, DRFee, "0x", { value: DRFee })).to.be.revertedWith( + RevertReasons.ONLY_PROTOCOL + ); + }); + + it("uuid does not exist", async function () { + uuid = "2"; + + // Invalid uuid + await expect( + mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x", { value: DRFee }) + ).to.be.revertedWith(RevertReasons.INVALID_UUID); + }); + + it("same uuid is used twice", async function () { + await mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x", { value: DRFee }); + + // Invalid uuid + await expect( + mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x", { value: DRFee }) + ).to.be.revertedWith(RevertReasons.INVALID_UUID); + }); + + it("sent value is not equal to _feeAmount", async function () { + await expect( + mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x", { value: DRFee.add(1) }) + ).to.be.revertedWith(RevertReasons.INSUFFICIENT_VALUE_RECEIVED); + }); + }); + }); + + context("💰 ERC20", function () { + let uuid; + + beforeEach(async function () { + const agreementId = "1"; + uuid = "1"; + + amount = agreement.maxTotalMutualizedAmount; + await foreign20.connect(mutualizerOwner).mint(mutualizerOwner.address, amount); + await foreign20.connect(mutualizerOwner).approve(mutualizer.address, amount); + await mutualizer.connect(mutualizerOwner).deposit(foreign20.address, amount); + + // Create a new agreement + agreement.token = foreign20.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + // Confirm the agreement + await foreign20.connect(assistant).mint(assistant.address, agreement.premium); + await foreign20.connect(assistant).approve(mutualizer.address, agreement.premium); + await mutualizer.connect(assistant).payPremium(agreementId); + + // Request the DR fee + DRFee = ethers.BigNumber.from(agreement.maxMutualizedAmountPerTransaction).div(2); + await mutualizer.connect(protocol).requestDRFee(assistant.address, foreign20.address, DRFee, "0x"); + + // Approve the mutualizer to transfer fees back + await foreign20.connect(protocol).approve(mutualizer.address, DRFee); + }); + + it("should emit a DRFeeReturned event", async function () { + // Return DR fee, test for event + await expect(mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x")) + .to.emit(mutualizer, "DRFeeReturned") + .withArgs(uuid, foreign20.address, DRFee, "0x"); + }); + + it("should transfer funds", async function () { + await expect(() => mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x")).to.changeTokenBalances( + foreign20, + [protocol, mutualizer], + [DRFee.mul(-1), DRFee] + ); + }); + + context("💔 Revert Reasons", async function () { + it("native token is sent along", async function () { + // Expect revert if native token is sent along + await expect( + mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x", { value: DRFee }) + ).to.be.revertedWith(RevertReasons.NATIVE_NOT_ALLOWED); + }); + + it("transferFrom fails", async function () { + await foreign20.connect(protocol).transfer(rando.address, "1"); // transfer to reduce balance + + // Expect revert if DRFee is higher than token balance + await expect(mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x")).to.be.revertedWith( + RevertReasons.ERC20_EXCEEDS_BALANCE + ); + + const reducedAllowance = DRFee.sub(1); + await foreign20.connect(protocol).approve(mutualizer.address, reducedAllowance); + + // Expect revert if premium higher than allowance + await expect(mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x")).to.be.revertedWith( + RevertReasons.ERC20_INSUFFICIENT_ALLOWANCE + ); + }); + + it("Transfer of funds failed - ERC20 token does not exist anymore", async function () { + // destruct foreign20 + await foreign20.destruct(); + + // Expect revert if ERC20 does not exist anymore + await expect(mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x")).to.be.revertedWith( + RevertReasons.EOA_FUNCTION_CALL + ); + }); + }); + }); + }); }); }); From f6c36185528f3811528db7d7dbcacd866a3bc9ca Mon Sep 17 00:00:00 2001 From: zajck Date: Wed, 31 May 2023 13:25:47 +0200 Subject: [PATCH 28/33] isSellerCovered tests --- .../interfaces/clients/IDRFeeMutualizer.sol | 2 + .../clients/feeMutualizer/DRFeeMutualizer.sol | 8 +- test/protocol/clients/DRFeeMutualizerTest.js | 166 ++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) diff --git a/contracts/interfaces/clients/IDRFeeMutualizer.sol b/contracts/interfaces/clients/IDRFeeMutualizer.sol index 3cde1dbef..d67fae174 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizer.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizer.sol @@ -25,6 +25,8 @@ interface IDRFeeMutualizer { /** * @notice Tells if mutualizer will cover the fee amount for a given seller and requrested by a given address. * + * It checks if agreement is valid, but not if the mutualizer has enough funds to cover the fee. + * * @param _sellerAddress - the seller address * @param _token - the token address (use 0x0 for ETH) * @param _feeAmount - amount to cover diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index 13d299538..55637abcc 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -34,6 +34,8 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { /** * @notice Tells if mutualizer will cover the fee amount for a given seller and requrested by a given address. * + * It checks if agreement is valid, but not if the mutualizer has enough funds to cover the fee. + * * @param _sellerAddress - the seller address * @param _token - the token address (use 0x0 for ETH) * @param _feeAmount - amount to cover @@ -48,6 +50,10 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { bytes calldata /*_context*/ ) external view returns (bool) { uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; + if (agreementId == 0 || agreementId >= agreements.length) { + return false; + } + Agreement storage agreement = agreements[agreementId]; AgreementStatus storage status = agreementStatus[agreementId]; @@ -56,7 +62,7 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { agreement.endTimestamp >= block.timestamp && !status.voided && agreement.maxMutualizedAmountPerTransaction >= _feeAmount && - agreement.maxTotalMutualizedAmount + _feeAmount >= agreementStatus[agreementId].totalMutualizedAmount); + agreement.maxTotalMutualizedAmount >= status.totalMutualizedAmount + _feeAmount); } /** diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js index 46763c55b..fdb6e6ca9 100644 --- a/test/protocol/clients/DRFeeMutualizerTest.js +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -1284,5 +1284,171 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { }); }); }); + + context("👉 isSellerCovered()", function () { + let amount, amountToRequest; + let agreementId; + + beforeEach(async function () { + agreementId = "1"; + + amount = agreement.maxTotalMutualizedAmount; + await mutualizer.connect(mutualizerOwner).deposit(ethers.constants.AddressZero, amount, { value: amount }); + + // Create a new agreement + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); + + amountToRequest = ethers.BigNumber.from(agreement.maxMutualizedAmountPerTransaction).div(2); + }); + + it("should return true for a valid agreement", async function () { + expect( + await mutualizer.isSellerCovered( + assistant.address, + ethers.constants.AddressZero, + amountToRequest, + protocol.address, + "0x" + ) + ).to.be.true; + }); + + it("should return false if _feeRequester is not the protocol", async function () { + expect( + await mutualizer.isSellerCovered( + assistant.address, + ethers.constants.AddressZero, + amountToRequest, + rando.address, + "0x" + ) + ).to.be.false; + }); + + it("should return false if agreement does not exist - no agreement for the token", async function () { + expect( + await mutualizer.isSellerCovered( + assistant.address, + foreign20.address, + amountToRequest, + protocol.address, + "0x" + ) + ).to.be.false; + }); + + it("should return false if agreement does not exist - no agreement for the seller", async function () { + expect( + await mutualizer.isSellerCovered( + rando.address, + ethers.constants.AddressZero, + amountToRequest, + protocol.address, + "0x" + ) + ).to.be.false; + }); + + it("should return false if agreement not confirmed yet", async function () { + // Create a new agreement, but don't confirm it + agreement.token = foreign20.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + + expect( + await mutualizer.isSellerCovered( + assistant.address, + foreign20.address, + amountToRequest, + protocol.address, + "0x" + ) + ).to.be.false; + }); + + it("should return false if agreement is voided", async function () { + await mutualizer.connect(mutualizerOwner).voidAgreement(agreementId); + + expect( + await mutualizer.isSellerCovered( + assistant.address, + ethers.constants.AddressZero, + amountToRequest, + protocol.address, + "0x" + ) + ).to.be.false; + }); + + it("should return false if agreement has not started yet", async function () { + // Create a new agreement with start date in the future + const startTimestamp = ethers.BigNumber.from(Date.now()) + .div(1000) + .add(oneMonth / 2); // valid in the future + agreement.startTimestamp = startTimestamp.toString(); + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + await mutualizer.connect(assistant).payPremium(++agreementId, { value: agreement.premium }); + + expect( + await mutualizer.isSellerCovered( + assistant.address, + ethers.constants.AddressZero, + amountToRequest, + protocol.address, + "0x" + ) + ).to.be.false; + }); + + it("should return false if agreement expired", async function () { + await setNextBlockTimestamp(ethers.BigNumber.from(agreement.endTimestamp).add(1).toHexString()); + + expect( + await mutualizer.isSellerCovered( + assistant.address, + ethers.constants.AddressZero, + amountToRequest, + protocol.address, + "0x" + ) + ).to.be.false; + }); + + it("should return false if fee amount exceeds max mutualized amount per transaction", async function () { + amountToRequest = ethers.BigNumber.from(agreement.maxMutualizedAmountPerTransaction).add(1); + + expect( + await mutualizer.isSellerCovered( + assistant.address, + ethers.constants.AddressZero, + amountToRequest, + protocol.address, + "0x" + ) + ).to.be.false; + }); + + it("should return false if fee amount exceeds max total mutualized amount", async function () { + amountToRequest = agreement.maxMutualizedAmountPerTransaction; + + // Request twice to reach max total mutualized amount + await mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x"); + await mutualizer + .connect(protocol) + .requestDRFee(assistant.address, ethers.constants.AddressZero, amountToRequest, "0x"); + + expect( + await mutualizer.isSellerCovered( + assistant.address, + ethers.constants.AddressZero, + amountToRequest, + protocol.address, + "0x" + ) + ).to.be.false; + }); + }); }); }); From ef2115846a9eff072179969c33cfdd3de9e5cfec Mon Sep 17 00:00:00 2001 From: zajck Date: Wed, 31 May 2023 16:58:10 +0200 Subject: [PATCH 29/33] fix failing tests --- test/protocol/FundsHandlerTest.js | 6 ++++-- test/protocol/clients/DRFeeMutualizerTest.js | 8 +++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index a4a9167ec..591178ca1 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -2135,7 +2135,8 @@ describe("IBosonFundsHandler", function () { }); // Create new agreements - const startTimestamp = BN(Date.now()).div(1000); // valid from now + const latestBlock = await ethers.provider.getBlock("latest") + const startTimestamp = BN(latestBlock.timestamp); // valid from now const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days const agreementToken = new Agreement( assistant.address, @@ -2368,7 +2369,8 @@ describe("IBosonFundsHandler", function () { await mutualizer.connect(mutualizerOwner).deposit(mockToken.address, poolToken); // Create new agreement - const startTimestamp = BN(Date.now()).div(1000); // valid from now + const latestBlock = await ethers.provider.getBlock("latest") + const startTimestamp = BN(latestBlock.timestamp); // valid from now const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days const agreementToken = new Agreement( assistant.address, diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js index 2f8ee6f96..9ab1240b0 100644 --- a/test/protocol/clients/DRFeeMutualizerTest.js +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -681,7 +681,7 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { // Expect revert if ERC20 does not exist anymore await expect( mutualizer.connect(mutualizerOwner).withdraw(foreign20.address, amountToWithdraw) - ).to.be.revertedWith(RevertReasons.EOA_FUNCTION_CALL); + ).to.be.revertedWithoutReason(); }); it("Transfer of funds failed - revert during ERC20 transfer", async function () { @@ -1089,7 +1089,7 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { // Expect revert if ERC20 does not exist anymore await expect( mutualizer.connect(protocol).requestDRFee(assistant.address, foreign20.address, amountToRequest, "0x") - ).to.be.revertedWith(RevertReasons.EOA_FUNCTION_CALL); + ).to.be.revertedWithoutReason(); }); it("Transfer of funds failed - revert during ERC20 transfer", async function () { @@ -1279,9 +1279,7 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { await foreign20.destruct(); // Expect revert if ERC20 does not exist anymore - await expect(mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x")).to.be.revertedWith( - RevertReasons.EOA_FUNCTION_CALL - ); + await expect(mutualizer.connect(protocol).returnDRFee(uuid, DRFee, "0x")).to.be.revertedWithoutReason(); }); }); }); From 9a0feab52390f3ee5d827fa4d853fc400b6159f0 Mon Sep 17 00:00:00 2001 From: zajck Date: Wed, 31 May 2023 17:21:23 +0200 Subject: [PATCH 30/33] bump coverage --- .../clients/feeMutualizer/DRFeeMutualizer.sol | 14 +++++++------- scripts/config/supported-interfaces.js | 2 +- test/protocol/FundsHandlerTest.js | 4 ++-- test/protocol/clients/DRFeeMutualizerTest.js | 18 +++++++++++++----- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index c455a5af6..1a81184ec 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -50,7 +50,7 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { bytes calldata /*_context*/ ) external view returns (bool) { uint256 agreementId = agreementBySellerAndToken[_sellerAddress][_token]; - if (agreementId == 0 || agreementId >= agreements.length) { + if (agreementId == 0) { return false; } @@ -243,12 +243,12 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { status.voided = true; - if (agreement.refundOnCancel) { - // calculate unused premium - // ToDo: what is the business logic here? - // what with the outstanding requests? - // uint256 unusedPremium = agreement.premium*(agreement.endTimestamp-block.timestamp)/(agreement.endTimestamp-agreement.startTimestamp); // potential overflow - } + // if (agreement.refundOnCancel) { + // // calculate unused premium + // // ToDo: what is the business logic here? + // // what with the outstanding requests? + // // uint256 unusedPremium = agreement.premium*(agreement.endTimestamp-block.timestamp)/(agreement.endTimestamp-agreement.startTimestamp); // potential overflow + // } emit AgreementVoided(agreement.sellerAddress, _agreementId); } diff --git a/scripts/config/supported-interfaces.js b/scripts/config/supported-interfaces.js index 07ab8d3aa..5c5da2305 100644 --- a/scripts/config/supported-interfaces.js +++ b/scripts/config/supported-interfaces.js @@ -53,7 +53,7 @@ async function getInterfaceIds(useCache = true) { "contracts/interfaces/IERC721.sol:IERC721", "contracts/interfaces/IERC2981.sol:IERC2981", "IAccessControl", - // "IDRFeeMutualizer", + "IDRFeeMutualizer", "IDRFeeMutualizerClient", ].forEach((iFace) => { skipBaseCheck[iFace] = false; diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index 591178ca1..96ee1e3f2 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -2135,7 +2135,7 @@ describe("IBosonFundsHandler", function () { }); // Create new agreements - const latestBlock = await ethers.provider.getBlock("latest") + const latestBlock = await ethers.provider.getBlock("latest"); const startTimestamp = BN(latestBlock.timestamp); // valid from now const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days const agreementToken = new Agreement( @@ -2369,7 +2369,7 @@ describe("IBosonFundsHandler", function () { await mutualizer.connect(mutualizerOwner).deposit(mockToken.address, poolToken); // Create new agreement - const latestBlock = await ethers.provider.getBlock("latest") + const latestBlock = await ethers.provider.getBlock("latest"); const startTimestamp = BN(latestBlock.timestamp); // valid from now const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days const agreementToken = new Agreement( diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js index 9ab1240b0..9056bd9fd 100644 --- a/test/protocol/clients/DRFeeMutualizerTest.js +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -40,8 +40,12 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { context("📋 Interfaces", async function () { context("👉 supportsInterface()", async function () { it("should indicate support for IDRFeeMutualizer and IDRFeeMutualizerClient", async function () { + // "ERC165" interface + let support = await mutualizer.supportsInterface(interfaceIds["IERC165"]); + expect(support, "IERC165 interface not supported").is.true; + // IDRFeeMutualizer interface - let support = await mutualizer.supportsInterface(interfaceIds["IDRFeeMutualizer"]); + support = await mutualizer.supportsInterface(interfaceIds["IDRFeeMutualizer"]); expect(support, "IDRFeeMutualizer interface not supported").is.true; // IDRFeeMutualizerClient interface @@ -131,7 +135,9 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { }); it("end timestamp is not greater than current block timestamp", async function () { - agreement.endTimestamp = ethers.BigNumber.from(Date.now()).div(1000).sub(1).toString(); + agreement.startTimestamp = "0"; + const latestBlock = await ethers.provider.getBlock("latest"); + agreement.endTimestamp = ethers.BigNumber.from(latestBlock.timestamp).sub(1).toString(); // Expect revert if the end timestamp is not greater than current block timestamp await expect(mutualizer.connect(mutualizerOwner).newAgreement(agreement)).to.be.revertedWith( @@ -190,7 +196,8 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { await mutualizer.connect(assistant).payPremium(agreementId, { value: agreement.premium }); // Create a new agreement for the same seller and token - const startTimestamp = ethers.BigNumber.from(Date.now()).div(1000); // valid from now + const latestBlock = await ethers.provider.getBlock("latest"); + const startTimestamp = ethers.BigNumber.from(latestBlock.timestamp); // valid from now const endTimestamp = startTimestamp.add(oneMonth * 2); // valid for 30 days agreement = new Agreement( assistant.address, @@ -798,8 +805,9 @@ describe("IDRFeeMutualizer + IDRFeeMutualizerClient", function () { context("📋 DRMutualizer protocol methods", async function () { let agreement; - beforeEach(function () { - const startTimestamp = ethers.BigNumber.from(Date.now()).div(1000); // valid from now + beforeEach(async function () { + const latestBlock = await ethers.provider.getBlock("latest"); + const startTimestamp = ethers.BigNumber.from(latestBlock.timestamp); // valid from now const endTimestamp = startTimestamp.add(oneMonth); // valid for 30 days agreement = new Agreement( assistant.address, From 292126f89bced1e5884d68ac0094fbd7e1fa49d3 Mon Sep 17 00:00:00 2001 From: zajck Date: Fri, 2 Jun 2023 14:06:46 +0200 Subject: [PATCH 31/33] Fix interface id calculation when contracts inherit others --- .../interfaces/clients/IDRFeeMutualizer.sol | 151 +++++++++++++++++- .../clients/IDRFeeMutualizerClient.sol | 150 ----------------- contracts/mock/FallbackError.sol | 2 +- .../clients/feeMutualizer/DRFeeMutualizer.sol | 5 +- scripts/config/supported-interfaces.js | 1 - scripts/util/diamond-utils.js | 9 +- 6 files changed, 154 insertions(+), 164 deletions(-) delete mode 100644 contracts/interfaces/clients/IDRFeeMutualizerClient.sol diff --git a/contracts/interfaces/clients/IDRFeeMutualizer.sol b/contracts/interfaces/clients/IDRFeeMutualizer.sol index 457e4c95c..5f76a19f4 100644 --- a/contracts/interfaces/clients/IDRFeeMutualizer.sol +++ b/contracts/interfaces/clients/IDRFeeMutualizer.sol @@ -6,8 +6,6 @@ pragma solidity 0.8.18; * * @notice This is the interface for the Dispute Resolver fee mutualizers. * - * ToDo: should this be split into two interfaces? Minimal interface for the protocol and full interface for the clients? - * * The ERC-165 identifier for this interface is: 0x41283543 */ interface IDRFeeMutualizer { @@ -23,7 +21,7 @@ interface IDRFeeMutualizer { event DRFeeReturned(uint256 indexed uuid, address indexed token, uint256 feeAmount, bytes context); /** - * @notice Tells if mutualizer will cover the fee amount for a given seller and requrested by a given address. + * @notice Tells if mutualizer will cover the fee amount for a given seller and requested by a given address. * * It checks if agreement is valid, but not if the mutualizer has enough funds to cover the fee. * @@ -93,3 +91,150 @@ interface IDRFeeMutualizer { */ function returnDRFee(uint256 _uuid, uint256 _feeAmount, bytes calldata _context) external payable; } + +/** + * @title IDRFeeMutualizerClient + * + * @notice This is the interface for the Dispute Resolver fee mutualizers. + * + * The ERC-165 identifier for this interface is: 0x391b17cd + */ +interface IDRFeeMutualizerClient is IDRFeeMutualizer { + struct Agreement { + address sellerAddress; + address token; + uint256 maxMutualizedAmountPerTransaction; + uint256 maxTotalMutualizedAmount; + uint256 premium; + uint128 startTimestamp; + uint128 endTimestamp; + bool refundOnCancel; + } + + struct AgreementStatus { + bool confirmed; + bool voided; + uint256 outstandingExchanges; + uint256 totalMutualizedAmount; + } + + event AgreementCreated(address indexed sellerAddress, uint256 indexed agreementId, Agreement agreement); + event AgreementConfirmed(address indexed sellerAddress, uint256 indexed agreementId); + event AgreementVoided(address indexed sellerAddress, uint256 indexed agreementId); + event FundsDeposited(address indexed tokenAddress, uint256 amount, address indexed depositor); + event FundsWithdrawn(address indexed tokenAddress, uint256 amount); + + /** + * @notice Stores a new agreement between mutualizer and seller. Only contract owner can submit an agreement, + * however it becomes valid only after seller confirms it by calling payPremium. + * + * Emits AgreementCreated event if successful. + * + * Reverts if: + * - caller is not the contract owner + * - max mutualized amount per transaction is greater than max total mutualized amount + * - max mutualized amount per transaction is 0 + * - end timestamp is not greater than start timestamp + * - end timestamp is not greater than current block timestamp + * + * @param _agreement - a fully populated agreement object + */ + function newAgreement(Agreement calldata _agreement) external; + + /** + * @notice Pay the premium for the agreement and confirm it. + * + * Emits AgreementConfirmed event if successful. + * + * Reverts if: + * - agreement does not exist + * - agreement is already confirmed + * - agreement is voided + * - agreement expired + * - token is native and sent value is not equal to the agreement premium + * - token is ERC20, but some native value is sent + * - token is ERC20 and sent value is not equal to the agreement premium + * - token is ERC20 and transferFrom fails + * + * @param _agreementId - a unique identifier of the agreement + */ + function payPremium(uint256 _agreementId) external payable; + + /** + * @notice Void the agreement. + * + * Emits AgreementVoided event if successful. + * + * Reverts if: + * - agreement does not exist + * - caller is not the contract owner or the seller + * - agreement is voided already + * - agreement expired + * + * @param _agreementId - a unique identifier of the agreement + */ + function voidAgreement(uint256 _agreementId) external; + + /** + * @notice Deposit funds to the mutualizer. Funds are used to cover the DR fees. + * + * Emits FundsDeposited event if successful. + * + * Reverts if: + * - token is native and sent value is not equal to _amount + * - token is ERC20, but some native value is sent + * - token is ERC20 and sent value is not equal to _amount + * - token is ERC20 and transferFrom fails + * + * @param _tokenAddress - the token address (use 0x0 for native token) + * @param _amount - amount to transfer + */ + function deposit(address _tokenAddress, uint256 _amount) external payable; + + /** + * @notice Withdraw funds from the mutualizer. + * + * Emits FundsWithdrawn event if successful. + * + * Reverts if: + * - caller is not the mutualizer owner + * - amount exceeds available balance + * - token is ERC20 and transferFrom fails + * + * @param _tokenAddress - the token address (use 0x0 for native token) + * @param _amount - amount to transfer + */ + function withdraw(address _tokenAddress, uint256 _amount) external; + + /** + * @notice Returns agreement details and status for a given agreement id. + * + * Reverts if: + * - agreement does not exist + * + * @param _agreementId - a unique identifier of the agreement + * @return agreement - agreement details + * @return status - agreement status + */ + function getAgreement( + uint256 _agreementId + ) external view returns (Agreement memory agreement, AgreementStatus memory status); + + /** + * @notice Returns agreement id, agreement details and status for given seller and token. + * + * Reverts if: + * - agreement does not exist + * - agreement is not confirmed yet + * + * @param _seller - the seller address + * @param _token - the token address (use 0x0 for native token) + * @return agreementId - a unique identifier of the agreement + * @return agreement - agreement details + * @return status - agreement status + */ + function getConfirmedAgreementBySellerAndToken( + address _seller, + address _token + ) external view returns (uint256 agreementId, Agreement memory agreement, AgreementStatus memory status); +} diff --git a/contracts/interfaces/clients/IDRFeeMutualizerClient.sol b/contracts/interfaces/clients/IDRFeeMutualizerClient.sol deleted file mode 100644 index be5ad9553..000000000 --- a/contracts/interfaces/clients/IDRFeeMutualizerClient.sol +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.18; -import "./IDRFeeMutualizer.sol"; - -/** - * @title IDRFeeMutualizerClient - * - * @notice This is the interface for the Dispute Resolver fee mutualizers. - * - * The ERC-165 identifier for this interface is: 0x391b17cd - */ -interface IDRFeeMutualizerClient is IDRFeeMutualizer { - struct Agreement { - address sellerAddress; - address token; - uint256 maxMutualizedAmountPerTransaction; - uint256 maxTotalMutualizedAmount; - uint256 premium; - uint128 startTimestamp; - uint128 endTimestamp; - bool refundOnCancel; - } - - struct AgreementStatus { - bool confirmed; - bool voided; - uint256 outstandingExchanges; - uint256 totalMutualizedAmount; - } - - event AgreementCreated(address indexed sellerAddress, uint256 indexed agreementId, Agreement agreement); - event AgreementConfirmed(address indexed sellerAddress, uint256 indexed agreementId); - event AgreementVoided(address indexed sellerAddress, uint256 indexed agreementId); - event FundsDeposited(address indexed tokenAddress, uint256 amount, address indexed depositor); - event FundsWithdrawn(address indexed tokenAddress, uint256 amount); - - /** - * @notice Stores a new agreement between mutualizer and seller. Only contract owner can submit an agreement, - * however it becomes valid only after seller confirms it by calling payPremium. - * - * Emits AgreementCreated event if successful. - * - * Reverts if: - * - caller is not the contract owner - * - max mutualized amount per transaction is greater than max total mutualized amount - * - max mutualized amount per transaction is 0 - * - end timestamp is not greater than start timestamp - * - end timestamp is not greater than current block timestamp - * - * @param _agreement - a fully populated agreement object - */ - function newAgreement(Agreement calldata _agreement) external; - - /** - * @notice Pay the premium for the agreement and confirm it. - * - * Emits AgreementConfirmed event if successful. - * - * Reverts if: - * - agreement does not exist - * - agreement is already confirmed - * - agreement is voided - * - agreement expired - * - token is native and sent value is not equal to the agreement premium - * - token is ERC20, but some native value is sent - * - token is ERC20 and sent value is not equal to the agreement premium - * - token is ERC20 and transferFrom fails - * - * @param _agreementId - a unique identifier of the agreement - */ - function payPremium(uint256 _agreementId) external payable; - - /** - * @notice Void the agreement. - * - * Emits AgreementVoided event if successful. - * - * Reverts if: - * - agreement does not exist - * - caller is not the contract owner or the seller - * - agreement is voided already - * - agreement expired - * - * @param _agreementId - a unique identifier of the agreement - */ - function voidAgreement(uint256 _agreementId) external; - - /** - * @notice Deposit funds to the mutualizer. Funds are used to cover the DR fees. - * - * Emits FundsDeposited event if successful. - * - * Reverts if: - * - token is native and sent value is not equal to _amount - * - token is ERC20, but some native value is sent - * - token is ERC20 and sent value is not equal to _amount - * - token is ERC20 and transferFrom fails - * - * @param _tokenAddress - the token address (use 0x0 for native token) - * @param _amount - amount to transfer - */ - function deposit(address _tokenAddress, uint256 _amount) external payable; - - /** - * @notice Withdraw funds from the mutualizer. - * - * Emits FundsWithdrawn event if successful. - * - * Reverts if: - * - caller is not the mutualizer owner - * - amount exceeds available balance - * - token is ERC20 and transferFrom fails - * - * @param _tokenAddress - the token address (use 0x0 for native token) - * @param _amount - amount to transfer - */ - function withdraw(address _tokenAddress, uint256 _amount) external; - - /** - * @notice Returns agreement details and status for a given agreement id. - * - * Reverts if: - * - agreement does not exist - * - * @param _agreementId - a unique identifier of the agreement - * @return agreement - agreement details - * @return status - agreement status - */ - function getAgreement( - uint256 _agreementId - ) external view returns (Agreement memory agreement, AgreementStatus memory status); - - /** - * @notice Returns agreement id, agreement details and status for given seller and token. - * - * Reverts if: - * - agreement does not exist - * - agreement is not confirmed yet - * - * @param _seller - the seller address - * @param _token - the token address (use 0x0 for native token) - * @return agreementId - a unique identifier of the agreement - * @return agreement - agreement details - * @return status - agreement status - */ - function getConfirmedAgreementBySellerAndToken( - address _seller, - address _token - ) external view returns (uint256 agreementId, Agreement memory agreement, AgreementStatus memory status); -} diff --git a/contracts/mock/FallbackError.sol b/contracts/mock/FallbackError.sol index bf56c00d3..242c6624d 100644 --- a/contracts/mock/FallbackError.sol +++ b/contracts/mock/FallbackError.sol @@ -2,7 +2,7 @@ pragma solidity 0.8.18; import { IBosonFundsHandler } from "../interfaces/handlers/IBosonFundsHandler.sol"; -import { IDRFeeMutualizerClient } from "../interfaces/clients/IDRFeeMutualizerClient.sol"; +import { IDRFeeMutualizerClient } from "../interfaces/clients/IDRFeeMutualizer.sol"; /** * @title WithoutFallbackError diff --git a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol index 1a81184ec..c8d2a0ae1 100644 --- a/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -1,8 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.18; import "../../../domain/BosonConstants.sol"; -import { IDRFeeMutualizer } from "../../../interfaces/clients/IDRFeeMutualizer.sol"; -import { IDRFeeMutualizerClient } from "../../../interfaces/clients/IDRFeeMutualizerClient.sol"; +import { IDRFeeMutualizer, IDRFeeMutualizerClient } from "../../../interfaces/clients/IDRFeeMutualizer.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -32,7 +31,7 @@ contract DRFeeMutualizer is IDRFeeMutualizerClient, Ownable, ERC165 { } /** - * @notice Tells if mutualizer will cover the fee amount for a given seller and requrested by a given address. + * @notice Tells if mutualizer will cover the fee amount for a given seller and requested by a given address. * * It checks if agreement is valid, but not if the mutualizer has enough funds to cover the fee. * diff --git a/scripts/config/supported-interfaces.js b/scripts/config/supported-interfaces.js index 5c5da2305..f0c184abb 100644 --- a/scripts/config/supported-interfaces.js +++ b/scripts/config/supported-interfaces.js @@ -53,7 +53,6 @@ async function getInterfaceIds(useCache = true) { "contracts/interfaces/IERC721.sol:IERC721", "contracts/interfaces/IERC2981.sol:IERC2981", "IAccessControl", - "IDRFeeMutualizer", "IDRFeeMutualizerClient", ].forEach((iFace) => { skipBaseCheck[iFace] = false; diff --git a/scripts/util/diamond-utils.js b/scripts/util/diamond-utils.js index a27559b8a..6f15d0e63 100644 --- a/scripts/util/diamond-utils.js +++ b/scripts/util/diamond-utils.js @@ -48,15 +48,12 @@ async function getInterfaceId(contractName, skipBaseCheck = false, isFullPath = // Get base contracts let buildInfo; const { sourceName } = await hre.artifacts.readArtifact(contractName); + contractName = contractName.split(":").pop(); - if (!isFullPath) { - buildInfo = await hre.artifacts.getBuildInfo(`${sourceName}:${contractName}`); - } else { - buildInfo = await hre.artifacts.getBuildInfo(contractName); - } + buildInfo = await hre.artifacts.getBuildInfo(`${sourceName}:${contractName}`); const nodes = buildInfo.output?.sources?.[sourceName]?.ast?.nodes; - const node = nodes.find((n) => n.baseContracts); // node with information about base contracts + const node = nodes.find((n) => n.name == contractName); // node with information about base contracts for (const baseContract of node.baseContracts) { const baseName = baseContract.baseName.name; From 82ea1a0b1c5c79d7e1cc47179de0b3456841f5d4 Mon Sep 17 00:00:00 2001 From: zajck Date: Wed, 7 Jun 2023 09:06:51 +0200 Subject: [PATCH 32/33] Unskip skipped tests --- contracts/mock/Foreign20.sol | 7 ++- test/protocol/DisputeHandlerTest.js | 80 +++++++++-------------------- 2 files changed, 31 insertions(+), 56 deletions(-) diff --git a/contracts/mock/Foreign20.sol b/contracts/mock/Foreign20.sol index 170fd97fa..92450c36e 100644 --- a/contracts/mock/Foreign20.sol +++ b/contracts/mock/Foreign20.sol @@ -170,6 +170,7 @@ contract Foreign20Malicious2 is Foreign20 { */ contract Foreign20WithFee is Foreign20 { uint256 private fee = 3; + address private noFeeAddress; /** * @dev See {ERC20-_beforeTokenTransfer}. @@ -178,7 +179,7 @@ contract Foreign20WithFee is Foreign20 { * */ function _afterTokenTransfer(address from, address to, uint256 amount) internal virtual override { - if (to != address(0) && from != address(0)) { + if (to != address(0) && from != address(0) && from != noFeeAddress) { uint256 _fee = (amount * fee) / 100; _burn(to, _fee); } @@ -188,6 +189,10 @@ contract Foreign20WithFee is Foreign20 { function setFee(uint256 _newFee) external { fee = _newFee; } + + function setNoFeeAddress(address _noFeeAddress) external { + noFeeAddress = _noFeeAddress; + } } /** diff --git a/test/protocol/DisputeHandlerTest.js b/test/protocol/DisputeHandlerTest.js index 1fdc30b36..959118196 100644 --- a/test/protocol/DisputeHandlerTest.js +++ b/test/protocol/DisputeHandlerTest.js @@ -48,14 +48,7 @@ describe("IBosonDisputeHandler", function () { adminDR, clerkDR, treasuryDR; - let erc165, - protocolDiamond, - accountHandler, - exchangeHandler, - offerHandler, - fundsHandler, - disputeHandler, - pauseHandler; + let erc165, accountHandler, exchangeHandler, offerHandler, fundsHandler, disputeHandler, pauseHandler; let buyerId, offer, offerId, seller; let block, blockNumber, tx; let support, newTime; @@ -172,7 +165,7 @@ describe("IBosonDisputeHandler", function () { expect(disputeResolver.isValid()).is.true; //Create DisputeResolverFee array so offer creation will succeed - DRFeeNative = "0"; + DRFeeNative = ethers.utils.parseEther("0.01"); disputeResolverFees = [new DisputeResolverFee(ethers.constants.AddressZero, "Native", DRFeeNative)]; // Make empty seller list, so every seller is allowed @@ -208,7 +201,7 @@ describe("IBosonDisputeHandler", function () { escalationPeriod = disputeResolver.escalationResponsePeriod; // Deposit seller funds so the commit will succeed - const fundsToDeposit = ethers.BigNumber.from(sellerDeposit).mul(quantityAvailable); + const fundsToDeposit = ethers.BigNumber.from(sellerDeposit).add(DRFeeNative).mul(quantityAvailable); await fundsHandler .connect(assistant) .depositFunds(seller.id, ethers.constants.AddressZero, fundsToDeposit, { value: fundsToDeposit }); @@ -1204,13 +1197,16 @@ describe("IBosonDisputeHandler", function () { }); context("👉 escalateDispute()", async function () { - async function createDisputeExchangeWithToken() { + async function createDisputeExchangeWithToken(token = "Foreign20") { // utility function that deploys a mock token, creates a offer with it, creates an exchange and push it into escalated state // deploy a mock token - const [mockToken] = await deployMockTokens(["Foreign20"]); + const [mockToken] = await deployMockTokens([token]); + if (token === "Foreign20WithFee") { + await mockToken.setNoFeeAddress(assistant.address); + } // add to DR fees - DRFeeToken = "0"; + DRFeeToken = ethers.utils.parseEther("10"); await accountHandler .connect(adminDR) .addFeesToDisputeResolver(disputeResolverId, [ @@ -1227,16 +1223,21 @@ describe("IBosonDisputeHandler", function () { .connect(assistant) .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId); - // mint tokens to buyer and approve the protocol - buyerEscalationDepositToken = applyPercentage(DRFeeToken, buyerEscalationDepositPercentage); - await mockToken.mint(buyer.address, buyerEscalationDepositToken); - await mockToken.connect(buyer).approve(disputeHandler.address, buyerEscalationDepositToken); + // Deposit funds needed for self-mutualization + await mockToken.mint(assistant.address, DRFeeToken); + await mockToken.connect(assistant).approve(fundsHandler.address, DRFeeToken); + await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, DRFeeToken); // Commit to offer and put exchange all the way to dispute await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id); await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); await disputeHandler.connect(buyer).raiseDispute(exchangeId); + // mint tokens to buyer and approve the protocol + buyerEscalationDepositToken = applyPercentage(DRFeeToken, buyerEscalationDepositPercentage); + await mockToken.mint(buyer.address, buyerEscalationDepositToken); + await mockToken.connect(buyer).approve(disputeHandler.address, buyerEscalationDepositToken); + return mockToken; } @@ -1407,7 +1408,7 @@ describe("IBosonDisputeHandler", function () { ); }); - it.skip("Insufficient native currency sent", async function () { + it("Insufficient native currency sent", async function () { // Attempt to escalate the dispute, expecting revert await expect( disputeHandler.connect(buyer).escalateDispute(exchangeId, { @@ -1427,7 +1428,7 @@ describe("IBosonDisputeHandler", function () { ).to.revertedWith(RevertReasons.NATIVE_NOT_ALLOWED); }); - it.skip("Token address is not a contract", async function () { + it("Token address is not a contract", async function () { // prepare a disputed exchange const mockToken = await createDisputeExchangeWithToken(); @@ -1435,12 +1436,10 @@ describe("IBosonDisputeHandler", function () { await mockToken.destruct(); // Attempt to commit to an offer, expecting revert - await expect(disputeHandler.connect(buyer).escalateDispute(exchangeId)).to.revertedWith( - RevertReasons.EOA_FUNCTION_CALL - ); + await expect(disputeHandler.connect(buyer).escalateDispute(exchangeId)).to.revertedWithoutReason(); }); - it.skip("Token contract reverts for another reason", async function () { + it("Token contract reverts for another reason", async function () { // prepare a disputed exchange const mockToken = await createDisputeExchangeWithToken(); @@ -1455,7 +1454,7 @@ describe("IBosonDisputeHandler", function () { // not approved await mockToken .connect(buyer) - .approve(protocolDiamond.address, ethers.BigNumber.from(buyerEscalationDepositToken).sub("1").toString()); + .approve(disputeHandler.address, ethers.BigNumber.from(buyerEscalationDepositToken).sub("1").toString()); // Attempt to commit to an offer, expecting revert await expect(disputeHandler.connect(buyer).escalateDispute(exchangeId)).to.revertedWith( @@ -1463,38 +1462,9 @@ describe("IBosonDisputeHandler", function () { ); }); - it.skip("Received ERC20 token amount differs from the expected value", async function () { + it("Received ERC20 token amount differs from the expected value", async function () { // Deploy ERC20 with fees - const [Foreign20WithFee] = await deployMockTokens(["Foreign20WithFee"]); - - // add to DR fees - DRFeeToken = ethers.utils.parseUnits("2", "ether").toString(); - await accountHandler - .connect(adminDR) - .addFeesToDisputeResolver(disputeResolverId, [ - new DisputeResolverFee(Foreign20WithFee.address, "Foreign20WithFee", "0"), - ]); - - // Create an offer with ERC20 with fees - // Prepare an absolute zero offer - offer.exchangeToken = Foreign20WithFee.address; - offer.sellerDeposit = offer.price = offer.buyerCancelPenalty = "0"; - offer.id++; - - // Create a new offer - await offerHandler - .connect(assistant) - .createOffer(offer, offerDates, offerDurations, disputeResolverId, agentId); - - // mint tokens and approve - buyerEscalationDepositToken = applyPercentage(DRFeeToken, buyerEscalationDepositPercentage); - await Foreign20WithFee.mint(buyer.address, buyerEscalationDepositToken); - await Foreign20WithFee.connect(buyer).approve(protocolDiamond.address, buyerEscalationDepositToken); - - // Commit to offer and put exchange all the way to dispute - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offer.id); - await exchangeHandler.connect(buyer).redeemVoucher(++exchangeId); - await disputeHandler.connect(buyer).raiseDispute(exchangeId); + await createDisputeExchangeWithToken("Foreign20WithFee"); // Attempt to escalate the dispute, expecting revert await expect(disputeHandler.connect(buyer).escalateDispute(exchangeId)).to.revertedWith( From 185d09ac7b38d3559af311dfaf9cdff5e385433d Mon Sep 17 00:00:00 2001 From: zajck Date: Wed, 7 Jun 2023 09:13:57 +0200 Subject: [PATCH 33/33] use anyValue predicate --- test/protocol/FundsHandlerTest.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index 96ee1e3f2..f02fd9270 100644 --- a/test/protocol/FundsHandlerTest.js +++ b/test/protocol/FundsHandlerTest.js @@ -31,7 +31,7 @@ const { mockBuyer, accountId, } = require("../util/mock"); -// const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); +const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); const { oneMonth } = require("../util/constants"); /** @@ -2181,7 +2181,7 @@ describe("IBosonFundsHandler", function () { await expect(tx) .to.emit(exchangeHandler, "DRFeeEncumbered") - .withArgs(mutualizer.address, "1", "1", mockToken.address, DRFeeToken, buyer.address); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + .withArgs(mutualizer.address, anyValue, "1", mockToken.address, DRFeeToken, buyer.address); // Commit to an offer with native currency, test for FundsEncumbered event const tx2 = await exchangeHandler @@ -2197,7 +2197,7 @@ describe("IBosonFundsHandler", function () { await expect(tx2) .to.emit(exchangeHandler, "DRFeeEncumbered") - .withArgs(mutualizer.address, "2", "2", ethers.constants.AddressZero, DRFeeNative, buyer.address); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + .withArgs(mutualizer.address, anyValue, "2", ethers.constants.AddressZero, DRFeeNative, buyer.address); }); it("should update state", async function () { @@ -2774,12 +2774,12 @@ describe("IBosonFundsHandler", function () { .to.emit(exchangeHandler, "DRFeeReturned") .withArgs( mutualizer.address, - "1", + anyValue, exchangeId, offerToken.exchangeToken, payoffs.mutualizer, caller.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + ); } }); @@ -2971,12 +2971,12 @@ describe("IBosonFundsHandler", function () { .to.emit(exchangeHandler, "DRFeeReturned") .withArgs( mutualizer.address, - "1", + anyValue, exchangeId, offerToken.exchangeToken, payoffs.mutualizer, buyer.address - ); // ToDo: upgrade hardhat, and use anyValue predicate for UUID field + ); } });