diff --git a/.github/workflows/olympix.yml b/.github/workflows/olympix.yml new file mode 100644 index 0000000..3edd943 --- /dev/null +++ b/.github/workflows/olympix.yml @@ -0,0 +1,13 @@ +name: Integrated Security Workflow +on: push +jobs: + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Olympix Integrated Security + uses: olympix/integrated-security@main + env: + OLYMPIX_API_TOKEN: ${{ secrets.OLYMPIX_API_TOKEN }} + with: + args: -f json --no-uninitialized-state-variable --no-default-visibility --no-missing-revert-reason-tests diff --git a/.gitmodules b/.gitmodules index 0034708..115e872 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,27 @@ [submodule "lib/chimera"] path = lib/chimera url = https://github.com/recon-fuzz/chimera +[submodule "lib/v4-core"] + path = lib/v4-core + url = https://github.com/uniswap/v4-core +[submodule "lib/v4-periphery"] + path = lib/v4-periphery + url = https://github.com/uniswap/v4-periphery +[submodule "lib/permit2"] + path = lib/permit2 + url = https://github.com/uniswap/permit2 +[submodule "lib/universal-router"] + path = lib/universal-router + url = https://github.com/uniswap/universal-router +[submodule "lib/v3-core"] + path = lib/v3-core + url = https://github.com/uniswap/v3-core +[submodule "lib/v2-core"] + path = lib/v2-core + url = https://github.com/uniswap/v2-core +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/morpho-blue"] + path = lib/morpho-blue + url = https://github.com/morpho-org/morpho-blue diff --git a/.olympix-ignore.json b/.olympix-ignore.json new file mode 100644 index 0000000..d402e7a --- /dev/null +++ b/.olympix-ignore.json @@ -0,0 +1,18 @@ +{ + "IgnoredVulnerabilities" : { + "missing-revert-reason-tests" : { + "contracts" : [] + }, + "reentrancy" : { + "contracts/tranches/Accounting.sol" : [330, 393], + "contracts/tranches/StrataCDO.sol" : [392], + "contracts/tranches/base/cooldown/UnstakeCooldown.sol": [113] + } + }, + "IgnoredPaths": [ + "contracts/test", + "contracts/oz", + "contracts/lens", + "test/PoC" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 36da3eb..2c263f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,76 +1,78 @@ { - "recon.foundryConfigPath": "foundry.toml", - "recon.defaultFuzzer": "medusa", - "security.olympix.detectors.arbitraryAddressSpoofingAttack": true, - "security.olympix.detectors.arbitraryTransferFrom": true, - "security.olympix.detectors.noAccessControlPayableFallback": true, - "security.olympix.detectors.ownerSinglePointOfFailure": true, - "security.olympix.detectors.anyTxOrigin": true, - "security.olympix.detectors.faultyDiv": true, - "security.olympix.detectors.enumConversionOutOfRange": true, - "security.olympix.detectors.uintToIntConversion": true, - "security.olympix.detectors.downcastOfNumberToAddress": true, - "security.olympix.detectors.unsafeDowncast": true, - "security.olympix.detectors.swappedShiftParams": true, - "security.olympix.detectors.unaryPlusExpression": true, - "security.olympix.detectors.uncheckedBlockWithSubtraction": true, - "security.olympix.detectors.assemblyReturnInsteadOfLeave": true, - "security.olympix.detectors.callsAssemblyReturn": true, - "security.olympix.detectors.delegateCallInLoop": true, - "security.olympix.detectors.emptyPayableFallback": true, - "security.olympix.detectors.lowLevelCallParamsVerified": true, - "security.olympix.detectors.improperDiamondPattern": true, - "security.olympix.detectors.missingGapVariable": true, - "security.olympix.detectors.unenforcedStateMaintenanceKeywords": true, - "security.olympix.detectors.unboundedPragma": true, - "security.olympix.detectors.directionalOverrideCharacter": true, - "security.olympix.detectors.abiEncodePackedDynamicTypes": true, - "security.olympix.detectors.callsInLoop": true, - "security.olympix.detectors.callWithoutGasBudget": true, - "security.olympix.detectors.msgValueReuse": true, - "security.olympix.detectors.abiEncoderArray": true, - "security.olympix.detectors.blockRandomness": true, - "security.olympix.project.includePath": "/contracts", - "security.olympix.project.testsPath": "/test", - "security.olympix.project.opixTestDir": "/test/auto", - "security.olympix.detectors.arbitraryDelegatecall": true, - "security.olympix.detectors.increasingLengthArrayAsLoopVariable": true, - "security.olympix.detectors.noParameterValidationInConstructor": true, - "security.olympix.detectors.reentrancy": true, - "security.olympix.detectors.reentrancyEvents": true, - "security.olympix.detectors.uncheckedLowLevel": true, - "security.olympix.detectors.uncheckedSend": true, - "security.olympix.detectors.uncheckedTokenTransfer": true, - "security.olympix.detectors.unsafeSelfDestruct": true, - "security.olympix.detectors.unusedReturnFunctionCall": true, - "security.olympix.detectors.signatureReplayAttacks": true, - "security.olympix.detectors.arrayLengthAssignment": true, - "security.olympix.detectors.arrayParameterLocation": true, - "security.olympix.detectors.nestedStructInMapping": true, - "security.olympix.detectors.insufficientParameterAssertion": true, - "security.olympix.detectors.uninitializedFunctionPointerConstructor": true, - "security.olympix.detectors.uninitializedLocalStorage": true, - "security.olympix.detectors.uninitializedStateVariable": true, - "security.olympix.detectors.structWithMappingDeletion": true, - "security.olympix.detectors.signedIntegerArray": true, - "security.olympix.detectors.erc20Interface": true, - "security.olympix.detectors.erc721Interface": true, - "security.olympix.detectors.functionSelectorClash": true, - "security.olympix.detectors.defaultVisibility": true, - "security.olympix.detectors.missingRevertReasonTests": true, - "security.olympix.detectors.multipleConstructors": true, - "security.olympix.detectors.sameNamedContracts": true, - "security.olympix.detectors.shadowingBuiltin": true, - "security.olympix.detectors.shadowingReservedKeyword": true, - "security.olympix.detectors.shadowingState": true, - "security.olympix.detectors.arbitrarySendEther": true, - "security.olympix.detectors.etherBalanceCheckStrictEquality": true, - "security.olympix.detectors.eventsPriceChange": true, - "security.olympix.detectors.externalCallPotentialOutOfGas": true, - "security.olympix.detectors.expectsOptionalErc20Functionality": true, - "security.olympix.detectors.lockedEther": true, - "security.olympix.detectors.possibleDivisionByZero": true, - "security.olympix.detectors.zeroAsParameter": true, - "security.olympix.detectors.oracleManipulation": true, - "security.olympix.project.useAiToPruneFindings": true + // "files.exclude": { + // "**/lib": true + // }, + "security.olympix.detectors.arbitraryAddressSpoofingAttack": true, + "security.olympix.detectors.arbitraryTransferFrom": true, + "security.olympix.detectors.noAccessControlPayableFallback": true, + "security.olympix.detectors.ownerSinglePointOfFailure": true, + "security.olympix.detectors.anyTxOrigin": true, + "security.olympix.detectors.faultyDiv": true, + "security.olympix.detectors.enumConversionOutOfRange": true, + "security.olympix.detectors.uintToIntConversion": true, + "security.olympix.detectors.downcastOfNumberToAddress": true, + "security.olympix.detectors.unsafeDowncast": true, + "security.olympix.detectors.swappedShiftParams": true, + "security.olympix.detectors.unaryPlusExpression": true, + "security.olympix.detectors.uncheckedBlockWithSubtraction": true, + "security.olympix.detectors.assemblyReturnInsteadOfLeave": true, + "security.olympix.detectors.callsAssemblyReturn": true, + "security.olympix.detectors.delegateCallInLoop": true, + "security.olympix.detectors.emptyPayableFallback": true, + "security.olympix.detectors.lowLevelCallParamsVerified": true, + "security.olympix.detectors.improperDiamondPattern": true, + "security.olympix.detectors.missingGapVariable": true, + "security.olympix.detectors.unenforcedStateMaintenanceKeywords": true, + "security.olympix.detectors.unboundedPragma": true, + "security.olympix.detectors.directionalOverrideCharacter": true, + "security.olympix.detectors.abiEncodePackedDynamicTypes": true, + "security.olympix.detectors.callsInLoop": true, + "security.olympix.detectors.callWithoutGasBudget": true, + "security.olympix.detectors.msgValueReuse": true, + "security.olympix.detectors.abiEncoderArray": true, + "security.olympix.detectors.blockRandomness": true, + "security.olympix.project.includePath": "/contracts", + "security.olympix.project.testsPath": "/test", + "security.olympix.project.opixTestDir": "/test/auto", + "security.olympix.detectors.arbitraryDelegatecall": true, + "security.olympix.detectors.increasingLengthArrayAsLoopVariable": true, + "security.olympix.detectors.noParameterValidationInConstructor": true, + "security.olympix.detectors.reentrancy": true, + "security.olympix.detectors.reentrancyEvents": true, + "security.olympix.detectors.uncheckedLowLevel": true, + "security.olympix.detectors.uncheckedSend": true, + "security.olympix.detectors.uncheckedTokenTransfer": true, + "security.olympix.detectors.unsafeSelfDestruct": true, + "security.olympix.detectors.unusedReturnFunctionCall": true, + "security.olympix.detectors.signatureReplayAttacks": true, + "security.olympix.detectors.arrayLengthAssignment": true, + "security.olympix.detectors.arrayParameterLocation": true, + "security.olympix.detectors.nestedStructInMapping": true, + "security.olympix.detectors.insufficientParameterAssertion": true, + "security.olympix.detectors.uninitializedFunctionPointerConstructor": true, + "security.olympix.detectors.uninitializedLocalStorage": true, + "security.olympix.detectors.uninitializedStateVariable": true, + "security.olympix.detectors.structWithMappingDeletion": true, + "security.olympix.detectors.signedIntegerArray": true, + "security.olympix.detectors.erc20Interface": true, + "security.olympix.detectors.erc721Interface": true, + "security.olympix.detectors.functionSelectorClash": true, + "security.olympix.detectors.defaultVisibility": true, + "security.olympix.detectors.missingRevertReasonTests": true, + "security.olympix.detectors.multipleConstructors": true, + "security.olympix.detectors.sameNamedContracts": true, + "security.olympix.detectors.shadowingBuiltin": true, + "security.olympix.detectors.shadowingReservedKeyword": true, + "security.olympix.detectors.shadowingState": true, + "security.olympix.detectors.arbitrarySendEther": true, + "security.olympix.detectors.etherBalanceCheckStrictEquality": true, + "security.olympix.detectors.eventsPriceChange": true, + "security.olympix.detectors.externalCallPotentialOutOfGas": true, + "security.olympix.detectors.expectsOptionalErc20Functionality": true, + "security.olympix.detectors.lockedEther": true, + "security.olympix.detectors.possibleDivisionByZero": true, + "security.olympix.detectors.zeroAsParameter": true, + "security.olympix.detectors.oracleManipulation": true, + "security.olympix.project.useAiToPruneFindings": true, + "security.olympix.detectors.missingEventsAssertion": false } diff --git a/contracts/tranches/Accounting.sol b/contracts/tranches/Accounting.sol index 0beba29..59881fe 100644 --- a/contracts/tranches/Accounting.sol +++ b/contracts/tranches/Accounting.sol @@ -166,6 +166,15 @@ contract Accounting is IAccounting, CDOComponent { } function maxWithdraw(bool isJrt) external view returns (uint256) { + return maxWithdrawInner(isJrt, false); + } + function maxWithdraw(bool isJrt, bool ownerIsSharesCooldown) external view returns (uint256) { + return maxWithdrawInner(isJrt, ownerIsSharesCooldown); + } + function maxWithdrawInner(bool isJrt, bool ownerIsSharesCooldown) internal view returns (uint256) { + if (ownerIsSharesCooldown) { + return isJrt ? jrtNav : srtNav; + } if (isJrt) { uint256 minJrt = srtNav * minimumJrtSrtRatio / 1e18; return Math.saturatingSub(jrtNav, minJrt); diff --git a/contracts/tranches/StrataCDO.sol b/contracts/tranches/StrataCDO.sol index 1d593de..74e5608 100644 --- a/contracts/tranches/StrataCDO.sol +++ b/contracts/tranches/StrataCDO.sol @@ -17,6 +17,8 @@ import { IStrategy } from "./interfaces/IStrategy.sol"; import { IStrataCDO, IStrataCDOSetters } from "./interfaces/IStrataCDO.sol"; import { TActionState } from "./structs/TActionState.sol"; import { IAccounting } from "./interfaces/IAccounting.sol"; +import { ISharesCooldown } from "./interfaces/cooldown/ISharesCooldown.sol"; + /// @notice Core CDO contract that orchestrates Tranches, Accounting, and Strategy /// @dev Manages deposits, withdrawals, and asset distribution between tranches @@ -59,6 +61,8 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { /// @dev Withdrawal fees for the Senior Tranche uint256 public exitFeeSrt; + ISharesCooldown public sharesCooldown; + event DepositsStateChanged(address indexed tranche, bool enabled); event WithdrawalsStateChanged(address indexed tranche, bool enabled); event ReserveReduced(address token, uint256 amount); @@ -67,6 +71,7 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { event ShortfallPaused(); event JrtShortfallPausePriceSet(uint256 pricePerShare); event ExitFeesSet(uint256 jrt, uint256 srt); + event SharesCooldownSet(address sharesCooldown); /// @notice Restricts function access to only the junior (JRT) or senior (SRT) tranche contracts @@ -126,27 +131,49 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { return accounting.maxDeposit(isJrt_); } function maxWithdraw(address tranche) external view returns (uint256) { + return maxWithdraw(tranche, address(0)); + } + + function maxWithdraw(address tranche, address owner) public view returns (uint256) { bool isJrt_ = isJrt(tranche); bool isWithdrawEnabled = isJrt_ ? actionsJrt.isWithdrawEnabled : actionsSrt.isWithdrawEnabled; if (isWithdrawEnabled == false) { return 0; } - return accounting.maxWithdraw(isJrt_); + bool ownerIsSharesCooldown = owner != address(0) && owner == address(sharesCooldown); + return accounting.maxWithdraw(isJrt_, ownerIsSharesCooldown); } - /// @notice Calculates the exit fee for a withdrawal from a specific tranche. - /// @dev The calculation can be based on either the gross withdrawal amount (before fees) - /// or the net amount a user wishes to receive (after fees). + /// @notice Determines the exit mode and associated parameters for a withdrawal from a specific tranche. + /// @dev Checks if shares cooldown is configured and calculates exit parameters based on coverage ratio. + /// If the owner is the shares cooldown contract, returns ERC4626 mode with no fees. + /// Otherwise, returns either SharesLock mode (if cooldown required) or Fee mode with applicable fees. /// @param tranche The address of the tranche (junior or senior). - /// @param amount The amount to calculate the fee on. - /// @param isGross If true, `amount` is the gross withdrawal amount. - /// If false, `amount` is the net amount to be received. - /// @return The calculated exit fee amount. - function calculateExitFee (address tranche, uint256 amount, bool isGross) external view returns (uint256) { - uint256 fee = isJrt(tranche) ? exitFeeJrt : exitFeeSrt; - return isGross - ? Math.mulDiv(amount, fee, 1e18, Math.Rounding.Floor) - : Math.mulDiv(amount, fee, 1e18 - fee, Math.Rounding.Floor); + /// @param owner The shares owner. No fee or cooldown is applied when the shares cooldown contract redeems. + /// @return mode The exit mode (ERC4626, SharesLock, or Fee). + /// @return fee The exit fee in 18 decimals (0 if no fee applies). + /// @return cooldownSeconds The cooldown period in seconds (0 if no cooldown applies). + function calculateExitMode (address tranche, address owner) external view returns (TExitMode mode, uint256 fee, uint32 cooldownSeconds) { + if (address(sharesCooldown) != address(0)) { + if (owner == address(sharesCooldown)) { + return (TExitMode.ERC4626, 0, 0); + } + uint32 cov = coverage(); + ISharesCooldown.TExitParams memory exit = sharesCooldown.calculateExitParams(tranche, cov); + if (exit.feePpm > 0) { + // Convert to 18 decimals + fee = uint256(exit.feePpm) * 1e18 / 1e6; + } + if (exit.sharesLock > 0) { + return (TExitMode.SharesLock, fee, exit.sharesLock); + } + } + if (fee == 0) { + // default + bool isJrt_ = isJrt(tranche); + fee = isJrt_ ? exitFeeJrt : exitFeeSrt; + } + return (TExitMode.Fee, fee, 0); } /// @notice On behalf of a tranche, moves accrued fees from the tranche's TVL to the reserve. @@ -156,6 +183,33 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { accounting.accrueFee(isJrt(tranche), assets); } + /// @notice Returns tranche NAVs excluding assets locked in the shares cooldown (silo). + /// @dev Reads accounting totals and subtracts locked assets to compute available TVLs and coverage. + function totalAssetsUnlocked() public view returns (uint256 jrtNav, uint256 srtNav) { + (jrtNav, srtNav, ) = accounting.totalAssetsT0(); + + uint256 jrtNavLocked = jrtVault.convertToAssets(jrtVault.balanceOf(address(sharesCooldown))); + uint256 srtNavLocked = srtVault.convertToAssets(srtVault.balanceOf(address(sharesCooldown))); + + jrtNav = jrtNav > jrtNavLocked ? jrtNav - jrtNavLocked : 0; + srtNav = srtNav > srtNavLocked ? srtNav - srtNavLocked : 0; + return (jrtNav, srtNav); + } + + /// @notice Returns the coverage ratio using available TVLs (assets not locked in the silo). + /// @dev Uses totals from totalAssetsUnlocked() so locked assets do not affect coverage. + function coverage () public view returns (uint32) { + (uint256 jrtNav, uint256 srtNav) = totalAssetsUnlocked(); + if (srtNav == 0) { + return type(uint32).max; + } + uint256 coverage_ = jrtNav * 1e6 / srtNav; + return coverage_ > type(uint32).max ? type(uint32).max : uint32(coverage_); + } + + + /// @notice Refreshes accounting state before tranche deposit or redemption flows. + /// @dev Called by tranche contracts to sync balances with current strategy TVL. function updateAccounting () external onlyTranche { uint256 totalAssetsOverall = strategy.totalAssets(); accounting.updateAccounting(totalAssetsOverall); @@ -181,24 +235,50 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { } function withdraw(address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver) external onlyTranche nonReentrant { + if (tokenAmount == 0 || baseAssets == 0) { + revert ZeroAmount(); + } bool isJrt_ = isJrt(tranche); bool enabled = isJrt_ ? actionsJrt.isWithdrawEnabled : actionsSrt.isWithdrawEnabled; if (!enabled) { revert WithdrawalsDisabled(tranche); } - if (baseAssets > accounting.maxWithdraw(isJrt_)) { + bool isSharesLockup = sender == address(sharesCooldown); + if (baseAssets > accounting.maxWithdraw(isJrt_, isSharesLockup)) { revert WithdrawalCapReached(tranche); } - if (tokenAmount == 0 || baseAssets == 0) { - revert ZeroAmount(); - } - strategy.withdraw(tranche, token, tokenAmount, baseAssets, sender, receiver); + // When the sender is the shares lockup contract, we should skip any cooldown on our side, + // unless the underlying protocol has some cooldown/unstake process. + bool shouldSkipCooldown = isSharesLockup == true; + strategy.withdraw(tranche, token, tokenAmount, baseAssets, sender, receiver, shouldSkipCooldown); uint256 jrtAssetsOut = isJrt_ ? baseAssets : 0; uint256 srtAssetsOut = isJrt_ ? 0 : baseAssets; accounting.updateBalanceFlow(0, jrtAssetsOut, 0, srtAssetsOut); shortfallPauser(); } + /// @notice Initiates a cooldown period for share redemption by transferring shares to the cooldown contract. + /// @dev Validates withdrawal permissions and delegates to the sharesCooldown contract to handle the lock-up. + /// The shares are held in escrow during the cooldown period before they can be redeemed for assets. + /// The caller MUST transfer the required shares to the shares cooldown contract before calling this function. + /// @param tranche The address of the tranche (junior or senior). + /// @param shares The amount of shares to lock for cooldown. + /// @param sender The address initiating the cooldown (original share owner). + /// @param receiver The address that will receive the assets after cooldown completes. + /// @param fee The exit fee to be applied when redeeming (in 18 decimals). + /// @param cooldownSeconds The duration of the cooldown period in seconds. + function cooldownShares(address tranche, uint256 shares, address sender, address receiver, uint256 fee, uint32 cooldownSeconds) external onlyTranche nonReentrant { + if (shares == 0) { + revert ZeroAmount(); + } + bool isJrt_ = isJrt(tranche); + bool enabled = isJrt_ ? actionsJrt.isWithdrawEnabled : actionsSrt.isWithdrawEnabled; + if (!enabled) { + revert WithdrawalsDisabled(tranche); + } + sharesCooldown.requestRedeem(ITranche(tranche), sender, receiver, shares, fee, cooldownSeconds); + } + /// @notice Determines if the given address is the Junior (BB) Tranche /// @dev Used to differentiate between Junior and Senior Tranches /// @param tranche The address to check @@ -309,6 +389,13 @@ contract StrataCDO is IErrors, IStrataCDO, IStrataCDOSetters, AccessControlled { emit JrtShortfallPausePriceSet(jrtShortfallPausePrice_); } + /// @notice Sets the shares cooldown contract address + /// @param sharesCooldown_ The new shares cooldown contract address + function setSharesCooldown (ISharesCooldown sharesCooldown_) external onlyOwner { + sharesCooldown = sharesCooldown_; + emit SharesCooldownSet(address(sharesCooldown_)); + } + function shortfallPauser () internal { (uint256 jrtNav,,) = accounting.totalAssetsT0(); uint256 jrtPrice = calculatePricePerShare(jrtNav, jrtVault.totalSupply()); diff --git a/contracts/tranches/SwapContract.sol b/contracts/tranches/SwapContract.sol new file mode 100644 index 0000000..954d171 --- /dev/null +++ b/contracts/tranches/SwapContract.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {UniversalRouter} from "@uniswap/universal-router/contracts/UniversalRouter.sol"; +import {Commands} from "@uniswap/universal-router/contracts/libraries/Commands.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IV4Router} from "@uniswap/v4-periphery/src/interfaces/IV4Router.sol"; +import {Actions} from "@uniswap/v4-periphery/src/libraries/Actions.sol"; +import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; + +/// @title SwapContract +/// @notice A contract for performing swaps on Uniswap V4 pools via Universal Router +/// @dev Based on https://docs.uniswap.org/contracts/v4/quickstart/swap +contract SwapContract { + using StateLibrary for IPoolManager; + + UniversalRouter public immutable router; + IPoolManager public immutable poolManager; + IPermit2 public immutable permit2; + + constructor(address _router, address _poolManager, address _permit2) { + router = UniversalRouter(payable(_router)); + poolManager = IPoolManager(_poolManager); + permit2 = IPermit2(_permit2); + } + + /// @notice Approve a token for use with Permit2 and the Universal Router + /// @param token The token to approve + /// @param amount The amount to approve + /// @param expiration The expiration time for the approval + function approveTokenWithPermit2(address token, uint160 amount, uint48 expiration) external { + IERC20(token).approve(address(permit2), type(uint256).max); + permit2.approve(token, address(router), amount, expiration); + } + + /// @notice Swap exact input tokens for output tokens (token0 -> token1) + /// @param key The PoolKey identifying the Uniswap V4 pool + /// @param amountIn Exact amount of input tokens to swap + /// @param minAmountOut Minimum amount of output tokens (slippage protection) + /// @param deadline Timestamp after which the transaction reverts + /// @return amountOut The amount of output tokens received + function swapExactInputSingle(PoolKey calldata key, uint128 amountIn, uint128 minAmountOut, uint256 deadline) + external + returns (uint256 amountOut) + { + // Encode the Universal Router command + bytes memory commands = abi.encodePacked(uint8(Commands.V4_SWAP)); + bytes[] memory inputs = new bytes[](1); + + // Encode V4Router actions + bytes memory actions = + abi.encodePacked(uint8(Actions.SWAP_EXACT_IN_SINGLE), uint8(Actions.SETTLE_ALL), uint8(Actions.TAKE_ALL)); + + // Prepare parameters for each action + bytes[] memory params = new bytes[](3); + params[0] = abi.encode( + IV4Router.ExactInputSingleParams({ + poolKey: key, zeroForOne: true, amountIn: amountIn, amountOutMinimum: minAmountOut, hookData: bytes("") + }) + ); + params[1] = abi.encode(key.currency0, amountIn); + params[2] = abi.encode(key.currency1, minAmountOut); + + // Combine actions and params into inputs + inputs[0] = abi.encode(actions, params); + + // Execute the swap + router.execute(commands, inputs, deadline); + + // Verify and return the output amount + amountOut = IERC20(Currency.unwrap(key.currency1)).balanceOf(address(this)); + require(amountOut >= minAmountOut, "Insufficient output amount"); + return amountOut; + } + + /// @notice Swap exact input tokens for output tokens with direction control + /// @param key The PoolKey identifying the Uniswap V4 pool + /// @param zeroForOne Direction: true = token0 -> token1, false = token1 -> token0 + /// @param amountIn Exact amount of input tokens to swap + /// @param minAmountOut Minimum amount of output tokens (slippage protection) + /// @param deadline Timestamp after which the transaction reverts + /// @param hookData Arbitrary data passed to the pool's hook + /// @return amountOut The amount of output tokens received + function swap( + PoolKey calldata key, + bool zeroForOne, + uint128 amountIn, + uint128 minAmountOut, + uint256 deadline, + bytes calldata hookData + ) external returns (uint256 amountOut) { + // Determine input and output currencies based on swap direction + Currency inputCurrency = zeroForOne ? key.currency0 : key.currency1; + Currency outputCurrency = zeroForOne ? key.currency1 : key.currency0; + + // Encode the Universal Router command + bytes memory commands = abi.encodePacked(uint8(Commands.V4_SWAP)); + bytes[] memory inputs = new bytes[](1); + + // Encode V4Router actions + bytes memory actions = + abi.encodePacked(uint8(Actions.SWAP_EXACT_IN_SINGLE), uint8(Actions.SETTLE_ALL), uint8(Actions.TAKE_ALL)); + + // Prepare parameters for each action + bytes[] memory params = new bytes[](3); + params[0] = abi.encode( + IV4Router.ExactInputSingleParams({ + poolKey: key, + zeroForOne: zeroForOne, + amountIn: amountIn, + amountOutMinimum: minAmountOut, + hookData: hookData + }) + ); + params[1] = abi.encode(inputCurrency, amountIn); + params[2] = abi.encode(outputCurrency, minAmountOut); + + // Combine actions and params into inputs + inputs[0] = abi.encode(actions, params); + + // Execute the swap + router.execute(commands, inputs, deadline); + + // Verify and return the output amount + amountOut = IERC20(Currency.unwrap(outputCurrency)).balanceOf(address(this)); + require(amountOut >= minAmountOut, "Insufficient output amount"); + return amountOut; + } + + /// @notice Swap tokens to receive an exact amount of output tokens + /// @param key The PoolKey identifying the Uniswap V4 pool + /// @param zeroForOne Direction: true = token0 -> token1, false = token1 -> token0 + /// @param amountOut Exact amount of output tokens desired + /// @param maxAmountIn Maximum amount of input tokens willing to spend (slippage protection) + /// @param deadline Timestamp after which the transaction reverts + /// @param hookData Arbitrary data passed to the pool's hook + /// @return amountIn The amount of input tokens spent + function swapExactOutput( + PoolKey calldata key, + bool zeroForOne, + uint128 amountOut, + uint128 maxAmountIn, + uint256 deadline, + bytes calldata hookData + ) external returns (uint256 amountIn) { + // Determine input and output currencies based on swap direction + Currency inputCurrency = zeroForOne ? key.currency0 : key.currency1; + Currency outputCurrency = zeroForOne ? key.currency1 : key.currency0; + + // Encode the Universal Router command + bytes memory commands = abi.encodePacked(uint8(Commands.V4_SWAP)); + bytes[] memory inputs = new bytes[](1); + + // Encode V4Router actions + bytes memory actions = + abi.encodePacked(uint8(Actions.SWAP_EXACT_OUT_SINGLE), uint8(Actions.SETTLE_ALL), uint8(Actions.TAKE_ALL)); + + // Prepare parameters for each action + bytes[] memory params = new bytes[](3); + params[0] = abi.encode( + IV4Router.ExactOutputSingleParams({ + poolKey: key, + zeroForOne: zeroForOne, + amountOut: amountOut, + amountInMaximum: maxAmountIn, + hookData: hookData + }) + ); + params[1] = abi.encode(inputCurrency, maxAmountIn); + params[2] = abi.encode(outputCurrency, amountOut); + + // Combine actions and params into inputs + inputs[0] = abi.encode(actions, params); + + // Execute the swap + router.execute(commands, inputs, deadline); + + // Calculate actual input amount spent + uint256 inputBalance = IERC20(Currency.unwrap(inputCurrency)).balanceOf(address(this)); + amountIn = maxAmountIn - inputBalance; + + return amountIn; + } + + /// @notice Swap exact input tokens for output tokens with ABI-encoded PoolKey + /// @dev This function decodes the poolKeyData and calls the swap function + /// @param poolKeyData ABI-encoded PoolKey struct + /// @param zeroForOne Direction: true = token0 -> token1, false = token1 -> token0 + /// @param amountIn Exact amount of input tokens to swap + /// @param minAmountOut Minimum amount of output tokens (slippage protection) + /// @param deadline Timestamp after which the transaction reverts + /// @param hookData Arbitrary data passed to the pool's hook + /// @return amountOut The amount of output tokens received + function swapWithEncodedKey( + bytes calldata poolKeyData, + bool zeroForOne, + uint128 amountIn, + uint128 minAmountOut, + uint256 deadline, + bytes calldata hookData + ) external returns (uint256 amountOut) { + PoolKey memory key = abi.decode(poolKeyData, (PoolKey)); + return this.swap(key, zeroForOne, amountIn, minAmountOut, deadline, hookData); + } +} diff --git a/contracts/tranches/Tranche.sol b/contracts/tranches/Tranche.sol index e0791de..43aed71 100644 --- a/contracts/tranches/Tranche.sol +++ b/contracts/tranches/Tranche.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.28; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; @@ -10,15 +11,25 @@ import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ER import { ERC20PermitUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol"; import { IStrataCDO } from "./interfaces/IStrataCDO.sol"; import { IStrategy } from "./interfaces/IStrategy.sol"; +import { ITranche } from "./interfaces/ITranche.sol"; import { CDOComponent } from "./base/CDOComponent.sol"; -contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { +contract Tranche is ITranche, CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { /// @notice Minimum non-zero shares amount to prevent donation attack uint256 private constant MIN_SHARES = 0.1 ether; event OnMetaDeposit(address indexed owner, address indexed token, uint256 tokenAssets, uint256 shares); event OnMetaWithdraw(address indexed owner, address indexed token, uint256 tokenAssets, uint256 shares); + event OnExit( + address indexed owner, + address indexed token, + uint256 tokenAssets, + uint256 shares, + IStrataCDO.TExitMode exitMode, + uint256 exitFee, + uint32 cooldownSeconds + ); function initialize( address owner_, @@ -38,11 +49,11 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { /// @return uint256 The total assets for this tranche - function totalAssets() public view override returns (uint256) { + function totalAssets() public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { return cdo.totalAssets(address(this)); } - function decimals() public view override(ERC20Upgradeable, ERC4626Upgradeable) returns (uint8) { + function decimals() public view override(ERC20Upgradeable, ERC4626Upgradeable, IERC20Metadata) returns (uint8) { return super.decimals(); } @@ -53,12 +64,12 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { */ /** @dev Extends {IERC4626-maxDeposit} to handle the paused state and the TVL ratio */ - function maxDeposit(address owner) public view override returns (uint256) { + function maxDeposit(address) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { return cdo.maxDeposit(address(this)); } /** @dev Extends {IERC4626-maxMint} to handle the paused state and the TVL ratio */ - function maxMint(address owner) public view override returns (uint256) { + function maxMint(address) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { uint256 assets = cdo.maxDeposit(address(this)); if (assets == type(uint256).max) { // No mint-cap @@ -68,29 +79,38 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { } /** @dev Extends {IERC4626-maxWithdraw} to handle the paused state and the TVL ratio */ - function maxWithdraw(address owner) public view override returns (uint256 assetsNet) { + function maxWithdraw(address owner) public view override(ERC4626Upgradeable, IERC4626) returns (uint256 assetsNet) { uint256 sharesGross = balanceOf(owner); - assetsNet = Math.min(previewRedeem(sharesGross), cdo.maxWithdraw(address(this))); + assetsNet = Math.min(previewRedeem(sharesGross), cdo.maxWithdraw(address(this), owner)); } /** @dev Extends {IERC4626-maxRedeem} to handle the paused state and the TVL ratio */ - function maxRedeem(address owner) public view override returns (uint256 sharesGross) { - uint256 assetsProtocolMax = cdo.maxWithdraw(address(this)); + function maxRedeem(address owner) public view override(ERC4626Upgradeable, IERC4626) returns (uint256 sharesGross) { + uint256 assetsProtocolMax = cdo.maxWithdraw(address(this), owner); uint256 sharesProtocolMax = convertToShares(assetsProtocolMax); sharesGross = Math.min(super.maxRedeem(owner), sharesProtocolMax); } /** @dev Extends {IERC4626-previewRedeem} to handle fee calculation */ - function previewRedeem(uint256 sharesGross) public view override returns (uint256 assetsNet) { - uint256 fee = cdo.calculateExitFee(address(this), sharesGross, true); - assetsNet = super.previewRedeem(sharesGross - fee); + function previewRedeem(uint256 sharesGross) public view override(ERC4626Upgradeable, IERC4626) returns (uint256 assetsNet) { + (, uint256 fee, ) = cdo.calculateExitMode(address(this), address(0)); + assetsNet = quoteRedeem(sharesGross, fee); + } + function quoteRedeem(uint256 sharesGross, uint256 fee) public view returns (uint256 assetsNet) { + uint256 sharesFee = fee > 0 ? calculateExitFee(sharesGross, fee, /*isGross*/true) : 0; + assetsNet = super.previewRedeem(sharesGross - sharesFee); } /** @dev Extends {IERC4626-previewWithdraw} to handle fee calculation */ - function previewWithdraw(uint256 assetsNet) public view override returns (uint256 sharesGross) { + function previewWithdraw(uint256 assetsNet) public view override(ERC4626Upgradeable, IERC4626) returns (uint256 sharesGross) { + (, uint256 exitFee, ) = cdo.calculateExitMode(address(this), address(0)); + sharesGross = quoteWithdraw(assetsNet, exitFee); + } + + function quoteWithdraw(uint256 assetsNet, uint256 fee) public view returns (uint256 sharesGross) { uint256 sharesNet = super.previewWithdraw(assetsNet); - uint256 fee = cdo.calculateExitFee(address(this), sharesNet, false); - sharesGross = sharesNet + fee; + uint256 sharesFee = fee > 0 ? calculateExitFee(sharesNet, fee, /*isGross*/false) : 0; + sharesGross = sharesNet + sharesFee; } /** @@ -148,7 +168,7 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { */ /** @dev See {IERC4626-deposit}. */ - function deposit(uint256 tokenAssets, address receiver) public override returns (uint256) { + function deposit(uint256 tokenAssets, address receiver) public override(ERC4626Upgradeable, IERC4626) returns (uint256) { cdo.updateAccounting(); uint256 shares = super.deposit(tokenAssets, receiver); return shares; @@ -165,7 +185,7 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { return shares; } /** @dev See {IERC4626-mint}. */ - function mint(uint256 shares, address receiver) public override returns (uint256) { + function mint(uint256 shares, address receiver) public override(ERC4626Upgradeable, IERC4626) returns (uint256) { cdo.updateAccounting(); uint256 assets = super.mint(shares, receiver); return assets; @@ -214,78 +234,54 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { */ /** @dev See {IERC4626-withdraw}. */ - function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256) { - cdo.updateAccounting(); - uint256 shares = super.withdraw(assets, receiver, owner); - return shares; + function withdraw(uint256 assets, address receiver, address owner) public override(ERC4626Upgradeable, IERC4626) returns (uint256) { + return withdraw(asset(), assets, receiver, owner); } function withdraw(address token, uint256 tokenAmount, address receiver, address owner) public virtual returns (uint256) { - if (token == asset()) { - return withdraw(tokenAmount, receiver, owner); - } + return withdraw(token, tokenAmount, receiver, owner, TRedemptionParams(IStrataCDO.TExitMode.Dynamic, 0, 0)); + } + function withdraw(address token, uint256 tokenAmount, address receiver, address owner, TRedemptionParams memory params) public virtual returns (uint256) { cdo.updateAccounting(); + (IStrataCDO.TExitMode exitMode, uint256 exitFee, uint32 cooldownSec) = cdo.calculateExitMode(address(this), owner); + validateRedemptionParams(params, exitMode, exitFee, cooldownSec); + // {Optimistic path} Reverts if token is not supported uint256 baseAssets = cdo.strategy().convertToAssets(token, tokenAmount, Math.Rounding.Floor); uint256 maxAssets = maxWithdraw(owner); if (baseAssets > maxAssets) { revert ERC4626ExceededMaxWithdraw(owner, baseAssets, maxAssets); } - uint256 shares = previewWithdraw(baseAssets); - _withdraw(token, _msgSender(), receiver, owner, baseAssets, tokenAmount, shares); + uint256 shares = quoteWithdraw(baseAssets, exitFee); + _withdraw(token, _msgSender(), receiver, owner, baseAssets, tokenAmount, shares, exitMode, exitFee, cooldownSec); return shares; } /** @dev See {IERC4626-redeem}. */ - function redeem(uint256 shares, address receiver, address owner) public override returns (uint256) { - cdo.updateAccounting(); - uint256 assets = super.redeem(shares, receiver, owner); - return assets; + function redeem(uint256 shares, address receiver, address owner) public override(ERC4626Upgradeable, IERC4626) returns (uint256) { + return redeem(asset(), shares, receiver, owner); } function redeem(address token, uint256 shares, address receiver, address owner) public virtual returns (uint256) { - if (token == asset()) { - return redeem(shares, receiver, owner); - } + return redeem(token, shares, receiver, owner, TRedemptionParams(IStrataCDO.TExitMode.Dynamic, 0, 0)); + } + function redeem(address token, uint256 shares, address receiver, address owner, TRedemptionParams memory params) public virtual returns (uint256) { cdo.updateAccounting(); uint256 maxShares = maxRedeem(owner); if (shares > maxShares) { revert ERC4626ExceededMaxRedeem(owner, shares, maxShares); } - uint256 baseAssets = previewRedeem(shares); + (IStrataCDO.TExitMode exitMode, uint256 exitFee, uint32 cooldownSec) = cdo.calculateExitMode(address(this), owner); + validateRedemptionParams(params, exitMode, exitFee, cooldownSec); + + uint256 baseAssets = quoteRedeem(shares, exitFee); // {Optimistic path} Reverts if token is not supported uint256 tokenAssets = cdo.strategy().convertToTokens(token, baseAssets, Math.Rounding.Ceil); - _withdraw(token, _msgSender(), receiver, owner, baseAssets, tokenAssets, shares); + _withdraw(token, _msgSender(), receiver, owner, baseAssets, tokenAssets, shares, exitMode, exitFee, cooldownSec); return tokenAssets; } /** - * @dev Withdraw/redeem common workflow for base token - * assets ~ net - * shares ~ gross - */ - function _withdraw( - address caller, - address receiver, - address owner, - uint256 assetsNet, - uint256 sharesGross - ) internal override { - if (caller != owner) { - _spendAllowance(owner, caller, sharesGross); - } - uint256 assetsGross = convertToAssets(sharesGross); - uint256 fee = Math.saturatingSub(assetsGross, assetsNet); - - _burn(owner, sharesGross); - cdo.accrueFee(address(this), fee); - cdo.withdraw(address(this), asset(), assetsNet, assetsNet, owner, receiver); - - _onAfterWithdrawalChecks(); - emit Withdraw(caller, receiver, owner, assetsNet, sharesGross); - } - - /** - * @dev Withdraw/redeem common workflow for meta token + * @dev Withdraw/redeem common workflow for base and meta tokens */ function _withdraw( address token, @@ -294,11 +290,23 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { address owner, uint256 baseAssets, uint256 tokenAssets, - uint256 sharesGross + uint256 sharesGross, + IStrataCDO.TExitMode exitMode, + uint256 exitFee, + uint32 cooldownSec ) internal virtual { if (caller != owner) { _spendAllowance(owner, caller, sharesGross); } + + emit OnExit(receiver, token, tokenAssets, sharesGross, exitMode, exitFee, cooldownSec); + + if (exitMode == IStrataCDO.TExitMode.SharesLock) { + _transfer(owner, address(cdo.sharesCooldown()), sharesGross); + cdo.cooldownShares(address(this), sharesGross, owner, receiver, exitFee, cooldownSec); + return; + } + uint256 baseAssetsGross = convertToAssets(sharesGross); uint256 fee = Math.saturatingSub(baseAssetsGross, baseAssets); @@ -310,6 +318,33 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { emit OnMetaWithdraw(receiver, token, tokenAssets, sharesGross); } + /** + * ============================================ + * Fee methods + * ============================================ + */ + + /// @notice Burns shares as fee without withdrawing assets. Permissionless but typically called by SharesCooldown + /// to accrue fees on the redeemable portion during cooldown process. + /// @param shares The amount of shares to burn as fee + /// @param owner The owner of the shares to burn + /// @return assets The base assets accounted as fee + function burnSharesAsFee(uint256 shares, address owner) external returns (uint256 assets) { + address caller = _msgSender(); + if (caller != owner) { + _spendAllowance(owner, caller, shares); + } + uint256 maxShares = maxRedeem(owner); + if (shares > maxShares) { + revert ERC4626ExceededMaxRedeem(owner, shares, maxShares); + } + + assets = convertToAssets(shares); + _burn(owner, shares); + cdo.accrueFee(address(this), assets); + _onAfterWithdrawalChecks(); + } + /** * ============================================ * Configuration @@ -338,4 +373,30 @@ contract Tranche is CDOComponent, ERC4626Upgradeable, ERC20PermitUpgradeable { revert MinSharesViolation(); } } + + /// @dev The calculation can be based on either the gross withdrawal amount (before fees) + /// or the net amount a user wishes to receive (after fees). + /// @param amount The amount to calculate the fee on. + /// @param isGross If true, `amount` is the gross withdrawal amount. + /// If false, `amount` is the net amount to be received. + /// @return The calculated exit fee amount + function calculateExitFee (uint256 amount, uint256 fee, bool isGross) internal pure returns (uint256) { + return isGross + ? Math.mulDiv(amount, fee, 1e18, Math.Rounding.Floor) + : Math.mulDiv(amount, fee, 1e18 - fee, Math.Rounding.Floor); + } + + + function validateRedemptionParams(TRedemptionParams memory params, IStrataCDO.TExitMode exitMode, uint256 exitFee, uint32 cooldownSec) internal pure { + if (params.exitMode == IStrataCDO.TExitMode.Dynamic) { + return; + } + if (params.exitMode != exitMode || params.exitFee != exitFee || params.cooldownSeconds != cooldownSec) { + revert RedemptionParamsMismatch(params, TRedemptionParams({ + exitMode: exitMode, + exitFee: exitFee, + cooldownSeconds: cooldownSec + })); + } + } } diff --git a/contracts/tranches/TwoStepConfigManager.sol b/contracts/tranches/TwoStepConfigManager.sol index 7b21c32..747fe90 100644 --- a/contracts/tranches/TwoStepConfigManager.sol +++ b/contracts/tranches/TwoStepConfigManager.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.28; import { AccessControlled } from "../governance/AccessControlled.sol"; import { IStrataCDO, IStrataCDOSetters } from "./interfaces/IStrataCDO.sol"; +import { ISharesCooldown } from "./interfaces/cooldown/ISharesCooldown.sol"; interface IStrataCDOFull is IStrataCDO, IStrataCDOSetters { function acm () external view returns (address); @@ -32,6 +33,40 @@ abstract contract PendingFeesTypes { event ExitFeeChangeCancelled(); } +/// @notice Shared types and events for pending exit mode configuration. +abstract contract PendingExitModeTypes { + /// @notice Pending exit mode change that becomes executable after a delay. + struct TPendingExitModeBoundsChange { + ISharesCooldown.TExitUpperBounds bounds; + uint64 executeAfter; + } + /// @notice Emitted when a new exit fee change is scheduled. + event ExitModeBoundsChangeScheduled( + ISharesCooldown.TExitUpperBounds boundsJrt, + ISharesCooldown.TExitUpperBounds boundsSrt, + uint64 executeAfter + ); + /// @notice Emitted when a pending exit-fee change is executed on the CDO. + event ExitModeBoundsChangeExecuted( + ISharesCooldown.TExitUpperBounds boundsJrt, + ISharesCooldown.TExitUpperBounds boundsSrt + ); + /// @notice Emitted when a pending exit fee change is cancelled. + event ExitModeBoundsChangeCancelled(); + + function validateBounds (ISharesCooldown.TExitUpperBounds calldata bounds) internal pure { + require(bounds.p0 <= bounds.p1, 'P1>P2'); + + require(bounds.r0.feePpm <= 0.05e6, "InvalidFee"); + require(bounds.r1.feePpm <= 0.05e6, "InvalidFee"); + require(bounds.r2.feePpm <= 0.05e6, "InvalidFee"); + + require(bounds.r0.sharesLock <= 30 days, "InvalidCooldown"); + require(bounds.r1.sharesLock <= 30 days, "InvalidCooldown"); + require(bounds.r2.sharesLock <= 30 days, "InvalidCooldown"); + } +} + /** * @title TwoStepConfigManager @@ -45,12 +80,14 @@ abstract contract PendingFeesTypes { * This contract is intended to be extended later with additional * two-step configuration methods (e.g. other risk params). */ -contract TwoStepConfigManager is AccessControlled, PendingFeesTypes { +contract TwoStepConfigManager is AccessControlled, PendingFeesTypes, PendingExitModeTypes{ uint256 public constant MIN_DELAY = 1 days; IStrataCDOFull public immutable cdo; TPendingExitFeeChange public pendingExitFeeChange; + TPendingExitModeBoundsChange public pendingExitModeBoundsJrt; + TPendingExitModeBoundsChange public pendingExitModeBoundsSrt; constructor (IStrataCDOFull _cdo) { cdo = _cdo; @@ -122,4 +159,68 @@ contract TwoStepConfigManager is AccessControlled, PendingFeesTypes { delete pendingExitFeeChange; emit ExitFeeChangeCancelled(); } + + /** + * ============================================ + * Exit mode upper bounds configuration + * ============================================ + */ + + /** + * @notice Step 1: Schedule a new exit mode bounds configuration. + * @param boundsJrt Exit mode upper bounds for the junior tranche (JRT), defining coverage ratio thresholds and corresponding fee/lock parameters. + * @param boundsSrt Exit mode upper bounds for the senior tranche (SRT), defining coverage ratio thresholds and corresponding fee/lock parameters. + * @param delay Delay in seconds until the change can be executed. + * Must be greater or equal than MIN_DELAY. + */ + function scheduleExitModeBoundsChange( + ISharesCooldown.TExitUpperBounds calldata boundsJrt, + ISharesCooldown.TExitUpperBounds calldata boundsSrt, + uint256 delay + ) external onlyRole(PROPOSER_CONFIG_ROLE) { + + validateBounds(boundsJrt); + validateBounds(boundsSrt); + require(delay >= MIN_DELAY, "InvalidDelay"); + + uint64 executeAfter = uint64(block.timestamp + delay); + pendingExitModeBoundsJrt = TPendingExitModeBoundsChange({ + bounds: boundsJrt, + executeAfter: executeAfter + }); + pendingExitModeBoundsSrt = TPendingExitModeBoundsChange({ + bounds: boundsSrt, + executeAfter: executeAfter + }); + emit ExitModeBoundsChangeScheduled(boundsJrt, boundsSrt, executeAfter); + } + + /** + * @notice Step 2: execute the pending exit mode bounds change on the underlying CDO. + */ + function executeExitModeBoundsChange() external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { + TPendingExitModeBoundsChange memory pendingJrt = pendingExitModeBoundsJrt; + TPendingExitModeBoundsChange memory pendingSrt = pendingExitModeBoundsSrt; + require(pendingJrt.executeAfter != 0, "NoPendingChange"); + require(block.timestamp >= pendingJrt.executeAfter, "TooEarly"); + // Clear pending state + delete pendingExitModeBoundsJrt; + delete pendingExitModeBoundsSrt; + // External call to the underlying contract. + + cdo.sharesCooldown().setVaultExitBounds(address(cdo.jrtVault()), pendingJrt.bounds); + cdo.sharesCooldown().setVaultExitBounds(address(cdo.srtVault()), pendingSrt.bounds); + + emit ExitModeBoundsChangeExecuted(pendingJrt.bounds, pendingSrt.bounds); + } + + /** + * @notice Cancel the currently pending exit mode bounds change, if any. + */ + function cancelExitModeBoundsChange() external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { + require(pendingExitModeBoundsJrt.executeAfter != 0, "NoPendingChange"); + delete pendingExitModeBoundsJrt; + delete pendingExitModeBoundsSrt; + emit ExitModeBoundsChangeCancelled(); + } } diff --git a/contracts/tranches/base/cooldown/SharesCooldown.sol b/contracts/tranches/base/cooldown/SharesCooldown.sol new file mode 100644 index 0000000..d417a70 --- /dev/null +++ b/contracts/tranches/base/cooldown/SharesCooldown.sol @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {ISharesCooldown} from "../../interfaces/cooldown/ISharesCooldown.sol"; +import {ICooldown} from "../../interfaces/cooldown/ICooldown.sol"; +import {ITranche} from "../../interfaces/ITranche.sol"; +import {CooldownBase} from "./CooldownBase.sol"; + +/** + * @title Strata Shares Cooldown Manager + * @notice Manages cooldown periods for vault share redemptions with configurable lock times and fees. + * @dev This contract acts as a silo that holds vault shares during the cooldown period before redemption. + * Key features: + * - Supports multiple concurrent redemption requests per user (up to 70 active requests) + * - Configurable cooldown periods based on vault coverage ratios + * - Optional early exit with proportional fees (fee = vaultEarlyExitFeePerDay * daysRemaining) + * - Users can cancel pending requests and recover their shares + * - Supports redemptions to external receivers (limited to 40 requests per receiver) + * + * Workflow: + * 1. Tranche Vault initiates redemption via requestRedeem() - shares are locked in this contract + * 2. After cooldown period expires, user calls finalize() to redeem underlying assets + * 3. Alternatively, user can call finalizeWithFee() for early exit with fee + * 4. User can cancel() pending requests to recover locked shares + */ +contract SharesCooldown is ISharesCooldown, CooldownBase { + + modifier onlyUser (address user) { + require(msg.sender == user, "OnlyOwner"); + _; + } + + mapping(address vault => mapping(address account => TRequest[] requests)) public activeRequests; + mapping(address vault => uint256 fee) public vaultEarlyExitFeePerDay; + mapping(address vault => TExitUpperBounds bounds) public vaultExitBounds; + + + /// @notice Initiates a share redemption request with optional cooldown period and fee. + /// @dev Called by COOLDOWN_WORKER_ROLE (typically StrataCDO) when vault coverage requires lockup or fee. + /// Lockup and fee values are determined using calculateExitParams() based on vault coverage. + /// Request handling follows the same pattern as ERC20Cooldown and UnstakeCooldown transfer methods. + /// @param vault The tranche vault holding the shares + /// @param initialFrom The original owner initiating the redemption + /// @param to The recipient who will receive the redeemed assets (can differ from initialFrom) + /// @param shares Amount of vault shares to redeem + /// @param fee Fee in basis points (1e18 = 100%) to burn from shares before locking + /// @param cooldownSeconds Lock duration in seconds; 0 for immediate redemption + function requestRedeem(ITranche vault, address initialFrom, address to, uint256 shares, uint256 fee, uint32 cooldownSeconds) external onlyRole(COOLDOWN_WORKER_ROLE) { + if (shares == 0) { + return; + } + if (fee > 0) { + (uint256 sharesUser, ) = accrueFee(vault, shares, fee); + shares = sharesUser; + } + if (cooldownSeconds == 0) { + vault.redeem(shares, to, address(this)); + emit Finalized(IERC20(address(vault)), to, shares); + return; + } + + TRequest[] storage requests = activeRequests[address(vault)][to]; + + uint256 requestsCount = requests.length; + if (initialFrom != to && requestsCount >= PUBLIC_REQUEST_SLOTS_CAP) { + revert ExternalReceiverRequestLimitReached( + vault, + initialFrom, + to, + shares + ); + } + + uint64 unlockAt = uint64(block.timestamp + cooldownSeconds); + if (requestsCount < MAX_ACTIVE_REQUEST_SLOTS) { + if ( + requestsCount > 0 && + requests[requestsCount - 1].unlockAt == unlockAt + ) { + // is requested within current block + TRequest storage last = requests[requestsCount - 1]; + last.shares += uint192(shares); + } else { + requests.push(TRequest(unlockAt, uint192(shares))); + } + } else { + TRequest storage last = requests[requestsCount - 1]; + last.shares += uint192(shares); + if (last.unlockAt < unlockAt) { + last.unlockAt = unlockAt; + } + } + + emit TransferRequested(vault, initialFrom, to, shares, unlockAt); + } + + function finalize(ITranche vault, address token, address user) external returns (uint256 claimed) { + return finalize(vault, token, user, block.timestamp); + } + function finalize(ITranche vault, address token, address user, uint256 at) public returns (uint256 claimed) { + claimed = extractClaimableInner(address(vault), user, at); + vault.redeem(token, claimed, user, address(this)); + emit Finalized(vault, user, claimed); + return claimed; + } + function finalize(IERC20 vault, address user) external returns (uint256 claimed) { + return finalize(vault, user, block.timestamp); + } + function finalize(IERC20 vault, address user, uint256 at) public returns (uint256 claimed) { + claimed = extractClaimableInner(address(vault), user, at); + IERC4626(address(vault)).redeem(claimed, user, address(this)); + emit Finalized(vault, user, claimed); + return claimed; + } + + /// @notice Finalizes a redemption request before the unlock time by paying an early exit fee. + /// @dev Allows users to bypass the cooldown period by paying a fee proportional to the remaining lock time. + /// The fee is calculated as: fee = vaultEarlyExitFeePerDay * daysLeft + /// The fee is burned from the shares, and the remaining shares are redeemed to the user. + /// Only the recipient (user) can finalize their own requests early. + /// Scenario: If Alice redeems shares to Bob with a 7-day lock, Bob can call this function + /// after 3 days to receive the shares immediately by paying a fee for the remaining 4 days. + /// @param vault The vault/tranche token address + /// @param user The recipient address of the redemption request (must be msg.sender) + /// @param i The index of the request in the user's active requests array + /// @return claimed The amount of shares claimed after deducting the early exit fee + function finalizeWithFee(ITranche vault, address user, uint256 i) external onlyUser(user) returns (uint256 claimed) { + TRequest[] storage requests = activeRequests[address(vault)][user]; + uint256 len = requests.length; + require(i < len, "OutOfRange"); + TRequest memory req = requests[i]; + if (i < len - 1) { + requests[i] = requests[len - 1]; + } + requests.pop(); + + require(req.unlockAt > block.timestamp, "RequestReady"); + + uint256 shares = req.shares; + uint256 daysLeft = (req.unlockAt - block.timestamp) / (24 * 60 * 60) + 1; // includes current day in the count + uint256 fee = vaultEarlyExitFeePerDay[address(vault)]; + + (uint256 sharesUser, uint256 sharesFee) = accrueFee(vault, shares, fee * daysLeft); + + vault.redeem(sharesUser, user, address(this)); + emit ExitFeeAccrued(address(this), user, sharesFee, sharesUser); + + claimed = sharesUser; + } + + /// @notice Cancels an active redemption request and returns the shares to the user. + /// @dev The shares are transferred back to the user who is the recipient of the redemption request. + /// Only the recipient (user) can cancel their own requests. + /// Scenario: If Alice redeems shares to Bob, the shares remain locked in this contract. + /// Only Bob can cancel the request and receive the shares back to his account. + /// @param vault The vault/tranche token address + /// @param user The recipient address of the redemption request (must be msg.sender) + /// @param i The index of the request in the user's active requests array + function cancel(IERC20 vault, address user, uint256 i) external onlyUser(user) { + + TRequest[] storage requests = activeRequests[address(vault)][user]; + uint256 len = requests.length; + require(i < len, "OutOfRange"); + TRequest memory req = requests[i]; + if (i < len - 1) { + requests[i] = requests[len - 1]; + } + requests.pop(); + vault.transfer(user, req.shares); + emit RequestCanceled(address(vault), user, req.shares); + } + + function balanceOf(IERC20 vault, address user) external view returns (ICooldown.TBalanceState memory) { + return balanceOf(vault, user, block.timestamp); + } + function balanceOf(IERC20 vault, address user, uint256 at) public view returns (ICooldown.TBalanceState memory) { + TRequest[] storage requests = activeRequests[address(vault)][user]; + + bool isCooldownActive = isCooldownActiveInner(address(vault)); + + uint256 l = requests.length; + uint256 pending; + uint256 claimable; + uint256 nextUnlockAt; + uint256 nextUnlockAmount; + + for (uint256 i; i < l; i++) { + TRequest memory req = requests[i]; + if (isCooldownActive && req.unlockAt > at) { + pending += req.shares; + if (nextUnlockAt == 0 || req.unlockAt < nextUnlockAt) { + nextUnlockAt = req.unlockAt; + nextUnlockAmount = req.shares; + continue; + } + if (req.unlockAt == nextUnlockAt) { + nextUnlockAmount += req.shares; + } + continue; + } + claimable += req.shares; + } + return + TBalanceState({ + pending: pending, + claimable: claimable, + nextUnlockAt: nextUnlockAt, + nextUnlockAmount: nextUnlockAmount, + totalRequests: l + }); + } + + /// @notice Configures exit parameters (cooldown periods and fees) for a specific vault based on coverage thresholds. + /// @dev Only callable by TwoStepConfigManager to ensure governance-controlled configuration changes. + /// Defines three coverage ranges with corresponding exit parameters: + /// - Range 0: coverage <= p0 (most restrictive, typically longest lock/highest fee) + /// - Range 1: p0 < coverage <= p1 (moderate restrictions) + /// - Range 2: coverage > p1 (least restrictive, typically no lock/minimal fee) + /// The bounds.p0 and bounds.p1 values are in parts per million (ppm), where 1e6 = 100%. + /// Example: p0=5000 (0.5%), p1=23000 (2.3%) creates three ranges for different coverage levels. + /// @param vault The tranche vault address to configure + /// @param bounds The exit bounds configuration containing coverage thresholds (p0, p1) and corresponding exit parameters (r0, r1, r2) + function setVaultExitBounds(address vault, TExitUpperBounds calldata bounds) external onlyTwoStepConfigManager { + require(bounds.p0 <= bounds.p1, 'P1>P2'); + + vaultExitBounds[vault] = bounds; + emit VaultCooldownBoundsUpdated(vault, bounds); + } + + /// @notice Sets the daily early exit fee rate for a vault, capped at 1% per day. + function setVaultEarlyExitFee(address vault, uint256 fee) external onlyOwner { + require(fee <= 0.01e18, "InvalidFee"); + vaultEarlyExitFeePerDay[vault] = fee; + + emit VaultEarlyExitFeeSet(vault, fee); + } + + /// @notice Calculates exit parameters (cooldown period and fee) for a vault based on current coverage ratio. + function calculateExitParams (address vault, uint32 coveragePpm) public view returns (TExitParams memory) { + TExitUpperBounds memory bounds = vaultExitBounds[vault]; + if (coveragePpm <= bounds.p0) return bounds.r0; + if (coveragePpm <= bounds.p1) return bounds.r1; + return bounds.r2; + } + + /// @dev Returns false if cooldown is disabled (p1=0 and r2.sharesLock=0), indicating immediate finalizations are allowed. + function isCooldownActiveInner (address vault) internal view returns (bool) { + return vaultExitBounds[vault].p1 > 0 || vaultExitBounds[vault].r2.sharesLock > 0; + } + + /// @dev Accrues exit fees by burning a portion of shares; + /// called either before lockup (immediate fee) or during early exit (proportional to remaining lock time). + function accrueFee (ITranche vault, uint256 shares, uint256 feeBps) internal returns (uint256 sharesUser, uint256 sharesFee) { + sharesFee = Math.mulDiv(shares, feeBps, 1e18, Math.Rounding.Floor); + sharesUser = shares > sharesFee ? shares - sharesFee : 0; + + require(sharesUser > 0 && sharesFee > 0, "EmptyFee"); + vault.burnSharesAsFee(sharesFee, address(this)); + } + + /// @dev Extracts and removes all claimable requests (unlocked or cooldown disabled) from user's active requests array. + function extractClaimableInner(address vault, address user, uint256 at) internal returns (uint256 claimable) { + if (at > block.timestamp) { + revert InvalidTime(); + } + TRequest[] storage requests = activeRequests[address(vault)][user]; + bool isCooldownActive = isCooldownActiveInner(vault); + + uint256 len = requests.length; + for (uint256 i; i < len; ) { + TRequest memory req = requests[i]; + if (isCooldownActive && req.unlockAt > at) { + // still pending + unchecked { + i++; + } + continue; + } + claimable += req.shares; + + if (i < len - 1) { + requests[i] = requests[len - 1]; + } + requests.pop(); + unchecked { + len--; + } + } + if (claimable == 0) { + revert NothingToFinalize(); + } + return claimable; + } + +} diff --git a/contracts/tranches/interfaces/IAccounting.sol b/contracts/tranches/interfaces/IAccounting.sol index 6723c6d..51f83f0 100644 --- a/contracts/tranches/interfaces/IAccounting.sol +++ b/contracts/tranches/interfaces/IAccounting.sol @@ -21,6 +21,7 @@ interface IAccounting is ICDOComponent, IAprPairFeedListener { function accrueFee(bool isJrt, uint256 amount) external; function maxWithdraw(bool isJrt) external view returns (uint256); + function maxWithdraw(bool isJrt, bool ownerIsSharesCooldown) external view returns (uint256); function maxDeposit(bool isJrt) external view returns (uint256); } diff --git a/contracts/tranches/interfaces/IDistributor.sol b/contracts/tranches/interfaces/IDistributor.sol new file mode 100644 index 0000000..99d4cb6 --- /dev/null +++ b/contracts/tranches/interfaces/IDistributor.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice Merkle tree structure for reward distribution +struct MerkleTree { + /// @notice Root of a Merkle tree whose leaves are `(address user, address token, uint amount)` + bytes32 merkleRoot; + /// @dev Deprecated: this used to be the IPFS hash of the complete tree data + bytes32 ipfsHash; +} + +/// @notice Claim tracking structure +struct Claim { + /// @notice Cumulative amount claimed by the user for this token + uint208 amount; + /// @notice Timestamp of the last claim + uint48 timestamp; + /// @notice Merkle root that was active when the last claim occurred + bytes32 merkleRoot; +} + +/// @title IDistributor +/// @notice Interface for the Merkl Distributor contract +/// @dev Manages the distribution of Merkl rewards and allows users to claim their earned tokens +interface IDistributor { + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + event Claimed(address indexed user, address indexed token, uint256 amount); + event ClaimRecipientUpdated(address indexed user, address indexed token, address indexed recipient); + event DisputeAmountUpdated(uint256 _disputeAmount); + event Disputed(string reason); + event DisputePeriodUpdated(uint48 _disputePeriod); + event DisputeResolved(bool valid); + event DisputeTokenUpdated(address indexed _disputeToken); + event EpochDurationUpdated(uint32 newEpochDuration); + event MainOperatorStatusUpdated(address indexed operator, address indexed token, bool isWhitelisted); + event OperatorClaimingToggled(address indexed user, bool isEnabled); + event OperatorToggled(address indexed user, address indexed operator, bool isWhitelisted); + event Recovered(address indexed token, address indexed to, uint256 amount); + event Revoked(); + event TreeUpdated(bytes32 merkleRoot, bytes32 ipfsHash, uint48 endOfDisputePeriod); + event TrustedToggled(address indexed eoa, bool trust); + event UpgradeabilityRevoked(); + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + PUBLIC VARIABLES + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Current active Merkle tree containing claimable token data + function tree() external view returns (bytes32 merkleRoot, bytes32 ipfsHash); + + /// @notice Previous Merkle tree that was active before the last update + function lastTree() external view returns (bytes32 merkleRoot, bytes32 ipfsHash); + + /// @notice Token required as a deposit to dispute a tree update + function disputeToken() external view returns (IERC20); + + /// @notice Address that created the current ongoing dispute + function disputer() external view returns (address); + + /// @notice Timestamp after which the current tree becomes effective and undisputable + function endOfDisputePeriod() external view returns (uint48); + + /// @notice Number of epochs to wait before a tree update becomes effective + function disputePeriod() external view returns (uint48); + + /// @notice Amount of disputeToken required to create a dispute + function disputeAmount() external view returns (uint256); + + /// @notice Tracks cumulative claimed amounts for each user and token + function claimed(address user, address token) + external + view + returns (uint208 amount, uint48 timestamp, bytes32 merkleRoot); + + /// @notice Trusted addresses authorized to update the Merkle root + function canUpdateMerkleRoot(address) external view returns (uint256); + + /// @notice Authorization for operators to claim on behalf of users + function operators(address user, address operator) external view returns (uint256); + + /// @notice Whether contract upgradeability has been permanently disabled + function upgradeabilityDeactivated() external view returns (uint128); + + /// @notice Custom recipient addresses for user claims per token + function claimRecipient(address user, address token) external view returns (address); + + /// @notice Global operators authorized to claim specific tokens on behalf of any user + function mainOperators(address operator, address token) external view returns (uint256); + + /// @notice Success message that must be returned by `IClaimRecipient.onClaim` callback + function CALLBACK_SUCCESS() external view returns (bytes32); + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + MAIN FUNCTIONS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Claims rewards for a set of users based on Merkle proofs + /// @param users Addresses claiming rewards (or being claimed for) + /// @param tokens ERC20 tokens being claimed + /// @param amounts Cumulative amounts earned (not incremental amounts) + /// @param proofs Merkle proofs validating each claim + function claim( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs + ) external; + + /// @notice Claims rewards with custom recipient addresses and callback data + /// @param users Addresses claiming rewards (or being claimed for) + /// @param tokens ERC20 tokens being claimed + /// @param amounts Cumulative amounts earned (not incremental amounts) + /// @param proofs Merkle proofs validating each claim + /// @param recipients Custom recipient addresses for each claim + /// @param datas Arbitrary data passed to recipient's onClaim callback + function claimWithRecipient( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs, + address[] calldata recipients, + bytes[] memory datas + ) external; + + /// @notice Returns the currently active Merkle root for claim verification + function getMerkleRoot() external view returns (bytes32); + + /// @notice Returns the epoch duration used for dispute period calculations + function getEpochDuration() external view returns (uint32 epochDuration); + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + USER ADMIN FUNCTIONS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Toggles an operator's authorization to claim rewards on behalf of a user + function toggleOperator(address user, address operator) external; + + /// @notice Sets a custom recipient address for a user's token claims + function setClaimRecipient(address recipient, address token) external; + + /// @notice Toggles a main operator's authorization to claim tokens on behalf of any user + function toggleMainOperatorStatus(address operator, address token) external; + + /// @notice Creates a dispute to freeze the current Merkle tree update + function disputeTree(string memory reason) external; + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + GOVERNANCE FUNCTIONS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Updates the active Merkle tree with new reward data + function updateTree(MerkleTree calldata _tree) external; + + /// @notice Toggles an address's authorization to update the Merkle tree + function toggleTrusted(address trustAddress) external; + + /// @notice Permanently disables contract upgradeability + function revokeUpgradeability() external; + + /// @notice Updates the epoch duration used for dispute period calculations + function setEpochDuration(uint32 epochDuration) external; + + /// @notice Resolves an ongoing dispute + function resolveDispute(bool valid) external; + + /// @notice Reverts to the previous Merkle tree immediately + function revokeTree() external; + + /// @notice Recovers ERC20 tokens accidentally sent to the contract + function recoverERC20(address tokenAddress, address to, uint256 amountToRecover) external; + + /// @notice Updates the dispute period duration + function setDisputePeriod(uint48 _disputePeriod) external; + + /// @notice Updates the token required as collateral for disputes + function setDisputeToken(IERC20 _disputeToken) external; + + /// @notice Updates the amount of tokens required to create a dispute + function setDisputeAmount(uint256 _disputeAmount) external; +} + diff --git a/contracts/tranches/interfaces/IStrataCDO.sol b/contracts/tranches/interfaces/IStrataCDO.sol index 87c82ed..934356c 100644 --- a/contracts/tranches/interfaces/IStrataCDO.sol +++ b/contracts/tranches/interfaces/IStrataCDO.sol @@ -3,10 +3,21 @@ pragma solidity ^0.8.28; import { IStrategy } from "./IStrategy.sol"; import { ITranche } from "./ITranche.sol"; +import { ISharesCooldown } from "./cooldown/ISharesCooldown.sol"; interface IStrataCDO { + enum TExitMode { + Dynamic, + Fee, + AssetsLock, + SharesLock, + ERC4626 + } + + function strategy() external view returns (IStrategy); + function sharesCooldown() external view returns (ISharesCooldown); function totalAssets (address tranche) external view returns (uint256); function totalStrategyAssets () external view returns (uint256); @@ -14,8 +25,10 @@ interface IStrataCDO { function deposit (address tranche, address token, uint256 tokenAmount, uint256 baseAssets) external; function withdraw (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address owner, address receiver) external; + function cooldownShares(address tranche, uint256 shares, address sender, address receiver, uint256 fee, uint32 cooldownSeconds) external; function maxWithdraw(address tranche) external view returns (uint256); + function maxWithdraw(address tranche, address owner) external view returns (uint256); function maxDeposit(address tranche) external view returns (uint256); // reverts if neither Jrt nor Srt @@ -24,10 +37,11 @@ interface IStrataCDO { function jrtVault() external view returns (ITranche); function srtVault() external view returns (ITranche); - function calculateExitFee(address tranche, uint256 amount, bool isGross) external view returns (uint256); function accrueFee(address tranche, uint256 assetsFee) external; function exitFeeJrt () external view returns (uint256); function exitFeeSrt () external view returns (uint256); + + function calculateExitMode (address tranche, address owner) external view returns (TExitMode mode, uint256 feeBps, uint32 coverage); } interface IStrataCDOSetters { diff --git a/contracts/tranches/interfaces/IStrategy.sol b/contracts/tranches/interfaces/IStrategy.sol index 5ea74d4..0d0ad6b 100644 --- a/contracts/tranches/interfaces/IStrategy.sol +++ b/contracts/tranches/interfaces/IStrategy.sol @@ -9,6 +9,7 @@ interface IStrategy is ICDOComponent { function deposit (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address owner) external returns (uint256); function withdraw (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver) external returns (uint256); + function withdraw (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver, bool shouldSkipCooldown) external returns (uint256); function totalAssets () external view returns (uint256); function reduceReserve (address token, uint256 tokenAmount, address receiver) external; diff --git a/contracts/tranches/interfaces/ISwapContract.sol b/contracts/tranches/interfaces/ISwapContract.sol new file mode 100644 index 0000000..eacb936 --- /dev/null +++ b/contracts/tranches/interfaces/ISwapContract.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/// @title ISwapContract +/// @notice Interface for the SwapContract that performs swaps on Uniswap V4 pools +/// @dev Uses bytes for poolKey to avoid importing v4-core types which causes version conflicts +interface ISwapContract { + /// @notice Swap exact input tokens for output tokens with direction control + /// @param poolKeyData ABI-encoded PoolKey struct + /// @param zeroForOne Direction: true = token0 -> token1, false = token1 -> token0 + /// @param amountIn Exact amount of input tokens to swap + /// @param minAmountOut Minimum amount of output tokens (slippage protection) + /// @param deadline Timestamp after which the transaction reverts + /// @param hookData Arbitrary data passed to the pool's hook + /// @return amountOut The amount of output tokens received + function swapWithEncodedKey( + bytes calldata poolKeyData, + bool zeroForOne, + uint128 amountIn, + uint128 minAmountOut, + uint256 deadline, + bytes calldata hookData + ) external returns (uint256 amountOut); + + /// @notice Approve a token for use with Permit2 and the Universal Router + /// @param token The token to approve + /// @param amount The amount to approve + /// @param expiration The expiration time for the approval + function approveTokenWithPermit2(address token, uint160 amount, uint48 expiration) external; +} diff --git a/contracts/tranches/interfaces/ITranche.sol b/contracts/tranches/interfaces/ITranche.sol index 8038a24..d515fec 100644 --- a/contracts/tranches/interfaces/ITranche.sol +++ b/contracts/tranches/interfaces/ITranche.sol @@ -3,8 +3,20 @@ pragma solidity ^0.8.28; import { ICDOComponent } from "./ICDOComponent.sol"; import { IMetaVault } from "./IMetaVault.sol"; +import { IStrataCDO } from "./IStrataCDO.sol"; interface ITranche is ICDOComponent, IMetaVault { + /// @dev Reverts when user-specified redemption parameters don't match current exit mode settings, + /// protecting against mode slippage before transaction execution + struct TRedemptionParams { + IStrataCDO.TExitMode exitMode; + uint256 exitFee; + uint32 cooldownSeconds; + } + + error RedemptionParamsMismatch(TRedemptionParams requested, TRedemptionParams current); + function configure () external; + function burnSharesAsFee(uint256 shares, address owner) external returns (uint256); } diff --git a/contracts/tranches/interfaces/cooldown/ISharesCooldown.sol b/contracts/tranches/interfaces/cooldown/ISharesCooldown.sol new file mode 100644 index 0000000..9b28b94 --- /dev/null +++ b/contracts/tranches/interfaces/cooldown/ISharesCooldown.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { ITranche } from "../ITranche.sol"; +import { ICooldown } from "./ICooldown.sol"; +import { IStrataCDO } from "../IStrataCDO.sol"; + +interface ISharesCooldown is ICooldown { + + struct TRequest { + uint64 unlockAt; + uint192 shares; + } + + struct TExitParams { + uint32 feePpm; + uint32 sharesLock; + } + + /// @notice Defines exit mode upper bounds for coverage-based fee and lockup parameters. + /// @dev Structure is gas and storage optimized by packing all fields into a single 256-bit slot. + /// Coverage ratios (p0, p1) and exit parameters (r0, r1, r2) are stored as uint32 values. + /// The coverage ratio determines which range applies: + /// - coverage < p0: use r0 (typically highest fees/longest locks) + /// - p0 <= coverage < p1: use r1 (typically medium fees/locks) + /// - coverage >= p1: use r2 (lowest fees/shortest locks, typically zero) + /// @param p0 First breakpoint in parts per million (upper bound for range0) + /// @param p1 Second breakpoint in parts per million (upper bound for range1) + /// @param r0 Exit parameters (fee in ppm, shares lock in seconds) for coverage < p0 + /// @param r1 Exit parameters for p0 <= coverage < p1 + /// @param r2 Exit parameters for coverage >= p1 (default) + struct TExitUpperBounds { + uint32 p0; + uint32 p1; + TExitParams r0; + TExitParams r1; + TExitParams r2; + } + + event RedeemRequested(IERC4626 indexed vault, address indexed from, address indexed to, uint256 shares, uint256 unlockAt); + event VaultCooldownUpdated(address indexed vault, uint256 cooldownSeconds); + event RequestCanceled(address indexed vault, address user, uint256 shares); + event VaultCooldownBoundsUpdated(address indexed vault,TExitUpperBounds bounds); + event VaultEarlyExitFeeSet(address indexed vault, uint256 earlyExitFee); + event ExitFeeAccrued(address indexed vault, address user, uint256 sharesFee, uint256 sharesUser); + + function finalize(ITranche vault, address token, address user) external returns (uint256 claimed); + function finalize(ITranche vault, address token, address user, uint256 at) external returns (uint256 claimed); + function requestRedeem( + ITranche vault, + address initialFrom, + address to, + uint256 shares, + uint256 exitFee, + uint32 exitSharesLock + ) external; + + function setVaultExitBounds(address vault, TExitUpperBounds calldata bounds)external; + function calculateExitParams (address vault, uint32 coveragePpm) external view returns (TExitParams memory); +} diff --git a/contracts/tranches/strategies/ethena/sUSDeStrategy.sol b/contracts/tranches/strategies/ethena/sUSDeStrategy.sol index be6c074..bf11617 100644 --- a/contracts/tranches/strategies/ethena/sUSDeStrategy.sol +++ b/contracts/tranches/strategies/ethena/sUSDeStrategy.sol @@ -90,9 +90,17 @@ contract sUSDeStrategy is Strategy { * @return The amount of tokens withdrawn (shares for sUSDe, baseAssets for USDe) */ function withdraw (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver) external onlyCDO returns (uint256) { + return withdrawInner(tranche, token, tokenAmount, baseAssets, sender, receiver, false); + } + + function withdraw (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver, bool shouldSkipCooldown) external onlyCDO returns (uint256) { + return withdrawInner(tranche, token, tokenAmount, baseAssets, sender, receiver, shouldSkipCooldown); + } + + function withdrawInner (address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address sender, address receiver, bool shouldSkipCooldown) internal returns (uint256) { uint256 shares = sUSDe.previewWithdraw(baseAssets); if (token == address(sUSDe)) { - uint256 cooldownSeconds = cdo.isJrt (tranche) ? sUSDeCooldownJrt : sUSDeCooldownSrt; + uint256 cooldownSeconds = shouldSkipCooldown ? 0 : (cdo.isJrt (tranche) ? sUSDeCooldownJrt : sUSDeCooldownSrt); erc20Cooldown.transfer(sUSDe, sender, receiver, shares, cooldownSeconds); return shares; } diff --git a/contracts/tranches/strategies/morpho/IMetaMorpho.sol b/contracts/tranches/strategies/morpho/IMetaMorpho.sol new file mode 100644 index 0000000..f7ed0fe --- /dev/null +++ b/contracts/tranches/strategies/morpho/IMetaMorpho.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.5.0; + +import {IMorpho, Id, MarketParams} from "@metamorpho/morpho-blue/interfaces/IMorpho.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; + +import {MarketConfig, PendingUint192, PendingAddress} from "./PendingLib.sol"; + +struct MarketAllocation { + /// @notice The market to allocate. + MarketParams marketParams; + /// @notice The amount of assets to allocate. + uint256 assets; +} + +interface IMulticall { + function multicall(bytes[] calldata) external returns (bytes[] memory); +} + +interface IOwnable { + function owner() external view returns (address); + function transferOwnership(address) external; + function renounceOwnership() external; + function acceptOwnership() external; + function pendingOwner() external view returns (address); +} + +/// @dev This interface is used for factorizing IMetaMorphoStaticTyping and IMetaMorpho. +/// @dev Consider using the IMetaMorpho interface instead of this one. +interface IMetaMorphoBase { + /// @notice The address of the Morpho contract. + function MORPHO() external view returns (IMorpho); + + /// @notice OpenZeppelin decimals offset used by the ERC4626 implementation. + /// @dev Calculated to be max(0, 18 - underlyingDecimals). + /// @dev When equal to zero (<=> token decimals >= 18), the protection against the inflation front-running attack on + /// empty vault is low (see https://docs.openzeppelin.com/contracts/5.x/erc4626#inflation-attack). To protect + /// against this attack, vault deployers should make an initial deposit of a non-trivial amount in the vault or + /// depositors should check that the share price does not exceed a certain limit. + function DECIMALS_OFFSET() external view returns (uint8); + + /// @notice The address of the curator. + function curator() external view returns (address); + + /// @notice Stores whether an address is an allocator or not. + function isAllocator(address target) external view returns (bool); + + /// @notice The current guardian. Can be set even without the timelock set. + function guardian() external view returns (address); + + /// @notice The current fee. + function fee() external view returns (uint96); + + /// @notice The fee recipient. + function feeRecipient() external view returns (address); + + /// @notice The skim recipient. + function skimRecipient() external view returns (address); + + /// @notice The current timelock. + function timelock() external view returns (uint256); + + /// @dev Stores the order of markets on which liquidity is supplied upon deposit. + /// @dev Can contain any market. A market is skipped as soon as its supply cap is reached. + function supplyQueue(uint256) external view returns (Id); + + /// @notice Returns the length of the supply queue. + function supplyQueueLength() external view returns (uint256); + + /// @dev Stores the order of markets from which liquidity is withdrawn upon withdrawal. + /// @dev Always contain all non-zero cap markets as well as all markets on which the vault supplies liquidity, + /// without duplicate. + function withdrawQueue(uint256) external view returns (Id); + + /// @notice Returns the length of the withdraw queue. + function withdrawQueueLength() external view returns (uint256); + + /// @notice Stores the total assets managed by this vault when the fee was last accrued. + /// @dev May be greater than `totalAssets()` due to removal of markets with non-zero supply or socialized bad debt. + /// This difference will decrease the fee accrued until one of the functions updating `lastTotalAssets` is + /// triggered (deposit/mint/withdraw/redeem/setFee/setFeeRecipient). + function lastTotalAssets() external view returns (uint256); + + /// @notice Submits a `newTimelock`. + /// @dev Warning: Reverts if a timelock is already pending. Revoke the pending timelock to overwrite it. + /// @dev In case the new timelock is higher than the current one, the timelock is set immediately. + function submitTimelock(uint256 newTimelock) external; + + /// @notice Accepts the pending timelock. + function acceptTimelock() external; + + /// @notice Revokes the pending timelock. + /// @dev Does not revert if there is no pending timelock. + function revokePendingTimelock() external; + + /// @notice Submits a `newSupplyCap` for the market defined by `marketParams`. + /// @dev Warning: Reverts if a cap is already pending. Revoke the pending cap to overwrite it. + /// @dev Warning: Reverts if a market removal is pending. + /// @dev In case the new cap is lower than the current one, the cap is set immediately. + function submitCap(MarketParams memory marketParams, uint256 newSupplyCap) external; + + /// @notice Accepts the pending cap of the market defined by `marketParams`. + function acceptCap(MarketParams memory marketParams) external; + + /// @notice Revokes the pending cap of the market defined by `id`. + /// @dev Does not revert if there is no pending cap. + function revokePendingCap(Id id) external; + + /// @notice Submits a forced market removal from the vault, eventually losing all funds supplied to the market. + /// @notice This forced removal is expected to be used as an emergency process in case a market constantly reverts. + /// To softly remove a sane market, the curator role is expected to bundle a reallocation that empties the market + /// first (using `reallocate`), followed by the removal of the market (using `updateWithdrawQueue`). + /// @dev Warning: Removing a market with non-zero supply will instantly impact the vault's price per share. + /// @dev Warning: Reverts for non-zero cap or if there is a pending cap. Successfully submitting a zero cap will + /// prevent such reverts. + function submitMarketRemoval(MarketParams memory marketParams) external; + + /// @notice Revokes the pending removal of the market defined by `id`. + /// @dev Does not revert if there is no pending market removal. + function revokePendingMarketRemoval(Id id) external; + + /// @notice Submits a `newGuardian`. + /// @notice Warning: a malicious guardian could disrupt the vault's operation, and would have the power to revoke + /// any pending guardian. + /// @dev In case there is no guardian, the gardian is set immediately. + /// @dev Warning: Submitting a gardian will overwrite the current pending gardian. + function submitGuardian(address newGuardian) external; + + /// @notice Accepts the pending guardian. + function acceptGuardian() external; + + /// @notice Revokes the pending guardian. + function revokePendingGuardian() external; + + /// @notice Skims the vault `token` balance to `skimRecipient`. + function skim(address) external; + + /// @notice Sets `newAllocator` as an allocator or not (`newIsAllocator`). + function setIsAllocator(address newAllocator, bool newIsAllocator) external; + + /// @notice Sets `curator` to `newCurator`. + function setCurator(address newCurator) external; + + /// @notice Sets the `fee` to `newFee`. + function setFee(uint256 newFee) external; + + /// @notice Sets `feeRecipient` to `newFeeRecipient`. + function setFeeRecipient(address newFeeRecipient) external; + + /// @notice Sets `skimRecipient` to `newSkimRecipient`. + function setSkimRecipient(address newSkimRecipient) external; + + /// @notice Sets `supplyQueue` to `newSupplyQueue`. + /// @param newSupplyQueue is an array of enabled markets, and can contain duplicate markets, but it would only + /// increase the cost of depositing to the vault. + function setSupplyQueue(Id[] calldata newSupplyQueue) external; + + /// @notice Updates the withdraw queue. Some markets can be removed, but no market can be added. + /// @notice Removing a market requires the vault to have 0 supply on it, or to have previously submitted a removal + /// for this market (with the function `submitMarketRemoval`). + /// @notice Warning: Anyone can supply on behalf of the vault so the call to `updateWithdrawQueue` that expects a + /// market to be empty can be griefed by a front-run. To circumvent this, the allocator can simply bundle a + /// reallocation that withdraws max from this market with a call to `updateWithdrawQueue`. + /// @dev Warning: Removing a market with supply will decrease the fee accrued until one of the functions updating + /// `lastTotalAssets` is triggered (deposit/mint/withdraw/redeem/setFee/setFeeRecipient). + /// @dev Warning: `updateWithdrawQueue` is not idempotent. Submitting twice the same tx will change the queue twice. + /// @param indexes The indexes of each market in the previous withdraw queue, in the new withdraw queue's order. + function updateWithdrawQueue(uint256[] calldata indexes) external; + + /// @notice Reallocates the vault's liquidity so as to reach a given allocation of assets on each given market. + /// @dev The behavior of the reallocation can be altered by state changes, including: + /// - Deposits on the vault that supplies to markets that are expected to be supplied to during reallocation. + /// - Withdrawals from the vault that withdraws from markets that are expected to be withdrawn from during + /// reallocation. + /// - Donations to the vault on markets that are expected to be supplied to during reallocation. + /// - Withdrawals from markets that are expected to be withdrawn from during reallocation. + /// @dev Sender is expected to pass `assets = type(uint256).max` with the last MarketAllocation of `allocations` to + /// supply all the remaining withdrawn liquidity, which would ensure that `totalWithdrawn` = `totalSupplied`. + /// @dev A supply in a reallocation step will make the reallocation revert if the amount is greater than the net + /// amount from previous steps (i.e. total withdrawn minus total supplied). + function reallocate(MarketAllocation[] calldata allocations) external; +} + +/// @dev This interface is inherited by MetaMorpho so that function signatures are checked by the compiler. +/// @dev Consider using the IMetaMorpho interface instead of this one. +interface IMetaMorphoStaticTyping is IMetaMorphoBase { + /// @notice Returns the current configuration of each market. + function config(Id) external view returns (uint184 cap, bool enabled, uint64 removableAt); + + /// @notice Returns the pending guardian. + function pendingGuardian() external view returns (address guardian, uint64 validAt); + + /// @notice Returns the pending cap for each market. + function pendingCap(Id) external view returns (uint192 value, uint64 validAt); + + /// @notice Returns the pending timelock. + function pendingTimelock() external view returns (uint192 value, uint64 validAt); +} + +/// @title IMetaMorpho +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @dev Use this interface for MetaMorpho to have access to all the functions with the appropriate function signatures. +interface IMetaMorpho is IMetaMorphoBase, IERC4626, IERC20Permit, IOwnable, IMulticall { + /// @notice Returns the current configuration of each market. + function config(Id) external view returns (MarketConfig memory); + + /// @notice Returns the pending guardian. + function pendingGuardian() external view returns (PendingAddress memory); + + /// @notice Returns the pending cap for each market. + function pendingCap(Id) external view returns (PendingUint192 memory); + + /// @notice Returns the pending timelock. + function pendingTimelock() external view returns (PendingUint192 memory); +} diff --git a/contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol b/contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol new file mode 100644 index 0000000..6bf3a9e --- /dev/null +++ b/contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IStrategyAprPairProvider} from "../../interfaces/IAprPairFeed.sol"; +import {IMetaMorpho} from "./IMetaMorpho.sol"; +import {Id, MarketParams, Market, IMorpho} from "@metamorpho/morpho-blue/interfaces/IMorpho.sol"; +import {MathLib, WAD} from "@metamorpho/morpho-blue/libraries/MathLib.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SharesMathLib} from "@metamorpho/morpho-blue/libraries/SharesMathLib.sol"; +import {MorphoLib} from "@metamorpho/morpho-blue/libraries/periphery/MorphoLib.sol"; +import {MarketParamsLib} from "@metamorpho/morpho-blue/libraries/MarketParamsLib.sol"; +import {MorphoBalancesLib} from "@metamorpho/morpho-blue/libraries/periphery/MorphoBalancesLib.sol"; +import {UtilsLib} from "@metamorpho/morpho-blue/libraries/UtilsLib.sol"; +import {IIrm} from "@metamorpho/morpho-blue/interfaces/IIrm.sol"; + +/// @title MorphoAprPairProvider +/// @notice Provides APR pair data from MetaMorpho vaults +/// @dev APRs are returned in SD7x12 format (scaled by 1e12) +contract MorphoAprPairProvider is IStrategyAprPairProvider { + using SharesMathLib for uint256; + using MathLib for uint256; + using Math for uint256; + using MarketParamsLib for MarketParams; + using MorphoLib for IMorpho; + using MorphoBalancesLib for IMorpho; + using UtilsLib for uint256; + + /// @notice The Morpho Blue contract + IMorpho public immutable morpho; + + /// @notice The MetaMorpho vault used as base APR source + address public immutable baseVault; + + /// @notice The MetaMorpho vault used as target APR source (optional, can be same as baseVault) + address public immutable targetVault; + + /// @notice Scale factor for converting from WAD (1e18) to SD7x12 (1e12) + uint256 private constant WAD_TO_SD7X12 = 1e6; + + /// @param morphoAddress The Morpho Blue contract address + /// @param baseVault_ The MetaMorpho vault for base APR + /// @param targetVault_ The MetaMorpho vault for target APR (use address(0) for same as base) + constructor(address morphoAddress, address baseVault_, address targetVault_) { + require(morphoAddress != address(0), "Morpho address cannot be 0"); + require(baseVault_ != address(0), "Base vault address cannot be 0"); + + morpho = IMorpho(morphoAddress); + baseVault = baseVault_; + targetVault = targetVault_ == address(0) ? baseVault_ : targetVault_; + } + + /// @notice Returns the APR pair (target and base) from the configured vaults + /// @return aprTarget The target APR scaled by 1e12 + /// @return aprBase The base APR scaled by 1e12 + /// @return timestamp The current block timestamp + function getAprPair() external view returns (int64 aprTarget, int64 aprBase, uint64 timestamp) { + timestamp = uint64(block.timestamp); + aprTarget = getAPRtarget(); + aprBase = getAPRbase(); + } + + /// @notice Calculates the target APR from the target vault + /// @return The target APR as int64, scaled by 1e12 + function getAPRtarget() public view returns (int64) { + uint256 apyWad = supplyAPYVaultV1(targetVault); + // Convert from WAD (1e18) to SD7x12 (1e12) + uint256 apr = apyWad / WAD_TO_SD7X12; + return int64(int256(apr)); + } + + /// @notice Calculates the base APR from the base vault + /// @return The base APR as int64, scaled by 1e12 + function getAPRbase() public view returns (int64) { + uint256 apyWad = supplyAPYVaultV1(baseVault); + // Convert from WAD (1e18) to SD7x12 (1e12) + uint256 apr = apyWad / WAD_TO_SD7X12; + return int64(int256(apr)); + } + + /// @notice Returns the total assets supplied into a specific morpho blue market by a MetaMorpho `vault`. + /// @param vault The address of the MetaMorpho vault. + /// @param marketParams The morpho blue market. + function vaultAssetsInMarket(address vault, MarketParams memory marketParams) public view returns (uint256 assets) { + assets = morpho.expectedSupplyAssets(marketParams, vault); + } + + /// @notice Returns the current APY of a Morpho Blue market. + /// @param marketParams The morpho blue market parameters. + /// @param market The morpho blue market state. + function supplyAPYMarketV1(MarketParams memory marketParams, Market memory market) + public + view + returns (uint256 supplyApy) + { + // Get the borrow rate + uint256 borrowRate; + if (marketParams.irm == address(0)) { + return 0; + } else { + borrowRate = IIrm(marketParams.irm).borrowRateView(marketParams, market).wTaylorCompounded(365 days); + } + + (uint256 totalSupplyAssets,, uint256 totalBorrowAssets,) = morpho.expectedMarketBalances(marketParams); + + // Get the supply rate + uint256 utilization = totalBorrowAssets == 0 ? 0 : totalBorrowAssets.wDivUp(totalSupplyAssets); + supplyApy = borrowRate.wMulDown(1 ether - market.fee).wMulDown(utilization); + } + + /// @notice Returns the current APY of a MetaMorpho vault. + /// @dev It is computed as the sum of all APY of enabled markets weighted by the supply on these markets. + /// @param vault The address of the MetaMorpho vault. + function supplyAPYVaultV1(address vault) public view returns (uint256 avgSupplyApy) { + uint256 ratio; + uint256 queueLength = IMetaMorpho(vault).withdrawQueueLength(); + + uint256 totalAmount = IMetaMorpho(vault).totalAssets(); + if (totalAmount == 0) return 0; + + for (uint256 i; i < queueLength; ++i) { + Id idMarket = IMetaMorpho(vault).withdrawQueue(i); + + MarketParams memory marketParams = morpho.idToMarketParams(idMarket); + Market memory market = morpho.market(idMarket); + + uint256 currentSupplyAPY = supplyAPYMarketV1(marketParams, market); + uint256 vaultAsset = vaultAssetsInMarket(vault, marketParams); + ratio += currentSupplyAPY.wMulDown(vaultAsset); + } + + avgSupplyApy = ratio.mulDivDown(WAD - IMetaMorpho(vault).fee(), totalAmount); + } +} diff --git a/contracts/tranches/strategies/morpho/MorphoStrategy.sol b/contracts/tranches/strategies/morpho/MorphoStrategy.sol new file mode 100644 index 0000000..bd87d24 --- /dev/null +++ b/contracts/tranches/strategies/morpho/MorphoStrategy.sol @@ -0,0 +1,476 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IErrors} from "../../interfaces/IErrors.sol"; +import {IStrataCDO} from "../../interfaces/IStrataCDO.sol"; +import {IERC20Cooldown} from "../../interfaces/cooldown/ICooldown.sol"; +import {IDistributor} from "../../interfaces/IDistributor.sol"; +import {ISwapContract} from "../../interfaces/ISwapContract.sol"; +import {Strategy} from "../../Strategy.sol"; + +contract MorphoStrategy is Strategy { + IERC4626 public immutable morphoVault; + IERC20 public immutable asset; + + IERC20Cooldown public erc20Cooldown; + + /// @notice Merkl distributor contract for claiming rewards + IDistributor public distributor; + + /// @notice SwapContract for swapping rewards to asset + ISwapContract public swapContract; + + /** + * configuration + */ + uint256 public vaultCooldownJrt; + uint256 public vaultCooldownSrt; + + /** + * @notice Vesting configuration for reward deposits + * @dev VestedUSDC tracks the total amount of USDC deposited from rewards that is still vesting. + * This amount decreases linearly over the vesting duration. + */ + uint256 public vestedUSDC; + uint256 public vestingDuration; + uint256 public lastVestingUpdate; + + event CooldownsChanged(uint256 jrt, uint256 srt); + event RewardsClaimed(address indexed rewardToken, uint256 rewardAmount, uint256 assetReceived); + event DistributorUpdated(address indexed distributor); + event SwapContractUpdated(address indexed swapContract); + event VestingDurationUpdated(uint256 newDuration); + + constructor(IERC4626 vault_, IDistributor distributor_, ISwapContract swapContract_, uint256 vestingDuration_) { + morphoVault = vault_; + asset = IERC20(vault_.asset()); + distributor = distributor_; + swapContract = swapContract_; + vestingDuration = vestingDuration_; + } + + function initialize(address owner_, address acm_, IStrataCDO cdo_, IERC20Cooldown erc20Cooldown_) + public + virtual + initializer + { + AccessControlled_init(owner_, acm_); + + cdo = cdo_; + erc20Cooldown = erc20Cooldown_; + + SafeERC20.forceApprove(morphoVault, address(erc20Cooldown), type(uint256).max); + + // Initialize vesting state + lastVestingUpdate = block.timestamp; + } + + /** + * @notice Processes asset deposits for the CDO contract. + * @dev This method is called by the CDO contract to handle asset deposits. + * If the deposited token is the base asset, it will be deposited to receive vault shares. + * If the deposited token is already vault shares, it will be accepted as is. + * @param tranche The address of the tranche depositing assets (not used in this strategy) + * @param token The address of the token being deposited + * @param tokenAmount The amount of tokens being deposited + * @param baseAssets The amount of base assets represented by the deposit (used for vault deposits) + * @param owner The address of the asset owner from whom to transfer tokens + * @return The amount of base assets received after deposit + */ + function deposit(address tranche, address token, uint256 tokenAmount, uint256 baseAssets, address owner) + external + onlyCDO + returns (uint256) + { + SafeERC20.safeTransferFrom(IERC20(token), owner, address(this), tokenAmount); + + if (token == address(asset)) { + SafeERC20.forceApprove(asset, address(morphoVault), tokenAmount); + morphoVault.deposit(tokenAmount, address(this)); + return tokenAmount; + } + if (token == address(morphoVault)) { + // already transferred in ↑ + return baseAssets; + } + revert UnsupportedToken(token); + } + + /** + * @notice Processes asset withdrawals for the CDO contract. + * @dev This method is called by the CDO contract to handle asset withdrawals. + * If withdrawing vault shares, a cooldown period is applied based on the tranche type. + * If withdrawing the base asset, the vault shares are redeemed directly (no cooldown mechanism). + * An overloaded version accepts a shouldSkipCooldown parameter to skip the cooldown for vault share withdrawals. + * @param tranche The address of the tranche withdrawing assets + * @param token The address of the token to be withdrawn + * @param tokenAmount The amount of tokens to be withdrawn (not used in this implementation) + * @param baseAssets The amount of base assets to be withdrawn + * @param receiver The address that will receive the withdrawn assets + * @param sender The account that initiated the withdrawal + * @return The amount of tokens withdrawn (shares for vault, baseAssets for base asset) + */ + function withdraw( + address tranche, + address token, + uint256 tokenAmount, + uint256 baseAssets, + address sender, + address receiver + ) external onlyCDO returns (uint256) { + return withdrawInner(tranche, token, tokenAmount, baseAssets, sender, receiver, false); + } + + function withdraw( + address tranche, + address token, + uint256 tokenAmount, + uint256 baseAssets, + address sender, + address receiver, + bool shouldSkipCooldown + ) external onlyCDO returns (uint256) { + return withdrawInner(tranche, token, tokenAmount, baseAssets, sender, receiver, shouldSkipCooldown); + } + + function withdrawInner( + address tranche, + address token, + uint256 tokenAmount, + uint256 baseAssets, + address sender, + address receiver, + bool shouldSkipCooldown + ) internal returns (uint256) { + uint256 shares = morphoVault.previewWithdraw(baseAssets); + if (token == address(morphoVault)) { + uint256 cooldownSeconds = + shouldSkipCooldown ? 0 : (cdo.isJrt(tranche) ? vaultCooldownJrt : vaultCooldownSrt); + erc20Cooldown.transfer(morphoVault, sender, receiver, shares, cooldownSeconds); + return shares; + } + if (token == address(asset)) { + // Morpho allows direct withdrawal - no cooldown needed + morphoVault.withdraw(baseAssets, receiver, address(this)); + return baseAssets; + } + revert UnsupportedToken(token); + } + + /** + * @notice Allows the CDO to withdraw tokens from the strategy's reserve + * @dev This function is part of the reserve reduction process and can only be called by the CDO. + * It handles both vault shares and base asset tokens, applying different transfer mechanisms for each. + * For vault shares, it uses erc20Cooldown with no cooldown period. + * For base asset, it withdraws directly from the Morpho vault. + * @param token The address of the token to be withdrawn (either vault shares or base asset) + * @param tokenAmount The amount of tokens to be withdrawn + * @param receiver The address that will receive the withdrawn tokens + */ + function reduceReserve(address token, uint256 tokenAmount, address receiver) external onlyCDO { + if (token == address(morphoVault)) { + erc20Cooldown.transfer(morphoVault, receiver, receiver, tokenAmount, 0); + return; + } + if (token == address(asset)) { + // Direct withdrawal from Morpho vault + morphoVault.withdraw(tokenAmount, receiver, address(this)); + return; + } + revert UnsupportedToken(token); + } + + /** + * @notice Calculates the current unvested USDC amount + * @dev Returns the amount of USDC that is still vesting, decreasing linearly over time + * @return The amount of unvested USDC + */ + function getUnvestedUSDC() public view returns (uint256) { + if (vestedUSDC == 0 || vestingDuration == 0) { + return 0; + } + + uint256 elapsed = block.timestamp > lastVestingUpdate ? block.timestamp - lastVestingUpdate : 0; + + if (elapsed >= vestingDuration) { + return 0; // Fully vested + } + + // Linear vesting: unvested = vestedUSDC * (1 - elapsed / duration) + // Using: unvested = vestedUSDC * (vestingDuration - elapsed) / vestingDuration + return (vestedUSDC * (vestingDuration - elapsed)) / vestingDuration; + } + + /** + * @notice Updates the vesting state by applying time decay + * @dev This should be called before modifying vestedUSDC to apply time-based decay. + * When vesting occurs, the newly vested amount is transferred to the junior vault + * so that users can withdraw their money back. + */ + function _updateVesting() internal { + uint256 vestedUSDCBefore = vestedUSDC; + + if (vestedUSDC > 0 && vestingDuration > 0 && lastVestingUpdate > 0) { + uint256 elapsed = block.timestamp > lastVestingUpdate ? block.timestamp - lastVestingUpdate : 0; + + if (elapsed >= vestingDuration) { + // Fully vested, reset to zero + vestedUSDC = 0; + } else { + // Apply linear decay + vestedUSDC = (vestedUSDC * (vestingDuration - elapsed)) / vestingDuration; + } + } + lastVestingUpdate = block.timestamp; + + // Calculate newly vested amount and transfer to junior vault + if (vestedUSDCBefore > vestedUSDC) { + uint256 newlyVested = vestedUSDCBefore - vestedUSDC; + _transferVestedToJuniorVault(newlyVested); + } + } + + /** + * @notice Transfers newly vested assets to the junior vault + * @dev Uses USDC already in the strategy contract (from current reward claims after swapping). + * Transfers assets directly to the vault to increase its total value without minting shares. + * The vestedUSDC is kept as USDC in the strategy contract, not deposited to the vault. + * If the junior vault is not yet configured, the transfer is skipped. + * @param newlyVested The amount of newly vested base assets to transfer + */ + function _transferVestedToJuniorVault(uint256 newlyVested) internal { + if (newlyVested == 0) return; + + // Skip if junior vault is not configured yet + if (address(cdo.jrtVault()) == address(0)) { + return; + } + + // The USDC should already be in the strategy contract from reward claims + // Transfer directly to the vault to increase its total value without minting shares + SafeERC20.safeTransfer(asset, address(cdo.jrtVault()), newlyVested); + } + + /** + * @notice Calculates the total assets managed by this strategy + * @dev This function returns the current value of the strategy's assets in the base asset. + * @return baseAssets The total amount of base asset managed by this strategy + */ + function totalAssets() external view returns (uint256 baseAssets) { + uint256 shares = morphoVault.balanceOf(address(this)); + baseAssets = morphoVault.previewRedeem(shares); + + // Add USDC balance in strategy contract (where vestedUSDC is held) + uint256 usdcBalance = asset.balanceOf(address(this)); + baseAssets += usdcBalance; + + // Subtract unvested USDC from rewards + uint256 unvested = getUnvestedUSDC(); + if (baseAssets > unvested) { + baseAssets -= unvested; + } else { + baseAssets = 0; + } + + return baseAssets; + } + + /** + * @notice Converts a given amount of supported tokens to their equivalent in the base asset + * @dev This function handles conversion for both vault shares and base asset tokens. + * For vault shares, it uses the vault's exchange rate, considering the rounding direction. + * For base asset, it returns the input amount as is. + * @param token The address of the token to convert (either vault shares or base asset) + * @param tokenAmount The amount of tokens to convert + * @param rounding The rounding direction to use for the conversion (floor or ceiling) + * @return The equivalent amount in the base asset + */ + function convertToAssets(address token, uint256 tokenAmount, Math.Rounding rounding) + external + view + returns (uint256) + { + if (token == address(morphoVault)) { + return rounding == Math.Rounding.Floor + ? morphoVault.previewRedeem(tokenAmount) // aka convertToAssets(tokenAmount) + : morphoVault.previewMint(tokenAmount); + } + if (token == address(asset)) { + return tokenAmount; + } + revert UnsupportedToken(token); + } + + /** + * @notice Converts a given amount of base assets to the equivalent amount of supported tokens + * @dev This function handles conversion for both vault shares and base asset tokens. + * For vault shares, it uses the vault's exchange rate, considering the rounding direction. + * For base asset, it returns the input amount as is. + * @param token The address of the token to convert to (either vault shares or base asset) + * @param baseAssets The amount of base assets to convert + * @param rounding The rounding direction to use for the conversion (floor or ceiling) + * @return The equivalent amount in the requested token (vault shares or base asset) + */ + function convertToTokens(address token, uint256 baseAssets, Math.Rounding rounding) + external + view + returns (uint256) + { + if (token == address(morphoVault)) { + return rounding == Math.Rounding.Floor + ? morphoVault.previewDeposit(baseAssets) // aka convertToShares(baseAssets) + : morphoVault.previewWithdraw(baseAssets); + } + if (token == address(asset)) { + return baseAssets; + } + revert UnsupportedToken(token); + } + + /** + * @notice Returns an array of supported tokens: vault shares and base asset + */ + function getSupportedTokens() external view returns (IERC20[] memory) { + IERC20[] memory tokens = new IERC20[](2); + tokens[0] = IERC20(address(morphoVault)); + tokens[1] = asset; + return tokens; + } + + /** + * @notice Updates the cooldown periods for vault share withdrawals + */ + function setCooldowns(uint256 vaultCooldownJrt_, uint256 vaultCooldownSrt_) + external + onlyRole(UPDATER_STRAT_CONFIG_ROLE) + { + uint256 WEEK = 7 days; + if (vaultCooldownJrt_ > WEEK || vaultCooldownSrt_ > WEEK) { + revert InvalidConfigCooldown(); + } + vaultCooldownJrt = vaultCooldownJrt_; + vaultCooldownSrt = vaultCooldownSrt_; + + bool isDisabled = vaultCooldownJrt_ == 0 && vaultCooldownSrt_ == 0; + erc20Cooldown.setCooldownDisabled(morphoVault, isDisabled); + emit CooldownsChanged(vaultCooldownJrt_, vaultCooldownSrt_); + } + + /** + * @notice Sets the Merkl distributor contract address + * @param distributor_ The address of the Merkl distributor contract + */ + function setDistributor(IDistributor distributor_) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { + distributor = distributor_; + emit DistributorUpdated(address(distributor_)); + } + + /** + * @notice Sets the SwapContract address for swapping rewards + * @param swapContract_ The address of the SwapContract + */ + function setSwapContract(ISwapContract swapContract_) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { + swapContract = swapContract_; + emit SwapContractUpdated(address(swapContract_)); + } + + /** + * @notice Claims rewards from the Merkl distributor, swaps them to the base asset, and deposits to Junior Vault + * @dev This function claims rewards for this contract, swaps them to the base asset using the SwapContract, + * and deposits the resulting assets into the Morpho vault. The increased vault shares benefit the + * strategy's totalAssets which flows through CDO accounting to the Junior tranche. + * @param tokens Array of reward token addresses to claim + * @param amounts Array of cumulative amounts earned (from Merkle tree) + * @param proofs Array of Merkle proofs for each claim + * @param poolKeysData Array of ABI-encoded PoolKey structs for swapping each reward token to base asset + * @param zeroForOnes Array of swap directions for each token + * @param minAmountsOut Array of minimum base asset amounts expected from each swap (slippage protection) + * @param deadline Timestamp after which the swaps will revert + * @return totalAssetReceived Total base asset received from all swaps and deposited to vault + */ + function claimRewards( + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs, + bytes[] calldata poolKeysData, + bool[] calldata zeroForOnes, + uint128[] calldata minAmountsOut, + uint256 deadline + ) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) returns (uint256 totalAssetReceived) { + uint256 length = tokens.length; + require( + length == amounts.length && length == proofs.length && length == poolKeysData.length + && length == zeroForOnes.length && length == minAmountsOut.length, + "Array length mismatch" + ); + require(address(distributor) != address(0), "Distributor not set"); + require(address(swapContract) != address(0), "SwapContract not set"); + + // Build the users array - all claims are for this contract + address[] memory users = new address[](length); + for (uint256 i = 0; i < length; i++) { + users[i] = address(this); + } + + // Claim rewards from the distributor + distributor.claim(users, tokens, amounts, proofs); + + // Swap each reward token to the base asset + for (uint256 i = 0; i < length; i++) { + address rewardToken = tokens[i]; + + // Skip if the reward is already the base asset + if (rewardToken == address(asset)) { + uint256 assetBalance = asset.balanceOf(address(this)); + totalAssetReceived += assetBalance; + emit RewardsClaimed(rewardToken, assetBalance, assetBalance); + continue; + } + + // Get the balance of the reward token we just claimed + uint256 rewardBalance = IERC20(rewardToken).balanceOf(address(this)); + if (rewardBalance == 0) continue; + + // Approve the SwapContract to spend the reward tokens + SafeERC20.forceApprove(IERC20(rewardToken), address(swapContract), rewardBalance); + + // Swap the reward token to the base asset + uint256 assetReceived = swapContract.swapWithEncodedKey( + poolKeysData[i], zeroForOnes[i], uint128(rewardBalance), minAmountsOut[i], deadline, bytes("") + ); + + totalAssetReceived += assetReceived; + emit RewardsClaimed(rewardToken, rewardBalance, assetReceived); + } + + // Handle vesting - vestedUSDC is kept as USDC in the strategy contract + // This benefits the Junior tranche through the CDO accounting mechanism + if (totalAssetReceived > 0) { + // Update vesting state - this will transfer newly vested amounts to junior vault + // using the USDC currently in the strategy contract from this claim + _updateVesting(); + + // Track the new reward deposit as vested USDC (stays as USDC in strategy, not in vault) + vestedUSDC += totalAssetReceived; + } + + return totalAssetReceived; + } + + /** + * @notice Sets the vesting duration for reward deposits + * @param vestingDuration_ The duration in seconds over which rewards vest (decrease to 0) + */ + function setVestingDuration(uint256 vestingDuration_) external onlyRole(UPDATER_STRAT_CONFIG_ROLE) { + // Update vesting state before changing duration + _updateVesting(); + + vestingDuration = vestingDuration_; + emit VestingDurationUpdated(vestingDuration_); + } +} + diff --git a/contracts/tranches/strategies/morpho/PendingLib.sol b/contracts/tranches/strategies/morpho/PendingLib.sol new file mode 100644 index 0000000..45be617 --- /dev/null +++ b/contracts/tranches/strategies/morpho/PendingLib.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +struct MarketConfig { + /// @notice The maximum amount of assets that can be allocated to the market. + /// @dev The exposure to a given market can go beyond the cap in case of interest or donations. + uint184 cap; + /// @notice Whether the market is in the withdraw queue. + bool enabled; + /// @notice The timestamp at which the market can be instantly removed from the withdraw queue. + uint64 removableAt; +} + +struct PendingUint192 { + /// @notice The pending value to set. + uint192 value; + /// @notice The timestamp at which the pending value becomes valid. + uint64 validAt; +} + +struct PendingAddress { + /// @notice The pending value to set. + address value; + /// @notice The timestamp at which the pending value becomes valid. + uint64 validAt; +} + +/// @title PendingLib +/// @author Morpho Labs +/// @custom:contact security@morpho.org +/// @notice Library to manage pending values and their validity timestamp. +library PendingLib { + /// @dev Updates `pending`'s value to `newValue` and its corresponding `validAt` timestamp. + /// @dev Assumes `timelock` <= `MAX_TIMELOCK`. + function update(PendingUint192 storage pending, uint184 newValue, uint256 timelock) internal { + pending.value = newValue; + // Safe "unchecked" cast because timelock <= MAX_TIMELOCK. + pending.validAt = uint64(block.timestamp + timelock); + } + + /// @dev Updates `pending`'s value to `newValue` and its corresponding `validAt` timestamp. + /// @dev Assumes `timelock` <= `MAX_TIMELOCK`. + function update(PendingAddress storage pending, address newValue, uint256 timelock) internal { + pending.value = newValue; + // Safe "unchecked" cast because timelock <= MAX_TIMELOCK. + pending.validAt = uint64(block.timestamp + timelock); + } +} diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..c433d6e --- /dev/null +++ b/foundry.lock @@ -0,0 +1,41 @@ +{ + "lib/chimera": { + "rev": "aaa856e70f2756fd3762bd7665262dba13ff0dd7" + }, + "lib/forge-std": { + "rev": "77041d2ce690e692d6e03cc812b57d1ddaa4d505" + }, + "lib/morpho-blue": { + "tag": { + "name": "v1.0.0", + "rev": "55d2d99304fb3fb930c688462ae2ccabb1d533ad" + } + }, + "lib/openzeppelin-contracts": { + "rev": "fcbae5394ae8ad52d8e580a3477db99814b9d565" + }, + "lib/permit2": { + "rev": "cc56ad0f3439c502c246fc5cfcc3db92bb8b7219" + }, + "lib/properties": { + "rev": "d346d8ed5c1a9f7deebe92fcbaf237b185fda326" + }, + "lib/setup-helpers": { + "rev": "9120629af5b6e8f22c78d596b1e1b00aac47bc5b" + }, + "lib/universal-router": { + "rev": "705f7bb9836ebc4f6ad1aad91629c3d0fc4128d4" + }, + "lib/v2-core": { + "rev": "4dd59067c76dea4a0e8e4bfdda41877a6b16dedc" + }, + "lib/v3-core": { + "rev": "e3589b192d0be27e100cd0daaf6c97204fdb1899" + }, + "lib/v4-core": { + "rev": "d153b048868a60c2403a3ef5b2301bb247884d46" + }, + "lib/v4-periphery": { + "rev": "3779387e5d296f39df543d23524b050f89a62917" + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 7716e0b..7023360 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,5 +3,6 @@ src = 'contracts' out = 'out' libs = ['node_modules', 'lib'] test = 'test' -cache_path = 'cache_forge' +cache_path = 'cache_forge' via_ir = true +evm_version = 'cancun' diff --git a/lib/morpho-blue b/lib/morpho-blue new file mode 160000 index 0000000..55d2d99 --- /dev/null +++ b/lib/morpho-blue @@ -0,0 +1 @@ +Subproject commit 55d2d99304fb3fb930c688462ae2ccabb1d533ad diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..fcbae53 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit fcbae5394ae8ad52d8e580a3477db99814b9d565 diff --git a/lib/permit2 b/lib/permit2 new file mode 160000 index 0000000..cc56ad0 --- /dev/null +++ b/lib/permit2 @@ -0,0 +1 @@ +Subproject commit cc56ad0f3439c502c246fc5cfcc3db92bb8b7219 diff --git a/lib/universal-router b/lib/universal-router new file mode 160000 index 0000000..705f7bb --- /dev/null +++ b/lib/universal-router @@ -0,0 +1 @@ +Subproject commit 705f7bb9836ebc4f6ad1aad91629c3d0fc4128d4 diff --git a/lib/v2-core b/lib/v2-core new file mode 160000 index 0000000..4dd5906 --- /dev/null +++ b/lib/v2-core @@ -0,0 +1 @@ +Subproject commit 4dd59067c76dea4a0e8e4bfdda41877a6b16dedc diff --git a/lib/v3-core b/lib/v3-core new file mode 160000 index 0000000..e3589b1 --- /dev/null +++ b/lib/v3-core @@ -0,0 +1 @@ +Subproject commit e3589b192d0be27e100cd0daaf6c97204fdb1899 diff --git a/lib/v4-core b/lib/v4-core new file mode 160000 index 0000000..d153b04 --- /dev/null +++ b/lib/v4-core @@ -0,0 +1 @@ +Subproject commit d153b048868a60c2403a3ef5b2301bb247884d46 diff --git a/lib/v4-periphery b/lib/v4-periphery new file mode 160000 index 0000000..3779387 --- /dev/null +++ b/lib/v4-periphery @@ -0,0 +1 @@ +Subproject commit 3779387e5d296f39df543d23524b050f89a62917 diff --git a/package.json b/package.json index 71809ff..54edabf 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "compile": "hardhat compile", "coverage": "hardhat clean && atma --max-old-space-size=12000 test --coverage", "coverage-nocompile": "atma --max-old-space-size=12000 test --coverage --no-compile", + "coverage-server": "cd coverage/report && npx atma server", "deploy-local": "atma act tasks/deploy.act.ts -q \"deploy and configure\" --chain hardhat", "fork-eth": "hardhat node --verbose --show-stack-traces --fork %MAINNET_RPC_URL%" }, diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..f963d4a --- /dev/null +++ b/remappings.txt @@ -0,0 +1,7 @@ +@uniswap/v4-core/=lib/v4-core/ +@uniswap/v4-periphery/=lib/v4-periphery/ +@uniswap/permit2/=lib/permit2/ +@uniswap/universal-router/=lib/universal-router/ +@uniswap/v3-core/=lib/v3-core/ +@uniswap/v2-core/=lib/v2-core/ +@metamorpho/morpho-blue/=lib/morpho-blue/src/ \ No newline at end of file diff --git a/specs/SIPs/SIP-01-share-lockup-redemption.md b/specs/SIPs/SIP-01-share-lockup-redemption.md new file mode 100644 index 0000000..bd34f55 --- /dev/null +++ b/specs/SIPs/SIP-01-share-lockup-redemption.md @@ -0,0 +1,235 @@ +--- +SIP: 01 +Title: Share Lock-Up Redemption Mechanism for Tranche Vaults +Author: Strata Protocol Contributors +Status: Final +Type: Protocol +Created: 2025-12-15 +--- + +# Tranche Vault — Share Lock-Up Redemption Specification + +## 1. Overview + +This specification defines a **share lock-up redemption mechanism** for Tranche Vaults (Junior and Senior tranches) that introduces a cooldown period on shares rather than on assets. + +Under this mechanism: + +* Users initiate a redemption request by transferring shares into a Silo Contract. +* Shares remain locked for a predefined lock-up period. +* After the lock-up period elapses, the user may finalize the redemption. +* The final exchange rate is determined at the time of finalization. +* Asset transfer occurs directly from the Tranche Vault at finalization. + +This design preserves ERC-4626 compatibility while enabling delayed liquidity and controlled redemption flows. + +--- + +## 2. Actors + +* **User**: An externally owned account (EOA) or contract initiating a redemption. +* **Receiver**: An account that receives the assets upon redemption. Per the ERC-4626 specification, the receiver MAY be different from the User. +* **Tranche Vault**: An ERC-4626-compatible vault managing Junior or Senior tranche shares. +* **Silo Contract**: - A contract responsible for holding locked shares and executing final redemption on behalf of users. +* **Strategy**: The underlying asset manager (may require unstaking prior to asset withdrawal, e.g., USDe). + +--- + +## 3. Current Redemption Flow (Reference) + +The current (non lock-up) redemption flow is as follows: + +1. User calls `redeem` or `withdraw` +2. Vault calculates redeemable assets (excluding fees) +3. Vault burns the corresponding shares +4. Vault transfers assets: + + * Directly to the receiver, or + * To an unstaking / cooldown contract used by the Strategy + +--- + +## 4. Lock-Up Redemption Flow (When Enabled) + +### 4.1 Redemption Request Phase + +1. User initiates a redemption request via `redeem` or `withdraw` +2. Instead of burning shares: + + * The Tranche Vault transfers the specified shares to the Silo Contract + +3. The Silo Contract records: + + * User address + * Receiver address + * Share amount + * Lock-up end timestamp + +At this stage: + +* No assets are transferred +* Only fee shares are burned to accrue fees in accounting +* The user may finalize the redemption before the lock-up period expires only if an ExitFee is specified for the Vault by the Protocol +* The user may cancel the lock-up; in this case, the request is dismissed and the shares are transferred back to the user. + +--- + +### 4.2 Finalization Phase + +After the lock-up period has elapsed: + +1. The user triggers finalization via the Silo Contract +2. The Silo Contract calls `redeem` on the Tranche Vault **on behalf of the user** +3. The Tranche Vault: + + * Detects that the caller is the Silo Contract + * Skips any lock-up or cooldown logic + * Skips fee accrual (if fees are active at the time of finalization) + * Processes the redemption as a direct ERC4626 redemption request +4. The Tranche Vault: + + * Burns the locked shares + * Calculates the redeemable assets using the **current exchange rate** +5. Asset handling: + + * If the asset is directly withdrawable, assets are transferred to the user + * If unstaking is required (e.g. USDe), the unstaking process is executed before transfer +6. During finalization, the user MAY specify the desired output asset (e.g. USDe or sUSDe) + +--- + +## 5. Exchange Rate Determination + +* The exchange rate used for redemption is determined **at the time of finalization** +* No price or asset amount is snapshotted at request time +* Users remain fully exposed to vault performance (positive or negative) during the lock-up period + +--- + +## 6. Lock-Up Duration and Fees + +The protocol MAY apply a **coverage-dependent lock-up period** and associated fees to share redemptions when the Share Lock-Up mechanism is enabled. + +All parameters described in this section are **evaluated at redemption request time** and remain immutable for the lifetime of the corresponding lock-up entry. + +### 6.1 Lock-Up Duration + +The protocol MAY define up to **two coverage thresholds** (`C₀`, `C₁`) that partition the coverage space into **three mutually exclusive ranges**, each associated with a predefined lock-up duration. + +Let `Coverage` be the current coverage ratio at redemption request time. + +| Coverage Range | Applicable Lock-Up Duration | +| -------------------- | --------------------------- | +| `Coverage ≤ C₀` | `lockupSeconds[0]` | +| `C₀ < Coverage ≤ C₁` | `lockupSeconds[1]` | +| `Coverage > C₁` | `lockupSeconds[2]` | + +Properties: + +* Coverage thresholds MUST be strictly increasing (`C₀ < C₁`) or equal (`C₀ == C₁`) - this effectively disables the `C₁` range +* Each range maps to exactly one lock-up duration +* A lock-up duration of `0` seconds represents **immediate finalization eligibility** +* The selected lock-up duration is recorded in the Silo Contract at request time and MUST NOT change afterwards + +--- + +### 6.2 Redemption Fee (Request-Time Fee) + +The protocol MAY apply a **redemption fee at request time**, expressed as a percentage of shares being redeemed. + +Characteristics: + +* The applicable fee tier is determined using the **same coverage range selection** as defined in Section 6.1 +* The fee is accrued **immediately at redemption request** +* Fee collection is implemented via burning and assets distribution according to the accounting rules +* The fee is independent of whether the redemption is later finalized, cancelled, or early-exited + +--- + +### 6.3 Early-Exit Fee + +If enabled by the protocol, a user MAY finalize a redemption **before the lock-up period has elapsed** by paying an **early-exit fee**. + +Early-exit fee rules: + +* The fee is calculated based on the **remaining lock-up time** at the moment of finalization +* The fee rate is defined as a **per-day penalty** +* Early-exit fees are applied **in addition to** any redemption fee already accrued at request time + +If early-exit is disabled: + +* Finalization attempts prior to lock-up expiry MUST revert + +--- + +## 7. Redemption Pause Handling (`PAUSER_ROLE`) + +* If redemptions are fully paused at the Tranche Vault: + + * Finalization requests from the Silo Contract MUST revert + * Locked shares remain held in the Silo Contract until redemptions are unpaused + +--- + +## 8. Junior Tranche Redemption Limits + +The protocol enforces a maximum redeemable amount to preserve the MINIMUM Junior / Senior ratio. + +With the new lock-up mechanism, shares that are already locked in the Silo contract are not included in the ratio formula, meaning the Net Asset Value for a Tranche is changed to: + +$$ +\text{JuniorNAV} = \text{JuniorTVL} - \text{JuniorTVLInSilo} +$$ + +$$ +\text{SeniorNAV} = \text{SeniorTVL} - \text{SeniorTVLInSilo} +$$ + +--- + +## 9. Multiple Withdrawal Requests + +### 9.1 Request Granularity + +* Each redemption request is treated as a distinct lock-up entry +* Each entry has its own: + + * Share amount + * Lock-up end timestamp + +--- + +### 9.2 Aggregated Finalization + +* The Silo Contract MAY aggregate multiple completed lock-up entries +* Finalization may redeem the sum of all eligible completed requests in a single transaction + +--- + +### 9.3 Request Spam Prevention + +To prevent request spamming: + +* A maximum of **N** simultaneous lock-up requests per user is enforced (currently **50**) +* Once the limit is reached: + + * Any new request: + + * Increases the share amount of the last request + * Extends the lock-up end timestamp of that request + +--- + +### 9.4 Request Finalizer Account + +* A redemption request represents a **public intent** to exit the vault +* The receiver address is **recorded at request time** and **cannot be modified** +* The request has **no impact on the share exchange rate** + +Based on these properties: + +* The **finalization of a redemption request is permissionless**; any account MAY finalize an eligible redemption request. + +--- + +🏁 diff --git a/specs/SIPs/SIP-02-unified-exit-modes.md b/specs/SIPs/SIP-02-unified-exit-modes.md new file mode 100644 index 0000000..9446833 --- /dev/null +++ b/specs/SIPs/SIP-02-unified-exit-modes.md @@ -0,0 +1,158 @@ +--- +SIP: 02 +Title: Unified Exit Modes +Author: Strata Protocol Contributors +Status: Final +Type: Protocol +Created: 2026-12-20 +Requires: SIP-01 +--- + +# SIP-02: Unified Exit Modes and Coverage-Aware Redemption Flow + +## 1. Abstract + +This specification defines a **unified exit-mode process** for Tranche Vault redemptions, formalizing three distinct exit modes: + +1. **SharesLock** +2. **Fee** +3. **AssetsLock** + +The SIP standardizes how exit modes are selected, how **coverage-dependent parameters** are applied, and how share-level and asset-level cooldowns interact during the withdrawal and redemption lifecycle. + +--- + +## 2. Motivation + +Historically, the protocol supported two exit behaviors: + +* **Fee-based exits**, applying a static redemption fee +* **Asset-level cooldowns**, delaying asset delivery after shares were burned + +With the introduction of **SharesLock**, the protocol gains the ability to: + +* Delay redemptions *before* shares are burned +* Apply **dynamic, coverage-aware lock-ups and fees** +* Preserve ERC-4626 invariants while improving risk management + +This SIP provides a single, coherent process model governing **all exit paths**, ensuring predictable behavior and extensibility. + +--- + +## 3. Actors + +* **User**: Initiates a `withdraw` or `redeem` +* **Tranche Vault**: An ERC-4626-compliant vault (Junior or Senior) +* **SharesCooldown Contract**: Calculates share lock-up duration and fees based on coverage +* **Strategy**: Underlying asset manager (may require unstaking) + +--- + +## 4. Coverage Input + +During every `withdraw` or `redeem` call, the Tranche Vault MUST compute the **current coverage ratio** and supply it to the SharesCooldown Contract. + +Coverage is defined as: + +``` +Coverage = JuniorTVL / SeniorTVL +``` + +* Coverage is evaluated **at execution time** + +--- + +## 5. Unified Exit Flow + +### 5.1 SharesLock Exit Mode + +When **SharesLock** is active: + +1. The Vault passes the current coverage to the SharesCooldown Contract +2. The SharesCooldown Contract computes: + + * Share lock-up duration + * Redemption fee (if configured) +3. If `cooldownSeconds > 0`: + + * Shares MUST NOT be burned + * Shares are transferred to the SharesCooldown Contract + * Lock-up metadata is recorded +4. If `cooldownSeconds == 0`: + + * Shares MAY be redeemed immediately + * Fee logic is applied if configured + +At this stage: + +* No assets are transferred + +--- + +### 5.2 Fee Exit Mode + +When **SharesLock** is not active for a redemption, the protocol MAY select the **Fee** exit mode (as defined in earlier protocol versions) by checking global fee parameters configured in the CDO contract. + +Under the Fee exit mode: + +1. Shares are burned immediately +2. A redemption fee is accrued +3. The fee portion: + + * Either increases Junior or Senior TVL (retention), or + * Is transferred to the protocol reserve +4. Net assets proceed to asset delivery + +The fee applied in this mode is **not coverage-dependent**. + +--- + +### 5.3 Fee Accrual Rules + +Fee accrual MAY occur in the following scenarios: + +* Immediate redemption under SharesLock when a fee is configured +* Fee-only exit mode + +Fee application rules: + +* Fees are expressed as a percentage of redeemed shares +* Fee logic MUST be applied **before asset transfer** + +--- + +### 5.4 Asset Delivery and Asset Lock + +After shares are burned and net assets are determined: + +1. The Strategy is instructed to release assets +2. If an asset-level cooldown is enabled: + + * Assets are transferred into the ERC-20 AssetCooldown contract +3. Otherwise: + + * Assets are transferred directly to the receiver + +Asset-level cooldowns are **independent of SharesLock** and MAY be applied in combination when configured. + +--- + +### 5.5 Mandatory Unstaking + +If the Strategy MUST return an unstaked asset (e.g., USDe): + +1. The Strategy initiates the unstaking process +2. The protocol enforces the mandatory unstaking cooldown +3. Assets are transferred only after unstaking finalization + +This process is orthogonal to both SharesLock and AssetsLock and MUST always be honored. + +--- + +## 6. Backward Compatibility + +* Existing Fee and AssetsLock flows remain valid +* SharesLock is opt-in via protocol configuration +* No changes are required for ERC-4626 integrators + +--- diff --git a/src/utils/$exitMode.ts b/src/utils/$exitMode.ts new file mode 100644 index 0000000..4aa6d3c --- /dev/null +++ b/src/utils/$exitMode.ts @@ -0,0 +1,55 @@ +import { SharesCooldown } from '@0xc/hardhat/SharesCooldown/SharesCooldown'; +import { TwoStepConfigManager } from '@0xc/hardhat/TwoStepConfigManager/TwoStepConfigManager'; +import { TEth } from 'dequanto/models/TEth'; +import { $bigint } from 'dequanto/utils/$bigint'; +import { $require } from 'dequanto/utils/$require'; + +export namespace $exitMode { + + interface IExitMode { + // Percentage + covPct: number + // Duration (seconds) + lock?: number + // Exit fee percentage + feePct?: number + } + + export async function propose (proposer: TEth.IAccount, twoStepConfig: TwoStepConfigManager, jrt: IExitMode[], srt: IExitMode[], delay?: number) { + await twoStepConfig.$receipt().scheduleExitModeBoundsChange(proposer, map(jrt), map(srt), BigInt(delay ?? 24 * 60 * 60)); + } + export function map (modes: IExitMode[]) { + while (modes.length < 3) { + modes.unshift({ covPct: 0 }); + } + + const arr = modes.map(mode => { + return { + coverage: Number($bigint.toWei(mode.covPct / 100, 6)), + sharesLock: mode.lock ?? 0, + fee: Number($bigint.toWei((mode.feePct ?? 0) / 100, 6)), + assetsLock: 0 + }; + }); + return { + p0: arr[0].coverage, + p1: arr[1]?.coverage ?? 0, + r0: { feePpm: arr[0].fee, sharesLock: arr[0].sharesLock }, + r1: { feePpm: arr[1]?.fee ?? 0, sharesLock: arr[1]?.sharesLock ?? 0 }, + r2: { feePpm: arr[2]?.fee ?? 0, sharesLock: arr[2]?.sharesLock ?? 0 }, + } + } + + export async function set (sharesCooldown: SharesCooldown, twoStepConfig: TEth.IAccount, vault: TEth.Address, modes: IExitMode[]): Promise { + $require.eq(sharesCooldown.client.platform, 'hardhat') + await sharesCooldown.client.debug.setBalance(twoStepConfig.address, BigInt(1e18)); + await sharesCooldown.$receipt().setVaultExitBounds({ + address: twoStepConfig.address, + type: 'impersonated', + }, vault, map(modes)); + } + + export async function execute (executor: TEth.IAccount, twoStepConfig: TwoStepConfigManager) { + await twoStepConfig.$receipt().executeExitModeBoundsChange(executor); + } +} diff --git a/test/MorphoAprPairProvider.fork.t.sol b/test/MorphoAprPairProvider.fork.t.sol new file mode 100644 index 0000000..e3ec405 --- /dev/null +++ b/test/MorphoAprPairProvider.fork.t.sol @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; + +import {MorphoAprPairProvider} from "../contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol"; +import {IMetaMorpho} from "../contracts/tranches/strategies/morpho/IMetaMorpho.sol"; +import {IStrategyAprPairProvider} from "../contracts/tranches/interfaces/IAprPairFeed.sol"; + +/// @title MorphoAprPairProvider Fork Test +/// @notice Fork test for MorphoAprPairProvider using Morpho Blue on Ethereum mainnet +/// @dev Uses the official Morpho contract at 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb +/// @dev Reference: https://docs.morpho.org/get-started/resources/addresses/ +contract MorphoAprPairProviderForkTest is Test { + // ============ Morpho Protocol Contracts on Mainnet ============ + /// @dev Morpho Blue core contract - https://docs.morpho.org/get-started/resources/addresses/ + address constant MORPHO = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; + + // ============ Well-known MetaMorpho Vaults on Mainnet ============ + /// @dev Steakhouse USDC vault (MetaMorpho) + address constant STEAKHOUSE_USDC = 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB; + + /// @dev Gauntlet USDC Prime vault (MetaMorpho) + address constant GAUNTLET_USDC_PRIME = 0xdd0f28e19C1780eb6396170735D45153D261490d; + + /// @dev Gauntlet WETH Prime vault (MetaMorpho) + address constant GAUNTLET_WETH_PRIME = 0x4881Ef0BF6d2365D3dd6499ccd7532bcdBCE0658; + + /// @dev Re7 WETH vault (MetaMorpho) + address constant RE7_WETH = 0x78Fc2c2eD1A4cDb5402365934aE5648aDAd094d0; + + // ============ Test State ============ + MorphoAprPairProvider public aprProvider; + uint256 public mainnetFork; + + function setUp() public { + // Create mainnet fork + string memory rpcUrl = vm.envString("MAINNET_RPC_URL"); + mainnetFork = vm.createFork(rpcUrl); + vm.selectFork(mainnetFork); + + console2.log("Fork created at block:", block.number); + console2.log("Using Morpho at:", MORPHO); + } + + /// @notice Verify the fork is set up correctly + function test_ForkSetup() public view { + assertEq(vm.activeFork(), mainnetFork); + + // Verify Morpho contract exists on mainnet + assertTrue(MORPHO.code.length > 0, "Morpho not deployed"); + assertTrue(STEAKHOUSE_USDC.code.length > 0, "Steakhouse USDC vault not deployed"); + + console2.log("Morpho contract verified on mainnet"); + } + + /// @notice Test deployment with Steakhouse USDC vault + function test_DeployWithSteakhouseUSDC() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + assertEq(address(aprProvider.morpho()), MORPHO); + assertEq(aprProvider.baseVault(), STEAKHOUSE_USDC); + assertEq(aprProvider.targetVault(), STEAKHOUSE_USDC); + + console2.log("MorphoAprPairProvider deployed with:"); + console2.log(" Morpho:", address(aprProvider.morpho())); + console2.log(" Base Vault:", aprProvider.baseVault()); + console2.log(" Target Vault:", aprProvider.targetVault()); + } + + /// @notice Test deployment with different target and base vaults + function test_DeployWithDifferentVaults() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, GAUNTLET_USDC_PRIME); + + assertEq(address(aprProvider.morpho()), MORPHO); + assertEq(aprProvider.baseVault(), STEAKHOUSE_USDC); + assertEq(aprProvider.targetVault(), GAUNTLET_USDC_PRIME); + } + + /// @notice Test that deployment reverts with zero Morpho address + function test_RevertOnZeroMorphoAddress() public { + vm.expectRevert("Morpho address cannot be 0"); + new MorphoAprPairProvider(address(0), STEAKHOUSE_USDC, address(0)); + } + + /// @notice Test that deployment reverts with zero base vault address + function test_RevertOnZeroBaseVaultAddress() public { + vm.expectRevert("Base vault address cannot be 0"); + new MorphoAprPairProvider(MORPHO, address(0), address(0)); + } + + /// @notice Test getAprPair returns valid data from Steakhouse USDC + function test_GetAprPairSteakhouseUSDC() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = aprProvider.getAprPair(); + + console2.log("=== Steakhouse USDC APR Data ==="); + console2.log("APR Target (scaled 1e12):", uint256(uint64(aprTarget))); + console2.log("APR Base (scaled 1e12):", uint256(uint64(aprBase))); + console2.log("Timestamp:", timestamp); + + // Convert to percentage for logging (divide by 1e10 to get percentage with 2 decimals) + console2.log("APR Target (%):", uint256(uint64(aprTarget)) / 1e10); + console2.log("APR Base (%):", uint256(uint64(aprBase)) / 1e10); + + // Verify timestamp is current + assertEq(timestamp, uint64(block.timestamp)); + + // Verify APRs are non-negative (they should be positive for an active vault) + assertTrue(aprTarget >= 0, "Target APR should be non-negative"); + assertTrue(aprBase >= 0, "Base APR should be non-negative"); + + // Verify APRs are reasonable (less than 100% = 1e12) + assertTrue(uint64(aprTarget) < 1e12, "Target APR should be less than 100%"); + assertTrue(uint64(aprBase) < 1e12, "Base APR should be less than 100%"); + } + + /// @notice Test getAprPair with Gauntlet USDC Prime vault + function test_GetAprPairGauntletUSDCPrime() public { + aprProvider = new MorphoAprPairProvider(MORPHO, GAUNTLET_USDC_PRIME, address(0)); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = aprProvider.getAprPair(); + + console2.log("=== Gauntlet USDC Prime APR Data ==="); + console2.log("APR Target (scaled 1e12):", uint256(uint64(aprTarget))); + console2.log("APR Base (scaled 1e12):", uint256(uint64(aprBase))); + console2.log("APR Target (%):", uint256(uint64(aprTarget)) / 1e10); + console2.log("APR Base (%):", uint256(uint64(aprBase)) / 1e10); + + assertEq(timestamp, uint64(block.timestamp)); + assertTrue(aprBase >= 0, "Base APR should be non-negative"); + } + + /// @notice Test getAprPair with WETH vault + function test_GetAprPairGauntletWETH() public { + aprProvider = new MorphoAprPairProvider(MORPHO, GAUNTLET_WETH_PRIME, address(0)); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = aprProvider.getAprPair(); + + console2.log("=== Gauntlet WETH Prime APR Data ==="); + console2.log("APR Base (scaled 1e12):", uint256(uint64(aprBase))); + console2.log("APR Base (%):", uint256(uint64(aprBase)) / 1e10); + + assertEq(timestamp, uint64(block.timestamp)); + assertTrue(aprBase >= 0, "Base APR should be non-negative"); + } + + /// @notice Test individual APR getter functions + function test_IndividualAPRGetters() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + int64 aprTarget = aprProvider.getAPRtarget(); + int64 aprBase = aprProvider.getAPRbase(); + + console2.log("=== Individual APR Getters ==="); + console2.log("getAPRtarget():", uint256(uint64(aprTarget))); + console2.log("getAPRbase():", uint256(uint64(aprBase))); + + // Since target and base vault are the same, they should return the same value + assertEq(aprTarget, aprBase, "Same vault should return same APR"); + } + + /// @notice Test supplyAPYVaultV1 directly + function test_SupplyAPYVaultV1() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + uint256 apyWad = aprProvider.supplyAPYVaultV1(STEAKHOUSE_USDC); + + console2.log("=== Supply APY (WAD) ==="); + console2.log("Steakhouse USDC APY (WAD):", apyWad); + console2.log("Steakhouse USDC APY (%):", apyWad / 1e16); // Convert to percentage + + // Verify APY is reasonable (less than 100% = 1e18) + assertTrue(apyWad < 1e18, "APY should be less than 100%"); + } + + /// @notice Test with different vaults to compare APRs + function test_CompareVaultAPRs() public { + console2.log("=== Comparing Vault APRs ==="); + + // Test Steakhouse USDC + MorphoAprPairProvider steakhouseProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + uint256 steakhouseApy = steakhouseProvider.supplyAPYVaultV1(STEAKHOUSE_USDC); + console2.log("Steakhouse USDC APY (%):", steakhouseApy / 1e16); + + // Test Gauntlet USDC Prime + MorphoAprPairProvider gauntletProvider = new MorphoAprPairProvider(MORPHO, GAUNTLET_USDC_PRIME, address(0)); + uint256 gauntletApy = gauntletProvider.supplyAPYVaultV1(GAUNTLET_USDC_PRIME); + console2.log("Gauntlet USDC Prime APY (%):", gauntletApy / 1e16); + + // Test Gauntlet WETH Prime + MorphoAprPairProvider wethProvider = new MorphoAprPairProvider(MORPHO, GAUNTLET_WETH_PRIME, address(0)); + uint256 wethApy = wethProvider.supplyAPYVaultV1(GAUNTLET_WETH_PRIME); + console2.log("Gauntlet WETH Prime APY (%):", wethApy / 1e16); + } + + /// @notice Test that the provider implements IStrategyAprPairProvider interface + function test_ImplementsInterface() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + // Call through the interface + IStrategyAprPairProvider provider = IStrategyAprPairProvider(address(aprProvider)); + (int64 aprTarget, int64 aprBase, uint64 timestamp) = provider.getAprPair(); + + console2.log("=== Interface Test ==="); + console2.log("Called through IStrategyAprPairProvider"); + console2.log("APR Target:", uint256(uint64(aprTarget))); + console2.log("APR Base:", uint256(uint64(aprBase))); + + assertEq(timestamp, uint64(block.timestamp)); + } + + /// @notice Test vault with different target and base + function test_DifferentTargetAndBase() public { + // Use Steakhouse USDC as base and Gauntlet USDC Prime as target + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, GAUNTLET_USDC_PRIME); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = aprProvider.getAprPair(); + + console2.log("=== Different Target and Base ==="); + console2.log("Base (Steakhouse USDC) APR:", uint256(uint64(aprBase))); + console2.log("Target (Gauntlet USDC Prime) APR:", uint256(uint64(aprTarget))); + console2.log("Base APR (%):", uint256(uint64(aprBase)) / 1e10); + console2.log("Target APR (%):", uint256(uint64(aprTarget)) / 1e10); + + assertEq(timestamp, uint64(block.timestamp)); + + // Target and base may be different since they come from different vaults + // Both should be non-negative + assertTrue(aprTarget >= 0, "Target APR should be non-negative"); + assertTrue(aprBase >= 0, "Base APR should be non-negative"); + } + + /// @notice Test querying market-level data + function test_VaultAssetsInMarket() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + // Get the first market from the withdraw queue + uint256 queueLength = IMetaMorpho(STEAKHOUSE_USDC).withdrawQueueLength(); + console2.log("=== Vault Market Analysis ==="); + console2.log("Number of markets in withdraw queue:", queueLength); + + assertTrue(queueLength > 0, "Vault should have at least one market"); + } + + /// @notice Test that APR values are stable across multiple calls + function test_APRStability() public { + aprProvider = new MorphoAprPairProvider(MORPHO, STEAKHOUSE_USDC, address(0)); + + (int64 aprTarget1, int64 aprBase1,) = aprProvider.getAprPair(); + (int64 aprTarget2, int64 aprBase2,) = aprProvider.getAprPair(); + + // APRs should be identical when called in the same block + assertEq(aprTarget1, aprTarget2, "APR target should be stable within same block"); + assertEq(aprBase1, aprBase2, "APR base should be stable within same block"); + } +} + diff --git a/test/MorphoStrategy.t.sol b/test/MorphoStrategy.t.sol new file mode 100644 index 0000000..6a195de --- /dev/null +++ b/test/MorphoStrategy.t.sol @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {MockERC4626} from "../contracts/test/MockERC4626.sol"; + +import {Tranche} from "../contracts/tranches/Tranche.sol"; +import {Accounting} from "../contracts/tranches/Accounting.sol"; + +import {MorphoStrategy} from "../contracts/tranches/strategies/morpho/MorphoStrategy.sol"; +import {AccessControlManager} from "../contracts/governance/AccessControlManager.sol"; + +import {AprPairFeed} from "../contracts/tranches/oracles/AprPairFeed.sol"; +import {IStrategyAprPairProvider} from "../contracts/tranches/interfaces/IAprPairFeed.sol"; + +import {console2} from "forge-std/console2.sol"; + +import {StrataCDO} from "../contracts/tranches/StrataCDO.sol"; + +import {ERC20Cooldown} from "../contracts/tranches/base/cooldown/ERC20Cooldown.sol"; +import {CooldownBase} from "../contracts/tranches/base/cooldown/CooldownBase.sol"; + +import {ITranche} from "../contracts/tranches/interfaces/ITranche.sol"; +import {IStrategy} from "../contracts/tranches/interfaces/IStrategy.sol"; +import {IAccounting} from "../contracts/tranches/interfaces/IAccounting.sol"; +import {IDistributor, MerkleTree} from "../contracts/tranches/interfaces/IDistributor.sol"; +import {ISwapContract} from "../contracts/tranches/interfaces/ISwapContract.sol"; + +// Mock USDC token +contract MockUSDC is ERC20 { + constructor() ERC20("MockUSDC", "USDC") {} + + function decimals() public pure override returns (uint8) { + return 6; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +// Simple mock APR provider +contract MockAprPairProvider is IStrategyAprPairProvider { + int64 public aprTarget = 500e9; // 5% scaled by 1e12 + int64 public aprBase = 800e9; // 8% scaled by 1e12 + + function getAprPair() external view returns (int64, int64, uint64) { + return (aprTarget, aprBase, uint64(block.timestamp)); + } + + function setAprs(int64 _aprTarget, int64 _aprBase) external { + aprTarget = _aprTarget; + aprBase = _aprBase; + } +} + +// Mock Distributor +contract MockDistributor is IDistributor { + function claim(address[] calldata, address[] calldata, uint256[] calldata, bytes32[][] calldata) + external + override + {} + + function claimWithRecipient( + address[] calldata, + address[] calldata, + uint256[] calldata, + bytes32[][] calldata, + address[] calldata, + bytes[] memory + ) external override {} + + // Minimal implementation for interface compliance + function tree() external pure override returns (bytes32, bytes32) { + return (bytes32(0), bytes32(0)); + } + + function lastTree() external pure override returns (bytes32, bytes32) { + return (bytes32(0), bytes32(0)); + } + + function disputeToken() external pure override returns (IERC20) { + return IERC20(address(0)); + } + + function disputer() external pure override returns (address) { + return address(0); + } + + function endOfDisputePeriod() external pure override returns (uint48) { + return 0; + } + + function disputePeriod() external pure override returns (uint48) { + return 0; + } + + function disputeAmount() external pure override returns (uint256) { + return 0; + } + + function claimed(address, address) external pure override returns (uint208, uint48, bytes32) { + return (0, 0, bytes32(0)); + } + + function canUpdateMerkleRoot(address) external pure override returns (uint256) { + return 0; + } + + function operators(address, address) external pure override returns (uint256) { + return 0; + } + + function upgradeabilityDeactivated() external pure override returns (uint128) { + return 0; + } + + function claimRecipient(address, address) external pure override returns (address) { + return address(0); + } + + function mainOperators(address, address) external pure override returns (uint256) { + return 0; + } + + function CALLBACK_SUCCESS() external pure override returns (bytes32) { + return bytes32(0); + } + + function getMerkleRoot() external pure override returns (bytes32) { + return bytes32(0); + } + + function getEpochDuration() external pure override returns (uint32) { + return 0; + } + + function toggleOperator(address, address) external override {} + + function setClaimRecipient(address, address) external override {} + + function toggleMainOperatorStatus(address, address) external override {} + + function disputeTree(string memory) external override {} + + function updateTree(MerkleTree calldata _tree) external override {} + + function toggleTrusted(address) external override {} + + function revokeUpgradeability() external override {} + + function setEpochDuration(uint32) external override {} + + function resolveDispute(bool) external override {} + + function revokeTree() external override {} + + function recoverERC20(address, address, uint256) external override {} + + function setDisputePeriod(uint48) external override {} + + function setDisputeToken(IERC20) external override {} + + function setDisputeAmount(uint256) external override {} +} + +// Mock SwapContract +contract MockSwapContract is ISwapContract { + function swapWithEncodedKey(bytes calldata, bool, uint128, uint128, uint256, bytes calldata) + external + pure + override + returns (uint256) + { + return 0; + } + + function approveTokenWithPermit2(address, uint160, uint48) external override {} +} + +contract MorphoStrategyTest is Test { + // External protocols + MockUSDC public USDC; + MockERC4626 public morphoVault; + + // Auth + AccessControlManager public acm; + + // Strata CDO + StrataCDO public cdo; + + // Tranches + Tranche public jrtVault; + Tranche public srtVault; + + // Accounting Component + Accounting public accounting; + + // Basic Feed + AprPairFeed public feed; + MockAprPairProvider public aprProvider; + + // Strategy + MorphoStrategy public morphoStrategy; + ERC20Cooldown public erc20Cooldown; + + address account; + + function setUp() public { + address owner = msg.sender; + + vm.startPrank(owner); + + // Prepare USDC and morpho vault + USDC = new MockUSDC(); + morphoVault = new MockERC4626(IERC20(address(USDC))); + + // Prepare Acm + acm = new AccessControlManager(owner); + + // Create CDO + cdo = StrataCDO( + address( + new ERC1967Proxy( + address(new StrataCDO()), abi.encodeWithSelector(StrataCDO.initialize.selector, owner, address(acm)) + ) + ) + ); + + // Prepare Tranches + jrtVault = Tranche( + address( + new ERC1967Proxy( + address(new Tranche()), + abi.encodeWithSelector( + Tranche.initialize.selector, + owner, + address(acm), + "jrtVault", + "jrtUSDC", + IERC20(address(USDC)), + address(cdo) + ) + ) + ) + ); + srtVault = Tranche( + address( + new ERC1967Proxy( + address(new Tranche()), + abi.encodeWithSelector( + Tranche.initialize.selector, + owner, + address(acm), + "srtVault", + "srtUSDC", + IERC20(address(USDC)), + address(cdo) + ) + ) + ) + ); + + // Prepare cooldown + erc20Cooldown = ERC20Cooldown( + address( + new ERC1967Proxy( + address(new ERC20Cooldown()), + abi.encodeWithSelector(CooldownBase.initialize.selector, owner, address(acm)) + ) + ) + ); + + // Prepare mocks for strategy constructor + MockDistributor mockDistributor = new MockDistributor(); + MockSwapContract mockSwapContract = new MockSwapContract(); + uint256 vestingDuration = 30 days; + + // Prepare Strategy + morphoStrategy = MorphoStrategy( + address( + new ERC1967Proxy( + address( + new MorphoStrategy( + IERC4626(address(morphoVault)), + IDistributor(address(mockDistributor)), + ISwapContract(address(mockSwapContract)), + vestingDuration + ) + ), + abi.encodeWithSelector( + MorphoStrategy.initialize.selector, owner, address(acm), address(cdo), address(erc20Cooldown) + ) + ) + ) + ); + acm.grantRole(erc20Cooldown.COOLDOWN_WORKER_ROLE(), address(morphoStrategy)); + acm.grantRole(morphoStrategy.UPDATER_STRAT_CONFIG_ROLE(), owner); + + // Prepare Feed + aprProvider = new MockAprPairProvider(); + feed = AprPairFeed( + address( + new ERC1967Proxy( + address(new AprPairFeed()), + abi.encodeWithSelector( + AprPairFeed.initialize.selector, + owner, + address(acm), + IStrategyAprPairProvider(address(aprProvider)), + 4 hours, + "Morpho CDO APR Pair" + ) + ) + ) + ); + + // Prepare accounting + accounting = Accounting( + address( + new ERC1967Proxy( + address(new Accounting()), + abi.encodeWithSelector( + Accounting.initialize.selector, owner, address(acm), address(cdo), address(feed) + ) + ) + ) + ); + + // Configure CDO + cdo.configure( + IAccounting(address(accounting)), + IStrategy(address(morphoStrategy)), + ITranche(address(jrtVault)), + ITranche(address(srtVault)) + ); + acm.grantRole(cdo.PAUSER_ROLE(), owner); + cdo.setActionStates(address(0), true, true); + + vm.stopPrank(); + } + + function test_Flow() public { + assert(address(USDC) != address(0)); + + account = msg.sender; + address owner = msg.sender; + + vm.startPrank(owner); + + // Set cooldown periods (7 days for both tranches) + uint256 cooldownPeriod = 7 days; + morphoStrategy.setCooldowns(cooldownPeriod, cooldownPeriod); + + // test deposit + uint256 shares = 1000 * 10 ** USDC.decimals(); // 1000 USDC + USDC.mint(account, shares); + USDC.approve(address(jrtVault), shares); + jrtVault.deposit(address(USDC), shares, address(0xdead)); + assertBalance(jrtVault, address(0xdead), shares, "Deposit shares failed"); + + USDC.mint(account, shares); + USDC.approve(address(jrtVault), shares); + jrtVault.deposit(address(USDC), shares, account); + jrtVault.withdraw(address(USDC), shares, account, account); + assertBalance(USDC, account, 0, "Cooldown period failed"); + + vm.warp(block.timestamp + 7 days); + erc20Cooldown.finalize(morphoVault, account); + assertBalance(USDC, account, shares, "After-Cooldown period failed"); + + vm.stopPrank(); + } + + function depositGeneric(IERC4626 vault, uint256 amount) internal { + IERC20 asset = IERC20(vault.asset()); + asset.approve(address(vault), amount); + vault.deposit(amount, account); + } + + function assertBalance(IERC20 token, address owner, uint256 amount, string memory message) internal { + uint256 balance = token.balanceOf(owner); + assertEq(balance, amount, message); + } +} + diff --git a/test/SmokehouseDeploy.t.sol b/test/SmokehouseDeploy.t.sol new file mode 100644 index 0000000..3d5402b --- /dev/null +++ b/test/SmokehouseDeploy.t.sol @@ -0,0 +1,433 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +import {AccessControlManager} from "../contracts/governance/AccessControlManager.sol"; +import {StrataCDO} from "../contracts/tranches/StrataCDO.sol"; +import {Tranche} from "../contracts/tranches/Tranche.sol"; +import {Accounting} from "../contracts/tranches/Accounting.sol"; +import {AprPairFeed} from "../contracts/tranches/oracles/AprPairFeed.sol"; + +import {MorphoStrategy} from "../contracts/tranches/strategies/morpho/MorphoStrategy.sol"; +import {MorphoAprPairProvider} from "../contracts/tranches/strategies/morpho/MorphoAprPairProvider.sol"; + +import {ERC20Cooldown} from "../contracts/tranches/base/cooldown/ERC20Cooldown.sol"; +import {CooldownBase} from "../contracts/tranches/base/cooldown/CooldownBase.sol"; + +import {IAccounting} from "../contracts/tranches/interfaces/IAccounting.sol"; +import {IStrategy} from "../contracts/tranches/interfaces/IStrategy.sol"; +import {ITranche} from "../contracts/tranches/interfaces/ITranche.sol"; +import {IStrataCDO} from "../contracts/tranches/interfaces/IStrataCDO.sol"; +import {IAprPairFeed} from "../contracts/tranches/interfaces/IAprPairFeed.sol"; +import {IERC20Cooldown} from "../contracts/tranches/interfaces/cooldown/ICooldown.sol"; +import {IDistributor, MerkleTree} from "../contracts/tranches/interfaces/IDistributor.sol"; +import {ISwapContract} from "../contracts/tranches/interfaces/ISwapContract.sol"; + +contract SmokehouseDeploy is Test { + // Mainnet addresses + address public constant MORPHO = 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb; + + // Smokehouse USDC vault - the base vault for strategy and base APR + address public constant SMOKEHOUSE_USDC = 0xBEeFFF209270748ddd194831b3fa287a5386f5bC; + + // Steakhouse USDC vault - the target vault for target APR + address public constant STEAKHOUSE_USDC = 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB; + + // USDC address on mainnet + address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + // Smokehouse was deployed at a recent block + uint256 constant MAINNET_BLOCK = 21834000; + + // Roles + bytes32 constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + bytes32 constant UPDATER_STRAT_CONFIG_ROLE = keccak256("UPDATER_STRAT_CONFIG_ROLE"); + bytes32 constant UPDATER_FEED_ROLE = keccak256("UPDATER_FEED_ROLE"); + bytes32 constant UPDATER_CDO_APR_ROLE = keccak256("UPDATER_CDO_APR_ROLE"); + bytes32 constant RESERVE_MANAGER_ROLE = keccak256("RESERVE_MANAGER_ROLE"); + bytes32 constant CDO_OWNER_ROLE = keccak256("CDO_OWNER_ROLE"); + bytes32 constant COOLDOWN_WORKER_ROLE = keccak256("COOLDOWN_WORKER_ROLE"); + + // Mock contracts for strategy constructor + MockDistributor internal mockDistributor; + MockSwapContract internal mockSwapContract; + + // Deployed contracts + address internal owner; + AccessControlManager internal acm; + StrataCDO internal cdo; + Tranche internal jrtVault; + Tranche internal srtVault; + ERC20Cooldown internal erc20Cooldown; + MorphoStrategy internal strategy; + MorphoAprPairProvider internal provider; + AprPairFeed internal feed; + Accounting internal accounting; + + function setUp() public virtual { + string memory rpcUrl = vm.envString("MAINNET_RPC_URL"); + uint256 forkId = vm.createFork(rpcUrl, MAINNET_BLOCK); + vm.selectFork(forkId); + + owner = makeAddr("strataOwner"); + vm.label(owner, "DeployerOwner"); + vm.label(MORPHO, "Morpho"); + vm.label(SMOKEHOUSE_USDC, "SmokehouseUSDC"); + vm.label(STEAKHOUSE_USDC, "SteakhouseUSDC"); + vm.label(USDC, "USDC"); + + vm.deal(owner, 100 ether); + + // Deploy mock contracts for strategy constructor + mockDistributor = new MockDistributor(); + mockSwapContract = new MockSwapContract(); + } + + function testDeploySmokehouseStackMatchesScript() public { + _deployStrataStack(); + + // Verify CDO configuration + assertEq(address(cdo.strategy()), address(strategy)); + assertEq(address(cdo.jrtVault()), address(jrtVault)); + assertEq(address(cdo.srtVault()), address(srtVault)); + assertEq(jrtVault.asset(), USDC); + assertEq(srtVault.asset(), USDC); + + // Verify strategy configuration + assertEq(address(strategy.morphoVault()), SMOKEHOUSE_USDC); + assertEq(address(strategy.asset()), USDC); + assertEq(strategy.vaultCooldownJrt(), 7 days); + assertEq(strategy.vaultCooldownSrt(), 0); + + // Verify provider configuration + assertEq(address(provider.morpho()), MORPHO); + assertEq(provider.baseVault(), SMOKEHOUSE_USDC); + assertEq(provider.targetVault(), STEAKHOUSE_USDC); + + // Verify feed configuration + assertEq(address(feed.provider()), address(provider)); + assertEq(feed.roundStaleAfter(), 4 hours); + assertEq(address(accounting.aprPairFeed()), address(feed)); + + // Verify roles + assertTrue(acm.hasRole(PAUSER_ROLE, owner)); + assertTrue(acm.hasRole(UPDATER_STRAT_CONFIG_ROLE, owner)); + assertTrue(acm.hasRole(UPDATER_FEED_ROLE, owner)); + assertTrue(acm.hasRole(UPDATER_CDO_APR_ROLE, address(feed))); + assertTrue(acm.hasRole(COOLDOWN_WORKER_ROLE, address(strategy))); + + // Verify action states + (bool jrtDepositsEnabled, bool jrtWithdrawalsEnabled) = cdo.actionsJrt(); + (bool srtDepositsEnabled, bool srtWithdrawalsEnabled) = cdo.actionsSrt(); + assertTrue(jrtDepositsEnabled && jrtWithdrawalsEnabled); + assertTrue(srtDepositsEnabled && srtWithdrawalsEnabled); + + // Verify supported tokens + IERC20[] memory supported = strategy.getSupportedTokens(); + assertEq(supported.length, 2); + assertEq(address(supported[0]), SMOKEHOUSE_USDC); + assertEq(address(supported[1]), USDC); + } + + function testAprPairProviderReturnsValidData() public { + _deployStrataStack(); + + (int64 aprTarget, int64 aprBase, uint64 timestamp) = provider.getAprPair(); + + // APRs should be positive and reasonable (< 100% = 1e12) + assertTrue(aprTarget >= 0, "Target APR should be non-negative"); + assertTrue(aprBase >= 0, "Base APR should be non-negative"); + assertTrue(aprTarget < 1e12, "Target APR should be less than 100%"); + assertTrue(aprBase < 1e12, "Base APR should be less than 100%"); + assertEq(timestamp, uint64(block.timestamp)); + } + + function _deployStrataStack() internal { + vm.startPrank(owner); + + // 1. Deploy AccessControlManager + acm = new AccessControlManager(owner); + vm.label(address(acm), "AccessControlManager"); + + // 2. Deploy StrataCDO + StrataCDO cdoImpl = new StrataCDO(); + vm.label(address(cdoImpl), "StrataCDO_Impl"); + cdo = StrataCDO( + address( + new ERC1967Proxy( + address(cdoImpl), abi.encodeWithSelector(StrataCDO.initialize.selector, owner, address(acm)) + ) + ) + ); + vm.label(address(cdo), "StrataCDO"); + + // 3. Deploy Tranches + jrtVault = _deployTranche("JRT", "Junior Tranche"); + srtVault = _deployTranche("SRT", "Senior Tranche"); + + // 4. Deploy ERC20Cooldown + ERC20Cooldown erc20CooldownImpl = new ERC20Cooldown(); + vm.label(address(erc20CooldownImpl), "ERC20Cooldown_Impl"); + erc20Cooldown = ERC20Cooldown( + address( + new ERC1967Proxy( + address(erc20CooldownImpl), + abi.encodeWithSelector(CooldownBase.initialize.selector, owner, address(acm)) + ) + ) + ); + vm.label(address(erc20Cooldown), "ERC20Cooldown"); + + // 5. Deploy MorphoStrategy (uses Smokehouse vault) + MorphoStrategy strategyImpl = new MorphoStrategy( + IERC4626(SMOKEHOUSE_USDC), + IDistributor(address(mockDistributor)), + ISwapContract(address(mockSwapContract)), + 30 days // vestingDuration + ); + vm.label(address(strategyImpl), "MorphoStrategy_Impl"); + strategy = MorphoStrategy( + address( + new ERC1967Proxy( + address(strategyImpl), + abi.encodeWithSelector( + MorphoStrategy.initialize.selector, + owner, + address(acm), + IStrataCDO(address(cdo)), + IERC20Cooldown(address(erc20Cooldown)) + ) + ) + ) + ); + vm.label(address(strategy), "MorphoStrategy"); + + // 6. Deploy MorphoAprPairProvider + // baseVault = Smokehouse (for base APR), targetVault = Steakhouse (for target APR) + provider = new MorphoAprPairProvider(MORPHO, SMOKEHOUSE_USDC, STEAKHOUSE_USDC); + vm.label(address(provider), "MorphoAprPairProvider"); + + // 7. Deploy AprPairFeed + AprPairFeed feedImpl = new AprPairFeed(); + vm.label(address(feedImpl), "AprPairFeed_Impl"); + feed = AprPairFeed( + address( + new ERC1967Proxy( + address(feedImpl), + abi.encodeWithSelector( + AprPairFeed.initialize.selector, + owner, + address(acm), + provider, + uint256(4 hours), + "Smokehouse CDO APR Pair" + ) + ) + ) + ); + vm.label(address(feed), "AprPairFeed"); + + // 8. Deploy Accounting + Accounting accountingImpl = new Accounting(); + vm.label(address(accountingImpl), "Accounting_Impl"); + accounting = Accounting( + address( + new ERC1967Proxy( + address(accountingImpl), + abi.encodeWithSelector( + Accounting.initialize.selector, + owner, + address(acm), + IStrataCDO(address(cdo)), + IAprPairFeed(address(feed)) + ) + ) + ) + ); + vm.label(address(accounting), "Accounting"); + + // 9. Grant roles + _grantRole(PAUSER_ROLE, owner); + _grantRole(UPDATER_STRAT_CONFIG_ROLE, owner); + _grantRole(UPDATER_FEED_ROLE, owner); + _grantRole(UPDATER_CDO_APR_ROLE, address(feed)); + _grantRole(COOLDOWN_WORKER_ROLE, address(strategy)); + + // 10. Configure CDO + cdo.configure( + IAccounting(address(accounting)), + IStrategy(address(strategy)), + ITranche(address(jrtVault)), + ITranche(address(srtVault)) + ); + + // 11. Set strategy cooldowns (7 days for JRT, 0 for SRT) + strategy.setCooldowns(7 days, 0); + + // 12. Enable actions on tranches + cdo.setActionStates(address(jrtVault), true, true); + cdo.setActionStates(address(srtVault), true, true); + + // 13. Set reserve basis points + accounting.setReserveBps(0.02e18); + + vm.stopPrank(); + } + + function _deployTranche(string memory name, string memory symbol) internal returns (Tranche) { + Tranche trancheImpl = new Tranche(); + vm.label(address(trancheImpl), string.concat(name, "_Tranche_Impl")); + + address proxy = address( + new ERC1967Proxy( + address(trancheImpl), + abi.encodeWithSelector( + Tranche.initialize.selector, + owner, + address(acm), + name, + symbol, + IERC20(USDC), + IStrataCDO(address(cdo)) + ) + ) + ); + string memory label = string.concat(name, "_Tranche"); + vm.label(proxy, label); + return Tranche(proxy); + } + + function _grantRole(bytes32 role, address grantee) internal { + acm.grantRole(role, grantee); + } +} + +// Mock Distributor +contract MockDistributor is IDistributor { + function claim(address[] calldata, address[] calldata, uint256[] calldata, bytes32[][] calldata) + external + override + {} + + function claimWithRecipient( + address[] calldata, + address[] calldata, + uint256[] calldata, + bytes32[][] calldata, + address[] calldata, + bytes[] memory + ) external override {} + + // Minimal implementation for interface compliance + function tree() external pure override returns (bytes32, bytes32) { + return (bytes32(0), bytes32(0)); + } + + function lastTree() external pure override returns (bytes32, bytes32) { + return (bytes32(0), bytes32(0)); + } + + function disputeToken() external pure override returns (IERC20) { + return IERC20(address(0)); + } + + function disputer() external pure override returns (address) { + return address(0); + } + + function endOfDisputePeriod() external pure override returns (uint48) { + return 0; + } + + function disputePeriod() external pure override returns (uint48) { + return 0; + } + + function disputeAmount() external pure override returns (uint256) { + return 0; + } + + function claimed(address, address) external pure override returns (uint208, uint48, bytes32) { + return (0, 0, bytes32(0)); + } + + function canUpdateMerkleRoot(address) external pure override returns (uint256) { + return 0; + } + + function operators(address, address) external pure override returns (uint256) { + return 0; + } + + function upgradeabilityDeactivated() external pure override returns (uint128) { + return 0; + } + + function claimRecipient(address, address) external pure override returns (address) { + return address(0); + } + + function mainOperators(address, address) external pure override returns (uint256) { + return 0; + } + + function CALLBACK_SUCCESS() external pure override returns (bytes32) { + return bytes32(0); + } + + function getMerkleRoot() external pure override returns (bytes32) { + return bytes32(0); + } + + function getEpochDuration() external pure override returns (uint32) { + return 0; + } + + function toggleOperator(address, address) external override {} + + function setClaimRecipient(address, address) external override {} + + function toggleMainOperatorStatus(address, address) external override {} + + function disputeTree(string memory) external override {} + + function updateTree(MerkleTree calldata _tree) external override {} + + function toggleTrusted(address) external override {} + + function revokeUpgradeability() external override {} + + function setEpochDuration(uint32) external override {} + + function resolveDispute(bool) external override {} + + function revokeTree() external override {} + + function recoverERC20(address, address, uint256) external override {} + + function setDisputePeriod(uint48) external override {} + + function setDisputeToken(IERC20) external override {} + + function setDisputeAmount(uint256) external override {} +} + +// Mock SwapContract +contract MockSwapContract is ISwapContract { + function swapWithEncodedKey(bytes calldata, bool, uint128, uint128, uint256, bytes calldata) + external + pure + override + returns (uint256) + { + return 0; + } + + function approveTokenWithPermit2(address, uint160, uint48) external override {} +} + diff --git a/test/Swap.t.sol b/test/Swap.t.sol index 3db70f4..405b27b 100644 --- a/test/Swap.t.sol +++ b/test/Swap.t.sol @@ -5,7 +5,7 @@ import "forge-std/console2.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {MockUSDe} from "../contracts/test/MockUSDe.sol"; import {MockStakedUSDe} from "../contracts/test/MockStakedUSDe.sol"; @@ -16,33 +16,34 @@ import {Tranche} from "../contracts/tranches/Tranche.sol"; import {Accounting} from "../contracts/tranches/Accounting.sol"; import {TrancheDepositor} from "../contracts/tranches/TrancheDepositor.sol"; +import {sUSDeCooldownRequestImpl as SUSDeCooldownRequestImpl} from + "../contracts/tranches/strategies/ethena/sUSDeCooldownRequestImpl.sol"; +import { + sUSDeAprPairProvider as SUSDeAprPairProvider, + IsUSDS +} from "../contracts/tranches/strategies/ethena/sUSDeAprPairProvider.sol"; +import {sUSDeStrategy as SUSDeStrategy} from "../contracts/tranches/strategies/ethena/sUSDeStrategy.sol"; -import { sUSDeCooldownRequestImpl as SUSDeCooldownRequestImpl } from "../contracts/tranches/strategies/ethena/sUSDeCooldownRequestImpl.sol"; -import { sUSDeAprPairProvider as SUSDeAprPairProvider, IsUSDS } from "../contracts/tranches/strategies/ethena/sUSDeAprPairProvider.sol"; -import { sUSDeStrategy as SUSDeStrategy} from "../contracts/tranches/strategies/ethena/sUSDeStrategy.sol"; +import {AccessControlManager} from "../contracts/governance/AccessControlManager.sol"; -import { AccessControlManager } from "../contracts/governance/AccessControlManager.sol"; +import {AprPairFeed} from "../contracts/tranches/oracles/AprPairFeed.sol"; -import { AprPairFeed } from "../contracts/tranches/oracles/AprPairFeed.sol"; +import {console2} from "forge-std/console2.sol"; -import { console2} from "forge-std/console2.sol"; +import {StrataCDO} from "../contracts/tranches/StrataCDO.sol"; -import { StrataCDO} from "../contracts/tranches/StrataCDO.sol"; +import {ERC20Cooldown} from "../contracts/tranches/base/cooldown/ERC20Cooldown.sol"; +import {UnstakeCooldown} from "../contracts/tranches/base/cooldown/UnstakeCooldown.sol"; +import {CooldownBase} from "../contracts/tranches/base/cooldown/CooldownBase.sol"; -import { ERC20Cooldown } from "../contracts/tranches/base/cooldown/ERC20Cooldown.sol"; -import { UnstakeCooldown } from "../contracts/tranches/base/cooldown/UnstakeCooldown.sol"; -import { CooldownBase } from "../contracts/tranches/base/cooldown/CooldownBase.sol"; - - -import { IsUSDe } from "../contracts/tranches/strategies/ethena/IsUSDe.sol"; -import { IUnstakeHandler } from "../contracts/tranches/interfaces/cooldown/IUnstakeHandler.sol"; -import { ITranche } from "../contracts/tranches/interfaces/ITranche.sol"; -import { IStrategy } from "../contracts/tranches/interfaces/IStrategy.sol"; -import { IAccounting } from "../contracts/tranches/interfaces/IAccounting.sol"; -import { IMetaVault } from "../contracts/tranches/interfaces/IMetaVault.sol"; +import {IsUSDe} from "../contracts/tranches/strategies/ethena/IsUSDe.sol"; +import {IUnstakeHandler} from "../contracts/tranches/interfaces/cooldown/IUnstakeHandler.sol"; +import {ITranche} from "../contracts/tranches/interfaces/ITranche.sol"; +import {IStrategy} from "../contracts/tranches/interfaces/IStrategy.sol"; +import {IAccounting} from "../contracts/tranches/interfaces/IAccounting.sol"; +import {IMetaVault} from "../contracts/tranches/interfaces/IMetaVault.sol"; contract CDOTest is Test { - // External protocols MockUSDe public USDe; MockStakedUSDe public sUSDe; @@ -73,7 +74,6 @@ contract CDOTest is Test { ERC20Cooldown public erc20Cooldown; SUSDeCooldownRequestImpl public sUSDeCooldownRequestImpl; - address account; function setUp() public { @@ -97,8 +97,7 @@ contract CDOTest is Test { cdo = StrataCDO( address( new ERC1967Proxy( - address(new StrataCDO()), - abi.encodeWithSelector(StrataCDO.initialize.selector, owner, address(acm)) + address(new StrataCDO()), abi.encodeWithSelector(StrataCDO.initialize.selector, owner, address(acm)) ) ) ); @@ -108,7 +107,15 @@ contract CDOTest is Test { address( new ERC1967Proxy( address(new Tranche()), - abi.encodeWithSelector(Tranche.initialize.selector, owner, address(acm), "jrtVault", "jrtUSDe", IERC20(address(USDe)), address(cdo)) + abi.encodeWithSelector( + Tranche.initialize.selector, + owner, + address(acm), + "jrtVault", + "jrtUSDe", + IERC20(address(USDe)), + address(cdo) + ) ) ) ); @@ -116,7 +123,15 @@ contract CDOTest is Test { address( new ERC1967Proxy( address(new Tranche()), - abi.encodeWithSelector(Tranche.initialize.selector, owner, address(acm), "srtVault", "srtUSDe", IERC20(address(USDe)), address(cdo)) + abi.encodeWithSelector( + Tranche.initialize.selector, + owner, + address(acm), + "srtVault", + "srtUSDe", + IERC20(address(USDe)), + address(cdo) + ) ) ) ); @@ -132,8 +147,10 @@ contract CDOTest is Test { ) ); sUSDeCooldownRequestImpl = new SUSDeCooldownRequestImpl(IsUSDe(address(sUSDe))); - address[] memory unstakeAddrs = new address[](1); unstakeAddrs[0] = address(sUSDe); - IUnstakeHandler[] memory unstakeImpls = new IUnstakeHandler[](1); unstakeImpls[0] = IUnstakeHandler(address(sUSDeCooldownRequestImpl)); + address[] memory unstakeAddrs = new address[](1); + unstakeAddrs[0] = address(sUSDe); + IUnstakeHandler[] memory unstakeImpls = new IUnstakeHandler[](1); + unstakeImpls[0] = IUnstakeHandler(address(sUSDeCooldownRequestImpl)); unstakeCooldown.setImplementations(unstakeAddrs, unstakeImpls); @@ -142,7 +159,14 @@ contract CDOTest is Test { address( new ERC1967Proxy( address(new SUSDeStrategy(IERC4626(address(sUSDe)))), - abi.encodeWithSelector(SUSDeStrategy.initialize.selector, owner, address(acm), address(cdo), address(erc20Cooldown), address(unstakeCooldown)) + abi.encodeWithSelector( + SUSDeStrategy.initialize.selector, + owner, + address(acm), + address(cdo), + address(erc20Cooldown), + address(unstakeCooldown) + ) ) ) ); @@ -154,7 +178,14 @@ contract CDOTest is Test { address( new ERC1967Proxy( address(new AprPairFeed()), - abi.encodeWithSelector(AprPairFeed.initialize.selector, owner, address(acm), address(sUSDeAprPairProvider), 4 hours, "Ethena CDO APR Pair") + abi.encodeWithSelector( + AprPairFeed.initialize.selector, + owner, + address(acm), + address(sUSDeAprPairProvider), + 4 hours, + "Ethena CDO APR Pair" + ) ) ) ); @@ -164,7 +195,9 @@ contract CDOTest is Test { address( new ERC1967Proxy( address(new Accounting()), - abi.encodeWithSelector(Accounting.initialize.selector, owner, address(acm), address(cdo), address(feed)) + abi.encodeWithSelector( + Accounting.initialize.selector, owner, address(acm), address(cdo), address(feed) + ) ) ) ); @@ -179,7 +212,6 @@ contract CDOTest is Test { acm.grantRole(cdo.PAUSER_ROLE(), owner); cdo.setActionStates(address(0), true, true); - trancheDepositor = TrancheDepositor( address( new ERC1967Proxy( @@ -193,18 +225,16 @@ contract CDOTest is Test { trancheDepositor.addCdo(cdo); } - function test_Flow() public { address router = address(0xE592427A0AEce92De3Edee1F18E0157C05861564); IERC20 usdt = IERC20(address(0xdAC17F958D2ee523a2206206994597C13D831ec7)); address usde = address(0x4c9EDD5852cd905f086C759E8383e09bff1E68B3); - trancheDepositor.addSwapInfo(usdt, TrancheDepositor.TAutoSwap(router, 100, 900)); + trancheDepositor.addSwapInfo(address(usdt), TrancheDepositor.TAutoSwap(router, 100, 900)); address someUsdtHolder = address(0x6AC38D1b2f0c0c3b9E816342b1CA14d91D5Ff60B); vm.startPrank(someUsdtHolder); - SafeERC20.forceApprove(usdt, address(trancheDepositor), type(uint256).max); TrancheDepositor.TDepositParams memory params = TrancheDepositor.TDepositParams({ @@ -219,5 +249,4 @@ contract CDOTest is Test { console2.log("Supports", trancheDepositor.tranches(address(jrtVault), address(usde))); trancheDepositor.deposit(IMetaVault(address(jrtVault)), usdt, 10e6, someUsdtHolder, params); } - } diff --git a/test/SwapContract.fork.t.sol b/test/SwapContract.fork.t.sol new file mode 100644 index 0000000..29dd3a1 --- /dev/null +++ b/test/SwapContract.fork.t.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {SwapContract} from "../contracts/tranches/SwapContract.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; + +/// @title SwapContract Fork Test +/// @notice Fork test for SwapContract using Uniswap V4 on Ethereum mainnet +/// @dev Based on https://getfoundry.sh/forge/tests/fork-testing/ +contract SwapContractForkTest is Test { + using StateLibrary for IPoolManager; + + // ============ Deployed Uniswap V4 Contracts on Mainnet ============ + address constant POOL_MANAGER = 0x000000000004444c5dc75cB358380D2e3dE08A90; + address constant UNIVERSAL_ROUTER = 0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af; + address constant PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3; + address constant POSITION_MANAGER = 0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e; + address constant QUOTER = 0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203; + address constant STATE_VIEW = 0x7fFE42C4a5DEeA5b0feC41C94C136Cf115597227; + + // ============ Common Mainnet Tokens ============ + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + + // ============ Test State ============ + SwapContract public swapContract; + IPoolManager public poolManager; + uint256 public mainnetFork; + + // Test accounts + address public alice; + address public bob; + + function setUp() public { + // Create mainnet fork + string memory rpcUrl = vm.envString("MAINNET_RPC_URL"); + mainnetFork = vm.createFork(rpcUrl); + vm.selectFork(mainnetFork); + + console2.log("Fork created at block:", block.number); + + // Set up test accounts + alice = makeAddr("alice"); + bob = makeAddr("bob"); + + // Deploy SwapContract with mainnet Uniswap V4 addresses + swapContract = new SwapContract(UNIVERSAL_ROUTER, POOL_MANAGER, PERMIT2); + + poolManager = IPoolManager(POOL_MANAGER); + + console2.log("SwapContract deployed at:", address(swapContract)); + console2.log("Using Universal Router:", UNIVERSAL_ROUTER); + console2.log("Using Pool Manager:", POOL_MANAGER); + console2.log("Using Permit2:", PERMIT2); + } + + /// @notice Verify the fork is set up correctly + function test_ForkSetup() public view { + assertEq(vm.activeFork(), mainnetFork); + + // Verify contracts exist on mainnet + assertTrue(POOL_MANAGER.code.length > 0, "PoolManager not deployed"); + assertTrue(UNIVERSAL_ROUTER.code.length > 0, "UniversalRouter not deployed"); + assertTrue(PERMIT2.code.length > 0, "Permit2 not deployed"); + + console2.log("All Uniswap V4 contracts verified on mainnet"); + } + + /// @notice Verify SwapContract is deployed correctly + function test_SwapContractDeployment() public view { + assertEq(address(swapContract.router()), UNIVERSAL_ROUTER); + assertEq(address(swapContract.poolManager()), POOL_MANAGER); + assertEq(address(swapContract.permit2()), PERMIT2); + } + + /// @notice Test WETH balance can be acquired via deal + function test_CanDealWETH() public { + uint256 amount = 10 ether; + deal(WETH, alice, amount); + + assertEq(IERC20(WETH).balanceOf(alice), amount); + console2.log("Alice WETH balance:", IERC20(WETH).balanceOf(alice)); + } + + /// @notice Test USDC balance can be acquired via deal + function test_CanDealUSDC() public { + uint256 amount = 10_000e6; // 10,000 USDC (6 decimals) + deal(USDC, alice, amount); + + assertEq(IERC20(USDC).balanceOf(alice), amount); + console2.log("Alice USDC balance:", IERC20(USDC).balanceOf(alice)); + } + + /// @notice Test approving tokens with Permit2 + function test_ApproveTokenWithPermit2() public { + // Give Alice some WETH + deal(WETH, address(swapContract), 10 ether); + + // Approve WETH for Permit2 and Universal Router + swapContract.approveTokenWithPermit2(WETH, type(uint160).max, uint48(block.timestamp + 1 days)); + + // Check the approval was set + uint256 permit2Allowance = IERC20(WETH).allowance(address(swapContract), PERMIT2); + assertEq(permit2Allowance, type(uint256).max); + + console2.log("Permit2 allowance set successfully"); + } + + /// @notice Test swap with a real WETH/USDC V4 pool if one exists + /// @dev This test will attempt to find and use a real V4 pool + function test_SwapExactInputSingle() public { + // Set up: Give the swap contract some WETH + uint128 amountIn = 0.1 ether; + deal(WETH, address(swapContract), amountIn); + + // Approve tokens via Permit2 + swapContract.approveTokenWithPermit2(WETH, type(uint160).max, uint48(block.timestamp + 1 days)); + + // Create a PoolKey for WETH/USDC + // Note: currency0 must be < currency1 (sorted by address) + // USDC (0xA0b8...) < WETH (0xC02a...) so USDC is currency0 + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(USDC), + currency1: Currency.wrap(WETH), + fee: 3000, // 0.3% fee tier + tickSpacing: 60, // Standard tick spacing for 0.3% + hooks: IHooks(address(0)) // No hooks + }); + + console2.log("Attempting swap..."); + console2.log("Input amount (WETH):", amountIn); + console2.log("currency0 (USDC):", USDC); + console2.log("currency1 (WETH):", WETH); + + // Try the swap - this may revert if no pool exists with these exact parameters + // In a real scenario, you would query the StateView to find valid pools + uint256 deadline = block.timestamp + 20; + + // Note: Since WETH is currency1 and we're swapping WETH -> USDC, + // we need to use zeroForOne = false (swapping token1 for token0) + // But swapExactInputSingle always uses zeroForOne = true + // So we need to use the more flexible swap() function + + try swapContract.swap( + key, + false, // zeroForOne = false because we're swapping WETH (currency1) -> USDC (currency0) + amountIn, + 0, // minAmountOut (0 for testing, use proper slippage in production) + deadline, + bytes("") + ) returns (uint256 amountOut) { + console2.log("Swap successful!"); + console2.log("Amount out (USDC):", amountOut); + + // Verify we received USDC + uint256 usdcBalance = IERC20(USDC).balanceOf(address(swapContract)); + assertGt(usdcBalance, 0, "Should have received USDC"); + } catch Error(string memory reason) { + console2.log("Swap failed with reason:", reason); + // This is expected if no V4 pool exists with these exact parameters + } catch (bytes memory) { + console2.log("Swap failed - pool may not exist with these parameters"); + // This is expected if no V4 pool exists + } + } + + /// @notice Test swap with native ETH pool (ETH/USDC) + /// @dev Uses Currency.wrap(address(0)) for native ETH + function test_SwapWithNativeETH() public { + // Set up: Give the swap contract some ETH + uint128 amountIn = 0.1 ether; + vm.deal(address(swapContract), amountIn); + + // Create a PoolKey for ETH/USDC + // Native ETH is represented as address(0) + // address(0) < USDC so ETH is currency0 + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(0)), // Native ETH + currency1: Currency.wrap(USDC), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + + console2.log("Attempting ETH -> USDC swap..."); + console2.log("Input amount (ETH):", amountIn); + + uint256 deadline = block.timestamp + 20; + + try swapContract.swap( + key, + true, // zeroForOne = true (ETH -> USDC) + amountIn, + 0, + deadline, + bytes("") + ) returns (uint256 amountOut) { + console2.log("Swap successful!"); + console2.log("Amount out (USDC):", amountOut); + } catch Error(string memory reason) { + console2.log("Swap failed with reason:", reason); + } catch (bytes memory) { + console2.log("Swap failed - ETH/USDC pool may not exist on V4"); + } + } + + /// @notice Helper to log pool state if needed + function _logPoolState(PoolKey memory key) internal view { + console2.log("=== Pool Key ==="); + console2.log("currency0:", Currency.unwrap(key.currency0)); + console2.log("currency1:", Currency.unwrap(key.currency1)); + console2.log("fee:", key.fee); + console2.log("tickSpacing:", key.tickSpacing); + console2.log("hooks:", address(key.hooks)); + } +} diff --git a/test/tranches/accounting/Apr.spec.ts b/test/tranches/accounting/Apr.spec.ts index 2cd79f9..fc02556 100644 --- a/test/tranches/accounting/Apr.spec.ts +++ b/test/tranches/accounting/Apr.spec.ts @@ -64,7 +64,7 @@ UTest.create({ ] }); }, - async '!drastically change the APR due to TVL ratio' () { + async 'drastically change the APR due to TVL ratio' () { let exec = new ProtocolExecutor($hh.test); await exec.run({ diff --git a/test/tranches/cooldowns/$testCooldown.ts b/test/tranches/cooldowns/$testCooldown.ts index 43740e7..895df1e 100644 --- a/test/tranches/cooldowns/$testCooldown.ts +++ b/test/tranches/cooldowns/$testCooldown.ts @@ -4,16 +4,18 @@ import { $acc } from '../utils/$acc'; import { $date } from 'dequanto/utils/$date'; import { $require } from 'dequanto/utils/$require'; import { $hh } from '../utils/$hh'; +import { ISharesCooldown } from '@0xc/hardhat/ISharesCooldown/ISharesCooldown'; export namespace $testCooldown { - export async function finalize (cooldown: ICooldown, token: ERC20 | any, to: $acc.Address) { - await cooldown.$receipt().finalize($hh.test.deployer, token.address, $acc.toAddress(to)); + export async function finalize (cooldown: Pick, token: ERC20 | any, to: $acc.Address) { + let tx = await cooldown.finalize($hh.test.deployer, token.address, $acc.toAddress(to)); + return await tx.wait(); } - export async function eqBalanceOf(cooldown: ICooldown, token: ERC20 | any, account: $acc.Address, amounts: { + export async function eqBalanceOf(cooldown: ICooldown | ISharesCooldown, token: ERC20 | any, account: $acc.Address, amounts: { pending?: bigint, claimable?: bigint nextUnlockAmount?: bigint diff --git a/test/tranches/cooldowns/ERC20Cooldown.spec.ts b/test/tranches/cooldowns/ERC20Cooldown.spec.ts index ba8b34e..512b6ac 100644 --- a/test/tranches/cooldowns/ERC20Cooldown.spec.ts +++ b/test/tranches/cooldowns/ERC20Cooldown.spec.ts @@ -82,7 +82,7 @@ UAction.create({ await transfer(erc20Cooldown, USDe, alice, bob.address, 21n, '7days'); - let result = await $promise.caught(() => $testCooldown.finalize(erc20Cooldown, USDe, bob.address)); + let result = await $promise.caught($testCooldown.finalize(erc20Cooldown, USDe, bob.address)); $require.match(/NothingToFinalize/, result.error?.message); await erc20Cooldown.$receipt().setCooldownDisabled(alice, USDe.address, true); diff --git a/test/tranches/cooldowns/Exit.spec.ts b/test/tranches/cooldowns/Exit.spec.ts new file mode 100644 index 0000000..010ead0 --- /dev/null +++ b/test/tranches/cooldowns/Exit.spec.ts @@ -0,0 +1,340 @@ +import { HardhatProvider } from 'dequanto/hardhat/HardhatProvider'; +import { UTest } from 'atma-utest' +import { $usde } from '../utils/$usde'; +import { $erc20 } from '../utils/$erc20'; +import { $tranche } from '../utils/$tranche'; +import { $hh } from '../utils/$hh'; +import { $require } from 'dequanto/utils/$require'; +import { $bigint } from 'dequanto/utils/$bigint'; +import { $erc4626 } from '../utils/$erc4626'; +import { $exitMode } from '@s/utils/$exitMode'; +import { $date } from 'dequanto/utils/$date'; +import { $ethena } from '../utils/$ethena'; +import { $promise } from 'dequanto/utils/$promise'; + + +let hh = new HardhatProvider(); +let alice = await hh.deployer(1); + +let { + jrtVault, + srtVault, + strategy, + erc20Cooldown, + unstakeCooldown, + sharesCooldown, + accounting, + cdo, + USDe, + sUSDe, +} = await $hh.test.deploy(); + +let { + configManager +} = await $hh.test.factory.ensureConfigManager(); + + +let { + client, + deployer +} = $hh.test; + +await $erc20.mint(USDe, deployer, alice, 1e6); + +//await strategy.$receipt().setCooldowns($hh.test.factory.accounts.timelock.admin, 0n, 0n); +await $ethena.setCooldownDuration(sUSDe, deployer, 0); +await accounting.$receipt().setMinimumJrtSrtRatio(deployer, $bigint.toWei(0.01)) +await accounting.$receipt().setMinimumJrtSrtRatioBuffer(deployer, $bigint.toWei(0.01)) + + +await $hh.test.factory.addRoles({ + [cdo.address]: [ + await sharesCooldown.COOLDOWN_WORKER_ROLE() + ] +}); + +await $hh.test.snapshot('exit'); + +UTest.create({ + async $after () { + await $hh.test.reset(); + }, + async $teardown () { + await $hh.test.reset('exit'); + }, + async 'coverage checks' () { + + // Alice deposits into jrt and srt vaults + await $usde.mint(USDe, alice, 1000.0); + await $tranche.deposit(jrtVault, alice, USDe, 300.0); + + // Senior === 0, the coverage must be uint32.max + let coverage = await cdo.coverage(); + $require.eq(coverage, 2 ** 32 - 1); + + // Senior === Junior, the coverage must be 100% + await $tranche.deposit(srtVault, alice, USDe, 300.0); + coverage = await cdo.coverage(); + $require.eq(coverage, 1e6); + }, + async 'junior redemption' () { + + await $tranche.deposit(jrtVault, alice, USDe, 100.0); + await $tranche.deposit(srtVault, alice, USDe, 100.0); + + await $exitMode.set(sharesCooldown, configManager, jrtVault.address, [ + { covPct: 5, feePct: 10, lock: $date.parseTimespan('1day', { get: 's' }) }, + { covPct: 10, feePct: 5, lock: $date.parseTimespan('8hours', { get: 's' }) }, + { covPct: 0, feePct: 1, lock: 0 }, + ]); + + await $hh.test.snapshot('exit-jrt'); + return UTest.create({ + async $teardown () { + await $hh.test.reset('exit-jrt'); + }, + async 'should exit with 1% fee' () { + await $util.ensureCoverage(20, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await jrtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(jrtVault, alice, shares); + let fee = $bigint.toEther(assets) * .01; + $require.eq(fee, 10 * 0.01, `Should accrue 1% fee`); + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + async 'should exit with 5% fee and 8hours shares-lock' () { + await $util.ensureCoverage(8, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await jrtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(jrtVault, alice, shares); + $require.eq(assetsFact, 0n); + let fee = $bigint.toEther(assets) * .05; + + await client.debug.mine('1hour'); + let { error } = await $promise.caught(sharesCooldown.$receipt().finalize(alice, jrtVault.address, alice.address)); + $require.match(/NothingToFinalize/, error?.message); + + await client.debug.mine('8hours'); + let assetsBefore = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalize(alice, jrtVault.address, alice.address); + assetsFact = await USDe.balanceOf(alice.address) - assetsBefore; + + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + async 'should exit with 10% fee and 1day shares-lock' () { + await $util.ensureCoverage(4, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await jrtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(jrtVault, alice, shares); + $require.eq(assetsFact, 0n); + let fee = $bigint.toEther(assets) * .10; + + await client.debug.mine('1hour'); + let { error } = await $promise.caught(sharesCooldown.$receipt().finalize(alice, jrtVault.address, alice.address)); + $require.match(/NothingToFinalize/, error?.message); + + await client.debug.mine('1day'); + let assetsBefore = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalize(alice, jrtVault.address, alice.address); + assetsFact = await USDe.balanceOf(alice.address) - assetsBefore; + + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + } + }) + }, + async 'senior redemption' () { + + await $tranche.deposit(jrtVault, alice, USDe, 100.0); + await $tranche.deposit(srtVault, alice, USDe, 100.0); + + await $exitMode.set(sharesCooldown, configManager, srtVault.address, [ + { covPct: 5, feePct: 10, lock: $date.parseTimespan('1day', { get: 's' }) }, + { covPct: 10, feePct: 5, lock: $date.parseTimespan('8hours', { get: 's' }) }, + { covPct: 0, feePct: 1, lock: 0 }, + ]); + + await $hh.test.snapshot('exit-srt'); + return UTest.create({ + async $teardown () { + await $hh.test.reset('exit-srt'); + }, + async 'should exit with 1% fee' () { + await $util.ensureCoverage(20, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await srtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(srtVault, alice, shares); + let fee = $bigint.toEther(assets) * .01; + $require.eq(fee, 10 * 0.01, `Should accrue 1% fee`); + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + async 'should exit with 5% fee and 8hours shares-lock' () { + await $util.ensureCoverage(8, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await srtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(srtVault, alice, shares); + $require.eq(assetsFact, 0n); + let fee = $bigint.toEther(assets) * .05; + + await client.debug.mine('1hour'); + let { error } = await $promise.caught(sharesCooldown.$receipt().finalize(alice, srtVault.address, alice.address)); + $require.match(/NothingToFinalize/, error?.message); + + await client.debug.mine('8hours'); + let assetsBefore = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalize(alice, srtVault.address, alice.address); + assetsFact = await USDe.balanceOf(alice.address) - assetsBefore; + + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + async 'should exit with 10% fee and 1day shares-lock' () { + await $util.ensureCoverage(4, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await srtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(srtVault, alice, shares); + $require.eq(assetsFact, 0n); + let fee = $bigint.toEther(assets) * .10; + + await client.debug.mine('1hour'); + let { error } = await $promise.caught(sharesCooldown.$receipt().finalize(alice, srtVault.address, alice.address)); + $require.match(/NothingToFinalize/, error?.message); + + await client.debug.mine('1day'); + let assetsBefore = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalize(alice, srtVault.address, alice.address); + assetsFact = await USDe.balanceOf(alice.address) - assetsBefore; + + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + } + }) + }, + async 'should use old exit fee flow' () { + await $tranche.deposit(jrtVault, alice, USDe, 100.0); + await $tranche.deposit(srtVault, alice, USDe, 100.0); + + await $hh.test.snapshot('exit-old-fee'); + return UTest.create({ + async $teardown () { + await $hh.test.reset('exit-old-fee'); + }, + async 'when no lockup bounds' () { + await $exitMode.set(sharesCooldown, configManager, srtVault.address, [ + { covPct: 0, feePct: 0, lock: 0 }, + { covPct: 0, feePct: 0, lock: 0 }, + { covPct: 0, feePct: 0, lock: 0 }, + ]); + await cdo.$receipt().setExitFees({ + address: configManager.address, + type: 'impersonated' + }, BigInt(0.003e18), BigInt(0.003e18)); + + await $util.ensureCoverage(20, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await srtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(srtVault, alice, shares); + let fee = $bigint.toEther(assets) * .003; + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + async 'when empty default bound' () { + await $exitMode.set(sharesCooldown, configManager, srtVault.address, [ + { covPct: 10, feePct: 2, lock: 60 }, + { covPct: 30, feePct: 0, lock: 120 }, + { covPct: 0, feePct: 0, lock: 0 }, + ]); + await cdo.$receipt().setExitFees({ + address: configManager.address, + type: 'impersonated' + }, BigInt(0.0025e18), BigInt(0.0025e18)); + + await $util.ensureCoverage(60, { jrt: 1000.0 }); + let shares = BigInt(10e18); + let assets = await srtVault.convertToAssets(shares); + let assetsFact = await $erc4626.redeem(srtVault, alice, shares); + let fee = $bigint.toEther(assets) * .0025; + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + } + }) + }, + async 'should early exit the shares cooldown' () { + await $tranche.deposit(jrtVault, alice, USDe, 100.0); + await $tranche.deposit(srtVault, alice, USDe, 100.0); + + await $exitMode.set(sharesCooldown, configManager, jrtVault.address, [ + { covPct: 70, feePct: 0, lock: $date.parseTimespan('8days', { get: 's' }) }, + ]); + await sharesCooldown.$receipt().setVaultEarlyExitFee(deployer, jrtVault.address, BigInt(0.01e18)); + + let amount = BigInt(10e18); + let assets = await jrtVault.convertToAssets(amount); + let assetsFact = await $erc4626.redeem(jrtVault, alice, 10.0); + $require.eq(assetsFact, 0n); + + + let balanceBefore = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalizeWithFee(alice, jrtVault.address, alice.address, 0n); + + assetsFact = await USDe.balanceOf(alice.address) - balanceBefore; + + let fee = $bigint.toEther(assets) * 0.01 * 8; + $require.eq($bigint.toEther(assetsFact) + fee, $bigint.toEther(assets)); + }, + + async 'should revert on exit when coverage is below minimumJrtSrtRatio' () { + await $tranche.deposit(jrtVault, alice, USDe, 100.0); + await $tranche.deposit(srtVault, alice, USDe, 100.0); + + await $exitMode.set(sharesCooldown, configManager, jrtVault.address, [ + { covPct: 1, feePct: 2 , lock: $date.parseTimespan('8days', { get: 's' }) }, + { covPct: 70, feePct: 1.5, lock: $date.parseTimespan('8days', { get: 's' }) }, + ]); + + let assetsFact = await $erc4626.redeem(jrtVault, alice, 10.0); + $require.eq(assetsFact, 0n); + + await accounting.$receipt().setMinimumJrtSrtRatioBuffer(deployer, BigInt(1.10e18)); + await accounting.$receipt().setMinimumJrtSrtRatio(deployer, BigInt(1.10e18)); + await client.debug.mine('10days'); + + // new redemption should fail + let { error } = await $promise.caught($erc4626.redeem(jrtVault, alice, 10.0)); + $require.match(/ERC4626ExceededMaxRedeem/, error.message); + + // finalization works + let before = await USDe.balanceOf(alice.address); + await sharesCooldown.$receipt().finalize(alice, jrtVault.address, alice.address); + let received = $bigint.toEther(await USDe.balanceOf(alice.address) - before); + + $require.eq(received, 10 - 10 * 0.015); + } +}) + + +namespace $util { + export async function ensureCoverage (cov: number, minimums?: { jrt?: number, srt?: number }) { + let { jrtNav, srtNav } = await cdo.totalAssetsUnlocked(); + let jrtNavEth = $bigint.toEther(jrtNav); + let srtNavEth = $bigint.toEther(srtNav); + + let current = jrtNavEth / srtNavEth * 100; + if (current < cov) { + let amount = cov / 100 * srtNavEth - jrtNavEth; + await $erc4626.deposit(jrtVault, alice, $bigint.toWei(amount)); + jrtNavEth += amount; + } else if (current > cov) { + let amount = jrtNavEth * 100 / cov - srtNavEth; + await $erc4626.deposit(srtVault, alice, $bigint.toWei(amount)); + srtNavEth += amount; + } + + let scale = 1; + if (minimums?.jrt > jrtNavEth) { + scale = minimums.jrt / jrtNavEth; + } + if (minimums?.srt > srtNavEth) { + scale = Math.max(scale, minimums.srt / srtNavEth); + } + if (scale > 1) { + await $erc4626.deposit(jrtVault, alice, $bigint.toWei(jrtNavEth * scale - jrtNavEth)); + await $erc4626.deposit(srtVault, alice, $bigint.toWei(srtNavEth * scale - srtNavEth)); + } + } +} diff --git a/test/tranches/cooldowns/SharesCooldown.spec.ts b/test/tranches/cooldowns/SharesCooldown.spec.ts new file mode 100644 index 0000000..85e02a1 --- /dev/null +++ b/test/tranches/cooldowns/SharesCooldown.spec.ts @@ -0,0 +1,270 @@ +import { HardhatProvider } from 'dequanto/hardhat/HardhatProvider'; +import { UAction } from 'atma-utest' +import { $erc20 } from '../utils/$erc20'; +import { $acc } from '../utils/$acc'; +import { TEth } from 'dequanto/models/TEth'; +import { $date } from 'dequanto/utils/$date'; +import { $require } from 'dequanto/utils/$require'; +import { $hh } from '../utils/$hh'; +import { $testCooldown } from './$testCooldown'; +import { $promise } from 'dequanto/utils/$promise'; +import { l } from 'dequanto/utils/$logger'; +import { $erc4626 } from '../utils/$erc4626'; +import { SharesCooldown } from '@0xc/hardhat/SharesCooldown/SharesCooldown'; +import { ERC4626 } from 'dequanto/prebuilt/openzeppelin/ERC4626'; +import { MockERC4626 } from '@0xc/hardhat/MockERC4626/MockERC4626'; +import { $exitMode } from '@s/utils/$exitMode'; + + +const _7DAYS = BigInt(7 * 24 * 60 * 60); + +let hh = new HardhatProvider(); +let alice = await hh.deployer(1); +let bob = await hh.deployer(2); + +let { + jrtVault, + srtVault, +} = await $hh.test.deploy(); + +let { USDe } = await $hh.test.factory.ensureEthena(); +let { contract: Vault } = await $hh.test.factory.ds.ensure(MockERC4626, { + arguments: [ USDe.address ] +}); +let { sharesCooldown, acm } = await $hh.test.factory.ensureCooldowns(); +let { strategy } = $hh.test.tranches; + +UAction.create({ + async $before () { + await $hh.test.factory.addRoles({ + [alice.address]: [ + await sharesCooldown.COOLDOWN_WORKER_ROLE(), + ] + }); + await sharesCooldown.$receipt().setTwoStepConfigManager($hh.test.deployer, alice.address); + await $erc4626.deposit(Vault, alice, 1000.0); + await $exitMode.set(sharesCooldown, alice, Vault.address, [ + { covPct: 100, lock: 60 } + ]); + await $hh.test.snapshot('sharesCooldown'); + }, + async $teardown() { + await $hh.test.client.debug.setAutomine(true); + await $hh.test.reset('sharesCooldown'); + }, + async $after() { + await $hh.test.reset(); + }, + async 'requestRedeem'() { + + await requestRedeem(sharesCooldown, Vault, alice, bob, BigInt(2e18), '60s'); + await $erc20.eqBalance(Vault, alice, 998.0); + await requestRedeem(sharesCooldown, Vault, alice, bob, BigInt(3e18), '120s'); + await $erc20.eqBalance(Vault, alice, 995.0); + + await $hh.test.mine(`30s`); + + // no transfers yet + await $erc20.eqBalance(Vault, bob, 0n); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: BigInt(5e18), + nextUnlockAmount: BigInt(2e18) + }); + + // fail to withdraw + const { error } = await $promise.caught($testCooldown.finalize(sharesCooldown, Vault, bob)); + $require.match(/NothingToFinalize/, error.message); + + // #1: 60s passed, withdraw 1. portion + await $hh.test.mine(`51s`); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: BigInt(3e18), + claimable: BigInt(2e18), + nextUnlockAmount: BigInt(3e18) + }); + + await $testCooldown.finalize(sharesCooldown, Vault, bob); + await $erc20.eqBalance(USDe, bob, BigInt(2e18)); + + // No balance yet + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { pending: BigInt(3e18), claimable: 0n, nextUnlockAmount: BigInt(3e18) }); + + // #2: 120s passed, withdraw 2. portion + await $hh.test.mine(`61s`); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { pending: 0n, claimable: BigInt(3e18), nextUnlockAmount: 0n }); + await $testCooldown.finalize(sharesCooldown, Vault, bob); + await $erc20.eqBalance(USDe, bob, BigInt(5e18)); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { pending: 0n, claimable: 0n, nextUnlockAmount: 0n }); + }, + async 'emergency disable'() { + await requestRedeem(sharesCooldown, Vault, alice, bob.address, 21n, '7days'); + let result = await $promise.caught($testCooldown.finalize(sharesCooldown, Vault, bob.address)); + $require.match(/NothingToFinalize/, result.error?.message); + + + await $exitMode.set(sharesCooldown, alice, Vault.address, []); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { pending: 0n, claimable: 21n, nextUnlockAmount: 0n, nextUnlockAt: 0n }); + await $testCooldown.finalize(sharesCooldown, Vault, bob.address); + await $erc20.eqBalance(USDe, bob, 21n); + }, + async 'ensure unstake request is reused within single block'() { + + await Vault.$receipt().transfer(alice, sharesCooldown.address, 50n); + + await $hh.test.client.debug.setAutomine(false); + await $promise.wait(200); + let tx1 = await sharesCooldown.requestRedeem(alice, Vault.address, alice.address, bob.address, 23n, 0n, 60); + let tx2 = await sharesCooldown.requestRedeem(alice, Vault.address, alice.address, bob.address, 27n, 0n, 60); + await $promise.wait(200); + await $hh.test.client.debug.setAutomine(true); + await $hh.test.client.debug.mine(1); + + let [ r1, r2 ] = await Promise.all([ + tx1.wait(), + tx2.wait() + ]); + + + l`1 request only, as 2 requests were made in the same block`; + $require.eq(r1.blockNumber, r2.blockNumber, 'different blocks'); + + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: 50n, + nextUnlockAmount: 50n, + totalRequests: 1n + }); + + await $hh.test.mine(`7days`); + await $testCooldown.finalize(sharesCooldown, Vault, bob.address); + await $erc20.eqBalance(USDe, bob, 50n); + }, + async 'max active requests'() { + await Vault.$receipt().transfer(alice, sharesCooldown.address, 80n); + + const MAX = $hh.isCoverage() ? 2 : 71; + for (let i = 0; i < MAX; i++) { + await sharesCooldown.$receipt().requestRedeem(alice, Vault.address, bob.address, bob.address, 1n, 0n, 7 * 24 * 60 * 60); + } + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: BigInt(MAX), + nextUnlockAmount: 1n, + totalRequests: BigInt(Math.min(MAX, 70)) + }); + await $hh.test.mine(`8days`); + await $testCooldown.finalize(sharesCooldown, Vault, bob.address); + await $erc20.eqBalance(USDe, bob, BigInt(MAX)); + }, + async 'max external requests'() { + await Vault.$receipt().transfer(alice, sharesCooldown.address, 80n); + + const MAX = $hh.isCoverage() ? 2 : 40; + const _7d = 7 * 24 * 60 * 60; + for (let i = 0; i < MAX; i++) { + await sharesCooldown.$receipt().requestRedeem(alice, Vault.address, alice.address, bob.address, 1n, 0n, _7d ); + } + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: 40n, + nextUnlockAmount: 1n, + totalRequests: 40n + }); + + let result = await $promise.caught(() => { + return sharesCooldown.$receipt().requestRedeem(alice, Vault.address, alice.address, bob.address, 1n, 0n, _7d); + }); + $require.match(/ExternalReceiverRequestLimitReached/, result.error?.message); + + await sharesCooldown.$receipt().requestRedeem(alice, Vault.address, bob.address, bob.address, 5n, 0n, _7d); + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + pending: 45n, + nextUnlockAmount: 1n, + totalRequests: 41n + }); + + await $hh.test.mine(`8days`); + await $testCooldown.finalize(sharesCooldown, Vault, bob.address); + await $erc20.eqBalance(USDe, bob, 45n); + }, + async 'cancel request' () { + await requestRedeem(sharesCooldown, Vault, alice, bob, BigInt(2e18), '60s'); + + let { error } = await $promise.caught(sharesCooldown.$receipt().cancel(alice, Vault.address, bob.address, 0n)); + $require.match(/OnlyOwner/, error.message); + + await $erc20.eqBalance(Vault, bob, 0); + + await sharesCooldown.$receipt().cancel(bob, Vault.address, bob.address, 0n); + await $erc20.eqBalance(Vault, bob, 2.0); + + await $testCooldown.eqBalanceOf(sharesCooldown, Vault, bob, { + totalRequests: 0n + }); + }, + async 'configure via TwoStepConfigManager' () { + let { configManager } = await $hh.test.factory.ensureConfigManager(); + let { client, deployer } = $hh.test; + + await sharesCooldown.$receipt().setTwoStepConfigManager(deployer, configManager.address); + + await $exitMode.propose(deployer, configManager, + [ + { covPct: .5, feePct: 1, lock: $date.parseTimespan('2days', { get: 's' }) }, + { covPct: 2.3, feePct: 0.5, lock: $date.parseTimespan('8hours', { get: 's' }) }, + { covPct: 0, feePct: 0.03, lock: 0 }, + ], + [ + { covPct: 2, feePct: 1, lock: $date.parseTimespan('3day', { get: 's' }) }, + { covPct: 40, feePct: 0.7, lock: $date.parseTimespan('7hours', { get: 's' }) }, + { covPct: 0, feePct: 0.15, lock: 0 }, + ] + ); + await client.debug.mine('2days'); + await $exitMode.execute(deployer, configManager); + + let jrt = await sharesCooldown.vaultExitBounds(jrtVault.address); + $require.eq(jrt.p0, 0.005e6); + $require.eq(jrt.p1, 0.023e6); + $require.eq(jrt.r0.feePpm, 0.01e6); + $require.eq(jrt.r0.sharesLock, 2 * 24 * 60 * 60); + $require.eq(jrt.r1.feePpm, 0.005e6); + $require.eq(jrt.r1.sharesLock, 8 * 60 * 60); + $require.eq(jrt.r2.feePpm, 0.0003e6); + + let srt = await sharesCooldown.vaultExitBounds(srtVault.address); + $require.eq(srt.p0, 0.02e6); + $require.eq(srt.p1, 0.4e6); + $require.eq(srt.r0.feePpm, 0.01e6); + $require.eq(srt.r0.sharesLock, 3 * 24 * 60 * 60); + $require.eq(srt.r1.feePpm, 0.007e6); + $require.eq(srt.r1.sharesLock, 7 * 60 * 60); + $require.eq(srt.r2.feePpm, 0.0015e6); + + + l`should cancel`; + await $exitMode.propose(deployer, configManager, + [ + { covPct: .5, feePct: 1.1, lock: $date.parseTimespan('2days', { get: 's' }) }, + { covPct: 2.3, feePct: 3, lock: $date.parseTimespan('8hours', { get: 's' }) }, + { covPct: 0, feePct: 0.3, lock: 0 }, + ], + [ + { covPct: 2, feePct: 1, lock: $date.parseTimespan('3day', { get: 's' }) }, + { covPct: 40, feePct: 3, lock: $date.parseTimespan('7hours', { get: 's' }) }, + { covPct: 0, feePct: 1.5, lock: 0 }, + ] + ); + + let pending = await configManager.pendingExitModeBoundsJrt(); + $require.eq(pending.bounds.p0, 0.005e6); + + await configManager.$receipt().cancelExitModeBoundsChange(deployer); + pending = await configManager.pendingExitModeBoundsJrt(); + $require.eq(pending.bounds.p0, 0); + } +}) + + +async function requestRedeem(cooldown: SharesCooldown, token: ERC4626 | any, from: TEth.IAccount, to: $acc.Address, amount: bigint, timespan: string) { + let cooldownSeconds = Math.floor($date.parseTimespan(timespan) / 1000); + await token.$receipt().transfer(from, cooldown.address, amount); + await cooldown.$receipt().requestRedeem(from, token.address, from.address, $acc.toAddress(to), amount, 0n, cooldownSeconds); +} diff --git a/test/tranches/e2e/v1_1_0/fees.spec.ts b/test/tranches/e2e/v1_1_0/fees.spec.ts index 857a706..9f69731 100644 --- a/test/tranches/e2e/v1_1_0/fees.spec.ts +++ b/test/tranches/e2e/v1_1_0/fees.spec.ts @@ -18,7 +18,6 @@ import { Addresses } from '@s/constants'; import { l } from 'dequanto/utils/$logger'; import { AprPairFeed } from '@0xc/hardhat/AprPairFeed/AprPairFeed'; import { $strata } from '../../utils/$strata'; -import { $promise } from 'dequanto/utils/$promise'; await $hh.test.init(); @@ -598,25 +597,30 @@ UTest.create({ } }, async 'final TVLs should be equal' () { - $require.eq(TVLs_v110_fees.jrtBefore, TVLs_v100_noFee.jrtBefore, '(before) JRT TVLs should be same'); - $require.eq(TVLs_v110_fees.srtBefore, TVLs_v100_noFee.srtBefore, '(before) SRT TVLs should be same'); - $require.eq(TVLs_v110_noFee.srtBefore, TVLs_v100_noFee.srtBefore, 'v110 vs v100 - (before) SRT TVLs should be same'); + function eqRounded(a: bigint, b: bigint, hint: string) { + $require.lte($bigint.abs(a - b), 1n, hint); + } + + eqRounded(TVLs_v110_fees.jrtBefore, TVLs_v100_noFee.jrtBefore, '(before) JRT TVLs should be same'); + eqRounded(TVLs_v110_fees.srtBefore, TVLs_v100_noFee.srtBefore, '(before) SRT TVLs should be same'); + + eqRounded(TVLs_v110_noFee.srtBefore, TVLs_v100_noFee.srtBefore, 'v110 vs v100 - (before) SRT TVLs should be same'); - $require.eq(TVLs_v110_fees.jrtWithdrawnGross, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT Withdrawn Gross final TVLs should be same'); - $require.eq(TVLs_v110_fees.srtWithdrawnGross, TVLs_v100_noFee.srtWithdrawnGross, 'SRT Withdrawn Gross final TVLs should be same'); + eqRounded(TVLs_v110_fees.jrtWithdrawnGross, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT Withdrawn Gross final TVLs should be same'); + eqRounded(TVLs_v110_fees.srtWithdrawnGross, TVLs_v100_noFee.srtWithdrawnGross, 'SRT Withdrawn Gross final TVLs should be same'); - $require.eq(TVLs_v110_noFee.jrtWithdrawnGross, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT Withdrawn Gross final TVLs should be same'); - $require.eq(TVLs_v110_noFee.srtWithdrawnGross, TVLs_v100_noFee.srtWithdrawnGross, 'SRT Withdrawn Gross final TVLs should be same'); + eqRounded(TVLs_v110_noFee.jrtWithdrawnGross, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT Withdrawn Gross final TVLs should be same'); + eqRounded(TVLs_v110_noFee.srtWithdrawnGross, TVLs_v100_noFee.srtWithdrawnGross, 'SRT Withdrawn Gross final TVLs should be same'); - $require.eq(TVLs_v110_fees.srtAfter - TVLs_v110_fees.srtFees, TVLs_v100_noFee.srtAfter, '(after) SRT TVLs should be same'); - $require.eq(TVLs_v110_fees.jrtAfter - TVLs_v110_fees.jrtFees, TVLs_v100_noFee.jrtAfter, '(after) JRT TVLs should be same'); + eqRounded(TVLs_v110_fees.srtAfter - TVLs_v110_fees.srtFees, TVLs_v100_noFee.srtAfter, '(after) SRT TVLs should be same'); + eqRounded(TVLs_v110_fees.jrtAfter - TVLs_v110_fees.jrtFees, TVLs_v100_noFee.jrtAfter, '(after) JRT TVLs should be same'); - $require.eq(TVLs_v110_noFee.srtAfter, TVLs_v100_noFee.srtAfter, '(after) SRT TVLs should be same'); - $require.eq(TVLs_v110_noFee.jrtAfter, TVLs_v100_noFee.jrtAfter, '(after) JRT TVLs should be same'); + eqRounded(TVLs_v110_noFee.srtAfter, TVLs_v100_noFee.srtAfter, '(after) SRT TVLs should be same'); + eqRounded(TVLs_v110_noFee.jrtAfter, TVLs_v100_noFee.jrtAfter, '(after) JRT TVLs should be same'); - $require.eq(TVLs_v110_fees.jrtWithdrawnNet + TVLs_v110_fees.jrtFees, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT'); - $require.eq(TVLs_v110_fees.srtWithdrawnNet + TVLs_v110_fees.srtFees, TVLs_v100_noFee.srtWithdrawnGross, 'SRT'); + eqRounded(TVLs_v110_fees.jrtWithdrawnNet + TVLs_v110_fees.jrtFees, TVLs_v100_noFee.jrtWithdrawnGross, 'JRT'); + eqRounded(TVLs_v110_fees.srtWithdrawnNet + TVLs_v110_fees.srtFees, TVLs_v100_noFee.srtWithdrawnGross, 'SRT'); } }) diff --git a/test/tranches/utils/$ethena.ts b/test/tranches/utils/$ethena.ts index 17324f9..f03d199 100644 --- a/test/tranches/utils/$ethena.ts +++ b/test/tranches/utils/$ethena.ts @@ -4,6 +4,7 @@ import { TEth } from 'dequanto/models/TEth'; import { ERC20 } from 'dequanto/prebuilt/openzeppelin/ERC20'; import { $bigint } from 'dequanto/utils/$bigint'; import { l } from 'dequanto/utils/$logger'; +import { $require } from 'dequanto/utils/$require'; export namespace $ethena { export async function distribute (sUSDe: MockStakedUSDe | any, USDe: ERC20 | any, distributor: TEth.IAccount, amount: number | bigint) { @@ -20,4 +21,9 @@ export namespace $ethena { l`Distributing yellow<${amount}>`; await sUSDe.$receipt().transferInRewards(distributor, amountWei); } + + export async function setCooldownDuration (sUSDe: MockStakedUSDe, sender: TEth.IAccount, seconds: number) { + $require.eq(sUSDe.client.platform, 'hardhat'); + await sUSDe.$receipt().setCooldownDuration(sender, seconds); + } }