diff --git a/contracts/gelato/CapInterestHarvester.sol b/contracts/gelato/CapInterestHarvester.sol index 5e86dd01..a1098628 100644 --- a/contracts/gelato/CapInterestHarvester.sol +++ b/contracts/gelato/CapInterestHarvester.sol @@ -11,6 +11,7 @@ import { ILender } from "../interfaces/ILender.sol"; import { IMinter } from "../interfaces/IMinter.sol"; import { IVault } from "../interfaces/IVault.sol"; import { CapInterestHarvesterStorageUtils } from "../storage/CapInterestHarvesterStorageUtils.sol"; +import { FlashLoanTransientState } from "./FlashLoanTansientState.sol"; import { IBalancerVault } from "./interfaces/IBalancerVault.sol"; import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import { IERC20, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -18,7 +19,13 @@ import { IERC20, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/Saf /// @title Cap Interest Harvester /// @author weso, Cap Labs /// @notice Harvests interest from borrow and the fractional reserve, sends to fee auction, buys interest, calls distribute on fee receiver -contract CapInterestHarvester is ICapInterestHarvester, UUPSUpgradeable, Access, CapInterestHarvesterStorageUtils { +contract CapInterestHarvester is + ICapInterestHarvester, + UUPSUpgradeable, + Access, + CapInterestHarvesterStorageUtils, + FlashLoanTransientState +{ using SafeERC20 for IERC20; error InvalidFlashLoan(); @@ -73,8 +80,6 @@ contract CapInterestHarvester is ICapInterestHarvester, UUPSUpgradeable, Access, /// 4. Call distribute on fee receiver _distributeInterest($.feeReceiver); - $.lastharvest = block.timestamp; - emit HarvestedInterest(block.timestamp); } @@ -96,10 +101,7 @@ contract CapInterestHarvester is ICapInterestHarvester, UUPSUpgradeable, Access, /// @dev Flashloan buy all the interest /// @param _asset Asset address - function _flashloanBuyInterest(address _balancerVault, address _cusd, address _feeAuction, address _asset) - private - { - CapInterestHarvesterStorage storage $ = getCapInterestHarvesterStorage(); + function _flashloanBuyInterest(address _balancerVault, address _cusd, address _feeAuction, address _asset) private { uint256 assetBalOfFeeAuction = IERC20(_asset).balanceOf(_feeAuction); uint256 price = IFeeAuction(_feeAuction).currentPrice(); (uint256 cusdAmountFromMint,) = IMinter(_cusd).getMintAmount(_asset, assetBalOfFeeAuction); @@ -111,10 +113,9 @@ contract CapInterestHarvester is ICapInterestHarvester, UUPSUpgradeable, Access, uint256[] memory amounts = new uint256[](1); amounts[0] = assetBalOfFeeAuction; - IBalancerVault balancerVault = IBalancerVault(_balancerVault); - $.flashInProgress = true; + setFlashLoanInProgress(true); - balancerVault.flashLoan(address(this), assets, amounts, ""); + IBalancerVault(_balancerVault).flashLoan(address(this), assets, amounts, ""); } } @@ -132,7 +133,7 @@ contract CapInterestHarvester is ICapInterestHarvester, UUPSUpgradeable, Access, checkAccess(this.receiveFlashLoan.selector) { CapInterestHarvesterStorage storage $ = getCapInterestHarvesterStorage(); - if (!$.flashInProgress) revert InvalidFlashLoan(); + if (!isFlashLoanInProgress()) revert InvalidFlashLoan(); _checkApproval($.asset, $.cusd); _checkApproval($.cusd, $.feeAuction); @@ -157,7 +158,6 @@ contract CapInterestHarvester is ICapInterestHarvester, UUPSUpgradeable, Access, IERC20($.asset).safeTransfer($.balancerVault, amounts[0] + feeAmounts[0]); uint256 excessAmount = IERC20($.asset).balanceOf(address(this)); if (excessAmount > 0) IERC20($.asset).safeTransfer($.excessReceiver, excessAmount); - $.flashInProgress = false; } /// @dev Check approval @@ -170,11 +170,6 @@ contract CapInterestHarvester is ICapInterestHarvester, UUPSUpgradeable, Access, } } - /// @inheritdoc ICapInterestHarvester - function lastHarvest() public view returns (uint256) { - return getCapInterestHarvesterStorage().lastharvest; - } - /// @inheritdoc ICapInterestHarvester function setExcessReceiver(address _excessReceiver) external checkAccess(this.setExcessReceiver.selector) { CapInterestHarvesterStorage storage $ = getCapInterestHarvesterStorage(); @@ -184,23 +179,45 @@ contract CapInterestHarvester is ICapInterestHarvester, UUPSUpgradeable, Access, } /// @inheritdoc ICapInterestHarvester - function checker() external view returns (bool canExec, bytes memory execPayload) { + function expectedProfit(uint256 transactionCost) external view returns (int256) { CapInterestHarvesterStorage storage $ = getCapInterestHarvesterStorage(); - // Just harvest if its been 24 hours since last harvest - if (block.timestamp - $.lastharvest > 24 hours) { - return (true, abi.encodeCall(this.harvestInterest, ())); - } - uint256 assetBalOfFeeAuction = IERC20($.asset).balanceOf($.feeAuction); + uint256 amountIn = transactionCost > assetBalOfFeeAuction ? 0 : assetBalOfFeeAuction - transactionCost; uint256 price = IFeeAuction($.feeAuction).currentPrice(); - (uint256 cusdAmountFromMint,) = IMinter($.cusd).getMintAmount($.asset, assetBalOfFeeAuction); + (uint256 cusdAmountFromMint,) = IMinter($.cusd).getMintAmount($.asset, amountIn); + + return int256(cusdAmountFromMint) - int256(price); + } - canExec = cusdAmountFromMint > price; + /// @inheritdoc ICapInterestHarvester + function nextProfitable(uint256 secondsPerBlock, uint256 transactionCost) external view returns (uint256, uint256) { + CapInterestHarvesterStorage storage $ = getCapInterestHarvesterStorage(); - if (!canExec) return (canExec, bytes("Not enough cUSD to mint")); + uint256 nextProfitableTimestamp; + { + uint256 assetBalOfFeeAuction = IERC20($.asset).balanceOf($.feeAuction); + uint256 amountIn = transactionCost > assetBalOfFeeAuction ? 0 : assetBalOfFeeAuction - transactionCost; + (uint256 cusdAmountFromMint,) = IMinter($.cusd).getMintAmount($.asset, amountIn); + + uint256 startPrice = IFeeAuction($.feeAuction).startPrice(); + uint256 startTimestamp = IFeeAuction($.feeAuction).startTimestamp(); + uint256 duration = IFeeAuction($.feeAuction).duration(); + + // Profitable when price < cusdAmountFromMint. Solve for elapsed. + // price(elapsed) = startPrice * (1e27 - (elapsed * 0.9e27 / duration)) / 1e27 < cusdAmountFromMint + // => elapsed > (1e27 - cusdAmountFromMint * 1e27 / startPrice) * duration / 0.9e27 + // Price floors at 0.1 * startPrice after duration; if cusdAmountFromMint < that, never profitable. + uint256 term = 1e27 - (cusdAmountFromMint * 1e27 / startPrice); + uint256 elapsedNeeded = (term * duration + 0.9e27 - 1) / 0.9e27; // ceiling + if (elapsedNeeded >= duration) return (0, type(uint256).max); + + nextProfitableTimestamp = startTimestamp + elapsedNeeded; + if (nextProfitableTimestamp <= block.timestamp) return (block.number, block.timestamp); // already profitable + } - execPayload = abi.encodeCall(this.harvestInterest, ()); + uint256 blockOffset = (nextProfitableTimestamp - block.timestamp + secondsPerBlock - 1) / secondsPerBlock; + return (uint256(block.number) + blockOffset, nextProfitableTimestamp); } /// @inheritdoc UUPSUpgradeable diff --git a/contracts/gelato/FlashLoanTansientState.sol b/contracts/gelato/FlashLoanTansientState.sol new file mode 100644 index 00000000..a29e96c8 --- /dev/null +++ b/contracts/gelato/FlashLoanTansientState.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { TransientSlot } from "@openzeppelin/contracts/utils/TransientSlot.sol"; + +/// @title Flash Loan Transient State +/// @author weso, Cap Labs +/// @notice Transient state for flash loan +contract FlashLoanTransientState { + using TransientSlot for *; + + /// @dev keccak256(abi.encode(uint256(keccak256("cap.storage.FlashLoanTransientState")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 internal constant FLASH_LOAN_TRANSIENT_STATE_SLOT = + 0x080a6eb1727523b7cca4b7cf7a3debc9ba08c2ed83fc97b4f9cb68f53138c400; + + function setFlashLoanInProgress(bool _flashLoanInProgress) internal { + FLASH_LOAN_TRANSIENT_STATE_SLOT.asBoolean().tstore(_flashLoanInProgress); + } + + function isFlashLoanInProgress() internal view returns (bool) { + return FLASH_LOAN_TRANSIENT_STATE_SLOT.asBoolean().tload(); + } +} diff --git a/contracts/interfaces/ICapInterestHarvester.sol b/contracts/interfaces/ICapInterestHarvester.sol index 82bae234..79811361 100644 --- a/contracts/interfaces/ICapInterestHarvester.sol +++ b/contracts/interfaces/ICapInterestHarvester.sol @@ -14,8 +14,6 @@ interface ICapInterestHarvester { address lender; address balancerVault; address excessReceiver; - uint256 lastharvest; - bool flashInProgress; } /// @notice Initialize the CapInterestHarvester contract @@ -59,11 +57,17 @@ interface ICapInterestHarvester { bytes memory _userData ) external; - /// @notice Last harvest timestamp - function lastHarvest() external view returns (uint256); + /// @notice Expected profit from harvesting (CUSD from minting fee-asset minus auction price). + /// @param transactionCost Cost in asset terms deducted from the fee auction balance before mint + /// @return expectedHarvestProfit Profit in CUSD terms (can be negative) + function expectedProfit(uint256 transactionCost) external view returns (int256 expectedHarvestProfit); - /// @notice Gelato checker function - /// @return canExec Whether the task can be executed - /// @return execPayload The payload to execute - function checker() external view returns (bool canExec, bytes memory execPayload); + /// @notice First block at which harvesting is profitable, or sentinel values. + /// @param secondsPerBlock Estimated seconds per block for block-offset calculation + /// @param transactionCost Cost in asset terms deducted from the fee auction balance before mint + /// @return nextProfitableBlock Block number when profitable; -1 if already profitable; 0 if now or never profitable + function nextProfitable(uint256 secondsPerBlock, uint256 transactionCost) + external + view + returns (uint256 nextProfitableBlock, uint256 nextProfitableTimestamp); } diff --git a/test/noTest/GelatoInterestHarvester.t.sol b/test/noTest/GelatoInterestHarvester.t.sol index 6925c39b..d2228cdd 100644 --- a/test/noTest/GelatoInterestHarvester.t.sol +++ b/test/noTest/GelatoInterestHarvester.t.sol @@ -45,18 +45,17 @@ contract HarvesterTest is Test { vm.stopPrank(); vm.prank(admin); - AccessControl(accessControl).grantAccess( - ICapInterestHarvester.receiveFlashLoan.selector, address(proxy), balancerVault - ); + AccessControl(accessControl) + .grantAccess(ICapInterestHarvester.receiveFlashLoan.selector, address(proxy), balancerVault); vm.stopPrank(); } function test_gelatoHarvest() public { - (bool canExec,) = CapInterestHarvester(address(proxy)).checker(); - console.log("canExec", canExec); + int256 expectedProfit = ICapInterestHarvester(address(proxy)).expectedProfit(1); + console.log("expectedProfit", int256(expectedProfit)); vm.prank(gelato); - if (canExec) ICapInterestHarvester(address(proxy)).harvestInterest(); + if (expectedProfit > 0) ICapInterestHarvester(address(proxy)).harvestInterest(); vm.stopPrank(); } }