From 3fc56f44d8b22976353913b0e555f8fddffd3b6f Mon Sep 17 00:00:00 2001 From: christn Date: Wed, 10 Jan 2024 01:43:06 +0800 Subject: [PATCH] feat(extension): Add OptimisticAuctionRebalanceExtension (#156) * Initialize OptimisticAuctionRebalance module from Global version and fix compilation issues * Add AssetAllowList to BaseOptimisticRebalanceExtension * Use extisting AuctionRebalanceExtension * Only allowed assets as new components * Port tests from global version * smol fix * PR Feedback round 2 * PR Feedback round 3 * Simplify constructor tests * Copy unit tests and run integrated with existing dseth deployment * Test failing with unsupported identifier error * All tests passing against uma deployed oracle * Set block number individually in test * Add isOpen boolean gate * Set isOpen to false after starting rebalance * Add onlyIfOpen modifier to startRebalance function * Refactor / extend integration tests * Disable locking set token * Adjust tests * Add V1 suffix * Remove shouldLockSetToken argument entirely from propose method * Add events for updating the isOpen parameter * chore(edits): UMA Extension Edits (#157) * Add events for updating the isOpen parameter * lintoor * remove .only() * lintoor * fix AssetAllowList nits * Run only new integration tests * Fix unittests * Add tests for require statements in proposeRebalance * Add tests for emitted events * Extend event emission test * Adjust integration test to use index token as proposer collateral (currently failing) * integration test test * cleanups * edit integration tests * Test bond transfer and claim construction * Fix unittest --------- Co-authored-by: ckoopmann * Add comment with link to optimistic governor reference implementation of _constructClaim * Additional tests arround asset allow list * Additional tests to increase coverage * Declare setToken and auctionModule as immutable * Remove Proposal struct because we are only managing one product per extension * Additional tests to trigger require statements in dispute callback * Fix integration tests * Update contracts/adapters/AuctionRebalanceExtension.sol * integration test cleanups * fix buy auction params * Update contracts/adapters/OptimisticAuctionRebalanceExtensionV1.sol * cleanups * Dummy commit to retrigger CI --------- Co-authored-by: pblivin0x <84149824+pblivin0x@users.noreply.github.com> Co-authored-by: Pranav Bhardwaj --- .../adapters/AuctionRebalanceExtension.sol | 5 +- .../OptimisticAuctionRebalanceExtensionV1.sol | 411 ++++++ contracts/interfaces/IIdentifierWhitelist.sol | 15 + .../OptimisticOracleV3Interface.sol | 11 +- contracts/lib/AssetAllowList.sol | 147 +++ contracts/mocks/OptimisticOracleV3Mock.sol | 13 +- .../BoundedStepwiseLinearPriceAdapter.json | 206 +++ .../set/BoundedStepwiseLinearPriceAdapter.sol | 169 +++ ...imisticAuctionRebalanceExtensionV1.spec.ts | 1100 +++++++++++++++++ ...ptimisticAuctionRebalanceExtension.spec.ts | 22 +- test/integration/ethereum/addresses.ts | 17 +- ...imisticAuctionRebalanceExtenisonV1.spec.ts | 662 ++++++++++ test/manager/baseManagerV2.spec.ts | 4 +- utils/common/conversionUtils.ts | 21 +- utils/common/index.ts | 4 +- utils/contracts/index.ts | 3 +- utils/contracts/setV2.ts | 1 + utils/deploys/deployExtensions.ts | 19 +- 18 files changed, 2797 insertions(+), 33 deletions(-) create mode 100644 contracts/adapters/OptimisticAuctionRebalanceExtensionV1.sol create mode 100644 contracts/interfaces/IIdentifierWhitelist.sol create mode 100644 contracts/lib/AssetAllowList.sol create mode 100644 external/abi/set/BoundedStepwiseLinearPriceAdapter.json create mode 100644 external/contracts/set/BoundedStepwiseLinearPriceAdapter.sol create mode 100644 test/adapters/optimisticAuctionRebalanceExtensionV1.spec.ts create mode 100644 test/integration/ethereum/optimisticAuctionRebalanceExtenisonV1.spec.ts diff --git a/contracts/adapters/AuctionRebalanceExtension.sol b/contracts/adapters/AuctionRebalanceExtension.sol index 120119a4..f511d568 100644 --- a/contracts/adapters/AuctionRebalanceExtension.sol +++ b/contracts/adapters/AuctionRebalanceExtension.sol @@ -49,8 +49,8 @@ contract AuctionRebalanceExtension is BaseExtension { /* ============ State Variables ============ */ - ISetToken public setToken; - IAuctionRebalanceModuleV1 public auctionModule; // AuctionRebalanceModuleV1 + ISetToken public immutable setToken; + IAuctionRebalanceModuleV1 public immutable auctionModule; // AuctionRebalanceModuleV1 /* ============ Constructor ============ */ @@ -88,6 +88,7 @@ contract AuctionRebalanceExtension is BaseExtension { uint256 _positionMultiplier ) external + virtual onlyOperator { address[] memory currentComponents = setToken.getComponents(); diff --git a/contracts/adapters/OptimisticAuctionRebalanceExtensionV1.sol b/contracts/adapters/OptimisticAuctionRebalanceExtensionV1.sol new file mode 100644 index 00000000..0213c74a --- /dev/null +++ b/contracts/adapters/OptimisticAuctionRebalanceExtensionV1.sol @@ -0,0 +1,411 @@ +/* + 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 { AncillaryData } from "../lib/AncillaryData.sol"; +import { AssetAllowList } from "../lib/AssetAllowList.sol"; +import { AuctionRebalanceExtension } from "./AuctionRebalanceExtension.sol"; +import { IAuctionRebalanceModuleV1 } from "../interfaces/IAuctionRebalanceModuleV1.sol"; +import { IBaseManager } from "../interfaces/IBaseManager.sol"; +import { ISetToken } from "../interfaces/ISetToken.sol"; +import { OptimisticOracleV3Interface } from "../interfaces/OptimisticOracleV3Interface.sol"; + +/** + * @title OptimisticAuctionRebalanceExtension + * @author Index Coop + * + * @dev The contract extends `AuctionRebalanceExtension` 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. + * @dev Version 1 is characterised by: Optional Asset Whitelist, No Set Token locking, control over rebalance timing via "isOpen" flag + */ +contract OptimisticAuctionRebalanceExtensionV1 is AuctionRebalanceExtension, AssetAllowList { + using AddressArrayUtils for address[]; + using SafeERC20 for IERC20; + + /* ============ Events ============ */ + + event ProductSettingsUpdated( + IERC20 indexed setToken, + address indexed manager, + OptimisticRebalanceParams optimisticParams, + bytes32 indexed rulesHash + ); + + event RebalanceProposed( + ISetToken indexed setToken, + IERC20 indexed quoteAsset, + address[] oldComponents, + address[] newComponents, + AuctionExecutionParams[] newComponentsAuctionParams, + AuctionExecutionParams[] oldComponentsAuctionParams, + uint256 rebalanceDuration, + uint256 positionMultiplier + ); + + event AssertedClaim( + IERC20 indexed setToken, + address indexed _assertedBy, + bytes32 indexed rulesHash, + bytes32 _assertionId, + bytes _claimData + ); + + event ProposalDeleted( + bytes32 assertionID, + bytes32 indexed proposalHash + ); + + event IsOpenUpdated( + bool indexed isOpen + ); + + /* ============ Structs ============ */ + + struct AuctionExtensionParams { + IBaseManager baseManager; // Manager Contract of the set token for which to deploy this extension + IAuctionRebalanceModuleV1 auctionModule; // Contract that rebalances index sets via single-asset auctions + bool useAssetAllowlist; // Bool indicating whether to use asset allow list + address[] allowedAssets; // Array of allowed assets + } + + 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. + } + + /* ============ State Variables ============ */ + + ProductSettings public productSettings; + mapping(bytes32 => bytes32) public assertionIds; // Maps proposal hashes to assertionIds. + mapping(bytes32 => bytes32) public assertionIdToProposalHash; // Maps assertionIds to a proposal hash. + bool public isOpen; // Bool indicating whether the extension is open for proposing rebalances. + + // Keys for assertion claim data. + bytes public constant PROPOSAL_HASH_KEY = "proposalHash"; + bytes public constant RULES_KEY = "rulesIPFSHash"; + + /* ============ Constructor ============ */ + + /* + * @dev Initializes the OptimisticAuctionRebalanceExtension with the passed parameters. + * + * @param _auctionParams AuctionExtensionParams struct containing the baseManager and auctionModule addresses. + */ + constructor( + AuctionExtensionParams memory _auctionParams + ) + public + AuctionRebalanceExtension(_auctionParams.baseManager, _auctionParams.auctionModule) + AssetAllowList(_auctionParams.allowedAssets, _auctionParams.useAssetAllowlist) + {} + + /* ============ Modifier ============ */ + + modifier onlyIfOpen() { + require(isOpen, "Must be open for rebalancing"); + _; + } + + /* ============ External Functions ============ */ + + /** + * ONLY OPERATOR: Add new asset(s) that can be included as new components in rebalances + * + * @param _assets New asset(s) to add + */ + function addAllowedAssets(address[] memory _assets) external onlyOperator { + _addAllowedAssets(_assets); + } + + /** + * ONLY OPERATOR: Remove asset(s) so that it/they can't be included as new components in rebalances + * + * @param _assets Asset(s) to remove + */ + function removeAllowedAssets(address[] memory _assets) external onlyOperator { + _removeAllowedAssets(_assets); + } + + /** + * ONLY OPERATOR: Toggle useAssetAllowlist on and off. When false asset allowlist is ignored + * when true it is enforced. + * + * @param _useAssetAllowlist Bool indicating whether to use asset allow list + */ + function updateUseAssetAllowlist(bool _useAssetAllowlist) external onlyOperator { + _updateUseAssetAllowlist(_useAssetAllowlist); + } + + /** + * ONLY OPERATOR: Toggle isOpen on and off. When false the extension is closed for proposing rebalances. + * when true it is open. + * + * @param _isOpen Bool indicating whether the extension is open for proposing rebalances. + */ + function updateIsOpen(bool _isOpen) external onlyOperator { + _updateIsOpen(_isOpen); + } + + /** + * @dev OPERATOR ONLY: sets product settings for a given set token + * @param _optimisticParams OptimisticRebalanceParams struct containing optimistic rebalance parameters. + * @param _rulesHash bytes32 containing the ipfs hash rules for the product. + */ + function setProductSettings( + OptimisticRebalanceParams memory _optimisticParams, + bytes32 _rulesHash + ) + external + onlyOperator + { + productSettings = ProductSettings({ + optimisticParams: _optimisticParams, + rulesHash: _rulesHash + }); + + emit ProductSettingsUpdated(setToken, setToken.manager(), _optimisticParams, _rulesHash); + } + + /** + * @dev IF OPEN ONLY: Proposes a rebalance for the SetToken using the Optimistic Oracle V3. + * + * @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 _rebalanceDuration Duration of the rebalance in seconds. + * @param _positionMultiplier Position multiplier at the time target units were calculated. + */ + function proposeRebalance( + IERC20 _quoteAsset, + address[] memory _oldComponents, + address[] memory _newComponents, + AuctionExecutionParams[] memory _newComponentsAuctionParams, + AuctionExecutionParams[] memory _oldComponentsAuctionParams, + uint256 _rebalanceDuration, + uint256 _positionMultiplier + ) + external + onlyAllowedAssets(_newComponents) + onlyIfOpen() + { + bytes32 proposalHash = keccak256(abi.encode( + setToken, + _quoteAsset, + _oldComponents, + _newComponents, + _newComponentsAuctionParams, + _oldComponentsAuctionParams, + false, // We don't allow locking the set token in this version + _rebalanceDuration, + _positionMultiplier + )); + require(assertionIds[proposalHash] == bytes32(0), "Proposal already exists"); + require(productSettings.rulesHash != bytes32(""), "Rules not set"); + require(address(productSettings.optimisticParams.optimisticOracleV3) != address(0), "Oracle not set"); + + bytes memory claim = _constructClaim(proposalHash, productSettings.rulesHash); + uint256 totalBond = _pullBond(productSettings.optimisticParams); + + bytes32 assertionId = productSettings.optimisticParams.optimisticOracleV3.assertTruth( + claim, + msg.sender, + address(this), + address(0), + productSettings.optimisticParams.liveness, + productSettings.optimisticParams.collateral, + totalBond, + productSettings.optimisticParams.identifier, + bytes32(0) + ); + + assertionIds[proposalHash] = assertionId; + assertionIdToProposalHash[assertionId] = proposalHash; + + emit RebalanceProposed( setToken, _quoteAsset, _oldComponents, _newComponents, _newComponentsAuctionParams, _oldComponentsAuctionParams, _rebalanceDuration, _positionMultiplier); + emit AssertedClaim(setToken, msg.sender, productSettings.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. Has to be false in this version + * @param _rebalanceDuration Duration of the rebalance in seconds. + * @param _positionMultiplier Position multiplier at the time target units were calculated. + */ + function startRebalance( + IERC20 _quoteAsset, + address[] memory _oldComponents, + address[] memory _newComponents, + AuctionExecutionParams[] memory _newComponentsAuctionParams, + AuctionExecutionParams[] memory _oldComponentsAuctionParams, + bool _shouldLockSetToken, + uint256 _rebalanceDuration, + uint256 _positionMultiplier + ) + external + override + onlyIfOpen() + { + 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"); + + _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. + productSettings.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(address(auctionModule), callData); + _updateIsOpen(false); + } + + /** + * @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 { + bytes32 proposalHash = assertionIdToProposalHash[_assertionId]; + + require(address(productSettings.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(productSettings.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(proposalHash != bytes32(0), "Invalid proposal hash"); + require(productSettings.optimisticParams.optimisticOracleV3.getAssertion(_assertionId).asserter == address(0), "Oracle has assertion"); + _deleteProposal(_assertionId); + } + emit ProposalDeleted(_assertionId, proposalHash); + } + + /* ============ Internal Functions ============ */ + + // Constructs the claim that will be asserted at the Optimistic Oracle V3. + // @dev Inspired by the equivalent function in the OptimisticGovernor: https://github.com/UMAprotocol/protocol/blob/96cf5be32a3f57ac761f004890dd3466c63e1fa5/packages/core/contracts/optimistic-governor/implementation/OptimisticGovernor.sol#L437 + function _constructClaim(bytes32 proposalHash, bytes32 rulesHash) internal pure returns (bytes memory) { + return + abi.encodePacked( + AncillaryData.appendKeyValueBytes32("", PROPOSAL_HASH_KEY, proposalHash), + ",", + RULES_KEY, + ":\"", + rulesHash, + "\"" + ); + } + + /// @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 { + bytes32 proposalHash = assertionIdToProposalHash[assertionId]; + delete assertionIds[proposalHash]; + delete assertionIdToProposalHash[assertionId]; + } + + /// @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; + } + + function _updateIsOpen(bool _isOpen) internal { + isOpen = _isOpen; + emit IsOpenUpdated(_isOpen); + } +} diff --git a/contracts/interfaces/IIdentifierWhitelist.sol b/contracts/interfaces/IIdentifierWhitelist.sol new file mode 100644 index 00000000..4c7fee57 --- /dev/null +++ b/contracts/interfaces/IIdentifierWhitelist.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +interface IIdentifierWhitelist { + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event SupportedIdentifierAdded(bytes32 indexed identifier); + event SupportedIdentifierRemoved(bytes32 indexed identifier); + + function addSupportedIdentifier(bytes32 identifier) external; + function isIdentifierSupported(bytes32 identifier) external view returns (bool); + function owner() external view returns (address); + function removeSupportedIdentifier(bytes32 identifier) external; + function renounceOwnership() external; + function transferOwnership(address newOwner) external; +} diff --git a/contracts/interfaces/OptimisticOracleV3Interface.sol b/contracts/interfaces/OptimisticOracleV3Interface.sol index 168cb8ea..d9be9a64 100644 --- a/contracts/interfaces/OptimisticOracleV3Interface.sol +++ b/contracts/interfaces/OptimisticOracleV3Interface.sol @@ -39,6 +39,15 @@ interface OptimisticOracleV3Interface { 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. @@ -167,4 +176,4 @@ interface OptimisticOracleV3Interface { ); event AdminPropertiesSet(IERC20 defaultCurrency, uint64 defaultLiveness, uint256 burnedBondPercentage); -} \ No newline at end of file +} diff --git a/contracts/lib/AssetAllowList.sol b/contracts/lib/AssetAllowList.sol new file mode 100644 index 00000000..845f4e69 --- /dev/null +++ b/contracts/lib/AssetAllowList.sol @@ -0,0 +1,147 @@ +/* + 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; +import { AddressArrayUtils } from "./AddressArrayUtils.sol"; + +/** + * @title AssetAllowList + * @author Index Coop + * + * Abstract contract that allows inheriting contracts to restrict the assets that can be traded to, wrapped to, or claimed + */ +abstract contract AssetAllowList { + using AddressArrayUtils for address[]; + + /* ============ Events ============ */ + + event AllowedAssetAdded( + address indexed _asset + ); + + event AllowedAssetRemoved( + address indexed _asset + ); + + event UseAssetAllowlistUpdated( + bool _status + ); + + /* ============ State Variables ============ */ + + // Boolean indicating wether to use asset allow list + bool public useAssetAllowlist; + + // Mapping keeping track of allowed assets + mapping(address => bool) public assetAllowlist; + + // List of allowed assets + address[] internal allowedAssets; + + /* ============ Modifiers ============ */ + + modifier onlyAllowedAssets(address[] memory _assets) { + require( + _areAllowedAssets(_assets), + "Invalid asset" + ); + _; + } + + /* ============ Constructor ============ */ + + /** + * Set state variables and map asset pairs to their oracles + * + * @param _allowedAssets Array of allowed assets + * @param _useAssetAllowlist Bool indicating whether to use asset allow list + */ + constructor(address[] memory _allowedAssets, bool _useAssetAllowlist) public { + _addAllowedAssets(_allowedAssets); + _updateUseAssetAllowlist(_useAssetAllowlist); + } + + /* ============ External Functions ============ */ + + function getAllowedAssets() external view returns(address[] memory) { + return allowedAssets; + } + + /* ============ Internal Functions ============ */ + + + /** + * Add new assets that can be traded to, wrapped to, or claimed + * + * @param _assets New asset to add + */ + function _addAllowedAssets(address[] memory _assets) internal { + for (uint256 i = 0; i < _assets.length; i++) { + address asset = _assets[i]; + + require(!assetAllowlist[asset], "Asset already added"); + + allowedAssets.push(asset); + + assetAllowlist[asset] = true; + + emit AllowedAssetAdded(asset); + } + } + + /** + * Remove asset(s) so that it/they can't be traded to, wrapped to, or claimed + * + * @param _assets Asset(s) to remove + */ + function _removeAllowedAssets(address[] memory _assets) internal { + for (uint256 i = 0; i < _assets.length; i++) { + address asset = _assets[i]; + + require(assetAllowlist[asset], "Asset not already added"); + + allowedAssets.removeStorage(asset); + + assetAllowlist[asset] = false; + + emit AllowedAssetRemoved(asset); + } + } + + /** + * Toggle useAssetAllowlist on and off. When false asset allowlist is ignored + * when true it is enforced. + * + * @param _useAssetAllowlist Bool indicating whether to use asset allow list + */ + function _updateUseAssetAllowlist(bool _useAssetAllowlist) internal { + useAssetAllowlist = _useAssetAllowlist; + + emit UseAssetAllowlistUpdated(_useAssetAllowlist); + } + + /// @notice Check that all assets in array are allowed to be treated + /// @dev ca be bypassed by setting the useAssetAllowlist to false (default) + function _areAllowedAssets(address[] memory _assets) internal view returns(bool) { + if (!useAssetAllowlist) { return true; } + for (uint256 i = 0; i < _assets.length; i++) { + if (!assetAllowlist[_assets[i]]) { return false; } + } + return true; + } +} diff --git a/contracts/mocks/OptimisticOracleV3Mock.sol b/contracts/mocks/OptimisticOracleV3Mock.sol index 3ddb4087..33d53b7d 100644 --- a/contracts/mocks/OptimisticOracleV3Mock.sol +++ b/contracts/mocks/OptimisticOracleV3Mock.sol @@ -21,11 +21,16 @@ interface callbackInterface { * tradeoffs, enabling the notion of "sovereign security". */ contract OptimisticOracleV3Mock is OptimisticOracleV3Interface { + address public asserter; // Mock implementation of defaultIdentifier function defaultIdentifier() public view override returns (bytes32) { return (bytes32("helloWorld")); } + function setAsserter(address _asserter) public { + asserter = _asserter; + } + // Mock implementation of getAssertion function getAssertion(bytes32 ) public view override returns (Assertion memory) { return (Assertion({ @@ -36,7 +41,7 @@ contract OptimisticOracleV3Mock is OptimisticOracleV3Interface { assertingCaller: address(0), escalationManager: address(0) }), - asserter: address(0), + asserter: asserter, assertionTime: uint64(0), settled: false, currency: IERC20(address(0)), @@ -80,6 +85,10 @@ contract OptimisticOracleV3Mock is OptimisticOracleV3Interface { return (false); } + function disputeAssertion(bytes32 assertionId, address disputer) external override { + revert("Not implemented"); + } + // Mock implementation of getMinimumBond function getMinimumBond(address ) public view override returns (uint256) { return (uint256(0)); @@ -95,4 +104,4 @@ contract OptimisticOracleV3Mock is OptimisticOracleV3Interface { callbackInterface(target).assertionResolvedCallback(assertionId, truthfully); } -} \ No newline at end of file +} diff --git a/external/abi/set/BoundedStepwiseLinearPriceAdapter.json b/external/abi/set/BoundedStepwiseLinearPriceAdapter.json new file mode 100644 index 00000000..25087d2b --- /dev/null +++ b/external/abi/set/BoundedStepwiseLinearPriceAdapter.json @@ -0,0 +1,206 @@ +{ + "_format": "hh-sol-artifact-1", + "contractName": "BoundedStepwiseLinearPriceAdapter", + "sourceName": "contracts/protocol/integration/auction-price/BoundedStepwiseLinearPriceAdapter.sol", + "abi": [ + { + "inputs": [ + { + "internalType": "uint256", + "name": "_initialPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_slope", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_bucketSize", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_maxPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_minPrice", + "type": "uint256" + } + ], + "name": "areParamsValid", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "pure", + "type": "function", + "gas": "0xa7d8c0" + }, + { + "inputs": [ + { + "internalType": "bytes", + "name": "_data", + "type": "bytes" + } + ], + "name": "getDecodedData", + "outputs": [ + { + "internalType": "uint256", + "name": "initialPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "slope", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "bucketSize", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "isDecreasing", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "maxPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "minPrice", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function", + "gas": "0xa7d8c0" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_initialPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_slope", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_bucketSize", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "_isDecreasing", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "_maxPrice", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_minPrice", + "type": "uint256" + } + ], + "name": "getEncodedData", + "outputs": [ + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "stateMutability": "pure", + "type": "function", + "gas": "0xa7d8c0" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_timeElapsed", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_priceAdapterConfigData", + "type": "bytes" + } + ], + "name": "getPrice", + "outputs": [ + { + "internalType": "uint256", + "name": "price", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function", + "gas": "0xa7d8c0" + }, + { + "inputs": [ + { + "internalType": "bytes", + "name": "_priceAdapterConfigData", + "type": "bytes" + } + ], + "name": "isPriceAdapterConfigDataValid", + "outputs": [ + { + "internalType": "bool", + "name": "isValid", + "type": "bool" + } + ], + "stateMutability": "pure", + "type": "function", + "gas": "0xa7d8c0" + } + ], + "bytecode": "0x608060405234801561001057600080fd5b506106d0806100206000396000f3fe608060405234801561001057600080fd5b50600436106100575760003560e01c8063079f6bf41461005c578063104b9b641461008457806363dc2e6b146100a5578063bf160e2514610105578063d3516db614610118575b600080fd5b61006f61006a36600461040a565b61015a565b60405190151581526020015b60405180910390f35b610097610092366004610463565b61018f565b60405190815260200161007b565b6100f86100b33660046104ee565b604080516020810197909752868101959095526060860193909352901515608085015260a084015260c0808401919091528151808403909101815260e0909201905290565b60405161007b9190610541565b61006f61011336600461058f565b6102fa565b61012b61012636600461040a565b610336565b6040805196875260208701959095529385019290925215156060840152608083015260a082015260c00161007b565b60008060008060008061016c87610336565b955095505094509450945061018485858585856102fa565b979650505050505050565b60008060008060008060006101a388610336565b9550955095509550955095506101bc86868685856102fa565b6102265760405162461bcd60e51b815260206004820152603160248201527f426f756e64656453746570776973654c696e6561725072696365416461707465604482015270723a20496e76616c696420706172616d7360781b606482015260840160405180910390fd5b6000610232858c6105e0565b9050610240866000196105e0565b81111561026257836102525782610254565b815b9750505050505050506102f0565b600061026e8783610602565b905084156102b5578781111561028e5782985050505050505050506102f0565b6102a661029b828a61061f565b848110818618021890565b985050505050505050506102f0565b6102c18860001961061f565b8111156102d85783985050505050505050506102f0565b6102a66102e5828a610632565b858111818718021890565b9695505050505050565b6000808611801561030b5750600085115b80156103175750600084115b80156103235750828611155b80156102f0575050909310159392505050565b600080600080600080868060200190518101906103539190610645565b949c939b5091995097509550909350915050565b634e487b7160e01b600052604160045260246000fd5b600082601f83011261038e57600080fd5b813567ffffffffffffffff808211156103a9576103a9610367565b604051601f8301601f19908116603f011681019082821181831017156103d1576103d1610367565b816040528381528660208588010111156103ea57600080fd5b836020870160208301376000602085830101528094505050505092915050565b60006020828403121561041c57600080fd5b813567ffffffffffffffff81111561043357600080fd5b61043f8482850161037d565b949350505050565b80356001600160a01b038116811461045e57600080fd5b919050565b60008060008060008060c0878903121561047c57600080fd5b61048587610447565b955061049360208801610447565b945060408701359350606087013592506080870135915060a087013567ffffffffffffffff8111156104c457600080fd5b6104d089828a0161037d565b9150509295509295509295565b80151581146104eb57600080fd5b50565b60008060008060008060c0878903121561050757600080fd5b8635955060208701359450604087013593506060870135610527816104dd565b9598949750929560808101359460a0909101359350915050565b600060208083528351808285015260005b8181101561056e57858101830151858201604001528201610552565b506000604082860101526040601f19601f8301168501019250505092915050565b600080600080600060a086880312156105a757600080fd5b505083359560208501359550604085013594606081013594506080013592509050565b634e487b7160e01b600052601160045260246000fd5b6000826105fd57634e487b7160e01b600052601260045260246000fd5b500490565b8082028115828204841417610619576106196105ca565b92915050565b81810381811115610619576106196105ca565b80820180821115610619576106196105ca565b60008060008060008060c0878903121561065e57600080fd5b865195506020870151945060408701519350606087015161067e816104dd565b809350506080870151915060a08701519050929550929550929556fea2646970667358221220a50447f4d241ad3f0e8ac061ae0c04c474f1f7c3a7a5cab954e568dac556890e64736f6c63430008110033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106100575760003560e01c8063079f6bf41461005c578063104b9b641461008457806363dc2e6b146100a5578063bf160e2514610105578063d3516db614610118575b600080fd5b61006f61006a36600461040a565b61015a565b60405190151581526020015b60405180910390f35b610097610092366004610463565b61018f565b60405190815260200161007b565b6100f86100b33660046104ee565b604080516020810197909752868101959095526060860193909352901515608085015260a084015260c0808401919091528151808403909101815260e0909201905290565b60405161007b9190610541565b61006f61011336600461058f565b6102fa565b61012b61012636600461040a565b610336565b6040805196875260208701959095529385019290925215156060840152608083015260a082015260c00161007b565b60008060008060008061016c87610336565b955095505094509450945061018485858585856102fa565b979650505050505050565b60008060008060008060006101a388610336565b9550955095509550955095506101bc86868685856102fa565b6102265760405162461bcd60e51b815260206004820152603160248201527f426f756e64656453746570776973654c696e6561725072696365416461707465604482015270723a20496e76616c696420706172616d7360781b606482015260840160405180910390fd5b6000610232858c6105e0565b9050610240866000196105e0565b81111561026257836102525782610254565b815b9750505050505050506102f0565b600061026e8783610602565b905084156102b5578781111561028e5782985050505050505050506102f0565b6102a661029b828a61061f565b848110818618021890565b985050505050505050506102f0565b6102c18860001961061f565b8111156102d85783985050505050505050506102f0565b6102a66102e5828a610632565b858111818718021890565b9695505050505050565b6000808611801561030b5750600085115b80156103175750600084115b80156103235750828611155b80156102f0575050909310159392505050565b600080600080600080868060200190518101906103539190610645565b949c939b5091995097509550909350915050565b634e487b7160e01b600052604160045260246000fd5b600082601f83011261038e57600080fd5b813567ffffffffffffffff808211156103a9576103a9610367565b604051601f8301601f19908116603f011681019082821181831017156103d1576103d1610367565b816040528381528660208588010111156103ea57600080fd5b836020870160208301376000602085830101528094505050505092915050565b60006020828403121561041c57600080fd5b813567ffffffffffffffff81111561043357600080fd5b61043f8482850161037d565b949350505050565b80356001600160a01b038116811461045e57600080fd5b919050565b60008060008060008060c0878903121561047c57600080fd5b61048587610447565b955061049360208801610447565b945060408701359350606087013592506080870135915060a087013567ffffffffffffffff8111156104c457600080fd5b6104d089828a0161037d565b9150509295509295509295565b80151581146104eb57600080fd5b50565b60008060008060008060c0878903121561050757600080fd5b8635955060208701359450604087013593506060870135610527816104dd565b9598949750929560808101359460a0909101359350915050565b600060208083528351808285015260005b8181101561056e57858101830151858201604001528201610552565b506000604082860101526040601f19601f8301168501019250505092915050565b600080600080600060a086880312156105a757600080fd5b505083359560208501359550604085013594606081013594506080013592509050565b634e487b7160e01b600052601160045260246000fd5b6000826105fd57634e487b7160e01b600052601260045260246000fd5b500490565b8082028115828204841417610619576106196105ca565b92915050565b81810381811115610619576106196105ca565b80820180821115610619576106196105ca565b60008060008060008060c0878903121561065e57600080fd5b865195506020870151945060408701519350606087015161067e816104dd565b809350506080870151915060a08701519050929550929550929556fea2646970667358221220a50447f4d241ad3f0e8ac061ae0c04c474f1f7c3a7a5cab954e568dac556890e64736f6c63430008110033", + "linkReferences": {}, + "deployedLinkReferences": {} +} diff --git a/external/contracts/set/BoundedStepwiseLinearPriceAdapter.sol b/external/contracts/set/BoundedStepwiseLinearPriceAdapter.sol new file mode 100644 index 00000000..23be78fb --- /dev/null +++ b/external/contracts/set/BoundedStepwiseLinearPriceAdapter.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.17; + +import { FixedPointMathLib } from "solady/src/utils/FixedPointMathLib.sol"; + +/** + * @title BoundedStepwiseLinearPriceAdapter + * @author Index Coop + * @notice Price adapter contract for the AuctionRebalanceModuleV1. It returns a price that + * increases or decreases linearly in steps over time, within a bounded range. + * The rate of change is constant. + * Price formula: price = initialPrice +/- slope * timeBucket + */ +contract BoundedStepwiseLinearPriceAdapter { + + /** + * @dev Calculates and returns the linear price. + * + * @param _timeElapsed Time elapsed since the start of the auction. + * @param _priceAdapterConfigData Encoded bytes representing the linear function parameters. + * + * @return price The price calculated using the linear function. + */ + function getPrice( + address /* _setToken */, + address /* _component */, + uint256 /* _componentQuantity */, + uint256 _timeElapsed, + uint256 /* _duration */, + bytes memory _priceAdapterConfigData + ) + external + pure + returns (uint256 price) + { + ( + uint256 initialPrice, + uint256 slope, + uint256 bucketSize, + bool isDecreasing, + uint256 maxPrice, + uint256 minPrice + ) = getDecodedData(_priceAdapterConfigData); + + require( + areParamsValid(initialPrice, slope, bucketSize, maxPrice, minPrice), + "BoundedStepwiseLinearPriceAdapter: Invalid params" + ); + + uint256 bucket = _timeElapsed / bucketSize; + + // Protect against priceChange overflow + if (bucket > type(uint256).max / slope) { + return isDecreasing ? minPrice : maxPrice; + } + + uint256 priceChange = bucket * slope; + + if (isDecreasing) { + // Protect against price underflow + if (priceChange > initialPrice) { + return minPrice; + } + return FixedPointMathLib.max(initialPrice - priceChange, minPrice); + } else { + // Protect against price overflow + if (priceChange > type(uint256).max - initialPrice) { + return maxPrice; + } + return FixedPointMathLib.min(initialPrice + priceChange, maxPrice); + } + } + + /** + * @dev Returns true if the price adapter is valid for the given parameters. + * + * @param _priceAdapterConfigData Encoded data for configuring the price adapter. + * + * @return isValid Boolean indicating if the adapter config data is valid. + */ + function isPriceAdapterConfigDataValid( + bytes memory _priceAdapterConfigData + ) + external + pure + returns (bool isValid) + { + ( + uint256 initialPrice, + uint256 slope, + uint256 bucketSize, + , + uint256 maxPrice, + uint256 minPrice + ) = getDecodedData(_priceAdapterConfigData); + + return areParamsValid(initialPrice, slope, bucketSize, maxPrice, minPrice); + } + + /** + * @dev Returns true if the price adapter parameters are valid. + * + * @param _initialPrice Initial price of the auction + * @param _bucketSize Time elapsed between each bucket + * @param _maxPrice Maximum price of the auction + * @param _minPrice Minimum price of the auction + */ + function areParamsValid( + uint256 _initialPrice, + uint256 _slope, + uint256 _bucketSize, + uint256 _maxPrice, + uint256 _minPrice + ) + public + pure + returns (bool) + { + return _initialPrice > 0 + && _slope > 0 + && _bucketSize > 0 + && _initialPrice <= _maxPrice + && _initialPrice >= _minPrice; + } + + /** + * @dev Returns the encoded data for the price curve parameters + * + * @param _initialPrice Initial price of the auction + * @param _slope Slope of the linear price change + * @param _bucketSize Time elapsed between each bucket + * @param _isDecreasing Flag for whether the price is decreasing or increasing + * @param _maxPrice Maximum price of the auction + * @param _minPrice Minimum price of the auction + */ + function getEncodedData( + uint256 _initialPrice, + uint256 _slope, + uint256 _bucketSize, + bool _isDecreasing, + uint256 _maxPrice, + uint256 _minPrice + ) + external + pure + returns (bytes memory data) + { + return abi.encode(_initialPrice, _slope, _bucketSize, _isDecreasing, _maxPrice, _minPrice); + } + + /** + * @dev Decodes the parameters from the provided bytes. + * + * @param _data Bytes encoded auction parameters + * @return initialPrice Initial price of the auction + * @return slope Slope of the linear price change + * @return bucketSize Time elapsed between each bucket + * @return isDecreasing Flag for whether the price is decreasing or increasing + * @return maxPrice Maximum price of the auction + * @return minPrice Minimum price of the auction + */ + function getDecodedData(bytes memory _data) + public + pure + returns (uint256 initialPrice, uint256 slope, uint256 bucketSize, bool isDecreasing, uint256 maxPrice, uint256 minPrice) + { + return abi.decode(_data, (uint256, uint256, uint256, bool, uint256, uint256)); + } +} diff --git a/test/adapters/optimisticAuctionRebalanceExtensionV1.spec.ts b/test/adapters/optimisticAuctionRebalanceExtensionV1.spec.ts new file mode 100644 index 00000000..d2495cad --- /dev/null +++ b/test/adapters/optimisticAuctionRebalanceExtensionV1.spec.ts @@ -0,0 +1,1100 @@ +import "module-alias/register"; + +import { Address, Account } from "@utils/types"; +import { base58ToHexString } from "@utils/common"; +import { ADDRESS_ZERO, ZERO } from "@utils/constants"; +import { + BaseManager, + ConstantPriceAdapter, + OptimisticAuctionRebalanceExtensionV1, + 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, constants } from "ethers"; + +const expect = getWaffleExpect(); + +describe("OptimisticAuctionRebalanceExtensionV1", () => { + let owner: Account; + let methodologist: Account; + let operator: Account; + + let setV2Setup: SetFixture; + + let deployer: DeployHelper; + let setToken: SetToken; + let baseManager: BaseManager; + + let auctionRebalanceExtension: OptimisticAuctionRebalanceExtensionV1; + + let priceAdapter: ConstantPriceAdapter; + + let optimisticOracleV3Mock: OptimisticOracleV3Mock; + + let optimisticOracleV3MockUpgraded: OptimisticOracleV3Mock; + + let collateralAsset: StandardTokenMock; + + let useAssetAllowlist: boolean; + let allowedAssets: Address[]; + + before(async () => { + [owner, methodologist, operator] = 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(operator.address, 18); + + await setV2Setup.integrationRegistry.addIntegration( + setV2Setup.auctionModule.address, + "ConstantPriceAdapter", + priceAdapter.address, + ); + + useAssetAllowlist = false; + allowedAssets = []; + + setToken = await setV2Setup.createSetToken( + [setV2Setup.dai.address, setV2Setup.wbtc.address, setV2Setup.weth.address], + [ether(100), bitcoin(0.01), ether(0.1)], + [setV2Setup.auctionModule.address, setV2Setup.issuanceModule.address], + ); + + await setV2Setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + + baseManager = await deployer.manager.deployBaseManager( + setToken.address, + operator.address, + methodologist.address, + ); + baseManager = baseManager.connect(operator.wallet); + await setToken.setManager(baseManager.address); + + auctionRebalanceExtension = await deployer.extensions.deployOptimisticAuctionRebalanceExtensionV1( + baseManager.address, + setV2Setup.auctionModule.address, + useAssetAllowlist, + allowedAssets, + ); + auctionRebalanceExtension = auctionRebalanceExtension.connect(operator.wallet); + await collateralAsset + .connect(operator.wallet) + .approve(auctionRebalanceExtension.address, ether(1000)); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", () => { + let subjectBaseManager: Address; + let subjectAuctionRebalanceModule: Address; + let subjectUseAssetAllowlist: boolean; + let subjectAllowedAssets: Address[]; + + beforeEach(async () => { + subjectBaseManager = baseManager.address; + subjectAuctionRebalanceModule = setV2Setup.auctionModule.address; + subjectUseAssetAllowlist = false; + subjectAllowedAssets = []; + }); + + function subject(): Promise { + return deployer.extensions.deployOptimisticAuctionRebalanceExtensionV1( + subjectBaseManager, + subjectAuctionRebalanceModule, + subjectUseAssetAllowlist, + subjectAllowedAssets, + ); + } + + it("should set the correct base manager", async () => { + const auctionExtension = await subject(); + + const actualBaseManager = await auctionExtension.manager(); + expect(actualBaseManager).to.eq(subjectBaseManager); + }); + + it("should set the correct auction rebalance module address", async () => { + const auctionExtension = await subject(); + + const actualAuctionRebalanceModule = await auctionExtension.auctionModule(); + expect(actualAuctionRebalanceModule).to.eq(subjectAuctionRebalanceModule); + }); + }); + + describe("#updateUseAssetAllowlist", () => { + let subjectCaller: Account; + let subjectNewValue: boolean; + function subject() { + return auctionRebalanceExtension + .connect(subjectCaller.wallet) + .updateUseAssetAllowlist(subjectNewValue); + } + beforeEach(async () => { + subjectCaller = operator; + }); + [true, false].forEach((useAssetAllowlist: boolean) => { + describe(`when setting value to ${useAssetAllowlist}`, () => { + beforeEach(async () => { + subjectNewValue = useAssetAllowlist; + await auctionRebalanceExtension + .connect(operator.wallet) + .updateUseAssetAllowlist(!subjectNewValue); + }); + + it("should update the useAssetAllowlist correctly", async () => { + await subject(); + const actualUseAssetAllowlist = await auctionRebalanceExtension.useAssetAllowlist(); + expect(actualUseAssetAllowlist).to.eq(subjectNewValue); + }); + }); + }); + context("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = methodologist; + subjectNewValue = true; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#removeAllowedAssets", () => { + let subjectCaller: Account; + let subjectRemovedAssets: Address[]; + async function subject() { + return await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .removeAllowedAssets(subjectRemovedAssets); + } + beforeEach(async () => { + subjectRemovedAssets = [collateralAsset.address]; + subjectCaller = operator; + await auctionRebalanceExtension + .connect(operator.wallet) + .addAllowedAssets(subjectRemovedAssets); + }); + + it("should add the new assets to the allowed assets", async () => { + await subject(); + for (let i = 0; i < subjectRemovedAssets.length; i++) { + const isAllowed = await auctionRebalanceExtension.assetAllowlist(subjectRemovedAssets[i]); + expect(isAllowed).to.be.false; + } + }); + + it("should emit AllowedAssetAdded event", async () => { + const promise = subject(); + for (let i = 0; i < subjectRemovedAssets.length; i++) { + await expect(promise) + .to.emit(auctionRebalanceExtension, "AllowedAssetRemoved") + .withArgs(subjectRemovedAssets[i]); + } + }); + + context("If the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#addAllowedAssets", () => { + let subjectCaller: Account; + let subjectNewAssets: Address[]; + async function subject() { + return await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .addAllowedAssets(subjectNewAssets); + } + beforeEach(async () => { + subjectCaller = operator; + subjectNewAssets = [collateralAsset.address]; + }); + + it("should add the new assets to the allowed assets", async () => { + await subject(); + for (let i = 0; i < subjectNewAssets.length; i++) { + const isAllowed = await auctionRebalanceExtension.assetAllowlist(subjectNewAssets[i]); + expect(isAllowed).to.be.true; + } + }); + + it("should emit AllowedAssetAdded event", async () => { + const promise = subject(); + for (let i = 0; i < subjectNewAssets.length; i++) { + await expect(promise) + .to.emit(auctionRebalanceExtension, "AllowedAssetAdded") + .withArgs(subjectNewAssets[i]); + } + }); + + context("If the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + context( + "when auction rebalance extension is added as adapter and needs to be initialized", + () => { + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = operator; + await baseManager.addAdapter(auctionRebalanceExtension.address); + }); + + describe("#initialize", () => { + async function subject() { + return await auctionRebalanceExtension.connect(subjectCaller.wallet).initialize(); + } + + it("should initialize AuctionRebalanceModule", async () => { + await subject(); + const isInitialized = await setToken.isInitializedModule( + setV2Setup.auctionModule.address, + ); + expect(isInitialized).to.be.true; + }); + + describe("when the initializer is not the operator", () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + context("when auction rebalance extension is deployed and initialized.", () => { + beforeEach(async () => { + await auctionRebalanceExtension.connect(operator.wallet).initialize(); + }); + + context("when the product settings have been set", () => { + let rulesHash: Uint8Array; + let bondAmount: BigNumber; + beforeEach(async () => { + rulesHash = utils.arrayify( + base58ToHexString("Qmc5gCcjYypU7y28oCALwfSvxCBskLuPKWpK4qpterKC7z"), + ); + bondAmount = ether(140); // 140 INDEX minimum bond + await auctionRebalanceExtension.connect(operator.wallet).setProductSettings( + { + collateral: collateralAsset.address, + liveness: BigNumber.from(60 * 60), // 7 days + bondAmount, + identifier: utils.formatBytes32String(""), + optimisticOracleV3: optimisticOracleV3Mock.address, + }, + rulesHash, + ); + }); + + context("When the rebalance settings are set correctly", () => { + 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; + 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(0.01), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData(ether(0.005)), + }, + { + targetUnit: ether(0.1), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData(ether(0.005)), + }, + ]; + + subjectShouldLockSetToken = false; + subjectRebalanceDuration = BigNumber.from(86400); + subjectPositionMultiplier = ether(0.999); + subjectCaller = operator; + }); + + async function proposeRebalance(): Promise { + await auctionRebalanceExtension.updateIsOpen(true); + return auctionRebalanceExtension + .connect(subjectCaller.wallet) + .proposeRebalance( + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectRebalanceDuration, + subjectPositionMultiplier, + ); + } + + describe("#proposeRebalance", () => { + async function subject(): Promise { + return auctionRebalanceExtension + .connect(subjectCaller.wallet) + .proposeRebalance( + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectRebalanceDuration, + subjectPositionMultiplier, + ); + } + + function constructClaim(): string { + const abi = utils.defaultAbiCoder; + const proposalHash = utils.keccak256( + abi.encode( + [ + "address", + "address", + "address[]", + "address[]", + "(uint256,string,bytes)[]", + "(uint256,string,bytes)[]", + "bool", + "uint256", + "uint256", + ], + [ + setToken.address, + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams.map(component => [ + component.targetUnit, + component.priceAdapterName, + component.priceAdapterConfigData, + ]), + subjectOldComponentsAuctionParams.map(component => [ + component.targetUnit, + component.priceAdapterName, + component.priceAdapterConfigData, + ]), + false, // We don't allow locking the set token in this version + subjectRebalanceDuration, + subjectPositionMultiplier, + ], + ), + ); + const firstPart = utils.toUtf8Bytes( + "proposalHash:" + proposalHash.slice(2) + ',rulesIPFSHash:"', + ); + const lastPart = utils.toUtf8Bytes('"'); + + return utils.hexlify(utils.concat([firstPart, rulesHash, lastPart])); + } + + context("when the extension is open for rebalance", () => { + beforeEach(async () => { + await auctionRebalanceExtension.updateIsOpen(true); + }); + + it("should not revert", async () => { + await subject(); + }); + + it("should update proposal hash correctly", async () => { + const proposalHashBefore = await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .assertionIdToProposalHash(utils.formatBytes32String("win")); + expect(proposalHashBefore).to.eq(constants.HashZero); + + await subject(); + + const proposalHashAfter = await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .assertionIdToProposalHash(utils.formatBytes32String("win")); + expect(proposalHashAfter).to.not.eq(constants.HashZero); + }); + + it("should pull bond", async () => { + const collateralBalanceBefore = await collateralAsset.balanceOf( + subjectCaller.address, + ); + await subject(); + const collateralBalanceAfter = await collateralAsset.balanceOf( + subjectCaller.address, + ); + expect(collateralBalanceAfter).to.eq(collateralBalanceBefore.sub(bondAmount)); + }); + + it("should emit RebalanceProposed event", async () => { + const receipt = (await subject().then(tx => tx.wait())) as any; + const proposeEvent = receipt.events.find( + (event: any) => event.event === "RebalanceProposed", + ); + expect(proposeEvent.args.setToken).to.eq(setToken.address); + expect(proposeEvent.args.quoteAsset).to.eq(subjectQuoteAsset); + expect(proposeEvent.args.oldComponents).to.deep.eq(subjectOldComponents); + expect(proposeEvent.args.newComponents).to.deep.eq(subjectNewComponents); + expect(proposeEvent.args.rebalanceDuration).to.eq(subjectRebalanceDuration); + expect(proposeEvent.args.positionMultiplier).to.eq(subjectPositionMultiplier); + + const newComponentsAuctionParams = proposeEvent.args.newComponentsAuctionParams.map( + (entry: any) => { + return { + priceAdapterConfigData: entry.priceAdapterConfigData, + priceAdapterName: entry.priceAdapterName, + targetUnit: entry.targetUnit, + }; + }, + ); + expect(newComponentsAuctionParams).to.deep.eq(subjectNewComponentsAuctionParams); + + const oldComponentsAuctionParams = proposeEvent.args.oldComponentsAuctionParams.map( + (entry: any) => { + return { + priceAdapterConfigData: entry.priceAdapterConfigData, + priceAdapterName: entry.priceAdapterName, + targetUnit: entry.targetUnit, + }; + }, + ); + expect(oldComponentsAuctionParams).to.deep.eq(subjectOldComponentsAuctionParams); + }); + + it("should emit AssertedClaim event", async () => { + const receipt = (await subject().then(tx => tx.wait())) as any; + const assertEvent = receipt.events.find( + (event: any) => event.event === "AssertedClaim", + ); + const emittedSetToken = assertEvent.args.setToken; + expect(emittedSetToken).to.eq(setToken.address); + const assertedBy = assertEvent.args._assertedBy; + expect(assertedBy).to.eq(operator.wallet.address); + const emittedRulesHash = assertEvent.args.rulesHash; + expect(emittedRulesHash).to.eq(utils.hexlify(rulesHash)); + const claim = assertEvent.args._claimData; + expect(claim).to.eq(constructClaim()); + }); + + context("when the same rebalance has been proposed already", () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Proposal already exists"); + }); + }); + + context("when asset allow list is activated", () => { + beforeEach(async () => { + await auctionRebalanceExtension.updateUseAssetAllowlist(true); + }); + + context("when new assets are not on the allow list", () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid asset"); + }); + }); + + context("when new assets are on the allow list", () => { + beforeEach(async () => { + await auctionRebalanceExtension.addAllowedAssets(subjectNewComponents); + }); + + it("should not revert", async () => { + await subject(); + }); + }); + }); + context("when the rule hash is empty", () => { + beforeEach(async () => { + const currentSettings = await auctionRebalanceExtension.productSettings(); + await auctionRebalanceExtension.setProductSettings( + currentSettings.optimisticParams, + constants.HashZero, + ); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Rules not set"); + }); + }); + + context("when the oracle address is zero", () => { + beforeEach(async () => { + const [ + currentOptimisticParams, + ruleHash, + ] = await auctionRebalanceExtension.productSettings(); + const optimisticParams = { + ...currentOptimisticParams, + optimisticOracleV3: constants.AddressZero, + }; + await auctionRebalanceExtension.setProductSettings(optimisticParams, ruleHash); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Oracle not set"); + }); + }); + }); + + context("when the extension is not open for rebalance", () => { + beforeEach(async () => { + await auctionRebalanceExtension.updateIsOpen(false); + }); + + it("should revert", async () => { + expect(subject()).to.be.revertedWith("Must be open for rebalancing"); + }); + }); + }); + + describe("#startRebalance", () => { + async function subject(): Promise { + return auctionRebalanceExtension + .connect(subjectCaller.wallet) + .startRebalance( + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectShouldLockSetToken, + subjectRebalanceDuration, + subjectPositionMultiplier, + ); + } + describe("when old components are passed in different order", () => { + beforeEach(async () => { + subjectOldComponents = [ + setV2Setup.dai.address, + setV2Setup.weth.address, + setV2Setup.wbtc.address, + ]; + await proposeRebalance(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith( + "Mismatch: old and current components", + ); + }); + }); + + describe("when any parameter is different from the proposedRebalance", () => { + beforeEach(async () => { + await proposeRebalance(); + subjectPositionMultiplier = subjectPositionMultiplier.add(1); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Proposal hash does not exist"); + }); + }); + + 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(0.01), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData(ether(0.005)), + }, + ]; + await proposeRebalance(); + }); + + 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(0.01), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: price, + }, + { + targetUnit: ether(0.1), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: price, + }, + { + targetUnit: usdc(100), + priceAdapterName: "ConstantPriceAdapter", + priceAdapterConfigData: price, + }, + ]; + await proposeRebalance(); + }); + + 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, + ]; + await proposeRebalance(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith( + "Mismatch: old and current components", + ); + }); + }); + context("when the rebalance has been proposed", () => { + beforeEach(async () => { + await proposeRebalance(); + }); + it("should set isOpen to false", async () => { + await subject(); + const isOpen = await auctionRebalanceExtension.isOpen(); + expect(isOpen).to.be.false; + }); + + 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("#assertionDisputedCallback", () => { + let subjectAssertionId: string; + function subject(): Promise { + return optimisticOracleV3Mock + .connect(subjectCaller.wallet) + .mockAssertionDisputedCallback( + auctionRebalanceExtension.address, + subjectAssertionId, + ); + } + beforeEach(() => { + subjectAssertionId = utils.formatBytes32String("win"); + }); + + context("when the caller is not the oracle", () => { + function subject(): Promise { + return auctionRebalanceExtension + .connect(subjectCaller.wallet) + .assertionDisputedCallback(subjectAssertionId); + } + context("when the assertionId is wrong", () => { + beforeEach(async () => { + subjectAssertionId = utils.formatBytes32String("wrongid"); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid proposal hash"); + }); + }); + context("when the oracle does not have the assertion", () => { + it("should delete the proposal", async () => { + const proposalHash = await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .assertionIdToProposalHash(utils.formatBytes32String("win")); + expect(proposalHash).to.not.eq(constants.HashZero); + + await subject(); + + const proposalHashAfter = await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .assertionIdToProposalHash(utils.formatBytes32String("win")); + expect(proposalHashAfter).to.eq(constants.HashZero); + }); + }); + + context("when the oracle has the assertion", () => { + beforeEach(async () => { + await optimisticOracleV3Mock.setAsserter(subjectCaller.wallet.address); + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Oracle has assertion"); + }); + }); + }); + + context("when the oracle address is zero", () => { + beforeEach(async () => { + const [ + currentOptimisticParams, + ruleHash, + ] = await auctionRebalanceExtension.productSettings(); + const optimisticParams = { + ...currentOptimisticParams, + optimisticOracleV3: constants.AddressZero, + }; + await auctionRebalanceExtension.setProductSettings( + optimisticParams, + ruleHash, + ); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid oracle address"); + }); + }); + + it("should delete the proposal on a disputed callback", async () => { + const proposalHash = await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .assertionIdToProposalHash(utils.formatBytes32String("win")); + expect(proposalHash).to.not.eq(constants.HashZero); + + await subject(); + + const proposalHashAfter = await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .assertionIdToProposalHash(utils.formatBytes32String("win")); + expect(proposalHashAfter).to.eq(constants.HashZero); + }); + + it("should delete the proposal on a disputed callback from currently set oracle", async () => { + await auctionRebalanceExtension.connect(operator.wallet).setProductSettings( + { + collateral: collateralAsset.address, + liveness: BigNumber.from(0), + bondAmount: BigNumber.from(0), + identifier: utils.formatBytes32String(""), + optimisticOracleV3: optimisticOracleV3MockUpgraded.address, + }, + utils.arrayify( + base58ToHexString("Qmc5gCcjYypU7y28oCALwfSvxCBskLuPKWpK4qpterKC7z"), + ), + ); + + const proposalHash = await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .assertionIdToProposalHash(utils.formatBytes32String("win")); + expect(proposalHash).to.not.eq(constants.HashZero); + await subject(); + + const proposalHashAfter = await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .assertionIdToProposalHash(utils.formatBytes32String("win")); + expect(proposalHashAfter).to.eq(constants.HashZero); + }); + }); + + describe("assertionResolvedCallback", () => { + it("should not revert on a resolved callback", async () => { + await expect( + optimisticOracleV3Mock + .connect(subjectCaller.wallet) + .mockAssertionResolvedCallback( + auctionRebalanceExtension.address, + utils.formatBytes32String("win"), + true, + ), + ).to.not.be.reverted; + }); + }); + + describe("when the caller is not the operator", () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should not revert", async () => { + await subject(); + }); + }); + }); + + describe("when there are no new components", () => { + beforeEach(async () => { + subjectNewComponents = []; + subjectNewComponentsAuctionParams = []; + await proposeRebalance(); + }); + + 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("#setRaiseTargetPercentage", () => { + let subjectRaiseTargetPercentage: BigNumber; + let subjectCaller: Account; + + beforeEach(async () => { + subjectRaiseTargetPercentage = ether(0.001); + subjectCaller = operator; + }); + + async function subject(): Promise { + return await auctionRebalanceExtension + .connect(subjectCaller.wallet) + .setRaiseTargetPercentage(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 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(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 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(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 operator"); + }); + }); + }); + }); + }); + }, + ); +}); diff --git a/test/global-extensions/globalOptimisticAuctionRebalanceExtension.spec.ts b/test/global-extensions/globalOptimisticAuctionRebalanceExtension.spec.ts index d108c665..56dc8cdf 100644 --- a/test/global-extensions/globalOptimisticAuctionRebalanceExtension.spec.ts +++ b/test/global-extensions/globalOptimisticAuctionRebalanceExtension.spec.ts @@ -1,6 +1,7 @@ import "module-alias/register"; import { Address, Account } from "@utils/types"; +import { base58ToHexString } from "@utils/common"; import { ADDRESS_ZERO, ZERO } from "@utils/constants"; import { GlobalOptimisticAuctionRebalanceExtension, DelegatedManager, ManagerCore, @@ -21,29 +22,10 @@ import { } 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", () => { +describe("GlobalOptimisticAuctionRebalanceExtension", () => { let owner: Account; let methodologist: Account; let operator: Account; diff --git a/test/integration/ethereum/addresses.ts b/test/integration/ethereum/addresses.ts index aa61a80c..a198db19 100644 --- a/test/integration/ethereum/addresses.ts +++ b/test/integration/ethereum/addresses.ts @@ -2,14 +2,16 @@ import structuredClone from "@ungap/structured-clone"; export const PRODUCTION_ADDRESSES = { tokens: { + index: "0x0954906da0Bf32d5479e25f46056d22f08464cab", stEthAm: "0x28424507fefb6f7f8E9D3860F56504E4e5f5f390", stEth: "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", dai: "0x6B175474E89094C44Da98b954EedeAC495271d0F", + dsEth: "0x341c05c0E9b33C0E38d64de76516b2Ce970bB3BE", weth: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", icEth: "0x7C07F7aBe10CE8e33DC6C5aD68FE033085256A84", icReth: "0xe8888Cdbc0A5958C29e7D91DAE44897c7e64F9BC", rETH: "0xae78736Cd615f374D3085123A210448E74Fc6393", - aEthrETH : "0xCc9EE9483f662091a1de4795249E24aC0aC2630f", + aEthrETH: "0xCc9EE9483f662091a1de4795249E24aC0aC2630f", aSTETH: "0x1982b2F5814301d4e9a8b0201555376e62F82428", ETH2xFli: "0xAa6E8127831c9DE45ae56bB1b0d4D4Da6e5665BD", cEther: "0x4ddc2d193948926d02f9b1fe9e1daa0718270ed5", @@ -20,6 +22,9 @@ export const PRODUCTION_ADDRESSES = { wsETH2: "0x5dA21D9e63F1EA13D34e48B7223bcc97e3ecD687", rETH2: "0x20BC832ca081b91433ff6c17f85701B6e92486c5", sETH2: "0xFe2e637202056d30016725477c5da089Ab0A043A", + wbtc: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + swETH: "0xf951E335afb289353dc249e82926178EaC7DEd78", + ETHx: "0xA35b1B31Ce002FBF2058D22F30f95D405200A15b", }, whales: { stEth: "0xdc24316b9ae028f1497c275eb9192a3ea0f67022", @@ -63,10 +68,14 @@ export const PRODUCTION_ADDRESSES = { controller: "0xD2463675a099101E36D85278494268261a66603A", debtIssuanceModuleV2: "0xa0a98EB7Af028BE00d04e46e1316808A62a8fd59", notionalTradeModule: "0x600d9950c6ecAef98Cc42fa207E92397A6c43416", + integrationRegistry: "0xb9083dee5e8273E54B9DB4c31bA9d4aB7C6B28d3", + auctionModuleV1: "0x59D55D53a715b3B4581c52098BCb4075C2941DBa", tradeModule: "0xFaAB3F8f3678f68AA0d307B66e71b636F82C28BF", airdropModule: "0x09b9e7c7e2daf40fCb286fE6b863e517d5d5c40F", aaveV3LeverageStrategyExtension: "0x7d3f7EDD04916F3Cb2bC6740224c636B9AE43200", aaveV3LeverageModule: "0x71E932715F5987077ADC5A7aA245f38841E0DcBe", + constantPriceAdapter: "0x13c33656570092555Bf27Bdf53Ce24482B85D992", + linearPriceAdapter: "0x237F7BBe0b358415bE84AB6d279D4338C0d026bB", }, lending: { aave: { @@ -83,6 +92,12 @@ export const PRODUCTION_ADDRESSES = { nUpgreadableBeacon: "0xFAaF0C5B81E802C231A5249221cfe0B6ae639118", }, }, + oracles: { + uma: { + optimisticOracleV3: "0xfb55F43fB9F48F63f9269DB7Dde3BbBe1ebDC0dE", + identifierWhitelist: "0xcF649d9Da4D1362C4DAEa67573430Bd6f945e570", + }, + }, }; export const STAGING_ADDRESSES = structuredClone(PRODUCTION_ADDRESSES); diff --git a/test/integration/ethereum/optimisticAuctionRebalanceExtenisonV1.spec.ts b/test/integration/ethereum/optimisticAuctionRebalanceExtenisonV1.spec.ts new file mode 100644 index 00000000..156ae3b4 --- /dev/null +++ b/test/integration/ethereum/optimisticAuctionRebalanceExtenisonV1.spec.ts @@ -0,0 +1,662 @@ +import "module-alias/register"; + +import { Address, Account } from "@utils/types"; +import { increaseTimeAsync } from "@utils/test"; +import { setBlockNumber } from "@utils/test/testingUtils"; +import { base58ToHexString } from "@utils/common"; +import { ONE_HOUR_IN_SECONDS, ZERO } from "@utils/constants"; +import { OptimisticAuctionRebalanceExtensionV1 } from "@utils/contracts/index"; +import { + AuctionRebalanceModuleV1, + AuctionRebalanceModuleV1__factory, + BoundedStepwiseLinearPriceAdapter, + BoundedStepwiseLinearPriceAdapter__factory, + SetToken, + SetToken__factory, + BaseManagerV2, + BaseManagerV2__factory, + IntegrationRegistry, + IntegrationRegistry__factory, + IIdentifierWhitelist, + IIdentifierWhitelist__factory, + IERC20, + IERC20__factory, + OptimisticOracleV3Mock, + OptimisticOracleV3Interface, + OptimisticOracleV3Interface__factory, +} from "../../../typechain"; +import DeployHelper from "@utils/deploys"; +import { impersonateAccount } from "./utils"; +import { PRODUCTION_ADDRESSES } from "./addresses"; +import { + addSnapshotBeforeRestoreAfterEach, + ether, + getAccounts, + getWaffleExpect, + getTransactionTimestamp, + getRandomAccount, +} from "@utils/index"; +import { BigNumber, ContractTransaction, utils, Signer } from "ethers"; +import { ethers } from "hardhat"; + +const expect = getWaffleExpect(); + +if (process.env.INTEGRATIONTEST) { + describe("OptimisticAuctionRebalanceExtensionV1 - Integration Test dsEth", () => { + const contractAddresses = PRODUCTION_ADDRESSES; + + const liveness = BigNumber.from(60 * 60 * 24 * 2); // 2 days + const minimumBond = ether(140); // 140 INDEX Minimum Bond + + let owner: Account; + let methodologist: Account; + let operator: Signer; + + let deployer: DeployHelper; + let dsEth: SetToken; + let baseManager: BaseManagerV2; + + let auctionModule: AuctionRebalanceModuleV1; + let auctionRebalanceExtension: OptimisticAuctionRebalanceExtensionV1; + let integrationRegistry: IntegrationRegistry; + + let priceAdapter: BoundedStepwiseLinearPriceAdapter; + + // UMA contracts + let optimisticOracleV3: OptimisticOracleV3Interface; + let optimisticOracleV3Mock: OptimisticOracleV3Mock; + let identifierWhitelist: IIdentifierWhitelist; + + let collateralAssetAddress: string; + + let useAssetAllowlist: boolean; + let allowedAssets: Address[]; + + let indexToken: IERC20; + + setBlockNumber(18924016); + + before(async () => { + [owner, methodologist] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + + priceAdapter = BoundedStepwiseLinearPriceAdapter__factory.connect( + contractAddresses.setFork.linearPriceAdapter, + owner.wallet, + ); + indexToken = IERC20__factory.connect(contractAddresses.tokens.index, owner.wallet); + collateralAssetAddress = indexToken.address; + + optimisticOracleV3 = OptimisticOracleV3Interface__factory.connect( + contractAddresses.oracles.uma.optimisticOracleV3, + owner.wallet, + ); + + optimisticOracleV3Mock = await deployer.mocks.deployOptimisticOracleV3Mock(); + + identifierWhitelist = IIdentifierWhitelist__factory.connect( + contractAddresses.oracles.uma.identifierWhitelist, + owner.wallet, + ); + const whitelistOwner = await impersonateAccount(await identifierWhitelist.owner()); + await ethers.provider.send("hardhat_setBalance", [ + await whitelistOwner.getAddress(), + ethers.utils.parseEther("10").toHexString(), + ]); + identifierWhitelist = identifierWhitelist.connect(whitelistOwner); + + integrationRegistry = IntegrationRegistry__factory.connect( + contractAddresses.setFork.integrationRegistry, + owner.wallet, + ); + const integrationRegistryOwner = await impersonateAccount(await integrationRegistry.owner()); + integrationRegistry = integrationRegistry.connect(integrationRegistryOwner); + + auctionModule = AuctionRebalanceModuleV1__factory.connect( + contractAddresses.setFork.auctionModuleV1, + owner.wallet, + ); + + useAssetAllowlist = true; + allowedAssets = [contractAddresses.tokens.swETH, contractAddresses.tokens.ETHx]; // New dsETH components + + dsEth = SetToken__factory.connect(contractAddresses.tokens.dsEth, owner.wallet); + + baseManager = BaseManagerV2__factory.connect(await dsEth.manager(), owner.wallet); + operator = await impersonateAccount(await baseManager.operator()); + baseManager = baseManager.connect(operator); + + auctionRebalanceExtension = await deployer.extensions.deployOptimisticAuctionRebalanceExtensionV1( + baseManager.address, + auctionModule.address, + useAssetAllowlist, + allowedAssets, + ); + auctionRebalanceExtension = auctionRebalanceExtension.connect(operator); + }); + + async function getIndexTokens(receiver: string, amount: BigNumber): Promise { + const INDEX_TOKEN_WHALE = "0x9467cfADC9DE245010dF95Ec6a585A506A8ad5FC"; + const indexWhaleSinger = await impersonateAccount(INDEX_TOKEN_WHALE); + await indexToken.connect(indexWhaleSinger).transfer(receiver, amount); + } + + addSnapshotBeforeRestoreAfterEach(); + + context("when auction rebalance extension is added as extension", () => { + beforeEach(async () => { + await baseManager.addExtension(auctionRebalanceExtension.address); + }); + + context("when the product settings have been set", () => { + let productSettings: any; + let identifier: string; + + beforeEach(async () => { + identifier = "0x4153534552545f54525554480000000000000000000000000000000000000000"; // ASSERT_TRUTH identifier + + productSettings = { + collateral: collateralAssetAddress, + liveness, + bondAmount: minimumBond, + identifier, + optimisticOracleV3: optimisticOracleV3.address, + }; + + await auctionRebalanceExtension + .connect(operator) + .setProductSettings( + productSettings, + utils.arrayify(base58ToHexString("Qmc5gCcjYypU7y28oCALwfSvxCBskLuPKWpK4qpterKC7z")), + ); + }); + + context("when the extension is open to rebalances", () => { + beforeEach(async () => { + await auctionRebalanceExtension.updateIsOpen(true); + }); + + 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: Signer; + let effectiveBond: BigNumber; + + beforeEach(async () => { + effectiveBond = productSettings.bondAmount.gt(minimumBond) + ? productSettings.bondAmount + : minimumBond; + + subjectQuoteAsset = contractAddresses.tokens.weth; + + subjectOldComponents = await dsEth.getComponents(); + + subjectNewComponents = [contractAddresses.tokens.swETH, contractAddresses.tokens.ETHx]; + subjectNewComponentsAuctionParams = [ + { // swETH: https://etherscan.io/address/0xf951E335afb289353dc249e82926178EaC7DEd78#readProxyContract#F6 + targetUnit: "155716754710815260", + priceAdapterName: "BoundedStepwiseLinearPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData( + ether(1.043), + ether(0.0005), + ONE_HOUR_IN_SECONDS, + false, + ether(1.05), + ether(1.043), + ), + }, + { // ETHx: https://etherscan.io/address/0xcf5ea1b38380f6af39068375516daf40ed70d299#readProxyContract#F5 + targetUnit: "162815732702576500", + priceAdapterName: "BoundedStepwiseLinearPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData( + ether(1.014), + ether(0.0005), + ONE_HOUR_IN_SECONDS, + false, + ether(1.02), + ether(1.014), + ), + }, + ]; + + subjectOldComponentsAuctionParams = [ + { // wstETH: https://etherscan.io/address/0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0#readContract#F10 + targetUnit: "148503139447300450", + priceAdapterName: "BoundedStepwiseLinearPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData( + ether(1.155), + ether(0.001), + ONE_HOUR_IN_SECONDS, + true, + ether(1.155), + ether(1.149), + ), + }, + { // rETH: https://etherscan.io/address/0xae78736Cd615f374D3085123A210448E74Fc6393#readContract#F6 + targetUnit: "233170302540761920", + priceAdapterName: "BoundedStepwiseLinearPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData( + ether(1.097), + ether(0.001), + ONE_HOUR_IN_SECONDS, + true, + ether(1.097), + ether(1.091), + ), + }, + { // sfrxETH: https://etherscan.io/address/0xac3E018457B222d93114458476f3E3416Abbe38F#readContract#F20 + targetUnit: "123631627061020350", + priceAdapterName: "BoundedStepwiseLinearPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData( + ether(1.073), + ether(0.001), + ONE_HOUR_IN_SECONDS, + true, + ether(1.073), + ether(1.067), + ), + }, + { // osETH: https://etherscan.io/address/0x8023518b2192fb5384dadc596765b3dd1cdfe471#readContract#F3 + targetUnit: "153017509830141340", + priceAdapterName: "BoundedStepwiseLinearPriceAdapter", + priceAdapterConfigData: await priceAdapter.getEncodedData( + ether(1.005), + ether(0.001), + ONE_HOUR_IN_SECONDS, + true, + ether(1.005), + ether(1.004), + ), + }, + ]; + + subjectShouldLockSetToken = false; + subjectRebalanceDuration = BigNumber.from(60 * 60 * 24 * 3); + subjectPositionMultiplier = await dsEth.positionMultiplier(); + subjectCaller = operator; + + const quantity = utils + .parseEther("1000") + .add(effectiveBond) + .toHexString(); + + // set operator balance to effective bond + await ethers.provider.send("hardhat_setBalance", [ + await subjectCaller.getAddress(), + quantity, + ]); + + await getIndexTokens(await subjectCaller.getAddress(), effectiveBond); + await indexToken + .connect(subjectCaller) + .approve(auctionRebalanceExtension.address, effectiveBond); + }); + + describe("#startRebalance", () => { + async function subject(): Promise { + return auctionRebalanceExtension + .connect(subjectCaller) + .startRebalance( + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectShouldLockSetToken, + subjectRebalanceDuration, + subjectPositionMultiplier, + ); + } + + context("when the rebalance has been proposed", () => { + let proposalId: string; + + beforeEach(async () => { + const tx = await auctionRebalanceExtension + .connect(subjectCaller) + .proposeRebalance( + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectRebalanceDuration, + subjectPositionMultiplier, + ); + const receipt = await tx.wait(); + + // @ts-ignore + const assertEvent = receipt.events[receipt.events.length - 1] as any; + proposalId = assertEvent.args._assertionId; + }); + + context("when the liveness period has passed", () => { + beforeEach(async () => { + await increaseTimeAsync(liveness.add(1)); + }); + + context("when the rebalance has been executed once already", () => { + beforeEach(async () => { + await auctionRebalanceExtension + .connect(subjectCaller) + .startRebalance( + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectShouldLockSetToken, + subjectRebalanceDuration, + subjectPositionMultiplier, + ); + await auctionRebalanceExtension.updateIsOpen(true); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Proposal hash does not exist"); + }); + + context("when identical rebalanced again but liveness has not passed", () => { + beforeEach(async () => { + // set operator balance to effective bond + await getIndexTokens(await subjectCaller.getAddress(), effectiveBond); + await indexToken + .connect(subjectCaller) + .approve(auctionRebalanceExtension.address, effectiveBond); + + await auctionRebalanceExtension + .connect(subjectCaller) + .proposeRebalance( + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectRebalanceDuration, + subjectPositionMultiplier, + ); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Assertion not expired"); + }); + }); + }); + + 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 auctionModule.executionInfo( + dsEth.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 auctionModule.rebalanceInfo(dsEth.address); + + expect(utils.getAddress(rebalanceInfo.quoteAsset)).to.eq( + utils.getAddress(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 auctionModule.getRebalanceComponents( + dsEth.address, + ); + const aggregateComponents = [...subjectOldComponents, ...subjectNewComponents]; + + for (let i = 0; i < rebalanceComponents.length; i++) { + expect(utils.getAddress(rebalanceComponents[i])).to.eq( + utils.getAddress(aggregateComponents[i]), + ); + } + }); + }); + + describe("assertionDisputedCallback", () => { + it("should delete the proposal on a disputed callback", async () => { + const proposalHash = await auctionRebalanceExtension + .connect(subjectCaller) + .assertionIdToProposalHash(proposalId); + + expect(proposalHash).to.not.eq(ethers.constants.HashZero); + + await getIndexTokens(await subjectCaller.getAddress(), effectiveBond); + await indexToken.connect(subjectCaller).approve(optimisticOracleV3.address, effectiveBond); + await optimisticOracleV3 + .connect(subjectCaller) + .disputeAssertion(proposalId, owner.address); + + const proposalHashAfter = await auctionRebalanceExtension + .connect(subjectCaller) + .assertionIdToProposalHash(proposalId); + + expect(proposalHashAfter).to.eq(ethers.constants.HashZero); + }); + + it("should delete the proposal on a disputed callback from currently set oracle", async () => { + await auctionRebalanceExtension.connect(operator).setProductSettings( + { + collateral: collateralAssetAddress, + liveness, + bondAmount: minimumBond, + identifier, + optimisticOracleV3: optimisticOracleV3Mock.address, + }, + utils.arrayify( + base58ToHexString("Qmc5gCcjYypU7y28oCALwfSvxCBskLuPKWpK4qpterKC7z"), + ), + ); + + const proposalHash = await auctionRebalanceExtension + .connect(subjectCaller) + .assertionIdToProposalHash(proposalId); + expect(proposalHash).to.not.eq(ethers.constants.HashZero); + + await expect( + optimisticOracleV3Mock + .connect(subjectCaller) + .mockAssertionDisputedCallback( + auctionRebalanceExtension.address, + proposalId, + ), + ).to.not.be.reverted; + const proposalHashAfter = await auctionRebalanceExtension + .connect(subjectCaller) + .assertionIdToProposalHash(proposalId); + expect(proposalHashAfter).to.eq(ethers.constants.HashZero); + }); + }); + }); + describe("assertionResolvedCallback", () => { + it("should not revert on a resolved callback", async () => { + await auctionRebalanceExtension.connect(operator).setProductSettings( + { + collateral: collateralAssetAddress, + liveness, + bondAmount: minimumBond, + identifier, + optimisticOracleV3: optimisticOracleV3Mock.address, + }, + utils.arrayify( + base58ToHexString("Qmc5gCcjYypU7y28oCALwfSvxCBskLuPKWpK4qpterKC7z"), + ), + ); + const tx = await auctionRebalanceExtension + .connect(subjectCaller) + .proposeRebalance( + subjectQuoteAsset, + subjectOldComponents, + subjectNewComponents, + subjectNewComponentsAuctionParams, + subjectOldComponentsAuctionParams, + subjectRebalanceDuration, + subjectPositionMultiplier, + ); + const receipt = await tx.wait(); + // @ts-ignore + const assertEvent = receipt.events[receipt.events.length - 1] as any; + const proposalId = assertEvent.args._assertionId; + + await optimisticOracleV3Mock + .connect(subjectCaller) + .mockAssertionResolvedCallback( + auctionRebalanceExtension.address, + proposalId, + true, + ); + }); + }); + }); + }); + }); + describe("#setRaiseTargetPercentage", () => { + let subjectRaiseTargetPercentage: BigNumber; + let subjectCaller: Signer; + + beforeEach(async () => { + subjectRaiseTargetPercentage = ether(0.001); + subjectCaller = operator; + }); + + async function subject(): Promise { + return await auctionRebalanceExtension + .connect(subjectCaller) + .setRaiseTargetPercentage(subjectRaiseTargetPercentage); + } + + it("should correctly set the raiseTargetPercentage", async () => { + await subject(); + + const actualRaiseTargetPercentage = (await auctionModule.rebalanceInfo(dsEth.address)) + .raiseTargetPercentage; + + expect(actualRaiseTargetPercentage).to.eq(subjectRaiseTargetPercentage); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = (await getRandomAccount()).wallet; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#setBidderStatus", () => { + let subjectBidders: Address[]; + let subjectStatuses: boolean[]; + let subjectCaller: Signer; + + beforeEach(async () => { + subjectBidders = [methodologist.address]; + subjectStatuses = [true]; + subjectCaller = operator; + }); + + async function subject(): Promise { + return await auctionRebalanceExtension + .connect(subjectCaller) + .setBidderStatus(subjectBidders, subjectStatuses); + } + + it("should correctly set the bidder status", async () => { + await subject(); + + const isCaller = await auctionModule.isAllowedBidder(dsEth.address, subjectBidders[0]); + + expect(isCaller).to.be.true; + }); + + describe("when the caller is not the operator", () => { + beforeEach(async () => { + subjectCaller = (await getRandomAccount()).wallet; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#setAnyoneBid", () => { + let subjectStatus: boolean; + let subjectCaller: Signer; + + beforeEach(async () => { + subjectStatus = true; + subjectCaller = operator; + }); + + async function subject(): Promise { + return await auctionRebalanceExtension + .connect(subjectCaller) + .setAnyoneBid(subjectStatus); + } + + it("should correctly set anyone bid", async () => { + await subject(); + + const anyoneBid = await auctionModule.permissionInfo(dsEth.address); + + expect(anyoneBid).to.be.true; + }); + + describe("when the caller is not the operator", () => { + beforeEach(async () => { + subjectCaller = (await getRandomAccount()).wallet; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#removeExtension", () => { + async function subject() { + return await baseManager + .connect(operator) + .removeExtension(auctionRebalanceExtension.address); + } + + it("should remove the extension", async () => { + expect(await baseManager.isExtension(auctionRebalanceExtension.address)).to.be.true; + await subject(); + expect(await baseManager.isExtension(auctionRebalanceExtension.address)).to.be.false; + }); + }); + }); + }); + }); +} diff --git a/test/manager/baseManagerV2.spec.ts b/test/manager/baseManagerV2.spec.ts index e2b00552..977e7316 100644 --- a/test/manager/baseManagerV2.spec.ts +++ b/test/manager/baseManagerV2.spec.ts @@ -371,8 +371,8 @@ describe("BaseManagerV2", () => { }); describe("when the extension is authorized for a protected module", () => { - beforeEach(() => { - baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); + beforeEach(async () => { + await baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); }); it("should revert", async() => { diff --git a/utils/common/conversionUtils.ts b/utils/common/conversionUtils.ts index 1dcd2430..056dc297 100644 --- a/utils/common/conversionUtils.ts +++ b/utils/common/conversionUtils.ts @@ -1,3 +1,22 @@ import { BigNumber } from "ethers/lib/ethers"; +import base58 from "bs58"; -export const bigNumberToData = (number: BigNumber) => number.toHexString().replace("0x", "").padStart(64, "0"); \ No newline at end of file +export const bigNumberToData = (number: BigNumber) => number.toHexString().replace("0x", "").padStart(64, "0"); + +export const 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) +export const 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; +}; diff --git a/utils/common/index.ts b/utils/common/index.ts index eeac5056..d50dd8dd 100644 --- a/utils/common/index.ts +++ b/utils/common/index.ts @@ -21,5 +21,7 @@ export { convertLibraryNameToLinkId } from "./libraryUtils"; export { - bigNumberToData + bigNumberToData, + bufferToHex, + base58ToHexString, } from "./conversionUtils"; diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index bbfb9235..15a791cc 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -65,4 +65,5 @@ export { GlobalWrapExtension } from "../../typechain/GlobalWrapExtension"; export { GlobalClaimExtension } from "../../typechain/GlobalClaimExtension"; export { GlobalAuctionRebalanceExtension } from "../../typechain/GlobalAuctionRebalanceExtension"; export { GlobalOptimisticAuctionRebalanceExtension } from "../../typechain/GlobalOptimisticAuctionRebalanceExtension"; -export { OptimisticOracleV3Mock } from "../../typechain/OptimisticOracleV3Mock"; \ No newline at end of file +export { OptimisticAuctionRebalanceExtensionV1 } from "../../typechain/OptimisticAuctionRebalanceExtensionV1"; +export { OptimisticOracleV3Mock } from "../../typechain/OptimisticOracleV3Mock"; diff --git a/utils/contracts/setV2.ts b/utils/contracts/setV2.ts index 295237ad..91c39c8d 100644 --- a/utils/contracts/setV2.ts +++ b/utils/contracts/setV2.ts @@ -4,6 +4,7 @@ export { AaveV2 } from "../../typechain/AaveV2"; export { AirdropModule } from "../../typechain/AirdropModule"; export { AuctionRebalanceModuleV1 } from "../../typechain/AuctionRebalanceModuleV1"; export { BasicIssuanceModule } from "../../typechain/BasicIssuanceModule"; +export { BoundedStepwiseLinearPriceAdapter } from "../../typechain/BoundedStepwiseLinearPriceAdapter"; export { Compound } from "../../typechain/Compound"; export { Controller } from "../../typechain/Controller"; export { ContractCallerMock } from "../../typechain/ContractCallerMock"; diff --git a/utils/deploys/deployExtensions.ts b/utils/deploys/deployExtensions.ts index 04b694a1..c64d1c99 100644 --- a/utils/deploys/deployExtensions.ts +++ b/utils/deploys/deployExtensions.ts @@ -24,6 +24,7 @@ import { FeeSplitExtension, GIMExtension, GovernanceExtension, + OptimisticAuctionRebalanceExtensionV1, StreamingFeeSplitExtension, WrapExtension, } from "../contracts/index"; @@ -52,6 +53,7 @@ import { FlexibleLeverageStrategyExtension__factory } from "../../typechain/fact import { GIMExtension__factory } from "../../typechain/factories/GIMExtension__factory"; import { GovernanceExtension__factory } from "../../typechain/factories/GovernanceExtension__factory"; import { FixedRebalanceExtension__factory } from "../../typechain/factories/FixedRebalanceExtension__factory"; +import { OptimisticAuctionRebalanceExtensionV1__factory } from "../../typechain/factories/OptimisticAuctionRebalanceExtensionV1__factory"; import { StakeWiseReinvestmentExtension__factory } from "../../typechain/factories/StakeWiseReinvestmentExtension__factory"; import { StreamingFeeSplitExtension__factory } from "../../typechain/factories/StreamingFeeSplitExtension__factory"; import { WrapExtension__factory } from "../../typechain/factories/WrapExtension__factory"; @@ -259,11 +261,10 @@ export default class DeployExtensions { basicIssuanceModuleAddress, aaveLeveragedModuleAddress, aaveAddressProviderAddress, - BalancerV2VaultAddress + BalancerV2VaultAddress, ); } - public async deployExchangeIssuanceLeveragedForCompound( wethAddress: Address, quickRouterAddress: Address, @@ -518,4 +519,18 @@ export default class DeployExtensions { minPositions, ); } + + public async deployOptimisticAuctionRebalanceExtensionV1( + baseManager: Address, + auctionModule: Address, + useAssetAllowlist: boolean, + allowedAssets: Address[], + ): Promise { + return await new OptimisticAuctionRebalanceExtensionV1__factory(this._deployerSigner).deploy({ + baseManager, + auctionModule, + useAssetAllowlist, + allowedAssets, + }); + } }