diff --git a/src/MetaKeeper.sol b/src/MetaKeeper.sol new file mode 100644 index 0000000..f9b77d6 --- /dev/null +++ b/src/MetaKeeper.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {AutomationCompatibleInterface} from "chainlink/src/v0.8/automation/AutomationCompatible.sol"; +import {IAutomationRegistryConsumer} from "chainlink/src/v0.8/automation/interfaces/IAutomationRegistryConsumer.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {TlxOwnable} from "./utils/TlxOwnable.sol"; + +import {Errors} from "./libraries/Errors.sol"; +import {AddressKeys} from "./libraries/AddressKeys.sol"; +import {ScaledNumber} from "./libraries/ScaledNumber.sol"; + +import {IMetaKeeper} from "./interfaces/IMetaKeeper.sol"; +import {IAddressProvider} from "./interfaces/IAddressProvider.sol"; +import {ITlxUpkeepRegistry} from "./interfaces/ITlxUpkeepRegistry.sol"; + +contract MetaKeeper is IMetaKeeper, TlxOwnable { + using ScaledNumber for uint256; + using EnumerableSet for EnumerableSet.AddressSet; + + struct MinBalance { + address registryAddress; + uint256 minBalance; + } + + uint256 internal constant _MIN_BALANCE_BUFFER = 0.2e18; + + IAddressProvider internal immutable _addressProvider; + + uint256 public maxTopUps; + IERC20 public topUpAsset; + uint96 public topUpAmount; + + EnumerableSet.AddressSet internal _forwarderAddresses; + + constructor( + address addressProvider_, + uint256 maxTopUps_, + address topUpAsset_, + uint96 topUpAmount_ + ) TlxOwnable(addressProvider_) { + _addressProvider = IAddressProvider(addressProvider_); + maxTopUps = maxTopUps_; + topUpAsset = IERC20(topUpAsset_); + topUpAmount = topUpAmount_; + } + + /// @inheritdoc AutomationCompatibleInterface + function performUpkeep(bytes calldata performData_) external override { + if (!_forwarderAddresses.contains(msg.sender)) revert NotForwarder(); + + uint256[] memory upkeepsToTopup_ = abi.decode( + performData_, + (uint256[]) + ); + + uint256 topUpCount_ = upkeepsToTopup_.length; + if (topUpCount_ == 0) revert NoUpkeepsToTopUp(); + + ITlxUpkeepRegistry tlxUpkeepRegistry_ = ITlxUpkeepRegistry( + _addressProvider.addressOf(AddressKeys.UPKEEP_REGISTRY) + ); + uint96 topUpAmount_ = topUpAmount; + + for (uint256 i_; i_ < topUpCount_; i_++) { + uint256 upkeepID_ = upkeepsToTopup_[i_]; + if (!tlxUpkeepRegistry_.isUpkeep(upkeepID_)) { + revert NotUpkeep(); + } + ITlxUpkeepRegistry.Upkeep memory upkeepInfo_ = tlxUpkeepRegistry_ + .upkeepInfoForID(upkeepID_); + IAutomationRegistryConsumer chainlinkRegistry_ = IAutomationRegistryConsumer( + upkeepInfo_.registryAddress + ); + uint256 thresholdBalance_ = uint256( + chainlinkRegistry_.getMinBalance(upkeepID_) + ).mul(1e18 + _MIN_BALANCE_BUFFER); + if (chainlinkRegistry_.getBalance(upkeepID_) > thresholdBalance_) + revert UpkeepHasSufficientFunds(); + if (topUpAsset.balanceOf(address(this)) < uint256(topUpAmount_)) + revert OutOfFunds(); + + topUpAsset.approve(address(chainlinkRegistry_), topUpAmount_); + chainlinkRegistry_.addFunds(upkeepID_, topUpAmount_); + + emit UpkeepPerformed(upkeepID_); + } + } + + /// @inheritdoc IMetaKeeper + function setMaxTopUps(uint256 maxTopUps_) external override onlyOwner { + maxTopUps = maxTopUps_; + } + + /// @inheritdoc IMetaKeeper + function setTopUpAmount(uint96 topUpAmount_) external override onlyOwner { + topUpAmount = topUpAmount_; + } + + /// @inheritdoc IMetaKeeper + function addForwarderAddress( + address forwarderAddress_ + ) external override onlyOwner { + if (!_forwarderAddresses.add(forwarderAddress_)) { + revert Errors.AlreadyExists(); + } + } + + /// @inheritdoc IMetaKeeper + function removeForwarderAddress( + address forwarderAddress_ + ) external override onlyOwner { + if (!_forwarderAddresses.remove(forwarderAddress_)) { + revert Errors.DoesNotExist(); + } + } + + /// @inheritdoc IMetaKeeper + function recoverAsset( + address receiver, + address asset, + uint256 amount + ) external override onlyOwner { + IERC20 token = IERC20(asset); + token.transfer(receiver, amount); + emit AssetRecovered(asset, receiver, amount); + } + + /// @inheritdoc IMetaKeeper + function forwarderAddresses() + external + view + override + returns (address[] memory) + { + return _forwarderAddresses.values(); + } + + /// @inheritdoc AutomationCompatibleInterface + function checkUpkeep( + bytes calldata + ) + external + view + override + returns (bool upkeepNeeded, bytes memory performData) + { + ITlxUpkeepRegistry tlxUpkeepRegistry_ = ITlxUpkeepRegistry( + _addressProvider.addressOf(AddressKeys.UPKEEP_REGISTRY) + ); + ITlxUpkeepRegistry.Upkeep[] memory upkeeps_ = tlxUpkeepRegistry_ + .allUpkeepIDsAndInfo(); + MinBalance[] memory minBalances_ = _getMinBalances(upkeeps_); + + uint256 maxTopUps_ = maxTopUps; + uint256[] memory upkeepsToTopup_ = new uint256[](maxTopUps_); + uint256 topUpsCount_; + IAutomationRegistryConsumer chainlinkRegistry_; + for (uint256 i_; i_ < upkeeps_.length; i_++) { + uint256 upkeepID_ = upkeeps_[i_].id; + chainlinkRegistry_ = IAutomationRegistryConsumer( + upkeeps_[i_].registryAddress + ); + uint256 thresholdBalance_ = _findMinBalance( + minBalances_, + upkeeps_[i_].registryAddress + ).mul(1e18 + _MIN_BALANCE_BUFFER); + if (chainlinkRegistry_.getBalance(upkeepID_) > thresholdBalance_) + continue; + upkeepsToTopup_[topUpsCount_] = upkeepID_; + topUpsCount_++; + if (topUpsCount_ == maxTopUps_) break; + } + + if ( + topUpsCount_ == 0 || + topUpAsset.balanceOf(address(this)) < uint256(topUpAmount) + ) return (false, ""); + upkeepNeeded = true; + + // solhint-disable-next-line + assembly { + mstore(upkeepsToTopup_, topUpsCount_) + } + performData = abi.encode(upkeepsToTopup_); + } + + function _getMinBalances( + ITlxUpkeepRegistry.Upkeep[] memory upkeeps_ + ) internal view returns (MinBalance[] memory minBalances_) { + minBalances_ = new MinBalance[](upkeeps_.length); + uint256 count; + for (uint256 i_; i_ < upkeeps_.length; i_++) { + bool exists; + for (uint256 j_; j_ < count; j_++) { + if ( + upkeeps_[i_].registryAddress == + minBalances_[j_].registryAddress + ) { + exists = true; + break; + } + } + if (exists) continue; + + uint256 minBalance_ = IAutomationRegistryConsumer( + upkeeps_[i_].registryAddress + ).getMinBalance(upkeeps_[i_].id); + minBalances_[i_] = MinBalance({ + registryAddress: upkeeps_[i_].registryAddress, + minBalance: minBalance_ + }); + count++; + } + + // solhint-disable-next-line + assembly { + mstore(minBalances_, count) + } + } + + function _findMinBalance( + MinBalance[] memory minBalances_, + address registryAddress_ + ) internal pure returns (uint256 minBalance_) { + for (uint256 i_; i_ < minBalances_.length; i_++) { + if (minBalances_[i_].registryAddress == registryAddress_) { + minBalance_ = minBalances_[i_].minBalance; + break; + } + } + } +} diff --git a/src/SynthetixHandler.sol b/src/SynthetixHandler.sol index 1b1be5c..060da66 100644 --- a/src/SynthetixHandler.sol +++ b/src/SynthetixHandler.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.13; import {ISynthetixHandler} from "./interfaces/ISynthetixHandler.sol"; import {IAddressProvider} from "./interfaces/IAddressProvider.sol"; +import {ILeveragedToken} from "./interfaces/ILeveragedToken.sol"; import {IPerpsV2MarketSettings} from "./interfaces/synthetix/IPerpsV2MarketSettings.sol"; import {IPerpsV2MarketData} from "./interfaces/synthetix/IPerpsV2MarketData.sol"; import {IPerpsV2MarketConsolidated} from "./interfaces/synthetix/IPerpsV2MarketConsolidated.sol"; @@ -42,6 +43,7 @@ contract SynthetixHandler is ISynthetixHandler { /// @inheritdoc ISynthetixHandler function depositMargin(address market_, uint256 amount_) public override { + _validateMintAmount(market_, amount_); IPerpsV2MarketConsolidated(market_).transferMargin(int256(amount_)); } @@ -85,6 +87,7 @@ contract SynthetixHandler is ISynthetixHandler { ); } + /// @inheritdoc ISynthetixHandler function computePriceImpact( address market_, uint256 leverage_, @@ -92,21 +95,34 @@ contract SynthetixHandler is ISynthetixHandler { bool isLong_, bool isDeposit_ ) public view override returns (uint256, bool) { + // Calculating target size delta uint256 assetPrice_ = assetPrice(market_); uint256 absTargetSizeDelta_ = baseAmount_.mul(leverage_).div( assetPrice_ ); int256 targetSizeDelta_ = int256(absTargetSizeDelta_); + + // Calculating price impact if (!isLong_) targetSizeDelta_ = -targetSizeDelta_; // Invert if shorting if (!isDeposit_) targetSizeDelta_ = -targetSizeDelta_; // Invert if redeeming uint256 fillPrice_ = fillPrice(market_, targetSizeDelta_); - - bool isLoss = (isLong_ && fillPrice_ > assetPrice_) || + bool isLoss_ = (isLong_ && fillPrice_ > assetPrice_) || (!isLong_ && fillPrice_ < assetPrice_); - uint256 slippage = absTargetSizeDelta_.mul( + uint256 slippage_ = absTargetSizeDelta_.mul( assetPrice_.absSub(fillPrice_) ); - return (slippage, isLoss); + + // Calculating fees + uint256 orderFee_ = _orderFee(market_, targetSizeDelta_); + if (isLoss_) { + slippage_ += orderFee_; + } else if (orderFee_ > slippage_) { + slippage_ = orderFee_ - slippage_; + isLoss_ = true; + } else { + slippage_ -= orderFee_; + } + return (slippage_, isLoss_); } /// @inheritdoc ISynthetixHandler @@ -263,6 +279,32 @@ contract SynthetixHandler is ISynthetixHandler { return _marketSettings.maxMarketValue(_key(targetAsset_)).mul(price_); } + function _validateMintAmount( + address market_, + uint256 mintAmount_ + ) internal view { + uint256 price_ = assetPrice(market_); + uint256 increase_ = mintAmount_.mul(_leverage()).div(price_); + bytes32 key_ = IPerpsV2MarketConsolidated(market_).marketKey(); + uint256 maxMarketValue_ = _marketSettings.maxMarketValue(key_); + uint256 buffer_ = _addressProvider + .parameterProvider() + .maxBaseAssetAmountBuffer(); + uint256 max_ = maxMarketValue_.mul(1e18 - buffer_); + (uint256 long_, uint256 short_) = IPerpsV2MarketConsolidated(market_) + .marketSizes(); + uint256 currentSize_ = _isLong() ? long_ : short_; + if (currentSize_ + increase_ > max_) revert MaxMarketValueExceeded(); + } + + function _isLong() internal view returns (bool) { + return ILeveragedToken(address(this)).isLong(); + } + + function _leverage() internal view returns (uint256) { + return ILeveragedToken(address(this)).targetLeverage(); + } + function _pnl( address market_, address account_ @@ -273,6 +315,16 @@ contract SynthetixHandler is ISynthetixHandler { return pnl_; } + function _orderFee( + address market_, + int256 sizeDelta_ + ) internal view returns (uint256) { + (uint256 fee_, bool invalid_) = IPerpsV2MarketConsolidated(market_) + .orderFee(sizeDelta_, IPerpsV2MarketBaseTypes.OrderType.Offchain); + if (invalid_) revert ErrorGettingOrderFee(); + return fee_; + } + function _minKeeperFee() internal view returns (uint256) { return _marketSettings.minKeeperFee(); } diff --git a/src/TlxUpkeepRegistry.sol b/src/TlxUpkeepRegistry.sol new file mode 100644 index 0000000..91c410c --- /dev/null +++ b/src/TlxUpkeepRegistry.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {TlxOwnable} from "./utils/TlxOwnable.sol"; +import {ITlxUpkeepRegistry} from "./interfaces/ITlxUpkeepRegistry.sol"; + +contract TlxUpkeepRegistry is ITlxUpkeepRegistry, TlxOwnable { + using EnumerableSet for EnumerableSet.UintSet; + + EnumerableSet.UintSet internal _upkeepIDs; + mapping(uint256 => Upkeep) internal _upkeepInfos; + + constructor(address addressProvider_) TlxOwnable(addressProvider_) {} + + /// @inheritdoc ITlxUpkeepRegistry + + function addUpkeep( + Upkeep calldata upkeepInfo_ + ) external override onlyOwner { + bool added_ = _upkeepIDs.add(upkeepInfo_.id); + _upkeepInfos[upkeepInfo_.id] = upkeepInfo_; + if (added_) + emit UpkeepAdded( + upkeepInfo_.id, + upkeepInfo_.registryAddress, + upkeepInfo_.upkeepAddress + ); + } + + /// @inheritdoc ITlxUpkeepRegistry + function removeUpkeep(uint256 upkeepID_) external override onlyOwner { + bool removed_ = _upkeepIDs.remove(upkeepID_); + delete _upkeepInfos[upkeepID_]; + if (removed_) emit UpkeepRemoved(upkeepID_); + } + + /// @inheritdoc ITlxUpkeepRegistry + function isUpkeep(uint256 upkeepID_) external view override returns (bool) { + return _upkeepIDs.contains(upkeepID_); + } + + /// @inheritdoc ITlxUpkeepRegistry + function allUpkeepIDs() external view override returns (uint256[] memory) { + return _upkeepIDs.values(); + } + + /// @inheritdoc ITlxUpkeepRegistry + function upkeepInfoForID( + uint256 upkeepID_ + ) external view override returns (Upkeep memory) { + return _upkeepInfos[upkeepID_]; + } + + /// @inheritdoc ITlxUpkeepRegistry + function allUpkeepIDsAndInfo() + external + view + override + returns (Upkeep[] memory upkeepInfos_) + { + uint256[] memory upkeepIDs_ = _upkeepIDs.values(); + upkeepInfos_ = new Upkeep[](upkeepIDs_.length); + for (uint256 i_; i_ < upkeepIDs_.length; i_++) { + upkeepInfos_[i_] = _upkeepInfos[upkeepIDs_[i_]]; + } + } +} diff --git a/src/helpers/VelodromeVoterAutomation.sol b/src/helpers/VelodromeVoterAutomation.sol new file mode 100644 index 0000000..9f8d775 --- /dev/null +++ b/src/helpers/VelodromeVoterAutomation.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {AutomationCompatibleInterface} from "chainlink/src/v0.8/automation/AutomationCompatible.sol"; + +import {ScaledNumber} from "../libraries/ScaledNumber.sol"; + +import {TlxOwnable} from "../utils/TlxOwnable.sol"; + +import {IAddressProvider} from "../interfaces/IAddressProvider.sol"; +import {IRewards} from "../interfaces/velodrome/IRewards.sol"; + +contract VelodromeVoterAutomation is AutomationCompatibleInterface, TlxOwnable { + using ScaledNumber for uint256; + using SafeERC20 for IERC20; + + IAddressProvider internal immutable _addressProvider; + IRewards internal immutable _tlxEthRewards; + uint256 internal immutable _periodDecayMultiplier; + uint256 internal immutable _periodDuration; + + uint256 internal _tlxPerSecond; + uint256 internal _availableTlxCache; + uint256 internal _lastUpdate; + uint256 internal _lastDecayTimestamp; + uint256 internal _lastIncentivesTimestamp; + uint256 internal _incentivesPaid; + + error CanNotRun(); + + constructor( + address addressProvider_, + uint256 initialTlxPerSecond_, + uint256 periodDecayMultiplier_, + uint256 periodDuration_, + uint256 lastIncentivesTimestamp_, + address tlxEthRewards_, + uint256 inflationStartTimestamp_, + uint256 incentivesPaid_ + ) TlxOwnable(addressProvider_) { + // Set the initial values + _addressProvider = IAddressProvider(addressProvider_); + _tlxEthRewards = IRewards(tlxEthRewards_); + _tlxPerSecond = initialTlxPerSecond_; + _periodDecayMultiplier = periodDecayMultiplier_; + _periodDuration = periodDuration_; + _lastIncentivesTimestamp = lastIncentivesTimestamp_; + + // Start inflation + _lastUpdate = inflationStartTimestamp_; + _lastDecayTimestamp = inflationStartTimestamp_; + _incentivesPaid = incentivesPaid_; + + // Approvals + IAddressProvider(addressProvider_).tlx().approve( + tlxEthRewards_, + type(uint256).max + ); + } + + function performUpkeep(bytes calldata) external override { + if (!_canRun()) revert CanNotRun(); + uint256 nextIncentiveTimestamp_ = _nextIncentivesTimestamp(); + _updateCache(nextIncentiveTimestamp_); + address tlx_ = address(_addressProvider.tlx()); + uint256 incentives_ = _availableTlxCache - _incentivesPaid; + _tlxEthRewards.notifyRewardAmount(tlx_, incentives_); + _lastIncentivesTimestamp = nextIncentiveTimestamp_; + _incentivesPaid = _availableTlxCache; + } + + function recoverTokens(address token_, address to_) external onlyOwner { + uint256 balance_ = IERC20(token_).balanceOf(address(this)); + IERC20(token_).safeTransfer(to_, balance_); + } + + function checkUpkeep( + bytes calldata + ) external view override returns (bool upkeepNeeded, bytes memory) { + upkeepNeeded = _canRun(); + return (upkeepNeeded, ""); + } + + function _updateCache(uint256 updateTime_) internal { + uint256 nextDecay_ = _lastDecayTimestamp + _periodDuration; + + // We are still within the current period + if (updateTime_ < nextDecay_) { + uint256 time_ = updateTime_ - _lastUpdate; + _availableTlxCache += time_ * _tlxPerSecond; + _lastUpdate = updateTime_; + return; + } + // We are in a new period + else { + // Update the cache with the remaining time in the current period + uint256 periodTime_ = nextDecay_ - _lastUpdate; + uint256 tlxPerSecond_ = _tlxPerSecond; + _availableTlxCache += periodTime_ * tlxPerSecond_; + _tlxPerSecond = tlxPerSecond_.mul(_periodDecayMultiplier); + _lastDecayTimestamp = nextDecay_; + _lastUpdate = nextDecay_; + + // Update the cache with the remaining time in the new period(s) + _updateCache(updateTime_); + } + } + + function _canRun() internal view returns (bool) { + return + block.timestamp > _lastIncentivesTimestamp && + block.timestamp < _nextIncentivesTimestamp(); + } + + function _nextIncentivesTimestamp() internal view returns (uint256) { + return _lastIncentivesTimestamp + 7 days; + } +} diff --git a/src/interfaces/IMetaKeeper.sol b/src/interfaces/IMetaKeeper.sol new file mode 100644 index 0000000..124453f --- /dev/null +++ b/src/interfaces/IMetaKeeper.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {AutomationCompatibleInterface} from "chainlink/src/v0.8/automation/AutomationCompatible.sol"; + +interface IMetaKeeper is AutomationCompatibleInterface { + event UpkeepPerformed(uint256 indexed upkeepID); + event AssetRecovered( + address indexed asset, + address indexed receiver, + uint256 amount + ); + + error NoUpkeepsToTopUp(); + error NotUpkeep(); + error NotForwarder(); + error OutOfFunds(); + error UpkeepHasSufficientFunds(); + + /** + * @notice Sets the maximum number of topups that can be performed in a single upkeep. + * @param maxTopUps_ The new maximum number of topups. + */ + function setMaxTopUps(uint256 maxTopUps_) external; + + /** + * @notice Sets the topup amount to transfer to in an upkeep, + * @param topUpAmount_ The new topup amount. + */ + function setTopUpAmount(uint96 topUpAmount_) external; + + /** + * @notice Adds a forwarder address that can call the performUpkeep function. + * @dev Only callable by the contract owner. + * @param forwarderAddress The new address of the Chainlink forwarder. + */ + function addForwarderAddress(address forwarderAddress) external; + + /** + * @notice Removes a forwarder address that can call the performUpkeep function. + * @dev Only callable by the contract owner. + * @param forwarderAddress The address of the Chainlink forwarder to remove. + */ + function removeForwarderAddress(address forwarderAddress) external; + + /** + * @notice Transfers an asset held by the automation contract to another address. + * @dev Only callable by the contract owner. + * @param receiver Address to transfer to. + * @param asset Address of asset to transfer. + * @param amount Amount to transfer. + */ + function recoverAsset( + address receiver, + address asset, + uint256 amount + ) external; + + /** + * @notice Returns the addresses of the Chainlink forwarders. + * @return forwarderAddresses The addresses of the Chainlink forwarders. + */ + function forwarderAddresses() + external + view + returns (address[] memory forwarderAddresses); + + /** + * @notice Returns the maximum number of topups that can be performed in a single upkeep. + */ + function maxTopUps() external view returns (uint256); + + /** + * @notice Returns the topup amount per upkeep. + */ + function topUpAmount() external view returns (uint96); +} diff --git a/src/interfaces/IPolManager.sol b/src/interfaces/IPolManager.sol new file mode 100644 index 0000000..c1093e9 --- /dev/null +++ b/src/interfaces/IPolManager.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +interface IPolManager { + struct TokenToRedeem { + address tokenAddress; + uint256 amount; + } + event RedeemFailed(address indexed tokenAddress, uint256 amount); + + function redeemTokens( + TokenToRedeem[] calldata tokensToRedeem, + uint256 minAmountOut + ) external returns (uint256); +} diff --git a/src/interfaces/ISynthetixHandler.sol b/src/interfaces/ISynthetixHandler.sol index e42747a..dbcc87b 100644 --- a/src/interfaces/ISynthetixHandler.sol +++ b/src/interfaces/ISynthetixHandler.sol @@ -8,6 +8,7 @@ interface ISynthetixHandler { error ErrorGettingFillPrice(); error ErrorGettingAssetPrice(); error NoMargin(); + error MaxMarketValueExceeded(); /** * @notice Deposit `amount` of margin to Synthetix for the `market`. @@ -40,6 +41,7 @@ interface ISynthetixHandler { /** * @notice Computes expected price impact for a position adjustment at current prices. + * @dev This also includes fees charged by Synthetix for modifying the position. * @param market The market for which to compute price impact for. * @param leverage The leverage to target. * @param baseAmount The margin amount to compute price impact for. diff --git a/src/interfaces/ITlxUpkeepRegistry.sol b/src/interfaces/ITlxUpkeepRegistry.sol new file mode 100644 index 0000000..4b3c1d3 --- /dev/null +++ b/src/interfaces/ITlxUpkeepRegistry.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +interface ITlxUpkeepRegistry { + struct Upkeep { + uint256 id; + address registryAddress; + address upkeepAddress; + } + + event UpkeepAdded( + uint256 indexed upkeepID, + address registryAddress, + address upkeepAddress + ); + event UpkeepRemoved(uint256 indexed upkeepID); + + /** + * @notice Adds a chainlink upkeep to the registry. + * @dev Reverts if the `upkeep` is already registered. + * @param upkeep The chainlink information for the upkeep. + */ + function addUpkeep(Upkeep calldata upkeep) external; + + /** + * @notice Removes the `upkeepID` as a registered upkeep. + * @dev Reverts if the `upkeep` is not a registered. + * @param upkeepID The chainlink ID of the upkeep to be removed. + */ + function removeUpkeep(uint256 upkeepID) external; + + /** + * @notice Returns if the given `upkeepID` is a registered upkeep. + * @param upkeepID The chainlink ID of the upkeep to be checked. + * @return isUpkeep Whether the upkeepID is a registered upkeep. + */ + function isUpkeep(uint256 upkeepID) external view returns (bool isUpkeep); + + /** + * @notice Returns the list of IDs of registered upkeeps. + * @return upkeeps The list of IDs. + */ + function allUpkeepIDs() external view returns (uint256[] memory upkeeps); + + /** + * @notice Returns the upkeep info for a given ID. + * @param upkeepID The ID to get the upkeep info for. + * @return upkeepInfo The upkeep info. + */ + function upkeepInfoForID( + uint256 upkeepID + ) external view returns (Upkeep memory upkeepInfo); + + /** + * @notice Returns the list of IDs and infos of registered upkeeps. + * @return upkeeps The list of upkeeps. + */ + function allUpkeepIDsAndInfo() + external + view + returns (Upkeep[] memory upkeeps); +} diff --git a/src/interfaces/velodrome/IRewards.sol b/src/interfaces/velodrome/IRewards.sol new file mode 100644 index 0000000..ab9f83b --- /dev/null +++ b/src/interfaces/velodrome/IRewards.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +interface IRewards { + event Deposit( + address indexed from, + uint256 indexed tokenId, + uint256 amount + ); + event Withdraw( + address indexed from, + uint256 indexed tokenId, + uint256 amount + ); + event NotifyReward( + address indexed from, + address indexed reward, + uint256 indexed epoch, + uint256 amount + ); + event ClaimRewards( + address indexed from, + address indexed reward, + uint256 amount + ); + + error InvalidReward(); + error NotAuthorized(); + error NotGauge(); + error NotEscrowToken(); + error NotSingleToken(); + error NotVotingEscrow(); + error NotWhitelisted(); + error ZeroAmount(); + + /// @notice Deposit an amount into the rewards contract to earn future rewards associated to a veNFT + /// @dev Internal notation used as only callable internally by `authorized`. + /// @param amount Amount deposited for the veNFT + /// @param tokenId Unique identifier of the veNFT + function deposit(uint256 amount, uint256 tokenId) external; + + /// @notice Withdraw an amount from the rewards contract associated to a veNFT + /// @dev Internal notation used as only callable internally by `authorized`. + /// @param amount Amount deposited for the veNFT + /// @param tokenId Unique identifier of the veNFT + function withdraw(uint256 amount, uint256 tokenId) external; + + /// @notice Claim the rewards earned by a veNFT staker + /// @param tokenId Unique identifier of the veNFT + /// @param tokens Array of tokens to claim rewards of + function getReward(uint256 tokenId, address[] memory tokens) external; + + /// @notice Add rewards for stakers to earn + /// @param token Address of token to reward + /// @param amount Amount of token to transfer to rewards + function notifyRewardAmount(address token, uint256 amount) external; + + /// @notice Determine the prior balance for an account as of a block number + /// @dev Block number must be a finalized block or else this function will revert to prevent misinformation. + /// @param tokenId The token of the NFT to check + /// @param timestamp The timestamp to get the balance at + /// @return The balance the account had as of the given block + function getPriorBalanceIndex( + uint256 tokenId, + uint256 timestamp + ) external view returns (uint256); + + /// @notice Determine the prior index of supply staked by of a timestamp + /// @dev Timestamp must be <= current timestamp + /// @param timestamp The timestamp to get the index at + /// @return Index of supply checkpoint + function getPriorSupplyIndex( + uint256 timestamp + ) external view returns (uint256); + + /// @notice Calculate how much in rewards are earned for a specific token and veNFT + /// @param token Address of token to fetch rewards of + /// @param tokenId Unique identifier of the veNFT + /// @return Amount of token earned in rewards + function earned( + address token, + uint256 tokenId + ) external view returns (uint256); + + function voter() external view returns (address); +} diff --git a/src/interfaces/velodrome/IVoter.sol b/src/interfaces/velodrome/IVoter.sol new file mode 100644 index 0000000..0ab63e9 --- /dev/null +++ b/src/interfaces/velodrome/IVoter.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +interface IVoter { + event GaugeCreated( + address indexed poolFactory, + address indexed votingRewardsFactory, + address indexed gaugeFactory, + address pool, + address bribeVotingReward, + address feeVotingReward, + address gauge, + address creator + ); + event GaugeKilled(address indexed gauge); + event GaugeRevived(address indexed gauge); + event Voted( + address indexed voter, + address indexed pool, + uint256 indexed tokenId, + uint256 weight, + uint256 totalWeight, + uint256 timestamp + ); + event Abstained( + address indexed voter, + address indexed pool, + uint256 indexed tokenId, + uint256 weight, + uint256 totalWeight, + uint256 timestamp + ); + event NotifyReward( + address indexed sender, + address indexed reward, + uint256 amount + ); + event DistributeReward( + address indexed sender, + address indexed gauge, + uint256 amount + ); + event WhitelistToken( + address indexed whitelister, + address indexed token, + bool indexed _bool + ); + event WhitelistNFT( + address indexed whitelister, + uint256 indexed tokenId, + bool indexed _bool + ); + + error AlreadyVotedOrDeposited(); + error DistributeWindow(); + error FactoryPathNotApproved(); + error GaugeAlreadyKilled(); + error GaugeAlreadyRevived(); + error GaugeExists(); + error GaugeDoesNotExist(address _pool); + error GaugeNotAlive(address _gauge); + error InactiveManagedNFT(); + error MaximumVotingNumberTooLow(); + error NonZeroVotes(); + error NotAPool(); + error NotApprovedOrOwner(); + error NotGovernor(); + error NotEmergencyCouncil(); + error NotMinter(); + error NotWhitelistedNFT(); + error NotWhitelistedToken(); + error SameValue(); + error SpecialVotingWindow(); + error TooManyPools(); + error UnequalLengths(); + error ZeroBalance(); + error ZeroAddress(); + + /// @notice Called by Minter to distribute weekly emissions rewards for disbursement amongst gauges. + /// @dev Assumes totalWeight != 0 (Will never be zero as long as users are voting). + /// Throws if not called by minter. + /// @param _amount Amount of rewards to distribute. + function notifyRewardAmount(uint256 _amount) external; + + /// @dev Utility to distribute to gauges of pools in range _start to _finish. + /// @param _start Starting index of gauges to distribute to. + /// @param _finish Ending index of gauges to distribute to. + function distribute(uint256 _start, uint256 _finish) external; + + /// @dev Utility to distribute to gauges of pools in array. + /// @param _gauges Array of gauges to distribute to. + function distribute(address[] memory _gauges) external; + + /// @notice Called by users to update voting balances in voting rewards contracts. + /// @param _tokenId Id of veNFT whose balance you wish to update. + function poke(uint256 _tokenId) external; + + /// @notice Called by users to vote for pools. Votes distributed proportionally based on weights. + /// Can only vote or deposit into a managed NFT once per epoch. + /// Can only vote for gauges that have not been killed. + /// @dev Weights are distributed proportional to the sum of the weights in the array. + /// Throws if length of _poolVote and _weights do not match. + /// @param _tokenId Id of veNFT you are voting with. + /// @param _poolVote Array of pools you are voting for. + /// @param _weights Weights of pools. + function vote( + uint256 _tokenId, + address[] calldata _poolVote, + uint256[] calldata _weights + ) external; + + /// @notice Called by users to reset voting state. Required if you wish to make changes to + /// veNFT state (e.g. merge, split, deposit into managed etc). + /// Cannot reset in the same epoch that you voted in. + /// Can vote or deposit into a managed NFT again after reset. + /// @param _tokenId Id of veNFT you are reseting. + function reset(uint256 _tokenId) external; + + /// @notice Called by users to deposit into a managed NFT. + /// Can only vote or deposit into a managed NFT once per epoch. + /// Note that NFTs deposited into a managed NFT will be re-locked + /// to the maximum lock time on withdrawal. + /// @dev Throws if not approved or owner. + /// Throws if managed NFT is inactive. + /// Throws if depositing within privileged window (one hour prior to epoch flip). + function depositManaged(uint256 _tokenId, uint256 _mTokenId) external; + + /// @notice Called by users to withdraw from a managed NFT. + /// Cannot do it in the same epoch that you deposited into a managed NFT. + /// Can vote or deposit into a managed NFT again after withdrawing. + /// Note that the NFT withdrawn is re-locked to the maximum lock time. + function withdrawManaged(uint256 _tokenId) external; + + /// @notice Claim emissions from gauges. + /// @param _gauges Array of gauges to collect emissions from. + function claimRewards(address[] memory _gauges) external; + + /// @notice Claim bribes for a given NFT. + /// @dev Utility to help batch bribe claims. + /// @param _bribes Array of BribeVotingReward contracts to collect from. + /// @param _tokens Array of tokens that are used as bribes. + /// @param _tokenId Id of veNFT that you wish to claim bribes for. + function claimBribes( + address[] memory _bribes, + address[][] memory _tokens, + uint256 _tokenId + ) external; + + /// @notice Claim fees for a given NFT. + /// @dev Utility to help batch fee claims. + /// @param _fees Array of FeesVotingReward contracts to collect from. + /// @param _tokens Array of tokens that are used as fees. + /// @param _tokenId Id of veNFT that you wish to claim fees for. + function claimFees( + address[] memory _fees, + address[][] memory _tokens, + uint256 _tokenId + ) external; + + /// @notice Set new governor. + /// @dev Throws if not called by governor. + /// @param _governor . + function setGovernor(address _governor) external; + + /// @notice Set new epoch based governor. + /// @dev Throws if not called by governor. + /// @param _epochGovernor . + function setEpochGovernor(address _epochGovernor) external; + + /// @notice Set new emergency council. + /// @dev Throws if not called by emergency council. + /// @param _emergencyCouncil . + function setEmergencyCouncil(address _emergencyCouncil) external; + + /// @notice Whitelist (or unwhitelist) token for use in bribes. + /// @dev Throws if not called by governor. + /// @param _token . + /// @param _bool . + function whitelistToken(address _token, bool _bool) external; + + /// @notice Whitelist (or unwhitelist) token id for voting in last hour prior to epoch flip. + /// @dev Throws if not called by governor. + /// Throws if already whitelisted. + /// @param _tokenId . + /// @param _bool . + function whitelistNFT(uint256 _tokenId, bool _bool) external; + + /// @notice Create a new gauge (unpermissioned). + /// @dev Governor can create a new gauge for a pool with any address. + /// @dev V1 gauges can only be created by governor. + /// @param _poolFactory . + /// @param _pool . + function createGauge( + address _poolFactory, + address _pool + ) external returns (address); + + /// @notice Kills a gauge. The gauge will not receive any new emissions and cannot be deposited into. + /// Can still withdraw from gauge. + /// @dev Throws if not called by emergency council. + /// Throws if gauge already killed. + /// @param _gauge . + function killGauge(address _gauge) external; + + /// @notice Revives a killed gauge. Gauge will can receive emissions and deposits again. + /// @dev Throws if not called by emergency council. + /// Throws if gauge is not killed. + /// @param _gauge . + function reviveGauge(address _gauge) external; + + /// @dev Update claims to emissions for an array of gauges. + /// @param _gauges Array of gauges to update emissions for. + function updateFor(address[] memory _gauges) external; + + /// @dev Update claims to emissions for gauges based on their pool id as stored in Voter. + /// @param _start Starting index of pools. + /// @param _end Ending index of pools. + function updateFor(uint256 _start, uint256 _end) external; + + /// @dev Update claims to emissions for single gauge + /// @param _gauge . + function updateFor(address _gauge) external; + + // mappings + function gauges(address pool) external view returns (address); + + function poolForGauge(address gauge) external view returns (address); + + function gaugeToFees(address gauge) external view returns (address); + + function gaugeToBribe(address gauge) external view returns (address); + + function weights(address pool) external view returns (uint256); + + function votes( + uint256 tokenId, + address pool + ) external view returns (uint256); + + function usedWeights(uint256 tokenId) external view returns (uint256); + + function lastVoted(uint256 tokenId) external view returns (uint256); + + function isGauge(address) external view returns (bool); + + function isWhitelistedToken(address token) external view returns (bool); + + function isWhitelistedNFT(uint256 tokenId) external view returns (bool); + + function isAlive(address gauge) external view returns (bool); + + function ve() external view returns (address); + + function governor() external view returns (address); + + function epochGovernor() external view returns (address); + + function emergencyCouncil() external view returns (address); + + function length() external view returns (uint256); +} diff --git a/src/libraries/AddressKeys.sol b/src/libraries/AddressKeys.sol index 13a8213..85175c5 100644 --- a/src/libraries/AddressKeys.sol +++ b/src/libraries/AddressKeys.sol @@ -18,4 +18,5 @@ library AddressKeys { bytes32 public constant REBALANCE_FEE_RECEIVER = "rebalanceFeeReceiver"; bytes32 public constant ZAP_SWAP = "zapSwap"; bytes32 public constant OWNER = "owner"; + bytes32 public constant UPKEEP_REGISTRY = "upkeepRegistry"; } diff --git a/src/libraries/Config.sol b/src/libraries/Config.sol index 3ae7540..426610b 100644 --- a/src/libraries/Config.sol +++ b/src/libraries/Config.sol @@ -52,6 +52,19 @@ library Config { uint256 public constant STREAMING_FEE = 0.02e18; // 2% uint256 public constant MAX_REBALANCES = 2; // The maximum number of rebalances that can be performed in a single transaction + uint256 public constant MAX_TOPUPS = 2; // The maximum number of topups that can be performed in a single transaction + uint256 public constant TOPUP_BASE_NEXT_ATTEMPT_DELAY = 1 minutes; // 1 minute (doubles each attempt) + + uint256 public constant MIN_MARGIN = 80e18; + uint256 public constant MAX_SLIPPAGE = 0.05e18; // 5% + uint256 public constant TARGET_AMOUNT_PER_TOKEN = 120e18; + + uint256 public constant META_KEEPER_MAX_TOPUPS = 3; + uint256 public constant META_KEEPER_TOPUP_AMOUNT = 10e18; // 10 LINK + + uint256 public constant VOTER_INITIAL_TLX_PER_SECOND = + 0.16204761904761905e18; // Used for the Velodrome Voter Helper + // Bytes bytes32 public constant MERKLE_ROOT = bytes32( diff --git a/src/libraries/Contracts.sol b/src/libraries/Contracts.sol index 5771cc5..78846f0 100644 --- a/src/libraries/Contracts.sol +++ b/src/libraries/Contracts.sol @@ -12,10 +12,15 @@ library Contracts { address public constant PERPS_V2_EXCHANGE_RATE = 0x2C15259D4886e2C0946f9aB7a5E389c86b3c3b04; + // DEXs address public constant VELODROME_ROUTER = 0xa062aE8A9c5e11aaA026fc2670B0D65cCc8B2858; address public constant VELODROME_DEFAULT_FACTORY = 0xF1046053aa5682b4F9a81b5481394DA16BE5FF5a; address public constant UNISWAP_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + + // Velodrome + address public constant TLX_ETH_REWARDS = + 0xCd9b5776485AdB0DD96D4536329D93756A7110a2; } diff --git a/src/libraries/Symbols.sol b/src/libraries/Symbols.sol index 57b91e9..033ff94 100644 --- a/src/libraries/Symbols.sol +++ b/src/libraries/Symbols.sol @@ -11,4 +11,6 @@ library Symbols { string public constant SOL = "SOL"; string public constant LINK = "LINK"; string public constant OP = "OP"; + string public constant PEPE = "PEPE"; + string public constant DOGE = "DOGE"; } diff --git a/src/libraries/Tokens.sol b/src/libraries/Tokens.sol index 57d3f81..63b7623 100644 --- a/src/libraries/Tokens.sol +++ b/src/libraries/Tokens.sol @@ -12,4 +12,5 @@ library Tokens { address public constant USDCE = 0x7F5c764cBc14f9669B88837ca1490cCa17c31607; address public constant USDT = 0x94b008aA00579c1307B0EF2c499aD98a8ce58e58; address public constant WETH = 0x4200000000000000000000000000000000000006; + address public constant LINK = 0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6; } diff --git a/src/zaps/ZapSwap.sol b/src/zaps/ZapSwap.sol index 157b681..839aae1 100644 --- a/src/zaps/ZapSwap.sol +++ b/src/zaps/ZapSwap.sol @@ -2,11 +2,15 @@ pragma solidity ^0.8.13; import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; import {TlxOwnable} from "../utils/TlxOwnable.sol"; import {Errors} from "../libraries/Errors.sol"; +import {ScaledNumber} from "../libraries/ScaledNumber.sol"; +import {IStaker} from "../interfaces/IStaker.sol"; +import {IReferrals} from "../interfaces/IReferrals.sol"; import {IZapSwap} from "../interfaces/IZapSwap.sol"; import {IAddressProvider} from "../interfaces/IAddressProvider.sol"; import {IVelodromeRouter} from "../interfaces/exchanges/IVelodromeRouter.sol"; @@ -14,6 +18,9 @@ import {IUniswapRouter} from "../interfaces/exchanges/IUniswapRouter.sol"; import {ILeveragedToken} from "../interfaces/ILeveragedToken.sol"; contract ZapSwap is IZapSwap, TlxOwnable { + using SafeERC20 for IERC20; + using ScaledNumber for uint256; + IAddressProvider internal immutable _addressProvider; IVelodromeRouter internal immutable _velodromeRouter; IUniswapRouter internal immutable _uniswapRouter; @@ -39,6 +46,15 @@ contract ZapSwap is IZapSwap, TlxOwnable { ); } + function setReferral() external { + IReferrals referrals_ = _addressProvider.referrals(); + bytes32 code = referrals_.code(address(this)); + if (code == bytes32(0)) { + revert IReferrals.InvalidCode(); + } + referrals_.setReferral(code); + } + /// @inheritdoc IZapSwap function setAssetSwapData( address zapAsset_, @@ -181,12 +197,50 @@ contract ZapSwap is IZapSwap, TlxOwnable { leveragedTokenAmountIn_ ); + IERC20 baseAsset_ = _addressProvider.baseAsset(); + // Redeeming leveraged token for base asset - targetLeveragedToken.redeem(leveragedTokenAmountIn_, 0); + uint256 baseAssetAmountIn_ = targetLeveragedToken.redeem( + leveragedTokenAmountIn_, + 0 + ); + + IReferrals referrals_ = _addressProvider.referrals(); + uint256 totalFeeRatio_ = referrals_.rebatePercent() + + referrals_.referralPercent(); + + // At this point, fees not related to the referral system (when referral + rebate < 1) + // have already been sent to the staker, but we need to redistribute all the referral fees + uint256 totalFees_ = referrals_.claimEarnings(); + uint256 feesLeft_ = totalFees_; + + // If totalFeeRatio_ is 0, neither the referral nor the rebate is enabled + // so we bypass the referral system + if (totalFeeRatio_ > 0) { + uint256 adjustedFees_ = totalFees_.div(totalFeeRatio_); + + baseAsset_.safeIncreaseAllowance( + address(referrals_), + adjustedFees_ + ); + uint256 feesTaken_ = referrals_.takeEarnings( + adjustedFees_, + msg.sender + ); + feesLeft_ = totalFees_ > feesTaken_ ? totalFees_ - feesTaken_ : 0; + } + + // `feesLeft_` will be equal to the total fees if the user does not use a referral code. + // If a referral code is used, `feesLeft_` could be very slightly above 0 because of rounding errors. + // This means that we do a useless transfer to the staker but since we're on an L2 it's acceptable + if (feesLeft_ > 0) { + IStaker staker_ = _addressProvider.staker(); + + baseAsset_.safeIncreaseAllowance(address(staker_), feesLeft_); + staker_.donateRewards(feesLeft_); + } - IERC20 baseAsset_ = _addressProvider.baseAsset(); IERC20 zapAsset_ = IERC20(zapAssetAddress_); - uint256 baseAssetAmountIn_ = baseAsset_.balanceOf(address(this)); // Swapping base asset for zap asset based on swap data _swapAsset( diff --git a/test/LeveragedToken.t.sol b/test/LeveragedToken.t.sol index 5c62a8d..bfb40ac 100644 --- a/test/LeveragedToken.t.sol +++ b/test/LeveragedToken.t.sol @@ -14,6 +14,8 @@ import {Errors} from "../src/libraries/Errors.sol"; import {ScaledNumber} from "../src/libraries/ScaledNumber.sol"; import {ILeveragedToken} from "../src/interfaces/ILeveragedToken.sol"; +import {ISynthetixHandler} from "../src/interfaces/ISynthetixHandler.sol"; +import {IPerpsV2MarketConsolidated} from "../src/interfaces/synthetix/IPerpsV2MarketConsolidated.sol"; contract LeveragedTokenTest is IntegrationTest { using ScaledNumber for uint256; @@ -78,7 +80,6 @@ contract LeveragedTokenTest is IntegrationTest { uint256 targetValue = 100e18; if (isLoss) targetValue = targetValue - slippage; - else targetValue = targetValue + slippage; assertEq(leveragedTokenAmountOut, targetValue); assertEq(leveragedToken.totalSupply(), targetValue); assertEq(leveragedToken.balanceOf(address(this)), targetValue); @@ -149,11 +150,7 @@ contract LeveragedTokenTest is IntegrationTest { leveragedToken.balanceOf(address(this)), baseAmountIn - slippage ); - else - assertEq( - leveragedToken.balanceOf(address(this)), - baseAmountIn + slippage - ); + else assertEq(leveragedToken.balanceOf(address(this)), baseAmountIn); // Redeeming Leveraged Tokens uint256 leveragedTokenAmountIn = 1e18; @@ -236,7 +233,7 @@ contract LeveragedTokenTest is IntegrationTest { ); assertTrue(leveragedToken.canRebalance()); leveragedToken.rebalance(); - skip(30 seconds); + skip(10 seconds); _executeOrder(address(leveragedToken)); assertApproxEqRel( synthetixHandler.leverage(market, address(leveragedToken)), @@ -346,11 +343,7 @@ contract LeveragedTokenTest is IntegrationTest { leveragedToken.balanceOf(address(this)), amount - slippage ); - else - assertEq( - leveragedToken.balanceOf(address(this)), - amount + slippage - ); + else assertEq(leveragedToken.balanceOf(address(this)), amount); // Redeeming Leveraged Tokens assertEq(base.balanceOf(address(staker)), 0); @@ -383,7 +376,8 @@ contract LeveragedTokenTest is IntegrationTest { (uint256 shortSlippage, bool shortIsLoss) = shortLeveragedToken .computePriceImpact(baseAmountIn, true); - assertFalse(isLoss == shortIsLoss, "short and long are both a loss"); + // both could be a loss if positive price impact is less than fee + assertTrue(isLoss || shortIsLoss, "neither short nor long are losses"); assertGe(slippage, 0); assertGe(shortSlippage, 0); } @@ -399,7 +393,7 @@ contract LeveragedTokenTest is IntegrationTest { (uint256 shortSlippage, bool shortIsLoss) = shortLeveragedToken .computePriceImpact(baseAmountIn, false); - assertFalse(isLoss == shortIsLoss, "short and long are both a loss"); + assertTrue(isLoss || shortIsLoss, "neither short nor long are losses"); assertGe(slippage, 0); assertGe(shortSlippage, 0); } @@ -469,6 +463,29 @@ contract LeveragedTokenTest is IntegrationTest { assertGt(referralsAfter, 0); } + function testMintLargeAmount() public { + uint256 baseAmountIn = 10_096_656e18; + _mintTokensFor(Config.BASE_ASSET, address(this), baseAmountIn); + IERC20(Config.BASE_ASSET).approve( + address(leveragedToken), + baseAmountIn + ); + + leveragedToken.mint(baseAmountIn, 0); + } + + function testValidateMintAmount() public { + uint256 baseAmountIn = 60_096_656e18; + _mintTokensFor(Config.BASE_ASSET, address(this), baseAmountIn); + IERC20(Config.BASE_ASSET).approve( + address(leveragedToken), + baseAmountIn + ); + + vm.expectRevert(); + leveragedToken.mint(baseAmountIn, 0); + } + function _mintTokens() public { uint256 baseAmountIn = 100e18; _mintTokensFor(Config.BASE_ASSET, address(this), baseAmountIn); diff --git a/test/MetaKeeper.t.sol b/test/MetaKeeper.t.sol new file mode 100644 index 0000000..46bda1a --- /dev/null +++ b/test/MetaKeeper.t.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {IntegrationTest} from "./shared/IntegrationTest.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + +import {IAutomationRegistryConsumer} from "chainlink/src/v0.8/automation/interfaces/IAutomationRegistryConsumer.sol"; + +import {Tokens} from "../src/libraries/Tokens.sol"; +import {Errors} from "../src/libraries/Errors.sol"; +import {AddressKeys} from "../src/libraries/AddressKeys.sol"; + +import {MetaKeeper} from "../src/MetaKeeper.sol"; +import {TlxUpkeepRegistry} from "../src/TlxUpkeepRegistry.sol"; +import {ITlxUpkeepRegistry} from "../src/interfaces/ITlxUpkeepRegistry.sol"; +import {IMetaKeeper} from "../src/interfaces/IMetaKeeper.sol"; + +import "../lib/forge-std/src/console.sol"; + +contract MetaKeeperTest is IntegrationTest { + MetaKeeper public metaKeeper; + + TlxUpkeepRegistry public upkeepRegistry; + + uint256 constant ETH_UPKEEP_ID = + 69761711317135691618368880510486796211784454606102499205980248015108075576007; + address constant ETH_REGISTRY_ADDRESS = + 0x696fB0d7D069cc0bb35a7c36115CE63E55cb9AA6; + address constant ETH_UPKEEP_ADDRESS = + 0x57B76c38B583DE48ABAA3B8612B2D75A031FcD62; + address constant ETH_OWNER_ADDRESS = + 0xf6A741259da6ee0d1A863bd847b12A6c2943Ea57; + + IAutomationRegistryConsumer public chainlinkRegistry = + IAutomationRegistryConsumer(ETH_REGISTRY_ADDRESS); + + function setUp() public override { + super.setUp(); + + upkeepRegistry = new TlxUpkeepRegistry(address(addressProvider)); + addressProvider.updateAddress( + AddressKeys.UPKEEP_REGISTRY, + address(upkeepRegistry) + ); + + metaKeeper = new MetaKeeper( + address(addressProvider), + 2, + Tokens.LINK, + 100e18 + ); + + metaKeeper.addForwarderAddress(bob); + + ITlxUpkeepRegistry.Upkeep memory upkeep = ITlxUpkeepRegistry.Upkeep( + ETH_UPKEEP_ID, + ETH_REGISTRY_ADDRESS, + ETH_UPKEEP_ADDRESS + ); + + upkeepRegistry.addUpkeep(upkeep); + } + + function testInit() public { + (bool upkeepNeeded, bytes memory performData) = metaKeeper.checkUpkeep( + "" + ); + assertEq(upkeepNeeded, false); + assertEq(performData.length, 0); + } + + function testWithInsufficientBalance() public { + _mintTokensFor(Tokens.LINK, address(metaKeeper), 500e18); + uint96 currentBalance = chainlinkRegistry.getBalance(ETH_UPKEEP_ID); + vm.mockCall( + address(chainlinkRegistry), + abi.encodeWithSelector( + IAutomationRegistryConsumer.getMinBalance.selector, + ETH_UPKEEP_ID + ), + abi.encode(currentBalance + 1e18) + ); + (bool upkeepNeeded, bytes memory performData) = metaKeeper.checkUpkeep( + "" + ); + assertEq(upkeepNeeded, true); + uint256[] memory keepersToTopUp = abi.decode(performData, (uint256[])); + assertEq(keepersToTopUp.length, 1); + } + + function testUpKeepPerformed() public { + _mintTokensFor(Tokens.LINK, address(metaKeeper), 500e18); + uint96 previousBalance = chainlinkRegistry.getBalance(ETH_UPKEEP_ID); + vm.mockCall( + address(chainlinkRegistry), + abi.encodeWithSelector( + IAutomationRegistryConsumer.getMinBalance.selector, + ETH_UPKEEP_ID + ), + abi.encode(previousBalance + 1e18) + ); + (bool upkeepNeeded, bytes memory performData) = metaKeeper.checkUpkeep( + "" + ); + assertEq(upkeepNeeded, true); + uint256[] memory keepersToTopUp = abi.decode(performData, (uint256[])); + assertEq(keepersToTopUp.length, 1); + + vm.prank(bob); + metaKeeper.performUpkeep(performData); + assertEq( + chainlinkRegistry.getBalance(ETH_UPKEEP_ID), + previousBalance + 100e18 + ); + assertEq(IERC20(Tokens.LINK).balanceOf(address(metaKeeper)), 400e18); + } + + function testRevertsWithNonForwarder() public { + _mintTokensFor(Tokens.LINK, address(metaKeeper), 500e18); + uint96 previousBalance = chainlinkRegistry.getBalance(ETH_UPKEEP_ID); + vm.mockCall( + address(chainlinkRegistry), + abi.encodeWithSelector( + IAutomationRegistryConsumer.getMinBalance.selector, + ETH_UPKEEP_ID + ), + abi.encode(previousBalance + 1e18) + ); + (bool upkeepNeeded, bytes memory performData) = metaKeeper.checkUpkeep( + "" + ); + assertEq(upkeepNeeded, true); + uint256[] memory keepersToTopUp = abi.decode(performData, (uint256[])); + assertEq(keepersToTopUp.length, 1); + + vm.expectRevert(IMetaKeeper.NotForwarder.selector); + metaKeeper.performUpkeep(performData); + } + + function testRevertWithOutOfFunds() public { + _mintTokensFor(Tokens.LINK, address(metaKeeper), 500e18); + + uint96 previousBalance = chainlinkRegistry.getBalance(ETH_UPKEEP_ID); + vm.mockCall( + address(chainlinkRegistry), + abi.encodeWithSelector( + IAutomationRegistryConsumer.getMinBalance.selector, + ETH_UPKEEP_ID + ), + abi.encode(previousBalance + 1e18) + ); + (, bytes memory performData) = metaKeeper.checkUpkeep(""); + vm.prank(address(metaKeeper)); + IERC20(Tokens.LINK).transfer(address(this), 499e18); + + vm.prank(bob); + vm.expectRevert(IMetaKeeper.OutOfFunds.selector); + metaKeeper.performUpkeep(performData); + } + + function testPerformRevertsWithSufficientFunds() public { + _mintTokensFor(Tokens.LINK, address(metaKeeper), 500e18); + uint96 previousBalance = chainlinkRegistry.getBalance(ETH_UPKEEP_ID); + vm.mockCall( + address(chainlinkRegistry), + abi.encodeWithSelector( + IAutomationRegistryConsumer.getMinBalance.selector, + ETH_UPKEEP_ID + ), + abi.encode(previousBalance + 1e18) + ); + (bool upkeepNeeded, bytes memory performData) = metaKeeper.checkUpkeep( + "" + ); + assertEq(upkeepNeeded, true); + uint256[] memory keepersToTopUp = abi.decode(performData, (uint256[])); + assertEq(keepersToTopUp.length, 1); + + _mintTokensFor(Tokens.LINK, address(this), 500e18); + IERC20(Tokens.LINK).approve(ETH_REGISTRY_ADDRESS, 300e18); + chainlinkRegistry.addFunds(ETH_UPKEEP_ID, 300e18); + + vm.prank(bob); + vm.expectRevert(IMetaKeeper.UpkeepHasSufficientFunds.selector); + metaKeeper.performUpkeep(performData); + } +} diff --git a/test/VelodromeVoterAutomation.t.sol b/test/VelodromeVoterAutomation.t.sol new file mode 100644 index 0000000..3de5547 --- /dev/null +++ b/test/VelodromeVoterAutomation.t.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {IntegrationTest} from "./shared/IntegrationTest.sol"; + +import {AddressKeys} from "../src/libraries/AddressKeys.sol"; +import {Config} from "../src/libraries/Config.sol"; +import {Contracts} from "../src/libraries/Contracts.sol"; + +import {ITlxToken} from "../src/interfaces/ITlxToken.sol"; +import {VelodromeVoterAutomation} from "../src/helpers/VelodromeVoterAutomation.sol"; + +contract VelodromeVoterRunnerTest is IntegrationTest { + uint256 constant PRECISION = 0.001e18; // 0.1% + + function setUp() public override { + super.setUp(); + + // Transferring the TLX to the voter runner + uint256 balance_ = tlx.balanceOf(Config.AMM_DISTRIBUTOR); + vm.prank(Config.AMM_DISTRIBUTOR); + tlx.transfer(address(voterAutomation), balance_ / 2); + } + + function testRecoverTokens() public { + address owner_ = addressProvider.owner(); + uint256 runnerBefore_ = tlx.balanceOf(address(voterAutomation)); + assertGt(runnerBefore_, 0, "balanceBefore"); + uint256 balanceBefore_ = tlx.balanceOf(address(this)); + vm.expectRevert(); + vm.prank(alice); + voterAutomation.recoverTokens(address(tlx), address(this)); + vm.prank(owner_); + voterAutomation.recoverTokens(address(tlx), address(this)); + uint256 runnerAfter_ = tlx.balanceOf(address(voterAutomation)); + assertEq(runnerAfter_, 0, "balanceAfter"); + uint256 balanceAfter_ = tlx.balanceOf(address(this)); + assertEq(balanceAfter_, balanceBefore_ + runnerBefore_, "balanceAfter"); + } + + function testRun() public { + skip(1 days); + (bool upkeepNeeded, ) = voterAutomation.checkUpkeep(""); + assertTrue(upkeepNeeded, "canRun"); + uint256 tlxBefore_ = tlx.balanceOf(Contracts.TLX_ETH_REWARDS); + voterAutomation.performUpkeep(""); + uint256 tlxAfter_ = tlx.balanceOf(Contracts.TLX_ETH_REWARDS); + uint256 tlxGained_ = tlxAfter_ - tlxBefore_; + assertApproxEqRel(tlxGained_, 98_006.40e18, PRECISION, "tlxGained"); + } + + function testCanNotRun() public { + skip(1 days); + (bool upkeepNeeded, ) = voterAutomation.checkUpkeep(""); + assertTrue(upkeepNeeded, "canRun"); + voterAutomation.performUpkeep(""); + vm.expectRevert(VelodromeVoterAutomation.CanNotRun.selector); + voterAutomation.performUpkeep(""); + } + + function testRunMany() public { + skip(1 days); + _testRun(98_006.40e18); + _testRun(98_006.40e18); + _testRun(97_614.35e18); + _testRun(95_262.04e18); + _testRun(95_262.04e18); + _testRun(94_499.90e18); + _testRun(92_594.53e18); + _testRun(92_594.53e18); + _testRun(91_483.32e18); + _testRun(90_001.71e18); + _testRun(90_001.71e18); + } + + function testRunWithDelay() public { + VelodromeVoterAutomation delayedRunner = new VelodromeVoterAutomation( + address(addressProvider), + Config.VOTER_INITIAL_TLX_PER_SECOND, + Config.PERIOD_DECAY_MULTIPLIER, + Config.PERIOD_DURATION, + block.timestamp - 1 days, + Contracts.TLX_ETH_REWARDS, + block.timestamp - 7 days * 3 - 1 days, + 293_627.15e18 + ); + uint256 balance_ = tlx.balanceOf(Config.AMM_DISTRIBUTOR); + vm.prank(Config.AMM_DISTRIBUTOR); + tlx.transfer(address(delayedRunner), balance_); + uint256 tlxBefore_ = tlx.balanceOf(Contracts.TLX_ETH_REWARDS); + delayedRunner.performUpkeep(""); + uint256 tlxAfter_ = tlx.balanceOf(Contracts.TLX_ETH_REWARDS); + uint256 tlxGained_ = tlxAfter_ - tlxBefore_; + assertApproxEqRel(tlxGained_, 95_262.04e18, PRECISION, "tlxGained"); + } + + function _testRun(uint256 expected_) internal { + uint256 tlxBefore_ = tlx.balanceOf(Contracts.TLX_ETH_REWARDS); + voterAutomation.performUpkeep(""); + uint256 tlxAfter_ = tlx.balanceOf(Contracts.TLX_ETH_REWARDS); + uint256 tlxGained_ = tlxAfter_ - tlxBefore_; + assertApproxEqRel(tlxGained_, expected_, PRECISION, "tlxGained"); + skip(7 days); + } +} diff --git a/test/shared/IntegrationTest.sol b/test/shared/IntegrationTest.sol index 5a7a025..893a3be 100644 --- a/test/shared/IntegrationTest.sol +++ b/test/shared/IntegrationTest.sol @@ -14,7 +14,6 @@ import {ParameterKeys} from "../../src/libraries/ParameterKeys.sol"; import {Config} from "../../src/libraries/Config.sol"; import {Symbols} from "../../src/libraries/Symbols.sol"; import {InitialMint} from "../../src/libraries/InitialMint.sol"; -import {ForkBlock} from "./ForkBlock.sol"; import {ScaledNumber} from "../../src/libraries/ScaledNumber.sol"; import {IVesting} from "../../src/interfaces/IVesting.sol"; @@ -25,6 +24,9 @@ import {AggregatorV2V3Interface} from "../../src/interfaces/chainlink/Aggregator import {IPerpsV2MarketData} from "../../src/interfaces/synthetix/IPerpsV2MarketData.sol"; import {IPerpsV2MarketConsolidated} from "../../src/interfaces/synthetix/IPerpsV2MarketConsolidated.sol"; import {IPerpsV2ExchangeRate} from "../../src/interfaces/synthetix/IPerpsV2ExchangeRate.sol"; +import {ILeveragedToken} from "../../src/interfaces/ILeveragedToken.sol"; +import {IRewards} from "../../src/interfaces/velodrome/IRewards.sol"; +import {IVoter} from "../../src/interfaces/velodrome/IVoter.sol"; import {LeveragedTokenFactory} from "../../src/LeveragedTokenFactory.sol"; import {AddressProvider} from "../../src/AddressProvider.sol"; @@ -37,6 +39,7 @@ import {GenesisLocker} from "../../src/GenesisLocker.sol"; import {Bonding} from "../../src/Bonding.sol"; import {Vesting} from "../../src/Vesting.sol"; import {SynthetixHandler} from "../../src/SynthetixHandler.sol"; +import {VelodromeVoterAutomation} from "../../src/helpers/VelodromeVoterAutomation.sol"; import {Base64} from "../../src/testing/Base64.sol"; @@ -48,11 +51,9 @@ contract IntegrationTest is Test { using ScaledNumber for uint256; // Some notes on why this is commented out below - // string constant PYTH_URL = "https://hermes.pyth.network/api/get_vaa"; - // string constant PYTH_ID = - // "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"; // ETH/USD - string constant VAA = - "UE5BVQEAAAADuAEAAAADDQBdPXDahGNNr23Wo+CIBkq9kZLHqI667+PA5fraNH918xy+0w/XbhLUvraNuaNEBYsYb5LhiY/2MgEhngj22e03AAMFiZExKBlpCCR0j8/kvO3bvsoQi7pFKiFfIePaw5Rn9nzGWGhl3WjGcEgH9I4nVa7UrnAbRqPMIaXLDLASlk0wAQZjulzaozrgAZrb3iog34sIxRMXbrOhmPmmPwmnBIvUbh4yi6egeUxf5S+95hg7dh8nwiEM0M0tqHvEz821vna7AQjnVJaM3w6iHnSThMkWydx633fhvMuUCRjxURW01stnBAOCBL1WMsGQWXDDHsdrES3MEuiNVYEkhY9K2uAiN+X9AAlOcZBXAU0Jb5izrcVrG8QOYKIWiVVkfq5Jsh/yPED7pV4RyGefKh5SkF95DLNqFZC8HjYMMqSh8gC4J0+bc4K3AQpZSA6i4rbOHZbxSlgoeG/fpeOcJf8aIl6Uv++kK8SM/xNe0FQA4R5jSo+wAfVijjOF2jTWDEX9hO6TJ69Y/WU6AQsbbzxflnBwq4hC5TtFjtusMLEDBlIzp7pYfWByk6MSkCk1YbguZ4/RWBH/X11Z/ETKVFuo3nWXDd2evwJY2EjxAQxlPMZa410JIXlnxIA4H3inO61sBMYWLJxR0ybKn0DtYH3N4ev40QJ+YOVuqAO0sIPqm69M12pxqVhKPlH/+KixAA0/nKdIHaz0SmC2nsFksxa30x8TJD6dR61fyaMdDIS6E03/HhJ8vT9bqPHHcfuLcAOPKGkhfmkWqPFPjhnRKONXAQ4Y+j5vmyD9l52Qn+tagF1TjAKiDVFNcQfBSiK06ThXC3hdJjj7Pt5Usf8SeKrAA6/+pf0RHgzNN728OsF3+z+VAA9+Do9+UOqPwhn/i3GiM1TvGhXDQwadPCwG9N/bhJq/my8eDlKd5sinXGIIE/A1w83wx4Pz1r3faxkWRniOu6zWABHhKHcCh8nLecCQTH8rOGwD6iIH3vM0YhWtpBKiyzkBahEGR1Ewin1v51O6D2QqQAeLDNyhkrW1hNJKLJDFNjpIABKzqVlIGGw6k+c/UUejYxYY9LFYRyYLFlBkZk2IO3l2ywoU1r6lfZlmiNLdsIw6x0loIy1fzfS1GjvRwbRmjjyFAWVzEgsAAAAAABrhAfrtrFhR4yubI7X5QRqMK6xKrj7U3XuBHdGnLqSqcQAAAAABy1tpAUFVV1YAAAAAAAbSX6oAACcQ1Z4XBgUcxMOAKhsGv2fZAZvH7pIBAFUA/2FJGpMREt3xvYFHzRtkE3X3n1glEm1mVICHRjT9Cs4AAAA3IM7epAAAAAAFSGFk////+AAAAABlcxIKAAAAAGVzEgkAAAA3DMMpcAAAAAAFgWxeCiya6yAcnUvKQ6WTHY+hZESCgVoOLtDwnokED5y2b6Eo/RCJ6ls/X/v8suW6/MSSp9FerSPbzooMjgGRNqpKwYnD73KDP9tSHlUYubhmLIQKv5PZhQGXWEDmq7Y23DDpuFo8DRQBF1Kxr1MRzQsQfhBJNI+4j3J1BUAHMGLhlkueL2BRfvVilCZwu92oEu5GL4GtbNyMbsivwthqdUvxmwqmlRQ6xdue3kiLMTXVQa8OxRIkKuhpr4w8sQy4T6DSpv/XeLnwksYn"; + string constant PYTH_URL = "https://hermes.pyth.network/api/get_vaa"; + + mapping(string => string) public assetPythIds; // Users address public alice = 0xEcfcf2996C7c2908Fc050f5EAec633c01A937712; @@ -72,11 +73,27 @@ contract IntegrationTest is Test { Bonding public bonding; Vesting public vesting; SynthetixHandler public synthetixHandler; + VelodromeVoterAutomation public voterAutomation; function setUp() public virtual { - vm.selectFork( - vm.createFork(vm.envString("OPTIMISM_RPC"), ForkBlock.NUMBER) - ); + vm.createSelectFork(vm.envString("OPTIMISM_RPC"), 122555729); + + // Set Pyth IDs + assetPythIds[ + Symbols.ETH + ] = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace"; + assetPythIds[ + Symbols.BTC + ] = "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43"; + assetPythIds[ + Symbols.SOL + ] = "0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d"; + assetPythIds[ + Symbols.LINK + ] = "0x8ac0c70fff57e9aefdf5edf44b51d62c2d433653cbb2cf5cc06bb115af04d221"; + assetPythIds[ + Symbols.OP + ] = "0x385f64d993f7b77d8182ed5003d97c60aa3361f3cecfe711544d2d59165e9bdf"; // AddressProvider Setup addressProvider = new AddressProvider(); @@ -204,6 +221,22 @@ contract IntegrationTest is Test { ); tlx.mintInitialSupply(InitialMint.getData(addressProvider)); + + // VelodromeVoterRunner Setup + voterAutomation = new VelodromeVoterAutomation( + address(addressProvider), + Config.VOTER_INITIAL_TLX_PER_SECOND, + Config.PERIOD_DECAY_MULTIPLIER, + Config.PERIOD_DURATION, + block.timestamp, + Contracts.TLX_ETH_REWARDS, + block.timestamp, + 0 + ); + IVoter voter_ = IVoter(IRewards(Contracts.TLX_ETH_REWARDS).voter()); + address governor_ = voter_.governor(); + vm.prank(governor_); + voter_.whitelistToken(address(tlx), true); } receive() external payable {} @@ -225,20 +258,31 @@ contract IntegrationTest is Test { .checked_write(amount_); } + // default version using ETH as the target asset function _executeOrder() internal { - _executeOrder(address(this)); + uint256 currentTime = block.timestamp; + uint256 searchTime = currentTime + 5; + string memory vaa = _getVaa(Symbols.ETH, searchTime); + bytes memory decoded = Base64.decode(vaa); + bytes memory hexData = abi.encodePacked(decoded); + bytes[] memory priceUpdateData = new bytes[](1); + priceUpdateData[0] = hexData; + _market(Symbols.ETH).executeOffchainDelayedOrder{value: 1 ether}( + address(this), + priceUpdateData + ); } function _executeOrder(address account_) internal { - // uint256 currentTime = block.timestamp; - // uint256 searchTime = currentTime + 5; - // string memory vaa = _getVaa(searchTime); - string memory vaa = _getVaa(); + uint256 currentTime = block.timestamp; + uint256 searchTime = currentTime + 5; + string memory asset = ILeveragedToken(account_).targetAsset(); + string memory vaa = _getVaa(asset, searchTime); bytes memory decoded = Base64.decode(vaa); bytes memory hexData = abi.encodePacked(decoded); bytes[] memory priceUpdateData = new bytes[](1); priceUpdateData[0] = hexData; - _market(Symbols.ETH).executeOffchainDelayedOrder{value: 1 ether}( + _market(asset).executeOffchainDelayedOrder{value: 1 ether}( account_, priceUpdateData ); @@ -308,32 +352,23 @@ contract IntegrationTest is Test { return rate / divisor; } - // We used to use this API logic, allowing us to get it at any timestamp. - // The endpoint is in the format https://hermes.pyth.network/api/get_vaa?id=0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace&publish_time=1702040074 - // However suddently the API became super flakey, and stopped working. Tried an alternative, and it was even worse - // Realised that the timestamp is always roughly the same, so we can just hard code the VAA. - // Although this will break when we update the block number, meaning we have to manually update the VAA again. - // Not ideal long term, but hopefully we can switch back to the API when it's more stable. - - // function _getVaa(uint256 publishTime) internal returns (string memory) { - // string memory url = string.concat( - // PYTH_URL, - // "?id=", - // PYTH_ID, - // "&publish_time=", - // Strings.toString(publishTime) - // ); - // console.log(url); - // string[] memory inputs = new string[](3); - // inputs[0] = "curl"; - // inputs[1] = url; - // inputs[2] = "-s"; - // bytes memory res = vm.ffi(inputs); - // return abi.decode(string(res).parseRaw(".vaa"), (string)); - // } - - function _getVaa() internal pure returns (string memory) { - return VAA; + function _getVaa( + string memory asset, + uint256 publishTime + ) internal returns (string memory) { + string memory url = string.concat( + PYTH_URL, + "?id=", + assetPythIds[asset], + "&publish_time=", + Strings.toString(publishTime) + ); + string[] memory inputs = new string[](3); + inputs[0] = "curl"; + inputs[1] = url; + inputs[2] = "-s"; + bytes memory res = vm.ffi(inputs); + return abi.decode(string(res).parseRaw(".vaa"), (string)); } function _market( diff --git a/test/zaps/PolManager.sol b/test/zaps/PolManager.sol new file mode 100644 index 0000000..c72cb76 --- /dev/null +++ b/test/zaps/PolManager.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.13; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; + +import {Errors} from "../libraries/Errors.sol"; +import {IAddressProvider} from "../interfaces/IAddressProvider.sol"; +import {ILeveragedToken} from "../interfaces/ILeveragedToken.sol"; +import {IPolManager} from "../interfaces/IPolManager.sol"; + +contract PolManager is Ownable, IPolManager { + IAddressProvider public immutable addressProvider; + + constructor( + address initialOwner_, + IAddressProvider addressProvider_ + ) Ownable(initialOwner_) { + addressProvider = addressProvider_; + } + + function redeemTokens( + TokenToRedeem[] calldata tokensToRedeem_, + uint256 minAmountOut_ + ) external override onlyOwner returns (uint256) { + address pol_ = addressProvider.pol(); + IERC20 baseAsset_ = addressProvider.baseAsset(); + uint256 totalReceived_; + + for (uint256 i; i < tokensToRedeem_.length; i++) { + totalReceived_ += _redeem(tokensToRedeem_[i]); + } + if (totalReceived_ < minAmountOut_) revert Errors.InsufficientAmount(); + baseAsset_.transfer(pol_, totalReceived_); + return totalReceived_; + } + + function recoverTokens( + address tokenAddress_, + uint256 amount_ + ) external onlyOwner { + IERC20(tokenAddress_).transfer(owner(), amount_); + } + + function _redeem( + TokenToRedeem memory tokenToRedeem_ + ) internal returns (uint256) { + IERC20(tokenToRedeem_.tokenAddress).transferFrom( + addressProvider.pol(), + address(this), + tokenToRedeem_.amount + ); + return + ILeveragedToken(tokenToRedeem_.tokenAddress).redeem( + tokenToRedeem_.amount, + 0 + ); + } +} diff --git a/test/zaps/ZapSwap.t.sol b/test/zaps/ZapSwap.t.sol index 8ce6b7a..a4c6116 100644 --- a/test/zaps/ZapSwap.t.sol +++ b/test/zaps/ZapSwap.t.sol @@ -4,7 +4,9 @@ pragma solidity ^0.8.13; import {IntegrationTest} from "../shared/IntegrationTest.sol"; import {ILeveragedToken} from "../../src/interfaces/ILeveragedToken.sol"; +import {IReferrals} from "../../src/interfaces/IReferrals.sol"; import {IZapSwap} from "../../src/interfaces/IZapSwap.sol"; +import {AggregatorV2V3Interface} from "../../src/interfaces/chainlink/AggregatorV2V3Interface.sol"; import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {Tokens} from "../../src/libraries/Tokens.sol"; @@ -44,6 +46,8 @@ contract WrappedZapSwap is ZapSwap { contract ZapSwapTest is IntegrationTest { using ScaledNumber for uint256; + uint256 public susdPrice; + // ZapSwap contract as deployed ZapSwap public zapSwap; @@ -58,13 +62,17 @@ contract ZapSwapTest is IntegrationTest { function setUp() public override { super.setUp(); - baseAsset = addressProvider.baseAsset(); ethPrice = synthetixHandler.assetPrice( synthetixHandler.market(Symbols.ETH) ); veloDefaultFactory = Contracts.VELODROME_DEFAULT_FACTORY; + AggregatorV2V3Interface susdFeed = AggregatorV2V3Interface( + 0x7f99817d87baD03ea21E05112Ca799d715730efe + ); + (, int256 price, , , ) = susdFeed.latestRoundData(); + susdPrice = uint256(price) * 10 ** 10; // Create new zapSwap zapSwap = new ZapSwap( address(addressProvider), @@ -91,6 +99,10 @@ contract ZapSwapTest is IntegrationTest { Config.REBALANCE_THRESHOLD ); leveragedToken = ILeveragedToken(longTokenAddress_); + + _mintTokensFor(address(tlx), address(this), 100e18); + tlx.approve(address(staker), 100e18); + staker.stake(100e18); } function testSwapPaths() public { @@ -98,7 +110,7 @@ contract ZapSwapTest is IntegrationTest { IZapSwap.SwapData memory usdtSwapPath = zapSwap.swapData(Tokens.USDT); assertEq(usdtSwapPath.supported, true); assertEq(usdtSwapPath.direct, false); - assertEq(usdtSwapPath.bridgeAsset, Tokens.USDCE); + assertEq(usdtSwapPath.bridgeAsset, Tokens.USDC); assertEq(usdtSwapPath.zapAssetSwapStable, true); assertEq(usdtSwapPath.baseAssetSwapStable, true); assertEq(usdtSwapPath.zapAssetFactory, veloDefaultFactory); @@ -156,7 +168,7 @@ contract ZapSwapTest is IntegrationTest { zapSwap.setAssetSwapData(address(9), dummySD); // Test setting uniSwap path with unsupported bridge assets - IZapSwap.SwapData memory unsupprtedBridgeSD = IZapSwap.SwapData({ + IZapSwap.SwapData memory unsupportedBridgeSD = IZapSwap.SwapData({ supported: true, direct: false, bridgeAsset: Tokens.CRV, @@ -168,11 +180,11 @@ contract ZapSwapTest is IntegrationTest { uniPoolFee: 0 }); vm.expectRevert(); - zapSwap.setAssetSwapData(address(9), unsupprtedBridgeSD); - IZapSwap.SwapData memory unsupprtedBridgeSDTwo = IZapSwap.SwapData({ + zapSwap.setAssetSwapData(address(9), unsupportedBridgeSD); + IZapSwap.SwapData memory unsupportedBridgeSDTwo = IZapSwap.SwapData({ supported: true, direct: false, - bridgeAsset: Tokens.USDC, + bridgeAsset: Tokens.USDT, zapAssetSwapStable: false, baseAssetSwapStable: false, zapAssetFactory: veloDefaultFactory, @@ -181,15 +193,15 @@ contract ZapSwapTest is IntegrationTest { uniPoolFee: 0 }); vm.expectRevert(); - zapSwap.setAssetSwapData(Tokens.USDC, unsupprtedBridgeSDTwo); + zapSwap.setAssetSwapData(Tokens.USDC, unsupportedBridgeSDTwo); } function testSupportedAssets() public { address[] memory supportedAssets = zapSwap.supportedZapAssets(); - assertEq(supportedAssets[0], Tokens.USDCE); - assertEq(supportedAssets[1], Tokens.USDT); - assertEq(supportedAssets[2], Tokens.DAI); - assertEq(supportedAssets[3], Tokens.USDC); + assertEq(supportedAssets[0], Tokens.USDC); + assertEq(supportedAssets[1], Tokens.USDCE); + assertEq(supportedAssets[2], Tokens.USDT); + assertEq(supportedAssets[3], Tokens.DAI); assertEq(supportedAssets[4], Tokens.WETH); assertEq(zapSwap.supportedZapAssets().length, 5); } @@ -209,7 +221,7 @@ contract ZapSwapTest is IntegrationTest { Tokens.USDT ) ); - zapSwap.removeAssetSwapData(Tokens.USDCE); + zapSwap.removeAssetSwapData(Tokens.USDC); // Verify removing an unsupported asset reverts correctly vm.expectRevert(IZapSwap.UnsupportedAsset.selector); @@ -236,23 +248,23 @@ contract ZapSwapTest is IntegrationTest { } function testSimpleMintWithUSDCe() public { - simpleMint(Tokens.USDCE, 1000e6, 1000e18); + simpleMint(Tokens.USDCE, 1000e6, uint256(1000e18).div(susdPrice)); } function testSimpleMintWithUSDT() public { - simpleMint(Tokens.USDT, 1000e6, 1000e18); + simpleMint(Tokens.USDT, 1000e6, uint256(1000e18).div(susdPrice)); } function testSimpleMintWithDAI() public { - simpleMint(Tokens.DAI, 1000e18, 1000e18); + simpleMint(Tokens.DAI, 1000e18, uint256(1000e18).div(susdPrice)); } function testSimpleMintWithUSDC() public { - simpleMint(Tokens.USDC, 1000e6, 1000e18); + simpleMint(Tokens.USDC, 1000e6, uint256(1000e18).div(susdPrice)); } function testSimpleMintWithWETH() public { - simpleMint(Tokens.WETH, 1e18, ethPrice); + simpleMint(Tokens.WETH, 1e18, ethPrice.div(susdPrice)); } function simpleMint( @@ -348,7 +360,7 @@ contract ZapSwapTest is IntegrationTest { address zapAssetOut, uint256 baseAssetAmountIn, uint256 zapAssetAmountOut - ) public { + ) public returns (uint256) { // Mint leveraged tokens with baseAsset and without using the zap // IERC20 baseAsset = IERC20(addressProvider.baseAsset()); _mintTokensFor(address(baseAsset), address(this), baseAssetAmountIn); @@ -377,11 +389,16 @@ contract ZapSwapTest is IntegrationTest { uint256 leveragedTokenAmountToRedeem = leveragedToken.balanceOf( address(this) ) / 2; - uint256 halfZapAssetAmountOut = zapAssetAmountOut.div(2e18); - uint256 minZapAssetAmountOut = zapAssetAmountOut.div(200e18).mul(97e18); + uint256 halfZapAssetAmountOut = zapAssetAmountOut.div(2e18).mul( + susdPrice + ); + uint256 minZapAssetAmountOut = zapAssetAmountOut + .div(200e18) + .mul(97e18) + .mul(susdPrice); leveragedToken.approve(address(zapSwap), leveragedTokenAmountToRedeem); uint256 zapAssetBalanceBeforeRedeem = zapAsset.balanceOf(address(this)); - zapSwap.redeem( + uint256 redeemed = zapSwap.redeem( zapAssetOut, address(leveragedToken), leveragedTokenAmountToRedeem, @@ -404,6 +421,7 @@ contract ZapSwapTest is IntegrationTest { 0.03e18, "Did not receive enough zapAsset from leveraged token redemption." ); + return redeemed; } function testRedeemRevertDirectZapSwap() public { @@ -543,7 +561,7 @@ contract ZapSwapTest is IntegrationTest { Tokens.USDCE, address(baseAsset), 10000e6, - 10000e18, + uint256(10000e18).div(susdPrice), wrappedZapSwap.swapData(Tokens.USDCE), true ); @@ -554,7 +572,7 @@ contract ZapSwapTest is IntegrationTest { address(baseAsset), Tokens.USDCE, 10000e18, - 10000e6, + uint256(10000e6).mul(susdPrice), wrappedZapSwap.swapData(Tokens.USDCE), false ); @@ -565,7 +583,7 @@ contract ZapSwapTest is IntegrationTest { Tokens.USDT, address(baseAsset), 10000e6, - 10000e18, + uint256(10000e18).div(susdPrice), wrappedZapSwap.swapData(Tokens.USDT), true ); @@ -576,7 +594,7 @@ contract ZapSwapTest is IntegrationTest { address(baseAsset), Tokens.USDT, 10000e18, - 10000e6, + uint256(10000e6).mul(susdPrice), wrappedZapSwap.swapData(Tokens.USDT), false ); @@ -587,7 +605,7 @@ contract ZapSwapTest is IntegrationTest { Tokens.DAI, address(baseAsset), 10000e18, - 10000e18, + uint256(10000e18).div(susdPrice), wrappedZapSwap.swapData(Tokens.DAI), true ); @@ -598,7 +616,7 @@ contract ZapSwapTest is IntegrationTest { address(baseAsset), Tokens.DAI, 10000e18, - 10000e18, + uint256(10000e18).mul(susdPrice), wrappedZapSwap.swapData(Tokens.DAI), false ); @@ -609,7 +627,7 @@ contract ZapSwapTest is IntegrationTest { Tokens.USDC, address(baseAsset), 10000e6, - 10000e18, + uint256(10000e18).div(susdPrice), wrappedZapSwap.swapData(Tokens.USDC), true ); @@ -620,7 +638,7 @@ contract ZapSwapTest is IntegrationTest { address(baseAsset), Tokens.USDC, 10000e18, - 10000e6, + uint256(10000e6).mul(susdPrice), wrappedZapSwap.swapData(Tokens.USDC), false ); @@ -631,7 +649,7 @@ contract ZapSwapTest is IntegrationTest { Tokens.WETH, address(baseAsset), 1e18, - ethPrice, + ethPrice.div(susdPrice), wrappedZapSwap.swapData(Tokens.WETH), true ); @@ -642,7 +660,7 @@ contract ZapSwapTest is IntegrationTest { address(baseAsset), Tokens.WETH, ethPrice, - 1e18, + susdPrice, wrappedZapSwap.swapData(Tokens.WETH), false ); @@ -684,8 +702,169 @@ contract ZapSwapTest is IntegrationTest { ); } + function testRedirectsFeesToStaker() public { + _setZapReferral(); + assertEq(baseAsset.balanceOf(address(staker)), 0); + + uint256 amountRedeemed_ = simpleRedeem(Tokens.DAI, 1000e18, 1000e18); + uint256 targetLeverage_ = leveragedToken.targetLeverage(); + uint256 feePercent_ = addressProvider + .parameterProvider() + .redemptionFee(); + uint256 fees_ = amountRedeemed_ + .mul(targetLeverage_) + .mul(feePercent_) + .div(1e18 - targetLeverage_.mul(feePercent_)); + assertApproxEqRel(baseAsset.balanceOf(address(staker)), fees_, 0.01e18); + } + + function testRedirectsFeesToReferrals() public { + _setZapReferral(); + assertEq(baseAsset.balanceOf(address(staker)), 0); + + address referrer = makeAddr("referrer"); + address gov = addressProvider.owner(); + IReferrals referrals_ = addressProvider.referrals(); + vm.prank(gov); + referrals_.register(referrer, "REF"); + referrals_.setReferral("REF"); + + uint256 amountRedeemed_ = simpleRedeem(Tokens.DAI, 1000e18, 1000e18); + uint256 targetLeverage_ = leveragedToken.targetLeverage(); + uint256 feePercent_ = addressProvider + .parameterProvider() + .redemptionFee(); + uint256 fees_ = amountRedeemed_ + .mul(targetLeverage_) + .mul(feePercent_) + .div(1e18 - targetLeverage_.mul(feePercent_)); + + uint256 rebatePercent_ = referrals_.rebatePercent(); + uint256 referralPercent_ = referrals_.referralPercent(); + + assertApproxEqAbs( + baseAsset.balanceOf(address(staker)), + fees_.mul(1e18 - rebatePercent_ - referralPercent_), + 5 + ); // only non-zero in case of rounding error, so typically 1 (actually 1, not 1e18) + + assertApproxEqRel( + baseAsset.balanceOf(address(referrals_)), + fees_.mul(rebatePercent_ + referralPercent_), + 0.01e18 + ); + assertApproxEqRel( + referrals_.earned(referrer), + fees_.mul(referralPercent_), + 0.01e18 + ); + assertApproxEqRel( + referrals_.earned(address(this)), + fees_.mul(rebatePercent_), + 0.01e18 + ); + } + + function testRedirectsFeesToReferralsWithPartialFees() public { + _runRedirectsFeesToReferralsWithPartialFees(0.35e18, 0.25e18); + } + + function testRedirectsFeesToReferralsWithPartialFeesFuzz( + uint256 referralPercent_, + uint256 rebatePercent_ + ) public { + referralPercent_ %= 1e18; + if (referralPercent_ > 0 && referralPercent_ < 0.01e18) { + referralPercent_ = 0.01e18; + } + rebatePercent_ %= 1e18; + if (rebatePercent_ > 0 && rebatePercent_ < 0.01e18) { + rebatePercent_ = 0.01e18; + } + if (referralPercent_ + rebatePercent_ > 1e18) { + rebatePercent_ = 1e18 - referralPercent_; + } + + _runRedirectsFeesToReferralsWithPartialFees( + referralPercent_, + rebatePercent_ + ); + } + + function _runRedirectsFeesToReferralsWithPartialFees( + uint256 referralPercent_, + uint256 rebatePercent_ + ) internal { + _setZapReferral(); + assertEq(baseAsset.balanceOf(address(staker)), 0); + + address referrer = makeAddr("referrer"); + IReferrals referrals_ = addressProvider.referrals(); + referrals_.register(referrer, "REF"); + referrals_.setReferral("REF"); + + referrals_.setReferralPercent(0); + if (rebatePercent_ != referrals_.rebatePercent()) { + referrals_.setRebatePercent(rebatePercent_); + } + if (referralPercent_ > 0) { + referrals_.setReferralPercent(referralPercent_); + } + + uint256 amountRedeemed_ = simpleRedeem(Tokens.DAI, 1000e18, 1000e18); + uint256 targetLeverage_ = leveragedToken.targetLeverage(); + uint256 feePercent_ = addressProvider + .parameterProvider() + .redemptionFee(); + uint256 fees_ = amountRedeemed_ + .mul(targetLeverage_) + .mul(feePercent_) + .div(1e18 - targetLeverage_.mul(feePercent_)); + + assertApproxEqAbs( + baseAsset.balanceOf(address(staker)), + fees_.mul(1e18 - rebatePercent_ - referralPercent_), + 0.1e18, + "staker balance" + ); + + assertApproxEqAbs( + baseAsset.balanceOf(address(referrals_)), + fees_.mul(rebatePercent_ + referralPercent_), + 0.1e18, + "referrals balance" + ); + assertApproxEqAbs( + referrals_.earned(referrer), + fees_.mul(referralPercent_), + 0.1e18, + "referrer balance" + ); + assertApproxEqAbs( + referrals_.earned(address(this)), + fees_.mul(rebatePercent_), + 0.1e18, + "referee balance" + ); + } + // Helper functions + function _setZapReferral() public { + IReferrals referrals_ = addressProvider.referrals(); + + vm.expectRevert(IReferrals.InvalidCode.selector); + wrappedZapSwap.setReferral(); + + address gov = addressProvider.owner(); + vm.prank(gov); + referrals_.register(address(zapSwap), "ZAP"); + + zapSwap.setReferral(); + + assertEq(referrals_.referral(address(zapSwap)), "ZAP"); + } + function setSwapDataForAllZapAssets(ZapSwap zapSwap_) public { // Set swap routes from ZapAssetRoutes library (