diff --git a/contracts/domain/BosonConstants.sol b/contracts/domain/BosonConstants.sol index 423fc6c88..b948af632 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"; @@ -149,6 +148,9 @@ 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"; +string constant INVALID_ENTITY_ID = "Invalid entity id"; // Revert Reasons: Meta-Transactions related string constant NONCE_USED_ALREADY = "Nonce used already"; @@ -197,6 +199,19 @@ 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"; +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( bytes( diff --git a/contracts/domain/BosonTypes.sol b/contracts/domain/BosonTypes.sol index a5c79b26a..77ad9f9c3 100644 --- a/contracts/domain/BosonTypes.sol +++ b/contracts/domain/BosonTypes.sol @@ -133,6 +133,7 @@ contract BosonTypes { uint256 escalationResponsePeriod; uint256 feeAmount; uint256 buyerEscalationDeposit; + address feeMutualizer; } struct Offer { @@ -146,6 +147,7 @@ contract BosonTypes { string metadataUri; string metadataHash; bool voided; + address feeMutualizer; } struct OfferDates { diff --git a/contracts/interfaces/clients/IDRFeeMutualizer.sol b/contracts/interfaces/clients/IDRFeeMutualizer.sol new file mode 100644 index 000000000..5f76a19f4 --- /dev/null +++ b/contracts/interfaces/clients/IDRFeeMutualizer.sol @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.18; + +/** + * @title IDRFeeMutualizer + * + * @notice This is the interface for the Dispute Resolver fee mutualizers. + * + * The ERC-165 identifier for this interface is: 0x41283543 + */ +interface IDRFeeMutualizer { + event DRFeeRequsted( + address indexed sellerAddress, + address _token, + uint256 feeAmount, + address feeRequester, + bytes context + ); + + event DRFeeSent(address indexed feeRequester, address token, uint256 feeAmount, uint256 indexed uuid); + 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 requested 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 + * @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. + * + * 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 + * @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. + * + * - 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; +} + +/** + * @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/events/IBosonFundsEvents.sol b/contracts/interfaces/events/IBosonFundsEvents.sol index b343dc2b5..514debedc 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/interfaces/events/IBosonOfferEvents.sol b/contracts/interfaces/events/IBosonOfferEvents.sol index b3b263628..e2d2d52f4 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/IBosonAccountHandler.sol b/contracts/interfaces/handlers/IBosonAccountHandler.sol index 3a81f3131..4b3552754 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( @@ -237,11 +235,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/interfaces/handlers/IBosonOfferHandler.sol b/contracts/interfaces/handlers/IBosonOfferHandler.sol index 6ac0aad33..85213d803 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: 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/interfaces/handlers/IBosonOrchestrationHandler.sol b/contracts/interfaces/handlers/IBosonOrchestrationHandler.sol index b5ad79e5b..d2c8769a0 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: 0x0c62d8e3 + * The ERC-165 identifier for this interface is: 0x9b9e9d9c */ interface IBosonOrchestrationHandler is IBosonAccountEvents, diff --git a/contracts/mock/FallbackError.sol b/contracts/mock/FallbackError.sol index 3c4e049f4..242c6624d 100644 --- a/contracts/mock/FallbackError.sol +++ b/contracts/mock/FallbackError.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.18; import { IBosonFundsHandler } from "../interfaces/handlers/IBosonFundsHandler.sol"; +import { IDRFeeMutualizerClient } from "../interfaces/clients/IDRFeeMutualizer.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/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/contracts/mock/MockDRFeeMutualizer.sol b/contracts/mock/MockDRFeeMutualizer.sol new file mode 100644 index 000000000..86cbb144d --- /dev/null +++ b/contracts/mock/MockDRFeeMutualizer.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.18; +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) { + return 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, uint256, bytes calldata) external payable { + revert("MockDRFeeMutualizer: revert"); + } + + receive() external payable {} +} diff --git a/contracts/protocol/bases/OfferBase.sol b/contracts/protocol/bases/OfferBase.sol index add7d3327..ff1b401e0 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; } @@ -243,6 +244,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/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol new file mode 100644 index 000000000..c8d2a0ae1 --- /dev/null +++ b/contracts/protocol/clients/feeMutualizer/DRFeeMutualizer.sol @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.18; +import "../../../domain/BosonConstants.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"; +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 IDRFeeMutualizerClient, Ownable, ERC165 { + using SafeERC20 for IERC20; + + address private immutable protocolAddress; + + Agreement[] private agreements; + mapping(address => mapping(address => uint256)) private agreementBySellerAndToken; + mapping(uint256 => AgreementStatus) private agreementStatus; + + mapping(uint256 => uint256) private agreementByUuid; + uint256 private uuidCounter; + + 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 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. + * + * @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]; + if (agreementId == 0) { + return false; + } + + Agreement storage agreement = agreements[agreementId]; + AgreementStatus storage status = agreementStatus[agreementId]; + + return (_feeRequester == protocolAddress && + agreement.startTimestamp <= block.timestamp && + agreement.endTimestamp >= block.timestamp && + !status.voided && + agreement.maxMutualizedAmountPerTransaction >= _feeAmount && + agreement.maxTotalMutualizedAmount >= status.totalMutualizedAmount + _feeAmount); + } + + /** + * @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. + * + * 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 + * @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 onlyProtocol returns (bool isCovered, uint256 uuid) { + // 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.maxMutualizedAmountPerTransaction >= _feeAmount, EXCEEDED_SINGLE_FEE); + + // Increase total mutualized amount + status.totalMutualizedAmount += _feeAmount; + require(agreement.maxTotalMutualizedAmount >= status.totalMutualizedAmount, EXCEEDED_TOTAL_FEE); + + // Increase number of exchanges + status.outstandingExchanges++; + + agreementByUuid[++uuidCounter] = agreementId; + + address token = agreement.token; + transferFundsFromMutualizer(token, _feeAmount); + + emit DRFeeSent(msg.sender, token, _feeAmount, uuidCounter); + + return (true, uuidCounter); + } + + /** + * @notice Return fee to the mutualizer. + * + * @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 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) { + 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) { + status.totalMutualizedAmount -= _feeAmount; + } else { + status.totalMutualizedAmount = 0; + } + } + + status.outstandingExchanges--; + + delete agreementByUuid[_uuid]; // prevent using the same uuid twice + + emit DRFeeReturned(_uuid, token, _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 + * - 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.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; + + 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 + * - 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 { + (Agreement storage agreement, AgreementStatus storage status) = getValidAgreement(_agreementId); + + require(!status.confirmed, AGREEMENT_ALREADY_CONFIRMED); + + 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); + } + + /** + * @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, AgreementStatus storage status) = getValidAgreement(_agreementId); + + require(msg.sender == owner() || msg.sender == agreement.sellerAddress, NOT_OWNER_OR_SELLER); + + 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 + // } + + 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 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 { + transferFundsFromMutualizer(_tokenAddress, _amount); // msg.sender is mutualizer owner + + emit FundsWithdrawn(_tokenAddress, _amount); + } + + /** + * @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(IDRFeeMutualizer).interfaceId) || + (interfaceId == type(IDRFeeMutualizerClient).interfaceId) || + super.supportsInterface(interfaceId); + } + + /** + * @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]; + } + + /** + * @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) { + agreementId = agreementBySellerAndToken[_seller][_token]; + (agreement, status) = getAgreement(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, NATIVE_NOT_ALLOWED); + 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, INSUFFICIENT_VALUE_RECEIVED); + } + } + + /** + * @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. + * + * 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, AgreementStatus storage status) { + require(_agreementId > 0 && _agreementId < agreements.length, INVALID_AGREEMENT); + + status = agreementStatus[_agreementId]; + require(!status.voided, AGREEMENT_VOIDED); + + agreement = agreements[_agreementId]; + require(agreement.endTimestamp > block.timestamp, AGREEMENT_EXPIRED); + } + + modifier onlyProtocol() { + require(msg.sender == protocolAddress, ONLY_PROTOCOL); + _; + } +} diff --git a/contracts/protocol/facets/DisputeResolverHandlerFacet.sol b/contracts/protocol/facets/DisputeResolverHandlerFacet.sol index 662e1039f..04dd5d2b8 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 @@ -416,11 +411,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, @@ -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/contracts/protocol/facets/ExchangeHandlerFacet.sol b/contracts/protocol/facets/ExchangeHandlerFacet.sol index 53c051fa0..dae177c16 100644 --- a/contracts/protocol/facets/ExchangeHandlerFacet.sol +++ b/contracts/protocol/facets/ExchangeHandlerFacet.sol @@ -69,6 +69,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 @@ -107,6 +108,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 @@ -151,6 +153,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 @@ -185,7 +188,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/facets/FundsHandlerFacet.sol b/contracts/protocol/facets/FundsHandlerFacet.sol index 6bf84e23a..d2ba117d8 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); + } } diff --git a/contracts/protocol/facets/OfferHandlerFacet.sol b/contracts/protocol/facets/OfferHandlerFacet.sol index 17699c11a..9d7892433 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/contracts/protocol/libs/FundsLib.sol b/contracts/protocol/libs/FundsLib.sol index 3d390e8b3..d3070e695 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 @@ -42,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. @@ -59,10 +86,11 @@ 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 _buyerId, bool _isPreminted) internal { + function encumberFunds(uint256 _offerId, uint256 _exchangeId, uint256 _buyerId, bool _isPreminted) internal { // Load protocol entities storage ProtocolLib.ProtocolEntities storage pe = ProtocolLib.protocolEntities(); @@ -74,16 +102,31 @@ 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 (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 decreaseAvailableFunds(sellerId, exchangeToken, sellerFundsEncumbered); // notify external observers @@ -135,17 +178,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; @@ -154,26 +197,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]; @@ -181,45 +227,66 @@ 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 + // Handle DR fee address exchangeToken = offer.exchangeToken; - uint256 sellerId = offer.sellerId; - uint256 buyerId = exchange.buyerId; + 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(); - 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); } } @@ -290,6 +357,87 @@ 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 + * + * Emits DRFeeEncumbered event if successful. + * + * 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); + + 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, _feeAmount, "") {} catch {} + + emit DRFeeReturned(_feeMutualizer, uuid, _exchangeId, _exchangeToken, _feeAmount, EIP712Lib.msgSender()); + } + /** * @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 a59072c58..ba6655b99 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 diff --git a/scripts/config/revert-reasons.js b/scripts/config/revert-reasons.js index 17138a460..86c3fe70e 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", @@ -155,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", @@ -169,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", @@ -210,4 +212,17 @@ 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", + AGREEMENT_ALREADY_CONFIRMED: "Agreement already confirmed", + NOT_OWNER_OR_SELLER: "Not owner or seller", }; diff --git a/scripts/config/supported-interfaces.js b/scripts/config/supported-interfaces.js index 2b2cc535a..f0c184abb 100644 --- a/scripts/config/supported-interfaces.js +++ b/scripts/config/supported-interfaces.js @@ -53,6 +53,7 @@ async function getInterfaceIds(useCache = true) { "contracts/interfaces/IERC721.sol:IERC721", "contracts/interfaces/IERC2981.sol:IERC2981", "IAccessControl", + "IDRFeeMutualizerClient", ].forEach((iFace) => { skipBaseCheck[iFace] = false; }); diff --git a/scripts/domain/Agreement.js b/scripts/domain/Agreement.js new file mode 100644 index 000000000..202f0ff6f --- /dev/null +++ b/scripts/domain/Agreement.js @@ -0,0 +1,244 @@ +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; + } + */ + + constructor( + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel + ) { + this.sellerAddress = sellerAddress; + this.token = token; + this.maxMutualizedAmountPerTransaction = maxMutualizedAmountPerTransaction; + this.maxTotalMutualizedAmount = maxTotalMutualizedAmount; + this.premium = premium; + this.startTimestamp = startTimestamp; + this.endTimestamp = endTimestamp; + this.refundOnCancel = refundOnCancel; + } + + /** + * 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, + } = o; + + return new Agreement( + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel + ); + } + + /** + * 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; + + // destructure struct + [ + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + ] = struct; + + return Agreement.fromObject({ + sellerAddress, + token, + maxMutualizedAmountPerTransaction: maxMutualizedAmountPerTransaction.toString(), + maxTotalMutualizedAmount: maxTotalMutualizedAmount.toString(), + premium: premium.toString(), + startTimestamp: startTimestamp.toString(), + endTimestamp: endTimestamp.toString(), + refundOnCancel, + }); + } + + /** + * 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, + ]; + } + + /** + * 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 valid? + * @returns {boolean} + */ + isValid() { + return ( + this.sellerAddressIsValid() && + this.tokenIsValid() && + this.maxMutualizedAmountPerTransactionIsValid() && + this.maxTotalMutualizedAmountIsValid() && + this.premiumIsValid() && + this.startTimestampIsValid() && + this.endTimestampIsValid() && + this.refundOnCancelIsValid() + ); + } +} + +// Export +module.exports = Agreement; 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/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/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/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; diff --git a/test/domain/Agreement.js b/test/domain/Agreement.js new file mode 100644 index 000000000..f472b168d --- /dev/null +++ b/test/domain/Agreement.js @@ -0,0 +1,391 @@ +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; + + 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; + }); + + 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 + ); + 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.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 + ); + 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; + }); + }); + + 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 + ); + expect(agreement.isValid()).is.true; + + // Create plain object + object = { + sellerAddress, + token, + maxMutualizedAmountPerTransaction, + maxTotalMutualizedAmount, + premium, + startTimestamp, + endTimestamp, + refundOnCancel, + }; + }); + + 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, + ]; + + // 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/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/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/domain/OfferTest.js b/test/domain/OfferTest.js index d5e4c3064..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"); @@ -19,7 +18,8 @@ describe("Offer", function () { exchangeToken, metadataUri, metadataHash, - voided; + voided, + feeMutualizer; beforeEach(async function () { // Get a list of accounts @@ -35,6 +35,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 +51,8 @@ describe("Offer", function () { exchangeToken, metadataUri, metadataHash, - voided + voided, + feeMutualizer ); expect(offer.idIsValid()).is.true; expect(offer.sellerIdIsValid()).is.true; @@ -62,6 +64,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 +82,8 @@ describe("Offer", function () { exchangeToken, metadataUri, metadataHash, - voided + voided, + feeMutualizer ); expect(offer.isValid()).is.true; }); @@ -323,6 +327,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 +367,8 @@ describe("Offer", function () { exchangeToken, metadataUri, metadataHash, - voided + voided, + feeMutualizer ); expect(offer.isValid()).is.true; @@ -357,6 +384,7 @@ describe("Offer", function () { metadataUri, metadataHash, voided, + feeMutualizer, }; }); @@ -386,6 +414,7 @@ describe("Offer", function () { offer.metadataUri, offer.metadataHash, offer.voided, + offer.feeMutualizer, ]; // Get struct 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( diff --git a/test/protocol/DisputeResolverHandlerTest.js b/test/protocol/DisputeResolverHandlerTest.js index 7d134b00f..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"), ]; @@ -594,15 +594,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); - }); }); }); @@ -610,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"]; @@ -1379,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); @@ -1405,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); @@ -1515,15 +1501,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); - }); }); }); @@ -1574,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( @@ -1611,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( diff --git a/test/protocol/FundsHandlerTest.js b/test/protocol/FundsHandlerTest.js index 27d3de436..f02fd9270 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 @@ -49,6 +53,7 @@ describe("IBosonFundsHandler", function () { clerkDR, treasuryDR, other, + mutualizerOwner, protocolTreasury; let erc165, accessController, @@ -58,7 +63,8 @@ describe("IBosonFundsHandler", function () { offerHandler, configHandler, disputeHandler, - pauseHandler; + pauseHandler, + orchestrationHandler; let support; let seller; let buyer, offerToken, offerNative; @@ -69,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; @@ -84,18 +97,11 @@ describe("IBosonFundsHandler", function () { let disputedDate, escalatedDate, timeout; let voucherInitValues; let emptyAuthToken; - let agent, - agentId, - agentFeePercentage, - agentFee, - agentPayoff, - agentOffer, - agentOfferProtocolFee, - expectedAgentAvailableFunds, - agentAvailableFunds; - let DRFee, buyerEscalationDeposit; + let agent, agentId, agentFeePercentage, agentFee, agentPayoff, agentOffer; + let DRFeeToken, DRFeeNative, buyerEscalationDeposit; let protocolDiamondAddress; let snapshotId; + let mutualizer; before(async function () { accountId.next(true); @@ -113,10 +119,11 @@ describe("IBosonFundsHandler", function () { configHandler: "IBosonConfigHandler", pauseHandler: "IBosonPauseHandler", disputeHandler: "IBosonDisputeHandler", + orchestrationHandler: "IBosonOrchestrationHandler", }; ({ - signers: [pauser, admin, treasury, rando, buyer, feeCollector, adminDR, treasuryDR, other], + signers: [pauser, admin, treasury, rando, buyer, feeCollector, adminDR, treasuryDR, other, mutualizerOwner], contractInstances: { erc165, accountHandler, @@ -126,6 +133,7 @@ describe("IBosonFundsHandler", function () { configHandler, pauseHandler, disputeHandler, + orchestrationHandler, }, protocolConfig: [, , { percentage: protocolFeePercentage, buyerEscalationDepositPercentage }], diamondAddress: protocolDiamondAddress, @@ -141,6 +149,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(); }); @@ -375,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 @@ -389,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; @@ -411,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 @@ -453,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 = ethers.BigNumber.from(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(); + // buyer: + const pot = BN(offerToken.price).add(offerToken.sellerDeposit).add(buyerEscalationDeposit); + buyerPayoff = applyPercentage(pot, buyerPercentBasisPoints); - // seller: sellerDeposit + buyerCancelPenalty - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit).add(offerToken.buyerCancelPenalty).toString(); + // seller: + sellerPayoff = pot.sub(buyerPayoff).toString(); + + // dispute resolver: + disputeResolverPayoff = DRFeeToken; }); it("should emit a FundsWithdrawn event", async function () { @@ -470,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, 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()]; + tokenAmountsDR = [disputeResolverPayoff, BN(disputeResolverPayoff).div("3").toString()]; // seller withdrawal const tx = await fundsHandler.connect(clerk).withdrawFunds(seller.id, tokenListSeller, tokenAmountsSeller); @@ -483,29 +516,41 @@ 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) + .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( - buyerId, - buyer.address, + disputeResolver.id, + treasuryDR.address, mockToken.address, - ethers.BigNumber.from(buyerPayoff).div("5"), - buyer.address + disputeResolverPayoff, + assistantDR.address ); - await expect(tx2) + await expect(tx3) .to.emit(fundsHandler, "FundsWithdrawn") - .withArgs(buyerId, buyer.address, ethers.constants.Zero, buyerPayoff, buyer.address); + .withArgs( + disputeResolver.id, + treasuryDR.address, + ethers.constants.Zero, + BN(disputeResolverPayoff).div("3"), + assistantDR.address + ); }); it("should update state", async function () { @@ -526,9 +571,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 +583,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 +744,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 +754,7 @@ describe("IBosonFundsHandler", function () { seller.id, treasury.address, mockToken.address, - ethers.BigNumber.from(sellerPayoff).sub(reduction).toString(), + BN(sellerPayoff).sub(reduction).toString(), clerk.address ); @@ -765,7 +808,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); }); @@ -786,9 +829,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 +845,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 +858,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 +874,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 +926,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( @@ -987,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(); @@ -1016,7 +1055,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); @@ -1028,7 +1067,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 +1118,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 +1143,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 +1289,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 +1299,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 +1354,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 +1501,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 +1535,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; @@ -1529,10 +1560,9 @@ describe("IBosonFundsHandler", function () { }); // Agents - // Create a valid agent, - agentId = "3"; - agentFeePercentage = "500"; //5% + // Create a valid agent agent = mockAgent(other.address); + agentFeePercentage = agent.feePercentage; // 5% (default) expect(agent.isValid()).is.true; @@ -1541,7 +1571,6 @@ describe("IBosonFundsHandler", function () { agentOffer = offerToken.clone(); agentOffer.id = "3"; - agentOfferProtocolFee = mo.offerFees.protocolFee; randoBuyerId = "4"; // 1: seller, 2: disputeResolver, 3: agent, 4: rando }); @@ -1552,437 +1581,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); - // get token balance after the commit - const buyerTokenBalanceAfter = await mockToken.balanceOf(buyer.address); - const randoTokenBalanceAfter = await mockToken.balanceOf(rando.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" + ); - // 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"); + // 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 }); - // 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"); - }); + // Seller available funds must be empty + sellersAvailableFunds = FundsList.fromStruct(await fundsHandler.getAvailableFunds(seller.id)); + expect(sellersAvailableFunds.funds.length).to.eql(0, "Funds length mismatch"); + }); - 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}`, - }); + 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"), + ]); - // 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" - ); + // top up seller's and buyer's account + await otherToken.mint(assistant.address, 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" - ); + // approve protocol to transfer the tokens + await otherToken.connect(assistant).approve(protocolDiamondAddress, 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"); - }); + // deposit to seller's pool + await fundsHandler.connect(assistant).depositFunds(seller.id, otherToken.address, sellerDeposit); - 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); - }); + // 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" + ); - 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); + // 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 }); + + // 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" ); - }); - - it("Token address is not a contract", async function () { - // create an offer with a bad token contrat - offerToken.exchangeToken = admin.address; - offerToken.id = "3"; + 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"); - // 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.revertedWithoutReason(); - }); + // 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 +1860,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); + + // 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" + ); - // reserve a range and premint vouchers for offer in native currency + // 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,2307 +1903,1159 @@ 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); - }); - - 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 - DRFee = ethers.utils.parseUnits("0", "ether").toString(); - await accountHandler - .connect(adminDR) - .addFeesToDisputeResolver(disputeResolverId, [ - new DisputeResolverFee(Foreign20WithFee.address, "Foreign20WithFee", DRFee), - ]); - - // 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); + // commit to an offer via preminted voucher + tx = await bosonVoucher.connect(assistant).transferFrom(assistant.address, buyer.address, tokenId); - // Attempt to commit to offer, expecting revert - await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( - RevertReasons.INSUFFICIENT_VALUE_RECEIVED + // 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"); }); - }); - }); - context("👉 releaseFunds()", async function () { - beforeEach(async function () { - // ids - protocolId = "0"; - buyerId = "4"; - exchangeId = "1"; + 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); + }); - // commit to offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - }); + 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); + }); - context("Final state COMPLETED", async function () { - beforeEach(async function () { - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + it("Token address contract does not support transferFrom", async function () { + // Deploy a contract without the transferFrom + [bosonToken] = await deployMockTokens(["BosonToken"]); - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // create an offer with a bad token contrat + offerToken.exchangeToken = bosonToken.address; + offerToken.id = "3"; - // expected payoffs - // buyer: 0 - buyerPayoff = 0; + // 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); - // seller: sellerDeposit + price - protocolFee - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .toString(); + // 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 + ); + }); - // protocol: protocolFee - protocolPayoff = offerTokenProtocolFee; - }); + it("Token address is not a contract", async function () { + // create an offer with a bad token contrat + offerToken.exchangeToken = admin.address; + offerToken.id = "3"; - it("should emit a FundsReleased event", async function () { - // Complete the exchange, expecting event - const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + // add to DR fees + await accountHandler + .connect(adminDR) + .addFeesToDisputeResolver(disputeResolver.id, [ + new DisputeResolverFee(offerToken.exchangeToken, "NotAContract", "0"), + ]); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + await offerHandler + .connect(assistant) + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); - }); + // Attempt to commit to an offer, expecting revert + await expect( + exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id) + ).to.revertedWithoutReason(); + }); - it("should update state", async function () { - // commit again, so seller has nothing in available funds - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); + 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 + ); - // 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)); + // 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 + ); + }); - // 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); + 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); - // Complete the exchange so the funds are released - await exchangeHandler.connect(buyer).completeExchange(exchangeId); + // Attempt to commit to an offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.INSUFFICIENT_AVAILABLE_FUNDS + ); - // 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); + // 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); - // 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); + // 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); + }); - 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", - ethers.BigNumber.from(sellerPayoff).mul(2).toString() - ); - expectedProtocolAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - ethers.BigNumber.from(protocolPayoff).mul(2).toString() - ); - expect(sellersAvailableFunds).to.eql(expectedSellerAvailableFunds); - expect(buyerAvailableFunds).to.eql(expectedBuyerAvailableFunds); - expect(protocolAvailableFunds).to.eql(expectedProtocolAvailableFunds); - expect(agentAvailableFunds).to.eql(expectedAgentAvailableFunds); - }); + 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); - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer + // reserve a range and premint vouchers for offer in native currency + exchangeId = await exchangeHandler.getNextExchangeId(); + tokenId = deriveTokenId(offerNative.id, exchangeId); await offerHandler .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + .reserveRange(offerNative.id, offerNative.quantityAvailable, assistant.address); + await bosonVoucher.connect(assistant).preMint(offerNative.id, offerNative.quantityAvailable); - // Commit to Offer - await exchangeHandler.connect(buyer).commitToOffer(buyer.address, agentOffer.id); + // 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); + }); - // succesfully redeem exchange - exchangeId = "2"; - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + it("Received ERC20 token amount differs from the expected value", async function () { + // Deploy ERC20 with fees + const [Foreign20WithFee] = await deployMockTokens(["Foreign20WithFee"]); - // expected payoffs - // buyer: 0 - buyerPayoff = 0; + // 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++; - // agentPayoff: agentFee - agentFee = ethers.BigNumber.from(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; + // Create a new offer + await offerHandler + .connect(assistant) + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId); - // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = ethers.BigNumber.from(agentOffer.sellerDeposit) - .add(agentOffer.price) - .sub(agentOfferProtocolFee) - .sub(agentFee) - .toString(); + // mint tokens and approve + await Foreign20WithFee.mint(buyer.address, offerToken.price); + await Foreign20WithFee.connect(buyer).approve(protocolDiamondAddress, offerToken.price); - // protocol: protocolFee - protocolPayoff = agentOfferProtocolFee; + // Attempt to commit to offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.INSUFFICIENT_VALUE_RECEIVED + ); }); + }); + }); - it("should emit a FundsReleased event", async function () { - // Complete the exchange, expecting event - const tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + context("External mutualizer", async function () { + beforeEach(async function () { + offerNative.feeMutualizer = offerToken.feeMutualizer = mutualizer.address; - // Complete the exchange, expecting event - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.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), + ]); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); + // Seller must deposit enough to cover DR fees + const poolToken = BN(DRFeeToken).mul(2); + const poolNative = BN(DRFeeNative).mul(2); + await mockToken.mint(mutualizerOwner.address, poolToken); - 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); - - // 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: 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(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); + await mutualizer.connect(mutualizerOwner).deposit(ethers.constants.AddressZero, poolNative, { + value: poolNative, }); - }); - }); - - context("Final state REVOKED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: sellerDeposit + price - buyerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit).add(offerToken.price).toString(); - - // seller: 0 - sellerPayoff = 0; - - // protocol: 0 - protocolPayoff = 0; - }); - - 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); - }); - - 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); - - // 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); - - // 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( + // Create new agreements + 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, mockToken.address, - "Foreign20", - ethers.BigNumber.from(buyerPayoff).mul(2).toString() + ethers.utils.parseUnits("1", "ether"), + ethers.utils.parseUnits("1", "ether"), + "0", + startTimestamp.toString(), + endTimestamp.toString(), + false, + false ); - expectedSellerAvailableFunds = new FundsList([ - new Funds(ethers.constants.AddressZero, "Native currency", `${2 * sellerDeposit}`), + const agreementNative = agreementToken.clone(); + agreementNative.token = ethers.constants.AddressZero; + await Promise.all([ + mutualizer.connect(mutualizerOwner).newAgreement(agreementToken), + mutualizer.connect(mutualizerOwner).newAgreement(agreementNative), ]); - 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); - - // 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 = ethers.BigNumber.from(agentOffer.sellerDeposit).add(agentOffer.price).toString(); - - // seller: 0 - sellerPayoff = 0; - - // protocol: 0 - protocolPayoff = 0; - // agent: 0 - agentPayoff = 0; + // Confirm agreements + const agreementIdToken = "1"; + const agreementIdNative = "2"; - 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", - ethers.BigNumber.from(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); - }); + await Promise.all([ + mutualizer.connect(assistant).payPremium(agreementIdToken), + mutualizer.connect(assistant).payPremium(agreementIdNative), + ]); }); - }); - - context("Final state CANCELED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: price - buyerCancelPenalty - buyerPayoff = ethers.BigNumber.from(offerToken.price).sub(offerToken.buyerCancelPenalty).toString(); - // seller: sellerDeposit + buyerCancelPenalty - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit).add(offerToken.buyerCancelPenalty).toString(); + it("should emit a FundsEncumbered event", async function () { + let buyerId = "4"; // 1: seller, 2: disputeResolver, 3: agent, 4: buyer - // protocol: 0 - protocolPayoff = 0; - }); - - it("should emit a FundsReleased event", async function () { - // Cancel the voucher, expecting event - const tx = await exchangeHandler.connect(buyer).cancelVoucher(exchangeId); + // 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, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.address); + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(buyerId, mockToken.address, price, 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"); - }); - - 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", - ethers.BigNumber.from(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("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 = ethers.BigNumber.from(agentOffer.price).sub(agentOffer.buyerCancelPenalty).toString(); - - // seller: sellerDeposit + buyerCancelPenalty - sellerPayoff = ethers.BigNumber.from(agentOffer.sellerDeposit) - .add(agentOffer.buyerCancelPenalty) - .toString(); - - // protocol: 0 - protocolPayoff = 0; - - // agent: 0 - agentPayoff = 0; + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(seller.id, mockToken.address, sellerDeposit, buyer.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)); - - // 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", - ethers.BigNumber.from(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)); + await expect(tx) + .to.emit(exchangeHandler, "DRFeeEncumbered") + .withArgs(mutualizer.address, anyValue, "1", mockToken.address, DRFeeToken, buyer.address); - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // 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); - // raise the dispute - tx = await disputeHandler.connect(buyer).raiseDispute(exchangeId); + await expect(tx2) + .to.emit(exchangeHandler, "FundsEncumbered") + .withArgs(seller.id, ethers.constants.AddressZero, sellerDeposit, buyer.address); - // 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 = ethers.BigNumber.from(disputedDate).add(resolutionPeriod).toString(); + await expect(tx2) + .to.emit(exchangeHandler, "DRFeeEncumbered") + .withArgs(mutualizer.address, anyValue, "2", ethers.constants.AddressZero, DRFeeNative, buyer.address); }); - context("Final state DISPUTED - RETRACTED", async function () { - beforeEach(async function () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // seller: sellerDeposit + price - protocolFee - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .toString(); - - // protocol: 0 - protocolPayoff = offerTokenProtocolFee; - }); - - 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; note that seller has sellerDeposit in availableFunds from before - // protocol: protocolFee - // agent: 0 - expectedSellerAvailableFunds.funds[0] = new Funds( - mockToken.address, - "Foreign20", - ethers.BigNumber.from(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 () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // agentPayoff: agentFee - agentFee = ethers.BigNumber.from(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = ethers.BigNumber.from(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); - - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - - // raise the dispute - await disputeHandler.connect(buyer).raiseDispute(exchangeId); - }); + 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), + ]); - it("should emit a FundsReleased event", async function () { - // Retract from the dispute, expecting event - const tx = await disputeHandler.connect(buyer).retractDispute(exchangeId); + // Commit to an offer with erc20 token + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); - await expect(tx) - .to.emit(disputeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, protocolPayoff, buyer.address); + // 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" + ); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, buyer.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 DRFee + expect( + BN(sellersAvailableFundsBefore.funds[0].availableAmount) + .sub(BN(sellersAvailableFundsAfter.funds[0].availableAmount)) + .toString() + ).to.eql(sellerDeposit, "Token seller available funds mismatch"); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); - }); + // Commit to an offer with native currency + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerNative.id, { value: price }); - 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)); + // 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" + ); - // 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", ethers.BigNumber.from(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); - }); - }); + // 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("Final state DISPUTED - RETRACTED via expireDispute", async function () { + context("💔 Revert Reasons", async function () { + const Mode = { Revert: 0, Decline: 1, SendLess: 2 }; + let mockMutualizer; beforeEach(async function () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // seller: sellerDeposit + price - protocolFee - sellerPayoff = ethers.BigNumber.from(offerToken.sellerDeposit) - .add(offerToken.price) - .sub(offerTokenProtocolFee) - .toString(); + // Deploy mock mutualizer and set it to the offer + const mockMutualizerFactory = await ethers.getContractFactory("MockDRFeeMutualizer"); + mockMutualizer = await mockMutualizerFactory.deploy(); - // protocol: protocolFee - protocolPayoff = offerTokenProtocolFee; - - await setNextBlockTimestamp(Number(timeout)); + await offerHandler.connect(assistant).changeOfferMutualizer(offerToken.id, mockMutualizer.address); }); - 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; - }); + it("Mutualizer contract reverts on the call", async function () { + await mockMutualizer.setMode(Mode.Revert); - 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", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + // Attempt to commit to offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.MUTUALIZER_REVERT ); - 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 = ethers.BigNumber.from(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agent fee - sellerPayoff = ethers.BigNumber.from(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 = ethers.BigNumber.from(disputedDate).add(resolutionPeriod).toString(); + it("Mutualizer contract declines the request", async function () { + await mockMutualizer.setMode(Mode.Decline); - 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 = ethers.BigNumber.from(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(); - - // 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", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + // Attempt to commit to offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.SELLER_NOT_COVERED ); - 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 = ethers.BigNumber.from(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(); - - // 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", ethers.BigNumber.from(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 = ethers.BigNumber.from(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); + it("Mutualizer contract sends less than requested - ERC20", async function () { + await mockMutualizer.setMode(Mode.SendLess); + await mockToken.mint(mockMutualizer.address, DRFeeToken); - // 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", - ethers.BigNumber.from(sellerDeposit).add(sellerPayoff).toString() + // Attempt to commit to offer, expecting revert + await expect(exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id)).to.revertedWith( + RevertReasons.DR_FEE_NOT_RECEIVED ); - 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 () { - // expected payoffs - // buyer: 0 - buyerPayoff = 0; - - // agentPayoff: agentFee - agentFee = ethers.BigNumber.from(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee + buyerEscalationDeposit - sellerPayoff = ethers.BigNumber.from(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("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 }); - 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", ethers.BigNumber.from(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); - }); + // 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); }); }); + }); + }); - context("Final state DISPUTED - ESCALATED - RESOLVED", async function () { - beforeEach(async function () { - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = ethers.BigNumber.from(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = ethers.BigNumber.from(offerToken.price) - .add(offerToken.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 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"); - }); + let DRFeeToSeller, DRFeeToMutualizer; - 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 + 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", - ethers.BigNumber.from(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); - }); - - context("Offer has an agent", async function () { + ["self-mutualized", "external-mutualizer"].forEach((mutualizationType) => { + context(`👉 releaseFunds() [${mutualizationType}]`, async function () { + ["no-agent", "with-agent"].forEach((agentType) => { + context(`👉 ${agentType}`, 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); - - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = ethers.BigNumber.from(agentOffer.price) - .add(agentOffer.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = ethers.BigNumber.from(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); - }); - - 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", ethers.BigNumber.from(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); - }); - }); - }); + // ids + protocolId = "0"; + buyerId = "4"; + exchangeId = "1"; - context("Final state DISPUTED - ESCALATED - DECIDED", async function () { - beforeEach(async function () { - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = ethers.BigNumber.from(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = ethers.BigNumber.from(offerToken.price) - .add(offerToken.sellerDeposit) - .add(buyerEscalationDeposit) - .sub(buyerPayoff) - .toString(); - - // protocol: 0 - protocolPayoff = 0; - - // escalate the dispute - await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); + // Amounts that are returned if DR is not involved + if (mutualizationType === "self-mutualized") { + DRFeeToSeller = DRFeeToken; + DRFeeToMutualizer = "0"; + offerToken.feeMutualizer = ethers.constants.AddressZero; - 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); + // Seller must deposit enough to cover DR fees + const sellerPoolToken = BN(DRFeeToken).mul(2); + await mockToken.mint(assistant.address, sellerPoolToken); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); + // approve protocol to transfer the tokens + await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerPoolToken); - await expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); - }); + // deposit to seller's pool + await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerPoolToken); + } else { + DRFeeToSeller = "0"; + DRFeeToMutualizer = DRFeeToken; + offerToken.feeMutualizer = mutualizer.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(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); - - // 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", - ethers.BigNumber.from(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); - }); + // Seller must deposit enough to cover DR fees + const poolToken = BN(DRFeeToken).mul(2); + await mockToken.mint(mutualizerOwner.address, poolToken); - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer + // 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 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, + 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"; + agentId = agentType === "no-agent" ? "0" : agent.id; 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); - - // 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 = ethers.BigNumber.from(disputedDate).add(resolutionPeriod).toString(); - - buyerPercentBasisPoints = "5566"; // 55.66% - - // expected payoffs - // buyer: (price + sellerDeposit + buyerEscalationDeposit)*buyerPercentage - buyerPayoff = ethers.BigNumber.from(agentOffer.price) - .add(agentOffer.sellerDeposit) - .add(buyerEscalationDeposit) - .mul(buyerPercentBasisPoints) - .div("10000") - .toString(); - - // seller: (price + sellerDeposit + buyerEscalationDeposit)*(1-buyerPercentage) - sellerPayoff = ethers.BigNumber.from(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", ethers.BigNumber.from(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 = ethers.BigNumber.from(offerToken.price).add(buyerEscalationDeposit).toString(); - - // seller: sellerDeposit - sellerPayoff = offerToken.sellerDeposit; - - // protocol: 0 - protocolPayoff = 0; - - // 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)); + .createOffer(offerToken, offerDates, offerDurations, disputeResolverId, agentId), + // commit to offer + await exchangeHandler.connect(buyer).commitToOffer(buyer.address, offerToken.id); }); - 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 () { - // 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 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", - ethers.BigNumber.from(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); - }); + 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)", + ]; - context("Offer has an agent", async function () { - beforeEach(async function () { - // Create Agent offer - await offerHandler - .connect(assistant) - .createOffer(agentOffer, offerDates, offerDurations, disputeResolverId, agent.id); + // 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)); - // succesfully redeem exchange + // 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 = ethers.BigNumber.from(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 + // Get the block timestamp of the confirmed tx and set disputedDate blockNumber = tx.blockNumber; block = await ethers.provider.getBlock(blockNumber); - escalatedDate = block.timestamp.toString(); + disputedDate = block.timestamp.toString(); + timeout = BN(disputedDate).add(resolutionPeriod).toString(); - await setNextBlockTimestamp(Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod)); - }); + await setNextBlockTimestamp(Number(timeout)); + }, + "DISPUTED - ESCALATED": async function () { + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - 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", ethers.BigNumber.from(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 = ethers.BigNumber.from(offerToken.price).add(buyerEscalationDeposit).toString(); + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - // seller: sellerDeposit - sellerPayoff = offerToken.sellerDeposit; + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); - // protocol: 0 - protocolPayoff = 0; + // 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)); - // Escalate the dispute - tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); - }); + // successfully 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); + // raise the dispute + await disputeHandler.connect(buyer).raiseDispute(exchangeId); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, offerToken.exchangeToken, sellerPayoff, assistantDR.address); + // Escalate the dispute + tx = await disputeHandler.connect(buyer).escalateDispute(exchangeId); - await expect(tx) - .to.emit(disputeHandler, "FundsReleased") - .withArgs(exchangeId, buyerId, offerToken.exchangeToken, buyerPayoff, assistantDR.address); + // 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 expect(tx).to.not.emit(disputeHandler, "ProtocolFeeCollected"); + await setNextBlockTimestamp(Number(escalatedDate) + Number(disputeResolver.escalationResponsePeriod)); + }, + }; - //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, + 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"]; + + 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), ]); - 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 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", - ethers.BigNumber.from(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); + return { availableFunds, mutualizerTokenBalance }; + } + + 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": + 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, + }; + 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" : applyPercentage(offerToken.price, agentFeePercentage); + + 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 pot = BN(offerToken.price).add(offerToken.sellerDeposit).add(buyerEscalationDeposit); + const buyerPayoffSplit = applyPercentage(pot, buyerPercentBasisPoints); + + payoffs = { + buyer: buyerPayoffSplit, + seller: pot.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.agent, + 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, + anyValue, + exchangeId, + offerToken.exchangeToken, + payoffs.mutualizer, + caller.address + ); + } + }); + + 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)); + }); + }); }); - 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); + context("special cases", function () { + let payoffs; + beforeEach(async function () { // expected payoffs - // buyer: price + buyerEscalationDeposit - buyerPayoff = ethers.BigNumber.from(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); + 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, + }; }); - it("should update state", async function () { + it("No new entry is created when multiple exchanges are finalized", 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", ethers.BigNumber.from(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("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; - - // seller: sellerDeposit + price - protocolFee - sellerPayoff = ethers.BigNumber.from(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); + expectedAvailableFunds.buyer = new FundsList([]); + expectedAvailableFunds.protocol = new FundsList([]); + expectedAvailableFunds.agent = new FundsList([]); + expectedAvailableFunds.disputeResolver = new FundsList([]); - // 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, offerToken.exchangeToken, sellerPayoff, buyer.address); - - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, offerToken.exchangeToken, offerTokenProtocolFee, buyer.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); - - // 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, 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)); + for (let [key, value] of Object.entries(expectedAvailableFunds)) { + expect(FundsList.fromStruct(availableFunds[key])).to.eql(value, `${key} mismatch`); + } - // succesfully 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, sellerPayoff, 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 = ethers.BigNumber.from(agentOffer.price).mul(agentFeePercentage).div("10000").toString(); - agentPayoff = agentFee; - - // seller: sellerDeposit + price - protocolFee - agentFee - sellerPayoff = ethers.BigNumber.from(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); + // 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])); + } + } + } - // approve protocol to transfer the tokens - await mockToken.connect(assistant).approve(protocolDiamondAddress, sellerDeposit); - await mockToken.connect(buyer).approve(protocolDiamondAddress, price); + // 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])); + } + } + } + // 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(); - // deposit to seller's pool - await fundsHandler.connect(assistant).depositFunds(seller.id, mockToken.address, sellerDeposit); + // Read on chain state + ({ availableFunds, mutualizerTokenBalance: mutualizerTokenBalanceAfter } = + await getAllAvailableFunds()); - // 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(); + 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)); + }); - // Set time forward to the offer's voucherRedeemableFrom - await setNextBlockTimestamp(Number(voucherRedeemableFrom)); + it("Changing the mutualizer", async function () { + let newMutualizer; + if (mutualizationType === "self-mutualized") { + newMutualizer = mutualizer.address; + } else { + newMutualizer = ethers.constants.AddressZero; + } - // succesfully redeem exchange - await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + // Change the mutualizer + await offerHandler.connect(assistant).changeOfferMutualizer(offerToken.id, newMutualizer); - // Complete the exchange, expecting event - tx = await exchangeHandler.connect(buyer).completeExchange(exchangeId); + // Set time forward to the offer's voucherRedeemableFrom + await setNextBlockTimestamp(Number(voucherRedeemableFrom)); - // Complete the exchange, expecting event - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, seller.id, agentOffer.exchangeToken, sellerPayoff, buyer.address); + // successfully redeem exchange + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); - await expect(tx) - .to.emit(exchangeHandler, "ProtocolFeeCollected") - .withArgs(exchangeId, agentOffer.exchangeToken, protocolPayoff, buyer.address); + // 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); + + // 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, + anyValue, + exchangeId, + offerToken.exchangeToken, + payoffs.mutualizer, + buyer.address + ); + } + }); - await expect(tx) - .to.emit(exchangeHandler, "FundsReleased") - .withArgs(exchangeId, agentId, agentOffer.exchangeToken, agentPayoff, buyer.address); + 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); + + // 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, + 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 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 + + // 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)); + + // 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); + + 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"); + }); + }); + }); }); }); }); diff --git a/test/protocol/MetaTransactionsHandlerTest.js b/test/protocol/MetaTransactionsHandlerTest.js index 7b0ca1ce6..2a28dacda 100644 --- a/test/protocol/MetaTransactionsHandlerTest.js +++ b/test/protocol/MetaTransactionsHandlerTest.js @@ -3099,7 +3099,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 31b8858c0..98dd287c9 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), @@ -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(); @@ -1275,7 +1284,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 }); @@ -1499,6 +1508,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 @@ -1767,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()); @@ -1787,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(); @@ -1800,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(); }); @@ -2976,5 +3078,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); + }); + }); + }); }); }); 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 2e2d14e80..c05ad530f 100644 --- a/test/protocol/clients/BosonVoucherTest.js +++ b/test/protocol/clients/BosonVoucherTest.js @@ -273,7 +273,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, @@ -312,7 +312,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, @@ -396,7 +396,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, @@ -654,7 +654,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) @@ -997,7 +997,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, @@ -1096,7 +1096,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, @@ -1166,7 +1166,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, diff --git a/test/protocol/clients/DRFeeMutualizerTest.js b/test/protocol/clients/DRFeeMutualizerTest.js new file mode 100644 index 000000000..9056bd9fd --- /dev/null +++ b/test/protocol/clients/DRFeeMutualizerTest.js @@ -0,0 +1,1462 @@ +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"); +const { getSnapshot, revertToSnapshot, setNextBlockTimestamp } = require("../../util/utils.js"); +const { oneMonth } = require("../../util/constants"); +const { deployMockTokens } = require("../../../scripts/util/deploy-mock-tokens"); + +describe("IDRFeeMutualizer + IDRFeeMutualizerClient", 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(); + }); + + 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 and IDRFeeMutualizerClient", async function () { + // "ERC165" interface + let support = await mutualizer.supportsInterface(interfaceIds["IERC165"]); + expect(support, "IERC165 interface not supported").is.true; + + // IDRFeeMutualizer interface + 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 client 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("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)) + .to.emit(mutualizer, "AgreementCreated") + .withArgs(assistant.address, "1", agreement.toStruct()); + }); + + 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, 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("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("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.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( + RevertReasons.INVALID_AGREEMENT + ); + }); + }); + }); + + context("👉 payPremium()", function () { + let agreementId; + + context("💰 Native Token", function () { + 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 () { + 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, 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); + }); + + 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("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 }); + + // Create a new agreement for the same seller and token + 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, + 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, 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", 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 + ); + }); + }); + }); + + context("💰 ERC20 tokens", function () { + beforeEach(async function () { + agreement.token = foreign20.address; + await mutualizer.connect(mutualizerOwner).newAgreement(agreement); + agreementId = "1"; + + await foreign20.connect(assistant).mint(assistant.address, agreement.premium); + await foreign20.connect(assistant).approve(mutualizer.address, agreement.premium); + }); + + 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); + }); + + 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.revertedWithoutReason(); + }); + }); + }); + }); + + 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 [, returnedStatus] = await mutualizer.getAgreement("1"); + + // Values should match + expect(returnedStatus.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 + ); + }); + }); + }); + + 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.revertedWithoutReason(); + }); + }); + }); + + 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.revertedWithoutReason(); + }); + + 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 - ERC20 returns false", 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); + }); + }); + + 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); + }); + }); + }); + }); + + context("📋 DRMutualizer protocol methods", async function () { + let agreement; + + 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, + 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.revertedWithoutReason(); + }); + + 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); + }); + }); + }); + }); + + 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.revertedWithoutReason(); + }); + }); + }); + }); + + 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; + }); + }); + }); +}); diff --git a/test/util/mock.js b/test/util/mock.js index fdf913b81..3a13a0d17 100644 --- a/test/util/mock.js +++ b/test/util/mock.js @@ -77,6 +77,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( @@ -89,7 +90,8 @@ async function mockOffer() { exchangeToken, metadataUri, metadataHash, - voided + voided, + feeMutualizer ); const offerDates = await mockOfferDates(); 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) {