diff --git a/contracts/global-extensions/GlobalAuctionRebalanceExtension.sol b/contracts/global-extensions/GlobalAuctionRebalanceExtension.sol index ba2e02ee..315ca8e7 100644 --- a/contracts/global-extensions/GlobalAuctionRebalanceExtension.sol +++ b/contracts/global-extensions/GlobalAuctionRebalanceExtension.sol @@ -63,11 +63,11 @@ contract GlobalAuctionRebalanceExtension is BaseGlobalExtension { /* ============ Constructor ============ */ - /* - * Instantiate with ManagerCore address and WrapModuleV2 address. - * - * @param _managerCore Address of ManagerCore contract - * @param _auctionModule Address of AuctionRebalanceModuleV1 contract + /** + * @dev Instantiate with ManagerCore address and WrapModuleV2 address. + * + * @param _managerCore Address of ManagerCore contract + * @param _auctionModule Address of AuctionRebalanceModuleV1 contract */ constructor(IManagerCore _managerCore, IAuctionRebalanceModuleV1 _auctionModule) public BaseGlobalExtension(_managerCore) { auctionModule = _auctionModule; @@ -155,6 +155,7 @@ contract GlobalAuctionRebalanceExtension is BaseGlobalExtension { uint256 _positionMultiplier ) external + virtual onlyOperator(_setToken) { address[] memory currentComponents = _setToken.getComponents(); diff --git a/contracts/global-extensions/GlobalOptimisticAuctionRebalanceExtension.sol b/contracts/global-extensions/GlobalOptimisticAuctionRebalanceExtension.sol new file mode 100644 index 00000000..75eeedf7 --- /dev/null +++ b/contracts/global-extensions/GlobalOptimisticAuctionRebalanceExtension.sol @@ -0,0 +1,360 @@ +/* + Copyright 2023 Index Coop + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; + +import { AddressArrayUtils } from "../lib/AddressArrayUtils.sol"; + +import { BaseGlobalExtension } from "../lib/BaseGlobalExtension.sol"; +import { IAuctionRebalanceModuleV1 } from "../interfaces/IAuctionRebalanceModuleV1.sol"; +import { IManagerCore } from "../interfaces/IManagerCore.sol"; +import { ISetToken } from "../interfaces/ISetToken.sol"; +import { IDelegatedManager } from "../interfaces/IDelegatedManager.sol"; +import {GlobalAuctionRebalanceExtension} from "./GlobalAuctionRebalanceExtension.sol"; +import {AncillaryData } from "../lib/AncillaryData.sol"; +import {OptimisticOracleV3Interface} from "../interfaces/OptimisticOracleV3Interface.sol"; + +/** + * @title GlobalOptimisticAuctionRebalanceExtension + * @author Index Coop + * + * @dev The contract extends `GlobalAuctionRebalanceExtension` by adding an optimistic oracle mechanism for validating rules on the proposing and executing of rebalances. + * It allows setting product-specific parameters for optimistic rebalancing and includes callback functions for resolved or disputed assertions. + */ +contract GlobalOptimisticAuctionRebalanceExtension is GlobalAuctionRebalanceExtension { + using AddressArrayUtils for address[]; + using SafeERC20 for IERC20; + + /* ============ Events ============ */ + + event ProductSettingsUpdated( + IERC20 indexed setToken, + address indexed delegatedManager, + OptimisticRebalanceParams optimisticParams, + bytes32 indexed rulesHash + ); + event RebalanceProposed( + ISetToken indexed setToken, + IERC20 indexed quoteAsset, + address[] oldComponents, + address[] newComponents, + AuctionExecutionParams[] newComponentsAuctionParams, + AuctionExecutionParams[] oldComponentsAuctionParams, + bool shouldLockSetToken, + uint256 rebalanceDuration, + uint256 positionMultiplier + ); + + event AssertedClaim( + IERC20 indexed _setToken, + address indexed _assertedBy, + bytes32 indexed rulesHash, + bytes32 _assertionId, + bytes _claimData + ); + + event ProposalDeleted( + bytes32 assertionID, + Proposal indexed proposal + ); + /* ============ Structs ============ */ + + struct AuctionExtensionParams { + IManagerCore managerCore; // Registry contract for governance approved GlobalExtensions, DelegatedManagerFactories, and DelegatedManagers. + IAuctionRebalanceModuleV1 auctionModule; // Contract that rebalances index sets via single-asset auctions + } + + struct OptimisticRebalanceParams{ + IERC20 collateral; // Collateral currency used to assert proposed transactions. + uint64 liveness; // The amount of time to dispute proposed transactions before they can be executed. + uint256 bondAmount; // Configured amount of collateral currency to make assertions for proposed transactions. + bytes32 identifier; // Identifier used to request price from the DVM. + OptimisticOracleV3Interface optimisticOracleV3; // Optimistic Oracle V3 contract used to assert proposed transactions. + } + + struct ProductSettings{ + OptimisticRebalanceParams optimisticParams; // OptimisticRebalanceParams struct containing optimistic rebalance parameters. + bytes32 rulesHash; // IPFS hash of the rules for the product. + } + + struct Proposal{ + bytes32 proposalHash; // Hash of the proposal. + ISetToken product; // Address of the SetToken to set rules and settings for. + } + + /* ============ State Variables ============ */ + + mapping (ISetToken=>ProductSettings) public productSettings; // Mapping of set token to ProductSettings + mapping(bytes32 => bytes32) public assertionIds; // Maps proposal hashes to assertionIds. + mapping(bytes32 => Proposal) public proposedProduct; // Maps assertionIds to a Proposal. + + // Keys for assertion claim data. + bytes public constant PROPOSAL_HASH_KEY = "proposalHash"; + bytes public constant RULES_KEY = "rulesIPFSHash"; + + + /* ============ Constructor ============ */ + /* + * @dev Initializes the GlobalOptimisticAuctionRebalanceExtension with the passed parameters. + * + * @param _auctionParams AuctionExtensionParams struct containing the managerCore and auctionModule addresses. + */ + constructor(AuctionExtensionParams memory _auctionParams) public GlobalAuctionRebalanceExtension(_auctionParams.managerCore, _auctionParams.auctionModule) { + + } + + /* ============ External Functions ============ */ + + /** + * @dev OPERATOR ONLY: sets product settings for a given set token + * @param _product Address of the SetToken to set rules and settings for. + * @param _optimisticParams OptimisticRebalanceParams struct containing optimistic rebalance parameters. + * @param _rulesHash bytes32 containing the ipfs hash rules for the product. + */ + function setProductSettings( + ISetToken _product, + OptimisticRebalanceParams memory _optimisticParams, + bytes32 _rulesHash + ) + external + onlyOperator(_product) + { + productSettings[_product] = ProductSettings({ + optimisticParams: _optimisticParams, + rulesHash: _rulesHash + }); + emit ProductSettingsUpdated(_product, _product.manager(), _optimisticParams, _rulesHash); + } + + /** + * @param _quoteAsset ERC20 token used as the quote asset in auctions. + * @param _oldComponents Addresses of existing components in the SetToken. + * @param _newComponents Addresses of new components to be added. + * @param _newComponentsAuctionParams AuctionExecutionParams for new components, indexed corresponding to _newComponents. + * @param _oldComponentsAuctionParams AuctionExecutionParams for existing components, indexed corresponding to + * the current component positions. Set to 0 for components being removed. + * @param _shouldLockSetToken Indicates if the rebalance should lock the SetToken. + * @param _rebalanceDuration Duration of the rebalance in seconds. + * @param _positionMultiplier Position multiplier at the time target units were calculated. + */ + function proposeRebalance( + ISetToken _setToken, + IERC20 _quoteAsset, + address[] memory _oldComponents, + address[] memory _newComponents, + AuctionExecutionParams[] memory _newComponentsAuctionParams, + AuctionExecutionParams[] memory _oldComponentsAuctionParams, + bool _shouldLockSetToken, + uint256 _rebalanceDuration, + uint256 _positionMultiplier + ) + external + { + bytes32 proposalHash = keccak256(abi.encode( + _setToken, + _quoteAsset, + _oldComponents, + _newComponents, + _newComponentsAuctionParams, + _oldComponentsAuctionParams, + _shouldLockSetToken, + _rebalanceDuration, + _positionMultiplier + )); + ProductSettings memory settings = productSettings[_setToken]; + + require(assertionIds[proposalHash] == bytes32(0), "Proposal already exists"); + require(settings.rulesHash != bytes32(""), "Rules not set"); + require(address(settings.optimisticParams.optimisticOracleV3) != address(0), "Oracle not set"); + + + bytes memory claim = _constructClaim(proposalHash, settings.rulesHash); + uint256 totalBond = _pullBond(settings.optimisticParams); + + bytes32 assertionId = settings.optimisticParams.optimisticOracleV3.assertTruth( + claim, + msg.sender, + address(this), + address(0), + settings.optimisticParams.liveness, + settings.optimisticParams.collateral, + totalBond, + settings.optimisticParams.identifier, + bytes32(0) + ); + + assertionIds[proposalHash] = assertionId; + proposedProduct[assertionId] = Proposal({ + proposalHash: proposalHash, + product: _setToken + }); + + emit RebalanceProposed( _setToken, _quoteAsset, _oldComponents, _newComponents, _newComponentsAuctionParams, _oldComponentsAuctionParams, _shouldLockSetToken, _rebalanceDuration, _positionMultiplier); + emit AssertedClaim(_setToken, msg.sender, settings.rulesHash, assertionId, claim); + + } + + /** + * @dev OPERATOR ONLY: Checks that the old components array matches the current components array and then invokes the + * AuctionRebalanceModuleV1 startRebalance function. + * + * Refer to AuctionRebalanceModuleV1 for function specific restrictions. + * + * @param _quoteAsset ERC20 token used as the quote asset in auctions. + * @param _oldComponents Addresses of existing components in the SetToken. + * @param _newComponents Addresses of new components to be added. + * @param _newComponentsAuctionParams AuctionExecutionParams for new components, indexed corresponding to _newComponents. + * @param _oldComponentsAuctionParams AuctionExecutionParams for existing components, indexed corresponding to + * the current component positions. Set to 0 for components being removed. + * @param _shouldLockSetToken Indicates if the rebalance should lock the SetToken. + * @param _rebalanceDuration Duration of the rebalance in seconds. + * @param _positionMultiplier Position multiplier at the time target units were calculated. + */ + function startRebalance( + ISetToken _setToken, + IERC20 _quoteAsset, + address[] memory _oldComponents, + address[] memory _newComponents, + AuctionExecutionParams[] memory _newComponentsAuctionParams, + AuctionExecutionParams[] memory _oldComponentsAuctionParams, + bool _shouldLockSetToken, + uint256 _rebalanceDuration, + uint256 _positionMultiplier + ) + external + override + { + bytes32 proposalHash = keccak256(abi.encode( + _setToken, + _quoteAsset, + _oldComponents, + _newComponents, + _newComponentsAuctionParams, + _oldComponentsAuctionParams, + _shouldLockSetToken, + _rebalanceDuration, + _positionMultiplier + )); + + bytes32 assertionId = assertionIds[proposalHash]; + // Disputed assertions are expected to revert here. Assumption past this point is that there was a valid assertion. + require(assertionId != bytes32(0), "Proposal hash does not exist"); + + ProductSettings memory settings = productSettings[_setToken]; + _deleteProposal(assertionId); + + // There is no need to check the assertion result as this point can be reached only for non-disputed assertions. + // It is expected that future versions of the Optimistic Oracle will always revert here, + // if the assertionId has not been settled and can not currently be settled. + settings.optimisticParams.optimisticOracleV3.settleAndGetAssertionResult(assertionId); + + address[] memory currentComponents = _setToken.getComponents(); + + require(currentComponents.length == _oldComponents.length, "Mismatch: old and current components length"); + + for (uint256 i = 0; i < _oldComponents.length; i++) { + require(currentComponents[i] == _oldComponents[i], "Mismatch: old and current components"); + } + + bytes memory callData = abi.encodeWithSelector( + IAuctionRebalanceModuleV1.startRebalance.selector, + _setToken, + _quoteAsset, + _newComponents, + _newComponentsAuctionParams, + _oldComponentsAuctionParams, + _shouldLockSetToken, + _rebalanceDuration, + _positionMultiplier + ); + + _invokeManager(_manager((_setToken)), address(auctionModule), callData); + } + + // Constructs the claim that will be asserted at the Optimistic Oracle V3. + function _constructClaim(bytes32 proposalHash, bytes32 rulesHash) internal pure returns (bytes memory) { + return + abi.encodePacked( + AncillaryData.appendKeyValueBytes32("", PROPOSAL_HASH_KEY, proposalHash), + ",", + RULES_KEY, + ":\"", + rulesHash, + "\"" + ); + } + + /** + * @notice Callback function that is called by Optimistic Oracle V3 when an assertion is resolved. + * @dev This function does nothing and is only here to satisfy the callback recipient interface. + * @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 to automatically delete a proposal that was disputed. + * @param _assertionId the identifier of the disputed assertion. + */ + function assertionDisputedCallback(bytes32 _assertionId) external { + Proposal memory proposal = proposedProduct[_assertionId]; + ProductSettings memory settings = productSettings[proposal.product]; + + require(address(settings.optimisticParams.optimisticOracleV3) != address(0), "Invalid oracle address"); + + // If the sender is the Optimistic Oracle V3, delete the proposal and associated assertionId. + if (msg.sender == address(settings.optimisticParams.optimisticOracleV3)) { + // Delete the disputed proposal and associated assertionId. + _deleteProposal(_assertionId); + + } else { + // If the sender is not the expected Optimistic Oracle V3, check if the expected Oracle has the assertion and if not delete. + require(proposal.proposalHash != bytes32(0), "Invalid proposal hash"); + require(settings.optimisticParams.optimisticOracleV3.getAssertion(_assertionId).asserter == address(0), "Oracle has assertion"); + _deleteProposal(_assertionId); + } + emit ProposalDeleted(_assertionId, proposal); + } + + /// @notice Pulls the higher of the minimum bond or configured bond amount from the sender. + /// @dev Internal function to pull the user's bond before asserting a claim. + /// @param optimisticRebalanceParams optimistic rebalance parameters for the product. + /// @return Bond amount pulled from the sender. + function _pullBond(OptimisticRebalanceParams memory optimisticRebalanceParams) internal returns (uint256) { + uint256 minimumBond = optimisticRebalanceParams.optimisticOracleV3.getMinimumBond(address(optimisticRebalanceParams.collateral)); + uint256 totalBond = minimumBond > optimisticRebalanceParams.bondAmount ? minimumBond : optimisticRebalanceParams.bondAmount; + + optimisticRebalanceParams.collateral.safeTransferFrom(msg.sender, address(this), totalBond); + optimisticRebalanceParams.collateral.safeIncreaseAllowance(address(optimisticRebalanceParams.optimisticOracleV3), totalBond); + + return totalBond; + } + + /// @notice Delete an existing proposal and associated assertionId. + /// @dev Internal function that deletes a proposal and associated assertionId. + /// @param assertionId assertionId of the proposal to delete. + function _deleteProposal(bytes32 assertionId) internal { + Proposal memory proposal = proposedProduct[assertionId]; + delete assertionIds[proposal.proposalHash]; + delete proposedProduct[assertionId]; + } +} diff --git a/contracts/interfaces/OptimisticOracleV3Interface.sol b/contracts/interfaces/OptimisticOracleV3Interface.sol new file mode 100644 index 00000000..168cb8ea --- /dev/null +++ b/contracts/interfaces/OptimisticOracleV3Interface.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title Optimistic Oracle V3 Interface that callers must use to assert truths about the world. + */ +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 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); +} \ No newline at end of file diff --git a/contracts/lib/AncillaryData.sol b/contracts/lib/AncillaryData.sol new file mode 100644 index 00000000..f08dd285 --- /dev/null +++ b/contracts/lib/AncillaryData.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.6.10; + +/** + * @title Library for encoding and decoding ancillary data for DVM price requests. + * @notice We assume that on-chain ancillary data can be formatted directly from bytes to utf8 encoding via + * web3.utils.hexToUtf8, and that clients will parse the utf8-encoded ancillary data as a comma-delimitted key-value + * dictionary. Therefore, this library provides internal methods that aid appending to ancillary data from Solidity + * smart contracts. More details on UMA's ancillary data guidelines below: + * https://docs.google.com/document/d/1zhKKjgY1BupBGPPrY_WOJvui0B6DMcd-xDR8-9-SPDw/edit + */ +library AncillaryData { + // This converts the bottom half of a bytes32 input to hex in a highly gas-optimized way. + // Source: the brilliant implementation at https://gitter.im/ethereum/solidity?at=5840d23416207f7b0ed08c9b. + function toUtf8Bytes32Bottom(bytes32 bytesIn) private pure returns (bytes32) { + uint256 x = uint256(bytesIn); + + // Nibble interleave + x = x & 0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff; + x = (x | (x * 2**64)) & 0x0000000000000000ffffffffffffffff0000000000000000ffffffffffffffff; + x = (x | (x * 2**32)) & 0x00000000ffffffff00000000ffffffff00000000ffffffff00000000ffffffff; + x = (x | (x * 2**16)) & 0x0000ffff0000ffff0000ffff0000ffff0000ffff0000ffff0000ffff0000ffff; + x = (x | (x * 2**8)) & 0x00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff00ff; + x = (x | (x * 2**4)) & 0x0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f; + + // Hex encode + uint256 h = (x & 0x0808080808080808080808080808080808080808080808080808080808080808) / 8; + uint256 i = (x & 0x0404040404040404040404040404040404040404040404040404040404040404) / 4; + uint256 j = (x & 0x0202020202020202020202020202020202020202020202020202020202020202) / 2; + x = x + (h & (i | j)) * 0x27 + 0x3030303030303030303030303030303030303030303030303030303030303030; + + // Return the result. + return bytes32(x); + } + + /** + * @notice Returns utf8-encoded bytes32 string that can be read via web3.utils.hexToUtf8. + * @dev Will return bytes32 in all lower case hex characters and without the leading 0x. + * This has minor changes from the toUtf8BytesAddress to control for the size of the input. + * @param bytesIn bytes32 to encode. + * @return utf8 encoded bytes32. + */ + function toUtf8Bytes(bytes32 bytesIn) internal pure returns (bytes memory) { + return abi.encodePacked(toUtf8Bytes32Bottom(bytesIn >> 128), toUtf8Bytes32Bottom(bytesIn)); + } + + /** + * @notice Returns utf8-encoded address that can be read via web3.utils.hexToUtf8. + * Source: https://ethereum.stackexchange.com/questions/8346/convert-address-to-string/8447#8447 + * @dev Will return address in all lower case characters and without the leading 0x. + * @param x address to encode. + * @return utf8 encoded address bytes. + */ + function toUtf8BytesAddress(address x) internal pure returns (bytes memory) { + return + abi.encodePacked(toUtf8Bytes32Bottom(bytes32(bytes20(x)) >> 128), bytes8(toUtf8Bytes32Bottom(bytes20(x)))); + } + + /** + * @notice Converts a uint into a base-10, UTF-8 representation stored in a `string` type. + * @dev This method is based off of this code: https://stackoverflow.com/a/65707309. + */ + function toUtf8BytesUint(uint256 x) internal pure returns (bytes memory) { + if (x == 0) { + return "0"; + } + uint256 j = x; + uint256 len; + while (j != 0) { + len++; + j /= 10; + } + bytes memory bstr = new bytes(len); + uint256 k = len; + while (x != 0) { + k = k - 1; + uint8 temp = (48 + uint8(x - (x / 10) * 10)); + bytes1 b1 = bytes1(temp); + bstr[k] = b1; + x /= 10; + } + return bstr; + } + + function appendKeyValueBytes32( + bytes memory currentAncillaryData, + bytes memory key, + bytes32 value + ) internal pure returns (bytes memory) { + bytes memory prefix = constructPrefix(currentAncillaryData, key); + return abi.encodePacked(currentAncillaryData, prefix, toUtf8Bytes(value)); + } + + /** + * @notice Adds "key:value" to `currentAncillaryData` where `value` is an address that first needs to be converted + * to utf8 bytes. For example, if `utf8(currentAncillaryData)="k1:v1"`, then this function will return + * `utf8(k1:v1,key:value)`, and if `currentAncillaryData` is blank, then this will return `utf8(key:value)`. + * @param currentAncillaryData This bytes data should ideally be able to be utf8-decoded, but its OK if not. + * @param key Again, this bytes data should ideally be able to be utf8-decoded, but its OK if not. + * @param value An address to set as the value in the key:value pair to append to `currentAncillaryData`. + * @return Newly appended ancillary data. + */ + function appendKeyValueAddress( + bytes memory currentAncillaryData, + bytes memory key, + address value + ) internal pure returns (bytes memory) { + bytes memory prefix = constructPrefix(currentAncillaryData, key); + return abi.encodePacked(currentAncillaryData, prefix, toUtf8BytesAddress(value)); + } + + /** + * @notice Adds "key:value" to `currentAncillaryData` where `value` is a uint that first needs to be converted + * to utf8 bytes. For example, if `utf8(currentAncillaryData)="k1:v1"`, then this function will return + * `utf8(k1:v1,key:value)`, and if `currentAncillaryData` is blank, then this will return `utf8(key:value)`. + * @param currentAncillaryData This bytes data should ideally be able to be utf8-decoded, but its OK if not. + * @param key Again, this bytes data should ideally be able to be utf8-decoded, but its OK if not. + * @param value A uint to set as the value in the key:value pair to append to `currentAncillaryData`. + * @return Newly appended ancillary data. + */ + function appendKeyValueUint( + bytes memory currentAncillaryData, + bytes memory key, + uint256 value + ) internal pure returns (bytes memory) { + bytes memory prefix = constructPrefix(currentAncillaryData, key); + return abi.encodePacked(currentAncillaryData, prefix, toUtf8BytesUint(value)); + } + + /** + * @notice Helper method that returns the left hand side of a "key:value" pair plus the colon ":" and a leading + * comma "," if the `currentAncillaryData` is not empty. The return value is intended to be prepended as a prefix to + * some utf8 value that is ultimately added to a comma-delimited, key-value dictionary. + */ + function constructPrefix(bytes memory currentAncillaryData, bytes memory key) internal pure returns (bytes memory) { + if (currentAncillaryData.length > 0) { + return abi.encodePacked(",", key, ":"); + } else { + return abi.encodePacked(key, ":"); + } + } +} \ No newline at end of file diff --git a/contracts/mocks/OptimisticOracleV3Mock.sol b/contracts/mocks/OptimisticOracleV3Mock.sol new file mode 100644 index 00000000..3ddb4087 --- /dev/null +++ b/contracts/mocks/OptimisticOracleV3Mock.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import {OptimisticOracleV3Interface} from "../interfaces/OptimisticOracleV3Interface.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + + +interface callbackInterface { + function assertionDisputedCallback(bytes32 assertionId) external; + function assertionResolvedCallback(bytes32 assertionId, bool assertedTruthfully) external; +} + + +/** + * @title Optimistic Oracle V3. + * @notice The OOv3 is used to assert truths about the world which are verified using an optimistic escalation game. + * @dev Core idea: an asserter makes a statement about a truth, calling "assertTruth". If this statement is not + * challenged, it is taken as the state of the world. If challenged, it is arbitrated using the UMA DVM, or if + * configured, an escalation manager. Escalation managers enable integrations to define their own security properties and + * tradeoffs, enabling the notion of "sovereign security". + */ +contract OptimisticOracleV3Mock is OptimisticOracleV3Interface { + // Mock implementation of defaultIdentifier + function defaultIdentifier() public view override returns (bytes32) { + return (bytes32("helloWorld")); + } + + // Mock implementation of getAssertion + function getAssertion(bytes32 ) public view override returns (Assertion memory) { + return (Assertion({ + escalationManagerSettings: EscalationManagerSettings({ + arbitrateViaEscalationManager: false, + discardOracle: false, + validateDisputers: false, + assertingCaller: address(0), + escalationManager: address(0) + }), + asserter: address(0), + assertionTime: uint64(0), + settled: false, + currency: IERC20(address(0)), + expirationTime: uint64(0), + settlementResolution: false, + domainId: bytes32(0), + identifier: bytes32(0), + bond: uint256(0), + callbackRecipient: address(0), + disputer: address(0) + })); + } + + // Mock implementation of assertTruthWithDefaults + function assertTruthWithDefaults(bytes memory , address) public override returns (bytes32) { + return (bytes32(0)); + } + + // Mock implementation of assertTruth + function assertTruth(bytes memory, address , address , address , uint64 , IERC20 , uint256 , bytes32 , bytes32 ) public override returns (bytes32) { + return (bytes32("win")); + } + + // Mock implementation of syncUmaParams + function syncUmaParams(bytes32 identifier, address currency) public override { + // No return for void functions + } + + // Mock implementation of settleAssertion + function settleAssertion(bytes32 assertionId) public override { + // No return for void functions + } + + // Mock implementation of settleAndGetAssertionResult + function settleAndGetAssertionResult(bytes32 ) public override returns (bool) { + return (false); + } + + // Mock implementation of getAssertionResult + function getAssertionResult(bytes32 ) public view override returns (bool) { + return (false); + } + + // Mock implementation of getMinimumBond + function getMinimumBond(address ) public view override returns (uint256) { + return (uint256(0)); + } + + // Mock calling a target contract's assertionDisputedCallback + function mockAssertionDisputedCallback(address target, bytes32 assertionId) public { + callbackInterface(target).assertionDisputedCallback(assertionId); + } + + // Mock calling a target contract's assertionResolvedCallback + function mockAssertionResolvedCallback(address target, bytes32 assertionId, bool truthfully) public { + callbackInterface(target).assertionResolvedCallback(assertionId, truthfully); + } + +} \ No newline at end of file diff --git a/package.json b/package.json index d3bf0498..2eb4a4e2 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@types/lodash": "^4.14.86", "@types/mocha": "^7.0.2", "@types/node": "^14.0.5", + "bs58": "^5.0.0", "chai": "^4.2.0", "coveralls": "^3.0.1", "dotenv": "^8.2.0", diff --git a/test/global-extensions/globalOptimisticAuctionRebalanceExtension.spec.ts b/test/global-extensions/globalOptimisticAuctionRebalanceExtension.spec.ts new file mode 100644 index 00000000..d108c665 --- /dev/null +++ b/test/global-extensions/globalOptimisticAuctionRebalanceExtension.spec.ts @@ -0,0 +1,675 @@ +import "module-alias/register"; + +import { Address, Account } from "@utils/types"; +import { ADDRESS_ZERO, ZERO } from "@utils/constants"; +import { + GlobalOptimisticAuctionRebalanceExtension, DelegatedManager, ManagerCore, + ConstantPriceAdapter, OptimisticOracleV3Mock, StandardTokenMock +} from "@utils/contracts/index"; +import { SetToken } from "@utils/contracts/setV2"; +import DeployHelper from "@utils/deploys"; +import { + addSnapshotBeforeRestoreAfterEach, + ether, + getAccounts, + getSetFixture, + getWaffleExpect, + bitcoin, + usdc, + getTransactionTimestamp, + getRandomAccount, +} from "@utils/index"; +import { SetFixture } from "@utils/fixtures"; +import { BigNumber, ContractTransaction, utils } from "ethers"; +import base58 from "bs58"; + +const expect = getWaffleExpect(); + +function bufferToHex(buffer: Uint8Array) { + let hexStr = ""; + + for (let i = 0; i < buffer.length; i++) { + const hex = (buffer[i] & 0xff).toString(16); + hexStr += (hex.length === 1) ? "0" + hex : hex; + } + + return hexStr; +} + +// Base58 decoding function (make sure you have a proper Base58 decoding function) +function base58ToHexString(base58String: string) { + const bytes = base58.decode(base58String); // Decode base58 to a buffer + const hexString = bufferToHex(bytes.slice(2)); // Convert buffer to hex, excluding the first 2 bytes + return "0x" + hexString; +} + +describe.only("GlobalOptimisticAuctionRebalanceExtension", () => { + let owner: Account; + let methodologist: Account; + let operator: Account; + let factory: Account; + + + let setV2Setup: SetFixture; + + let deployer: DeployHelper; + let setToken: SetToken; + let managerCore: ManagerCore; + let delegatedManager: DelegatedManager; + + let auctionRebalanceExtension: GlobalOptimisticAuctionRebalanceExtension; + + let priceAdapter: ConstantPriceAdapter; + + let optimisticOracleV3Mock: OptimisticOracleV3Mock; + + let optimisticOracleV3MockUpgraded: OptimisticOracleV3Mock; + + let collateralAsset: StandardTokenMock; + + before(async () => { + [ + owner, + methodologist, + operator, + factory, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + + setV2Setup = getSetFixture(owner.address); + await setV2Setup.initialize(); + + priceAdapter = await deployer.setV2.deployConstantPriceAdapter(); + optimisticOracleV3Mock = await deployer.mocks.deployOptimisticOracleV3Mock(); + optimisticOracleV3MockUpgraded = await deployer.mocks.deployOptimisticOracleV3Mock(); + collateralAsset = await deployer.mocks.deployStandardTokenMock(owner.address, 18); + + await setV2Setup.integrationRegistry.addIntegration( + setV2Setup.auctionModule.address, + "ConstantPriceAdapter", + priceAdapter.address + ); + + managerCore = await deployer.managerCore.deployManagerCore(); + auctionRebalanceExtension = await deployer.globalExtensions.deployGlobalOptimisticAuctionRebalanceExtension( + managerCore.address, + setV2Setup.auctionModule.address + ); + + + setToken = await setV2Setup.createSetToken( + [setV2Setup.dai.address, setV2Setup.wbtc.address, setV2Setup.weth.address], + [ether(100), bitcoin(.01), ether(.1)], + [setV2Setup.auctionModule.address, setV2Setup.issuanceModule.address] + ); + + await setV2Setup.issuanceModule.initialize( + setToken.address, + ADDRESS_ZERO + ); + + delegatedManager = await deployer.manager.deployDelegatedManager( + setToken.address, + factory.address, + methodologist.address, + [auctionRebalanceExtension.address], + [operator.address], + [setV2Setup.dai.address, setV2Setup.weth.address], + true + ); + await setToken.setManager(delegatedManager.address); + + await managerCore.initialize([auctionRebalanceExtension.address], [factory.address]); + await managerCore.connect(factory.wallet).addManager(delegatedManager.address); + + + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", () => { + let subjectManagerCore: Address; + let subjectAuctionRebalanceModule: Address; + + beforeEach(async () => { + subjectManagerCore = managerCore.address; + subjectAuctionRebalanceModule = setV2Setup.auctionModule.address; + }); + + async function subject(): Promise { + const extension = await deployer.globalExtensions.deployGlobalOptimisticAuctionRebalanceExtension( + subjectManagerCore, + subjectAuctionRebalanceModule + ); + await delegatedManager.addExtensions([extension.address]); + return extension; + } + + it("should set the correct manager core", async () => { + const auctionExtension = await subject(); + + const actualManagerCore = await auctionExtension.managerCore(); + expect(actualManagerCore).to.eq(subjectManagerCore); + }); + + it("should set the correct auction rebalance module address", async () => { + const auctionExtension = await subject(); + + const actualAuctionRebalanceModule = await auctionExtension.auctionModule(); + expect(actualAuctionRebalanceModule).to.eq(subjectAuctionRebalanceModule); + }); + it("should be able to initialize extension and module at the same time", async () => { + const auctionExtension = await subject(); + await expect(auctionExtension.connect(owner.wallet).initializeModuleAndExtension(delegatedManager.address)).to.not.be.reverted; + }); + + it("should revert if module is initialized and extension is not", async () => { + const extension = await deployer.globalExtensions.deployGlobalOptimisticAuctionRebalanceExtension( + subjectManagerCore, + subjectAuctionRebalanceModule + ); + await expect(extension.connect(owner.wallet).initializeModule(delegatedManager.address)).to.be.revertedWith("Extension must be initialized"); + }); + + it("should revert if module is initialized without being added", async () => { + const extension = await deployer.globalExtensions.deployGlobalOptimisticAuctionRebalanceExtension( + subjectManagerCore, + subjectAuctionRebalanceModule + ); + await expect(extension.connect(owner.wallet).initializeModuleAndExtension(delegatedManager.address)).to.be.revertedWith("Extension must be pending"); + }); + + it("should revert if extension is initialized without being added", async () => { + const extension = await deployer.globalExtensions.deployGlobalOptimisticAuctionRebalanceExtension( + subjectManagerCore, + subjectAuctionRebalanceModule + ); + await expect(extension.connect(owner.wallet).initializeExtension(delegatedManager.address)).to.be.revertedWith("Extension must be pending"); + }); + + }); + + context("when auction rebalance extension is deployed and module needs to be initialized", () => { + let subjectCaller: Account; + let subjectDelegatedManager: Address; + + beforeEach(async () => { + subjectCaller = owner; + subjectDelegatedManager = delegatedManager.address; + await auctionRebalanceExtension.connect(subjectCaller.wallet).initializeExtension(delegatedManager.address); + }); + + describe("#initializeModule", () => { + + async function subject() { + return await auctionRebalanceExtension.connect(subjectCaller.wallet).initializeModule(subjectDelegatedManager); + } + + it("should initialize AuctionRebalanceModule", async () => { + await subject(); + const isInitialized = await setToken.isInitializedModule(setV2Setup.auctionModule.address); + expect(isInitialized).to.be.true; + }); + + it("should set the correct delegated manager for the given setToken", async () => { + await subject(); + const actualManager = await auctionRebalanceExtension.setManagers(setToken.address); + expect(actualManager).to.eq(delegatedManager.address); + }); + + describe("when the initializer is not the owner", () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + + context("when auction rebalance extension is deployed and initialized.", () => { + beforeEach(async () => { + await auctionRebalanceExtension.connect(owner.wallet).initializeModule(delegatedManager.address); + }); + + context("when the product settings have been set", () => { + beforeEach(async () => { + await auctionRebalanceExtension.connect(operator.wallet).setProductSettings(setToken.address, { + collateral: collateralAsset.address, + liveness: BigNumber.from(0), + bondAmount: BigNumber.from(0), + identifier: utils.formatBytes32String(""), + optimisticOracleV3: optimisticOracleV3Mock.address, + }, utils.arrayify(base58ToHexString("Qmc5gCcjYypU7y28oCALwfSvxCBskLuPKWpK4qpterKC7z"))); + + }); + + context("when a rebalance has been proposed", () => { + let subjectQuoteAsset: Address; + let subjectOldComponents: Address[]; + let subjectNewComponents: Address[]; + let subjectNewComponentsAuctionParams: any[]; + let subjectOldComponentsAuctionParams: any[]; + let subjectShouldLockSetToken: boolean; + let subjectRebalanceDuration: BigNumber; + let subjectPositionMultiplier: BigNumber; + let subjectCaller: Account; + let subjectSetToken: Address; + beforeEach(async () => { + subjectQuoteAsset = setV2Setup.weth.address; + + subjectOldComponents = [setV2Setup.dai.address, setV2Setup.wbtc.address, setV2Setup.weth.address]; + subjectNewComponents = [setV2Setup.usdc.address]; + + subjectNewComponentsAuctionParams = [ + { + targetUnit: usdc(100), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData(ether(0.005)), + }, + ]; + + subjectOldComponentsAuctionParams = [ + { + targetUnit: ether(50), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData(ether(0.005)), + }, + { + targetUnit: bitcoin(.01), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData(ether(0.005)), + }, + { + targetUnit: ether(.1), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData(ether(0.005)), + }, + ]; + + subjectShouldLockSetToken = true; + subjectRebalanceDuration = BigNumber.from(86400); + subjectPositionMultiplier = ether(.999); + subjectCaller = operator; + subjectSetToken = setToken.address; + }); + describe("#startRebalance", () => { + + + async function subject(): Promise { + await auctionRebalanceExtension.connect(subjectCaller.wallet).proposeRebalance( + subjectSetToken, + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectShouldLockSetToken, + subjectRebalanceDuration, + subjectPositionMultiplier + ); + return auctionRebalanceExtension.connect(subjectCaller.wallet).startRebalance( + subjectSetToken, + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectShouldLockSetToken, + subjectRebalanceDuration, + subjectPositionMultiplier + ); + } + + it("should set the auction execution params correctly", async () => { + await subject(); + expect(1).to.eq(1); + + const aggregateComponents = [...subjectOldComponents, ...subjectNewComponents]; + const aggregateAuctionParams = [...subjectOldComponentsAuctionParams, ...subjectNewComponentsAuctionParams]; + + for (let i = 0; i < aggregateAuctionParams.length; i++) { + const executionInfo = await setV2Setup.auctionModule.executionInfo(setToken.address, aggregateComponents[i]); + expect(executionInfo.targetUnit).to.eq(aggregateAuctionParams[i].targetUnit); + expect(executionInfo.priceAdapterName).to.eq(aggregateAuctionParams[i].priceAdapterName); + expect(executionInfo.priceAdapterConfigData).to.eq(aggregateAuctionParams[i].priceAdapterConfigData); + } + }); + + it("should set the rebalance info correctly", async () => { + const txnTimestamp = await getTransactionTimestamp(subject()); + + const rebalanceInfo = await setV2Setup.auctionModule.rebalanceInfo(setToken.address); + + expect(rebalanceInfo.quoteAsset).to.eq(subjectQuoteAsset); + expect(rebalanceInfo.rebalanceStartTime).to.eq(txnTimestamp); + expect(rebalanceInfo.rebalanceDuration).to.eq(subjectRebalanceDuration); + expect(rebalanceInfo.positionMultiplier).to.eq(subjectPositionMultiplier); + expect(rebalanceInfo.raiseTargetPercentage).to.eq(ZERO); + + const rebalanceComponents = await setV2Setup.auctionModule.getRebalanceComponents(setToken.address); + const aggregateComponents = [...subjectOldComponents, ...subjectNewComponents]; + + for (let i = 0; i < rebalanceComponents.length; i++) { + expect(rebalanceComponents[i]).to.eq(aggregateComponents[i]); + } + }); + + describe("when there are no new components", () => { + beforeEach(async () => { + subjectNewComponents = []; + subjectNewComponentsAuctionParams = []; + }); + + it("should set the auction execution params correctly", async () => { + await subject(); + + for (let i = 0; i < subjectOldComponents.length; i++) { + const executionInfo = await setV2Setup.auctionModule.executionInfo(setToken.address, subjectOldComponents[i]); + expect(executionInfo.targetUnit).to.eq(subjectOldComponentsAuctionParams[i].targetUnit); + expect(executionInfo.priceAdapterName).to.eq(subjectOldComponentsAuctionParams[i].priceAdapterName); + expect(executionInfo.priceAdapterConfigData).to.eq(subjectOldComponentsAuctionParams[i].priceAdapterConfigData); + } + }); + + it("should set the rebalance info correctly", async () => { + const txnTimestamp = await getTransactionTimestamp(subject()); + + const rebalanceInfo = await setV2Setup.auctionModule.rebalanceInfo(setToken.address); + + expect(rebalanceInfo.quoteAsset).to.eq(subjectQuoteAsset); + expect(rebalanceInfo.rebalanceStartTime).to.eq(txnTimestamp); + expect(rebalanceInfo.rebalanceDuration).to.eq(subjectRebalanceDuration); + expect(rebalanceInfo.positionMultiplier).to.eq(subjectPositionMultiplier); + expect(rebalanceInfo.raiseTargetPercentage).to.eq(ZERO); + + const rebalanceComponents = await setV2Setup.auctionModule.getRebalanceComponents(setToken.address); + for (let i = 0; i < rebalanceComponents.length; i++) { + expect(rebalanceComponents[i]).to.eq(subjectOldComponents[i]); + } + }); + }); + + describe("when old components are passed in different order", () => { + beforeEach(async () => { + subjectOldComponents = [setV2Setup.dai.address, setV2Setup.weth.address, setV2Setup.wbtc.address]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Mismatch: old and current components"); + }); + }); + + describe("when old components array is shorter than current components array", () => { + beforeEach(async () => { + subjectOldComponents = [setV2Setup.dai.address, setV2Setup.wbtc.address]; + subjectOldComponentsAuctionParams = [ + { + targetUnit: ether(50), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData(ether(0.005)), + }, + { + targetUnit: bitcoin(.01), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData(ether(0.005)), + }, + ]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Mismatch: old and current components length"); + }); + }); + + describe("when old components array is longer than current components array", () => { + beforeEach(async () => { + const price = await priceAdapter.getEncodedData(ether(1)); + subjectOldComponents = [setV2Setup.dai.address, setV2Setup.wbtc.address, setV2Setup.weth.address, setV2Setup.usdc.address]; + subjectOldComponentsAuctionParams = [ + { + targetUnit: ether(50), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: price, + }, + { + targetUnit: bitcoin(.01), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: price, + }, + { + targetUnit: ether(.1), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: price, + }, + { + targetUnit: usdc(100), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: price, + }, + ]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Mismatch: old and current components length"); + }); + }); + + describe("when not all old components have an entry", () => { + beforeEach(async () => { + subjectOldComponents = [setV2Setup.dai.address, setV2Setup.wbtc.address, setV2Setup.usdc.address]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Mismatch: old and current components"); + }); + }); + + describe("when the caller is not the operator", () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should not revert", async () => { + await expect(subject()).not.to.be.reverted; + }); + }); + }); + describe("assertionDisputedCallback", () => { + it("should delete the proposal on a disputed callback", async () => { + await auctionRebalanceExtension.connect(subjectCaller.wallet).proposeRebalance( + subjectSetToken, + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectShouldLockSetToken, + subjectRebalanceDuration, + subjectPositionMultiplier + ); + const proposal = await auctionRebalanceExtension.connect(subjectCaller.wallet).proposedProduct(utils.formatBytes32String("win")); + expect(proposal.product).to.eq(subjectSetToken); + + await expect(optimisticOracleV3Mock.connect(subjectCaller.wallet).mockAssertionDisputedCallback( + auctionRebalanceExtension.address, utils.formatBytes32String("win") + )).to.not.be.reverted; + + const proposalAfter = await auctionRebalanceExtension.connect(subjectCaller.wallet).proposedProduct(utils.formatBytes32String("win")); + expect(proposalAfter.product).to.eq(ADDRESS_ZERO); + }); + it("should delete the proposal on a disputed callback from currently set oracle", async () => { + await auctionRebalanceExtension.connect(subjectCaller.wallet).proposeRebalance( + subjectSetToken, + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectShouldLockSetToken, + subjectRebalanceDuration, + subjectPositionMultiplier + ); + await auctionRebalanceExtension.connect(operator.wallet).setProductSettings(setToken.address, { + collateral: collateralAsset.address, + liveness: BigNumber.from(0), + bondAmount: BigNumber.from(0), + identifier: utils.formatBytes32String(""), + optimisticOracleV3: optimisticOracleV3MockUpgraded.address, + }, utils.arrayify(base58ToHexString("Qmc5gCcjYypU7y28oCALwfSvxCBskLuPKWpK4qpterKC7z"))); + const proposal = await auctionRebalanceExtension.connect(subjectCaller.wallet).proposedProduct(utils.formatBytes32String("win")); + expect(proposal.product).to.eq(subjectSetToken); + await expect(optimisticOracleV3Mock.connect(subjectCaller.wallet).mockAssertionDisputedCallback( + auctionRebalanceExtension.address, utils.formatBytes32String("win") + )).to.not.be.reverted; + const proposalAfter = await auctionRebalanceExtension.connect(subjectCaller.wallet).proposedProduct(utils.formatBytes32String("win")); + expect(proposalAfter.product).to.eq(ADDRESS_ZERO); + + }); + }); + describe("assertionResolvedCallback", () => { + it("should not revert on a resolved callback", async () => { + await auctionRebalanceExtension.connect(subjectCaller.wallet).proposeRebalance( + subjectSetToken, + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectShouldLockSetToken, + subjectRebalanceDuration, + subjectPositionMultiplier + ); + await expect(optimisticOracleV3Mock.connect(subjectCaller.wallet).mockAssertionResolvedCallback( + auctionRebalanceExtension.address, utils.formatBytes32String("win"), true) + ).to.not.be.reverted; + }); + }); + + }); + describe("#setRaiseTargetPercentage", () => { + let subjectRaiseTargetPercentage: BigNumber; + let subjectCaller: Account; + + beforeEach(async () => { + subjectRaiseTargetPercentage = ether(.001); + subjectCaller = operator; + }); + + async function subject(): Promise { + return await auctionRebalanceExtension.connect(subjectCaller.wallet).setRaiseTargetPercentage( + setToken.address, + subjectRaiseTargetPercentage, + ); + } + + it("should correctly set the raiseTargetPercentage", async () => { + await subject(); + + const actualRaiseTargetPercentage = (await setV2Setup.auctionModule.rebalanceInfo(setToken.address)).raiseTargetPercentage; + + expect(actualRaiseTargetPercentage).to.eq(subjectRaiseTargetPercentage); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be approved operator"); + }); + }); + }); + + describe("#setBidderStatus", () => { + let subjectBidders: Address[]; + let subjectStatuses: boolean[]; + let subjectCaller: Account; + + beforeEach(async () => { + subjectBidders = [methodologist.address]; + subjectStatuses = [true]; + subjectCaller = operator; + }); + + async function subject(): Promise { + return await auctionRebalanceExtension.connect(subjectCaller.wallet).setBidderStatus( + setToken.address, + subjectBidders, + subjectStatuses + ); + } + + it("should correctly set the bidder status", async () => { + await subject(); + + const isCaller = await setV2Setup.auctionModule.isAllowedBidder(setToken.address, subjectBidders[0]); + + expect(isCaller).to.be.true; + }); + + describe("when the caller is not the operator", () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be approved operator"); + }); + }); + }); + + describe("#setAnyoneBid", () => { + let subjectStatus: boolean; + let subjectCaller: Account; + + beforeEach(async () => { + subjectStatus = true; + subjectCaller = operator; + }); + + async function subject(): Promise { + return await auctionRebalanceExtension.connect(subjectCaller.wallet).setAnyoneBid( + setToken.address, + subjectStatus + ); + } + + it("should correctly set anyone bid", async () => { + await subject(); + + const anyoneBid = await setV2Setup.auctionModule.permissionInfo(setToken.address); + + expect(anyoneBid).to.be.true; + }); + + describe("when the caller is not the operator", () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be approved operator"); + }); + }); + }); + + describe("#removeExtension", () => { + async function subject() { + return await delegatedManager.connect(owner.wallet).removeExtensions([auctionRebalanceExtension.address]); + } + it("should remove the extension", async () => { + const setManagerBeforeRemove = auctionRebalanceExtension.setManagers(setToken.address); + await subject(); + const setManagerAfterRemove = auctionRebalanceExtension.setManagers(setToken.address); + + expect(setManagerBeforeRemove).to.not.eq(setManagerAfterRemove); + }); + }); + }); + }); + }); +}); diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index 54edd46d..bbfb9235 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -63,4 +63,6 @@ export { GlobalStreamingFeeSplitExtension } from "../../typechain/GlobalStreamin export { GlobalBatchTradeExtension } from "../../typechain/GlobalBatchTradeExtension"; export { GlobalWrapExtension } from "../../typechain/GlobalWrapExtension"; export { GlobalClaimExtension } from "../../typechain/GlobalClaimExtension"; -export { GlobalAuctionRebalanceExtension } from "../../typechain/GlobalAuctionRebalanceExtension"; \ No newline at end of file +export { GlobalAuctionRebalanceExtension } from "../../typechain/GlobalAuctionRebalanceExtension"; +export { GlobalOptimisticAuctionRebalanceExtension } from "../../typechain/GlobalOptimisticAuctionRebalanceExtension"; +export { OptimisticOracleV3Mock } from "../../typechain/OptimisticOracleV3Mock"; \ No newline at end of file diff --git a/utils/deploys/deployGlobalExtensions.ts b/utils/deploys/deployGlobalExtensions.ts index 657e8aaf..f3f697bb 100644 --- a/utils/deploys/deployGlobalExtensions.ts +++ b/utils/deploys/deployGlobalExtensions.ts @@ -17,6 +17,7 @@ import { GlobalStreamingFeeSplitExtension__factory } from "../../typechain/facto import { GlobalTradeExtension__factory } from "../../typechain/factories/GlobalTradeExtension__factory"; import { GlobalWrapExtension__factory } from "../../typechain/factories/GlobalWrapExtension__factory"; import { GlobalAuctionRebalanceExtension__factory } from "../../typechain/factories/GlobalAuctionRebalanceExtension__factory"; +import { GlobalOptimisticAuctionRebalanceExtension__factory } from "../../typechain/factories/GlobalOptimisticAuctionRebalanceExtension__factory"; export default class DeployGlobalExtensions { private _deployerSigner: Signer; @@ -100,4 +101,14 @@ export default class DeployGlobalExtensions { auctionModule, ); } + + public async deployGlobalOptimisticAuctionRebalanceExtension( + managerCore: Address, + auctionModule: Address + ) { + return await new GlobalOptimisticAuctionRebalanceExtension__factory(this._deployerSigner).deploy( + { managerCore: managerCore, auctionModule: auctionModule } + ); + } + } diff --git a/utils/deploys/deployMocks.ts b/utils/deploys/deployMocks.ts index 966c8bb7..faf24229 100644 --- a/utils/deploys/deployMocks.ts +++ b/utils/deploys/deployMocks.ts @@ -48,6 +48,7 @@ import { AaveV2LendingPoolMock__factory } from "@typechain/factories/AaveV2Lendi import { AaveV2LendingPoolMock } from "@typechain/AaveV2LendingPoolMock"; import { FlashMintLeveragedCompMock } from "@typechain/FlashMintLeveragedCompMock"; import { FlashMintLeveragedCompMock__factory } from "@typechain/factories/FlashMintLeveragedCompMock__factory"; +import { OptimisticOracleV3Mock__factory } from "@typechain/factories/OptimisticOracleV3Mock__factory"; export default class DeployMocks { private _deployerSigner: Signer; @@ -223,4 +224,8 @@ export default class DeployMocks { public async deployManagerMock(setToken: Address): Promise { return await new ManagerMock__factory(this._deployerSigner).deploy(setToken); } + + public async deployOptimisticOracleV3Mock() { + return await new OptimisticOracleV3Mock__factory(this._deployerSigner).deploy(); + } } diff --git a/yarn.lock b/yarn.lock index 7c352dc4..a8c63d23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2492,6 +2492,11 @@ base-x@^3.0.8: dependencies: safe-buffer "^5.0.1" +base-x@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-4.0.0.tgz#d0e3b7753450c73f8ad2389b5c018a4af7b2224a" + integrity sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw== + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -2731,6 +2736,13 @@ bs58@^4.0.0: dependencies: base-x "^3.0.2" +bs58@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-5.0.0.tgz#865575b4d13c09ea2a84622df6c8cbeb54ffc279" + integrity sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ== + dependencies: + base-x "^4.0.0" + bs58check@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/bs58check/-/bs58check-2.1.2.tgz#53b018291228d82a5aa08e7d796fdafda54aebfc"