diff --git a/contracts/interfaces/clients/OptimisticOracleV3CallbackRecipientInterface.sol b/contracts/interfaces/clients/OptimisticOracleV3CallbackRecipientInterface.sol new file mode 100644 index 000000000..a058a7298 --- /dev/null +++ b/contracts/interfaces/clients/OptimisticOracleV3CallbackRecipientInterface.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.22; + +/** + * @title OptimisticOracleV3CallbackRecipientInterface + * @notice Interface for contracts implementing callbacks to be received from the Optimistic Oracle V3 + * + * The ERC-165 identifier for this interface is: 0x25f9f25e + */ +interface OptimisticOracleV3CallbackRecipientInterface { + /** + * @notice Callback function that is called by Optimistic Oracle V3 when an assertion is resolved. + * @param assertionId The identifier of the assertion that was resolved. + * @param assertedTruthfully Whether the assertion was resolved as truthful or not. + */ + function assertionResolvedCallback(bytes32 assertionId, bool assertedTruthfully) external; + + /** + * @notice Callback function that is called by Optimistic Oracle V3 when an assertion is disputed. + * @param assertionId The identifier of the assertion that was disputed. + */ + function assertionDisputedCallback(bytes32 assertionId) external; +} diff --git a/contracts/interfaces/clients/OptimisticOracleV3Interface.sol b/contracts/interfaces/clients/OptimisticOracleV3Interface.sol new file mode 100644 index 000000000..7489ca27c --- /dev/null +++ b/contracts/interfaces/clients/OptimisticOracleV3Interface.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.22; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +/** + * @title OptimisticOracleV3Interface + * @notice Optimistic Oracle V3 Interface that callers must use to assert truths about the world + * + * The ERC-165 identifier for this interface is: 0x6e6309de + */ +interface OptimisticOracleV3Interface { + // Struct grouping together the settings related to the escalation manager stored in the assertion. + struct EscalationManagerSettings { + bool arbitrateViaEscalationManager; // False if the DVM is used as an oracle (EscalationManager on True). + bool discardOracle; // False if Oracle result is used for resolving assertion after dispute. + bool validateDisputers; // True if the EM isDisputeAllowed should be checked on disputes. + address assertingCaller; // Stores msg.sender when assertion was made. + address escalationManager; // Address of the escalation manager (zero address if not configured). + } + + // Struct for storing properties and lifecycle of an assertion. + struct Assertion { + EscalationManagerSettings escalationManagerSettings; // Settings related to the escalation manager. + address asserter; // Address of the asserter. + uint64 assertionTime; // Time of the assertion. + bool settled; // True if the request is settled. + IERC20 currency; // ERC20 token used to pay rewards and fees. + uint64 expirationTime; // Unix timestamp marking threshold when the assertion can no longer be disputed. + bool settlementResolution; // Resolution of the assertion (false till resolved). + bytes32 domainId; // Optional domain that can be used to relate the assertion to others in the escalationManager. + bytes32 identifier; // UMA DVM identifier to use for price requests in the event of a dispute. + uint256 bond; // Amount of currency that the asserter has bonded. + address callbackRecipient; // Address that receives the callback. + address disputer; // Address of the disputer. + } + + // Struct for storing cached currency whitelist. + struct WhitelistedCurrency { + bool isWhitelisted; // True if the currency is whitelisted. + uint256 finalFee; // Final fee of the currency. + } + + /** + * @notice Disputes an assertion. Depending on how the assertion was configured, this may either escalate to the UMA + * DVM or the configured escalation manager for arbitration. + * @dev The caller must approve this contract to spend at least bond amount of currency for the associated assertion. + * @param assertionId unique identifier for the assertion to dispute. + * @param disputer receives bonds back at settlement. + */ + function disputeAssertion(bytes32 assertionId, address disputer) external; + + /** + * @notice Returns the default identifier used by the Optimistic Oracle V3. + * @return The default identifier. + */ + function defaultIdentifier() external view returns (bytes32); + + /** + * @notice Fetches information about a specific assertion and returns it. + * @param assertionId unique identifier for the assertion to fetch information for. + * @return assertion information about the assertion. + */ + function getAssertion(bytes32 assertionId) external view returns (Assertion memory); + + /** + * @notice Asserts a truth about the world, using the default currency and liveness. No callback recipient or + * escalation manager is enabled. The caller is expected to provide a bond of finalFee/burnedBondPercentage + * (with burnedBondPercentage set to 50%, the bond is 2x final fee) of the default currency. + * @dev The caller must approve this contract to spend at least the result of getMinimumBond(defaultCurrency). + * @param claim the truth claim being asserted. This is an assertion about the world, and is verified by disputers. + * @param asserter receives bonds back at settlement. This could be msg.sender or + * any other account that the caller wants to receive the bond at settlement time. + * @return assertionId unique identifier for this assertion. + */ + function assertTruthWithDefaults(bytes memory claim, address asserter) external returns (bytes32); + + /** + * @notice Asserts a truth about the world, using a fully custom configuration. + * @dev The caller must approve this contract to spend at least bond amount of currency. + * @param claim the truth claim being asserted. This is an assertion about the world, and is verified by disputers. + * @param asserter receives bonds back at settlement. This could be msg.sender or + * any other account that the caller wants to receive the bond at settlement time. + * @param callbackRecipient if configured, this address will receive a function call assertionResolvedCallback and + * assertionDisputedCallback at resolution or dispute respectively. Enables dynamic responses to these events. The + * recipient _must_ implement these callbacks and not revert or the assertion resolution will be blocked. + * @param escalationManager if configured, this address will control escalation properties of the assertion. This + * means a) choosing to arbitrate via the UMA DVM, b) choosing to discard assertions on dispute, or choosing to + * validate disputes. Combining these, the asserter can define their own security properties for the assertion. + * escalationManager also _must_ implement the same callbacks as callbackRecipient. + * @param liveness time to wait before the assertion can be resolved. Assertion can be disputed in this time. + * @param currency bond currency pulled from the caller and held in escrow until the assertion is resolved. + * @param bond amount of currency to pull from the caller and hold in escrow until the assertion is resolved. This + * must be >= getMinimumBond(address(currency)). + * @param identifier UMA DVM identifier to use for price requests in the event of a dispute. Must be pre-approved. + * @param domainId optional domain that can be used to relate this assertion to others in the escalationManager and + * can be used by the configured escalationManager to define custom behavior for groups of assertions. This is + * typically used for "escalation games" by changing bonds or other assertion properties based on the other + * assertions that have come before. If not needed this value should be 0 to save gas. + * @return assertionId unique identifier for this assertion. + */ + function assertTruth( + bytes memory claim, + address asserter, + address callbackRecipient, + address escalationManager, + uint64 liveness, + IERC20 currency, + uint256 bond, + bytes32 identifier, + bytes32 domainId + ) external returns (bytes32); + + /** + * @notice Fetches information about a specific identifier & currency from the UMA contracts and stores a local copy + * of the information within this contract. This is used to save gas when making assertions as we can avoid an + * external call to the UMA contracts to fetch this. + * @param identifier identifier to fetch information for and store locally. + * @param currency currency to fetch information for and store locally. + */ + function syncUmaParams(bytes32 identifier, address currency) external; + + /** + * @notice Resolves an assertion. If the assertion has not been disputed, the assertion is resolved as true and the + * asserter receives the bond. If the assertion has been disputed, the assertion is resolved depending on the oracle + * result. Based on the result, the asserter or disputer receives the bond. If the assertion was disputed then an + * amount of the bond is sent to the UMA Store as an oracle fee based on the burnedBondPercentage. The remainder of + * the bond is returned to the asserter or disputer. + * @param assertionId unique identifier for the assertion to resolve. + */ + function settleAssertion(bytes32 assertionId) external; + + /** + * @notice Settles an assertion and returns the resolution. + * @param assertionId unique identifier for the assertion to resolve and return the resolution for. + * @return resolution of the assertion. + */ + function settleAndGetAssertionResult(bytes32 assertionId) external returns (bool); + + /** + * @notice Fetches the resolution of a specific assertion and returns it. If the assertion has not been settled then + * this will revert. If the assertion was disputed and configured to discard the oracle resolution return false. + * @param assertionId unique identifier for the assertion to fetch the resolution for. + * @return resolution of the assertion. + */ + function getAssertionResult(bytes32 assertionId) external view returns (bool); + + /** + * @notice Returns the minimum bond amount required to make an assertion. This is calculated as the final fee of the + * currency divided by the burnedBondPercentage. If burn percentage is 50% then the min bond is 2x the final fee. + * @param currency currency to calculate the minimum bond for. + * @return minimum bond amount. + */ + function getMinimumBond(address currency) external view returns (uint256); + + event AssertionMade( + bytes32 indexed assertionId, + bytes32 domainId, + bytes claim, + address indexed asserter, + address callbackRecipient, + address escalationManager, + address caller, + uint64 expirationTime, + IERC20 currency, + uint256 bond, + bytes32 indexed identifier + ); + + event AssertionDisputed(bytes32 indexed assertionId, address indexed caller, address indexed disputer); + + event AssertionSettled( + bytes32 indexed assertionId, + address indexed bondRecipient, + bool disputed, + bool settlementResolution, + address settleCaller + ); + + event AdminPropertiesSet(IERC20 defaultCurrency, uint64 defaultLiveness, uint256 burnedBondPercentage); +} diff --git a/contracts/mock/MockOptimisticOracleV3.sol b/contracts/mock/MockOptimisticOracleV3.sol new file mode 100644 index 000000000..fc79a7f4b --- /dev/null +++ b/contracts/mock/MockOptimisticOracleV3.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.22; + +import { + OptimisticOracleV3CallbackRecipientInterface +} from "../interfaces/clients/OptimisticOracleV3CallbackRecipientInterface.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title MockOptimisticOracleV3 + * @notice Simplified mock implementation of UMA's OptimisticOracleV3 for testing UMADisputeResolverAdapter + * @dev This mock only implements the minimal functionality needed for testing: + * - Simple bond management (getter/setter) + * - Basic assertTruth that returns predictable IDs + * - Direct callback triggers for testing adapter callbacks + */ +contract MockOptimisticOracleV3 { + mapping(address => uint256) public minimumBonds; + + uint256 private constant DEFAULT_MINIMUM_BOND = 1e18; // 1 token + + /** + * @notice Get minimum bond for a currency + * @param currency The currency address + * @return The minimum bond amount + */ + function getMinimumBond(address currency) external view returns (uint256) { + uint256 customBond = minimumBonds[currency]; + return customBond > 0 ? customBond : DEFAULT_MINIMUM_BOND; + } + + /** + * @notice Set minimum bond for testing purposes + * @param currency The currency address + * @param bond The minimum bond amount + */ + function setMinimumBond(address currency, uint256 bond) external { + minimumBonds[currency] = bond; + } + + /** + * @notice Simplified assertion creation for testing + * @param claim The claim being asserted + * @param asserter The account that will receive the bond back + * @param currency The currency for the bond + * @param bond The bond amount + * @return assertionId The unique assertion identifier + */ + function assertTruth( + bytes memory claim, + address asserter, + address, // callbackRecipient - not used in simplified mock + address, // escalationManager - not used in simplified mock + uint64, // liveness - not used in simplified mock + IERC20 currency, + uint256 bond, + bytes32, // identifier - not used in simplified mock + bytes32 // domainId - not used in simplified mock + ) external returns (bytes32 assertionId) { + require(asserter != address(0), "Asserter cannot be zero"); + require(bond >= this.getMinimumBond(address(currency)), "Bond too low"); + + // Transfer bond from caller for realistic testing + currency.transferFrom(msg.sender, address(this), bond); + + // Generate simple, predictable assertion ID + assertionId = keccak256(abi.encode(claim, asserter, block.timestamp, msg.sender)); + + return assertionId; + } + + /** + * @notice Test helper to trigger resolved callback on the adapter + * @param callbackRecipient The adapter address to call back + * @param assertionId The assertion ID + * @param assertedTruthfully Whether the assertion was deemed truthful + */ + function triggerResolvedCallback(address callbackRecipient, bytes32 assertionId, bool assertedTruthfully) external { + OptimisticOracleV3CallbackRecipientInterface(callbackRecipient).assertionResolvedCallback( + assertionId, + assertedTruthfully + ); + } + + /** + * @notice Test helper to trigger disputed callback on the adapter + * @param callbackRecipient The adapter address to call back + * @param assertionId The assertion ID + */ + function triggerDisputedCallback(address callbackRecipient, bytes32 assertionId) external { + OptimisticOracleV3CallbackRecipientInterface(callbackRecipient).assertionDisputedCallback(assertionId); + } +} diff --git a/contracts/protocol/clients/UMAAdapter/UMADisputeResolverAdapter.sol b/contracts/protocol/clients/UMAAdapter/UMADisputeResolverAdapter.sol new file mode 100644 index 000000000..48d4d5258 --- /dev/null +++ b/contracts/protocol/clients/UMAAdapter/UMADisputeResolverAdapter.sol @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.22; + +import { OptimisticOracleV3Interface } from "../../../interfaces/clients/OptimisticOracleV3Interface.sol"; +import { + OptimisticOracleV3CallbackRecipientInterface +} from "../../../interfaces/clients/OptimisticOracleV3CallbackRecipientInterface.sol"; +import { IBosonDisputeResolverHandler } from "../../../interfaces/handlers/IBosonDisputeResolverHandler.sol"; +import { IBosonDisputeHandler } from "../../../interfaces/handlers/IBosonDisputeHandler.sol"; +import { IBosonExchangeHandler } from "../../../interfaces/handlers/IBosonExchangeHandler.sol"; +import { IBosonOfferHandler } from "../../../interfaces/handlers/IBosonOfferHandler.sol"; +import { BosonTypes } from "../../../domain/BosonTypes.sol"; +import { BosonErrors } from "../../../domain/BosonErrors.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +/** + * @title UMADisputeResolverAdapter + * @notice Adapter contract that integrates UMA's OptimisticOracleV3 as a dispute resolver for Boson Protocol + * @dev This contract acts as a bridge between Boson Protocol's dispute resolution system and UMA's optimistic oracle. + * When disputes are escalated, they are submitted to UMA for decentralized resolution. + */ +contract UMADisputeResolverAdapter is + OptimisticOracleV3CallbackRecipientInterface, + Ownable, + ReentrancyGuard, + BosonErrors +{ + // State variables + address public immutable BOSON_PROTOCOL; + OptimisticOracleV3Interface public immutable UMA_ORACLE; + // UMA's standard identifier for boolean assertions + bytes32 public constant UMA_ASSERTION_IDENTIFIER = bytes32("ASSERT_TRUTH"); + + uint256 public disputeResolverId; + uint64 public challengePeriod; + + // Mapping from UMA assertion ID to Boson exchange ID + mapping(bytes32 => uint256) public assertionToExchange; + + // Mapping from exchange ID to UMA assertion ID + mapping(uint256 => bytes32) public exchangeToAssertion; + + // Mapping from assertion ID to proposed buyer percentage + mapping(bytes32 => uint256) private assertionToBuyerPercent; + + // Events + event DisputeEscalatedToUMA(uint256 indexed exchangeId, bytes32 indexed assertionId, address indexed asserter); + event UMAAssertionResolved(bytes32 indexed assertionId, uint256 indexed exchangeId, bool assertedTruthfully); + event DisputeResolverRegistered(uint256 indexed disputeResolverId); + event DisputeContested(uint256 indexed exchangeId, bytes32 indexed assertionId, uint256 timestamp); + + // Custom errors + error OnlyUMAOracle(); + error InvalidProtocolAddress(); + error InvalidUMAOracleAddress(); + error ExchangeNotEscalated(); + error AssertionNotFound(); + error DisputeNotEscalated(); + error InvalidExchangeId(); + error AssertionAlreadyExists(); + error NotRegistered(); + error AlreadyRegistered(); + error NotAssignedDisputeResolver(); + error InvalidDisputeResolverId(); + + /** + * @notice Constructor for UMADisputeResolverAdapter + * @param _bosonProtocol Address of the Boson Protocol diamond + * @param _umaOracle Address of UMA's OptimisticOracleV3 + * @param _challengePeriod Challenge period for UMA assertions in seconds + * @dev Sets immutable contract references and initial configuration + * + * Reverts if: + * - _bosonProtocol is zero address + * - _umaOracle is zero address + */ + constructor(address _bosonProtocol, address _umaOracle, uint64 _challengePeriod) { + if (_bosonProtocol == address(0)) revert InvalidProtocolAddress(); + if (_umaOracle == address(0)) revert InvalidUMAOracleAddress(); + + BOSON_PROTOCOL = _bosonProtocol; + UMA_ORACLE = OptimisticOracleV3Interface(_umaOracle); + challengePeriod = _challengePeriod; + } + + /** + * @notice Register the caller as a dispute resolver and configure this adapter to serve them + * @param _treasury Treasury address for the dispute resolver + * @param _metadataUri Metadata URI for the dispute resolver + * @param _disputeResolverFees Array of fee structures + * @param _sellerAllowList Array of seller IDs allowed to use this DR (empty = no restrictions) + * @dev The caller becomes both admin and assistant for the dispute resolver to satisfy Boson's requirements. + * This adapter will then proxy dispute resolution calls for them. + * + * Emits: + * - DisputeResolverRegistered event with the assigned dispute resolver ID + * + * Reverts if: + * - Caller is not owner + * - Contract is already registered + * - Boson Protocol registration fails + */ + function registerDisputeResolver( + address payable _treasury, + string memory _metadataUri, + BosonTypes.DisputeResolverFee[] memory _disputeResolverFees, + uint256[] memory _sellerAllowList + ) external onlyOwner { + if (disputeResolverId != 0) revert AlreadyRegistered(); + + BosonTypes.DisputeResolver memory disputeResolver = BosonTypes.DisputeResolver({ + id: 0, // Will be set by the protocol + escalationResponsePeriod: challengePeriod, + assistant: address(this), + admin: address(this), + clerk: address(0), // Deprecated field + treasury: _treasury, + metadataUri: _metadataUri, + active: true + }); + + IBosonDisputeResolverHandler(BOSON_PROTOCOL).createDisputeResolver( + disputeResolver, + _disputeResolverFees, + _sellerAllowList + ); + + (, BosonTypes.DisputeResolver memory registeredDR, , ) = IBosonDisputeResolverHandler(BOSON_PROTOCOL) + .getDisputeResolverByAddress(address(this)); + + disputeResolverId = registeredDR.id; + emit DisputeResolverRegistered(disputeResolverId); + } + + /** + * @notice Creates a UMA assertion for an escalated dispute (MVP: manual call by buyer) + * @param _exchangeId The exchange ID of the escalated dispute + * @param _buyerPercent The buyer's proposed percentage split (0-10000, where 10000 = 100%) + * @param _additionalInfo Additional information to include in the claim + * @dev Creates UMA assertion with structured claim data and stores state mappings for callback handling. + * Caller must approve this contract to spend the minimum bond amount for the exchange token. + * + * Emits: + * - DisputeEscalatedToUMA event with exchange ID, assertion ID, and asserter address + * + * Reverts if: + * - _buyerPercent > 10000 + * - Exchange does not exist + * - Dispute is not in Escalated state + * - This contract is not the assigned dispute resolver for the offer + * - Assertion already exists for this exchange + * - UMA bond transfer fails + * - UMA assertion creation fails + */ + function assertTruthForDispute( + uint256 _exchangeId, + uint256 _buyerPercent, + string memory _additionalInfo + ) external nonReentrant { + if (_buyerPercent > 10000) revert InvalidBuyerPercent(); + + // Verify the dispute is escalated in Boson Protocol + (bool exists, BosonTypes.DisputeState state) = IBosonDisputeHandler(BOSON_PROTOCOL).getDisputeState( + _exchangeId + ); + if (!exists) revert InvalidExchangeId(); + if (state != BosonTypes.DisputeState.Escalated) revert DisputeNotEscalated(); + + _validateAssignedDisputeResolver(_exchangeId); + + if (exchangeToAssertion[_exchangeId] != bytes32(0)) revert AssertionAlreadyExists(); + + bytes32 assertionId = _createUMAAssertion(_exchangeId, _buyerPercent, _additionalInfo); + + assertionToExchange[assertionId] = _exchangeId; + exchangeToAssertion[_exchangeId] = assertionId; + assertionToBuyerPercent[assertionId] = _buyerPercent; + + emit DisputeEscalatedToUMA(_exchangeId, assertionId, msg.sender); + } + + /** + * @notice Callback from UMA when an assertion is resolved + * @param assertionId The ID of the resolved assertion + * @param assertedTruthfully Whether the assertion was deemed truthful + * @dev Only callable by UMA Oracle. Automatically resolves Boson dispute based on UMA's decision. + * If assertedTruthfully=true, uses original buyer percentage. If false, buyer gets 0%. + * This contract must be registered as the dispute resolver assistant for this to work. + * + * Emits: + * - UMAAssertionResolved event with assertion ID, exchange ID, and resolution result + * + * Reverts if: + * - Caller is not UMA Oracle + * - Assertion ID not found in mappings + * - Boson dispute resolution fails + */ + function assertionResolvedCallback(bytes32 assertionId, bool assertedTruthfully) external override { + if (msg.sender != address(UMA_ORACLE)) revert OnlyUMAOracle(); + + uint256 exchangeId = assertionToExchange[assertionId]; + if (exchangeId == 0) revert AssertionNotFound(); + + // Get the proposed buyer percentage before cleanup + uint256 proposedBuyerPercent = assertionToBuyerPercent[assertionId]; + + // Clean up mappings + delete assertionToExchange[assertionId]; + delete exchangeToAssertion[exchangeId]; + delete assertionToBuyerPercent[assertionId]; + + uint256 buyerPercent; + if (assertedTruthfully) { + buyerPercent = proposedBuyerPercent; + } + + IBosonDisputeHandler(BOSON_PROTOCOL).decideDispute(exchangeId, buyerPercent); + + emit UMAAssertionResolved(assertionId, exchangeId, assertedTruthfully); + } + + /** + * @notice Callback from UMA when someone disputes our assertion during the challenge period + * @param assertionId The ID of the disputed assertion + * @dev Only callable by UMA Oracle. Emits event for frontend integration to notify users of extended timeline. + * When disputed, resolution extends from 2h (challenge period) to 48-96h (UMA DVM voting period). + * + * Emits: + * - DisputeContested event with exchange ID, assertion ID, and timestamp (if assertion exists) + * + * Reverts if: + * - Caller is not UMA Oracle + */ + function assertionDisputedCallback(bytes32 assertionId) external override { + if (msg.sender != address(UMA_ORACLE)) revert OnlyUMAOracle(); + + uint256 exchangeId = assertionToExchange[assertionId]; + if (exchangeId != 0) { + emit DisputeContested(exchangeId, assertionId, block.timestamp); + } + } + + /** + * @notice Check if the contract is registered as a dispute resolver + * @return registered True if registered, false otherwise + */ + function isRegistered() external view returns (bool registered) { + return disputeResolverId != 0; + } + + /** + * @notice Add fees to the dispute resolver (owner only) + * @param _disputeResolverFees Array of fee structures to add + * @dev Only callable by owner after registration is complete + * + * Reverts if: + * - Caller is not owner + * - Contract is not registered as dispute resolver + * - Boson Protocol fee addition fails + */ + function addFeesToDisputeResolver( + BosonTypes.DisputeResolverFee[] calldata _disputeResolverFees + ) external onlyOwner { + if (disputeResolverId == 0) revert NotRegistered(); + + IBosonDisputeResolverHandler(BOSON_PROTOCOL).addFeesToDisputeResolver(disputeResolverId, _disputeResolverFees); + } + + /** + * @notice Remove fees from the dispute resolver (owner only) + * @param _feeTokenAddresses Array of token addresses to remove + * @dev Only callable by owner after registration is complete + * + * Reverts if: + * - Caller is not owner + * - Contract is not registered as dispute resolver + * - Boson Protocol fee removal fails + */ + function removeFeesFromDisputeResolver(address[] calldata _feeTokenAddresses) external onlyOwner { + if (disputeResolverId == 0) revert NotRegistered(); + + IBosonDisputeResolverHandler(BOSON_PROTOCOL).removeFeesFromDisputeResolver( + disputeResolverId, + _feeTokenAddresses + ); + } + + /** + * @notice Update the challenge period for UMA assertions (owner only) + * @param _newChallengePeriod New challenge period in seconds + * @dev Updates the challenge period used for future UMA assertions + * + * Reverts if: + * - Caller is not owner + */ + function setChallengePeriod(uint64 _newChallengePeriod) external onlyOwner { + challengePeriod = _newChallengePeriod; + } + + /** + * @notice Get dispute resolver information + * @return exists Whether the dispute resolver exists + * @return disputeResolver The dispute resolver details + * @return disputeResolverFees Array of fee structures + * @return sellerAllowList Array of allowed seller IDs + */ + function getDisputeResolver() + external + view + returns ( + bool exists, + BosonTypes.DisputeResolver memory disputeResolver, + BosonTypes.DisputeResolverFee[] memory disputeResolverFees, + uint256[] memory sellerAllowList + ) + { + return IBosonDisputeResolverHandler(BOSON_PROTOCOL).getDisputeResolver(disputeResolverId); + } + + /** + * @notice Validates that this contract is the assigned dispute resolver for the exchange + * @param _exchangeId The exchange ID to validate + * Reverts if: + * - Exchange does not exist + * - Offer does not exist + * - This contract is not registered as a dispute resolver + * - This contract is not the assigned dispute resolver for this exchange + */ + function _validateAssignedDisputeResolver(uint256 _exchangeId) internal view { + // it is already validated that there is a valid dispute for this exchange, so we assume exchange and offer exist + (, BosonTypes.Exchange memory exchange, ) = IBosonExchangeHandler(BOSON_PROTOCOL).getExchange(_exchangeId); + (, BosonTypes.Offer memory offer, , , , ) = IBosonOfferHandler(BOSON_PROTOCOL).getOffer(exchange.offerId); + (, , , , BosonTypes.DisputeResolutionTerms memory disputeResolutionTerms, ) = IBosonOfferHandler(BOSON_PROTOCOL) + .getOffer(offer.id); + + if (disputeResolutionTerms.disputeResolverId != disputeResolverId) { + revert NotAssignedDisputeResolver(); + } + } + + /** + * @notice Internal function to create UMA assertion + * @param _exchangeId The exchange ID + * @param _buyerPercent The buyer's proposed percentage + * @param _additionalInfo Additional information + * @return assertionId The created assertion ID + * @dev Creates human-readable claim following UMA's documented approach and submits to UMA Oracle. + * Uses exchange token for bond currency and exchange ID as domain ID. + * + * Reverts if: + * - Exchange does not exist + * - Offer does not exist + * - UMA bond calculation fails + * - UMA assertion creation fails + */ + function _createUMAAssertion( + uint256 _exchangeId, + uint256 _buyerPercent, + string memory _additionalInfo + ) internal returns (bytes32 assertionId) { + // Get exchange and offer details + (, BosonTypes.Exchange memory exchange, ) = IBosonExchangeHandler(BOSON_PROTOCOL).getExchange(_exchangeId); + (, BosonTypes.Offer memory offer, , , , ) = IBosonOfferHandler(BOSON_PROTOCOL).getOffer(exchange.offerId); + + // Create human-readable claim following UMA's example + bytes memory claim = abi.encodePacked( + "Boson Protocol dispute for exchange ", + Strings.toString(_exchangeId), + ": Buyer claims ", + Strings.toString(_buyerPercent), + "% of funds. ", + _additionalInfo, + " at timestamp ", + Strings.toString(block.timestamp) + ); + + uint256 bond = UMA_ORACLE.getMinimumBond(offer.exchangeToken); + IERC20 exchangeToken = IERC20(offer.exchangeToken); + + exchangeToken.transferFrom(msg.sender, address(this), bond); + exchangeToken.approve(address(UMA_ORACLE), bond); + + bytes32 domainId = bytes32(_exchangeId); + assertionId = UMA_ORACLE.assertTruth( + claim, + msg.sender, + address(this), + address(0), // we don't use specific escalation manager, but use the default one (DVN). + challengePeriod, + exchangeToken, + bond, + UMA_ASSERTION_IDENTIFIER, + domainId + ); + } +} diff --git a/test/protocol/clients/UMADisputeResolverAdapterTest.js b/test/protocol/clients/UMADisputeResolverAdapterTest.js new file mode 100644 index 000000000..4252bfdd8 --- /dev/null +++ b/test/protocol/clients/UMADisputeResolverAdapterTest.js @@ -0,0 +1,647 @@ +const { ethers } = require("hardhat"); +const { expect } = require("chai"); +const { ZeroAddress, getContractFactory, parseUnits, MaxUint256 } = ethers; +const { getSnapshot, revertToSnapshot, setupTestEnvironment, getEvent } = require("../../util/utils.js"); +const { deployMockTokens } = require("../../../scripts/util/deploy-mock-tokens"); +const { + mockSeller, + mockBuyer, + mockOffer, + mockAuthToken, + accountId, + mockVoucherInitValues, +} = require("../../util/mock"); +const DisputeState = require("../../../scripts/domain/DisputeState.js"); + +const TWO_HOURS = 2 * 60 * 60; +const ONE_ETHER = parseUnits("1", "ether"); +const TEN_ETHER = parseUnits("10", "ether"); + +describe("UMADisputeResolverAdapter", function () { + let UMAAdapterFactory; + let umaAdapter, mockUMAOracle, mockToken; + let deployer, buyer, seller, treasuryWallet; + let disputeHandler, exchangeHandler, exchangeCommitHandler, offerHandler, accountHandler, fundsHandler; + let snapshotId; + + const challengePeriod = TWO_HOURS; + const buyerPercent = 7500; // 75% + const additionalInfo = "Product was damaged during shipping"; + + let offer, sellerStruct, buyerStruct; + let exchangeId, offerId, sellerId, buyerId; + + beforeEach(async function () { + accountId.next(true); + + const contracts = { + accountHandler: "IBosonAccountHandler", + exchangeHandler: "IBosonExchangeHandler", + exchangeCommitHandler: "IBosonExchangeCommitHandler", + offerHandler: "IBosonOfferHandler", + disputeHandler: "IBosonDisputeHandler", + fundsHandler: "IBosonFundsHandler", + }; + + ({ + signers: [deployer, buyer, seller, treasuryWallet], + contractInstances: { + accountHandler, + exchangeHandler, + exchangeCommitHandler, + offerHandler, + disputeHandler, + fundsHandler, + }, + } = await setupTestEnvironment(contracts)); + + // Deploy mock token for testing + [mockToken] = await deployMockTokens(["Foreign20"]); + await mockToken.mint(await buyer.getAddress(), TEN_ETHER); + await mockToken.mint(await seller.getAddress(), TEN_ETHER); + + // Deploy mock UMA Oracle + const MockUMAOracleFactory = await getContractFactory("MockOptimisticOracleV3"); + mockUMAOracle = await MockUMAOracleFactory.deploy(); + await mockUMAOracle.waitForDeployment(); + + // Set minimum bond for our test token + await mockUMAOracle.setMinimumBond(await mockToken.getAddress(), ONE_ETHER); + + // Deploy UMA Adapter (deployer will be the owner) + UMAAdapterFactory = await getContractFactory("UMADisputeResolverAdapter"); + umaAdapter = await UMAAdapterFactory.connect(deployer).deploy( + await accountHandler.getAddress(), + await mockUMAOracle.getAddress(), + challengePeriod + ); + await umaAdapter.waitForDeployment(); + + snapshotId = await getSnapshot(); + }); + + afterEach(async function () { + await revertToSnapshot(snapshotId); + snapshotId = await getSnapshot(); + }); + + context("constructor", async function () { + it("should deploy successfully with valid parameters", async function () { + expect(await umaAdapter.BOSON_PROTOCOL()).to.equal(await accountHandler.getAddress()); + expect(await umaAdapter.UMA_ORACLE()).to.equal(await mockUMAOracle.getAddress()); + expect(await umaAdapter.challengePeriod()).to.equal(challengePeriod); + expect(await umaAdapter.disputeResolverId()).to.equal(0); + expect(await umaAdapter.isRegistered()).to.be.false; + }); + + context("💔 Revert Reasons", async function () { + it("should revert if protocol address is zero", async function () { + await expect( + UMAAdapterFactory.connect(deployer).deploy(ZeroAddress, await mockUMAOracle.getAddress(), challengePeriod) + ).to.be.revertedWithCustomError(umaAdapter, "InvalidProtocolAddress"); + }); + + it("should revert if UMA oracle address is zero", async function () { + await expect( + UMAAdapterFactory.connect(deployer).deploy(await accountHandler.getAddress(), ZeroAddress, challengePeriod) + ).to.be.revertedWithCustomError(umaAdapter, "InvalidUMAOracleAddress"); + }); + }); + }); + + context("registerDisputeResolver", async function () { + it("should register dispute resolver successfully", async function () { + const disputeResolverFees = [ + { + tokenAddress: await mockToken.getAddress(), + tokenName: "MockToken", + feeAmount: "0", + }, + ]; + const sellerAllowList = []; + + expect(await umaAdapter.isRegistered()).to.be.false; + expect(await umaAdapter.disputeResolverId()).to.equal(0); + + const tx = await umaAdapter + .connect(deployer) + .registerDisputeResolver( + await treasuryWallet.getAddress(), + "ipfs://uma-adapter-metadata", + disputeResolverFees, + sellerAllowList + ); + + await expect(tx).to.emit(umaAdapter, "DisputeResolverRegistered").withArgs(1); + + expect(await umaAdapter.isRegistered()).to.be.true; + expect(await umaAdapter.disputeResolverId()).to.equal(1); + + const [exists, disputeResolver] = await umaAdapter.getDisputeResolver(); + expect(exists).to.be.true; + expect(disputeResolver.id).to.equal(1); + expect(disputeResolver.assistant).to.equal(await umaAdapter.getAddress()); + expect(disputeResolver.admin).to.equal(await umaAdapter.getAddress()); + expect(disputeResolver.treasury).to.equal(await treasuryWallet.getAddress()); + expect(disputeResolver.metadataUri).to.equal("ipfs://uma-adapter-metadata"); + expect(disputeResolver.escalationResponsePeriod).to.equal(challengePeriod); + expect(disputeResolver.active).to.be.true; + }); + + context("💔 Revert Reasons", async function () { + it("should revert if not called by owner", async function () { + const disputeResolverFees = []; + const sellerAllowList = []; + + await expect( + umaAdapter + .connect(buyer) + .registerDisputeResolver( + await treasuryWallet.getAddress(), + "ipfs://metadata", + disputeResolverFees, + sellerAllowList + ) + ).to.be.revertedWith("Ownable: caller is not the owner"); + }); + + it("should revert if already registered", async function () { + const disputeResolverFees = []; + const sellerAllowList = []; + + // First registration + await umaAdapter + .connect(deployer) + .registerDisputeResolver( + await treasuryWallet.getAddress(), + "ipfs://metadata", + disputeResolverFees, + sellerAllowList + ); + + // Second registration should fail + await expect( + umaAdapter + .connect(deployer) + .registerDisputeResolver( + await treasuryWallet.getAddress(), + "ipfs://metadata2", + disputeResolverFees, + sellerAllowList + ) + ).to.be.revertedWithCustomError(umaAdapter, "AlreadyRegistered"); + }); + }); + }); + + /** + * Helper function to create a complete test setup with seller, buyer, offer, exchange and escalated dispute + */ + async function createEscalatedDisputeSetup() { + sellerStruct = mockSeller( + await seller.getAddress(), + await seller.getAddress(), + ZeroAddress, + await treasuryWallet.getAddress() + ); + const voucherInitValues = mockVoucherInitValues(); + await accountHandler.connect(seller).createSeller(sellerStruct, mockAuthToken(), voucherInitValues); + sellerId = sellerStruct.id; + + buyerStruct = mockBuyer(await buyer.getAddress()); + await accountHandler.connect(buyer).createBuyer(buyerStruct); + buyerId = buyerStruct.id; + + const disputeResolverFees = [ + { + tokenAddress: await mockToken.getAddress(), + tokenName: "MockToken", + feeAmount: "0", + }, + ]; + const sellerAllowList = []; + + expect(await umaAdapter.isRegistered()).to.equal(false); + const tx = await umaAdapter + .connect(deployer) + .registerDisputeResolver( + await treasuryWallet.getAddress(), + "ipfs://uma-adapter-metadata", + disputeResolverFees, + sellerAllowList + ); + + expect(tx).to.emit(umaAdapter, "DisputeResolverRegistered").withArgs(1); + expect(await umaAdapter.isRegistered()).to.equal(true); + + const disputeResolverId = await umaAdapter.disputeResolverId(); + + const mockOfferData = await mockOffer(); + offer = mockOfferData.offer; + + mockOfferData.offerDates.voucherRedeemableFrom = Math.floor(Date.now() / 1000) - 1; // 1 second ago + + offer.sellerId = sellerId; + offer.exchangeToken = await mockToken.getAddress(); + + await mockToken.connect(seller).approve(await accountHandler.getAddress(), offer.sellerDeposit); + await fundsHandler.connect(seller).depositFunds(sellerId, await mockToken.getAddress(), offer.sellerDeposit); + + mockOfferData.drParams.disputeResolverId = disputeResolverId; + + await offerHandler.connect(seller).createOffer( + mockOfferData.offer, + mockOfferData.offerDates, + mockOfferData.offerDurations, + mockOfferData.drParams, + "0", // agentId + MaxUint256 // unlimited offeFeeLimit + ); + offerId = offer.id; + + await mockToken.connect(buyer).approve(await accountHandler.getAddress(), offer.price); + await exchangeCommitHandler.connect(buyer).commitToOffer(await buyer.getAddress(), offerId); + + exchangeId = offerId; + + await exchangeHandler.connect(buyer).redeemVoucher(exchangeId); + await disputeHandler.connect(buyer).raiseDispute(exchangeId); + + await disputeHandler.connect(buyer).escalateDispute(exchangeId); + + return { exchangeId, offerId, sellerId, buyerId, disputeResolverId }; + } + + context("assertTruthForDispute", async function () { + it("should create UMA assertion - resolve to true", async function () { + const { exchangeId: testExchangeId } = await createEscalatedDisputeSetup(); + + await mockToken.connect(buyer).approve(await umaAdapter.getAddress(), ONE_ETHER); + + const tx = await umaAdapter.connect(buyer).assertTruthForDispute(testExchangeId, buyerPercent, additionalInfo); + + await expect(tx).to.emit(umaAdapter, "DisputeEscalatedToUMA"); + + const receipt = await tx.wait(); + const event = getEvent(receipt, UMAAdapterFactory, "DisputeEscalatedToUMA"); + const assertionId = event.assertionId; + + expect(await umaAdapter.assertionToExchange(assertionId)).to.equal(testExchangeId); + expect(await umaAdapter.exchangeToAssertion(testExchangeId)).to.equal(assertionId); + + await mockUMAOracle.triggerResolvedCallback(await umaAdapter.getAddress(), assertionId, true); + + const [disputeExists, disputeState] = await disputeHandler.getDisputeState(testExchangeId); + expect(disputeExists).to.be.true; + expect(disputeState).to.equal(DisputeState.Decided); + + const [disputeExists2, disputeData] = await disputeHandler.getDispute(testExchangeId); + expect(disputeExists2).to.be.true; + expect(disputeData.buyerPercent).to.equal(buyerPercent); + + expect(await umaAdapter.assertionToExchange(assertionId)).to.equal(0); + expect(await umaAdapter.exchangeToAssertion(testExchangeId)).to.equal(ethers.ZeroHash); + }); + it("should create UMA assertion - resolve to false", async function () { + const { exchangeId: testExchangeId } = await createEscalatedDisputeSetup(); + + await mockToken.connect(buyer).approve(await umaAdapter.getAddress(), ONE_ETHER); + + const tx = await umaAdapter.connect(buyer).assertTruthForDispute(testExchangeId, buyerPercent, additionalInfo); + + await expect(tx).to.emit(umaAdapter, "DisputeEscalatedToUMA"); + + // Get the assertion ID from the event + const receipt = await tx.wait(); + const event = getEvent(receipt, UMAAdapterFactory, "DisputeEscalatedToUMA"); + const assertionId = event.assertionId; + + expect(await umaAdapter.assertionToExchange(assertionId)).to.equal(testExchangeId); + expect(await umaAdapter.exchangeToAssertion(testExchangeId)).to.equal(assertionId); + + await mockUMAOracle.triggerResolvedCallback(await umaAdapter.getAddress(), assertionId, false); + + const [disputeExists, disputeState] = await disputeHandler.getDisputeState(testExchangeId); + expect(disputeExists).to.be.true; + expect(disputeState).to.equal(DisputeState.Decided); + + const [disputeExists2, disputeData] = await disputeHandler.getDispute(testExchangeId); + expect(disputeExists2).to.be.true; + expect(disputeData.buyerPercent).to.equal(0); + + expect(await umaAdapter.assertionToExchange(assertionId)).to.equal(0); + expect(await umaAdapter.exchangeToAssertion(testExchangeId)).to.equal(ethers.ZeroHash); + }); + + context("💔 Revert Reasons", async function () { + it("should revert if buyer percent is invalid (> 10000)", async function () { + const { exchangeId: testExchangeId } = await createEscalatedDisputeSetup(); + + await mockToken.connect(buyer).approve(await umaAdapter.getAddress(), ONE_ETHER); + + await expect( + umaAdapter.connect(buyer).assertTruthForDispute( + testExchangeId, + 10001, // Invalid: > 10000 + additionalInfo + ) + ).to.be.revertedWithCustomError(umaAdapter, "InvalidBuyerPercent"); + }); + + it("should revert if exchange does not exist", async function () { + const invalidExchangeId = 999999; + + await expect( + umaAdapter.connect(buyer).assertTruthForDispute(invalidExchangeId, buyerPercent, additionalInfo) + ).to.be.revertedWithCustomError(umaAdapter, "InvalidExchangeId"); + }); + + it("should revert if dispute is not escalated", async function () { + const { exchangeId } = await createEscalatedDisputeSetup(); + + await disputeHandler.connect(buyer).retractDispute(exchangeId); + + await expect( + umaAdapter.connect(buyer).assertTruthForDispute(exchangeId, buyerPercent, additionalInfo) + ).to.be.revertedWithCustomError(umaAdapter, "DisputeNotEscalated"); + }); + + it("should revert if assertion already exists for exchange", async function () { + const { exchangeId: testExchangeId } = await createEscalatedDisputeSetup(); + + await mockToken.connect(buyer).approve(await umaAdapter.getAddress(), ONE_ETHER * 2n); + + await umaAdapter.connect(buyer).assertTruthForDispute(testExchangeId, buyerPercent, additionalInfo); + + // Second assertion should fail + await expect( + umaAdapter.connect(buyer).assertTruthForDispute(testExchangeId, buyerPercent, additionalInfo) + ).to.be.revertedWithCustomError(umaAdapter, "AssertionAlreadyExists"); + }); + + it("should revert if not assigned dispute resolver", async function () { + // Create an escalated dispute setup but with different dispute resolver + const { exchangeId: testExchangeId } = await createEscalatedDisputeSetup(); + + // Create another UMA adapter with different ID + const anotherUMAAdapter = await UMAAdapterFactory.connect(deployer).deploy( + await accountHandler.getAddress(), + await mockUMAOracle.getAddress(), + challengePeriod + ); + await anotherUMAAdapter.waitForDeployment(); + + await mockToken.connect(buyer).approve(await anotherUMAAdapter.getAddress(), ONE_ETHER); + + await expect( + anotherUMAAdapter.connect(buyer).assertTruthForDispute(testExchangeId, buyerPercent, additionalInfo) + ).to.be.revertedWithCustomError(anotherUMAAdapter, "NotAssignedDisputeResolver"); + }); + }); + }); + + context("assertionResolvedCallback", async function () { + it("should handle resolved callback with true result", async function () { + const { exchangeId: testExchangeId } = await createEscalatedDisputeSetup(); + + await mockToken.connect(buyer).approve(await umaAdapter.getAddress(), ONE_ETHER); + + const tx = await umaAdapter.connect(buyer).assertTruthForDispute(testExchangeId, buyerPercent, additionalInfo); + + const receipt = await tx.wait(); + const event = getEvent(receipt, UMAAdapterFactory, "DisputeEscalatedToUMA"); + const assertionId = event.assertionId; + + expect(await umaAdapter.assertionToExchange(assertionId)).to.equal(testExchangeId); + expect(await umaAdapter.exchangeToAssertion(testExchangeId)).to.equal(assertionId); + + const resolveTx = await mockUMAOracle.triggerResolvedCallback(await umaAdapter.getAddress(), assertionId, true); + + await expect(resolveTx).to.emit(umaAdapter, "UMAAssertionResolved").withArgs(assertionId, testExchangeId, true); + + const [disputeExists, disputeData] = await disputeHandler.getDispute(testExchangeId); + expect(disputeExists).to.be.true; + expect(disputeData.buyerPercent).to.equal(buyerPercent); + + expect(await umaAdapter.assertionToExchange(assertionId)).to.equal(0); + expect(await umaAdapter.exchangeToAssertion(testExchangeId)).to.equal(ethers.ZeroHash); + }); + + it("should handle resolved callback with false result", async function () { + const { exchangeId: testExchangeId } = await createEscalatedDisputeSetup(); + + await mockToken.connect(buyer).approve(await umaAdapter.getAddress(), ONE_ETHER); + + const tx = await umaAdapter.connect(buyer).assertTruthForDispute(testExchangeId, buyerPercent, additionalInfo); + + const receipt = await tx.wait(); + const event = getEvent(receipt, UMAAdapterFactory, "DisputeEscalatedToUMA"); + const assertionId = event.assertionId; + + // Mock UMA oracle resolves assertion as false + const resolveTx = await mockUMAOracle.triggerResolvedCallback(await umaAdapter.getAddress(), assertionId, false); + + await expect(resolveTx).to.emit(umaAdapter, "UMAAssertionResolved").withArgs(assertionId, testExchangeId, false); + + // Verify dispute was decided with 0% for buyer + const [disputeExists, disputeData] = await disputeHandler.getDispute(testExchangeId); + expect(disputeExists).to.be.true; + expect(disputeData.buyerPercent).to.equal(0); + + expect(await umaAdapter.assertionToExchange(assertionId)).to.equal(0); + expect(await umaAdapter.exchangeToAssertion(testExchangeId)).to.equal(ethers.ZeroHash); + }); + + context("💔 Revert Reasons", async function () { + it("should revert if not called by UMA Oracle", async function () { + const dummyAssertionId = ethers.randomBytes(32); + + await expect( + umaAdapter.connect(buyer).assertionResolvedCallback(dummyAssertionId, true) + ).to.be.revertedWithCustomError(umaAdapter, "OnlyUMAOracle"); + }); + + it("should revert if assertion not found", async function () { + const nonExistentAssertionId = ethers.randomBytes(32); + + await expect( + mockUMAOracle.triggerResolvedCallback(await umaAdapter.getAddress(), nonExistentAssertionId, true) + ).to.be.revertedWithCustomError(umaAdapter, "AssertionNotFound"); + }); + }); + }); + + context("assertionDisputedCallback", async function () { + it("should handle disputed callback for existing assertion", async function () { + const { exchangeId: testExchangeId } = await createEscalatedDisputeSetup(); + + await mockToken.connect(buyer).approve(await umaAdapter.getAddress(), ONE_ETHER); + + const tx = await umaAdapter.connect(buyer).assertTruthForDispute(testExchangeId, buyerPercent, additionalInfo); + + const receipt = await tx.wait(); + const event = getEvent(receipt, UMAAdapterFactory, "DisputeEscalatedToUMA"); + const assertionId = event.assertionId; + + const disputeTx = await mockUMAOracle.triggerDisputedCallback(await umaAdapter.getAddress(), assertionId); + + const disputeReceipt = await disputeTx.wait(); + const disputeEvent = getEvent(disputeReceipt, UMAAdapterFactory, "DisputeContested"); + expect(disputeEvent.exchangeId).to.equal(testExchangeId); + expect(disputeEvent.assertionId).to.equal(assertionId); + expect(disputeEvent.timestamp).to.be.greaterThan(0); + }); + + it("should handle disputed callback for non-existent assertion gracefully", async function () { + const nonExistentAssertionId = ethers.randomBytes(32); + const tx = await mockUMAOracle.triggerDisputedCallback(await umaAdapter.getAddress(), nonExistentAssertionId); + + const receipt = await tx.wait(); + expect(receipt.logs.length).to.equal(0); // No events should be emitted + }); + + context("💔 Revert Reasons", async function () { + it("should revert if not called by UMA Oracle", async function () { + const dummyAssertionId = ethers.randomBytes(32); + + await expect( + umaAdapter.connect(buyer).assertionDisputedCallback(dummyAssertionId) + ).to.be.revertedWithCustomError(umaAdapter, "OnlyUMAOracle"); + }); + }); + }); + + context("addFeesToDisputeResolver and removeFeesFromDisputeResolver", async function () { + beforeEach(async function () { + const disputeResolverFees = []; + const sellerAllowList = []; + + await umaAdapter + .connect(deployer) + .registerDisputeResolver( + await treasuryWallet.getAddress(), + "ipfs://metadata", + disputeResolverFees, + sellerAllowList + ); + }); + + it("should add fees to dispute resolver successfully", async function () { + const newFees = [ + { + tokenAddress: await mockToken.getAddress(), + tokenName: "MockToken", + feeAmount: parseUnits("10", "ether"), + }, + ]; + + const tx = await umaAdapter.connect(deployer).addFeesToDisputeResolver(newFees); + expect(tx).to.emit(disputeHandler, "DisputeResolverFeesAdded").withArgs(1, newFees, deployer.address); + }); + + it("should remove fees from dispute resolver successfully", async function () { + const feesToAdd = [ + { + tokenAddress: await mockToken.getAddress(), + tokenName: "MockToken", + feeAmount: parseUnits("5", "ether"), + }, + ]; + + await umaAdapter.connect(deployer).addFeesToDisputeResolver(feesToAdd); + + let [, , fees] = await umaAdapter.getDisputeResolver(); + expect(fees.length).to.equal(1); + + const tokensToRemove = [await mockToken.getAddress()]; + await umaAdapter.connect(deployer).removeFeesFromDisputeResolver(tokensToRemove); + + [, , fees] = await umaAdapter.getDisputeResolver(); + expect(fees.length).to.equal(0); + }); + + context("💔 Revert Reasons", async function () { + it("should revert addFeesToDisputeResolver if not called by owner", async function () { + const newFees = [ + { + tokenAddress: await mockToken.getAddress(), + tokenName: "MockToken", + feeAmount: parseUnits("10", "ether"), + }, + ]; + + await expect(umaAdapter.connect(buyer).addFeesToDisputeResolver(newFees)).to.be.revertedWith( + "Ownable: caller is not the owner" + ); + }); + + it("should revert addFeesToDisputeResolver if not registered", async function () { + // Deploy a new unregistered adapter + const newAdapter = await UMAAdapterFactory.connect(deployer).deploy( + await accountHandler.getAddress(), + await mockUMAOracle.getAddress(), + challengePeriod + ); + await newAdapter.waitForDeployment(); + + const newFees = [ + { + tokenAddress: await mockToken.getAddress(), + tokenName: "MockToken", + feeAmount: parseUnits("10", "ether"), + }, + ]; + + await expect(newAdapter.connect(deployer).addFeesToDisputeResolver(newFees)).to.be.revertedWithCustomError( + newAdapter, + "NotRegistered" + ); + }); + + it("should revert removeFeesFromDisputeResolver if not called by owner", async function () { + const tokensToRemove = [await mockToken.getAddress()]; + + await expect(umaAdapter.connect(buyer).removeFeesFromDisputeResolver(tokensToRemove)).to.be.revertedWith( + "Ownable: caller is not the owner" + ); + }); + + it("should revert removeFeesFromDisputeResolver if not registered", async function () { + const newAdapter = await UMAAdapterFactory.connect(deployer).deploy( + await accountHandler.getAddress(), + await mockUMAOracle.getAddress(), + challengePeriod + ); + await newAdapter.waitForDeployment(); + + const tokensToRemove = [await mockToken.getAddress()]; + + await expect( + newAdapter.connect(deployer).removeFeesFromDisputeResolver(tokensToRemove) + ).to.be.revertedWithCustomError(newAdapter, "NotRegistered"); + }); + }); + }); + + context("setChallengePeriod", async function () { + it("should set challenge period successfully", async function () { + const newChallengePeriod = 4 * 60 * 60; // 4 hours + + expect(await umaAdapter.challengePeriod()).to.equal(challengePeriod); + + await umaAdapter.connect(deployer).setChallengePeriod(newChallengePeriod); + + expect(await umaAdapter.challengePeriod()).to.equal(newChallengePeriod); + }); + + context("💔 Revert Reasons", async function () { + it("should revert if not called by owner", async function () { + const newChallengePeriod = 4 * 60 * 60; // 4 hours + + await expect(umaAdapter.connect(buyer).setChallengePeriod(newChallengePeriod)).to.be.revertedWith( + "Ownable: caller is not the owner" + ); + }); + }); + }); +});