Repository: alchemix-finance/v3 Branch: master Commit: 0aa8f99d9f44 Subpath: /src Files: 148 Total size: 1.8 MB Directory structure: └── src/ ├── AlTokenV3.sol ├── AlchemistAllocator.sol ├── AlchemistCurator.sol ├── AlchemistETHVault.sol ├── AlchemistGate.sol ├── AlchemistStrategyClassifier.sol ├── AlchemistTokenVault.sol ├── AlchemistV3.sol ├── AlchemistV3Position.sol ├── AlchemistV3PositionRenderer.sol ├── FrxEthEthDualOracleAggregatorAdapter.sol ├── MYTStrategy.sol ├── PerpetualGauge.sol ├── Transmuter.sol ├── adapters/ │ ├── AbstractFeeVault.sol │ └── EulerUSDCAdapter.sol ├── base/ │ ├── ErrorMessages.sol │ ├── Errors.sol │ └── TransmuterErrors.sol ├── external/ │ ├── AlEth.sol │ └── interfaces/ │ ├── IDetailedERC20.sol │ ├── ISettlerActions.sol │ └── IVelodromePair.sol ├── interfaces/ │ ├── IAlchemicToken.sol │ ├── IAlchemistCurator.sol │ ├── IAlchemistETHVault.sol │ ├── IAlchemistTokenVault.sol │ ├── IAlchemistV3.sol │ ├── IAlchemistV3Position.sol │ ├── IAllocator.sol │ ├── IERC20Burnable.sol │ ├── IERC20Metadata.sol │ ├── IERC20Minimal.sol │ ├── IERC20Mintable.sol │ ├── IERC721Enumerable.sol │ ├── IFeeVault.sol │ ├── IMYTStrategy.sol │ ├── IMetadataRenderer.sol │ ├── IStrategyClassifier.sol │ ├── ITokenAdapter.sol │ ├── ITransmuter.sol │ ├── IWETH.sol │ ├── IWhitelist.sol │ ├── IWstETHLike.sol │ ├── IYearnVaultV2.sol │ ├── IYieldToken.sol │ └── test/ │ └── ITestYieldToken.sol ├── libraries/ │ ├── FixedPointMath.sol │ ├── NFTMetadataGenerator.sol │ ├── SafeCast.sol │ ├── SafeERC20.sol │ ├── Sets.sol │ ├── StakingGraph.sol │ └── TokenUtils.sol ├── mocks/ │ ├── ERC20Mock.sol │ ├── FixedPointMathOld.sol │ ├── Pool.sol │ ├── Stake.sol │ └── StakingPoolMock.sol ├── router/ │ └── AlchemistRouter.sol ├── strategies/ │ ├── AaveStrategy.sol │ ├── ERC4626Strategy.sol │ ├── EtherfiEETHStrategy.sol │ ├── MoonwellStrategy.sol │ ├── OraclePricedSwapStrategy.sol │ ├── SFraxETHStrategy.sol │ ├── SiUSDStrategy.sol │ ├── TokeAutoStrategy.sol │ ├── WstETHEthereumStrategy.sol │ ├── WstETHL2Strategy.sol │ └── interfaces/ │ └── ITokemac.sol ├── test/ │ ├── AlchemistAllocator.t.sol │ ├── AlchemistCurator.t.sol │ ├── AlchemistETHVault.t.sol │ ├── AlchemistStrategyClassifier.t.sol │ ├── AlchemistTokenVault.t.sol │ ├── AlchemistV3.t.sol │ ├── AlchemistV3_6_decimals.t.sol │ ├── BaseStrategyTest.sol │ ├── DeploySFraxETHStrategyScript.t.sol │ ├── DeploySiUSDStrategiesScript.t.sol │ ├── DeployWstETHEthereumStrategyScript.t.sol │ ├── DeployWstETHL2StrategyScript.t.sol │ ├── DeployYvWETHStrategyScript.t.sol │ ├── FrxEthEthDualOracleAggregatorAdapter.t.sol │ ├── IntegrationTest.t.sol │ ├── Invariants/ │ │ ├── CrucibleTest.sol │ │ ├── FullSystemInvariantsTest.sol │ │ ├── HardenedInvariantsTest.sol │ │ └── InvariantBaseTest.t.sol │ ├── InvariantsTest.t.sol │ ├── MYTStrategy.t.sol │ ├── MultiStrategyARBETH.invariant.t.sol │ ├── MultiStrategyARBUSDC.invariant.t.sol │ ├── MultiStrategyETH.invariant.t.sol │ ├── MultiStrategyOPETH.invariant.t.sol │ ├── MultiStrategyOPUSDC.invariant.t.sol │ ├── MultiStrategyUSDC.invariant.t.sol │ ├── PerpetualGaugeTest.t.sol │ ├── README.md │ ├── Transmuter.t.sol │ ├── ZeroXSwapVerifier.t.sol │ ├── base/ │ │ ├── BaseStrategyMulti.sol │ │ ├── BaseStrategySimple.sol │ │ ├── StrategyHandler.sol │ │ ├── StrategyOps.sol │ │ ├── StrategyRevertUtils.sol │ │ ├── StrategySetup.sol │ │ └── StrategyTypes.sol │ ├── libraries/ │ │ ├── AlchemistNFTHelper.sol │ │ ├── CustomBase64.sol │ │ └── MYTTestHelper.sol │ ├── mocks/ │ │ ├── AlchemicTokenV3.sol │ │ ├── MockAlchemistAllocator.sol │ │ ├── MockAlchemistCurator.sol │ │ ├── MockMYTStrategy.sol │ │ ├── MockMYTVault.sol │ │ ├── MockWETH.sol │ │ ├── MockYieldToken.sol │ │ ├── TestERC20.sol │ │ ├── TestYieldToken.sol │ │ └── TokenAdapterMock.sol │ ├── router/ │ │ └── AlchemistRouter.t.sol │ └── strategies/ │ ├── AaveV3ARBUSDCStrategy.t.sol │ ├── AaveV3ARBWETHStrategy.t.sol │ ├── AaveV3ETHWETHStrategy.t.sol │ ├── AaveV3OPUSDCStrategy.t.sol │ ├── AaveV3OPWETHStrategy.t.sol │ ├── EtherfiEETHStrategy.t.sol │ ├── EulerARBUSDCStrategy.t.sol │ ├── EulerARBWETHStrategy.t.sol │ ├── EulerUSDCStrategy.t.sol │ ├── EulerWETHStrategy.t.sol │ ├── FluidARBUSDCStrategy.t.sol │ ├── SFraxETHStrategy.t.sol │ ├── SiUSDStrategy.t.sol │ ├── TokeAutoETHStrategy.t.sol │ ├── TokeAutoUSDStrategy.t.sol │ ├── WstethMainnetStrategy.t.sol │ ├── WstethOptimismStrategy.t.sol │ ├── YvUSDCStrategy.t.sol │ ├── YvWETHStrategy.t.sol │ └── utils/ │ └── offchain/ │ └── quotes/ │ ├── stethToWeth.json │ ├── wethToWsteth.json │ └── wstethToWeth.json └── utils/ ├── PermissionedProxy.sol ├── Whitelist.sol └── ZeroXSwapVerifier.sol ================================================ FILE CONTENTS ================================================ ================================================ FILE: src/AlTokenV3.sol ================================================ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.28; import {CrossChainCanonicalBase} from "lib/v2-foundry/src/CrossChainCanonicalBase.sol"; import {AlchemicTokenV2Base} from "lib/v2-foundry/src/AlchemicTokenV2Base.sol"; import {IXERC20} from "lib/v2-foundry/src/interfaces/external/connext/IXERC20.sol"; contract CrossChainCanonicalAlchemicTokenV3 is CrossChainCanonicalBase, AlchemicTokenV2Base { /// @custom:oz-upgrades-unsafe-allow constructor constructor() initializer {} function initialize( string memory name, string memory symbol ) external initializer { __CrossChainCanonicalBase_init( name, symbol, msg.sender ); __AlchemicTokenV2Base_init(); } function burn(uint256 amount) external returns (bool) { // If bridge is registered check limits and update accordingly. if (xBridges[msg.sender].burnerParams.maxLimit > 0) { uint256 currentLimit = burningCurrentLimitOf(msg.sender); if (amount > currentLimit) revert IXERC20.IXERC20_NotHighEnoughLimits(); _useBurnerLimits(msg.sender, amount); } _burn(msg.sender, amount); return true; } /// @dev Destroys `amount` tokens from `account`, deducting from the caller's allowance. /// /// @param account The address the burn tokens from. /// @param amount The amount of tokens to burn. function burnFrom(address account, uint256 amount) external returns (bool) { if (msg.sender != account) { uint256 newAllowance = allowance(account, msg.sender) - amount; _approve(account, msg.sender, newAllowance); } // If bridge is registered check limits and update accordingly. if (xBridges[msg.sender].burnerParams.maxLimit > 0) { uint256 currentLimit = burningCurrentLimitOf(msg.sender); if (amount > currentLimit) revert IXERC20.IXERC20_NotHighEnoughLimits(); _useBurnerLimits(msg.sender, amount); } _burn(account, amount); return true; } } ================================================ FILE: src/AlchemistAllocator.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {PermissionedProxy} from "./utils/PermissionedProxy.sol"; import {IAllocator} from "./interfaces/IAllocator.sol"; import {IMYTStrategy} from "./interfaces/IMYTStrategy.sol"; import {IStrategyClassifier} from "./interfaces/IStrategyClassifier.sol"; /** * @title AlchemistAllocator * @notice This contract is used to allocate and deallocate funds to and from MYT strategies * @notice The MYT is a Morpho V2 Vault, and each strategy is just a vault adapter which interfaces with a third party protocol */ contract AlchemistAllocator is PermissionedProxy, IAllocator { IVaultV2 immutable public vault; IStrategyClassifier immutable public strategyClassifier; constructor(address _vault, address _admin, address _operator, address _classifier) PermissionedProxy(_admin, _operator) { require(IVaultV2(_vault).asset() != address(0), "IV"); require(_classifier != address(0), "IC"); vault = IVaultV2(_vault); strategyClassifier = IStrategyClassifier(_classifier); } /** * @notice Allocate (uses ActionType.direct) * @param adapter The strategy adapter address * @param amount The amount to allocate */ function allocate(address adapter, uint256 amount) external { require(msg.sender == admin || operators[msg.sender], "PD"); _validateCaps(adapter, amount); IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; bytes memory data = abi.encode(params); vault.allocate(adapter, data, amount); } /** * @notice Deallocate (uses ActionType.direct) * @param adapter The strategy adapter address * @param amount The amount to deallocate */ function deallocate(address adapter, uint256 amount) external { require(msg.sender == admin || operators[msg.sender], "PD"); IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; bytes memory data = abi.encode(params); vault.deallocate(adapter, data, amount); } /** * @notice Allocate with swap (uses ActionType.swap) * @param adapter The strategy adapter address * @param amount The amount to allocate * @param txData The 0x swap calldata */ function allocateWithSwap(address adapter, uint256 amount, bytes memory txData) external { require(msg.sender == admin || operators[msg.sender], "PD"); _validateCaps(adapter, amount); IMYTStrategy.SwapParams memory swapParams = IMYTStrategy.SwapParams({ txData: txData, minIntermediateOut: 0 }); IMYTStrategy.VaultAdapterParams memory params = IMYTStrategy.VaultAdapterParams( {action: IMYTStrategy.ActionType.swap, swapParams: swapParams}); bytes memory data = abi.encode(params); vault.allocate(adapter, data, amount); } /** * @notice Deallocate with dex swap (uses ActionType.swap) * @param adapter The strategy adapter address * @param amount The amount to deallocate * @param txData The 0x swap calldata */ function deallocateWithSwap(address adapter, uint256 amount, bytes memory txData) external { require(msg.sender == admin || operators[msg.sender], "PD"); IMYTStrategy.SwapParams memory swapParams = IMYTStrategy.SwapParams({ txData: txData, minIntermediateOut: 0 }); IMYTStrategy.VaultAdapterParams memory params = IMYTStrategy.VaultAdapterParams( {action: IMYTStrategy.ActionType.swap, swapParams: swapParams}); bytes memory data = abi.encode(params); vault.deallocate(adapter, data, amount); } /** * @notice Deallocate with unwrap + dex swap (uses ActionType.unwrapAndSwap) * @param adapter The strategy adapter address * @param amount The amount to deallocate * @param txData The 0x swap calldata * @param minIntermediateOut The intermediate asset to produce from unwrap (use quote's sellAmount) */ function deallocateWithUnwrapAndSwap(address adapter, uint256 amount, bytes memory txData, uint256 minIntermediateOut) external { require(msg.sender == admin || operators[msg.sender], "PD"); IMYTStrategy.SwapParams memory swapParams = IMYTStrategy.SwapParams({ txData: txData, minIntermediateOut: minIntermediateOut }); IMYTStrategy.VaultAdapterParams memory params = IMYTStrategy.VaultAdapterParams( {action: IMYTStrategy.ActionType.unwrapAndSwap, swapParams: swapParams}); bytes memory data = abi.encode(params); vault.deallocate(adapter, data, amount); } /// @notice Set the vault liquidity adapter and calldata used by deposit/withdraw flows. function setLiquidityAdapter(address adapter, bytes memory data) external { require(msg.sender == admin || operators[msg.sender], "PD"); vault.setLiquidityAdapterAndData(adapter, data); } /** * @notice Validate the caps for the given adapter and amount * @param adapter The strategy adapter address * @param amount The amount to validate */ function _validateCaps(address adapter, uint256 amount) internal view { bytes32 id = IMYTStrategy(adapter).adapterId(); uint256 absoluteCap = vault.absoluteCap(id); uint256 relativeCap = vault.relativeCap(id); // get risk caps uint256 strategyId = uint256(id); uint8 riskLevel = strategyClassifier.getStrategyRiskLevel(strategyId); uint256 globalRiskCapPct = strategyClassifier.getGlobalCap(riskLevel); uint256 localRiskCapPct = strategyClassifier.getIndividualCap(strategyId); // Convert relativeCap (WAD) to absolute value (WEI) uint256 totalAssets = vault.totalAssets(); uint256 absoluteValueOfRelativeCap = (totalAssets * relativeCap) / 1e18; // Convert risk caps from WAD percentages to absolute values uint256 globalRiskCap = (totalAssets * globalRiskCapPct) / 1e18; uint256 localRiskCap = (totalAssets * localRiskCapPct) / 1e18; // Calculate limit cap as the minimum of vault caps uint256 limit = absoluteCap < absoluteValueOfRelativeCap ? absoluteCap : absoluteValueOfRelativeCap; // Enforce global risk cap (aggregate across all strategies in this risk class) uint256 currentRiskAllocation = 0; uint256 len = vault.adaptersLength(); for (uint256 i = 0; i < len; i++) { address stratAdapter = vault.adapters(i); bytes32 stratId = IMYTStrategy(stratAdapter).adapterId(); // Check if the strategy belongs to the same risk level if (strategyClassifier.getStrategyRiskLevel(uint256(stratId)) == riskLevel) { currentRiskAllocation += vault.allocation(stratId); } } // Check if the proposed allocation exceeds the remaining capacity of the global risk cap uint256 remainingGlobal = currentRiskAllocation < globalRiskCap ? globalRiskCap - currentRiskAllocation : 0; require(amount <= remainingGlobal, EffectiveCap(amount, remainingGlobal)); // Apply local risk cap constraint for operators if (msg.sender != admin) { // caller is operator, further constrain by local risk cap limit = limit < localRiskCap ? limit : localRiskCap; } // Ensure the requested amount does not exceed the calculated individual strategy limit require(vault.allocation(id) + amount <= limit, EffectiveCap(amount, limit)); } function setMaxRate(uint256 rate) external onlyAdmin { vault.setMaxRate(rate); } } ================================================ FILE: src/AlchemistCurator.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IVaultV2} from "../lib/vault-v2/src/interfaces/IVaultV2.sol"; import {PermissionedProxy} from "./utils/PermissionedProxy.sol"; import {IAlchemistCurator} from "./interfaces/IAlchemistCurator.sol"; /** * @title AlchemistCurator * @notice This contract is used to update MYT caps and add/remove strategies to the MYT * @notice The MYT is a Morpho V2 Vault, and each strategy is just a vault adapter which interfaces with a third party protocol */ interface IMYTStrategyMinimal { function getIdData() external returns (bytes memory); } contract AlchemistCurator is IAlchemistCurator, PermissionedProxy { // map of myt adapter(strategy) address to myt address mapping(address => address) public adapterToMYT; constructor(address _admin, address _operator) PermissionedProxy(_admin, _operator) { } function submitSetStrategy(address adapter, address myt) external onlyOperator { require(adapter != address(0), "INVALID_ADDRESS"); require(myt != address(0), "INVALID_ADDRESS"); _submitSetStrategy(adapter, myt); } function setStrategy(address adapter, address myt) external onlyOperator { require(adapter != address(0), "INVALID_ADDRESS"); require(myt != address(0), "INVALID_ADDRESS"); _addStrategy(adapter, myt); } function submitRemoveStrategy(address adapter, address myt) external onlyOperator { require(adapter != address(0), "INVALID_ADDRESS"); require(myt != address(0), "INVALID_ADDRESS"); _submitRemoveStrategy(adapter, myt); } function removeStrategy(address adapter, address myt) external onlyOperator { require(adapter != address(0), "INVALID_ADDRESS"); require(myt != address(0), "INVALID_ADDRESS"); _removeStrategy(adapter, myt); // remove } function _submitSetStrategy(address adapter, address myt) internal { IVaultV2 vault = IVaultV2(myt); bytes memory data = abi.encodeCall(IVaultV2.addAdapter, adapter); vault.submit(data); emit SubmitSetStrategy(adapter, myt); } function _submitRemoveStrategy(address adapter, address myt) internal { IVaultV2 vault = IVaultV2(myt); bytes memory data = abi.encodeCall(IVaultV2.removeAdapter, adapter); vault.submit(data); emit SubmitRemoveStrategy(adapter, myt); } function _addStrategy(address adapter, address myt) internal { adapterToMYT[adapter] = myt; IVaultV2 vault = _vault(adapter); vault.addAdapter(adapter); emit StrategyAdded(adapter, myt); } function _removeStrategy(address adapter, address myt) internal { IVaultV2 vault = _vault(adapter); vault.removeAdapter(adapter); delete adapterToMYT[adapter]; emit StrategyRemoved(adapter, myt); } function decreaseAbsoluteCap(address adapter, uint256 amount) external onlyAdmin { bytes memory id = IMYTStrategyMinimal(adapter).getIdData(); _decreaseAbsoluteCap(adapter, id, amount); } function decreaseRelativeCap(address adapter, uint256 amount) external onlyAdmin { bytes memory id = IMYTStrategyMinimal(adapter).getIdData(); _decreaseRelativeCap(adapter, id, amount); } function _decreaseRelativeCap(address adapter, bytes memory id, uint256 amount) internal { IVaultV2 vault = _vault(adapter); vault.decreaseRelativeCap(id, amount); emit DecreaseRelativeCap(adapter, amount, id); } function _decreaseAbsoluteCap(address adapter, bytes memory id, uint256 amount) internal { IVaultV2 vault = _vault(adapter); vault.decreaseAbsoluteCap(id, amount); emit DecreaseAbsoluteCap(adapter, amount, id); } function increaseAbsoluteCap(address adapter, uint256 amount) external onlyAdmin { bytes memory id = IMYTStrategyMinimal(adapter).getIdData(); _increaseAbsoluteCap(adapter, id, amount); } function increaseRelativeCap(address adapter, uint256 amount) external onlyAdmin { bytes memory id = IMYTStrategyMinimal(adapter).getIdData(); _increaseRelativeCap(adapter, id, amount); } function submitIncreaseAbsoluteCap(address adapter, uint256 amount) external onlyAdmin { bytes memory id = IMYTStrategyMinimal(adapter).getIdData(); _submitIncreaseAbsoluteCap(adapter, id, amount); } function submitIncreaseRelativeCap(address adapter, uint256 amount) external onlyAdmin { bytes memory id = IMYTStrategyMinimal(adapter).getIdData(); _submitIncreaseRelativeCap(adapter, id, amount); } function _increaseAbsoluteCap(address adapter, bytes memory id, uint256 amount) internal { IVaultV2 vault = _vault(adapter); vault.increaseAbsoluteCap(id, amount); emit IncreaseAbsoluteCap(adapter, amount, id); } function _increaseRelativeCap(address adapter, bytes memory id, uint256 amount) internal { IVaultV2 vault = _vault(adapter); vault.increaseRelativeCap(id, amount); emit IncreaseRelativeCap(adapter, amount, id); } function _submitIncreaseAbsoluteCap(address adapter, bytes memory id, uint256 amount) internal { bytes memory data = abi.encodeCall(IVaultV2.increaseAbsoluteCap, (id, amount)); _vaultSubmit(adapter, data); emit SubmitIncreaseAbsoluteCap(adapter, amount, id); } function _submitIncreaseRelativeCap(address adapter, bytes memory id, uint256 amount) internal { bytes memory data = abi.encodeCall(IVaultV2.increaseRelativeCap, (id, amount)); _vaultSubmit(adapter, data); emit SubmitIncreaseRelativeCap(adapter, amount, id); } function submitSetAllocator(address myt, address allocator, bool v) external onlyAdmin { bytes memory data = abi.encodeCall(IVaultV2.setIsAllocator, (allocator, v)); IVaultV2(myt).submit(data); emit SubmitSetAllocator(allocator, v); } function submitSetForceDeallocatePenalty(address adapter, address myt, uint256 penalty) external onlyAdmin { bytes memory data = abi.encodeCall(IVaultV2.setForceDeallocatePenalty, (adapter, penalty)); IVaultV2(myt).submit(data); emit SubmitSetForceDeallocatePenalty(adapter, myt, penalty); } function submitSetPerformanceFeeRecipient(address myt, address recipient) external onlyAdmin { bytes memory data = abi.encodeCall(IVaultV2.setPerformanceFeeRecipient, (recipient)); IVaultV2(myt).submit(data); emit SubmitSetPerformanceFeeRecipient(myt, recipient); } function submitSetPerformanceFee(address myt, uint256 fee) external onlyAdmin { bytes memory data = abi.encodeCall(IVaultV2.setPerformanceFee, (fee)); IVaultV2(myt).submit(data); emit SubmitSetPerformanceFee(myt, fee); } function _vaultSubmit(address adapter, bytes memory data) internal { IVaultV2 vault = _vault(adapter); vault.submit(data); } function _vault(address adapter) internal view returns (IVaultV2) { require(adapterToMYT[adapter] != address(0), "INVALID_ADDRESS"); return IVaultV2(adapterToMYT[adapter]); } } ================================================ FILE: src/AlchemistETHVault.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IWETH} from "./interfaces/IWETH.sol"; import {AbstractFeeVault} from "./adapters/AbstractFeeVault.sol"; /** * @title AlchemistETHVault * @notice A simple vault for ETH/WETH deposits that only allows withdrawals by authorized parties * @dev Supports both native ETH and WETH deposits */ contract AlchemistETHVault is AbstractFeeVault, ReentrancyGuard { using SafeERC20 for IERC20; // Error for failed transfers error TransferFailed(); /** * @param _weth Address of the WETH contract * @param _alchemist Address of the AlchemistV3 contract * @param _owner Address of the owner */ constructor(address _weth, address _alchemist, address _owner) AbstractFeeVault(_weth, _alchemist, _owner) {} /** * @notice Get the total deposits in the vault * @return Total deposits */ function totalDeposits() public view override returns (uint256) { return address(this).balance; } /** * @notice Deposit ETH into the vault */ function deposit() external payable nonReentrant { if (msg.value == 0) revert ZeroAmount(); _deposit(msg.sender, msg.value); } /** * @notice Receive ETH into the vault */ receive() external payable {} /** * @notice Deposit WETH into the vault (automatically unwraps to ETH) * @param amount Amount of WETH to deposit */ function depositWETH(uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); // Transfer WETH from sender to this contract IERC20(token).safeTransferFrom(msg.sender, address(this), amount); // Unwrap WETH to ETH IWETH(token).withdraw(amount); // Record the deposit _deposit(msg.sender, amount); } /** * @notice Internal deposit logic * @param depositor Address of the depositor * @param amount Amount deposited */ function _deposit(address depositor, uint256 amount) internal { emit Deposited(depositor, amount); } /** * @notice Withdraw funds from the vault to a target address (always sends ETH) * @param recipient Address to receive the funds * @param amount Amount to withdraw */ function withdraw(address recipient, uint256 amount) external override onlyAuthorized nonReentrant { _checkNonZeroAddress(recipient); if (amount == 0) revert ZeroAmount(); // Check if the vault has enough balance if (amount > address(this).balance) revert InsufficientBalance(); // Send as native ETH (bool success,) = recipient.call{value: amount}(""); if (!success) revert TransferFailed(); emit Withdrawn(recipient, amount); } } ================================================ FILE: src/AlchemistGate.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "@openzeppelin/contracts/access/Ownable.sol"; contract AlchemistGate is Ownable { mapping(address => mapping( address => bool)) public authorized; constructor(address _owner) Ownable(_owner) {} function setAuthorization(address _vault, address _to, bool value) external onlyOwner { authorized[_vault][_to] = value; } } ================================================ FILE: src/AlchemistStrategyClassifier.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IStrategyClassifier} from "./interfaces/IStrategyClassifier.sol"; /** * @title AlchemistStrategyClassifier * @notice This contract is used to classify strategies based on their risk level and set the respective caps * @notice The MYT is a Morpho V2 Vault, and each strategy is just a vault adapter which interfaces with a third party protocol */ contract AlchemistStrategyClassifier is IStrategyClassifier { address public admin; address public pendingAdmin; /** * @notice globalCap is the maximum combined allocation for ALL strategies of this risk type, in WAD (1e18 = 100%). * @notice localCap is the maximum allocation for a SINGLE strategy in this risk class, in WAD (1e18 = 100%). * @dev Example: MEDIUM with globalCap=0.4e18 and localCap=0.25e18 means each MEDIUM strategy is capped at 25% * of totalAssets individually, and all MEDIUM strategies together cannot exceed 40% of totalAssets. */ struct RiskClass { uint256 globalCap; // Max combined allocation for all strategies in this class (WAD) uint256 localCap; // Max allocation for a single strategy in this class (WAD) } /// riskLevel => RiskClass data mapping(uint8 => RiskClass) public riskClasses; /// strategyId => riskLevel mapping(uint256 => uint8) public strategyRiskLevel; // ===== Constructor ===== constructor(address _admin) { require(_admin != address(0), "IA"); admin = _admin; // Initialize defaults (can be updated by admin later) riskClasses[0] = RiskClass(1e18, 1e18); // Low risk riskClasses[1] = RiskClass(4e17, 25e16); // Medium risk riskClasses[2] = RiskClass(1e17, 1e17); // High risk pendingAdmin = address(0); } // ===== Admin Management ===== function transferOwnership(address _newAdmin) external { require(msg.sender == admin, "PD"); pendingAdmin = _newAdmin; } function acceptOwnership() external { require(msg.sender == pendingAdmin, "PD"); admin = pendingAdmin; pendingAdmin = address(0); emit AdminChanged(admin); } // ===== Risk Class Management ===== function setRiskClass(uint8 classId, uint256 globalCap, uint256 localCap) external { require(msg.sender == admin, "PD"); riskClasses[classId] = RiskClass(globalCap, localCap); emit RiskClassModified(classId, globalCap, localCap); } function assignStrategyRiskLevel(uint256 strategyId, uint8 riskLevel) external { require(msg.sender == admin, "PD"); strategyRiskLevel[strategyId] = riskLevel; } // ===== IStrategyClassifier Interface Implementation ===== /// @notice Returns the maximum allowed allocation for a single strategy (WAD percentage) function getIndividualCap(uint256 strategyId) external view override returns (uint256) { uint8 riskLevel = strategyRiskLevel[strategyId]; return riskClasses[riskLevel].localCap; } /// @notice Returns the maximum allowed combined allocation for all strategies in a risk class (WAD percentage) function getGlobalCap(uint8 riskLevel) external view override returns (uint256) { return riskClasses[riskLevel].globalCap; } /// @notice Returns the risk level of a given strategy function getStrategyRiskLevel(uint256 strategyId) external view override returns (uint8) { return strategyRiskLevel[strategyId]; } } ================================================ FILE: src/AlchemistTokenVault.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./base/Errors.sol"; import "./adapters/AbstractFeeVault.sol"; /** * @title AlchemistTokenVault * @notice A vault that holds a specific ERC20 token, allowing anyone to deposit * but only authorized parties to withdraw. */ contract AlchemistTokenVault is AbstractFeeVault { using SafeERC20 for IERC20; /** * @notice Constructor initializes the token vault * @param _token The ERC20 token managed by this vault * @param _alchemist The Alchemist contract address that will be authorized * @param _owner The owner of the vault */ constructor(address _token, address _alchemist, address _owner) AbstractFeeVault(_token, _alchemist, _owner) {} /** * @notice Allows anyone to deposit tokens into the vault * @param amount The amount of tokens to deposit */ function deposit(uint256 amount) external { _checkNonZeroAmount(amount); IERC20(token).safeTransferFrom(msg.sender, address(this), amount); emit Deposited(msg.sender, amount); } /** * @notice Allows only authorized accounts to withdraw tokens * @param recipient The address to receive the tokens * @param amount The amount of tokens to withdraw */ function withdraw(address recipient, uint256 amount) external override onlyAuthorized { _checkNonZeroAddress(recipient); _checkNonZeroAmount(amount); IERC20(token).safeTransfer(recipient, amount); emit Withdrawn(recipient, amount); } /** * @notice Get the total deposits in the vault * @return Total deposits */ function totalDeposits() public view override returns (uint256) { return IERC20(token).balanceOf(address(this)); } } ================================================ FILE: src/AlchemistV3.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "./interfaces/IAlchemistV3.sol"; import {ITransmuter} from "./interfaces/ITransmuter.sol"; import {IAlchemistV3Position} from "./interfaces/IAlchemistV3Position.sol"; import {IFeeVault} from "./interfaces/IFeeVault.sol"; import {TokenUtils} from "./libraries/TokenUtils.sol"; import {Initializable} from "../lib/openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {Unauthorized, IllegalArgument, IllegalState, MissingInputData} from "./base/Errors.sol"; import {IERC20} from "../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {IVaultV2} from "../lib/vault-v2/src/interfaces/IVaultV2.sol"; import {FixedPointMath} from "./libraries/FixedPointMath.sol"; /// @title AlchemistV3 /// @author Alchemix Finance /// /// For Juris, Graham, and Marcus contract AlchemistV3 is IAlchemistV3, Initializable { uint256 public constant BPS = 10_000; uint256 public constant FIXED_POINT_SCALAR = 1e18; uint256 public constant ONE_Q128 = uint256(1) << 128; /// @inheritdoc IAlchemistV3Immutables string public constant version = "3.0.0"; /// @inheritdoc IAlchemistV3State address public admin; /// @inheritdoc IAlchemistV3State address public alchemistFeeVault; /// @inheritdoc IAlchemistV3Immutables address public debtToken; /// @inheritdoc IAlchemistV3State address public myt; /// @inheritdoc IAlchemistV3State uint256 public underlyingConversionFactor; /// @inheritdoc IAlchemistV3State uint256 public cumulativeEarmarked; /// @inheritdoc IAlchemistV3State uint256 public depositCap; /// @inheritdoc IAlchemistV3State uint256 public lastEarmarkBlock; /// @inheritdoc IAlchemistV3State uint256 public lastRedemptionBlock; /// @inheritdoc IAlchemistV3State uint256 public lastTransmuterTokenBalance; /// @inheritdoc IAlchemistV3State uint256 public minimumCollateralization; /// @inheritdoc IAlchemistV3State uint256 public collateralizationLowerBound; /// @inheritdoc IAlchemistV3State uint256 public globalMinimumCollateralization; /// @inheritdoc IAlchemistV3State uint256 public liquidationTargetCollateralization; /// @inheritdoc IAlchemistV3State uint256 public totalDebt; /// @inheritdoc IAlchemistV3State uint256 public totalSyntheticsIssued; /// @inheritdoc IAlchemistV3State uint256 public protocolFee; /// @inheritdoc IAlchemistV3State uint256 public liquidatorFee; /// @inheritdoc IAlchemistV3State uint256 public repaymentFee; /// @inheritdoc IAlchemistV3State address public alchemistPositionNFT; /// @inheritdoc IAlchemistV3State address public protocolFeeReceiver; /// @inheritdoc IAlchemistV3State address public underlyingToken; /// @inheritdoc IAlchemistV3State address public tokenAdapter; /// @inheritdoc IAlchemistV3State address public transmuter; /// @inheritdoc IAlchemistV3State address public pendingAdmin; /// @inheritdoc IAlchemistV3State bool public depositsPaused; /// @inheritdoc IAlchemistV3State bool public loansPaused; /// @inheritdoc IAlchemistV3State mapping(address => bool) public guardians; /// @dev Total debt retired through Transmuter-driven redemptions. uint256 private _totalRedeemedDebt; /// @dev Total MYT shares removed from global accounting by redemptions and redemption fees. uint256 private _totalRedeemedSharesOut; /// @dev Packed earmark weight used to track how much live unearmarked debt survives each earmark step. uint256 private _earmarkWeight; /// @dev Packed redemption weight used to track survival of earmarked debt across redemptions. uint256 private _redemptionWeight; /// @dev Accumulator used to reconstruct earmarked debt survival across redemption windows. uint256 private _survivalAccumulator; /// @dev Total MYT shares currently assigned to open positions. /// This intentionally excludes any unrelated MYT balance the contract may temporarily hold. uint256 private _mytSharesDeposited; /// @dev Transmuter MYT balance increases not yet applied as cover in `_earmark()`. uint256 private _pendingCoverShares; /// @dev User accounts mapping(uint256 => Account) private _accounts; /// @dev Redemption weight snapshot at the start of each earmark epoch. mapping(uint256 => uint256) private _earmarkEpochStartRedemptionWeight; /// @dev Survival accumulator snapshot at the start of each earmark epoch. mapping(uint256 => uint256) private _earmarkEpochStartSurvivalAccumulator; uint256 private constant _REDEMPTION_INDEX_BITS = 129; uint256 private constant _REDEMPTION_INDEX_MASK = (uint256(1) << _REDEMPTION_INDEX_BITS) - 1; uint256 private constant _EARMARK_INDEX_BITS = 129; uint256 private constant _EARMARK_INDEX_MASK = (uint256(1) << _EARMARK_INDEX_BITS) - 1; modifier onlyAdmin() { _onlyAdmin(); _; } modifier onlyAdminOrGuardian() { _onlyAdminOrGuardian(); _; } modifier onlyTransmuter() { _onlyTransmuter(); _; } constructor() initializer {} function initialize(AlchemistInitializationParams calldata params) external initializer { _checkArgument(params.protocolFee <= BPS); _checkArgument(params.liquidatorFee <= BPS); _checkArgument(params.repaymentFee <= BPS); _checkArgument(params.liquidationTargetCollateralization >= params.minimumCollateralization); debtToken = params.debtToken; underlyingToken = params.underlyingToken; underlyingConversionFactor = 10 ** (TokenUtils.expectDecimals(params.debtToken) - TokenUtils.expectDecimals(params.underlyingToken)); depositCap = params.depositCap; minimumCollateralization = params.minimumCollateralization; globalMinimumCollateralization = params.globalMinimumCollateralization; collateralizationLowerBound = params.collateralizationLowerBound; liquidationTargetCollateralization = params.liquidationTargetCollateralization; admin = params.admin; transmuter = params.transmuter; protocolFee = params.protocolFee; protocolFeeReceiver = params.protocolFeeReceiver; liquidatorFee = params.liquidatorFee; repaymentFee = params.repaymentFee; lastEarmarkBlock = block.number; lastRedemptionBlock = block.number; myt = params.myt; // Initialize packed weights at full survival. _redemptionWeight = ONE_Q128; _earmarkWeight = ONE_Q128; // Initialize epoch checkpoints. _earmarkEpochStartRedemptionWeight[0] = _redemptionWeight; _earmarkEpochStartSurvivalAccumulator[0] = _survivalAccumulator; } /// @notice Sets the NFT position token, callable by admin. function setAlchemistPositionNFT(address nft) external onlyAdmin { if (nft == address(0)) { revert AlchemistV3NFTZeroAddressError(); } if (alchemistPositionNFT != address(0)) { revert AlchemistV3NFTAlreadySetError(); } alchemistPositionNFT = nft; } /// @inheritdoc IAlchemistV3AdminActions function setAlchemistFeeVault(address value) external onlyAdmin { if (IFeeVault(value).token() != underlyingToken) { revert AlchemistVaultTokenMismatchError(); } alchemistFeeVault = value; emit AlchemistFeeVaultUpdated(value); } /// @inheritdoc IAlchemistV3AdminActions function setPendingAdmin(address value) external onlyAdmin { pendingAdmin = value; emit PendingAdminUpdated(value); } /// @inheritdoc IAlchemistV3AdminActions function acceptAdmin() external { _checkState(pendingAdmin != address(0)); if (msg.sender != pendingAdmin) { revert Unauthorized(); } admin = pendingAdmin; pendingAdmin = address(0); emit AdminUpdated(admin); emit PendingAdminUpdated(address(0)); } /// @inheritdoc IAlchemistV3AdminActions function setDepositCap(uint256 value) external onlyAdmin { _checkArgument(value >= IERC20(myt).balanceOf(address(this))); depositCap = value; emit DepositCapUpdated(value); } /// @inheritdoc IAlchemistV3AdminActions function setProtocolFeeReceiver(address value) external onlyAdmin { _checkArgument(value != address(0)); protocolFeeReceiver = value; emit ProtocolFeeReceiverUpdated(value); } /// @inheritdoc IAlchemistV3AdminActions function setProtocolFee(uint256 fee) external onlyAdmin { _checkArgument(fee <= BPS); protocolFee = fee; emit ProtocolFeeUpdated(fee); } /// @inheritdoc IAlchemistV3AdminActions function setLiquidatorFee(uint256 fee) external onlyAdmin { _checkArgument(fee <= BPS); liquidatorFee = fee; emit LiquidatorFeeUpdated(fee); } /// @inheritdoc IAlchemistV3AdminActions function setRepaymentFee(uint256 fee) external onlyAdmin { _checkArgument(fee <= BPS); repaymentFee = fee; emit RepaymentFeeUpdated(fee); } /// @inheritdoc IAlchemistV3AdminActions function setTokenAdapter(address value) external onlyAdmin { _checkArgument(value != address(0)); tokenAdapter = value; emit TokenAdapterUpdated(value); } /// @inheritdoc IAlchemistV3AdminActions function setGuardian(address guardian, bool isActive) external onlyAdmin { _checkArgument(guardian != address(0)); guardians[guardian] = isActive; emit GuardianSet(guardian, isActive); } /// @inheritdoc IAlchemistV3AdminActions function setMinimumCollateralization(uint256 value) external onlyAdmin { _checkArgument(value >= FIXED_POINT_SCALAR); // cannot exceed global minimum minimumCollateralization = value > globalMinimumCollateralization ? globalMinimumCollateralization : value; // cannot exceed liquidation target if (minimumCollateralization > liquidationTargetCollateralization) { minimumCollateralization = liquidationTargetCollateralization; } emit MinimumCollateralizationUpdated(minimumCollateralization); } /// @inheritdoc IAlchemistV3AdminActions function setGlobalMinimumCollateralization(uint256 value) external onlyAdmin { _checkArgument(value >= minimumCollateralization); globalMinimumCollateralization = value; emit GlobalMinimumCollateralizationUpdated(value); } /// @inheritdoc IAlchemistV3AdminActions function setCollateralizationLowerBound(uint256 value) external onlyAdmin { _checkArgument(value < minimumCollateralization); _checkArgument(value >= FIXED_POINT_SCALAR); collateralizationLowerBound = value; emit CollateralizationLowerBoundUpdated(value); } /// @inheritdoc IAlchemistV3AdminActions function setLiquidationTargetCollateralization(uint256 value) external onlyAdmin { _checkArgument(value > FIXED_POINT_SCALAR); _checkArgument(value >= minimumCollateralization); _checkArgument(value > collateralizationLowerBound); _checkArgument(value <= 2 * FIXED_POINT_SCALAR); liquidationTargetCollateralization = value; emit LiquidationTargetCollateralizationUpdated(value); } /// @inheritdoc IAlchemistV3AdminActions function pauseDeposits(bool isPaused) external onlyAdminOrGuardian { depositsPaused = isPaused; emit DepositsPaused(isPaused); } /// @inheritdoc IAlchemistV3AdminActions function pauseLoans(bool isPaused) external onlyAdminOrGuardian { loansPaused = isPaused; emit LoansPaused(isPaused); } /// @inheritdoc IAlchemistV3State function getCDP(uint256 tokenId) external view returns (uint256, uint256, uint256) { (uint256 debt, uint256 earmarked, uint256 collateral) = _calculateUnrealizedDebt(tokenId); return (collateral, debt, earmarked); } /// @inheritdoc IAlchemistV3State function getTotalDeposited() external view returns (uint256) { return _mytSharesDeposited; } /// @inheritdoc IAlchemistV3State function getMaxBorrowable(uint256 tokenId) external view returns (uint256) { (uint256 debt,, uint256 collateral) = _calculateUnrealizedDebt(tokenId); uint256 debtValueOfCollateral = convertYieldTokensToDebt(collateral); uint256 capacity = (debtValueOfCollateral * FIXED_POINT_SCALAR / minimumCollateralization); return debt > capacity ? 0 : capacity - debt; } /// @inheritdoc IAlchemistV3State function getMaxWithdrawable(uint256 tokenId) external view returns (uint256) { (uint256 debt,, uint256 collateral) = _calculateUnrealizedDebt(tokenId); uint256 lockedCollateral = 0; if (debt != 0) { uint256 debtShares = convertDebtTokensToYield(debt); lockedCollateral = FixedPointMath.mulDivUp(debtShares, minimumCollateralization, FIXED_POINT_SCALAR); } uint256 positionFree = collateral > lockedCollateral ? collateral - lockedCollateral : 0; uint256 required = _requiredLockedShares(); uint256 globalFree = _mytSharesDeposited > required ? _mytSharesDeposited - required : 0; return positionFree < globalFree ? positionFree : globalFree; } /// @inheritdoc IAlchemistV3State function mintAllowance(uint256 ownerTokenId, address spender) external view returns (uint256) { Account storage account = _accounts[ownerTokenId]; return account.mintAllowances[account.allowancesVersion][spender]; } /// @inheritdoc IAlchemistV3State function getTotalUnderlyingValue() external view returns (uint256) { return _getTotalUnderlyingValue(); } /// @inheritdoc IAlchemistV3State function getTotalLockedUnderlyingValue() external view returns (uint256) { return _getTotalLockedUnderlyingValue(); } /// @inheritdoc IAlchemistV3State function totalValue(uint256 tokenId) public view returns (uint256) { return _totalCollateralValue(tokenId, true); } /// @notice Returns cumulative earmarked debt including one simulated pending earmark window. function getUnrealizedCumulativeEarmarked() external view returns (uint256) { if (totalDebt == 0) return 0; (, uint256 effectiveEarmarked) = _simulateUnrealizedEarmark(); return cumulativeEarmarked + effectiveEarmarked; } /// @inheritdoc IAlchemistV3Actions function deposit(uint256 amount, address recipient, uint256 tokenId) external returns (uint256, uint256) { _checkArgument(recipient != address(0)); _checkArgument(amount > 0); _checkState(!depositsPaused); _checkState(!_isProtocolInBadDebt()); _checkState(_mytSharesDeposited + amount <= depositCap); // Only mint a new position if the id is 0 if (tokenId == 0) { tokenId = IAlchemistV3Position(alchemistPositionNFT).mint(recipient); emit AlchemistV3PositionNFTMinted(recipient, tokenId); } else { _checkForValidAccountId(tokenId); _earmark(); _sync(tokenId); } _accounts[tokenId].collateralBalance += amount; // Transfer tokens from msg.sender now that the internal storage updates have been committed. TokenUtils.safeTransferFrom(myt, msg.sender, address(this), amount); _mytSharesDeposited += amount; emit Deposit(amount, tokenId); return (tokenId, convertYieldTokensToDebt(amount)); } /// @inheritdoc IAlchemistV3Actions function withdraw(uint256 amount, address recipient, uint256 tokenId) external returns (uint256) { _checkArgument(recipient != address(0)); _checkForValidAccountId(tokenId); _checkArgument(amount > 0); _checkAccountOwnership(IAlchemistV3Position(alchemistPositionNFT).ownerOf(tokenId), msg.sender); _earmark(); _sync(tokenId); if (_accounts[tokenId].collateralBalance > _mytSharesDeposited) { _accounts[tokenId].collateralBalance = _mytSharesDeposited; } uint256 debtShares = convertDebtTokensToYield(_accounts[tokenId].debt); uint256 lockedCollateral = FixedPointMath.mulDivUp(debtShares, minimumCollateralization, FIXED_POINT_SCALAR); _checkArgument(_accounts[tokenId].collateralBalance - lockedCollateral >= amount); uint256 transferred = _subCollateralBalance(amount, tokenId); // Assure that the collateralization invariant is still held. _validate(tokenId); // Transfer the yield tokens to msg.sender TokenUtils.safeTransfer(myt, recipient, transferred); emit Withdraw(transferred, tokenId, recipient); return transferred; } /// @inheritdoc IAlchemistV3Actions function mint(uint256 tokenId, uint256 amount, address recipient) external { _checkArgument(recipient != address(0)); _checkForValidAccountId(tokenId); _checkArgument(amount > 0); _checkState(!loansPaused); _checkAccountOwnership(IAlchemistV3Position(alchemistPositionNFT).ownerOf(tokenId), msg.sender); // Query transmuter and earmark global debt _earmark(); _checkState(!_isProtocolInBadDebt()); // Sync current user debt before more is taken _sync(tokenId); // Mint tokens to recipient _mint(tokenId, amount, recipient); } /// @inheritdoc IAlchemistV3Actions function mintFrom(uint256 tokenId, uint256 amount, address recipient) external { _checkArgument(amount > 0); _checkForValidAccountId(tokenId); _checkArgument(recipient != address(0)); _checkState(!loansPaused); // Preemptively try and decrease the minting allowance. This will save gas when the allowance is not sufficient. _decreaseMintAllowance(tokenId, msg.sender, amount); // Query transmuter and earmark global debt _earmark(); _checkState(!_isProtocolInBadDebt()); // Sync current user debt before more is taken _sync(tokenId); // Mint tokens from the tokenId's account to the recipient. _mint(tokenId, amount, recipient); } /// @inheritdoc IAlchemistV3Actions function burn(uint256 amount, uint256 recipientId) external returns (uint256) { _checkArgument(amount > 0); _checkForValidAccountId(recipientId); // Block same-block mint -> burn round trips. if (block.number == _accounts[recipientId].lastMintBlock) revert CannotRepayOnMintBlock(); // Query transmuter and earmark global debt _earmark(); // Sync current user debt before more is taken _sync(recipientId); uint256 debt; // Debt-token burns can only repay the unearmarked portion. _checkState((debt = _accounts[recipientId].debt - _accounts[recipientId].earmarked) > 0); uint256 credit = _capDebtCredit(amount, debt); if (credit == 0) return 0; // Must only burn enough tokens that the transmuter positions can still be fulfilled if (credit > totalSyntheticsIssued - ITransmuter(transmuter).totalLocked()) { revert BurnLimitExceeded(credit, totalSyntheticsIssued - ITransmuter(transmuter).totalLocked()); } // Burn the debt tokens from the caller. TokenUtils.safeBurnFrom(debtToken, msg.sender, credit); // Apply the repayment to the target account. _subDebt(recipientId, credit); _accounts[recipientId].lastRepayBlock = block.number; totalSyntheticsIssued -= credit; // Assure that the collateralization invariant is still held. _validate(recipientId); emit Burn(msg.sender, credit, recipientId); return credit; } /// @inheritdoc IAlchemistV3Actions function repay(uint256 amount, uint256 recipientTokenId) external returns (uint256) { _checkArgument(amount > 0); _checkForValidAccountId(recipientTokenId); Account storage account = _accounts[recipientTokenId]; // Block same-block mint -> repay round trips. if (block.number == account.lastMintBlock) revert CannotRepayOnMintBlock(); // Query transmuter and earmark global debt _earmark(); // Sync current user debt before deciding how much is available to be repaid _sync(recipientTokenId); uint256 debt; // MYT repayments can extinguish both earmarked and unearmarked debt. _checkState((debt = account.debt) > 0); uint256 yieldToDebt = convertYieldTokensToDebt(amount); uint256 credit = _capDebtCredit(yieldToDebt, debt); if (credit == 0) return 0; // Repay earmarked debt first so protocol fees are charged on the earmarked portion only. uint256 earmarkedRepaid = _subEarmarkedDebt(credit, recipientTokenId); uint256 creditToYield = convertDebtTokensToYield(credit); uint256 earmarkedRepaidToYield = convertDebtTokensToYield(earmarkedRepaid); // Protocol fee only applies to the earmarked portion of the repayment. uint256 feeAmount = earmarkedRepaidToYield * protocolFee / BPS; if (feeAmount > account.collateralBalance) { revert IllegalState(); } else { _subCollateralBalance(feeAmount, recipientTokenId); } _subDebt(recipientTokenId, credit); account.lastRepayBlock = block.number; // Forward the repaid MYT to the transmuter. TokenUtils.safeTransferFrom(myt, msg.sender, transmuter, creditToYield); _syncEarmarkedTransmuterTransfer(creditToYield, earmarkedRepaidToYield); if (feeAmount > 0) { TokenUtils.safeTransfer(myt, protocolFeeReceiver, feeAmount); } emit Repay(msg.sender, amount, recipientTokenId, creditToYield); return creditToYield; } /// @inheritdoc IAlchemistV3Actions function liquidate(uint256 accountId) external override returns (uint256 yieldAmount, uint256 feeInYield, uint256 feeInUnderlying) { _checkForValidAccountId(accountId); uint256 debtBefore = _accounts[accountId].debt; (yieldAmount, feeInYield, feeInUnderlying) = _liquidate(accountId); if (yieldAmount > 0 || feeInYield > 0 || feeInUnderlying > 0 || _accounts[accountId].debt < debtBefore) { return (yieldAmount, feeInYield, feeInUnderlying); } else { // No collateral or fee movement occurred and debt did not decrease. revert LiquidationError(); } } /// @inheritdoc IAlchemistV3Actions function batchLiquidate(uint256[] calldata accountIds) external returns (uint256 totalAmountLiquidated, uint256 totalFeesInYield, uint256 totalFeesInUnderlying) { if (accountIds.length == 0) { revert MissingInputData(); } bool anyProgress = false; for (uint256 i = 0; i < accountIds.length; i++) { uint256 accountId = accountIds[i]; if (accountId == 0 || !_tokenExists(alchemistPositionNFT, accountId)) { continue; } uint256 debtBefore = _accounts[accountId].debt; (uint256 underlyingAmount, uint256 feeInYield, uint256 feeInUnderlying) = _liquidate(accountId); totalAmountLiquidated += underlyingAmount; totalFeesInYield += feeInYield; totalFeesInUnderlying += feeInUnderlying; if ( underlyingAmount > 0 || feeInYield > 0 || feeInUnderlying > 0 || _accounts[accountId].debt < debtBefore ) { anyProgress = true; } } if (anyProgress) { return (totalAmountLiquidated, totalFeesInYield, totalFeesInUnderlying); } else { // None of the requested accounts made progress. revert LiquidationError(); } } /// @inheritdoc IAlchemistV3Actions function redeem(uint256 amount) external onlyTransmuter returns (uint256 sharesSent) { _earmark(); uint256 liveEarmarked = cumulativeEarmarked; if (amount > liveEarmarked) amount = liveEarmarked; uint256 effectiveRedeemed = 0; if (liveEarmarked != 0 && amount != 0) { // ratioWanted = (liveEarmarked - amount) / liveEarmarked in Q128.128 uint256 ratioWanted = (amount == liveEarmarked) ? 0 : FixedPointMath.divQ128(liveEarmarked - amount, liveEarmarked); // Snapshot old packed uint256 packedOld = _redemptionWeight; uint256 oldEpoch = _redEpoch(packedOld); uint256 oldIndex = _redIndex(packedOld); // Normalize uninitialized / zero index if (packedOld == 0) { oldEpoch = 0; oldIndex = ONE_Q128; } if (oldIndex == 0) { oldEpoch += 1; oldIndex = ONE_Q128; } // Compute new packed uint256 newEpoch = oldEpoch; uint256 newIndex; if (ratioWanted == 0) { newEpoch += 1; newIndex = ONE_Q128; } else { newIndex = FixedPointMath.mulQ128(oldIndex, ratioWanted); } _redemptionWeight = _packRed(newEpoch, newIndex); // ratioApplied is what accounts will actually see via _redemptionSurvivalRatio() // epoch advance => full wipe => 0 survival uint256 ratioApplied = (newEpoch > oldEpoch) ? 0 : FixedPointMath.divQ128(newIndex, oldIndex); // Apply survival using the APPLIED ratio _survivalAccumulator = FixedPointMath.mulQ128(_survivalAccumulator, ratioApplied); // Derive effective redeemed amount using the SAME applied ratio uint256 remainingEarmarked = FixedPointMath.mulQ128(liveEarmarked, ratioApplied); effectiveRedeemed = liveEarmarked - remainingEarmarked; cumulativeEarmarked = remainingEarmarked; totalDebt -= effectiveRedeemed; } lastRedemptionBlock = block.number; // Use the effective redeemed amount everywhere downstream uint256 collRedeemed = convertDebtTokensToYield(effectiveRedeemed); uint256 feeCollateral = collRedeemed * protocolFee / BPS; _totalRedeemedDebt += effectiveRedeemed; _totalRedeemedSharesOut += collRedeemed; TokenUtils.safeTransfer(myt, transmuter, collRedeemed); _mytSharesDeposited -= collRedeemed; // If the remaining tracked MYT cannot fully cover the protocol fee, skip the fee entirely. if (feeCollateral <= _mytSharesDeposited) { TokenUtils.safeTransfer(myt, protocolFeeReceiver, feeCollateral); _mytSharesDeposited -= feeCollateral; _totalRedeemedSharesOut += feeCollateral; } emit Redemption(effectiveRedeemed); return collRedeemed; } /// @inheritdoc IAlchemistV3Actions function selfLiquidate(uint256 accountId, address recipient) external returns (uint256 amountLiquidated) { _checkArgument(recipient != address(0)); _checkForValidAccountId(accountId); _checkAccountOwnership(IAlchemistV3Position(alchemistPositionNFT).ownerOf(accountId), msg.sender); _poke(accountId); _checkState(_accounts[accountId].debt > 0); if (!_isAccountHealthy(accountId, false)) { // Unhealthy accounts must go through the regular liquidation path. revert AccountNotHealthy(); } Account storage account = _accounts[accountId]; // First clear any earmarked debt from the account's own collateral. uint256 repaidEarmarkedDebtInYield = _forceRepay(accountId, account.earmarked, true); uint256 debt = account.debt; // Then clear whatever debt remains. _subDebt(accountId, debt); // Remove the collateral that backed the remaining debt. uint256 repaidDebtInYield = _subCollateralBalance(convertDebtTokensToYield(debt), accountId); // Sweep any residual collateral out of the account. uint256 remainingCollateral = _subCollateralBalance(account.collateralBalance, accountId); if(repaidDebtInYield > 0) { // Forward repaid collateral to the transmuter. TokenUtils.safeTransfer(myt, transmuter, repaidDebtInYield); } if(remainingCollateral > 0) { // Return leftover collateral to the recipient. TokenUtils.safeTransfer(myt, recipient, remainingCollateral); } emit SelfLiquidated(accountId, repaidEarmarkedDebtInYield + repaidDebtInYield); return repaidEarmarkedDebtInYield + repaidDebtInYield; } /// @inheritdoc IAlchemistV3State function calculateLiquidation( uint256 collateral, uint256 debt, uint256 targetCollateralization, uint256 alchemistCurrentCollateralization, uint256 alchemistMinimumCollateralization, uint256 feeBps ) public pure returns (uint256 grossCollateralToSeize, uint256 debtToBurn, uint256 fee, uint256 outsourcedFee) { if (debt >= collateral) { outsourcedFee = (debt * feeBps) / BPS; // fully liquidate debt if debt is greater than collateral return (collateral, debt, 0, outsourcedFee); } if (alchemistCurrentCollateralization < alchemistMinimumCollateralization) { outsourcedFee = (debt * feeBps) / BPS; // fully liquidate debt in high ltv global environment return (debt, debt, 0, outsourcedFee); } // fee is taken from surplus = collateral - debt uint256 surplus = collateral - debt; fee = (surplus * feeBps) / BPS; // collateral remaining for margin‐restore calc uint256 adjCollat = collateral - fee; // compute m*d (both plain units) uint256 md = (targetCollateralization * debt) / FIXED_POINT_SCALAR; // if md <= adjCollat, nothing to liquidate if (md <= adjCollat) { return (0, 0, 0, 0); } // numerator = md - adjCollat uint256 num = md - adjCollat; // denom = m - 1 => (targetCollateralization - FIXED_POINT_SCALAR)/FIXED_POINT_SCALAR uint256 denom = targetCollateralization - FIXED_POINT_SCALAR; debtToBurn = (num * FIXED_POINT_SCALAR) / denom; // gross collateral seize = net + fee grossCollateralToSeize = debtToBurn + fee; } ///@inheritdoc IAlchemistV3Actions function reduceSyntheticsIssued(uint256 amount) external onlyTransmuter { totalSyntheticsIssued -= amount; } ///@inheritdoc IAlchemistV3Actions function setTransmuterTokenBalance(uint256 amount) external onlyTransmuter { uint256 last = lastTransmuterTokenBalance; // If balance went down, assume cover could have been spent and reduce it conservatively. if (amount < last) { uint256 spent = last - amount; uint256 cover = _pendingCoverShares; if (spent >= cover) { _pendingCoverShares = 0; } else { _pendingCoverShares = cover - spent; } } // Always keep cover <= actual transmuter balance. if (_pendingCoverShares > amount) { _pendingCoverShares = amount; } // Update baseline lastTransmuterTokenBalance = amount; } /// @dev Keeps already-earmarked transfers from being re-counted as future cover. function _syncEarmarkedTransmuterTransfer(uint256 sharesSent, uint256 earmarkedShares) internal { if (earmarkedShares == 0) return; // Only the portion that satisfied an existing earmark should bypass cover accounting. if (sharesSent > earmarkedShares) sharesSent = earmarkedShares; lastTransmuterTokenBalance += sharesSent; } /// @inheritdoc IAlchemistV3Actions function poke(uint256 tokenId) external { _checkForValidAccountId(tokenId); _poke(tokenId); } /// @dev Pokes the account owned by `tokenId` to sync the state. /// @param tokenId The tokenId of the account to poke. function _poke(uint256 tokenId) internal { _earmark(); _sync(tokenId); } /// @inheritdoc IAlchemistV3Actions function approveMint(uint256 tokenId, address spender, uint256 amount) external { _checkAccountOwnership(IAlchemistV3Position(alchemistPositionNFT).ownerOf(tokenId), msg.sender); _approveMint(tokenId, spender, amount); } /// @inheritdoc IAlchemistV3Actions function resetMintAllowances(uint256 tokenId) external { // Allow calls from either the token owner or the NFT contract if (msg.sender != address(alchemistPositionNFT)) { // Direct call - verify caller is current owner address tokenOwner = IERC721(alchemistPositionNFT).ownerOf(tokenId); if (msg.sender != tokenOwner) { revert Unauthorized(); } } // increment version to start the mapping from a fresh state _accounts[tokenId].allowancesVersion += 1; // Emit event to notify allowance clearing emit MintAllowancesReset(tokenId); } /// @inheritdoc IAlchemistV3State function convertYieldTokensToDebt(uint256 amount) public view returns (uint256) { return normalizeUnderlyingTokensToDebt(convertYieldTokensToUnderlying(amount)); } /// @inheritdoc IAlchemistV3State function convertDebtTokensToYield(uint256 amount) public view returns (uint256) { return convertUnderlyingTokensToYield(normalizeDebtTokensToUnderlying(amount)); } /// @inheritdoc IAlchemistV3State function convertYieldTokensToUnderlying(uint256 amount) public view returns (uint256) { return IVaultV2(myt).convertToAssets(amount); } /// @inheritdoc IAlchemistV3State function convertUnderlyingTokensToYield(uint256 amount) public view returns (uint256) { return IVaultV2(myt).convertToShares(amount); } /// @inheritdoc IAlchemistV3State function normalizeUnderlyingTokensToDebt(uint256 amount) public view returns (uint256) { return amount * underlyingConversionFactor; } /// @inheritdoc IAlchemistV3State function normalizeDebtTokensToUnderlying(uint256 amount) public view returns (uint256) { return amount / underlyingConversionFactor; } /// @dev Mints debt tokens to `recipient` using the account owned by `tokenId`. /// @param tokenId The tokenId of the account to mint from. /// @param amount The amount to mint. /// @param recipient The recipient of the minted debt tokens. function _mint(uint256 tokenId, uint256 amount, address recipient) internal { if (block.number == _accounts[tokenId].lastRepayBlock) revert CannotMintOnRepayBlock(); _addDebt(tokenId, amount); totalSyntheticsIssued += amount; // Validate the tokenId's account to assure that the collateralization invariant is still held. _validate(tokenId); _accounts[tokenId].lastMintBlock = block.number; // Mint the debt tokens to the recipient. TokenUtils.safeMint(debtToken, recipient, amount); emit Mint(tokenId, amount, recipient); } /** * @notice Uses the account's own collateral to retire debt and the associated protocol fee. * @param accountId The token id of the account to repay from. * @param amount The requested debt repayment amount, denominated in debt tokens. * @param skipPoke Whether to skip syncing the account before repayment. * @return creditToYield The MYT shares routed to the transmuter for the retired debt. */ function _forceRepay(uint256 accountId, uint256 amount, bool skipPoke) internal returns (uint256) { if (amount == 0) { return 0; } _checkForValidAccountId(accountId); if (!skipPoke) { _poke(accountId); } Account storage account = _accounts[accountId]; uint256 debt; // Retiring debt from collateral can cover any debt bucket. _checkState((debt = account.debt) > 0); // Clamp requested repayment against live account/global debt. uint256 credit = _capDebtCredit(amount, debt); if (credit == 0) return 0; // Reduce earmarked debt first, then total debt. uint256 earmarkedRepaid = _subEarmarkedDebt(credit, accountId); _subDebt(accountId, credit); // Remove the collateral that backs the retired debt. uint256 creditToYield = _subCollateralBalance(convertDebtTokensToYield(credit), accountId); // Collect as much protocol fee as the remaining collateral can support. uint256 targetProtocolFee = creditToYield * protocolFee / BPS; uint256 protocolFeeTotal = _subCollateralBalance(targetProtocolFee, accountId); emit ForceRepay(accountId, amount, creditToYield, protocolFeeTotal); if (creditToYield > 0) { // Route the retired collateral to the transmuter. TokenUtils.safeTransfer(myt, address(transmuter), creditToYield); _syncEarmarkedTransmuterTransfer(creditToYield, convertDebtTokensToYield(earmarkedRepaid)); } if (protocolFeeTotal > 0) { // Forward the realized fee to the protocol fee receiver. TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal); } return creditToYield; } /// @dev Subtracts the earmarked debt by `amount` for the account owned by `accountId`. /// @param amountInDebtTokens The amount of debt tokens to subtract from the earmarked debt. /// @param accountId The tokenId of the account to subtract the earmarked debt from. /// @return The amount of debt tokens subtracted from the earmarked debt. function _subEarmarkedDebt(uint256 amountInDebtTokens, uint256 accountId) internal returns (uint256) { Account storage account = _accounts[accountId]; uint256 debt = account.debt; uint256 earmarkedDebt = account.earmarked; uint256 credit = amountInDebtTokens > debt ? debt : amountInDebtTokens; uint256 earmarkToRemove = credit > earmarkedDebt ? earmarkedDebt : credit; // Always reduce local earmark by the full local repay amount. account.earmarked = earmarkedDebt - earmarkToRemove; // Global can lag local by rounding; clamp only the global subtraction. uint256 remove = earmarkToRemove > cumulativeEarmarked ? cumulativeEarmarked : earmarkToRemove; cumulativeEarmarked -= remove; return earmarkToRemove; } /// @dev Subtracts the collateral balance by `amount` for the account owned by `accountId`. /// @param amountInYieldTokens The amount of yield tokens to subtract from the collateral balance. /// @param accountId The tokenId of the account to subtract the collateral balance from. /// @return The amount of yield tokens subtracted from the collateral balance. function _subCollateralBalance(uint256 amountInYieldTokens, uint256 accountId) internal returns (uint256) { Account storage account = _accounts[accountId]; uint256 collateralBalance = account.collateralBalance; // Reconcile local collateral against global tracked shares before subtraction. // This prevents underflow if rounding/drift made local storage exceed global storage. if (collateralBalance > _mytSharesDeposited) { collateralBalance = _mytSharesDeposited; account.collateralBalance = collateralBalance; } uint256 amountToRemove = amountInYieldTokens > collateralBalance ? collateralBalance : amountInYieldTokens; account.collateralBalance = collateralBalance - amountToRemove; _mytSharesDeposited -= amountToRemove; return amountToRemove; } /// @dev Syncs the account, attempts earmarked debt repayment, and liquidates collateral if still unhealthy. /// @param accountId The token id of the account to liquidate. /// @return amountLiquidated The amount of MYT seized from the account. /// @return feeInYield The MYT fee paid to the liquidator. /// @return feeInUnderlying The underlying-denominated fee paid from the fee vault, if any. function _liquidate(uint256 accountId) internal returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying) { // Query transmuter and earmark global debt _earmark(); // Sync current user debt before deciding how much needs to be liquidated _sync(accountId); Account storage account = _accounts[accountId]; // If one share is worth zero underlying, liquidation math is undefined and should noop. if (IVaultV2(myt).convertToAssets(1e18) == 0) { return (0, 0, 0); } if (_isAccountHealthy(accountId, false)) { return (0, 0, 0); } // First try to clear earmarked debt from the account's own collateral. uint256 repaidAmountInYield = 0; if (account.earmarked > 0) { repaidAmountInYield = _forceRepay(accountId, account.earmarked, false); feeInYield = _calculateRepaymentFee(repaidAmountInYield); // Final safety check after all deductions if (account.collateralBalance == 0 && account.debt > 0) { uint256 debtToClear = _clearableDebt(account.debt); if (debtToClear > 0) { _subDebt(accountId, debtToClear); } } } // Recalculate ratio after any repayment to determine if further liquidation is needed if (_isAccountHealthy(accountId, false)) { if (feeInYield > 0) { uint256 targetFeeInYield = feeInYield; uint256 maxSafeFeeInYield = _maxRepaymentFeeInYield(accountId); // Use a single fee source: either the account can safely cover the full fee in MYT, // or the entire fee is outsourced to the fee vault. if (maxSafeFeeInYield < targetFeeInYield) { feeInYield = 0; feeInUnderlying = convertYieldTokensToUnderlying(targetFeeInYield); } } if (feeInYield > 0) { feeInYield = _subCollateralBalance(feeInYield, accountId); // clamps to available balance TokenUtils.safeTransfer(myt, msg.sender, feeInYield); } else if (feeInUnderlying > 0) { feeInUnderlying = _payWithFeeVault(feeInUnderlying); } emit RepaymentFee(accountId, msg.sender, feeInYield, feeInUnderlying); return (repaidAmountInYield, feeInYield, feeInUnderlying); } else { // Do actual liquidation return _doLiquidation(accountId); } } /// @dev Pays the fee to msg.sender in underlying tokens using the fee vault /// @param amountInUnderlying The amount of underlying tokens to pay /// @return actual amount paid based on the vault balance function _payWithFeeVault(uint256 amountInUnderlying) internal returns (uint256) { if (amountInUnderlying == 0) return 0; if (alchemistFeeVault == address(0)) { emit FeeShortfall(msg.sender, amountInUnderlying, 0); return 0; } uint256 vaultBalance = IFeeVault(alchemistFeeVault).totalDeposits(); if (vaultBalance > 0) { uint256 adjustedAmount = amountInUnderlying > vaultBalance ? vaultBalance : amountInUnderlying; IFeeVault(alchemistFeeVault).withdraw(msg.sender, adjustedAmount); if (adjustedAmount < amountInUnderlying) { emit FeeShortfall(msg.sender, amountInUnderlying, adjustedAmount); } return adjustedAmount; } emit FeeShortfall(msg.sender, amountInUnderlying, 0); return 0; } /// @dev Checks if the account is healthy /// @dev An account is healthy if its collateralization ratio is greater than the collateralization lower bound /// @dev An account is healthy if it has no debt /// @param accountId The tokenId of the account to check. /// @param refresh Whether to refresh the account's collateral value by including unrealized debt. /// @return true if the account is healthy, false otherwise. function _isAccountHealthy(uint256 accountId, bool refresh) internal view returns (bool) { if (_accounts[accountId].debt == 0) { return true; } uint256 collateralInUnderlying = _totalCollateralValue(accountId, refresh); uint256 collateralizationRatio = collateralInUnderlying * FIXED_POINT_SCALAR / _accounts[accountId].debt; return collateralizationRatio > collateralizationLowerBound; } /// @dev Performs the collateral-seizure phase of liquidation once the account must be liquidated. /// @param accountId The token id of the account to liquidate. /// @return amountLiquidated The amount of MYT seized from the account. /// @return feeInYield The MYT fee paid to the liquidator. /// @return feeInUnderlying The underlying-denominated fee paid from the fee vault. function _doLiquidation(uint256 accountId) internal returns (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying) { Account storage account = _accounts[accountId]; uint256 debt = account.debt; uint256 collateralInUnderlying = totalValue(accountId); (uint256 liquidationAmount, uint256 debtToBurn, uint256 baseFee, uint256 outsourcedFee) = calculateLiquidation( collateralInUnderlying, debt, liquidationTargetCollateralization, normalizeUnderlyingTokensToDebt(_getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / totalDebt, globalMinimumCollateralization, liquidatorFee ); if (liquidationAmount == 0) { // Debt-only closeout path: account can be insolvent with no remaining collateral to seize. if (debtToBurn > 0) { uint256 burnableDebt = _capDebtCredit(debtToBurn, account.debt); if (burnableDebt > 0) { _subDebt(accountId, burnableDebt); } } uint256 feeRequestInUnderlying = normalizeDebtTokensToUnderlying(outsourcedFee); if (feeRequestInUnderlying > 0) { feeInUnderlying = _payWithFeeVault(feeRequestInUnderlying); } if (account.debt < debt || feeInUnderlying > 0) { emit Liquidated(accountId, msg.sender, 0, 0, feeInUnderlying); return (0, 0, feeInUnderlying); } return (0, 0, 0); } uint256 requestedLiquidationInYield = convertDebtTokensToYield(liquidationAmount); amountLiquidated = _subCollateralBalance(requestedLiquidationInYield, accountId); if (amountLiquidated == 0) return (0, 0, 0); // Fee and debt burn are derived from idealized liquidation math, so clamp them to what was // actually realized after collateral capping to avoid underflow and over-burning debt. uint256 requestedFeeInYield = convertDebtTokensToYield(baseFee); feeInYield = requestedFeeInYield > amountLiquidated ? amountLiquidated : requestedFeeInYield; uint256 netToTransmuter = amountLiquidated - feeInYield; uint256 maxDebtByRealized = convertYieldTokensToDebt(netToTransmuter); uint256 maxDebtByStorage = account.debt < totalDebt ? account.debt : totalDebt; if (debtToBurn > maxDebtByRealized) debtToBurn = maxDebtByRealized; if (debtToBurn > maxDebtByStorage) debtToBurn = maxDebtByStorage; // Apply the realized debt reduction. if (debtToBurn > 0) { _subDebt(accountId, debtToBurn); } // If liquidation still leaves the account unhealthy, force-close the residual: // sweep all remaining collateral and clear any debt that cannot be backed anymore. if (account.debt > 0 && !_isAccountHealthy(accountId, false)) { uint256 remainingShares = account.collateralBalance; if (remainingShares > 0) { uint256 removedShares = _subCollateralBalance(remainingShares, accountId); netToTransmuter += removedShares; uint256 extraDebtBurn = _capDebtCredit(convertYieldTokensToDebt(removedShares), account.debt); if (extraDebtBurn > 0) { _subDebt(accountId, extraDebtBurn); } } if (account.collateralBalance == 0 && account.debt > 0) { uint256 debtToClear = _clearableDebt(account.debt); if (debtToClear > 0) { _subDebt(accountId, debtToClear); } } } // Route seized collateral net of liquidator fee to the transmuter. TokenUtils.safeTransfer(myt, transmuter, netToTransmuter); // Pay the liquidator from MYT if available, otherwise fall back to the fee vault. if (feeInYield > 0) { TokenUtils.safeTransfer(myt, msg.sender, feeInYield); } else if (normalizeDebtTokensToUnderlying(outsourcedFee) > 0) { feeInUnderlying = _payWithFeeVault(normalizeDebtTokensToUnderlying(outsourcedFee)); } emit Liquidated(accountId, msg.sender, amountLiquidated, feeInYield, feeInUnderlying); return (amountLiquidated, feeInYield, feeInUnderlying); } /// @dev Handles repayment fee calculation. /// @param repaidAmountInYield The amount of debt repaid in yield tokens. /// @return feeInYield The fee in yield tokens to be sent to the liquidator. function _calculateRepaymentFee(uint256 repaidAmountInYield) internal view returns (uint256 feeInYield) { return repaidAmountInYield * repaymentFee / BPS; } /// @dev Returns max yield-fee removable while remaining strictly healthy (> lower bound). /// @param accountId The tokenId of the account to compute the max repayment fee for. /// @return The max repayment fee in yield tokens. function _maxRepaymentFeeInYield(uint256 accountId) internal view returns (uint256) { Account storage account = _accounts[accountId]; uint256 debt = account.debt; if (debt == 0) { return account.collateralBalance; } uint256 collateralInDebt = convertYieldTokensToDebt(account.collateralBalance); uint256 minimumByLowerBound = FixedPointMath.mulDivUp(debt, collateralizationLowerBound, FIXED_POINT_SCALAR); if (minimumByLowerBound == type(uint256).max) { return 0; } // _isAccountHealthy uses a strict ">" check, so retain one debt-unit of margin. uint256 minRequiredPostFee = minimumByLowerBound + 1; if (collateralInDebt <= minRequiredPostFee) { return 0; } uint256 removableInDebt = collateralInDebt - minRequiredPostFee; return convertDebtTokensToYield(removableInDebt); } /// @dev Increases the debt by `amount` for the account owned by `tokenId`. /// /// @param tokenId The account owned by tokenId. /// @param amount The amount to increase the debt by. function _addDebt(uint256 tokenId, uint256 amount) internal { Account storage account = _accounts[tokenId]; uint256 newDebt = account.debt + amount; // After _sync(tokenId), you can use the current collateralBalance (no simulation needed) uint256 collateralValue = _totalCollateralValue(tokenId, false); uint256 required = FixedPointMath.mulDivUp(newDebt, minimumCollateralization, FIXED_POINT_SCALAR); if (collateralValue < required) revert Undercollateralized(); account.debt = newDebt; totalDebt += amount; } /// @dev Subtracts the debt by `amount` for the account owned by `tokenId`. /// /// @param tokenId The account owned by tokenId. /// @param amount The amount to decrease the debt by. function _subDebt(uint256 tokenId, uint256 amount) internal { Account storage account = _accounts[tokenId]; account.debt -= amount; totalDebt -= amount; if (cumulativeEarmarked > totalDebt) { cumulativeEarmarked = totalDebt; } } /// @dev Caps a debt-denominated credit against account debt and global debt. function _capDebtCredit(uint256 requested, uint256 accountDebt) internal view returns (uint256) { uint256 credit = requested > accountDebt ? accountDebt : requested; if (credit > totalDebt) credit = totalDebt; return credit; } /// @dev Returns debt that can be safely cleared against global debt accounting. function _clearableDebt(uint256 accountDebt) internal view returns (uint256) { return accountDebt > totalDebt ? totalDebt : accountDebt; } /// @dev Set the mint allowance for `spender` to `amount` for the account owned by `tokenId`. /// /// @param ownerTokenId The id of the account granting approval. /// @param spender The address of the spender. /// @param amount The amount of debt tokens to set the mint allowance to. function _approveMint(uint256 ownerTokenId, address spender, uint256 amount) internal { Account storage account = _accounts[ownerTokenId]; account.mintAllowances[account.allowancesVersion][spender] = amount; emit ApproveMint(ownerTokenId, spender, amount); } /// @dev Decrease the mint allowance for `spender` by `amount` for the account owned by `ownerTokenId`. /// /// @param ownerTokenId The id of the account owner. /// @param spender The address of the spender. /// @param amount The amount of debt tokens to decrease the mint allowance by. function _decreaseMintAllowance(uint256 ownerTokenId, address spender, uint256 amount) internal { Account storage account = _accounts[ownerTokenId]; account.mintAllowances[account.allowancesVersion][spender] -= amount; } /// @dev Checks an expression and reverts with an {IllegalArgument} error if the expression is {false}. /// /// @param expression The expression to check. function _checkArgument(bool expression) internal pure { if (!expression) { revert IllegalArgument(); } } /// @dev Checks if owner == sender and reverts with an {UnauthorizedAccountAccessError} error if the result is {false}. /// /// @param owner The address of the owner of an account. /// @param user The address of the user attempting to access an account. function _checkAccountOwnership(address owner, address user) internal pure { if (owner != user) { revert UnauthorizedAccountAccessError(); } } /// @dev reverts {UnknownAccountOwnerIDError} error by if no owner exists. /// /// @param tokenId The id of an account. function _checkForValidAccountId(uint256 tokenId) internal view { if (!_tokenExists(alchemistPositionNFT, tokenId)) { revert UnknownAccountOwnerIDError(); } } function _onlyAdmin() internal view { if (msg.sender != admin) { revert Unauthorized(); } } function _onlyAdminOrGuardian() internal view { if (msg.sender != admin && !guardians[msg.sender]) { revert Unauthorized(); } } function _onlyTransmuter() internal view { if (msg.sender != transmuter) { revert Unauthorized(); } } /** * @notice Checks whether a token id is linked to an owner. Non blocking / no reverts. * @param nft The address of the ERC721 based contract. * @param tokenId The token id to check. * @return exists A boolean that is true if the token exists. */ function _tokenExists(address nft, uint256 tokenId) internal view returns (bool exists) { if (tokenId == 0) { // token ids start from 1 return false; } try IERC721(nft).ownerOf(tokenId) { // If the call succeeds, the token exists. exists = true; } catch { // If the call fails, then the token does not exist. exists = false; } } /// @dev Checks an expression and reverts with an {IllegalState} error if the expression is {false}. /// /// @param expression The expression to check. function _checkState(bool expression) internal pure { if (!expression) { revert IllegalState(); } } /// @dev Checks that the account owned by `tokenId` is properly collateralized. /// @dev If the account is undercollateralized then this will revert with an {Undercollateralized} error. /// /// @param tokenId The id of the account owner. function _validate(uint256 tokenId) internal view { if (_isUnderCollateralized(tokenId)) revert Undercollateralized(); } /// @dev Calculate the total collateral value of the account in debt tokens. /// @param tokenId The id of the account owner. /// @return The total collateral value of the account in debt tokens. function _totalCollateralValue(uint256 tokenId, bool includeUnrealizedDebt) internal view returns (uint256) { uint256 totalUnderlying; if (includeUnrealizedDebt) { (,, uint256 collateral) = _calculateUnrealizedDebt(tokenId); if (collateral > 0) totalUnderlying += convertYieldTokensToUnderlying(collateral); } else { totalUnderlying = convertYieldTokensToUnderlying(_accounts[tokenId].collateralBalance); } return normalizeUnderlyingTokensToDebt(totalUnderlying); } /// @dev Update the user's earmarked and redeemed debt amounts. function _sync(uint256 tokenId) internal { Account storage account = _accounts[tokenId]; (uint256 newDebt, uint256 newEarmarked, uint256 redeemedTotal) = _computeUnrealizedAccount(account, _earmarkWeight, _redemptionWeight, _survivalAccumulator); // Calculate collateral to remove uint256 globalDebtDelta = _totalRedeemedDebt - account.lastTotalRedeemedDebt; if (globalDebtDelta != 0 && redeemedTotal != 0) { uint256 globalSharesDelta = _totalRedeemedSharesOut - account.lastTotalRedeemedSharesOut; // sharesToDebit = redeemedTotal * globalSharesDelta / globalDebtDelta uint256 sharesToDebit = FixedPointMath.mulDivUp(redeemedTotal, globalSharesDelta, globalDebtDelta); if (sharesToDebit > account.collateralBalance) sharesToDebit = account.collateralBalance; account.collateralBalance -= sharesToDebit; } // advance checkpoints even if redeemedTotal==0 account.lastTotalRedeemedDebt = _totalRedeemedDebt; account.lastTotalRedeemedSharesOut = _totalRedeemedSharesOut; account.earmarked = newEarmarked; account.debt = newDebt; // Advance account checkpoint account.lastAccruedEarmarkWeight = _earmarkWeight; account.lastAccruedRedemptionWeight = _redemptionWeight; // Snapshot G for this account account.lastSurvivalAccumulator = _survivalAccumulator; } /// @dev Computes the account debt/earmark state at a given global weight snapshot. /// @return newDebt The debt after applying earmark + redemption. /// @return newEarmarked The earmarked portion after applying survival and new earmarks. /// @return redeemedDebt Realized redeemed debt for this step. function _computeUnrealizedAccount( Account storage account, uint256 earmarkWeightCurrent, uint256 redemptionWeightCurrent, uint256 survivalAccumulatorCurrent ) internal view returns (uint256 newDebt, uint256 newEarmarked, uint256 redeemedDebt) { // Survival during current sync window uint256 survivalRatio = _redemptionSurvivalRatio(account.lastAccruedRedemptionWeight, redemptionWeightCurrent); // User exposure at last sync used to calculate newly earmarked debt pre redemption uint256 userExposure = account.debt > account.earmarked ? account.debt - account.earmarked : 0; uint256 unearmarkSurvivalRatio = _earmarkSurvivalRatio(account.lastAccruedEarmarkWeight, earmarkWeightCurrent); // amount that stayed unearmarked from userExposure uint256 unearmarkedRemaining = FixedPointMath.mulQ128(userExposure, unearmarkSurvivalRatio); // amount newly earmarked since last sync uint256 earmarkRaw = userExposure - unearmarkedRemaining; // No redemption in this sync window -> debt cannot decrease. if (survivalRatio == ONE_Q128) { newDebt = account.debt; newEarmarked = account.earmarked + earmarkRaw; if (newEarmarked > newDebt) newEarmarked = newDebt; redeemedDebt = 0; return (newDebt, newEarmarked, redeemedDebt); } // Unwind via the survival accumulator. uint256 earmarkSurvival = _redIndex(account.lastAccruedEarmarkWeight); if (earmarkSurvival == 0) earmarkSurvival = ONE_Q128; // Default path for accounts that stayed inside the same earmark epoch. uint256 decayedRedeemed = FixedPointMath.mulQ128(account.lastSurvivalAccumulator, survivalRatio); uint256 survivalDiff = survivalAccumulatorCurrent > decayedRedeemed ? survivalAccumulatorCurrent - decayedRedeemed : 0; if (survivalDiff > earmarkSurvival) survivalDiff = earmarkSurvival; uint256 unredeemedRatio = FixedPointMath.divQ128(survivalDiff, earmarkSurvival); uint256 earmarkedUnredeemed = FixedPointMath.mulQ128(userExposure, unredeemedRatio); uint256 oldEarEpoch = account.lastAccruedEarmarkWeight >> _EARMARK_INDEX_BITS; uint256 newEarEpoch = earmarkWeightCurrent >> _EARMARK_INDEX_BITS; if (newEarEpoch == oldEarEpoch) { uint256 currentEarmarkIndex = earmarkWeightCurrent & _EARMARK_INDEX_MASK; uint256 telescopedEarmarkDrop = earmarkSurvival > currentEarmarkIndex ? earmarkSurvival - currentEarmarkIndex : 0; // When the current sync window stayed inside one earmark epoch and the accumulator // collapses to the telescoped earmark drop, there were no interleaved pre-claim // redemptions that require per-step weighting. Using the user-sized formula here // avoids amplifying index-scale ceil rounding by exposure / earmarkSurvival. if (survivalDiff == FixedPointMath.mulQ128(telescopedEarmarkDrop, survivalRatio)) { earmarkedUnredeemed = FixedPointMath.mulQ128(earmarkRaw, survivalRatio); } } // If the account crossed an earmark epoch, split math at the first boundary: // - pre-boundary via accumulator diff at epoch boundary, // - post-boundary via redemption survival only. // This avoids both over-redemption (pre-boundary redemptions applied twice) // and under-redemption (post-boundary accumulator contamination). if (newEarEpoch > oldEarEpoch) { uint256 boundaryEpoch = oldEarEpoch + 1; uint256 boundaryRedemptionWeight = _earmarkEpochStartRedemptionWeight[boundaryEpoch]; uint256 boundarySurvivalAccumulator = _earmarkEpochStartSurvivalAccumulator[boundaryEpoch]; if (boundaryRedemptionWeight != 0) { uint256 preBoundarySurvival = _redemptionSurvivalRatio(account.lastAccruedRedemptionWeight, boundaryRedemptionWeight); uint256 decayedAtBoundary = FixedPointMath.mulQ128(account.lastSurvivalAccumulator, preBoundarySurvival); uint256 boundaryDiff = boundarySurvivalAccumulator > decayedAtBoundary ? boundarySurvivalAccumulator - decayedAtBoundary : 0; if (boundaryDiff > earmarkSurvival) boundaryDiff = earmarkSurvival; uint256 unredeemedAtBoundaryRatio = FixedPointMath.divQ128(boundaryDiff, earmarkSurvival); uint256 unredeemedAtBoundary = FixedPointMath.mulQ128(userExposure, unredeemedAtBoundaryRatio); uint256 postBoundarySurvival = _redemptionSurvivalRatio(boundaryRedemptionWeight, redemptionWeightCurrent); earmarkedUnredeemed = FixedPointMath.mulQ128(unredeemedAtBoundary, postBoundarySurvival); } else { // Backward-compatibility fallback for old state without boundary checkpoints. earmarkedUnredeemed = FixedPointMath.mulQ128(earmarkRaw, survivalRatio); } } if (earmarkedUnredeemed > earmarkRaw) earmarkedUnredeemed = earmarkRaw; // Old earmarks that survived redemptions in the current sync window uint256 exposureSurvival = FixedPointMath.mulQ128(account.earmarked, survivalRatio); // What was redeemed from the newly earmark between last sync and now uint256 redeemedFromEarmarked = earmarkRaw - earmarkedUnredeemed; // Total overall earmarked to adjust user debt uint256 redeemedTotal = (account.earmarked - exposureSurvival) + redeemedFromEarmarked; newDebt = account.debt >= redeemedTotal ? account.debt - redeemedTotal : 0; redeemedDebt = account.debt - newDebt; newEarmarked = exposureSurvival + earmarkedUnredeemed; if (newEarmarked > newDebt) newEarmarked = newDebt; } /// @dev Earmarks the debt for redemption. function _earmark() internal { if (totalDebt == 0) return; if (block.number <= lastEarmarkBlock) return; // update pending cover shares based on transmuter balance delta uint256 transmuterBalance = TokenUtils.safeBalanceOf(myt, address(transmuter)); if (transmuterBalance > lastTransmuterTokenBalance) { _pendingCoverShares += (transmuterBalance - lastTransmuterTokenBalance); } lastTransmuterTokenBalance = transmuterBalance; // how to earmark this window uint256 amount = ITransmuter(transmuter).queryGraph(lastEarmarkBlock + 1, block.number); // apply cover uint256 coverInDebt = convertYieldTokensToDebt(_pendingCoverShares); if (amount != 0 && coverInDebt != 0) { uint256 usedDebt = amount > coverInDebt ? coverInDebt : amount; amount -= usedDebt; // consume the corresponding portion of pending cover shares so we can't reuse it uint256 sharesUsed = FixedPointMath.mulDivUp(_pendingCoverShares, usedDebt, coverInDebt); if (sharesUsed > _pendingCoverShares) sharesUsed = _pendingCoverShares; _pendingCoverShares -= sharesUsed; } uint256 liveUnearmarked = totalDebt - cumulativeEarmarked; if (amount > liveUnearmarked) amount = liveUnearmarked; if (amount > 0 && liveUnearmarked != 0) { // ratioWanted = (liveUnearmarked - amount) / liveUnearmarked uint256 ratioWanted = (amount == liveUnearmarked) ? 0 : FixedPointMath.divQ128(liveUnearmarked - amount, liveUnearmarked); uint256 packedOld = _earmarkWeight; (uint256 packedNew, uint256 ratioApplied, uint256 oldIndex, uint256 newEpoch, bool epochAdvanced) = _simulateEarmarkPackedUpdate(packedOld, ratioWanted); _earmarkWeight = packedNew; // Survival increment uses the APPLIED earmark fraction uint256 earmarkedFraction = ONE_Q128 - ratioApplied; _survivalAccumulator += FixedPointMath.mulQ128(oldIndex, earmarkedFraction); if (epochAdvanced) { _earmarkEpochStartRedemptionWeight[newEpoch] = _redemptionWeight; _earmarkEpochStartSurvivalAccumulator[newEpoch] = _survivalAccumulator; } // Bump cumulativeEarmarked by the effective amount implied by ratioApplied uint256 newUnearmarked = FixedPointMath.mulQ128(liveUnearmarked, ratioApplied); uint256 effectiveEarmarked = liveUnearmarked - newUnearmarked; cumulativeEarmarked += effectiveEarmarked; } lastEarmarkBlock = block.number; } /// @dev Projects an account through one sync, including one simulated pending earmark window. /// /// @param tokenId The account id to project. /// /// @return The projected debt after sync. /// @return The projected earmarked debt after sync. /// @return The projected collateral remaining after realized redemptions are applied. function _calculateUnrealizedDebt(uint256 tokenId) internal view returns (uint256, uint256, uint256) { Account storage account = _accounts[tokenId]; // Simulate one uncommitted earmark window and use its simulated weight. (uint256 earmarkWeightCopy,) = _simulateUnrealizedEarmark(); // First, compute account state against committed globals only. (uint256 newDebt, uint256 newEarmarked, uint256 redeemedTotalSim) = _computeUnrealizedAccount(account, _earmarkWeight, _redemptionWeight, _survivalAccumulator); // Then, apply the simulated earmark-only delta. // Important: this prospective earmark happens "now", so historical redemptions must not // reduce debt again through this simulated step. if (earmarkWeightCopy != _earmarkWeight) { uint256 exposure = newDebt > newEarmarked ? newDebt - newEarmarked : 0; if (exposure != 0) { uint256 unearmarkedRatio = _earmarkSurvivalRatio(_earmarkWeight, earmarkWeightCopy); uint256 unearmarkedRemaining = FixedPointMath.mulQ128(exposure, unearmarkedRatio); uint256 newlyEarmarked = exposure - unearmarkedRemaining; newEarmarked += newlyEarmarked; if (newEarmarked > newDebt) newEarmarked = newDebt; } } // Calculate collateral to remove from fees and redemptions uint256 collateralBalanceCopy = account.collateralBalance; uint256 globalDebtDelta = _totalRedeemedDebt - account.lastTotalRedeemedDebt; if (globalDebtDelta != 0 && redeemedTotalSim != 0) { uint256 globalSharesDelta = _totalRedeemedSharesOut - account.lastTotalRedeemedSharesOut; uint256 sharesToDebit = FixedPointMath.mulDivUp(redeemedTotalSim, globalSharesDelta, globalDebtDelta); if (sharesToDebit > collateralBalanceCopy) sharesToDebit = collateralBalanceCopy; collateralBalanceCopy -= sharesToDebit; } return (newDebt, newEarmarked, collateralBalanceCopy); } /// @dev Checks that the account owned by `tokenId` is properly collateralized. /// @dev Returns true only if the account is undercollateralized /// /// @param tokenId The id of the account owner. function _isUnderCollateralized(uint256 tokenId) internal view returns (bool) { uint256 debt = _accounts[tokenId].debt; if (debt == 0) return false; // totalValue(tokenId) is already denominated in debt-token units uint256 collateralValue = totalValue(tokenId); // Required collateral value = ceil(debt * minCollat / 1e18) uint256 required = FixedPointMath.mulDivUp(debt, minimumCollateralization, FIXED_POINT_SCALAR); return collateralValue < required; } /// @dev Converts tracked MYT shares into underlying units. /// @return totalUnderlyingValue The underlying value represented by `_mytSharesDeposited`. function _getTotalUnderlyingValue() internal view returns (uint256 totalUnderlyingValue) { uint256 yieldTokenTVLInUnderlying = convertYieldTokensToUnderlying(_mytSharesDeposited); totalUnderlyingValue = yieldTokenTVLInUnderlying; } /// @dev Returns the underlying value that must remain locked to support live debt, capped by tracked MYT. function _getTotalLockedUnderlyingValue() internal view returns (uint256) { uint256 required = _requiredLockedShares(); // Cap by actual shares held in the Alchemist uint256 held = _mytSharesDeposited; uint256 lockedShares = required > held ? held : required; return convertYieldTokensToUnderlying(lockedShares); } /// @dev Returns true if issued synthetics exceed global backing. /// Backing mirrors Transmuter claim math: /// locked collateral in Alchemist + MYT shares currently held by Transmuter. function _isProtocolInBadDebt() internal view returns (bool) { if (totalSyntheticsIssued == 0) return false; uint256 transmuterShares = TokenUtils.safeBalanceOf(myt, address(transmuter)); uint256 backingUnderlying = _getTotalLockedUnderlyingValue() + convertYieldTokensToUnderlying(transmuterShares); uint256 backingDebt = normalizeUnderlyingTokensToDebt(backingUnderlying); return totalSyntheticsIssued > backingDebt; } /// @dev Calculates locked collateral based on share price function _requiredLockedShares() internal view returns (uint256) { if (totalDebt == 0) return 0; uint256 debtShares = convertDebtTokensToYield(totalDebt); return FixedPointMath.mulDivUp(debtShares, minimumCollateralization, FIXED_POINT_SCALAR); } // Survival ratio of *unearmarked* exposure between two packed earmark states. function _earmarkSurvivalRatio(uint256 oldPacked, uint256 newPacked) internal pure returns (uint256) { if (newPacked == oldPacked) return ONE_Q128; if (oldPacked == 0) return ONE_Q128; // uninitialized snapshot => assume "no change" uint256 oldEpoch = oldPacked >> _EARMARK_INDEX_BITS; uint256 newEpoch = newPacked >> _EARMARK_INDEX_BITS; // Epoch advanced => old unearmarked was fully earmarked at some point. if (newEpoch > oldEpoch) return 0; uint256 oldIdx = oldPacked & _EARMARK_INDEX_MASK; uint256 newIdx = newPacked & _EARMARK_INDEX_MASK; if (oldIdx == 0) return 0; return FixedPointMath.divQ128(newIdx, oldIdx); } /// @dev Computes redemption survival ratio between two redemption weights. /// Uses division when survivals are representable, and falls back to delta-weight /// when SurvivalFromWeight() underflows to 0 for both endpoints. function _redemptionSurvivalRatio(uint256 oldPacked, uint256 newPacked) internal pure returns (uint256) { if (newPacked == oldPacked) return ONE_Q128; if (oldPacked == 0) return ONE_Q128; uint256 oldEpoch = oldPacked >> _REDEMPTION_INDEX_BITS; uint256 newEpoch = newPacked >> _REDEMPTION_INDEX_BITS; // If epoch advances, there was a full wipe at some point if (newEpoch > oldEpoch) return 0; uint256 oldIndex = oldPacked & _REDEMPTION_INDEX_MASK; uint256 newIndex = newPacked & _REDEMPTION_INDEX_MASK; // If oldIndex is 0, treat as fully redeemed. if (oldIndex == 0) return 0; // ratio = newIndex / oldIndex return FixedPointMath.divQ128(newIndex, oldIndex); } /// @dev Simulates one uncommitted earmark window using current on-chain state. /// @return earmarkWeightCopy Simulated earmark packed weight after the window. /// @return effectiveEarmarked The additional earmarked debt from this simulated window. function _simulateUnrealizedEarmark() internal view returns (uint256 earmarkWeightCopy, uint256 effectiveEarmarked) { earmarkWeightCopy = _earmarkWeight; if (block.number <= lastEarmarkBlock || totalDebt == 0) return (earmarkWeightCopy, 0); uint256 transmuterBalance = TokenUtils.safeBalanceOf(myt, address(transmuter)); // simulate pending cover uint256 pendingCover = _pendingCoverShares; if (transmuterBalance > lastTransmuterTokenBalance) { pendingCover += (transmuterBalance - lastTransmuterTokenBalance); } // simulate earmark amount for this window uint256 amount = ITransmuter(transmuter).queryGraph(lastEarmarkBlock + 1, block.number); // apply cover the same way uint256 coverInDebt = convertYieldTokensToDebt(pendingCover); if (amount != 0 && coverInDebt != 0) { uint256 usedDebt = amount > coverInDebt ? coverInDebt : amount; amount -= usedDebt; } uint256 liveUnearmarked = totalDebt - cumulativeEarmarked; if (amount > liveUnearmarked) amount = liveUnearmarked; if (amount == 0 || liveUnearmarked == 0) return (earmarkWeightCopy, 0); // ratioWanted = (liveUnearmarked - amount) / liveUnearmarked uint256 ratioWanted = (amount == liveUnearmarked) ? 0 : FixedPointMath.divQ128(liveUnearmarked - amount, liveUnearmarked); (uint256 packedNew, uint256 ratioApplied,,,) = _simulateEarmarkPackedUpdate(earmarkWeightCopy, ratioWanted); earmarkWeightCopy = packedNew; uint256 newUnearmarked = FixedPointMath.mulQ128(liveUnearmarked, ratioApplied); effectiveEarmarked = liveUnearmarked - newUnearmarked; } /// @dev Simulates the packed earmark update and returns the applied survival ratio. /// @param packedOld Existing packed earmark weight. /// @param ratioWanted Wanted survival ratio for unearmarked debt in this step. /// @return packedNew The new packed earmark weight. /// @return ratioApplied The effective survival ratio that will be observed by accounts. /// @return oldIndex The normalized previous index. /// @return newEpoch The resulting epoch after this step. /// @return epochAdvanced Whether the update crossed an epoch boundary. function _simulateEarmarkPackedUpdate(uint256 packedOld, uint256 ratioWanted) internal pure returns (uint256 packedNew, uint256 ratioApplied, uint256 oldIndex, uint256 newEpoch, bool epochAdvanced) { uint256 oldEpoch = packedOld >> _EARMARK_INDEX_BITS; oldIndex = packedOld & _EARMARK_INDEX_MASK; if (packedOld == 0) { oldEpoch = 0; oldIndex = ONE_Q128; } if (oldIndex == 0) { oldEpoch += 1; oldIndex = ONE_Q128; } newEpoch = oldEpoch; uint256 newIndex; if (ratioWanted == 0) { newEpoch += 1; newIndex = ONE_Q128; } else { newIndex = FixedPointMath.mulQ128(oldIndex, ratioWanted); } epochAdvanced = newEpoch > oldEpoch; packedNew = _packRed(newEpoch, newIndex); ratioApplied = epochAdvanced ? 0 : FixedPointMath.divQ128(newIndex, oldIndex); } // Bitwise helpers function _redEpoch(uint256 packed) private pure returns (uint256) { return packed >> _REDEMPTION_INDEX_BITS; } function _redIndex(uint256 packed) private pure returns (uint256) { return packed & _REDEMPTION_INDEX_MASK; } function _packRed(uint256 epoch, uint256 index) private pure returns (uint256) { return (epoch << _REDEMPTION_INDEX_BITS) | index; } } ================================================ FILE: src/AlchemistV3Position.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {IAlchemistV3} from "./interfaces/IAlchemistV3.sol"; import {IMetadataRenderer} from "./interfaces/IMetadataRenderer.sol"; /** * @title AlchemistV3Position * @notice ERC721 position token for AlchemistV3, where only the AlchemistV3 contract * is allowed to mint and burn tokens. Minting returns a unique token id. */ contract AlchemistV3Position is ERC721Enumerable { using Strings for uint256; /// @notice The only address allowed to mint and burn position tokens. address public alchemist; /// @notice The admin of the NFT contract, allowed to update the metadata renderer. address public admin; /// @notice Counter used for generating unique token ids. uint256 private _currentTokenId; /// @notice The external contract that generates tokenURI metadata. address public metadataRenderer; /// @notice An error which is used to indicate that the function call failed because the caller is not the alchemist error CallerNotAlchemist(); /// @notice An error which is used to indicate that the function call failed because the caller is not the admin error CallerNotAdmin(); /// @notice An error which is used to indicate that Alchemist set is the zero address error AlchemistZeroAddressError(); /// @notice An error which is used to indicate that address minted to is the zero address error MintToZeroAddressError(); /// @notice An error which is used to indicate that the metadata renderer is not set error MetadataRendererNotSet(); /// @dev Modifier to restrict calls to only the authorized AlchemistV3 contract. modifier onlyAlchemist() { if (msg.sender != alchemist) { revert CallerNotAlchemist(); } _; } /// @dev Modifier to restrict calls to only the admin. modifier onlyAdmin() { if (msg.sender != admin) { revert CallerNotAdmin(); } _; } /** * @notice Constructor that sets the Alchemist address, admin, and initializes the ERC721 token. * @param alchemist_ The address of the Alchemist contract. * @param admin_ The address of the admin allowed to update the metadata renderer. */ constructor(address alchemist_, address admin_) ERC721("AlchemistV3Position", "ALCV3") { if (alchemist_ == address(0)) { revert AlchemistZeroAddressError(); } alchemist = alchemist_; admin = admin_; } /// @notice Sets or updates the metadata renderer. Only callable by the admin. /// @param renderer The address of the new metadata renderer contract. function setMetadataRenderer(address renderer) external onlyAdmin { metadataRenderer = renderer; } /// @notice Transfers admin rights to a new address. Only callable by the current admin. /// @param newAdmin The address of the new admin. function setAdmin(address newAdmin) external onlyAdmin { admin = newAdmin; } /** * @notice Mints a new position NFT to `to`. * @dev Only callable by the AlchemistV3 contract. * @param to The recipient address for the new position. * @return tokenId The unique token id minted. */ function mint(address to) external onlyAlchemist returns (uint256) { if (to == address(0)) { revert MintToZeroAddressError(); } _currentTokenId++; uint256 tokenId = _currentTokenId; _mint(to, tokenId); return tokenId; } function burn(uint256 tokenId) public onlyAlchemist { _burn(tokenId); } /** * @notice Returns the token URI with embedded SVG * @param tokenId The token ID * @return The full token URI with data */ function tokenURI(uint256 tokenId) public view override returns (string memory) { // revert if the token does not exist ERC721(address(this)).ownerOf(tokenId); if (metadataRenderer == address(0)) { revert MetadataRendererNotSet(); } return IMetadataRenderer(metadataRenderer).tokenURI(tokenId); } /** * @notice Override supportsInterface to resolve inheritance conflicts. */ function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721Enumerable) returns (bool) { return super.supportsInterface(interfaceId); } /** * @notice Hook that is called before any token transfer */ function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) { address from = _ownerOf(tokenId); // Reset mint allowances before the transfer completes if (from != address(0)) { // Skip during minting IAlchemistV3(alchemist).resetMintAllowances(tokenId); } // Call parent implementation first return super._update(to, tokenId, auth); } } ================================================ FILE: src/AlchemistV3PositionRenderer.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IMetadataRenderer} from "./interfaces/IMetadataRenderer.sol"; import {NFTMetadataGenerator} from "./libraries/NFTMetadataGenerator.sol"; /// @title AlchemistV3PositionRenderer /// @notice Default metadata renderer for AlchemistV3Position NFTs. contract AlchemistV3PositionRenderer is IMetadataRenderer { /// @inheritdoc IMetadataRenderer function tokenURI(uint256 tokenId) external pure override returns (string memory) { return NFTMetadataGenerator.generateTokenURI(tokenId, "Alchemist V3 Position"); } } ================================================ FILE: src/FrxEthEthDualOracleAggregatorAdapter.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; interface IFrxEthEthDualOracle { function getPrices() external view returns (bool isBadData, uint256 priceLow, uint256 priceHigh); } /// @notice Adapts the Frax dual-oracle frxETH/ETH source to a Chainlink-style reader. /// @dev The Frax dual oracle does not expose a publication timestamp. This adapter therefore /// returns the current block timestamp for `startedAt` and `updatedAt`, which satisfies /// the AggregatorV3Interface shape but does not provide an underlying freshness signal. contract FrxEthEthDualOracleAggregatorAdapter { IFrxEthEthDualOracle public immutable dualOracle; constructor(address _dualOracle) { require(_dualOracle != address(0), "Zero dual oracle address"); dualOracle = IFrxEthEthDualOracle(_dualOracle); } function decimals() external pure returns (uint8) { return 18; } function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) { (bool isBadData, uint256 priceLow, uint256 priceHigh) = dualOracle.getPrices(); require(!isBadData, "Bad dual oracle data"); uint256 averagePrice = (priceLow + priceHigh) / 2; require(averagePrice > 0, "Invalid dual oracle price"); // The dual oracle exposes no round ids or publication time, so these fields are synthesized. return (uint80(block.number), int256(averagePrice), block.timestamp, block.timestamp, uint80(block.number)); } } ================================================ FILE: src/MYTStrategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IVaultV2} from "vault-v2/interfaces/IVaultV2.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IMYTStrategy} from "./interfaces/IMYTStrategy.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "./libraries/SafeERC20.sol"; import {TokenUtils} from "./libraries/TokenUtils.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; interface IERC721Tiny { function ownerOf(uint256 tokenId) external view returns (address); } /** * @title MYTStrategy * @notice The MYT is a Morpho V2 Vault, and each strategy is just a vault adapter which interfaces with a third party protocol * @notice This contract should be inherited by all strategies */ contract MYTStrategy is IMYTStrategy, Ownable { using Math for uint256; IVaultV2 public immutable MYT; // address public immutable receiptToken; address public allowanceHolder; // 0x Allowance holder uint256 public constant SECONDS_PER_YEAR = 365 days; uint256 public constant FIXED_POINT_SCALAR = 1e18; bytes4 public constant FORCE_DEALLOCATE_SELECTOR = 0xe4d38cd8; IMYTStrategy.StrategyParams public params; bytes32 public immutable adapterId; /// @notice This value is true when the underlying protocol is known to /// experience issues or security incidents. In this case the allocation step is simply /// bypassed without reverts (to keep external allocators from reverting). bool public killSwitch; modifier onlyVault() { require(msg.sender == address(MYT), "PD"); _; } /* ========== CONSTRUCTOR ========== */ /** * @notice Constructor for the MYTStrategy contract * @param _myt The address of the MYT vault * @param _params The parameters for the strategy */ constructor(address _myt, StrategyParams memory _params) Ownable(_params.owner) { require(_params.owner != address(0)); require(_myt != address(0)); require(_params.slippageBPS < 5000); MYT = IVaultV2(_myt); params = _params; adapterId = keccak256(abi.encode("this", address(this))); allowanceHolder = 0x0000000000001fF3684f28c67538d4D072C22734; } /* ========== CORE ADAPTER FUNCTIONS ========== */ /// @notice See Morpho V2 vault spec function allocate(bytes memory data, uint256 assets, bytes4 selector, address sender) external onlyVault returns (bytes32[] memory strategyIds, int256 change) { if (killSwitch) revert StrategyAllocationPaused(address(this)); if (assets == 0) revert InvalidAmount(1, 0); VaultAdapterParams memory adapterParams = abi.decode(data, (VaultAdapterParams)); uint256 amountAllocated; if (adapterParams.action == ActionType.direct) { amountAllocated = _allocate(assets); } else if (adapterParams.action == ActionType.swap) { amountAllocated = _allocate(assets, adapterParams.swapParams.txData); } else { revert ActionNotSupported(); } uint256 oldAllocation = allocation(); uint256 newAllocation = _totalValue(); emit Allocate(amountAllocated, address(this)); return (ids(), int256(newAllocation) - int256(oldAllocation)); } /// @notice See Morpho V2 vault spec function deallocate(bytes memory data, uint256 assets, bytes4 selector, address sender) external onlyVault returns (bytes32[] memory strategyIds, int256 change) { if (assets == 0) revert InvalidAmount(1, 0); uint256 oldAllocation = allocation(); uint256 totalValueBefore = _totalValue(); VaultAdapterParams memory adapterParams = abi.decode(data, (VaultAdapterParams)); _validateDeallocateAction(adapterParams.action, selector); uint256 amountDeallocated; if (adapterParams.action == ActionType.direct) { amountDeallocated = _deallocate(assets); } else if (adapterParams.action == ActionType.swap) { amountDeallocated = _deallocate(assets, adapterParams.swapParams.txData); } else if (adapterParams.action == ActionType.unwrapAndSwap) { amountDeallocated = _deallocate(assets, adapterParams.swapParams.txData, adapterParams.swapParams.minIntermediateOut); } else { revert ActionNotSupported(); } uint256 totalValueAfter = _totalValue(); require(totalValueAfter >= assets, "inconsistent totalValue"); uint256 newAllocation = totalValueAfter - assets; emit Deallocate(amountDeallocated, address(this)); return (ids(), int256(newAllocation) - int256(oldAllocation)); } /* ========== INTERNAL HELPER FUNCTIONS ========== */ /// @dev Helper to swap tokens via 0x function dexSwap(address to, address from, uint256 amount, uint256 minAmountOut, bytes memory callData) internal returns (uint256) { SafeERC20.safeApprove(from, allowanceHolder, amount); uint256 targetBalanceBefore = IERC20(to).balanceOf(address(this)); (bool success, ) = allowanceHolder.call(callData); if (!success) revert CounterfeitSettler(allowanceHolder); SafeERC20.safeApprove(from, allowanceHolder, 0); uint256 amountReceived = IERC20(to).balanceOf(address(this)) - targetBalanceBefore; if (amountReceived < minAmountOut) revert InvalidAmount(minAmountOut, amountReceived); return amountReceived; } /// @dev Helper to check if strategy holds enough idle assets function _ensureIdleBalance(address asset, uint256 amount) internal view { uint256 balance = TokenUtils.safeBalanceOf(asset, address(this)); if (balance < amount) revert InsufficientBalance(amount, balance); } /// @dev Force deallocations are limited to direct withdrawals. function _validateDeallocateAction(ActionType action, bytes4 selector) internal pure { if (selector == FORCE_DEALLOCATE_SELECTOR && action != ActionType.direct) { revert ForceDeallocateSwapNotAllowed(); } } /* ========== VIEW FUNCTIONS ========== */ /// @notice helper function to estimate the correct amount that can be fully /// withdrawn from a strategy, accounting for losses /// due to slippage, protocol fees, and rounding differences function previewAdjustedWithdraw(uint256 amount) external view returns (uint256) { if (amount == 0) revert InvalidAmount(1, 0); return _previewAdjustedWithdraw(amount); } /// @notice call this function to handle strategies with withdrawal queue NFT function claimWithdrawalQueue(uint256 positionId) public virtual onlyOwner returns (uint256 ret) { return _claimWithdrawalQueue(positionId); } /// @notice call this function to claim all available rewards from the respective /// protocol of this strategy function claimRewards(address token, bytes memory quote, uint256 minAmountOut) public onlyOwner virtual returns (uint256) { require(!killSwitch, "emergency"); return _claimRewards(token, quote, minAmountOut); } /// @notice withdraw any leftover assets back to the vault function withdrawToVault() public virtual onlyOwner returns (uint256) { uint256 leftover = IERC20(MYT.asset()).balanceOf(address(this)); SafeERC20.safeTransfer(MYT.asset(), address(MYT), leftover); emit WithdrawToVault(leftover); return leftover; } /// @notice Rescue arbitrary ERC20 tokens sent to this contract by mistake /// @param token The token to rescue /// @param to The recipient address /// @param amount The amount to rescue function rescueTokens(address token, address to, uint256 amount) external onlyOwner { require(to != address(0), "Invalid recipient"); require(!_isProtectedToken(token), "Protected token"); uint256 balance = IERC20(token).balanceOf(address(this)); require(amount <= balance, "Insufficient balance"); IERC20(token).transfer(to, amount); emit TokensRescued(token, to, amount); } /* ========== ADMIN FUNCTIONS ========== */ /// @notice recategorize this strategy to a different risk class function setRiskClass(RiskClass newClass) public onlyOwner { params.riskClass = newClass; emit RiskClassUpdated(newClass); } /// @dev some protocols may pay yield in baby tokens /// so we need to manually collect them function setAdditionalIncentives(bool newValue) public onlyOwner { params.additionalIncentives = newValue; emit IncentivesUpdated(newValue); } /// @notice enter/exit emergency mode for this strategy function setKillSwitch(bool val) public onlyOwner { killSwitch = val; emit Emergency(val); } function setAllowanceHolder(address _new) public onlyOwner { require(_new != address(0)); allowanceHolder = _new; } /// @notice Update the slippage tolerance for this strategy function setSlippageBPS(uint256 newSlippageBPS) public onlyOwner { require(newSlippageBPS < 9999, "Slippage too high"); params.slippageBPS = newSlippageBPS; emit SlippageBPSUpdated(newSlippageBPS); } /* ========== GETTERS ========== */ /// @notice get the current snapshotted estimated yield for this strategy. /// This call does not guarantee the latest up-to-date yield and there might /// be discrepancies from the respective protocols numbers. function getEstimatedYield() public view returns (uint256) { return params.estimatedYield; } function getCap() external view returns (uint256) { return params.cap; } function getGlobalCap() external view returns (uint256) { return params.globalCap; } function ids() public view returns (bytes32[] memory) { bytes32[] memory ids_ = new bytes32[](1); ids_[0] = adapterId; return ids_; } /// @notice Returns the vault's current allocation tracking for this adapter function allocation() public view returns (uint256) { return MYT.allocation(adapterId); } function getIdData() external view returns (bytes memory) { return abi.encode("this", address(this)); } /// @notice External function per IAdapter spec - returns total underlying value function realAssets() external view virtual returns (uint256) { return _totalValue(); } /* ========== VIRTUAL FUNCTIONS ========== */ /// @dev Check if a token is protected and cannot be rescued. /// Override this function in child contracts to add protocol-specific protected tokens /// (e.g., receipt tokens, aTokens, mTokens, staking tokens). /// @param token The token to check /// @return True if the token is protected function _isProtectedToken(address token) internal view virtual returns (bool) { return token == MYT.asset(); } /// @dev override this function to handle wrapping/allocation/moving funds to /// the respective protocol of this strategy /// @notice uint56 amount returned should be equal to the amount parameter passed in /// @notice should attempt to log any loss due to rounding function _allocate(uint256 amount) internal virtual returns (uint256) { revert ActionNotSupported(); } /// @dev override this function to handle wrapping/allocation/moving funds to /// the respective protocol of this strategy /// @notice uint56 amount returned should be equal to the amount parameter passed in /// @notice should attempt to log any loss due to rounding function _allocate(uint256 amount, bytes memory callData) internal virtual returns (uint256) { revert ActionNotSupported(); } /// @dev override this function to handle unwrapping/deallocation/moving funds from /// the respective protocol of this strategy /// @notice uint56 amount returned must be equal to the amount parameter passed in /// @notice due to how MorphoVaultV2 internally handles deallocations, /// strategies must have atleast >= amount available at the end of this function call /// if not, the strategy will revert /// @notice amount of asset must be approved to the vault (i.e. msg.sender) function _deallocate(uint256 amount) internal virtual returns (uint256) { revert ActionNotSupported(); } /// @dev override this function to handle unwrapping/deallocation/moving funds from /// the respective protocol of this strategy /// @notice uint56 amount returned must be equal to the amount parameter passed in /// @notice due to how MorphoVaultV2 internally handles deallocations, /// strategies must have atleast >= amount available at the end of this function call /// if not, the strategy will revert /// @notice amount of asset must be approved to the vault (i.e. msg.sender) function _deallocate(uint256 amount, bytes memory callData) internal virtual returns (uint256) { revert ActionNotSupported(); } /// @dev override this function to handle unwrapping/deallocation/moving funds from /// the strategy to the vault using a swap with specified in calldata /// @param amount the WETH amount expected to be returned to the vault /// @param callData the 0x swap calldata /// @param minIntermediateOutAmount the minimum amount of intermediate token expected to be received from the unwrap function _deallocate(uint256 amount, bytes memory callData, uint256 minIntermediateOutAmount) internal virtual returns (uint256) { revert ActionNotSupported(); } /// @dev override this function to handle preview withdraw with slippage /// @notice this function should be used to estimate the correct amount that can be fully withdrawn, accounting for losses /// due to slippage, protocol fees, and rounding differences function _previewAdjustedWithdraw(uint256 amount) internal view virtual returns (uint256) {} /// @dev override this function to handle strategies with withdrawal queue NFT function _claimWithdrawalQueue(uint256 positionId) internal virtual returns (uint256) {} /// @dev override this function to claim all available rewards from the respective /// protocol of this strategy in the form of a specific token /// this ERC20 reward must then be converted to the MYT's asset function _claimRewards(address token, bytes memory quote, uint256 minAmountOut) internal virtual returns (uint256) {} /// @dev override this function to return the total underlying value of the strategy /// @dev must return the total underling value of the strategy's position (i.e. in vault asset e.g. USDC or WETH) function _totalValue() internal view virtual returns (uint256) {} function _idleAssets() internal view virtual returns (uint256) {} } ================================================ FILE: src/PerpetualGauge.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IStrategyClassifier } from "./interfaces/IStrategyClassifier.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; interface IAllocatorProxy { function allocate(uint256 strategyId, uint256 amount) external; } contract PerpetualGauge is ReentrancyGuard { event VoteUpdated(address indexed voter, uint256 ytId, uint256[] strategyIds, uint256[] weights, uint256 expiry); event AllocationExecuted(uint256 ytId, uint256[] strategyIds, uint256[] amounts); event VoterCleared(address indexed voter, uint256 ytId); struct Vote { uint256[] strategyIds; uint256[] weights; uint256 expiry; } IStrategyClassifier public stratClassifier; IAllocatorProxy public allocatorProxy; IERC20 public votingToken; uint256 public constant MAX_VOTE_DURATION = 365 days; uint256 public constant MIN_RESET_DURATION = 30 days; mapping(uint256 => mapping(address => Vote)) public votes; mapping(uint256 => uint256) public lastStrategyAddedAt; mapping(uint256 => address[]) private voters; mapping(uint256 => mapping(address => uint256)) private voterIndex; // Aggregate weighted votes per MYT + strategy mapping(uint256 => mapping(uint256 => uint256)) private aggStrategyWeight; constructor(address _stratClassifier, address _allocatorProxy, address _votingToken) { require(_stratClassifier != address(0) && _allocatorProxy != address(0) && _votingToken != address(0), "Bad address"); stratClassifier = IStrategyClassifier(_stratClassifier); allocatorProxy = IAllocatorProxy(_allocatorProxy); votingToken = IERC20(_votingToken); } function vote(uint256 ytId, uint256[] calldata strategyIds, uint256[] calldata weights) external nonReentrant { require(strategyIds.length == weights.length && strategyIds.length > 0, "Invalid input"); uint256 lastAdded = lastStrategyAddedAt[ytId]; Vote storage existing = votes[ytId][msg.sender]; uint256 expiry; if (existing.expiry > block.timestamp) { uint256 timeLeft = existing.expiry - block.timestamp; if (lastAdded > 0 && block.timestamp - lastAdded < MIN_RESET_DURATION && timeLeft < MIN_RESET_DURATION) { expiry = existing.expiry; } else { expiry = block.timestamp + MAX_VOTE_DURATION; } } else { expiry = block.timestamp + MAX_VOTE_DURATION; } uint256 power = votingToken.balanceOf(msg.sender); // 1. Remove old vote contribution from aggregate if (existing.strategyIds.length > 0 && existing.expiry > block.timestamp) { for (uint256 i = 0; i < existing.strategyIds.length; i++) { uint256 sid = existing.strategyIds[i]; uint256 prevWeighted = existing.weights[i] * power; aggStrategyWeight[ytId][sid] -= prevWeighted; } } // 2. Store new vote votes[ytId][msg.sender] = Vote({ strategyIds: strategyIds, weights: weights, expiry: expiry }); // 3. Add new contribution for (uint256 i = 0; i < strategyIds.length; i++) { uint256 sid = strategyIds[i]; uint256 newWeighted = weights[i] * power; aggStrategyWeight[ytId][sid] += newWeighted; } // 4. Track voter in registry if (voterIndex[ytId][msg.sender] == 0) { voters[ytId].push(msg.sender); voterIndex[ytId][msg.sender] = voters[ytId].length; // 1-based } emit VoteUpdated(msg.sender, ytId, strategyIds, weights, expiry); } function clearVote(uint256 ytId) external nonReentrant { Vote storage v = votes[ytId][msg.sender]; require(v.strategyIds.length > 0, "No vote"); uint256 power = votingToken.balanceOf(msg.sender); for (uint256 i = 0; i < v.strategyIds.length; i++) { uint256 sid = v.strategyIds[i]; uint256 weighted = v.weights[i] * power; aggStrategyWeight[ytId][sid] -= weighted; } delete votes[ytId][msg.sender]; emit VoterCleared(msg.sender, ytId); } function registerNewStrategy(uint256 ytId, uint256 strategyId) external nonReentrant { lastStrategyAddedAt[ytId] = block.timestamp; // TODO } function getCurrentAllocations(uint256 ytId) public view returns (uint256[] memory strategyIds, uint256[] memory normalizedWeights) { uint256 n = strategyList[ytId].length; strategyIds = new uint256[](n); normalizedWeights = new uint256[](n); uint256 total; for (uint256 i; i < n; i++) { uint256 sid = strategyList[ytId][i]; strategyIds[i] = sid; uint256 w = aggStrategyWeight[ytId][sid]; normalizedWeights[i] = w; total += w; } for (uint256 i; i < n; i++) { if (total > 0) { normalizedWeights[i] = (normalizedWeights[i] * 1e18) / total; } } } function executeAllocation(uint256 ytId, uint256 totalIdleAssets) external nonReentrant { (uint256[] memory sIds, uint256[] memory weights) = getCurrentAllocations(ytId); require(sIds.length > 0, "No allocations"); uint256 totalRiskAllocated; uint256[] memory allocatedAmounts = new uint256[](sIds.length); for (uint256 i = 0; i < sIds.length; i++) { uint8 risk = stratClassifier.getStrategyRiskLevel(sIds[i]); uint256 indivCap = stratClassifier.getIndividualCap(sIds[i]); uint256 globalCap = stratClassifier.getGlobalCap(risk); uint256 target = (weights[i] * totalIdleAssets) / 1e18; // Individual cap uint256 capIndiv = (indivCap * totalIdleAssets) / 1e4; if (target > capIndiv) target = capIndiv; // Global cap for risk group if (risk > 0) { uint256 capGlobalLeft = (globalCap * totalIdleAssets) / 1e4 - totalRiskAllocated; if (target > capGlobalLeft) target = capGlobalLeft; totalRiskAllocated += target; } if (target > 0) { // TODO double-check limits here? allocatorProxy.allocate(sIds[i], target); } allocatedAmounts[i] = target; } emit AllocationExecuted(ytId, sIds, allocatedAmounts); } // to keep track of strategies per ytId for getCurrentAllocations mapping(uint256 => uint256[]) public strategyList; } ================================================ FILE: src/Transmuter.sol ================================================ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.28; import {IAlchemistV3} from "./interfaces/IAlchemistV3.sol"; import {ITransmuter} from "./interfaces/ITransmuter.sol"; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import {NFTMetadataGenerator} from "./libraries/NFTMetadataGenerator.sol"; import {SafeCast} from "./libraries/SafeCast.sol"; import {StakingGraph} from "./libraries/StakingGraph.sol"; import {TokenUtils} from "./libraries/TokenUtils.sol"; import {FixedPointMath} from "./libraries/FixedPointMath.sol"; import {Unauthorized, IllegalArgument, IllegalState} from "./base/Errors.sol"; import "./base/TransmuterErrors.sol"; /// @title AlchemixV3 Transmuter /// /// @notice A contract which facilitates the exchange of alAssets to yield bearing assets. contract Transmuter is ITransmuter, ERC721Enumerable { using StakingGraph for StakingGraph.Graph; using SafeCast for int256; using SafeCast for uint256; uint256 public constant BPS = 10_000; uint256 public constant FIXED_POINT_SCALAR = 1e18; int256 public constant BLOCK_SCALING_FACTOR = 1e8; /// @inheritdoc ITransmuter string public constant version = "3.0.0"; /// @inheritdoc ITransmuter uint256 public depositCap; /// @inheritdoc ITransmuter uint256 public exitFee; /// @inheritdoc ITransmuter uint256 public transmutationFee; /// @inheritdoc ITransmuter uint256 public timeToTransmute; /// @inheritdoc ITransmuter uint256 public totalLocked; /// @inheritdoc ITransmuter uint256 public totalActiveLocked; /// @inheritdoc ITransmuter address public admin; /// @inheritdoc ITransmuter address public pendingAdmin; /// @inheritdoc ITransmuter address public protocolFeeReceiver; /// @inheritdoc ITransmuter address public syntheticToken; /// @inheritdoc ITransmuter IAlchemistV3 public alchemist; /// @dev Map of user positions data. mapping(uint256 => StakingPosition) private _positions; /// @dev Map of users whos position counts towards deposit cap. mapping(uint256 => bool) private _countsTowardCap; /// @dev Graph of transmuter positions. StakingGraph.Graph private _stakingGraph; /// @dev Nonce data used for minting of new nft positions. uint256 private _nonce; modifier onlyAdmin() { _checkArgument(msg.sender == admin); _; } constructor(ITransmuter.TransmuterInitializationParams memory params) ERC721("Alchemix V3 Transmuter", "TRNSMTR") { _checkArgument(params.feeReceiver != address(0)); _checkArgument(params.timeToTransmute != 0); _checkArgument(params.timeToTransmute <= type(int256).max.toUint256()); _checkArgument(params.transmutationFee <= BPS); _checkArgument(params.exitFee <= BPS); syntheticToken = params.syntheticToken; timeToTransmute = params.timeToTransmute; transmutationFee = params.transmutationFee; exitFee = params.exitFee; protocolFeeReceiver = params.feeReceiver; admin = msg.sender; } /// @inheritdoc ITransmuter function setPendingAdmin(address value) external onlyAdmin { pendingAdmin = value; emit PendingAdminUpdated(value); } /// @inheritdoc ITransmuter function acceptAdmin() external { _checkState(pendingAdmin != address(0)); if (msg.sender != pendingAdmin) { revert Unauthorized(); } admin = pendingAdmin; pendingAdmin = address(0); emit AdminUpdated(admin); emit PendingAdminUpdated(address(0)); } /// @inheritdoc ITransmuter function setAlchemist(address value) external onlyAdmin { alchemist = IAlchemistV3(value); emit AlchemistUpdated(value); } /// @inheritdoc ITransmuter function setDepositCap(uint256 cap) external onlyAdmin { _checkArgument(cap <= type(int256).max.toUint256()); depositCap = cap; emit DepositCapUpdated(cap); } /// @inheritdoc ITransmuter function setTransmutationFee(uint256 fee) external onlyAdmin { _checkArgument(fee <= BPS); transmutationFee = fee; emit TransmutationFeeUpdated(fee); } /// @inheritdoc ITransmuter function setExitFee(uint256 fee) external onlyAdmin { _checkArgument(fee <= BPS); exitFee = fee; emit ExitFeeUpdated(fee); } /// @inheritdoc ITransmuter function setTransmutationTime(uint256 time) external onlyAdmin { _checkArgument(time != 0); _checkArgument(time <= type(int256).max.toUint256()); timeToTransmute = time; emit TransmutationTimeUpdated(time); } /// @inheritdoc ITransmuter function setProtocolFeeReceiver(address value) external onlyAdmin { _checkArgument(value != address(0)); protocolFeeReceiver = value; emit ProtocolFeeReceiverUpdated(value); } function tokenURI(uint256 id) public view override returns (string memory) { return NFTMetadataGenerator.generateTokenURI(id, "Transmuter V3 Position"); } /// @inheritdoc ITransmuter function getPosition(uint256 id) external view returns (StakingPosition memory) { return _positions[id]; } /// @inheritdoc ITransmuter function createRedemption(uint256 syntheticDepositAmount, address recipient) external { if (syntheticDepositAmount == 0) { revert DepositZeroAmount(); } _checkArgument(recipient != address(0)); if (totalActiveLocked + syntheticDepositAmount > depositCap) { revert DepositCapReached(); } if (totalLocked + syntheticDepositAmount > alchemist.totalSyntheticsIssued()) { revert DepositCapReached(); } TokenUtils.safeTransferFrom(syntheticToken, msg.sender, address(this), syntheticDepositAmount); _positions[++_nonce] = StakingPosition(syntheticDepositAmount, block.number, block.number + timeToTransmute); // Update Fenwick Tree _updateStakingGraph(syntheticDepositAmount.toInt256() * BLOCK_SCALING_FACTOR / timeToTransmute.toInt256(), timeToTransmute); totalLocked += syntheticDepositAmount; totalActiveLocked += syntheticDepositAmount; _countsTowardCap[_nonce] = true; _mint(recipient, _nonce); emit PositionCreated(msg.sender, syntheticDepositAmount, _nonce); } /// @inheritdoc ITransmuter function claimRedemption(uint256 id) external returns (uint256 claimYield, uint256 feeYield, uint256 syntheticReturned, uint256 syntheticFee) { StakingPosition storage position = _positions[id]; if (position.maturationBlock == 0) { revert PositionNotFound(); } if (position.startBlock == block.number) { revert PrematureClaim(); } uint256 transmutationTime = position.maturationBlock - position.startBlock; uint256 blocksLeft = position.maturationBlock > block.number ? position.maturationBlock - block.number : 0; uint256 amountNottransmuted = blocksLeft > 0 ? FixedPointMath.mulDivUp(position.amount, blocksLeft, transmutationTime) : 0; uint256 amountTransmuted = position.amount - amountNottransmuted; if (_requireOwned(id) != msg.sender) { revert CallerNotOwner(); } // Burn position NFT _burn(id); // Ratio of total synthetics issued by the alchemist / underlingying value of collateral stored in the alchemist // If the system experiences bad debt we use this ratio to scale back the value of yield tokens that are transmuted address myt = alchemist.myt(); uint256 yieldTokenBalance = TokenUtils.safeBalanceOf(myt, address(this)); // Avoid divide by 0 uint256 backingUnderlying = alchemist.getTotalLockedUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(yieldTokenBalance); uint256 denominator = backingUnderlying > 0 ? backingUnderlying : 1; // Round up so badDebtRatio is never understated. // Understating badDebtRatio makes scaledTransmuted slightly too large, which can cause alchemist.redeem(amountToRedeem) to revert due to share rounding. uint256 badDebtRatio = FixedPointMath.mulDivUp(alchemist.totalSyntheticsIssued(), 10 ** TokenUtils.expectDecimals(alchemist.underlyingToken()), denominator); uint256 scaledTransmuted = amountTransmuted; if (badDebtRatio > 1e18) { scaledTransmuted = amountTransmuted * FIXED_POINT_SCALAR / badDebtRatio; } // If the contract has a balance of yield tokens from alchemist repayments then we only need to redeem partial or none from Alchemist earmarked uint256 debtValue = alchemist.convertYieldTokensToDebt(yieldTokenBalance); uint256 amountToRedeem = scaledTransmuted > debtValue ? scaledTransmuted - debtValue : 0; uint256 redeemedShares = 0; if (amountToRedeem > 0) { redeemedShares = alchemist.redeem(amountToRedeem); } uint256 sharesAvailable = yieldTokenBalance + redeemedShares; uint256 totalYield = alchemist.convertDebtTokensToYield(scaledTransmuted); uint256 distributable = totalYield <= sharesAvailable ? totalYield : sharesAvailable; // Split distributable amount. Round fee down. claimant gets the remainder. feeYield = distributable * transmutationFee / BPS; claimYield = distributable - feeYield; uint256 debtPaid = alchemist.convertYieldTokensToDebt(distributable); if (debtPaid > scaledTransmuted) { debtPaid = scaledTransmuted; } // Shortfall debt to offset if some amount of MYT cannot be paid to the user // We will return some synthetics instead of burning them all if this is the case uint256 shortfallDebt = scaledTransmuted > debtPaid ? scaledTransmuted - debtPaid : 0; syntheticFee = amountNottransmuted * exitFee / BPS; syntheticReturned = (amountNottransmuted - syntheticFee) + shortfallDebt; uint256 burnAmountDebt = position.amount - (syntheticReturned + syntheticFee); // Remove untransmuted amount from the staking graph if (blocksLeft > 0) _updateStakingGraph(-position.amount.toInt256() * BLOCK_SCALING_FACTOR / transmutationTime.toInt256(), blocksLeft); TokenUtils.safeTransfer(myt, msg.sender, claimYield); TokenUtils.safeTransfer(myt, protocolFeeReceiver, feeYield); TokenUtils.safeTransfer(syntheticToken, msg.sender, syntheticReturned); TokenUtils.safeTransfer(syntheticToken, protocolFeeReceiver, syntheticFee); if (burnAmountDebt > 0) { TokenUtils.safeBurn(syntheticToken, burnAmountDebt); alchemist.reduceSyntheticsIssued(burnAmountDebt); } alchemist.setTransmuterTokenBalance(TokenUtils.safeBalanceOf(myt, address(this))); // If this position still counted toward cap, remove it now. if (_countsTowardCap[id]) { _countsTowardCap[id] = false; totalActiveLocked -= position.amount; } totalLocked -= position.amount; emit PositionClaimed(msg.sender, claimYield, syntheticReturned); delete _positions[id]; } /// @inheritdoc ITransmuter function queryGraph(uint256 startBlock, uint256 endBlock) external view returns (uint256) { if (endBlock < startBlock) return 0; int256 queried = _stakingGraph.queryStake(startBlock, endBlock); if (queried == 0) return 0; return FixedPointMath.mulDivUp(queried.toUint256(), 1, uint256(BLOCK_SCALING_FACTOR)); } /// @inheritdoc ITransmuter function pokeMatured(uint256 id) external { StakingPosition storage position = _positions[id]; if (position.maturationBlock == 0) revert PositionNotFound(); // must be fully matured if (block.number < position.maturationBlock) { revert PositionNotMatured(id, position.maturationBlock, block.number); } if (!_countsTowardCap[id]) revert PositionAlreadyPoked(id); _countsTowardCap[id] = false; totalActiveLocked -= position.amount; emit PositionPoked(id, position.amount); } /// @dev Updates staking graphs function _updateStakingGraph(int256 amount, uint256 blocks) private { _stakingGraph.addStake(amount, block.number, blocks); } /// @dev Checks an expression and reverts with an {IllegalArgument} error if the expression is {false}. /// /// @param expression The expression to check. function _checkArgument(bool expression) internal pure { if (!expression) { revert IllegalArgument(); } } /// @dev Checks an expression and reverts with an {IllegalState} error if the expression is {false}. /// /// @param expression The expression to check. function _checkState(bool expression) internal pure { if (!expression) { revert IllegalState(); } } } ================================================ FILE: src/adapters/AbstractFeeVault.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "@openzeppelin/contracts/access/Ownable.sol"; import {IFeeVault} from "../interfaces/IFeeVault.sol"; /** * @title AbstractVault * @notice Abstract base class for Alchemist vaults that handles authorization logic * @dev Extend this to implement ETH or ERC20 token vaults */ abstract contract AbstractFeeVault is IFeeVault, Ownable { address public immutable token; // Custom errors error Unauthorized(); error ZeroAddress(); error ZeroAmount(); error InsufficientBalance(); // Mapping of addresses authorized to withdraw mapping(address => bool) public authorized; // Events event Deposited(address indexed from, uint256 amount); event Withdrawn(address indexed to, uint256 amount); event AuthorizationUpdated(address indexed account, bool status); /** * @dev Modifier to restrict access to authorized accounts */ modifier onlyAuthorized() { if (!authorized[msg.sender]) revert Unauthorized(); _; } /** * @notice Constructor to initialize the vault * @param _token The ERC20 token managed by this vault * @param _alchemist The Alchemist contract address * @param _owner The vault owner address */ constructor(address _token, address _alchemist, address _owner) Ownable(_owner) { _checkNonZeroAddress(_token); _checkNonZeroAddress(_alchemist); token = _token; authorized[_alchemist] = true; authorized[_owner] = true; emit AuthorizationUpdated(_alchemist, true); } /** * @notice Sets the authorization status of an account * @param account The address to authorize/deauthorize * @param status True to authorize, false to deauthorize */ function setAuthorization(address account, bool status) external onlyOwner { _checkNonZeroAddress(account); authorized[account] = status; emit AuthorizationUpdated(account, status); } /** * @notice Validates that an address is not the zero address * @param account The address to validate */ function _checkNonZeroAddress(address account) internal pure { if (account == address(0)) revert ZeroAddress(); } /** * @notice Validates that an amount is greater than zero * @param amount The amount to validate */ function _checkNonZeroAmount(uint256 amount) internal pure { if (amount == 0) revert ZeroAmount(); } /** * @notice Abstract function to withdraw assets from the vault * @param recipient Address to receive the assets * @param amount Amount to withdraw */ function withdraw(address recipient, uint256 amount) external virtual override; /** * @notice Abstract function to get total deposits in the vault * @return Total deposits in the vault */ function totalDeposits() external view virtual override returns (uint256); } ================================================ FILE: src/adapters/EulerUSDCAdapter.sol ================================================ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.28; import "../libraries/TokenUtils.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import '../interfaces/ITokenAdapter.sol'; import "@openzeppelin/contracts/interfaces/IERC4626.sol"; /// @title Euler Adapter contract EulerUSDCAdapter is ITokenAdapter { string public constant version = "1.0.0"; address public immutable token; address public immutable underlyingToken; constructor(address _token, address _underlyingToken) { token = _token; underlyingToken = _underlyingToken; } function price() external view returns (uint256) { return IERC4626(token).convertToAssets(10**TokenUtils.expectDecimals(token)); } } ================================================ FILE: src/base/ErrorMessages.sol ================================================ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.4; /// @notice An error used to indicate that an argument passed to a function is illegal or /// inappropriate. /// /// @param message The error message. error IllegalArgument(string message); /// @notice An error used to indicate that a function has encountered an unrecoverable state. /// /// @param message The error message. error IllegalState(string message); /// @notice An error used to indicate that an operation is unsupported. /// /// @param message The error message. error UnsupportedOperation(string message); /// @notice An error used to indicate that a message sender tried to execute a privileged function. /// /// @param message The error message. error Unauthorized(string message); ================================================ FILE: src/base/Errors.sol ================================================ pragma solidity ^0.8.23; /// @notice An error used to indicate that an action could not be completed because either the `msg.sender` or /// `msg.origin` is not authorized. error Unauthorized(); /// @notice An error used to indicate that an action could not be completed because the contract either already existed /// or entered an illegal condition which is not recoverable from. error IllegalState(); /// @notice An error used to indicate that an action could not be completed because of an illegal argument was passed /// to the function. error IllegalArgument(); /// @notice An error used to indicate that an action could not be completed because the required amount of allowance has not /// been approved. error InsufficientAllowance(); /// @notice An error used to indicate that the function input data is missing error MissingInputData(); /// @notice An error used to indicate that the function input data is missing error ZeroAmount(); /// @notice An error used to indicate that the function input data is missing error ZeroAddress(); ================================================ FILE: src/base/TransmuterErrors.sol ================================================ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.28; error NotRegisteredAlchemist(); error AlchemistDuplicateEntry(); error DepositCapReached(); error DepositZeroAmount(); error PositionNotFound(); error PrematureClaim(); error DepositTooLarge(); error CallerNotOwner(); error PositionNotMatured(uint256 id, uint256 maturationBlock, uint256 currentBlock); error PositionAlreadyPoked(uint256 id); ================================================ FILE: src/external/AlEth.sol ================================================ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.28; pragma experimental ABIEncoderV2; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IDetailedERC20} from "./interfaces/IDetailedERC20.sol"; /// @title AlToken /// /// @dev This is the contract for the Alchemix utillity token usd. /// @notice This version is modified for Alchemix V3 invariant testing. /// /// Initially, the contract deployer is given both the admin and minter role. This allows them to pre-mine tokens, /// transfer admin to a timelock contract, and lastly, grant the staking pools the minter role. After this is done, /// the deployer must revoke their admin role and minter role. contract AlEth is ERC20("Alchemix ETH", "alETH") { using SafeERC20 for ERC20; /// @dev The identifier of the role which maintains other roles. bytes32 public constant ADMIN_ROLE = keccak256("ADMIN"); /// @dev The identifier of the role which allows accounts to mint tokens. bytes32 public constant SENTINEL_ROLE = keccak256("SENTINEL"); /// @dev addresses whitelisted for minting new tokens mapping(address => bool) public whiteList; /// @dev addresses paused for minting new tokens mapping(address => bool) public paused; /// @dev ceiling per address for minting new tokens mapping(address => uint256) public ceiling; /// @dev already minted amount per address to track the ceiling mapping(address => uint256) public hasMinted; event AlchemistPaused(address alchemistAddress, bool isPaused); constructor() {} /// @dev A modifier which checks if whitelisted for minting. modifier onlyWhitelisted() { require(whiteList[msg.sender], "AlETH: Alchemist is not whitelisted"); _; } /// @dev Mints tokens to a recipient. /// /// This function reverts if the caller does not have the minter role. /// /// @param _recipient the account to mint tokens to. /// @param _amount the amount of tokens to mint. function mint(address _recipient, uint256 _amount) external onlyWhitelisted { require(!paused[msg.sender], "AlETH: Alchemist is currently paused."); hasMinted[msg.sender] = hasMinted[msg.sender] + _amount; _mint(_recipient, _amount); } /// This function reverts if the caller does not have the admin role. /// /// @param _toWhitelist the account to mint tokens to. /// @param _state the whitelist state. function setWhitelist(address _toWhitelist, bool _state) external { whiteList[_toWhitelist] = _state; } /// This function reverts if the caller does not have the admin role. /// /// @param _newSentinel the account to set as sentinel. /// This function reverts if the caller does not have the sentinel role. function pauseAlchemist(address _toPause, bool _state) external { paused[_toPause] = _state; } /// This function reverts if the caller does not have the admin role. /// /// @param _toSetCeiling the account set the ceiling off. /// @param _ceiling the max amount of tokens the account is allowed to mint. function setCeiling(address _toSetCeiling, uint256 _ceiling) external { ceiling[_toSetCeiling] = _ceiling; } /** * @dev Destroys `amount` tokens from the caller. * * See {ERC20-_burn}. */ function burn(uint256 amount) public virtual { _burn(_msgSender(), amount); } /** * @dev Destroys `amount` tokens from `account`, deducting from the caller's * allowance. * * See {ERC20-_burn} and {ERC20-allowance}. * * Requirements: * * - the caller must have allowance for ``accounts``'s tokens of at least * `amount`. */ function burnFrom(address account, uint256 amount) public virtual { uint256 decreasedAllowance = allowance(account, _msgSender()) - amount; _approve(account, _msgSender(), decreasedAllowance); _burn(account, amount); } /** * @dev lowers hasminted from the caller's allocation * */ function lowerHasMinted(uint256 amount) public onlyWhitelisted { if (amount >= hasMinted[msg.sender]) { hasMinted[msg.sender] = 0; } else { hasMinted[msg.sender] = hasMinted[msg.sender] - amount; } } } ================================================ FILE: src/external/interfaces/IDetailedERC20.sol ================================================ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.28; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IDetailedERC20 is IERC20 { function name() external returns (string memory); function symbol() external returns (string memory); function decimals() external returns (uint8); } ================================================ FILE: src/external/interfaces/ISettlerActions.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {ISignatureTransfer} from "../../../lib/permit2/src/interfaces/ISignatureTransfer.sol"; interface ISettlerActions { /// @dev Transfer funds from msg.sender Permit2. function TRANSFER_FROM(address recipient, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig) external; // @dev msgValue is interpreted as an upper bound on the expected msg.value, not as an exact specification function NATIVE_CHECK(uint256 deadline, uint256 msgValue) external; /// @dev Transfer funds from metatransaction requestor into the Settler contract using Permit2. Only for use in `Settler.executeMetaTxn` where the signature is provided as calldata function METATXN_TRANSFER_FROM(address recipient, ISignatureTransfer.PermitTransferFrom memory permit) external; /// @dev Settle an RfqOrder between maker and taker transfering funds directly between the parties // Post-req: Payout if recipient != taker function RFQ_VIP( address recipient, ISignatureTransfer.PermitTransferFrom memory makerPermit, address maker, bytes memory makerSig, ISignatureTransfer.PermitTransferFrom memory takerPermit, bytes memory takerSig ) external; /// @dev Settle an RfqOrder between maker and taker transfering funds directly between the parties for the entire amount function METATXN_RFQ_VIP( address recipient, ISignatureTransfer.PermitTransferFrom memory makerPermit, address maker, bytes memory makerSig, ISignatureTransfer.PermitTransferFrom memory takerPermit ) external; /// @dev Settle an RfqOrder between Maker and Settler. Transfering funds from the Settler contract to maker. /// Retaining funds in the settler contract. // Pre-req: Funded // Post-req: Payout function RFQ( address recipient, ISignatureTransfer.PermitTransferFrom memory permit, address maker, bytes memory makerSig, address takerToken, uint256 maxTakerAmount ) external; function UNISWAPV4( address recipient, address sellToken, uint256 bps, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, uint256 amountOutMin ) external; function UNISWAPV4_VIP( address recipient, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig, uint256 amountOutMin ) external; function METATXN_UNISWAPV4_VIP( address recipient, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, ISignatureTransfer.PermitTransferFrom memory permit, uint256 amountOutMin ) external; function BALANCERV3( address recipient, address sellToken, uint256 bps, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, uint256 amountOutMin ) external; function BALANCERV3_VIP( address recipient, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig, uint256 amountOutMin ) external; function METATXN_BALANCERV3_VIP( address recipient, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, ISignatureTransfer.PermitTransferFrom memory permit, uint256 amountOutMin ) external; function PANCAKE_INFINITY( address recipient, address sellToken, uint256 bps, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, uint256 amountOutMin ) external; function PANCAKE_INFINITY_VIP( address recipient, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig, uint256 amountOutMin ) external; function METATXN_PANCAKE_INFINITY_VIP( address recipient, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, ISignatureTransfer.PermitTransferFrom memory permit, uint256 amountOutMin ) external; /// @dev Trades against UniswapV3 using the contracts balance for funding // Pre-req: Funded // Post-req: Payout function UNISWAPV3(address recipient, uint256 bps, bytes memory path, uint256 amountOutMin) external; /// @dev Trades against UniswapV3 using user funds via Permit2 for funding function UNISWAPV3_VIP( address recipient, bytes memory path, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig, uint256 amountOutMin ) external; /// @dev Trades against UniswapV3 using user funds via Permit2 for funding. Metatransaction variant. Signature is over all actions. function METATXN_UNISWAPV3_VIP( address recipient, bytes memory path, ISignatureTransfer.PermitTransferFrom memory permit, uint256 amountOutMin ) external; function MAKERPSM(address recipient, uint256 bps, bool buyGem, uint256 amountOutMin, address psm, address dai) external; function CURVE_TRICRYPTO_VIP( address recipient, uint80 poolInfo, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig, uint256 minBuyAmount ) external; function METATXN_CURVE_TRICRYPTO_VIP( address recipient, uint80 poolInfo, ISignatureTransfer.PermitTransferFrom memory permit, uint256 minBuyAmount ) external; function DODOV1(address sellToken, uint256 bps, address pool, bool quoteForBase, uint256 minBuyAmount) external; function DODOV2( address recipient, address sellToken, uint256 bps, address pool, bool quoteForBase, uint256 minBuyAmount ) external; function VELODROME(address recipient, uint256 bps, address pool, uint24 swapInfo, uint256 minBuyAmount) external; /// @dev Trades against MaverickV2 using the contracts balance for funding /// This action does not use the MaverickV2 callback, so it takes an arbitrary pool address to make calls against. /// Passing `tokenAIn` as a parameter actually saves gas relative to introspecting the pool's `tokenA()` accessor. function MAVERICKV2( address recipient, address sellToken, uint256 bps, address pool, bool tokenAIn, uint256 minBuyAmount ) external; /// @dev Trades against MaverickV2, spending the taker's coupon inside the callback /// This action requires the use of the MaverickV2 callback, so we take the MaverickV2 CREATE2 salt as an argument to derive the pool address from the trusted factory and inithash. /// @param salt is formed as `keccak256(abi.encode(feeAIn, feeBIn, tickSpacing, lookback, tokenA, tokenB, kinds, address(0)))` function MAVERICKV2_VIP( address recipient, bytes32 salt, bool tokenAIn, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig, uint256 minBuyAmount ) external; /// @dev Trades against MaverickV2, spending the taker's coupon inside the callback; metatransaction variant function METATXN_MAVERICKV2_VIP( address recipient, bytes32 salt, bool tokenAIn, ISignatureTransfer.PermitTransferFrom memory permit, uint256 minBuyAmount ) external; /// @dev Trades against UniswapV2 using the contracts balance for funding /// @param swapInfo is encoded as the upper 16 bits as the fee of the pool in bps, the second /// lowest bit as "sell token has transfer fee", and the lowest bit as the /// "token0 for token1" flag. function UNISWAPV2( address recipient, address sellToken, uint256 bps, address pool, uint24 swapInfo, uint256 amountOutMin ) external; function POSITIVE_SLIPPAGE(address payable recipient, address token, uint256 expectedAmount, uint256 maxBps) external; /// @dev Trades against a basic AMM which follows the approval, transferFrom(msg.sender) interaction // Pre-req: Funded // Post-req: Payout function BASIC(address sellToken, uint256 bps, address pool, uint256 offset, bytes calldata data) external; function EKUBO( address recipient, address sellToken, uint256 bps, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, uint256 amountOutMin ) external; function EKUBO_VIP( address recipient, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, ISignatureTransfer.PermitTransferFrom memory permit, bytes memory sig, uint256 amountOutMin ) external; function METATXN_EKUBO_VIP( address recipient, bool feeOnTransfer, uint256 hashMul, uint256 hashMod, bytes memory fills, ISignatureTransfer.PermitTransferFrom memory permit, uint256 amountOutMin ) external; function EULERSWAP( address recipient, address sellToken, uint256 bps, address pool, bool zeroForOne, uint256 amountOutMin ) external; } ================================================ FILE: src/external/interfaces/IVelodromePair.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "../../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; interface IVelodromePair { function metadata() external view returns ( uint256 basis0, uint256 basis1, uint256 reserve0, uint256 reserve1, bool stable, IERC20 token0, IERC20 token1 ); function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external; } ================================================ FILE: src/interfaces/IAlchemicToken.sol ================================================ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity >=0.5.0; import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; /// @title IAlchemicToken /// @author Alchemix Finance interface IAlchemicToken is IERC20 { /// @notice Gets the total amount of minted tokens for an account. /// /// @param account The address of the account. /// /// @return The total minted. function hasMinted(address account) external view returns (uint256); /// @notice Lowers the number of tokens which the `msg.sender` has minted. /// /// This reverts if the `msg.sender` is not whitelisted. /// /// @param amount The amount to lower the minted amount by. function lowerHasMinted(uint256 amount) external; /// @notice Sets the mint allowance for a given account' /// /// This reverts if the `msg.sender` is not admin /// /// @param toSetCeiling The account whos allowance to update /// @param ceiling The amount of tokens allowed to mint function setCeiling(address toSetCeiling, uint256 ceiling) external; /// @notice Updates the state of an address in the whitelist map /// /// This reverts if msg.sender is not admin /// /// @param toWhitelist the address whos state is being updated /// @param state the boolean state of the whitelist function setWhitelist(address toWhitelist, bool state) external; function mint(address recipient, uint256 amount) external; function burn(uint256 amount) external; } ================================================ FILE: src/interfaces/IAlchemistCurator.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; interface IAlchemistCurator { function increaseAbsoluteCap(address adapter, uint256 amount) external; function increaseRelativeCap(address adapter, uint256 amount) external; function submitIncreaseAbsoluteCap(address adapter, uint256 amount) external; function submitIncreaseRelativeCap(address adapter, uint256 amount) external; event IncreaseAbsoluteCap(address indexed strategy, uint256 amount, bytes indexed id); event SubmitIncreaseAbsoluteCap(address indexed strategy, uint256 amount, bytes indexed id); event IncreaseRelativeCap(address indexed strategy, uint256 amount, bytes indexed id); event SubmitIncreaseRelativeCap(address indexed strategy, uint256 amount, bytes indexed id); function decreaseAbsoluteCap(address adapter, uint256 amount) external; event DecreaseAbsoluteCap(address indexed strategy, uint256 amount, bytes indexed id); event SubmitDecreaseAbsoluteCap(address indexed strategy, uint256 amount, bytes indexed id); function decreaseRelativeCap(address adapter, uint256 amount) external; function submitSetAllocator(address myt, address allocator, bool v) external; event SubmitSetAllocator(address indexed allocator, bool indexed v); event DecreaseRelativeCap(address indexed strategy, uint256 amount, bytes indexed id); event SubmitDecreaseRelativeCap(address indexed strategy, uint256 amount, bytes indexed id); event StrategyAdded(address indexed strategy, address indexed myt); event StrategyRemoved(address indexed strategy, address indexed myt); event SubmitSetStrategy(address indexed strategy, address indexed myt); event SubmitRemoveStrategy(address indexed strategy, address indexed myt); function submitSetForceDeallocatePenalty(address adapter, address myt, uint256 penalty) external; event SubmitSetForceDeallocatePenalty(address indexed adapter, address indexed myt, uint256 penalty); function submitSetPerformanceFeeRecipient(address myt, address recipient) external; event SubmitSetPerformanceFeeRecipient(address indexed myt, address indexed recipient); function submitSetPerformanceFee(address myt, uint256 fee) external; event SubmitSetPerformanceFee(address indexed myt, uint256 fee); } ================================================ FILE: src/interfaces/IAlchemistETHVault.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; /** * @title IAlchemistETHVault * @notice Interface for the AlchemistETHVault which handles ETH/WETH deposits * @dev Only authorized addresses (AlchemistV3 or admin) can withdraw funds */ interface IAlchemistETHVault { /** * @notice Get the ERC20 token managed by this vault * @return The WETH token address */ function token() external view returns (address); /** * @notice Deposit WETH into the vault * @param amount Amount of WETH to deposit */ function depositWETH(uint256 amount) external; /** * @notice Withdraw funds from the vault to a target address * @param recipient Address to receive the funds * @param amount Amount to withdraw */ function withdraw(address recipient, uint256 amount) external; /** * @notice Update the AlchemistV3 contract address * @param _alchemistV3 New AlchemistV3 address */ function setAlchemist(address _alchemistV3) external; /** * @notice Get the balance of a user * @param user Address of the user * @return User's balance in the vault */ function balanceOf(address user) external view returns (uint256); /** * @notice Get the WETH contract address * @return Address of the WETH contract */ function weth() external view returns (address); /** * @notice Get the AlchemistV3 contract address * @return Address of the AlchemistV3 contract */ function alchemist() external view returns (address); /** * @notice Get the total amount of deposits in the vault * @return Total deposits */ function totalDeposits() external view returns (uint256); /** * @notice Event emitted when funds are deposited * @param depositor Address that deposited funds * @param amount Amount deposited */ event Deposited(address indexed depositor, uint256 amount); /** * @notice Event emitted when funds are withdrawn * @param recipient Address that received funds * @param amount Amount withdrawn */ event Withdrawn(address indexed recipient, uint256 amount); /** * @notice Event emitted when the AlchemistV3 address is updated * @param newAlchemist New AlchemistV3 address */ event AlchemistV3Updated(address indexed newAlchemist); } ================================================ FILE: src/interfaces/IAlchemistTokenVault.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /** * @title IAlchemistTokenVault * @notice Interface for the AlchemistTokenVault contract */ interface IAlchemistTokenVault { /** * @notice Get the ERC20 token managed by this vault * @return The ERC20 token address */ function token() external view returns (address); /** * @notice Get the address of the Alchemist contract * @return The Alchemist contract address */ function alchemist() external view returns (address); /** * @notice Check if an address is authorized to withdraw * @param withdrawer The address to check * @return Whether the address is authorized */ function authorizedWithdrawers(address withdrawer) external view returns (bool); /** * @notice Allows anyone to deposit tokens into the vault * @param amount The amount of tokens to deposit */ function deposit(uint256 amount) external; /** * @notice Allows only the Alchemist or authorized withdrawers to withdraw tokens * @param to The address to receive the tokens * @param amount The amount of tokens to withdraw */ function withdraw(address to, uint256 amount) external; /** * @notice Sets the authorized status of a withdrawer * @param withdrawer The address to authorize/deauthorize * @param status True to authorize, false to deauthorize */ function setAuthorizedWithdrawer(address withdrawer, bool status) external; /** * @notice Updates the Alchemist address * @param _alchemist The new Alchemist address */ function setAlchemist(address _alchemist) external; /** * @notice Emitted when tokens are deposited */ event Deposited(address indexed from, uint256 amount); /** * @notice Emitted when tokens are withdrawn */ event Withdrawn(address indexed to, uint256 amount); /** * @notice Emitted when an authorized withdrawer status changes */ event AuthorizedWithdrawerSet(address indexed withdrawer, bool status); /** * @notice Emitted when the Alchemist address is updated */ event AlchemistUpdated(address indexed newAlchemist); } ================================================ FILE: src/interfaces/IAlchemistV3.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; /// @notice Contract initialization parameters. struct AlchemistInitializationParams { // The initial admin account. address admin; // The ERC20 token used to represent debt. i.e. the alAsset. address debtToken; // The ERC20 token used to represent the underlying token of the yield token. address underlyingToken; // The global maximum amount of deposited collateral. uint256 depositCap; // The minimum collateralization between 0 and 1 exclusive uint256 minimumCollateralization; // The global minimum collateralization, >= minimumCollateralization. uint256 globalMinimumCollateralization; // The minimum collateralization for liquidation eligibility. between 1 and minimumCollateralization inclusive. uint256 collateralizationLowerBound; // The target collateralization ratio to restore accounts to after liquidation. Must be >= minimumCollateralization. uint256 liquidationTargetCollateralization; // The initial transmuter or transmuter buffer. address transmuter; // The fee on user debt paid to the protocol. uint256 protocolFee; // The address that receives protocol fees. address protocolFeeReceiver; // Fee paid to liquidators. uint256 liquidatorFee; // Fee paid to liquidators forcing an account earmarked debt repayment. uint256 repaymentFee; // The address of the morpho v2 vault. address myt; } /// @notice A user account. /// @notice This account struct is included in the main contract, AlchemistV3.sol, to aid readability. struct Account { /// @notice User's collateral. uint256 collateralBalance; /// @notice User's debt. uint256 debt; /// @notice User debt earmarked for redemption. uint256 earmarked; /// @notice The amount of unlocked collateral. uint256 freeCollateral; /// @notice Last weight of earmark from most recent account sync. uint256 lastAccruedEarmarkWeight; /// @notice Last weight of redemption from most recent account sync. uint256 lastAccruedRedemptionWeight; /// @notice Last weight of collateral from most recent account sync. uint256 lastCollateralWeight; /// @notice Block of the most recent mint uint256 lastMintBlock; /// @notice Block of the most recent repay uint256 lastRepayBlock; /// @notice Last stored survival accumulator uint256 lastSurvivalAccumulator; /// @notice Total debt redeemed at last sync uint256 lastTotalRedeemedDebt; /// @notice Total debt paid out from redemptions at last sync uint256 lastTotalRedeemedSharesOut; /// @notice allowances for minting alAssets, per version. mapping(uint256 => mapping(address => uint256)) mintAllowances; /// @notice id used in the mintAllowances map which is incremented on reset. uint256 allowancesVersion; } /// @notice Information associated with a redemption. /// @notice This redemption struct is included in the main contract, AlchemistV3.sol, to aid in calculating user debt from historic redemptions. struct RedemptionInfo { uint256 earmarked; uint256 debt; uint256 earmarkWeight; } interface IAlchemistV3Actions { /// @notice Approve `spender` to mint `amount` debt tokens. /// /// @param tokenId The tokenId of account granting approval. /// @param spender The address that will be approved to mint. /// @param amount The amount of tokens that `spender` will be allowed to mint. function approveMint(uint256 tokenId, address spender, uint256 amount) external; /// @notice Synchronizes the state of the account owned by `owner`. /// /// @param tokenId The tokenId of account function poke(uint256 tokenId) external; /// @notice Deposit a yield token into a user's account. /// @notice Create a new position by using zero (0) for the `recipientId`. /// @notice Users may create as many positions as they want. /// /// @notice An approval must be set for `yieldToken` which is greater than `amount`. /// /// @notice `recipient` must be non-zero or this call will revert with an {IllegalArgument} error. /// @notice `amount` must be greater than zero or the call will revert with an {IllegalArgument} error. /// /// @notice Emits a {Deposit} event. /// /// @notice **_NOTE:_** When depositing, the `AlchemistV3` contract must have **allowance()** to spend funds on behalf of **msg.sender** for at least **amount** of the **yieldToken** being deposited. This can be done via the standard `ERC20.approve()` method. /// /// @notice **Example:** /// @notice ``` /// @notice address ydai = 0xdA816459F1AB5631232FE5e97a05BBBb94970c95; /// @notice uint256 amount = 50000; /// @notice IERC20(ydai).approve(alchemistAddress, amount); /// @notice (uint256 tokenId, uint256 debtValue) = AlchemistV3(alchemistAddress).deposit(amount, msg.sender, 0); /// @notice ``` /// /// @param amount The amount of yield tokens to deposit. /// @param recipient The owner of the account that will receive the resulting shares. /// @param recipientId The id of account. /// @return tokenId The id of the account. /// @return debtValue The value of deposited tokens normalized to debt token value. function deposit(uint256 amount, address recipient, uint256 recipientId) external returns (uint256 tokenId, uint256 debtValue); /// @notice Withdraw `amount` yield tokens to `recipient`. /// /// @notice `recipient` must be non-zero or this call will revert with an {IllegalArgument} error. /// /// @notice Emits a {Withdraw} event. /// /// @notice **_NOTE:_** When withdrawing, th amount withdrawn must not put user over allowed LTV ratio. /// /// @notice **Example:** /// @notice ``` /// @notice address ydai = 0xdA816459F1AB5631232FE5e97a05BBBb94970c95; /// @notice (uint256 LTV, ) = AlchemistV3(alchemistAddress).getLoanTerms(msg.sender); /// @notice (uint256 yieldTokens, ) = AlchemistV3(alchemistAddress).getCDP(tokenId); /// @notice uint256 maxWithdrawableTokens = (AlchemistV3(alchemistAddress).LTV() - LTV) * yieldTokens / LTV; /// @notice AlchemistV3(alchemistAddress).withdraw(maxWithdrawableTokens, msg.sender); /// @notice ``` /// /// @param amount The number of tokens to withdraw. /// @param recipient The address of the recipient. /// @param tokenId The tokenId of account. /// /// @return amountWithdrawn The number of yield tokens that were withdrawn to `recipient`. function withdraw(uint256 amount, address recipient, uint256 tokenId) external returns (uint256 amountWithdrawn); /// @notice Mint `amount` debt tokens. /// /// @notice `recipient` must be non-zero or this call will revert with an {IllegalArgument} error. /// @notice `amount` must be greater than zero or this call will revert with a {IllegalArgument} error. /// /// @notice Emits a {Mint} event. /// /// @notice **Example:** /// @notice ``` /// @notice uint256 amtDebt = 5000; /// @notice AlchemistV3(alchemistAddress).mint(amtDebt, msg.sender); /// @notice ``` /// /// @param tokenId The tokenId of account. /// @param amount The amount of tokens to mint. /// @param recipient The address of the recipient. function mint(uint256 tokenId, uint256 amount, address recipient) external; /// @notice Mint `amount` debt tokens from the account owned by `owner` to `recipient`. /// /// @notice `recipient` must be non-zero or this call will revert with an {IllegalArgument} error. /// @notice `amount` must be greater than zero or this call will revert with a {IllegalArgument} error. /// /// @notice Emits a {Mint} event. /// /// @notice **_NOTE:_** The caller of `mintFrom()` must have **mintAllowance()** to mint debt from the `Account` controlled by **owner** for at least the amount of **yieldTokens** that **shares** will be converted to. This can be done via the `approveMint()` or `permitMint()` methods. /// /// @notice **Example:** /// @notice ``` /// @notice uint256 amtDebt = 5000; /// @notice AlchemistV3(alchemistAddress).mintFrom(msg.sender, amtDebt, msg.sender); /// @notice ``` /// /// @param tokenId The tokenId of account. /// @param amount The amount of tokens to mint. /// @param recipient The address of the recipient. function mintFrom(uint256 tokenId, uint256 amount, address recipient) external; /// @notice Burn `amount` debt tokens to credit the account owned by `recipientId`. /// /// @notice `amount` will be limited up to the amount of unearmarked debt that `recipient` currently holds. /// /// @notice `recipientId` must be non-zero or this call will revert with an {IllegalArgument} error. /// @notice `amount` must be greater than zero or this call will revert with a {IllegalArgument} error. /// @notice account for `recipientId` must have non-zero debt or this call will revert with an {IllegalState} error. /// /// @notice Emits a {Burn} event. /// /// @notice **Example:** /// @notice ``` /// @notice uint256 amtBurn = 5000; /// @notice AlchemistV3(alchemistAddress).burn(amtBurn, 420); /// @notice ``` /// /// @param amount The amount of tokens to burn. /// @param recipientId The tokenId of account to being credited. /// /// @return amountBurned The amount of tokens that were burned. function burn(uint256 amount, uint256 recipientId) external returns (uint256 amountBurned); /// @notice Repay `amount` debt using yield tokens to credit the account owned by `recipientId`. /// /// @notice `amount` will be limited up to the amount of debt that `recipient` currently holds. /// /// @notice `amount` must be greater than zero or this call will revert with a {IllegalArgument} error. /// @notice `recipient` must be non-zero or this call will revert with an {IllegalArgument} error. /// /// @notice Emits a {Repay} event. /// /// @notice **Example:** /// @notice ``` /// @notice uint256 amtRepay = 5000; /// @notice AlchemistV3(alchemistAddress).repay(amtRepay, msg.sender); /// @notice ``` /// /// @param amount The amount of the yield tokens to repay with. /// @param recipientTokenId The tokenId of account to be repaid /// /// @return amountRepaid The amount of tokens that were repaid. function repay(uint256 amount, uint256 recipientTokenId) external returns (uint256 amountRepaid); /** * @notice Liquidates `owner` if the debt for account `owner` is greater than the underlying value of their collateral * LTV. * * @notice `owner` must be non-zero or this call will revert with an {IllegalArgument} error. * * @notice Emits a {Liquidate} event. * * @notice **Example:** * @notice ``` * @notice AlchemistV2(alchemistAddress).liquidate(id4); * @notice ``` * * @param accountId The tokenId of account * * @return yieldAmount Yield tokens sent to the transmuter. * @return feeInYield Fee paid to liquidator in yield tokens. * @return feeInUnderlying Fee paid to liquidator in underlying token. */ function liquidate(uint256 accountId) external returns (uint256 yieldAmount, uint256 feeInYield, uint256 feeInUnderlying); /// @notice Self liquidates the account owned by `accountId`. /// /// @notice `accountId` must be non-zero or this call will revert with an {IllegalArgument} error. /// @notice `recipient` must be non-zero or this call will revert with an {IllegalArgument} error. /// @notice `accountId` must be owned by `msg.sender` or this call will revert with an {Unauthorized} error. /// @notice `accountId` must be healthy (overcollateralized) or this call will revert with an {AccountNotHealthy} error. /// @notice in the event that account is not healthy, the account can only be liquidated using the regular liquidation path. (i.e. liquidate(accountId)) /// /// @notice Emits a {SelfLiquidated} event. /// /// @param accountId The tokenId of account to be self liquidated. /// @param recipient The address of the recipient. function selfLiquidate(uint256 accountId, address recipient) external returns (uint256 amountLiquidated); /// @notice Liquidates `owners` if the debt for account `owner` is greater than the underlying value of their collateral * LTV. /// /// @notice `owner` must be non-zero or this call will revert with an {IllegalArgument} error. /// /// /// @notice **Example:** /// @notice ``` /// @notice AlchemistV3(alchemistAddress).batchLiquidate([id1, id35]); /// @notice ``` /// /// @param accountIds The tokenId of each account /// /// @return totalAmountLiquidated Amount in yield tokens sent to the transmuter. /// @return totalFeesInYield Amount sent to liquidator in yield tokens. /// @return totalFeesInUnderlying Amount sent to liquidator in underlying token. function batchLiquidate(uint256[] memory accountIds) external returns (uint256 totalAmountLiquidated, uint256 totalFeesInYield, uint256 totalFeesInUnderlying); /// @notice Redeems `amount` debt from the alchemist in exchange for yield tokens sent to the transmuter. /// /// @notice This function is only callable by the transmuter. /// /// @notice Emits a {Redeem} event. /// /// @param amount The amount of tokens to redeem. function redeem(uint256 amount) external returns (uint256 sharesSent); /// @notice Reduces syntheticTokensIssued by `amount`. /// /// @notice This function is only callable by the transmuter. /// /// @param amount The amount of tokens burned during redemption. function reduceSyntheticsIssued(uint256 amount) external; /// @notice Sets lastTransmuterTokenBalance to `amount`. /// /// @notice This function is only callable by the transmuter. /// /// @param amount The balance of the transmuter. function setTransmuterTokenBalance(uint256 amount) external; /// @notice Resets all mint allowances by account managed by `tokenId`. /// /// @notice This function is only callable by the owner of the token id or the AlchemistV3Position contract. /// /// @notice Emits a {MintAllowancesReset} event. /// /// @param tokenId The token id of the account. function resetMintAllowances(uint256 tokenId) external; } interface IAlchemistV3AdminActions { /// @notice Sets the pending administrator. /// /// @notice `msg.sender` must be the admin or this call will will revert with an {Unauthorized} error. /// /// @notice Emits a {PendingAdminUpdated} event. /// /// @dev This is the first step in the two-step process of setting a new administrator. After this function is called, the pending administrator will then need to call {acceptAdmin} to complete the process. /// /// @param value The address to set the pending admin to. function setPendingAdmin(address value) external; /// @notice Sets the active state of a guardian. /// /// @notice `msg.sender` must be the admin or this call will will revert with an {Unauthorized} error. /// /// @notice Emits a {GuardianSet} event. /// /// @param guardian The address of the target guardian. /// @param isActive The active state to set for the guardian. function setGuardian(address guardian, bool isActive) external; /// @notice Allows for `msg.sender` to accepts the role of administrator. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// @notice The current pending administrator must be non-zero or this call will revert with an {IllegalState} error. /// /// @dev This is the second step in the two-step process of setting a new administrator. After this function is successfully called, this pending administrator will be reset and the new administrator will be set. /// /// @notice Emits a {AdminUpdated} event. /// @notice Emits a {PendingAdminUpdated} event. function acceptAdmin() external; /// @notice Set a new alchemist deposit cap. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {DepositCapUpdated} event. /// /// @param value The value of the new deposit cap. function setDepositCap(uint256 value) external; /// @notice Sets the token adapter for the yield token. /// /// @notice `msg.sender` must be the admin or this call will will revert with an {Unauthorized} error. /// /// @notice Emits a {TokenAdapterSet} event. /// /// @param value The address of token adapter. function setTokenAdapter(address value) external; /// @notice Set the minimum collateralization ratio. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {MinimumCollateralizationUpdated} event. /// /// @param value The new minimum collateralization ratio. function setMinimumCollateralization(uint256 value) external; /// @notice Set a new protocol fee receiver. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {ProtocolFeeReceiverUpdated} event. /// /// @param receiver The address of the new fee receiver. function setProtocolFeeReceiver(address receiver) external; /// @notice Set a new protocol debt fee. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {ProtocolFeeUpdated} event. /// /// @param fee The new protocol debt fee. function setProtocolFee(uint256 fee) external; /// @notice Set a new liquidator fee. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {LiquidatorFeeUpdated} event. /// /// @param fee The new liquidator fee. function setLiquidatorFee(uint256 fee) external; /// @notice Set a new repayment fee. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {RepaymentFeeUpdated} event. /// /// @param fee The new repayment fee. function setRepaymentFee(uint256 fee) external; /// @notice Set the global minimum collateralization ratio. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {GlobalMinimumCollateralizationUpdated} event. /// /// @param value The new global minimum collateralization ratio. function setGlobalMinimumCollateralization(uint256 value) external; /// @notice Set the collateralization lower bound ratio. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {CollateralizationLowerBoundUpdated} event. /// /// @param value The new collateralization lower bound ratio. function setCollateralizationLowerBound(uint256 value) external; /// @notice Set the liquidation target collateralization ratio. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {LiquidationTargetCollateralizationUpdated} event. /// /// @param value The new liquidation target collateralization ratio. function setLiquidationTargetCollateralization(uint256 value) external; /// @notice Pause all future deposits in the Alchemist. /// /// @notice `msg.sender` must be the admin or guardian or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {DepositsPaused} event. /// /// @param isPaused The new pause state for deposits in the alchemist. function pauseDeposits(bool isPaused) external; /// @notice Pause all future loans in the Alchemist. /// /// @notice `msg.sender` must be the admin or guardian or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {LoansPaused} event. /// /// @param isPaused The new pause state for loans in the alchemist. function pauseLoans(bool isPaused) external; /// @notice Set the alchemist Fee vault. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {AlchemistFeeVaultUpdated} event. /// /// @param value The address of the new alchemist Fee vault. function setAlchemistFeeVault(address value) external; } interface IAlchemistV3Events { /// @notice Emitted when the pending admin is updated. /// /// @param pendingAdmin The address of the pending admin. event PendingAdminUpdated(address pendingAdmin); /// @notice Emitted when the alchemist Fee vault is updated. /// /// @param alchemistFeeVault The address of the alchemist Fee vault. event AlchemistFeeVaultUpdated(address alchemistFeeVault); /// @notice Emitted when the administrator is updated. /// /// @param admin The address of the administrator. event AdminUpdated(address admin); /// @notice Emitted when the deposit cap is updated. /// /// @param value The value of the new deposit cap. event DepositCapUpdated(uint256 value); /// @notice Emitted when a guardian is added or removed from the alchemist. /// /// @param guardian The addres of the new guardian. /// @param state The active state of the guardian. event GuardianSet(address guardian, bool state); /// @notice Emitted when a new token adapter is set in the alchemist. /// /// @param adapter The addres of the new adapter. event TokenAdapterUpdated(address adapter); /// @notice Emitted when the transmuter is updated. /// /// @param transmuter The updated address of the transmuter. event TransmuterUpdated(address transmuter); /// @notice Emitted when the minimum collateralization is updated. /// /// @param minimumCollateralization The updated minimum collateralization. event MinimumCollateralizationUpdated(uint256 minimumCollateralization); /// @notice Emitted when the global minimum collateralization is updated. /// /// @param globalMinimumCollateralization The updated global minimum collateralization. event GlobalMinimumCollateralizationUpdated(uint256 globalMinimumCollateralization); /// @notice Emitted when the collateralization lower bound (for a liquidation) is updated. /// /// @param collateralizationLowerBound The updated collateralization lower bound. event CollateralizationLowerBoundUpdated(uint256 collateralizationLowerBound); /// @notice Emitted when the liquidation target collateralization ratio is updated. /// /// @param liquidationTargetCollateralization The updated liquidation target collateralization. event LiquidationTargetCollateralizationUpdated(uint256 liquidationTargetCollateralization); /// @notice Emitted when deposits are paused or unpaused in the alchemist. /// /// @param isPaused The current pause state of deposits in the alchemist. event DepositsPaused(bool isPaused); /// @notice Emitted when loans are paused or unpaused in the alchemist. /// /// @param isPaused The current pause state of loans in the alchemist. event LoansPaused(bool isPaused); /// @notice Emitted when `owner` grants `spender` the ability to mint debt tokens on its behalf. /// /// @param ownerTokenId The id of the account authorized to grant approval /// @param spender The address which is being permitted to mint tokens on the behalf of `owner`. /// @param amount The amount of debt tokens that `spender` is allowed to mint. event ApproveMint(uint256 indexed ownerTokenId, address indexed spender, uint256 amount); /// @notice Emitted when a user deposits `amount of yieldToken to `recipient`. /// /// @notice This event does not imply that `sender` directly deposited yield tokens. It is possible that the /// underlying tokens were wrapped. /// /// @param amount The amount of yield tokens that were deposited. /// @param recipientId The id of the account that received the deposited funds. event Deposit(uint256 amount, uint256 indexed recipientId); /// @notice Emitted when yieldToken is withdrawn from the account owned. /// by `owner` to `recipient`. /// /// @notice This event does not imply that `recipient` received yield tokens. It is possible that the yield tokens /// were unwrapped. /// /// @param amount Amount of tokens withdrawn. /// @param tokenId The id of the account that the funds are withdrawn from. /// @param recipient The address that received the withdrawn funds. event Withdraw(uint256 amount, uint256 indexed tokenId, address recipient); /// @notice Emitted when `amount` debt tokens are minted to `recipient` using the account owned by `owner`. /// /// @param tokenId The tokenId of the account owner. /// @param amount The amount of tokens that were minted. /// @param recipient The recipient of the minted tokens. event Mint(uint256 indexed tokenId, uint256 amount, address recipient); /// @notice Emitted when `sender` burns `amount` debt tokens to grant credit to account owner `recipientId`. /// /// @param amount The amount of tokens that were burned. /// @param recipientId The token id of account owned by recipientId that received credit for the burned tokens. event Burn(address indexed sender, uint256 amount, uint256 indexed recipientId); /// @notice Emitted when `amount` of `underlyingToken` are repaid to grant credit to account owned by `recipientId`. /// /// @param sender The address which is repaying tokens. /// @param amount The amount of the underlying token that was used to repay debt. /// @param recipientId The id of account that received credit for the repaid tokens. /// @param credit The amount of debt that was paid-off to the account owned by owner. event Repay(address indexed sender, uint256 amount, uint256 indexed recipientId, uint256 credit); /// @notice Emitted when the transmuter triggers a redemption. /// /// @param amount The amount of debt to redeem. event Redemption(uint256 amount); /// @notice Emitted when the protocol debt fee is updated. /// /// @param fee The new protocol fee. event ProtocolFeeUpdated(uint256 fee); /// @notice Emitted when the liquidator fee is updated. /// /// @param fee The new liquidator fee. event LiquidatorFeeUpdated(uint256 fee); /// @notice Emitted when the repayment fee is updated. /// /// @param fee The new repayment fee. event RepaymentFeeUpdated(uint256 fee); /// @notice Emitted when the fee receiver is updated. /// /// @param receiver The address of the new receiver. event ProtocolFeeReceiverUpdated(address receiver); /// @notice Emitted when account owned by 'accountId' has been liquidated. /// /// @param accountId The token id of the account liquidated /// @param liquidator The address of the liquidator /// @param amount The amount liquidated in yield tokens /// @param feeInYield The liquidation fee sent to 'liquidator' in yield tokens. /// @param feeInUnderlying The liquidation fee sent to 'liquidator' in ETH (if needed i.e. if there isn't enough remaining collateral to cover the fee). event Liquidated(uint256 indexed accountId, address liquidator, uint256 amount, uint256 feeInYield, uint256 feeInUnderlying); /// @notice Emitted when account owned by 'accountId' has been self liquidated. /// /// @param accountId The token id of the account self liquidated /// @param amountLiquidated The amount liquidated in yield tokens event SelfLiquidated(uint256 indexed accountId, uint256 amountLiquidated); /// @notice Emitted when account for 'owner' has been liquidated. /// /// @param accounts The address of the accounts liquidated /// @param liquidator The address of the liquidator /// @param amount The amount liquidated /// @param feeInYield The liquidation fee sent to 'liquidator' in yield tokens. /// @param feeInETH The liquidation fee sent to 'liquidator' in ETH (if needed i.e. if there isn't enough remaining collateral to cover the fee). event BatchLiquidated(uint256[] indexed accounts, address liquidator, uint256 amount, uint256 feeInYield, uint256 feeInETH); /// @notice Emitted when all mint allowances for account managed by `tokenId` are reset. /// /// @param tokenId The tokenId of the account. event MintAllowancesReset(uint256 indexed tokenId); /// @notice Emitted when `amount` of debt is force repaid from `accountId`. /// /// @param accountId The tokenId of the account. /// @param amount The amount of debt repaid. /// @param creditToYield The amount of collateral used to repay the debt in yield tokens. /// @param protocolFeeTotal The amount of protocol fee paid. event ForceRepay(uint256 indexed accountId, uint256 amount, uint256 creditToYield, uint256 protocolFeeTotal); /// @notice Emitted when `amount` of debt is repaid from `accountId`. /// /// @param accountId The tokenId of the account. /// @param feeReciever The address of the fee receiver. /// @param feeInYield The amount of fee paid in yield tokens. /// @param feeInUnderlying The amount of fee paid in underlying tokens. event RepaymentFee(uint256 indexed accountId, address feeReciever, uint256 feeInYield, uint256 feeInUnderlying); /// @notice Emitted when a fee-vault payout is short of the requested amount. /// /// @param liquidator The liquidator receiving the payout. /// @param requested The requested fee amount in underlying tokens. /// @param paid The amount actually paid from the fee vault. event FeeShortfall(address indexed liquidator, uint256 requested, uint256 paid); /// @notice Emitted when a new Position NFT is minted. /// /// @param to The address of the account that minted the Position NFT. /// @param tokenId The tokenId of the Position NFT. event AlchemistV3PositionNFTMinted(address indexed to, uint256 indexed tokenId); } interface IAlchemistV3Immutables { /// @notice Returns the version of the alchemist. /// /// @return The version. function version() external view returns (string memory); /// @notice Returns the address of the debt token used by the system. /// /// @return The address of the debt token. function debtToken() external view returns (address); } interface IAlchemistV3State { /// @notice Gets the address of the admin. /// /// @return admin The admin address. function admin() external view returns (address admin); function depositCap() external view returns (uint256 cap); function guardians(address guardian) external view returns (bool isActive); function cumulativeEarmarked() external view returns (uint256 earmarked); function lastEarmarkBlock() external view returns (uint256 block); function lastRedemptionBlock() external view returns (uint256 block); function lastTransmuterTokenBalance() external view returns (uint256 balance); function totalDebt() external view returns (uint256 debt); function totalSyntheticsIssued() external view returns (uint256 syntheticAmount); function protocolFee() external view returns (uint256 fee); function liquidatorFee() external view returns (uint256 fee); function repaymentFee() external view returns (uint256 fee); function underlyingConversionFactor() external view returns (uint256 factor); function protocolFeeReceiver() external view returns (address receiver); function underlyingToken() external view returns (address token); function myt() external view returns (address token); function depositsPaused() external view returns (bool isPaused); function loansPaused() external view returns (bool isPaused); function alchemistPositionNFT() external view returns (address nftContract); /// @notice Gets the address of the pending administrator. /// /// @return pendingAdmin The pending administrator address. function pendingAdmin() external view returns (address pendingAdmin); /// @notice Gets the address of the current yield token adapter. /// /// @return adapter The token adapter address. function tokenAdapter() external returns (address adapter); /// @notice Gets the address of the alchemist fee vault. /// /// @return vault The alchemist fee vault address. function alchemistFeeVault() external view returns (address vault); /// @notice Gets the address of the transmuter. /// /// @return transmuter The transmuter address. function transmuter() external view returns (address transmuter); /// @notice Gets the minimum collateralization. /// /// @notice Collateralization is determined by taking the total value of collateral that a user has deposited into their account and dividing it their debt. /// /// @dev The value returned is a 18 decimal fixed point integer. /// /// @return minimumCollateralization The minimum collateralization. function minimumCollateralization() external view returns (uint256 minimumCollateralization); /// @notice Gets the global minimum collateralization. /// /// @notice Collateralization is determined by taking the total value of collateral deposited in the alchemist and dividing it by the total debt. /// /// @dev The value returned is a 18 decimal fixed point integer. /// /// @return globalMinimumCollateralization The global minimum collateralization. function globalMinimumCollateralization() external view returns (uint256 globalMinimumCollateralization); /// @notice Gets collaterlization level that will result in an account being eligible for partial liquidation function collateralizationLowerBound() external view returns (uint256 ratio); /// @notice Gets the liquidation target collateralization ratio. /// /// @notice This is the collateralization ratio that accounts are restored to after liquidation. /// /// @dev The value returned is a 18 decimal fixed point integer. /// /// @return liquidationTargetCollateralization The liquidation target collateralization. function liquidationTargetCollateralization() external view returns (uint256 liquidationTargetCollateralization); /// @dev Returns the debt value of `amount` yield tokens. /// /// @param amount The amount to convert. function convertYieldTokensToDebt(uint256 amount) external view returns (uint256); /// @dev Returns the underlying value of `amount` yield tokens. /// /// @param amount The amount to convert. function convertYieldTokensToUnderlying(uint256 amount) external view returns (uint256); /// @dev Returns the yield token value of `amount` debt tokens. /// /// @param amount The amount to convert. function convertDebtTokensToYield(uint256 amount) external view returns (uint256); /// @dev Returns the yield token value of `amount` underlying tokens. /// /// @param amount The amount to convert. function convertUnderlyingTokensToYield(uint256 amount) external view returns (uint256); /// @notice Calculates fee, net debt burn, and gross collateral seize, /// using a single minCollateralization factor (FIXED_POINT_SCALAR scaled). /// @param collateral Current collateral value /// @param debt Current debt value /// @param targetCollateralization Target collateralization ratio, (e.g. 100/90 = 1.1111e18 for 111.11%) /// @param alchemistCurrentCollateralization Current collateralization ratio of the alchemist /// @param alchemistMinimumCollateralization Minimum collateralization ratio of the alchemist to trigger full liquidation /// @param feeBps Fee in basis points on the surplus (0–10000) /// @return grossCollateralToSeize Total collateral to take (fee + net) /// @return debtToBurn Amount of debt to erase (sent to protocol) /// @return fee Amount of collateral paid to liquidator /// @return outsourcedFee Amount of fee paid to liquidator in underlying tokens in the event that account funds are insufficient to cover the fee function calculateLiquidation( uint256 collateral, uint256 debt, uint256 targetCollateralization, uint256 alchemistCurrentCollateralization, uint256 alchemistMinimumCollateralization, uint256 feeBps ) external view returns (uint256 grossCollateralToSeize, uint256 debtToBurn, uint256 fee, uint256 outsourcedFee); /// @dev Normalizes underlying tokens to debt tokens. /// @notice This is to handle decimal conversion in the case where underlying tokens have < 18 decimals. /// /// @param amount The amount to convert. function normalizeUnderlyingTokensToDebt(uint256 amount) external view returns (uint256); /// @dev Normalizes debt tokens to underlying tokens. /// @notice This is to handle decimal conversion in the case where underlying tokens have < 18 decimals. /// /// @param amount The amount to convert. function normalizeDebtTokensToUnderlying(uint256 amount) external view returns (uint256); /// @dev Get information about CDP of tokenId /// /// @param tokenId The token Id of the account. /// /// @return collateral Collateral balance. /// @return debt Current debt. /// @return earmarked Current debt that is earmarked for redemption. function getCDP(uint256 tokenId) external view returns (uint256 collateral, uint256 debt, uint256 earmarked); /// @dev Gets total value of account managed by `tokenId` in units of underlying tokens. /// /// @param tokenId tokenId of the account to query. /// /// @return value Underlying value of the account. function totalValue(uint256 tokenId) external view returns (uint256 value); /// @dev Gets total value deposited in the alchemist /// /// @return amount Total deposite amount. function getTotalDeposited() external view returns (uint256 amount); /// @dev Gets maximum debt that `user` can borrow from their CDP. /// /// @param tokenId tokenId of the account to query. /// /// @return maxDebt Maximum debt that can be taken. function getMaxBorrowable(uint256 tokenId) external view returns (uint256 maxDebt); /// @notice Returns the maximum MYT shares that could be withdrawn from a position /// /// @param tokenId tokenId of the account to query. /// /// @return maxWithraw Maximum yield tokens that can be withdrawn. function getMaxWithdrawable(uint256 tokenId) external view returns (uint256 maxWithraw); /// @dev Gets total underlying value deposited in the alchemist. /// /// @return TVL Total value deposited. function getTotalUnderlyingValue() external view returns (uint256 TVL); /// @dev Gets total underlying value locked in the alchemist. /// /// @return TVL Total value locked. function getTotalLockedUnderlyingValue() external view returns (uint256); /// @notice Gets the amount of debt tokens `spender` is allowed to mint on behalf of `owner`. /// /// @param ownerTokenId tokenId of the account to query. /// @param spender The address which is allowed to mint on behalf of `owner`. /// /// @return allowance The amount of debt tokens that `spender` can mint on behalf of `owner`. function mintAllowance(uint256 ownerTokenId, address spender) external view returns (uint256 allowance); } interface IAlchemistV3Errors { /// @notice An error which is used to indicate that an operation failed because an account became undercollateralized. error Undercollateralized(); /// @notice An error which is used to indicate that a liquidate operation failed because an account is sufficiaenly collateralized. error LiquidationError(); /// @notice An error which is used to indicate that a user is performing an action on an account that requires account ownership error UnauthorizedAccountAccessError(); /// @notice An error which is used to indicate that a burn operation failed because the transmuter requires more debt in the system. /// /// @param amount The amount of debt tokens that were requested to be burned. /// @param available The amount of debt tokens which can be burned; error BurnLimitExceeded(uint256 amount, uint256 available); /// @notice An error which is used to indicate that the account id used is not linked to any owner error UnknownAccountOwnerIDError(); /// @notice An error which is used to indicate that the NFT address being set is the zero address error AlchemistV3NFTZeroAddressError(); /// @notice An error which is used to indicate that the NFT address for the Alchemist has already been set error AlchemistV3NFTAlreadySetError(); /// @notice An error which is used to indicate that the token address for the AlchemistTokenVault does not match the underlyingToken error AlchemistVaultTokenMismatchError(); /// @notice An error which is used to indicate that a user is trying to repay on the same block they are minting error CannotRepayOnMintBlock(); error CannotMintOnRepayBlock(); error GlobalCollateralizationTooLow(uint256, uint256); /// @notice An error which is used to indicate that an account is not healthy error AccountNotHealthy(); } /// @title IAlchemistV3 /// @author Alchemix Finance interface IAlchemistV3 is IAlchemistV3Actions, IAlchemistV3AdminActions, IAlchemistV3Errors, IAlchemistV3Immutables, IAlchemistV3Events, IAlchemistV3State {} ================================================ FILE: src/interfaces/IAlchemistV3Position.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC721Enumerable} from "../interfaces/IERC721Enumerable.sol"; /** * @title IAlchemistV3Position * @notice Interface for the AlchemistV3Position ERC721 token. */ interface IAlchemistV3Position is IERC721Enumerable { /** * @notice Mints a new position NFT to the specified address. * @param to The recipient address for the new position. * @return tokenId The unique token ID minted. */ function mint(address to) external returns (uint256); /** * @notice Burns the NFT with the specified token ID. * @param tokenId The ID of the token to burn. */ function burn(uint256 tokenId) external; /** * @notice Returns the address of the AlchemistV3 contract which is allowed to mint and burn tokens. */ function alchemist() external view returns (address); /** * @notice Returns the address of the admin allowed to update the metadata renderer. */ function admin() external view returns (address); /** * @notice Returns the address of the current metadata renderer contract. */ function metadataRenderer() external view returns (address); /** * @notice Sets or updates the metadata renderer contract. Only callable by the admin. * @param renderer The address of the new metadata renderer. */ function setMetadataRenderer(address renderer) external; /** * @notice Transfers admin rights to a new address. Only callable by the current admin. * @param newAdmin The address of the new admin. */ function setAdmin(address newAdmin) external; /** * @dev Returns the total amount of tokens stored by the contract. */ function totalSupply() external view returns (uint256); /** * @dev Returns a token ID owned by `owner` at a given `index` of its token list. * Use along with {balanceOf} to enumerate all of ``owner``'s tokens. */ function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256); /** * @dev Returns a token ID at a given `index` of all the tokens stored by the contract. * Use along with {totalSupply} to enumerate all tokens. */ function tokenByIndex(uint256 index) external view returns (uint256); } ================================================ FILE: src/interfaces/IAllocator.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; interface IAllocator { /// @notice Allocate with direct allocation (uses ActionType.direct) function allocate(address adapter, uint256 amount) external; /// @notice Deallocate with direct allocation (uses ActionType.direct) function deallocate(address adapter, uint256 amount) external; /// @notice Allocate with swap (uses ActionType.swap) function allocateWithSwap(address adapter, uint256 amount, bytes memory txData) external; /// @notice Deallocate with direct swap (uses ActionType.swap) function deallocateWithSwap(address adapter, uint256 amount, bytes memory txData) external; /// @notice Deallocate with unwrap + swap (uses ActionType.unwrapAndSwap) function deallocateWithUnwrapAndSwap(address adapter, uint256 amount, bytes memory txData, uint256 minIntermediateOut) external; /// @notice Set the vault liquidity adapter used on deposit/withdraw paths function setLiquidityAdapter(address adapter, bytes memory data) external; /// @notice Thrown when the effectice cap is exceeded during allocation error EffectiveCap(uint256 amount, uint256 limit); } ================================================ FILE: src/interfaces/IERC20Burnable.sol ================================================ pragma solidity >=0.5.0; import "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; /// @title IERC20Burnable /// @author Alchemix Finance interface IERC20Burnable is IERC20 { /// @notice Burns `amount` tokens from the balance of `msg.sender`. /// /// @param amount The amount of tokens to burn. /// /// @return If burning the tokens was successful. function burn(uint256 amount) external returns (bool); /// @notice Burns `amount` tokens from `owner`'s balance. /// /// @param owner The address to burn tokens from. /// @param amount The amount of tokens to burn. /// /// @return If burning the tokens was successful. function burnFrom(address owner, uint256 amount) external returns (bool); } ================================================ FILE: src/interfaces/IERC20Metadata.sol ================================================ pragma solidity >=0.5.0; /// @title IERC20Metadata /// @author Alchemix Finance interface IERC20Metadata { /// @notice Gets the name of the token. /// /// @return The name. function name() external view returns (string memory); /// @notice Gets the symbol of the token. /// /// @return The symbol. function symbol() external view returns (string memory); /// @notice Gets the number of decimals that the token has. /// /// @return The number of decimals. function decimals() external view returns (uint8); } ================================================ FILE: src/interfaces/IERC20Minimal.sol ================================================ pragma solidity >=0.5.0; /// @title IERC20Minimal /// @author Alchemix Finance interface IERC20Minimal { /// @notice An event which is emitted when tokens are transferred between two parties. /// /// @param owner The owner of the tokens from which the tokens were transferred. /// @param recipient The recipient of the tokens to which the tokens were transferred. /// @param amount The amount of tokens which were transferred. event Transfer(address indexed owner, address indexed recipient, uint256 amount); /// @notice An event which is emitted when an approval is made. /// /// @param owner The address which made the approval. /// @param spender The address which is allowed to transfer tokens on behalf of `owner`. /// @param amount The amount of tokens that `spender` is allowed to transfer. event Approval(address indexed owner, address indexed spender, uint256 amount); /// @notice Gets the current total supply of tokens. /// /// @return The total supply. function totalSupply() external view returns (uint256); /// @notice Gets the balance of tokens that an account holds. /// /// @param account The account address. /// /// @return The balance of the account. function balanceOf(address account) external view returns (uint256); /// @notice Gets the allowance that an owner has allotted for a spender. /// /// @param owner The owner address. /// @param spender The spender address. /// /// @return The number of tokens that `spender` is allowed to transfer on behalf of `owner`. function allowance(address owner, address spender) external view returns (uint256); /// @notice Transfers `amount` tokens from `msg.sender` to `recipient`. /// /// @notice Emits a {Transfer} event. /// /// @param recipient The address which will receive the tokens. /// @param amount The amount of tokens to transfer. /// /// @return If the transfer was successful. function transfer(address recipient, uint256 amount) external returns (bool); /// @notice Approves `spender` to transfer `amount` tokens on behalf of `msg.sender`. /// /// @notice Emits a {Approval} event. /// /// @param spender The address which is allowed to transfer tokens on behalf of `msg.sender`. /// @param amount The amount of tokens that `spender` is allowed to transfer. /// /// @return If the approval was successful. function approve(address spender, uint256 amount) external returns (bool); /// @notice Transfers `amount` tokens from `owner` to `recipient` using an approval that `owner` gave to `msg.sender`. /// /// @notice Emits a {Approval} event. /// @notice Emits a {Transfer} event. /// /// @param owner The address to transfer tokens from. /// @param recipient The address that will receive the tokens. /// @param amount The amount of tokens to transfer. /// /// @return If the transfer was successful. function transferFrom(address owner, address recipient, uint256 amount) external returns (bool); } ================================================ FILE: src/interfaces/IERC20Mintable.sol ================================================ pragma solidity >=0.5.0; import "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; /// @title IERC20Mintable /// @author Alchemix Finance interface IERC20Mintable is IERC20 { /// @notice Mints `amount` tokens to `recipient`. /// /// @param recipient The address which will receive the minted tokens. /// @param amount The amount of tokens to mint. function mint(address recipient, uint256 amount) external; } ================================================ FILE: src/interfaces/IERC721Enumerable.sol ================================================ // SPDX-License-Identifier: MIT // OpenZeppelin Contracts (last updated v5.0.0) (token/ERC721/extensions/IERC721Enumerable.sol) pragma solidity ^0.8.20; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; /** * @title ERC-721 Non-Fungible Token Standard, optional enumeration extension * @dev See https://eips.ethereum.org/EIPS/eip-721 */ interface IERC721Enumerable is IERC721 { /** * @dev Returns the total amount of tokens stored by the contract. */ function totalSupply() external view returns (uint256); /** * @dev Returns a token ID owned by `owner` at a given `index` of its token list. * Use along with {balanceOf} to enumerate all of ``owner``'s tokens. */ function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256); /** * @dev Returns a token ID at a given `index` of all the tokens stored by the contract. * Use along with {totalSupply} to enumerate all tokens. */ function tokenByIndex(uint256 index) external view returns (uint256); } ================================================ FILE: src/interfaces/IFeeVault.sol ================================================ pragma solidity >=0.5.0; /// @title IFeeVault /// @author Alchemix Finance interface IFeeVault { /** * @notice Get the ERC20 token managed by this vault * @return The ERC20 token address */ function token() external view returns (address); /** * @notice Get the total deposits in the vault * @return Total deposits */ function totalDeposits() external view returns (uint256); /** * @notice Withdraw funds from the vault to a target address * @param recipient Address to receive the funds * @param amount Amount to withdraw */ function withdraw(address recipient, uint256 amount) external; } ================================================ FILE: src/interfaces/IMYTStrategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; interface IMYTStrategy { // Enums enum RiskClass { LOW, MEDIUM, HIGH } // Structs struct StrategyParams { address owner; string name; string protocol; RiskClass riskClass; uint256 cap; uint256 globalCap; uint256 estimatedYield; bool additionalIncentives; uint256 slippageBPS; } enum ActionType { direct, // for wrap/unwrap swap, // for dex swap unwrapAndSwap // for unwrap -> dex swap } struct VaultAdapterParams { ActionType action; SwapParams swapParams; } struct SwapParams { bytes txData; // 0x swap calldata uint256 minIntermediateOut; // Minimum intermediate token out (e.g., stETH from unwrap) } // Only used for ActionType.unwrapAndSwap // Events event Allocate(uint256 indexed amount, address indexed strategy); event Deallocate(uint256 indexed amount, address indexed strategy); event DeallocateDex(uint256 indexed amount); event YieldUpdated(uint256 indexed yield); event RiskClassUpdated(RiskClass indexed class); event IncentivesUpdated(bool indexed enabled); event SlippageBPSUpdated(uint256 indexed newSlippageBPS); event MinAllocationOutBpsUpdated(uint256 indexed newMinAllocationOutBps); event Emergency(bool indexed isEmergency); event StrategyAllocationLoss(string message, uint256 amountRequested, uint256 actualAmountAllocated); event WithdrawToVault(uint256 indexed amount); event RewardsClaimed(address indexed token, uint256 indexed amount); event TokensRescued(address indexed token, address indexed to, uint256 amount); // Errors error StrategyAllocationPaused(address strategy); error CounterfeitSettler(address); error ActionNotSupported(); error ForceDeallocateSwapNotAllowed(); error InvalidAmount(uint256 min, uint256 received); error InsufficientBalance(uint256 required, uint256 available); // Functions /// @dev wrapper function for the customizable _allocate counterpart function allocate(bytes memory data, uint256 assets, bytes4 selector, address sender) external returns (bytes32[] memory strategyIds, int256 change); /// @dev wrapper function for the customizable _deallocate counterpart function deallocate(bytes memory data, uint256 assets, bytes4 selector, address sender) external returns (bytes32[] memory strategyIds, int256 change); /// @dev alternative withdraw/deallocate route using a 0x quote //function deallocateDex(bytes memory data, uint256 amount) external returns (bytes32[] memory strategyIds, int256 change); /// @dev override this function to handle strategies with withdrawal queue NFT function claimWithdrawalQueue(uint256 positionId) external returns (uint256); /// @notice withdraw any leftover assets back to the vault function withdrawToVault() external returns (uint256); /// @dev override this function to claim all available rewards from the respective /// protocol of this strategy function claimRewards(address token, bytes memory quote, uint256 minAmountOut) external returns (uint256); /// @notice recategorize this strategy to a different risk class function setRiskClass(RiskClass newClass) external; function setAdditionalIncentives(bool newValue) external; /// @notice enter/exit emergency mode for this strategy function setKillSwitch(bool val) external; /// @notice get the current snapshotted estimated yield for this strategy. /// This call does not guarantee the latest up-to-date yield and there might /// be discrepancies from the respective protocols numbers. function getEstimatedYield() external view returns (uint256); // Getter for params function params() external view returns ( address owner, string memory name, string memory protocol, RiskClass riskClass, uint256 cap, uint256 globalCap, uint256 estimatedYield, bool additionalIncentives, uint256 slippageBPS ); function getCap() external view returns (uint256); function getGlobalCap() external view returns (uint256); function realAssets() external view returns (uint256); function getIdData() external view returns (bytes memory); function ids() external view returns (bytes32[] memory); function adapterId() external view returns (bytes32); function previewAdjustedWithdraw(uint256 amount) external view returns (uint256); } ================================================ FILE: src/interfaces/IMetadataRenderer.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; interface IMetadataRenderer { /// @notice Generate the token URI for the given token ID. /// @param tokenId The token ID. /// @return The full token URI with data. function tokenURI(uint256 tokenId) external view returns (string memory); } ================================================ FILE: src/interfaces/IStrategyClassifier.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; /// @notice Simple interface for DAO-defined percentage-based caps storage (WAD: 1e18 = 100%) interface IStrategyClassifier { function getIndividualCap(uint256 strategyId) external view returns (uint256); // WAD percentage of totalAssets function getGlobalCap(uint8 riskLevel) external view returns (uint256); // WAD percentage of totalAssets function getStrategyRiskLevel(uint256 strategyId) external view returns (uint8); event AdminChanged(address indexed admin); event RiskClassModified(uint256 indexed class, uint256 indexed globalCap, uint256 indexed localCap); } ================================================ FILE: src/interfaces/ITokenAdapter.sol ================================================ pragma solidity >=0.5.0; /// @title ITokenAdapter /// @author Alchemix Finance interface ITokenAdapter { /// @notice Gets the current version. /// /// @return The version. function version() external view returns (string memory); /// @notice Gets the address of the yield token that this adapter supports. /// /// @return The address of the yield token. function token() external view returns (address); /// @notice Gets the address of the underlying token that the yield token wraps. /// /// @return The address of the underlying token. function underlyingToken() external view returns (address); /// @notice Gets the number of underlying tokens that a single whole yield token is redeemable /// for. /// /// @return The price. function price() external view returns (uint256); } ================================================ FILE: src/interfaces/ITransmuter.sol ================================================ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.28; import "./IAlchemistV3.sol"; interface ITransmuter { struct StakingPosition { // Amount staked. uint256 amount; // Block when the position was opened uint256 startBlock; // Time when the transmutation will be complete/claimable. uint256 maturationBlock; } struct TransmuterInitializationParams { address syntheticToken; address feeReceiver; uint256 timeToTransmute; uint256 transmutationFee; uint256 exitFee; uint256 graphSize; } /// @notice Gets the address of the alchemist. /// /// @return alchemist The alchemist address. function alchemist() external view returns (IAlchemistV3 alchemist); /// @notice Gets the address of the admin. /// /// @return admin The admin address. function admin() external view returns (address admin); /// @notice Gets the address of the pending admin. /// /// @return pendingAdmin The pending admin address. function pendingAdmin() external view returns (address pendingAdmin); /// @notice Returns the version of the alchemist. function version() external view returns (string memory version); /// @notice Returns the address of the synthetic token. function syntheticToken() external view returns (address token); /// @notice Returns the current transmuter deposit cap. function depositCap() external view returns (uint256 cap); /// @notice Returns the transmutation early exit fee. /// @notice This is for users who choose to pull from the transmuter before their position has fully matured. function exitFee() external view returns (uint256 fee); /// @notice Returns the transmutation fee. /// @notice This fee affects all claims. function transmutationFee() external view returns (uint256 fee); /// @notice Returns the current time to transmute (in blocks). function timeToTransmute() external view returns (uint256 transmutationTime); /// @notice Returns the total locked debt tokens in the transmuter adjusted for poked accounts. function totalActiveLocked() external view returns (uint256 totalLocked); /// @notice Returns the total locked debt tokens in the transmuter. function totalLocked() external view returns (uint256 totalLocked); function protocolFeeReceiver() external view returns (address receiver); /// @notice Sets the pending administrator. /// /// @notice `msg.sender` must be the admin or this call will will revert with an {Unauthorized} error. /// /// @notice Emits a {PendingAdminUpdated} event. /// /// @dev This is the first step in the two-step process of setting a new administrator. After this function is called, the pending administrator will then need to call {acceptAdmin} to complete the process. /// /// @param value The address to set the pending admin to. function setPendingAdmin(address value) external; /// @notice Allows for `msg.sender` to accepts the role of administrator. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// @notice The current pending administrator must be non-zero or this call will revert with an {IllegalState} error. /// /// @dev This is the second step in the two-step process of setting a new administrator. After this function is successfully called, this pending administrator will be reset and the new administrator will be set. /// /// @notice Emits a {AdminUpdated} event. /// @notice Emits a {PendingAdminUpdated} event. function acceptAdmin() external; /// @notice Set a new alchemist for redemptions. /// /// @param alchemist The address of the new alchemist. function setAlchemist(address alchemist) external; /// @notice Updates transmuter deposit limit to `cap`. /// /// @notice `cap` must be greater or equal to current synths locked in the transmuter. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// /// @param cap The new deposit cap. function setDepositCap(uint256 cap) external; /// @notice Sets time to transmute to `time`. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @param time The new transmutation time. function setTransmutationTime(uint256 time) external; /// @notice Sets the transmutation fee to `fee`. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @param fee The new transmutation fee. function setTransmutationFee(uint256 fee) external; /// @notice Sets the early exit fee to `fee`. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @param fee The new exit fee. function setExitFee(uint256 fee) external; /// @notice Set a new protocol fee receiver. /// /// @notice `msg.sender` must be the admin or this call will revert with an {Unauthorized} error. /// /// @notice Emits a {ProtocolFeeReceiverUpdated} event. /// /// @param receiver The address of the new fee receiver. function setProtocolFeeReceiver(address receiver) external; /// @notice Gets position info for `id`. /// /// @param id NFT ID, /// /// @return position Position data. function getPosition(uint256 id) external view returns (StakingPosition memory position); /// @notice Creates a new staking position in the transmuter. /// /// @notice `depositAmount` must be non-zero or this call will revert with a {DepositZeroAmount} error. /// /// @notice Emits a {PositionCreated} event. /// /// @param depositAmount Amount of debt tokens to deposit. /// @param recipient Address which will own the minted redemption position NFT. function createRedemption(uint256 depositAmount, address recipient) external; /// @notice Claims a staking position from the transmuter. /// /// @notice `id` must return a valid position or this call will revert with a {PositionNotFound} error. /// @notice End block of position must be <= to current block or this call will revert with a {PrematureClaim} error. /// /// @notice Emits a {PositionClaimed} event. /// /// @param id Id of the nft representing the position. function claimRedemption( uint256 id ) external returns (uint256 claimYield, uint256 feeYield, uint256 syntheticReturned, uint256 syntheticFee); /// @notice Queries the staking graph from `startBlock` to `endBlock`. /// /// @param startBlock The block to start query from. /// @param endBlock The last block to query up to. /// /// @return totalValue Total value of tokens needed to fulfill redemptions between `startBlock` and `endBlock`. function queryGraph(uint256 startBlock, uint256 endBlock) external view returns (uint256 totalValue); /// @notice Poke matured account to lower deposit cap /// /// @param id The position ID. function pokeMatured(uint256 id) external; /// @notice Emitted when the admin address is updated. /// /// @param admin The new admin address. event AdminUpdated(address admin); /// @notice Emitted when the pending admin is updated. /// /// @param pendingAdmin The address of the pending admin. event PendingAdminUpdated(address pendingAdmin); /// @notice Emitted when the associated alchemist is updated. /// /// @param alchemist The address of the new alchemist. event AlchemistUpdated(address alchemist); /// @dev Emitted when a position is created. /// /// @param creator The address that created the position. /// @param amountStaked The amount of tokens staked. /// @param nftId The id of the newly minted NFT. event PositionCreated(address indexed creator, uint256 amountStaked, uint256 nftId); /// @dev Emitted when a position is claimed. /// /// @param claimer The address that claimed the position. /// @param amountClaimed The amount of tokens claimed. /// @param amountUnclaimed The amount of tokens that were not transmuted. event PositionClaimed(address indexed claimer, uint256 amountClaimed, uint256 amountUnclaimed); /// @dev Emitted when the graph size is extended. /// /// @param size The new length of the graph. event GraphSizeUpdated(uint256 size); /// @dev Emitted when the deposit cap is updated. /// /// @param cap The new transmuter deposit cap. event DepositCapUpdated(uint256 cap); /// @dev Emitted when the transmutaiton time is updated. /// /// @param time The new transmutation time in blocks. event TransmutationTimeUpdated(uint256 time); /// @dev Emitted when the transmutaiton fee is updated. /// /// @param fee The new transmutation fee. event TransmutationFeeUpdated(uint256 fee); /// @dev Emitted when the early exit fee is updated. /// /// @param fee The new exit fee. event ExitFeeUpdated(uint256 fee); /// @dev Emitted when the fee receiver is updates. /// /// @param recevier The new receiver. event ProtocolFeeReceiverUpdated(address recevier); event PositionPoked(uint256 indexed id, uint256 amountRemovedFromCap); } ================================================ FILE: src/interfaces/IWETH.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /** * @title IWETH * @dev Interface for Wrapped Ether */ interface IWETH is IERC20 { /** * @notice Deposit ETH and get WETH */ function deposit() external payable; /** * @notice Withdraw ETH by unwrapping WETH * @param amount Amount of WETH to unwrap */ function withdraw(uint256 amount) external; } ================================================ FILE: src/interfaces/IWhitelist.sol ================================================ pragma solidity ^0.8.23; /// @title Whitelist /// @author Alchemix Finance interface IWhitelist { /// @dev Emitted when a contract is added to the whitelist. /// /// @param account The account that was added to the whitelist. event AccountAdded(address account); /// @dev Emitted when a contract is removed from the whitelist. /// /// @param account The account that was removed from the whitelist. event AccountRemoved(address account); /// @dev Emitted when the whitelist is deactivated. event WhitelistDisabled(); /// @dev Returns the list of addresses that are whitelisted for the given contract address. /// /// @return addresses The addresses that are whitelisted to interact with the given contract. function getAddresses() external view returns (address[] memory addresses); /// @dev Returns the disabled status of a given whitelist. /// /// @return disabled A flag denoting if the given whitelist is disabled. function disabled() external view returns (bool); /// @dev Adds an contract to the whitelist. /// /// @param caller The address to add to the whitelist. function add(address caller) external; /// @dev Adds a contract to the whitelist. /// /// @param caller The address to remove from the whitelist. function remove(address caller) external; /// @dev Disables the whitelist of the target whitelisted contract. /// /// This can only occur once. Once the whitelist is disabled, then it cannot be reenabled. function disable() external; /// @dev Checks that the `msg.sender` is whitelisted when it is not an EOA. /// /// @param account The account to check. /// /// @return whitelisted A flag denoting if the given account is whitelisted. function isWhitelisted(address account) external view returns (bool); } ================================================ FILE: src/interfaces/IWstETHLike.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; interface IWstETHLike { function balanceOf(address account) external view returns (uint256); function getStETHByWstETH(uint256 wstETHAmount) external view returns (uint256); function getWstETHByStETH(uint256 stETHAmount) external view returns (uint256); } ================================================ FILE: src/interfaces/IYearnVaultV2.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity >=0.5.0; import "../../lib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; /// @title IYearnVaultV2 /// @author Yearn Finance interface IYearnVaultV2 is IERC20Metadata { struct StrategyParams { uint256 performanceFee; uint256 activation; uint256 debtRatio; uint256 minDebtPerHarvest; uint256 maxDebtPerHarvest; uint256 lastReport; uint256 totalDebt; uint256 totalGain; uint256 totalLoss; bool enforceChangeLimit; uint256 profitLimitRatio; uint256 lossLimitRatio; address customCheck; } function apiVersion() external pure returns (string memory); function permit(address owner, address spender, uint256 amount, uint256 expiry, bytes calldata signature) external returns (bool); // NOTE: Vyper produces multiple signatures for a given function with "default" args function deposit() external returns (uint256); function deposit(uint256 amount) external returns (uint256); function deposit(uint256 amount, address recipient) external returns (uint256); // NOTE: Vyper produces multiple signatures for a given function with "default" args function withdraw() external returns (uint256); function withdraw(uint256 maxShares) external returns (uint256); function withdraw(uint256 maxShares, address recipient) external returns (uint256); function withdraw(uint256 maxShares, address recipient, uint256 maxLoss) external returns (uint256); function token() external view returns (address); function strategies(address _strategy) external view returns (StrategyParams memory); function pricePerShare() external view returns (uint256); function totalAssets() external view returns (uint256); function depositLimit() external view returns (uint256); function maxAvailableShares() external view returns (uint256); /// @notice View how much the Vault would increase this Strategy's borrow limit, based on its present performance /// (since its last report). Can be used to determine expectedReturn in your Strategy. function creditAvailable() external view returns (uint256); /// @notice View how much the Vault would like to pull back from the Strategy, based on its present performance /// (since its last report). Can be used to determine expectedReturn in your Strategy. function debtOutstanding() external view returns (uint256); /// @notice View how much the Vault expect this Strategy to return at the current block, based on its present /// performance (since its last report). Can be used to determine expectedReturn in your Strategy. function expectedReturn() external view returns (uint256); /// @notice This is the main contact point where the Strategy interacts with the Vault. It is critical that this call /// is handled as intended by the Strategy. Therefore, this function will be called by BaseStrategy to make /// sure the integration is correct. function report(uint256 _gain, uint256 _loss, uint256 _debtPayment) external returns (uint256); /// @notice This function should only be used in the scenario where the Strategy is being retired but no migration of /// the positions are possible, or in the extreme scenario that the Strategy needs to be put into /// "Emergency Exit" mode in order for it to exit as quickly as possible. The latter scenario could be for any /// reason that is considered "critical" that the Strategy exits its position as fast as possible, such as a /// sudden change in market conditions leading to losses, or an imminent failure in an external dependency. function revokeStrategy() external; /// @notice View the governance address of the Vault to assert privileged functions can only be called by governance. /// The Strategy serves the Vault, so it is subject to governance defined by the Vault. function governance() external view returns (address); /// @notice View the management address of the Vault to assert privileged functions can only be called by management. /// The Strategy serves the Vault, so it is subject to management defined by the Vault. function management() external view returns (address); /// @notice View the guardian address of the Vault to assert privileged functions can only be called by guardian. The /// Strategy serves the Vault, so it is subject to guardian defined by the Vault. function guardian() external view returns (address); } ================================================ FILE: src/interfaces/IYieldToken.sol ================================================ pragma solidity ^0.8.23; interface IYieldToken { function price() external view returns (uint256); } ================================================ FILE: src/interfaces/test/ITestYieldToken.sol ================================================ pragma solidity >=0.5.0; import "../../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; /// @title ITestYieldToken /// @author Alchemix Finance interface ITestYieldToken is IERC20 { /// @notice Gets the address of underlying token that the yield token wraps. /// /// @return The underlying token address. function underlyingToken() external view returns (address); /// @notice Gets the conversion rate of one whole unit of this token for the underlying token. /// /// @return The price. function price() external view returns (uint256); /// @notice Mints an amount of yield tokens from `amount` underlying tokens and transfers them to `recipient`. /// /// @param amount The amount of underlying tokens. /// @param recipient The address which will receive the minted yield tokens. /// /// @return The amount of minted yield tokens. function mint(uint256 amount, address recipient) external returns (uint256); /// @notice Redeems yield tokens for underlying tokens. /// /// @param amount The amount of yield tokens to redeem. /// @param recipient The address which will receive the redeemed underlying tokens. /// /// @return The amount of underlying tokens that the yield tokens were redeemed for. function redeem(uint256 amount, address recipient) external returns (uint256); /// @notice Simulates an atomic harvest of `amount` underlying tokens. /// /// @param amount The amount of the underlying token. function slurp(uint256 amount) external; /// @notice Simulates an atomic loss of `amount` underlying tokens. /// /// @param amount The amount of the underlying token. function siphon(uint256 amount) external; } ================================================ FILE: src/libraries/FixedPointMath.sol ================================================ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.23; /** * @notice A library which implements fixed point decimal math. */ library FixedPointMath { /** * @dev This will give approximately 60 bits of precision */ uint256 public constant DECIMALS = 18; uint256 public constant ONE = 10 ** DECIMALS; error MulDivZeroDenominator(); error MulDivOverflow(); /** * @notice A struct representing a fixed point decimal. */ struct Number { uint256 n; } /** * @notice Encodes a unsigned 256-bit integer into a fixed point decimal. * * @param value The value to encode. * @return The fixed point decimal representation. */ function encode(uint256 value) internal pure returns (Number memory) { return Number(FixedPointMath.encodeRaw(value)); } /** * @notice Encodes a unsigned 256-bit integer into a uint256 representation of a * fixed point decimal. * * @param value The value to encode. * @return The fixed point decimal representation. */ function encodeRaw(uint256 value) internal pure returns (uint256) { return value * ONE; } /** * @notice Encodes a uint256 MAX VALUE into a uint256 representation of a * fixed point decimal. * * @return The uint256 MAX VALUE fixed point decimal representation. */ function max() internal pure returns (Number memory) { return Number(type(uint256).max); } /** * @notice Creates a rational fraction as a Number from 2 uint256 values * * @param n The numerator. * @param d The denominator. * @return The fixed point decimal representation. */ function rational(uint256 n, uint256 d) internal pure returns (Number memory) { Number memory numerator = encode(n); return FixedPointMath.div(numerator, d); } /** * @notice Adds two fixed point decimal numbers together. * * @param self The left hand operand. * @param value The right hand operand. * @return The result. */ function add(Number memory self, Number memory value) internal pure returns (Number memory) { return Number(self.n + value.n); } /** * @notice Adds a fixed point number to a unsigned 256-bit integer. * * @param self The left hand operand. * @param value The right hand operand. This will be converted to a fixed point decimal. * @return The result. */ function add(Number memory self, uint256 value) internal pure returns (Number memory) { return add(self, FixedPointMath.encode(value)); } /** * @notice Subtract a fixed point decimal from another. * * @param self The left hand operand. * @param value The right hand operand. * @return The result. */ function sub(Number memory self, Number memory value) internal pure returns (Number memory) { return Number(self.n - value.n); } /** * @notice Subtract a unsigned 256-bit integer from a fixed point decimal. * * @param self The left hand operand. * @param value The right hand operand. This will be converted to a fixed point decimal. * @return The result. */ function sub(Number memory self, uint256 value) internal pure returns (Number memory) { return sub(self, FixedPointMath.encode(value)); } /** * @notice Multiplies a fixed point decimal by another fixed point decimal. * * @param self The fixed point decimal to multiply. * @param number The fixed point decimal to multiply by. * @return The result. */ function mul(Number memory self, Number memory number) internal pure returns (Number memory) { return Number((self.n * number.n) / ONE); } /** * @notice Multiplies a fixed point decimal by an unsigned 256-bit integer. * * @param self The fixed point decimal to multiply. * @param value The unsigned 256-bit integer to multiply by. * @return The result. */ function mul(Number memory self, uint256 value) internal pure returns (Number memory) { return Number(self.n * value); } /** * @notice Divides a fixed point decimal by an unsigned 256-bit integer. * * @param self The fixed point decimal to multiply by. * @param value The unsigned 256-bit integer to divide by. * @return The result. */ function div(Number memory self, uint256 value) internal pure returns (Number memory) { return Number(self.n / value); } /// @notice floor(x * y / denominator) with full precision. function mulDiv(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256 result) { unchecked { if (denominator == 0) revert MulDivZeroDenominator(); // 512-bit multiply: [prod1 prod0] = x * y uint256 prod0; uint256 prod1; assembly { let mm := mulmod(x, y, not(0)) prod0 := mul(x, y) prod1 := sub(sub(mm, prod0), lt(mm, prod0)) } // Handle non-overflow case (prod1 == 0) if (prod1 == 0) { return prod0 / denominator; } // Ensure result fits in 256 bits and denominator != 0 if (denominator <= prod1) revert MulDivOverflow(); // Make division exact by subtracting remainder from [prod1 prod0] uint256 remainder; assembly { remainder := mulmod(x, y, denominator) prod1 := sub(prod1, gt(remainder, prod0)) prod0 := sub(prod0, remainder) } // Factor powers of two out of denominator uint256 twos = denominator & (~denominator + 1); assembly { denominator := div(denominator, twos) prod0 := div(prod0, twos) // twos = 2^256 / twos twos := add(div(sub(0, twos), twos), 1) } // Shift high bits into prod0 prod0 |= prod1 * twos; // Compute modular inverse of denominator mod 2^256 (denominator is now odd) uint256 inv = (3 * denominator) ^ 2; inv *= 2 - denominator * inv; // 8 bits inv *= 2 - denominator * inv; // 16 inv *= 2 - denominator * inv; // 32 inv *= 2 - denominator * inv; // 64 inv *= 2 - denominator * inv; // 128 inv *= 2 - denominator * inv; // 256 // Multiply by inverse to get the quotient result = prod0 * inv; } } /// @notice ceil(x * y / denominator) with full precision. function mulDivUp(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256 result) { result = mulDiv(x, y, denominator); unchecked { if (mulmod(x, y, denominator) != 0) { if (result == type(uint256).max) revert MulDivOverflow(); result += 1; } } } /** * @notice Compares two fixed point decimals. * * @param self The left hand number to compare. * @param value The right hand number to compare. * @return When the left hand number is less than the right hand number this returns -1, * when the left hand number is greater than the right hand number this returns 1, * when they are equal this returns 0. */ function cmp(Number memory self, Number memory value) internal pure returns (int256) { if (self.n < value.n) { return -1; } if (self.n > value.n) { return 1; } return 0; } /** * @notice Gets if two fixed point numbers are equal. * * @param self the first fixed point number. * @param value the second fixed point number. * * @return if they are equal. */ function equals(Number memory self, Number memory value) internal pure returns (bool) { return self.n == value.n; } /** * @notice Truncates a fixed point decimal into an unsigned 256-bit integer. * * @return The integer portion of the fixed point decimal. */ function truncate(Number memory self) internal pure returns (uint256) { return self.n / ONE; } // Math helpers for Q128.128 function mulQ128(uint256 aQ, uint256 bQ) internal pure returns (uint256 z) { if (aQ == 0 || bQ == 0) return 0; uint256 lo; uint256 hi; assembly { // 512-bit product [hi lo] = aQ * bQ let mm := mulmod(aQ, bQ, not(0)) lo := mul(aQ, bQ) hi := sub(sub(mm, lo), lt(mm, lo)) } // floor((a*b) / 2^128) z = (hi << 128) | (lo >> 128); // if there are non-zero low bits, round up if (lo & ((uint256(1) << 128) - 1) != 0) { unchecked { z += 1; } } } function divQ128(uint256 numerQ128, uint256 denomQ128) internal pure returns (uint256) { if (numerQ128 == 0) return 0; unchecked { // Fast path: shifting is safe if numerQ128 < 2^128 if (numerQ128 <= type(uint256).max >> 128) { return (numerQ128 << 128) / denomQ128; } // Slow path: numerQ128 can only be 2^128 here. uint256 q = numerQ128 / denomQ128; // 0 or 1 in our domain uint256 r = numerQ128 - q * denomQ128; // remainder return (q << 128) + ((r << 128) / denomQ128); } } } ================================================ FILE: src/libraries/NFTMetadataGenerator.sol ================================================ pragma solidity 0.8.28; import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; library NFTMetadataGenerator { using Strings for uint256; // SVG colors string private constant SVG_BG_COLOR = "#1e1e1f"; string private constant SVG_TEXT_COLOR = "#f5c09a"; uint256 private constant TOKEN_ID_CHARS_PER_LINE = 25; uint256 private constant TOKEN_ID_LINE_HEIGHT = 22; uint256 private constant TOKEN_ID_FONT_SIZE = 30; uint256 private constant TOKEN_ID_START_Y = 250; /** * @notice Generate on-chain SVG for token * @param tokenId The token ID * @return SVG string */ function generateSVG(uint256 tokenId, string memory title) internal pure returns (string memory) { return string( abi.encodePacked( '', '', '', title, "", _generateTokenIdText(tokenId), _generateLogo(), "" ) ); } function _generateTokenIdText(uint256 tokenId) private pure returns (string memory) { bytes memory tokenIdBytes = bytes(string(abi.encodePacked("#", tokenId.toString()))); uint256 lineCount = (tokenIdBytes.length + TOKEN_ID_CHARS_PER_LINE - 1) / TOKEN_ID_CHARS_PER_LINE; bytes memory output = abi.encodePacked( '' ); for (uint256 i = 0; i < lineCount; i++) { uint256 start = i * TOKEN_ID_CHARS_PER_LINE; uint256 end = start + TOKEN_ID_CHARS_PER_LINE; if (end > tokenIdBytes.length) { end = tokenIdBytes.length; } bytes memory line = new bytes(end - start); for (uint256 j = start; j < end; j++) { line[j - start] = tokenIdBytes[j]; } output = abi.encodePacked( output, i == 0 ? '' : string(abi.encodePacked('')), line, "" ); } return string(abi.encodePacked(output, "")); } function _generateLogo() private pure returns (string memory) { return string( abi.encodePacked( '', _generateLogoWordmark(), _generateLogoMark(), "" ) ); } function _generateLogoWordmark() private pure returns (string memory) { return string( abi.encodePacked( '', '', '', '', '', '', '', '', '', '' ) ); } function _generateLogoMark() private pure returns (string memory) { return string( abi.encodePacked( '', '', '', '', '', '', '', '' ) ); } /** * @notice Generate the JSON string for the given token ID and SVG. * @param tokenId The token ID. * @param svg The SVG string. * @return The JSON string. */ function generateJSONString(uint256 tokenId, string memory svg) internal pure returns (string memory) { string memory json = Base64.encode( abi.encodePacked( '{"name": "AlchemistV3 Position #', tokenId.toString(), '", ', '"description": "Position token for Alchemist V3", ', '"image": "data:image/svg+xml;base64,', Base64.encode(bytes(svg)), '"}' ) ); return json; } /** * @notice Generate the token URI for the given token ID and title. * @param tokenId The token ID. * @param title The title of the token. * @return The full token URI with data. */ function generateTokenURI(uint256 tokenId, string memory title) internal pure returns (string memory) { string memory svg = generateSVG(tokenId, title); string memory json = generateJSONString(tokenId, svg); return string(abi.encodePacked("data:application/json;base64,", json)); } } ================================================ FILE: src/libraries/SafeCast.sol ================================================ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity >=0.5.0; import {IllegalArgument} from "../base/Errors.sol"; /// @title Safe casting methods /// @notice Contains methods for safely casting between types library SafeCast { /// @notice Cast a uint256 to a int256, revert on overflow /// @param y The uint256 to be casted /// @return z The casted integer, now type int256 function toInt256(uint256 y) internal pure returns (int256 z) { if (y >= 2 ** 255) { revert IllegalArgument(); } z = int256(y); } /// @notice Cast a int256 to a uint256, revert on underflow /// @param y The int256 to be casted /// @return z The casted integer, now type uint256 function toUint256(int256 y) internal pure returns (uint256 z) { if (y < 0) { revert IllegalArgument(); } z = uint256(y); } /// @notice Cast a uint256 to a uint128, revert on underflow /// @param y The uint256 to be casted /// @return z The casted integer, now type uint128 function uint256ToUint128(uint256 y) internal pure returns (uint128 z) { if (y > type(uint128).max) { revert IllegalArgument(); } z = uint128(y); } /// @notice Cast a uint128 to a uint256 /// @param y The uint128 to be casted /// @return z The casted integer, now type uint256 function uint128ToUint256(uint128 y) internal pure returns (uint256 z) { // Upcast will not overflow z = uint256(y); } } ================================================ FILE: src/libraries/SafeERC20.sol ================================================ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity >=0.8.4; import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "../interfaces/IERC20Metadata.sol"; /// @title SafeERC20 /// @author Alchemix Finance library SafeERC20 { /// @notice An error used to indicate that a call to an ERC20 contract failed. /// /// @param target The target address. /// @param success If the call to the token was a success. /// @param data The resulting data from the call. This is error data when the call was not a /// success. Otherwise, this is malformed data when the call was a success. error ERC20CallFailed(address target, bool success, bytes data); /// @dev A safe function to get the decimals of an ERC20 token. /// /// @dev Reverts with a {CallFailed} error if execution of the query fails or returns an /// unexpected value. /// /// @param token The target token. /// /// @return The amount of decimals of the token. function expectDecimals(address token) internal view returns (uint8) { (bool success, bytes memory data) = token.staticcall(abi.encodeWithSelector(IERC20Metadata.decimals.selector)); if (!success || data.length < 32) { revert ERC20CallFailed(token, success, data); } return abi.decode(data, (uint8)); } /// @dev Transfers tokens to another address. /// /// @dev Reverts with a {CallFailed} error if execution of the transfer failed or returns an /// unexpected value. /// /// @param token The token to transfer. /// @param recipient The address of the recipient. /// @param amount The amount of tokens to transfer. function safeTransfer(address token, address recipient, uint256 amount) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, recipient, amount)); if (!success || (data.length != 0 && !abi.decode(data, (bool)))) { revert ERC20CallFailed(token, success, data); } } /// @dev Approves tokens for the smart contract. /// /// @dev Reverts with a {CallFailed} error if execution of the approval fails or returns an /// unexpected value. /// /// @param token The token to approve. /// @param spender The contract to spend the tokens. /// @param value The amount of tokens to approve. function safeApprove(address token, address spender, uint256 value) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.approve.selector, spender, value)); if (!success || (data.length != 0 && !abi.decode(data, (bool)))) { revert ERC20CallFailed(token, success, data); } } /// @dev Transfer tokens from one address to another address. /// /// @dev Reverts with a {CallFailed} error if execution of the transfer fails or returns an /// unexpected value. /// /// @param token The token to transfer. /// @param owner The address of the owner. /// @param recipient The address of the recipient. /// @param amount The amount of tokens to transfer. function safeTransferFrom(address token, address owner, address recipient, uint256 amount) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transferFrom.selector, owner, recipient, amount)); if (!success || (data.length != 0 && !abi.decode(data, (bool)))) { revert ERC20CallFailed(token, success, data); } } } ================================================ FILE: src/libraries/Sets.sol ================================================ pragma solidity ^0.8.23; /// @title Sets /// @author Alchemix Finance library Sets { using Sets for AddressSet; /// @notice A data structure holding an array of values with an index mapping for O(1) lookup. struct AddressSet { address[] values; mapping(address => uint256) indexes; } /// @dev Add a value to a Set /// /// @param self The Set. /// @param value The value to add. /// /// @return Whether the operation was successful (unsuccessful if the value is already contained in the Set) function add(AddressSet storage self, address value) internal returns (bool) { if (self.contains(value)) { return false; } self.values.push(value); self.indexes[value] = self.values.length; return true; } /// @dev Remove a value from a Set /// /// @param self The Set. /// @param value The value to remove. /// /// @return Whether the operation was successful (unsuccessful if the value was not contained in the Set) function remove(AddressSet storage self, address value) internal returns (bool) { uint256 index = self.indexes[value]; if (index == 0) { return false; } // Normalize the index since we know that the element is in the set. index--; uint256 lastIndex = self.values.length - 1; if (index != lastIndex) { address lastValue = self.values[lastIndex]; self.values[index] = lastValue; self.indexes[lastValue] = index + 1; } self.values.pop(); delete self.indexes[value]; return true; } /// @dev Returns true if the value exists in the Set /// /// @param self The Set. /// @param value The value to check. /// /// @return True if the value is contained in the Set, False if it is not. function contains(AddressSet storage self, address value) internal view returns (bool) { return self.indexes[value] != 0; } } ================================================ FILE: src/libraries/StakingGraph.sol ================================================ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.28; /* Block-granular stake progression tracking for the Transmuter implemented * as a double, delta Fenwick tree. By tracking stake size and range, this * structure reports a block-granular report of the full amount of the stake * that can be redeemed in aggregate across all Transmuter stakes. * * For better gas effeciency, storage operations are halved by packing the * individual nodes of the two trees into a single 256-bit slot, utilizing * a 144/112 bit split. The least significant portion storing the raw * amount, and the other storing the amount * block. This 144/112 split * thus provides 32-bits for block numbers */ library StakingGraph { //112/144 bit split for delta and product storage, providing 32 bits for start/expiration uint256 private constant DELTA_BITS = 112; // Derive related constants from DELTA_BITS uint256 private constant DELTA_MASK = (uint256(1) << DELTA_BITS) - 1; uint256 private constant DELTA_SIGNBIT = uint256(1) << (DELTA_BITS - 1); uint256 private constant PRODUCT_BITS = 256 - DELTA_BITS; uint256 private constant PRODUCT_SIGNBIT = uint256(1) << (PRODUCT_BITS - 1); // Signed ranges that are actually representable in the packed fields int256 private constant DELTA_MAX = int256(DELTA_SIGNBIT - 1); int256 private constant DELTA_MIN = -int256(DELTA_SIGNBIT); int256 private constant PRODUCT_MAX = int256(PRODUCT_SIGNBIT - 1); int256 private constant PRODUCT_MIN = -int256(PRODUCT_SIGNBIT); // Maximum graph size as per bit-split (32-bit timeline when DELTA_BITS=112) uint256 private constant GRAPH_MAX = uint256(1) << (PRODUCT_BITS - DELTA_BITS); //Structure containing full graph state struct Graph { uint256 size; mapping(uint256 => uint256) g; } /** * Add/update a position in/to the graph * Revert if amount underflows or overflows, or if start/duration would exceed GRAPH_MAX * * @param g contract storage instance of a Graph struct * @param amount (DELTA_MIN >= amount >= DELTA_MAX) * @param start block where the stake change begins * @param duration total range of the stake change */ function addStake(Graph storage g, int256 amount, uint256 start, uint256 duration) internal { unchecked { require(amount <= DELTA_MAX && amount >= DELTA_MIN); require(start < GRAPH_MAX-1); uint256 expiration = start + duration; require(expiration < GRAPH_MAX-1); uint256 graphSize = g.size; //check if the tree must be expanded uint256 newSize = expiration + 2; if (newSize >= graphSize) { //round expiration up to the next power of 2 newSize |= newSize >> 1; newSize |= newSize >> 2; newSize |= newSize >> 4; newSize |= newSize >> 8; newSize |= newSize >> 16; if (GRAPH_MAX > 2**32) {//handle GRAPH_MAX > 32-bit newSize |= newSize >> 32; newSize |= newSize >> 64; newSize |= newSize >> 128; } newSize++; //DEBUG: uncomment for maximum tree size //newSize = GRAPH_MAX; require (newSize <= GRAPH_MAX); if (graphSize != 0) { //if the graph isn't null, copy the last entry up to the new end uint256 copy = g.g[graphSize]; while (graphSize <= newSize) { g.g[graphSize] = copy; graphSize += graphSize; } } graphSize = newSize; g.size = newSize; } //update tree storage with deltas, revert if results cannot be packed into storage update(g.g, start + 1, graphSize, amount, amount * int256(start)); update(g.g, expiration + 1, graphSize, -amount, -amount * int256(expiration)); } } /** * Query the new amount that is earmarked between blocks start and end * Revert if start or end exceed GRAPH_MAX * * @param g contract storage instance of a Graph struct * @param start block at the start of the query range * @param end block at end of the query range */ function queryStake(Graph storage g, uint256 start, uint256 end) internal view returns (int256) { int256 begDelta; int256 begProd; int256 endDelta; int256 endProd; uint256 graphSize; unchecked { require (end <= GRAPH_MAX); //catch overflow start--; require (start <= GRAPH_MAX); //catch overflow and underflow graphSize = g.size; if (graphSize == 0) return 0; // Clamp both prefix query indices to the tree domain. start = start > graphSize ? graphSize : start; end = end > graphSize ? graphSize : end; if (end <= start) return 0; (begDelta,begProd) = query(g.g, start); (endDelta,endProd) = query(g.g, end); return ((int256(end) * endDelta) - endProd) - ((int256(start) * begDelta) - begProd); } } /** * Update the packed fenwick tree with delta & deltaProd. Extend the tree if possible/necessary * Revert if the partial sums cannot be packed back into the structure * * For internal use within the library for index validation */ function update(mapping(uint256 => uint256) storage graph, uint256 index, uint256 treeSize, int256 delta, int256 deltaProd) private { unchecked { index += 1; while (index <= treeSize) { //graph[index] += delta on 2 packed values uint256 packed = graph[index]; int256 ad; int256 ap; //unpack values if ((packed&DELTA_SIGNBIT) != 0) { ad = int256(packed | ~DELTA_MASK); //extend set sign bit } else { ad = int256(packed & DELTA_MASK); //extend zero sign bit } ap = int256(packed)>>DELTA_BITS; //automatic sign extension ad+=delta; ap+=deltaProd; //pack and store new values require(ad <= DELTA_MAX && ad >= DELTA_MIN); require(ap <= PRODUCT_MAX && ap >= PRODUCT_MIN); graph[index] = (uint256(ad)&DELTA_MASK)|uint256(ap< uint256) storage graph, uint256 index) private view returns (int256 sum, int256 sumProd) { unchecked { index += 1; while (index > 0) { //sum += graph[index] on 2 packed values uint256 packed = graph[index]; int256 ad; int256 ap; //unpack values if ((packed&(2**(DELTA_BITS-1))) != 0) { ad = int256(packed | ~DELTA_MASK); //extend set sign bit } else { ad = int256(packed & DELTA_MASK); //extend zero sign bit } ap = int256(packed)>>DELTA_BITS; //automatic sign extension sum += ad; sumProd += ap; assembly { index := sub(index, and(index, sub(0, index))) } } } } } ================================================ FILE: src/libraries/TokenUtils.sol ================================================ pragma solidity ^0.8.23; import "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import "../../lib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import "../interfaces/IERC20Burnable.sol"; import "../interfaces/IERC20Mintable.sol"; /// @title TokenUtils /// @author Alchemix Finance library TokenUtils { /// @notice An error used to indicate that a call to an ERC20 contract failed. /// /// @param target The target address. /// @param success If the call to the token was a success. /// @param data The resulting data from the call. This is error data when the call was not a success. Otherwise, /// this is malformed data when the call was a success. error ERC20CallFailed(address target, bool success, bytes data); /// @dev A safe function to get the decimals of an ERC20 token. /// /// @dev Reverts with a {CallFailed} error if execution of the query fails or returns an unexpected value. /// /// @param token The target token. /// /// @return The amount of decimals of the token. function expectDecimals(address token) internal view returns (uint8) { (bool success, bytes memory data) = token.staticcall(abi.encodeWithSelector(IERC20Metadata.decimals.selector)); if (token.code.length == 0 || !success || data.length < 32) { revert ERC20CallFailed(token, success, data); } return abi.decode(data, (uint8)); } /// @dev Gets the balance of tokens held by an account. /// /// @dev Reverts with a {CallFailed} error if execution of the query fails or returns an unexpected value. /// /// @param token The token to check the balance of. /// @param account The address of the token holder. /// /// @return The balance of the tokens held by an account. function safeBalanceOf(address token, address account) internal view returns (uint256) { (bool success, bytes memory data) = token.staticcall(abi.encodeWithSelector(IERC20.balanceOf.selector, account)); if (token.code.length == 0 || !success || data.length < 32) { revert ERC20CallFailed(token, success, data); } return abi.decode(data, (uint256)); } /// @dev Transfers tokens to another address. /// /// @dev Reverts with a {CallFailed} error if execution of the transfer failed or returns an unexpected value. /// /// @param token The token to transfer. /// @param recipient The address of the recipient. /// @param amount The amount of tokens to transfer. function safeTransfer(address token, address recipient, uint256 amount) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, recipient, amount)); if (token.code.length == 0 || !success || (data.length != 0 && !abi.decode(data, (bool)))) { revert ERC20CallFailed(token, success, data); } } /// @dev Approves tokens for the smart contract. /// /// @dev Reverts with a {CallFailed} error if execution of the approval fails or returns an unexpected value. /// /// @param token The token to approve. /// @param spender The contract to spend the tokens. /// @param value The amount of tokens to approve. function safeApprove(address token, address spender, uint256 value) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.approve.selector, spender, value)); if (token.code.length == 0 || !success || (data.length != 0 && !abi.decode(data, (bool)))) { revert ERC20CallFailed(token, success, data); } } /// @dev Transfer tokens from one address to another address. /// /// @dev Reverts with a {CallFailed} error if execution of the transfer fails or returns an unexpected value. /// /// @param token The token to transfer. /// @param owner The address of the owner. /// @param recipient The address of the recipient. /// @param amount The amount of tokens to transfer. function safeTransferFrom(address token, address owner, address recipient, uint256 amount) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transferFrom.selector, owner, recipient, amount)); if (token.code.length == 0 || !success || (data.length != 0 && !abi.decode(data, (bool)))) { revert ERC20CallFailed(token, success, data); } } /// @dev Mints tokens to an address. /// /// @dev Reverts with a {CallFailed} error if execution of the mint fails or returns an unexpected value. /// /// @param token The token to mint. /// @param recipient The address of the recipient. /// @param amount The amount of tokens to mint. function safeMint(address token, address recipient, uint256 amount) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20Mintable.mint.selector, recipient, amount)); if (token.code.length == 0 || !success || (data.length != 0 && !abi.decode(data, (bool)))) { revert ERC20CallFailed(token, success, data); } } /// @dev Burns tokens. /// /// Reverts with a `CallFailed` error if execution of the burn fails or returns an unexpected value. /// /// @param token The token to burn. /// @param amount The amount of tokens to burn. function safeBurn(address token, uint256 amount) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20Burnable.burn.selector, amount)); if (token.code.length == 0 || !success || (data.length != 0 && !abi.decode(data, (bool)))) { revert ERC20CallFailed(token, success, data); } } /// @dev Burns tokens from its total supply. /// /// @dev Reverts with a {CallFailed} error if execution of the burn fails or returns an unexpected value. /// /// @param token The token to burn. /// @param owner The owner of the tokens. /// @param amount The amount of tokens to burn. function safeBurnFrom(address token, address owner, uint256 amount) internal { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20Burnable.burnFrom.selector, owner, amount)); if (token.code.length == 0 || !success || (data.length != 0 && !abi.decode(data, (bool)))) { revert ERC20CallFailed(token, success, data); } } } ================================================ FILE: src/mocks/ERC20Mock.sol ================================================ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.23; import {ERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; /// @title ERC20Mock /// /// @dev A mock of an ERC20 token which lets anyone burn and mint tokens. contract ERC20Mock is ERC20 { constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) {} function mint(address _recipient, uint256 _amount) external { _mint(_recipient, _amount); } function burn(address _account, uint256 _amount) external { _burn(_account, _amount); } } ================================================ FILE: src/mocks/FixedPointMathOld.sol ================================================ //SPDX-License-Identifier: Unlicense pragma solidity 0.8.28; library FixedPointMath { uint256 public constant DECIMALS = 18; uint256 public constant SCALAR = 10**DECIMALS; struct FixedDecimal { uint256 x; } function fromU256(uint256 value) internal pure returns (FixedDecimal memory) { uint256 x; require(value == 0 || (x = value * SCALAR) / SCALAR == value); return FixedDecimal(x); } function add(FixedDecimal memory self, FixedDecimal memory value) internal pure returns (FixedDecimal memory) { uint256 x; require((x = self.x + value.x) >= self.x); return FixedDecimal(x); } function add(FixedDecimal memory self, uint256 value) internal pure returns (FixedDecimal memory) { return add(self, fromU256(value)); } function sub(FixedDecimal memory self, FixedDecimal memory value) internal pure returns (FixedDecimal memory) { uint256 x; require((x = self.x - value.x) <= self.x); return FixedDecimal(x); } function sub(FixedDecimal memory self, uint256 value) internal pure returns (FixedDecimal memory) { return sub(self, fromU256(value)); } function mul(FixedDecimal memory self, uint256 value) internal pure returns (FixedDecimal memory) { uint256 x; require(value == 0 || (x = self.x * value) / value == self.x); return FixedDecimal(x); } function div(FixedDecimal memory self, uint256 value) internal pure returns (FixedDecimal memory) { require(value != 0); return FixedDecimal(self.x / value); } function cmp(FixedDecimal memory self, FixedDecimal memory value) internal pure returns (int256) { if (self.x < value.x) { return -1; } if (self.x > value.x) { return 1; } return 0; } function decode(FixedDecimal memory self) internal pure returns (uint256) { return self.x / SCALAR; } } ================================================ FILE: src/mocks/Pool.sol ================================================ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.28; pragma experimental ABIEncoderV2; 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 {FixedPointMath} from "./FixedPointMathOld.sol"; interface IDetailedERC20 is IERC20 { function name() external returns (string memory); function symbol() external returns (string memory); function decimals() external returns (uint8); } /// @title Pool /// /// @dev A library which provides the Pool data struct and associated functions. library Pool { using FixedPointMath for FixedPointMath.FixedDecimal; using Pool for Pool.Data; using Pool for Pool.List; using Math for uint256; struct Context { uint256 rewardRate; uint256 totalRewardWeight; } struct Data { IERC20 token; uint256 totalDeposited; uint256 rewardWeight; FixedPointMath.FixedDecimal accumulatedRewardWeight; uint256 lastUpdatedBlock; } struct List { Data[] elements; } /// @dev Updates the pool. /// /// @param _ctx the pool context. function update(Data storage _data, Context storage _ctx) internal { uint256 _elapsedTime = block.number - (_data.lastUpdatedBlock); uint256 _distributeAmount; if (block.number - (_data.lastUpdatedBlock) != 0) { uint256 _rewardRate = _data.getRewardRate(_ctx); _distributeAmount = _rewardRate * (_elapsedTime); } _data.accumulatedRewardWeight = _data.getUpdatedAccumulatedRewardWeight(_ctx); _data.lastUpdatedBlock = block.number; _data.totalDeposited -= _distributeAmount; } /// @dev Gets the rate at which the pool will distribute rewards to stakers. /// /// @param _ctx the pool context. /// /// @return the reward rate of the pool in tokens per block. function getRewardRate(Data storage _data, Context storage _ctx) internal view returns (uint256) { return _ctx.rewardRate * (_data.rewardWeight) / (_ctx.totalRewardWeight); } /// @dev Gets the accumulated reward weight of a pool. /// /// @param _ctx the pool context. /// /// @return the accumulated reward weight. function getUpdatedAccumulatedRewardWeight(Data storage _data, Context storage _ctx) internal view returns (FixedPointMath.FixedDecimal memory) { if (_data.totalDeposited == 0) { return _data.accumulatedRewardWeight; } uint256 _elapsedTime = block.number - (_data.lastUpdatedBlock); if (_elapsedTime == 0) { return _data.accumulatedRewardWeight; } uint256 _rewardRate = _data.getRewardRate(_ctx); uint256 _distributeAmount = _rewardRate * (_elapsedTime); if (_distributeAmount == 0) { return _data.accumulatedRewardWeight; } FixedPointMath.FixedDecimal memory _rewardWeight = FixedPointMath.fromU256(_distributeAmount).div(_data.totalDeposited); return _data.accumulatedRewardWeight.add(_rewardWeight); } /// @dev Adds an element to the list. /// /// @param _element the element to add. function push(List storage _self, Data memory _element) internal { _self.elements.push(_element); } /// @dev Gets an element from the list. /// /// @param _index the index in the list. /// /// @return the element at the specified index. function get(List storage _self, uint256 _index) internal view returns (Data storage) { return _self.elements[_index]; } /// @dev Gets the last element in the list. /// /// This function will revert if there are no elements in the list. ///ck /// @return the last element in the list. function last(List storage _self) internal view returns (Data storage) { return _self.elements[_self.lastIndex()]; } /// @dev Gets the index of the last element in the list. /// /// This function will revert if there are no elements in the list. /// /// @return the index of the last element. function lastIndex(List storage _self) internal view returns (uint256) { uint256 _length = _self.length(); return _length - 1; } /// @dev Gets the number of elements in the list. /// /// @return the number of elements. function length(List storage _self) internal view returns (uint256) { return _self.elements.length; } } ================================================ FILE: src/mocks/Stake.sol ================================================ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.28; pragma experimental ABIEncoderV2; 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 {FixedPointMath} from "./FixedPointMathOld.sol"; import {Pool} from "./Pool.sol"; interface IDetailedERC20 is IERC20 { function name() external returns (string memory); function symbol() external returns (string memory); function decimals() external returns (uint8); } /// @title Stake /// /// @dev A library which provides the Stake data struct and associated functions. library Stake { using FixedPointMath for FixedPointMath.FixedDecimal; using Pool for Pool.Data; using Math for uint256; using Stake for Stake.Data; struct Data { uint256 totalDeposited; uint256 totalUnclaimed; FixedPointMath.FixedDecimal lastAccumulatedWeight; } function update(Data storage _self, Pool.Data storage _pool, Pool.Context storage _ctx) internal { _self.totalUnclaimed = _self.getUpdatedTotalUnclaimed(_pool, _ctx); _self.lastAccumulatedWeight = _pool.getUpdatedAccumulatedRewardWeight(_ctx); } function getUpdatedTotalUnclaimed(Data storage _self, Pool.Data storage _pool, Pool.Context storage _ctx) internal view returns (uint256) { FixedPointMath.FixedDecimal memory _currentAccumulatedWeight = _pool.getUpdatedAccumulatedRewardWeight(_ctx); FixedPointMath.FixedDecimal memory _lastAccumulatedWeight = _self.lastAccumulatedWeight; if (_currentAccumulatedWeight.cmp(_lastAccumulatedWeight) == 0) { return _self.totalUnclaimed; } uint256 _distributedAmount = _currentAccumulatedWeight .sub(_lastAccumulatedWeight) .mul(_self.totalDeposited) .decode(); return _self.totalUnclaimed + (_distributedAmount); } } ================================================ FILE: src/mocks/StakingPoolMock.sol ================================================ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.28; pragma experimental ABIEncoderV2; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {FixedPointMath} from "./FixedPointMathOld.sol"; import {IERC20Mintable} from "../interfaces/IERC20Mintable.sol"; import {Pool} from "./Pool.sol"; import {Stake} from "./Stake.sol"; /// @title StakingPools // ___ __ __ _ ___ __ _ // / _ | / / ____ / / ___ __ _ (_) __ __ / _ \ ____ ___ ___ ___ ___ / /_ ___ (_) // / __ | / / / __/ / _ \/ -_) / ' \ / / \ \ / / ___/ / __// -_) (_- uint256) public tokenPoolIds; /// @dev The context shared between the pools. Pool.Context private _ctx; /// @dev A list of all of the pools. Pool.List private _pools; /// @dev A mapping of all of the user stakes mapped first by pool and then by address. mapping(address => mapping(uint256 => Stake.Data)) private _stakes; constructor(IERC20Mintable _reward, address _governance) { require(_governance != address(0), "StakingPools: governance address cannot be 0x0"); reward = _reward; governance = _governance; } /// @dev A modifier which reverts when the caller is not the governance. modifier onlyGovernance() { require(msg.sender == governance, "StakingPools: only governance"); _; } /// @dev Sets the governance. /// /// This function can only called by the current governance. /// /// @param _pendingGovernance the new pending governance. function setPendingGovernance(address _pendingGovernance) external onlyGovernance { require(_pendingGovernance != address(0), "StakingPools: pending governance address cannot be 0x0"); pendingGovernance = _pendingGovernance; emit PendingGovernanceUpdated(_pendingGovernance); } function acceptGovernance() external { require(msg.sender == pendingGovernance, "StakingPools: only pending governance"); address _pendingGovernance = pendingGovernance; governance = _pendingGovernance; emit GovernanceUpdated(_pendingGovernance); } /// @dev Sets the distribution reward rate. /// /// This will update all of the pools. /// /// @param _rewardRate The number of tokens to distribute per second. function setRewardRate(uint256 _rewardRate) external onlyGovernance { _updatePools(); _ctx.rewardRate = _rewardRate; emit RewardRateUpdated(_rewardRate); } /// @dev Creates a new pool. /// /// The created pool will need to have its reward weight initialized before it begins generating rewards. /// /// @param _token The token the pool will accept for staking. /// /// @return the identifier for the newly created pool. function createPool(IERC20 _token) external onlyGovernance returns (uint256) { require(tokenPoolIds[_token] == 0, "StakingPools: token already has a pool"); uint256 _poolId = _pools.length(); _pools.push( Pool.Data({ token: _token, totalDeposited: 0, rewardWeight: 0, accumulatedRewardWeight: FixedPointMath.FixedDecimal(0), lastUpdatedBlock: block.number }) ); tokenPoolIds[_token] = _poolId + 1; emit PoolCreated(_poolId, _token); return _poolId; } /// @dev Sets the reward weights of all of the pools. /// /// @param _rewardWeights The reward weights of all of the pools. function setRewardWeights(uint256[] calldata _rewardWeights) external onlyGovernance { require(_rewardWeights.length == _pools.length(), "StakingPools: weights length mismatch"); _updatePools(); uint256 _totalRewardWeight = _ctx.totalRewardWeight; for (uint256 _poolId = 0; _poolId < _pools.length(); _poolId++) { Pool.Data storage _pool = _pools.get(_poolId); uint256 _currentRewardWeight = _pool.rewardWeight; if (_currentRewardWeight == _rewardWeights[_poolId]) { continue; } _totalRewardWeight = _totalRewardWeight - (_currentRewardWeight) + (_rewardWeights[_poolId]); _pool.rewardWeight = _rewardWeights[_poolId]; emit PoolRewardWeightUpdated(_poolId, _rewardWeights[_poolId]); } _ctx.totalRewardWeight = _totalRewardWeight; } /// @dev Stakes tokens into a pool. /// /// @param _poolId the pool to deposit tokens into. /// @param _depositAmount the amount of tokens to deposit. function deposit(uint256 _poolId, uint256 _depositAmount) external nonReentrant { Pool.Data storage _pool = _pools.get(_poolId); _pool.update(_ctx); Stake.Data storage _stake = _stakes[msg.sender][_poolId]; _stake.update(_pool, _ctx); _deposit(_poolId, _depositAmount); } /// @dev Withdraws staked tokens from a pool. /// /// @param _poolId The pool to withdraw staked tokens from. /// @param _withdrawAmount The number of tokens to withdraw. function withdraw(uint256 _poolId, uint256 _withdrawAmount) external nonReentrant { Pool.Data storage _pool = _pools.get(_poolId); _pool.update(_ctx); Stake.Data storage _stake = _stakes[msg.sender][_poolId]; _stake.update(_pool, _ctx); _claim(_poolId); _withdraw(_poolId, _withdrawAmount); } /// @dev Claims all rewarded tokens from a pool. /// /// @param _poolId The pool to claim rewards from. /// /// @notice use this function to claim the tokens from a corresponding pool by ID. function claim(uint256 _poolId) external nonReentrant { Pool.Data storage _pool = _pools.get(_poolId); _pool.update(_ctx); Stake.Data storage _stake = _stakes[msg.sender][_poolId]; _stake.update(_pool, _ctx); _claim(_poolId); } /// @dev Claims all rewards from a pool and then withdraws all staked tokens. /// /// @param _poolId the pool to exit from. function exit(uint256 _poolId) external nonReentrant { Pool.Data storage _pool = _pools.get(_poolId); _pool.update(_ctx); Stake.Data storage _stake = _stakes[msg.sender][_poolId]; _stake.update(_pool, _ctx); _claim(_poolId); _withdraw(_poolId, _stake.totalDeposited); } /// @dev Gets the rate at which tokens are minted to stakers for all pools. /// /// @return the reward rate. function rewardRate() external view returns (uint256) { return _ctx.rewardRate; } /// @dev Gets the total reward weight between all the pools. /// /// @return the total reward weight. function totalRewardWeight() external view returns (uint256) { return _ctx.totalRewardWeight; } /// @dev Gets the number of pools that exist. /// /// @return the pool count. function poolCount() external view returns (uint256) { return _pools.length(); } /// @dev Gets the token a pool accepts. /// /// @param _poolId the identifier of the pool. /// /// @return the token. function getPoolToken(uint256 _poolId) external view returns (IERC20) { Pool.Data storage _pool = _pools.get(_poolId); return _pool.token; } /// @dev Gets the total amount of funds staked in a pool. /// /// @param _poolId the identifier of the pool. /// /// @return the total amount of staked or deposited tokens. function getPoolTotalDeposited(uint256 _poolId) external view returns (uint256) { Pool.Data storage _pool = _pools.get(_poolId); return _pool.totalDeposited; } /// @dev Gets the reward weight of a pool which determines how much of the total rewards it receives per block. /// /// @param _poolId the identifier of the pool. /// /// @return the pool reward weight. function getPoolRewardWeight(uint256 _poolId) external view returns (uint256) { Pool.Data storage _pool = _pools.get(_poolId); return _pool.rewardWeight; } /// @dev Gets the amount of tokens per block being distributed to stakers for a pool. /// /// @param _poolId the identifier of the pool. /// /// @return the pool reward rate. function getPoolRewardRate(uint256 _poolId) external view returns (uint256) { Pool.Data storage _pool = _pools.get(_poolId); return _pool.getRewardRate(_ctx); } /// @dev Gets the number of tokens a user has staked into a pool. /// /// @param _account The account to query. /// @param _poolId the identifier of the pool. /// /// @return the amount of deposited tokens. function getStakeTotalDeposited(address _account, uint256 _poolId) external view returns (uint256) { Stake.Data storage _stake = _stakes[_account][_poolId]; return _stake.totalDeposited; } /// @dev Gets the number of unclaimed reward tokens a user can claim from a pool. /// /// @param _account The account to get the unclaimed balance of. /// @param _poolId The pool to check for unclaimed rewards. /// /// @return the amount of unclaimed reward tokens a user has in a pool. function getStakeTotalUnclaimed(address _account, uint256 _poolId) external view returns (uint256) { Stake.Data storage _stake = _stakes[_account][_poolId]; return _stake.getUpdatedTotalUnclaimed(_pools.get(_poolId), _ctx); } /// @dev Updates all of the pools. function _updatePools() internal { for (uint256 _poolId = 0; _poolId < _pools.length(); _poolId++) { Pool.Data storage _pool = _pools.get(_poolId); _pool.update(_ctx); } } /// @dev Stakes tokens into a pool. /// /// The pool and stake MUST be updated before calling this function. /// /// @param _poolId the pool to deposit tokens into. /// @param _depositAmount the amount of tokens to deposit. function _deposit(uint256 _poolId, uint256 _depositAmount) internal { Pool.Data storage _pool = _pools.get(_poolId); Stake.Data storage _stake = _stakes[msg.sender][_poolId]; _pool.totalDeposited = _pool.totalDeposited + (_depositAmount); _stake.totalDeposited = _stake.totalDeposited + (_depositAmount); _pool.token.safeTransferFrom(msg.sender, address(this), _depositAmount); emit TokensDeposited(msg.sender, _poolId, _depositAmount); } /// @dev Withdraws staked tokens from a pool. /// /// The pool and stake MUST be updated before calling this function. /// /// @param _poolId The pool to withdraw staked tokens from. /// @param _withdrawAmount The number of tokens to withdraw. function _withdraw(uint256 _poolId, uint256 _withdrawAmount) internal { Pool.Data storage _pool = _pools.get(_poolId); Stake.Data storage _stake = _stakes[msg.sender][_poolId]; _pool.totalDeposited = _pool.totalDeposited - (_withdrawAmount); _stake.totalDeposited = _stake.totalDeposited - (_withdrawAmount); _pool.token.safeTransfer(msg.sender, _withdrawAmount); emit TokensWithdrawn(msg.sender, _poolId, _withdrawAmount); } /// @dev Claims all rewarded tokens from a pool. /// /// The pool and stake MUST be updated before calling this function. /// /// @param _poolId The pool to claim rewards from. /// /// @notice use this function to claim the tokens from a corresponding pool by ID. function _claim(uint256 _poolId) internal { Stake.Data storage _stake = _stakes[msg.sender][_poolId]; uint256 _claimAmount = _stake.totalUnclaimed; _stake.totalUnclaimed = 0; reward.mint(msg.sender, _claimAmount); emit TokensClaimed(msg.sender, _poolId, _claimAmount); } } ================================================ FILE: src/router/AlchemistRouter.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; import {IAlchemistV3} from "../interfaces/IAlchemistV3.sol"; import {IAlchemistV3Position} from "../interfaces/IAlchemistV3Position.sol"; import {IWETH} from "../interfaces/IWETH.sol"; import {IVaultV2} from "../../lib/vault-v2/src/interfaces/IVaultV2.sol"; import {ITransmuter} from "../interfaces/ITransmuter.sol"; /// @title AlchemistRouter /// @notice Batches multi-step Alchemist operations (deposit, borrow, repay, withdraw, self-liquidate, claim) into single transactions for EOA users. /// @dev Holds no user funds between transactions. Alchemist is set at construction via immutable. contract AlchemistRouter is ReentrancyGuardTransient { using SafeERC20 for IERC20; address public immutable alchemist; /// @dev Flag to allow receiving ETH from WETH unwrap during withdraw flows. bool private transient _ethExpected; /// @param _alchemist The Alchemist contract this router interacts with. constructor(address _alchemist) { require(_alchemist != address(0), "Zero address"); alchemist = _alchemist; } /// @notice Deposit underlying token into MYT vault + Alchemist, optionally borrow. /// @dev Caller must have approved this contract for `amount` of underlying. /// Pass `tokenId = 0` to create a new position, or an existing token ID to deposit into it. /// For existing positions: caller must own the NFT (it stays with the caller). /// If `borrowAmount` > 0 on an existing position, caller must have called /// `approveMint(tokenId, router, borrowAmount)` on the Alchemist. /// @param tokenId Position NFT token ID (0 to create a new position). /// @param amount Amount of underlying token to deposit. /// @param borrowAmount Amount of debt tokens to borrow (0 to skip borrowing). /// @param minSharesOut Minimum MYT shares to receive (slippage protection). /// @param deadline Timestamp after which the transaction reverts. /// @return The position NFT token ID (newly minted or same as input). function depositUnderlying( uint256 tokenId, uint256 amount, uint256 borrowAmount, uint256 minSharesOut, uint256 deadline ) external nonReentrant returns (uint256) { require(block.timestamp <= deadline, "Expired"); require(amount > 0, "Zero amount"); address mytVault = IAlchemistV3(alchemist).myt(); address underlying = IAlchemistV3(alchemist).underlyingToken(); IERC20(underlying).safeTransferFrom(msg.sender, address(this), amount); IERC20(underlying).forceApprove(mytVault, amount); uint256 shares = IVaultV2(mytVault).deposit(amount, address(this)); require(shares >= minSharesOut, "Slippage"); IERC20(underlying).forceApprove(mytVault, 0); return _depositAndBorrow(mytVault, shares, tokenId, borrowAmount); } /// @notice Deposit native ETH → WETH → MYT vault → Alchemist, optionally borrow. /// @dev Requires the Alchemist's underlyingToken to be WETH. /// Pass `tokenId = 0` to create a new position, or an existing token ID to deposit into it. /// For existing positions: caller must own the NFT (it stays with the caller). /// If `borrowAmount` > 0 on an existing position, caller must have called /// `approveMint(tokenId, router, borrowAmount)` on the Alchemist. /// @param tokenId Position NFT token ID (0 to create a new position). /// @param borrowAmount Amount of debt tokens to borrow (0 to skip borrowing). /// @param minSharesOut Minimum MYT shares to receive (slippage protection). /// @param deadline Timestamp after which the transaction reverts. /// @return The position NFT token ID (newly minted or same as input). function depositETH( uint256 tokenId, uint256 borrowAmount, uint256 minSharesOut, uint256 deadline ) external payable nonReentrant returns (uint256) { require(msg.value > 0, "No ETH sent"); require(block.timestamp <= deadline, "Expired"); address mytVault = IAlchemistV3(alchemist).myt(); address underlying = IAlchemistV3(alchemist).underlyingToken(); IWETH(underlying).deposit{value: msg.value}(); IERC20(underlying).forceApprove(mytVault, msg.value); uint256 shares = IVaultV2(mytVault).deposit(msg.value, address(this)); require(shares >= minSharesOut, "Slippage"); IERC20(underlying).forceApprove(mytVault, 0); return _depositAndBorrow(mytVault, shares, tokenId, borrowAmount); } /// @notice Deposit MYT shares directly into Alchemist, optionally borrow. /// @dev Caller must have approved this contract for `shares` of MYT. /// Pass `tokenId = 0` to create a new position, or an existing token ID to deposit into it. /// For existing positions: caller must own the NFT (it stays with the caller). /// If `borrowAmount` > 0 on an existing position, caller must have called /// `approveMint(tokenId, router, borrowAmount)` on the Alchemist. /// @param tokenId Position NFT token ID (0 to create a new position). /// @param shares Amount of MYT shares to deposit. /// @param borrowAmount Amount of debt tokens to borrow (0 to skip borrowing). /// @param deadline Timestamp after which the transaction reverts. /// @return The position NFT token ID (newly minted or same as input). function depositMYT( uint256 tokenId, uint256 shares, uint256 borrowAmount, uint256 deadline ) external nonReentrant returns (uint256) { require(block.timestamp <= deadline, "Expired"); require(shares > 0, "Zero shares"); address mytVault = IAlchemistV3(alchemist).myt(); IERC20(mytVault).safeTransferFrom(msg.sender, address(this), shares); return _depositAndBorrow(mytVault, shares, tokenId, borrowAmount); } /// @notice Deposit ETH into MYT vault only (no Alchemist position). /// MYT shares are sent directly to the caller. /// @dev Requires the Alchemist's underlyingToken to be WETH. /// @param minSharesOut Minimum MYT shares to receive (slippage protection). /// @param deadline Timestamp after which the transaction reverts. /// @return shares MYT shares received. function depositETHToVaultOnly( uint256 minSharesOut, uint256 deadline ) external payable nonReentrant returns (uint256 shares) { require(msg.value > 0, "No ETH sent"); require(block.timestamp <= deadline, "Expired"); address mytVault = IAlchemistV3(alchemist).myt(); address underlying = IAlchemistV3(alchemist).underlyingToken(); IWETH(underlying).deposit{value: msg.value}(); IERC20(underlying).forceApprove(mytVault, msg.value); shares = IVaultV2(mytVault).deposit(msg.value, msg.sender); // Clear residual approval before slippage check so it runs on all paths IERC20(underlying).forceApprove(mytVault, 0); require(shares >= minSharesOut, "Slippage"); } // ─── Repay ─────────────────────────────────────────────────────────── /// @notice Repay debt on a position using underlying tokens. /// @dev Caller must have approved this contract for `amount` of underlying. /// Any MYT shares not consumed by the repayment are returned to the caller. /// @param recipientTokenId The position NFT token ID to repay debt on. /// @param amount Amount of underlying token to use for repayment. /// @param minSharesOut Minimum MYT shares from vault deposit (slippage protection). /// @param deadline Timestamp after which the transaction reverts. function repayUnderlying( uint256 recipientTokenId, uint256 amount, uint256 minSharesOut, uint256 deadline ) external nonReentrant { require(block.timestamp <= deadline, "Expired"); require(amount > 0, "Zero amount"); address mytVault = IAlchemistV3(alchemist).myt(); address underlying = IAlchemistV3(alchemist).underlyingToken(); IERC20(underlying).safeTransferFrom(msg.sender, address(this), amount); IERC20(underlying).forceApprove(mytVault, amount); uint256 shares = IVaultV2(mytVault).deposit(amount, address(this)); require(shares >= minSharesOut, "Slippage"); IERC20(underlying).forceApprove(mytVault, 0); _repayAndRefund(mytVault, shares, recipientTokenId); } /// @notice Repay debt on a position using native ETH. /// @dev Requires the Alchemist's underlyingToken to be WETH. /// Any MYT shares not consumed by the repayment are returned to the caller /// as MYT vault shares (not ETH). Callers must redeem shares separately if /// they want the underlying back. /// @param recipientTokenId The position NFT token ID to repay debt on. /// @param minSharesOut Minimum MYT shares from vault deposit (slippage protection). /// @param deadline Timestamp after which the transaction reverts. function repayETH( uint256 recipientTokenId, uint256 minSharesOut, uint256 deadline ) external payable nonReentrant { require(msg.value > 0, "No ETH sent"); require(block.timestamp <= deadline, "Expired"); address mytVault = IAlchemistV3(alchemist).myt(); address underlying = IAlchemistV3(alchemist).underlyingToken(); IWETH(underlying).deposit{value: msg.value}(); IERC20(underlying).forceApprove(mytVault, msg.value); uint256 shares = IVaultV2(mytVault).deposit(msg.value, address(this)); require(shares >= minSharesOut, "Slippage"); IERC20(underlying).forceApprove(mytVault, 0); _repayAndRefund(mytVault, shares, recipientTokenId); } // ─── Withdraw ──────────────────────────────────────────────────────── /// @notice Withdraw MYT shares from Alchemist, redeem to underlying, send to caller. /// @dev Caller must approve this contract for the position NFT (ERC721 approve). /// NFT is temporarily held by the router and returned after withdraw. /// WARNING: The NFT round-trip resets ALL mint allowances (approveMint) on this position. /// @param tokenId The position NFT token ID to withdraw from. /// @param shares Amount of MYT shares to withdraw from the Alchemist. /// @param minAmountOut Minimum underlying tokens to receive (slippage protection on vault redeem). /// @param deadline Timestamp after which the transaction reverts. function withdrawUnderlying( uint256 tokenId, uint256 shares, uint256 minAmountOut, uint256 deadline ) external nonReentrant { require(block.timestamp <= deadline, "Expired"); require(shares > 0, "Zero shares"); require(tokenId != 0, "Invalid tokenId"); _withdraw(tokenId, shares, minAmountOut, false); } /// @notice Withdraw MYT shares from Alchemist, redeem to WETH, unwrap, send ETH to caller. /// @dev Caller must approve this contract for the position NFT (ERC721 approve). /// NFT is temporarily held by the router and returned after withdraw. /// WARNING: The NFT round-trip resets ALL mint allowances (approveMint) on this position. /// @param tokenId The position NFT token ID to withdraw from. /// @param shares Amount of MYT shares to withdraw from the Alchemist. /// @param minAmountOut Minimum ETH to receive (slippage protection on vault redeem). /// @param deadline Timestamp after which the transaction reverts. function withdrawETH( uint256 tokenId, uint256 shares, uint256 minAmountOut, uint256 deadline ) external nonReentrant { require(block.timestamp <= deadline, "Expired"); require(shares > 0, "Zero shares"); require(tokenId != 0, "Invalid tokenId"); _withdraw(tokenId, shares, minAmountOut, true); } /// @notice Self-liquidate a position: burn debt, redeem remaining collateral to underlying, send to caller. /// @dev Caller must approve this contract for the position NFT (ERC721 approve). /// NFT is temporarily held by the router and returned after self-liquidation. /// WARNING: The NFT round-trip resets ALL mint allowances (approveMint) on this position. /// @param tokenId The position NFT token ID to self-liquidate. /// @param minAmountOut Minimum underlying tokens to receive (slippage protection on vault redeem). /// @param deadline Timestamp after which the transaction reverts. function selfLiquidateToUnderlying( uint256 tokenId, uint256 minAmountOut, uint256 deadline ) external nonReentrant { require(block.timestamp <= deadline, "Expired"); require(tokenId != 0, "Invalid tokenId"); _selfLiquidate(tokenId, minAmountOut, false); } /// @notice Self-liquidate a position: burn debt, redeem remaining collateral to ETH, send to caller. /// @dev Caller must approve this contract for the position NFT (ERC721 approve). /// NFT is temporarily held by the router and returned after self-liquidation. /// WARNING: The NFT round-trip resets ALL mint allowances (approveMint) on this position. /// @param tokenId The position NFT token ID to self-liquidate. /// @param minAmountOut Minimum ETH to receive (slippage protection on vault redeem). /// @param deadline Timestamp after which the transaction reverts. function selfLiquidateToETH( uint256 tokenId, uint256 minAmountOut, uint256 deadline ) external nonReentrant { require(block.timestamp <= deadline, "Expired"); require(tokenId != 0, "Invalid tokenId"); _selfLiquidate(tokenId, minAmountOut, true); } // ─── Transmuter Claim ──────────────────────────────────────────────── /// @notice Claim a matured transmuter position, redeem MYT shares, and send proceeds to caller. /// @dev Caller must approve this contract for the transmuter position NFT (ERC721 approve). /// The transmuter burns the NFT on claim. Any untransmuted synthetic tokens /// are forwarded to the caller as-is. /// When `unwrapETH` is true, redeemed WETH is unwrapped and sent as native ETH. /// @param positionId The transmuter position NFT token ID to claim. /// @param minAmountOut Minimum underlying tokens (or ETH if unwrapETH) to receive (slippage protection). /// @param deadline Timestamp after which the transaction reverts. /// @param unwrapETH If true, redeem to WETH and unwrap to native ETH before sending. function claimRedemption( uint256 positionId, uint256 minAmountOut, uint256 deadline, bool unwrapETH ) external nonReentrant { require(block.timestamp <= deadline, "Expired"); _claimRedemption(positionId, minAmountOut, unwrapETH); } // ─── Internal ──────────────────────────────────────────────────────── /// @dev Unified deposit + optional borrow logic. /// Assumes MYT shares are already in this contract. /// When tokenId == 0: creates a new position (NFT minted to router, then transferred to caller). /// When tokenId != 0: deposits into existing position (NFT stays with caller, uses mintFrom for borrowing). function _depositAndBorrow( address mytVault, uint256 shares, uint256 tokenId, uint256 borrowAmount ) internal returns (uint256) { IERC20(mytVault).forceApprove(alchemist, shares); IAlchemistV3Position nft = IAlchemistV3Position(IAlchemistV3(alchemist).alchemistPositionNFT()); if (tokenId == 0) { (tokenId, ) = IAlchemistV3(alchemist).deposit(shares, address(this), 0); IERC20(mytVault).forceApprove(alchemist, 0); if (borrowAmount > 0) { IAlchemistV3(alchemist).mint(tokenId, borrowAmount, msg.sender); } nft.transferFrom(address(this), msg.sender, tokenId); } else { // Existing position: deposit to caller (NFT owner), borrow via mintFrom require(nft.ownerOf(tokenId) == msg.sender, "Not position owner"); IAlchemistV3(alchemist).deposit(shares, msg.sender, tokenId); IERC20(mytVault).forceApprove(alchemist, 0); if (borrowAmount > 0) { IAlchemistV3(alchemist).mintFrom(tokenId, borrowAmount, msg.sender); } } return tokenId; } /// @dev Withdraw MYT shares from Alchemist, redeem via vault, and deliver proceeds. /// When unwrapETH is true, redeems WETH to this contract, unwraps, and sends ETH to caller. /// Otherwise redeems underlying directly to caller. /// NFT is temporarily held by the router and returned after withdraw. function _withdraw(uint256 tokenId, uint256 shares, uint256 minAmountOut, bool unwrapETH) internal { IAlchemistV3Position nft = IAlchemistV3Position(IAlchemistV3(alchemist).alchemistPositionNFT()); address mytVault = IAlchemistV3(alchemist).myt(); // Only the position owner may withdraw (not merely an approved operator) require(nft.ownerOf(tokenId) == msg.sender, "Not position owner"); // Take custody of position NFT (caller must have approved router) nft.transferFrom(msg.sender, address(this), tokenId); // Withdraw MYT shares from Alchemist to this contract IAlchemistV3(alchemist).withdraw(shares, address(this), tokenId); // Return NFT to caller nft.transferFrom(address(this), msg.sender, tokenId); _redeemAndDeliver(mytVault, shares, minAmountOut, unwrapETH); } /// @dev Approve MYT spending by Alchemist, repay, clear approval, refund unused MYT shares to caller. /// Uses balance delta (not absolute balanceOf) to be donation-resistant. function _repayAndRefund( address mytVault, uint256 shares, uint256 recipientTokenId ) internal { IERC20(mytVault).forceApprove(alchemist, shares); uint256 balBefore = IERC20(mytVault).balanceOf(address(this)); IAlchemistV3(alchemist).repay(shares, recipientTokenId); uint256 consumed = balBefore - IERC20(mytVault).balanceOf(address(this)); IERC20(mytVault).forceApprove(alchemist, 0); // Return any unused MYT shares (repay caps to outstanding debt) uint256 remaining = shares - consumed; if (remaining > 0) { IERC20(mytVault).safeTransfer(msg.sender, remaining); } } /// @dev Shared claim logic: takes transmuter NFT, claims, forwards synthetic refund, redeems MYT. /// When unwrapETH is true, redeems MYT → WETH → unwrap → send native ETH to caller. /// When unwrapETH is false, redeems MYT → underlying sent directly to caller. function _claimRedemption( uint256 positionId, uint256 minAmountOut, bool unwrapETH ) internal { address transmuter = IAlchemistV3(alchemist).transmuter(); address mytVault = IAlchemistV3(alchemist).myt(); address syntheticToken = IAlchemistV3(alchemist).debtToken(); // Take custody of transmuter NFT (caller must have approved router) IERC721(transmuter).transferFrom(msg.sender, address(this), positionId); // Claim — transmuter burns the NFT, sends MYT shares + synthetic refund to this contract (uint256 claimYield, , uint256 syntheticReturned, ) = ITransmuter(transmuter).claimRedemption(positionId); require(claimYield > 0, "No MYT to redeem"); // Forward any returned synthetic tokens before the untrusted ETH .call if (syntheticReturned > 0) { IERC20(syntheticToken).safeTransfer(msg.sender, syntheticReturned); } _redeemAndDeliver(mytVault, claimYield, minAmountOut, unwrapETH); } /// @dev Self-liquidate a position: take NFT, call selfLiquidate, return NFT, redeem MYT proceeds. /// selfLiquidate burns debt against collateral and sends remaining MYT shares to this contract. /// When unwrapETH is true, redeems MYT → WETH → unwrap → send native ETH to caller. /// Otherwise redeems MYT → underlying sent directly to caller. function _selfLiquidate(uint256 tokenId, uint256 minAmountOut, bool unwrapETH) internal { IAlchemistV3Position nft = IAlchemistV3Position(IAlchemistV3(alchemist).alchemistPositionNFT()); address mytVault = IAlchemistV3(alchemist).myt(); // Only the position owner may self-liquidate (not merely an approved operator) require(nft.ownerOf(tokenId) == msg.sender, "Not position owner"); // Take custody of position NFT (caller must have approved router) nft.transferFrom(msg.sender, address(this), tokenId); // Self-liquidate — burns debt, sends remaining MYT shares to this contract. // Return value is the total collateral consumed for debt repayment, not the remainder we receive. uint256 mytBefore = IERC20(mytVault).balanceOf(address(this)); IAlchemistV3(alchemist).selfLiquidate(tokenId, address(this)); uint256 mytShares = IERC20(mytVault).balanceOf(address(this)) - mytBefore; // If all collateral was consumed by debt, there's nothing to redeem via the router. // Users who want to self-liquidate without return collateral can call the Alchemist directly. require(mytShares > 0, "No collateral remaining"); // Return NFT to caller (position is zeroed but NFT still exists) nft.transferFrom(address(this), msg.sender, tokenId); _redeemAndDeliver(mytVault, mytShares, minAmountOut, unwrapETH); } /// @dev Redeem MYT shares via vault and deliver proceeds to caller. /// When unwrapETH is true, redeems to this contract, unwraps WETH, sends native ETH. /// Otherwise redeems underlying directly to caller. function _redeemAndDeliver(address mytVault, uint256 mytShares, uint256 minAmountOut, bool unwrapETH) internal { if (unwrapETH) { uint256 assets = IVaultV2(mytVault).redeem(mytShares, address(this), address(this)); require(assets >= minAmountOut, "Slippage"); address underlying = IAlchemistV3(alchemist).underlyingToken(); _ethExpected = true; IWETH(underlying).withdraw(assets); _ethExpected = false; (bool success, ) = msg.sender.call{value: assets}(""); require(success, "ETH transfer failed"); } else { uint256 assets = IVaultV2(mytVault).redeem(mytShares, msg.sender, address(this)); require(assets >= minAmountOut, "Slippage"); } } /// @dev Accept ETH only from WETH unwrap (withdraw, self-liquidate, and claim flows). receive() external payable { require(_ethExpected, "Use depositETH"); } } ================================================ FILE: src/strategies/AaveStrategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {MYTStrategy} from "../MYTStrategy.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; interface IAavePool { function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; function withdraw(address asset, uint256 amount, address to) external returns (uint256); } interface IPoolAddressProvider { function getPool() external view returns (address); } interface IAaveAToken { function balanceOf(address) external view returns (uint256); } interface IRewardsController { function claimAllRewardsToSelf(address[] calldata assets) external returns (address[] memory rewardsList, uint256[] memory claimedAmounts); } /** * @title AaveStrategy * @notice Generic deployable strategy for Aave v3 integrations. */ contract AaveStrategy is MYTStrategy { IERC20 public immutable mytAsset; IPoolAddressProvider public immutable poolProvider; IAaveAToken public immutable aToken; IRewardsController public immutable rewardsController; IERC20 public immutable rewardToken; constructor( address _myt, StrategyParams memory _params, address _mytAsset, address _aToken, address _poolProvider, address _rewardsController, address _rewardToken ) MYTStrategy(_myt, _params) { mytAsset = IERC20(_mytAsset); aToken = IAaveAToken(_aToken); poolProvider = IPoolAddressProvider(_poolProvider); rewardsController = IRewardsController(_rewardsController); rewardToken = IERC20(_rewardToken); } function _allocate(uint256 amount) internal virtual override returns (uint256) { _ensureIdleBalance(address(mytAsset), amount); IAavePool pool = IAavePool(poolProvider.getPool()); TokenUtils.safeApprove(address(mytAsset), address(pool), amount); pool.supply(address(mytAsset), amount, address(this), 0); return amount; } function _deallocate(uint256 amount) internal virtual override returns (uint256) { IAavePool pool = IAavePool(poolProvider.getPool()); uint256 idleBalance = _idleAssets(); uint256 withdrawnAmount = amount; if (idleBalance < amount) { uint256 shortfall = amount - idleBalance; uint256 balanceBefore = TokenUtils.safeBalanceOf(address(mytAsset), address(this)); withdrawnAmount = pool.withdraw(address(mytAsset), shortfall, address(this)); uint256 balanceAfter = TokenUtils.safeBalanceOf(address(mytAsset), address(this)); if (withdrawnAmount < shortfall) revert InvalidAmount(shortfall, withdrawnAmount); if (balanceAfter < balanceBefore + shortfall) revert InsufficientBalance(balanceBefore + shortfall, balanceAfter); } TokenUtils.safeApprove(address(mytAsset), msg.sender, amount); return withdrawnAmount; } function _previewAdjustedWithdraw(uint256 amount) internal view virtual override returns (uint256) { // Aave doesn't charge withdrawal fees, so we just apply slippage. return amount - (amount * params.slippageBPS / 10_000); } function _totalValue() internal view virtual override returns (uint256) { // aToken balance reflects principal + interest in underlying units. return aToken.balanceOf(address(this)) + _idleAssets(); } function _idleAssets() internal view virtual override returns (uint256) { return TokenUtils.safeBalanceOf(address(mytAsset), address(this)); } function _claimRewards(address token, bytes memory quote, uint256 minAmountOut) internal virtual override returns (uint256) { address[] memory assets = new address[](1); assets[0] = token; uint256 rewardBefore = rewardToken.balanceOf(address(this)); rewardsController.claimAllRewardsToSelf(assets); uint256 rewardReceived = rewardToken.balanceOf(address(this)) - rewardBefore; if (rewardReceived == 0) return 0; emit RewardsClaimed(address(rewardToken), rewardReceived); uint256 assetsReceived = dexSwap(address(MYT.asset()), address(rewardToken), rewardReceived, minAmountOut, quote); TokenUtils.safeTransfer(address(MYT.asset()), address(MYT), assetsReceived); return assetsReceived; } function _isProtectedToken(address token) internal view virtual override returns (bool) { return token == MYT.asset() || token == address(aToken); } /// @notice Admin only function to perform a DEX swap via the 0x AllowanceHolder. /// @param to The target token address (token to buy). /// @param from The source token address (token to sell). /// @param amount The amount of `from` tokens to swap. /// @param minAmountOut The minimum amount of `to` tokens expected. /// @param callData The calldata for the 0x interaction. /// @return amountReceived The amount of `to` tokens received. function adminDexSwap( address to, address from, uint256 amount, uint256 minAmountOut, bytes calldata callData ) external onlyOwner returns (uint256) { return dexSwap(to, from, amount, minAmountOut, callData); } } ================================================ FILE: src/strategies/ERC4626Strategy.sol ================================================ // 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 {MYTStrategy} from "../MYTStrategy.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; /** * @title ERC4626Strategy * @notice Generic deployable strategy for vanilla ERC4626 vault integrations. */ contract ERC4626Strategy is MYTStrategy { IERC20 public immutable mytAsset; IERC4626 public immutable vault; constructor(address _myt, StrategyParams memory _params, address _vault) MYTStrategy(_myt, _params) { mytAsset = IERC20(MYT.asset()); vault = IERC4626(_vault); require(vault.asset() == MYT.asset(), "Vault asset != MYT asset"); } function _allocate(uint256 amount) internal virtual override returns (uint256) { _ensureIdleBalance(address(mytAsset), amount); TokenUtils.safeApprove(address(mytAsset), address(vault), amount); vault.deposit(amount, address(this)); return amount; } function _deallocate(uint256 amount) internal virtual override returns (uint256) { uint256 idleBalance = _idleAssets(); if (idleBalance < amount) { uint256 shortfall = amount - idleBalance; vault.withdraw(shortfall, address(this), address(this)); } _ensureIdleBalance(address(mytAsset), amount); TokenUtils.safeApprove(address(mytAsset), msg.sender, amount); return amount; } function _totalValue() internal view virtual override returns (uint256) { uint256 shares = vault.balanceOf(address(this)); if (shares == 0) return _idleAssets(); return vault.previewRedeem(shares) + _idleAssets(); } function _idleAssets() internal view virtual override returns (uint256) { return TokenUtils.safeBalanceOf(address(mytAsset), address(this)); } function _previewAdjustedWithdraw(uint256 amount) internal view virtual override returns (uint256) { uint256 sharesNoFee = vault.convertToShares(amount); uint256 sharesWithFee = vault.previewWithdraw(amount); uint256 feeShares = sharesWithFee > sharesNoFee ? sharesWithFee - sharesNoFee : 0; uint256 feeAssets = vault.previewRedeem(feeShares); uint256 netAssets = amount - feeAssets; return netAssets * (10_000 - params.slippageBPS) / 10_000; } function _isProtectedToken(address token) internal view virtual override returns (bool) { return token == MYT.asset() || token == address(vault); } } ================================================ FILE: src/strategies/EtherfiEETHStrategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {OraclePricedSwapStrategy} from "./OraclePricedSwapStrategy.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IWETH} from "../interfaces/IWETH.sol"; interface IDepositAdapter { function depositWETHForWeETH(uint256 amount, address referral) external returns (uint256); } interface IRedemptionManager { struct RedemptionLimit { uint64 capacity; uint64 remaining; uint64 lastRefill; uint64 refillRate; } function canRedeem(uint256 amount, address token) external view returns (bool); function liquidityPool() external view returns (address); function previewRedeem(uint256 shares, address token) external view returns (uint256); function tokenToRedemptionInfo(address token) external view returns (RedemptionLimit memory limit, uint16 exitFeeSplitToTreasuryInBps, uint16 exitFeeInBps, uint16 lowWatermarkInBpsOfTvl); function redeemWeEth(uint256 amount, address receiver, address outputToken) external returns (uint256); } interface ILiquidityPoolLike { function amountForShare(uint256 shares) external view returns (uint256); function sharesForAmount(uint256 amount) external view returns (uint256); function sharesForWithdrawalAmount(uint256 amount) external view returns (uint256); } interface IWeETH { function balanceOf(address account) external view returns (uint256); function getEETHByWeETH(uint256 weETHAmount) external view returns (uint256); function getWeETHByeETH(uint256 eETHAmount) external view returns (uint256); } /** * @title EtherfiEETHMYTStrategy * @notice Allocates WETH into weETH via Ether.fi DepositAdapter and supports * deallocation via Ether.fi instant redemption. * instant redemption through the RedemptionManager. * Also supports dex swaps for both allocation and deallocation. * */ contract EtherfiEETHMYTStrategy is OraclePricedSwapStrategy { uint256 internal constant BPS = 10_000; IDepositAdapter public immutable depositAdapter; IRedemptionManager public immutable redemptionManager; IWeETH public immutable weETH; IERC20 public immutable eETH; // address used to request native ETH instead of an ERC20 token. address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; constructor( address _myt, StrategyParams memory _params, address _eETH, address _weETH, address _depositAdapter, address _redemptionManager, address _weEthEthOracle, uint256 _maxOracleStaleness ) OraclePricedSwapStrategy(_myt, _params, _weEthEthOracle, _maxOracleStaleness) { require(_eETH != address(0), "Zero eETH address"); require(_weETH != address(0), "Zero weETH address"); require(_depositAdapter != address(0), "Zero deposit adapter address"); require(_redemptionManager != address(0), "Zero redemption manager address"); eETH = IERC20(_eETH); weETH = IWeETH(_weETH); depositAdapter = IDepositAdapter(_depositAdapter); redemptionManager = IRedemptionManager(_redemptionManager); } function _allocate(uint256 amount) internal override returns (uint256) { _ensureIdleBalance(_asset(), amount); TokenUtils.safeApprove(_asset(), address(depositAdapter), amount); depositAdapter.depositWETHForWeETH(amount, address(0)); TokenUtils.safeApprove(_asset(), address(depositAdapter), 0); return amount; } /// @notice Deallocate via Ether.fi instant redemption (no queue delay). /// @dev This path is liquidity-dependent and reverts when `canRedeem(amount, ETH)` is false. /// @param amount WETH amount expected to be returned to vault. function _deallocate(uint256 amount) internal override returns (uint256) { uint256 idleBalance = _idleAssets(); if (idleBalance >= amount) { TokenUtils.safeApprove(_asset(), msg.sender, amount); return amount; } uint256 shortfall = amount - idleBalance; (, uint16 exitFeeInBps,) = _redemptionInfo(); require(exitFeeInBps < BPS, "Invalid exit fee"); uint256 weETHBalance = weETH.balanceOf(address(this)); require(weETHBalance > 0, "No weETH available"); uint256 weETHToRedeem = _weETHForNetShortfall(shortfall, exitFeeInBps, weETHBalance); uint256 grossRedeemAmount = weETH.getEETHByWeETH(weETHToRedeem); require( redemptionManager.canRedeem(grossRedeemAmount, ETH), "Cannot redeem. Instant redemption path is not available." ); require(weETHToRedeem > 0, "No weETH to redeem"); TokenUtils.safeApprove(address(weETH), address(redemptionManager), weETHToRedeem); uint256 ethBefore = address(this).balance; redemptionManager.redeemWeEth(weETHToRedeem, address(this), ETH); uint256 ethReceived = address(this).balance - ethBefore; TokenUtils.safeApprove(address(weETH), address(redemptionManager), 0); require(ethReceived >= shortfall, "Insufficient ETH redeemed"); IWETH(MYT.asset()).deposit{value: ethReceived}(); require(_idleAssets() >= amount, "Insufficient WETH available"); TokenUtils.safeApprove(_asset(), msg.sender, amount); return amount; } function _redemptionInfo() internal view returns (uint16 exitFeeSplitToTreasuryInBps, uint16 exitFeeInBps, uint16 lowWatermarkInBpsOfTvl) { (, exitFeeSplitToTreasuryInBps, exitFeeInBps, lowWatermarkInBpsOfTvl) = redemptionManager.tokenToRedemptionInfo(ETH); } function _weETHForGrossRedeem(uint256 grossRedeemAmount, uint256 weETHBalance) internal view returns (uint256) { uint256 weETHToRedeem = weETH.getWeETHByeETH(grossRedeemAmount); if (weETHToRedeem < weETHBalance && weETH.getEETHByWeETH(weETHToRedeem) < grossRedeemAmount) { weETHToRedeem += 1; } return weETHToRedeem; } function _weETHForNetShortfall(uint256 shortfall, uint256 exitFeeInBps, uint256 weETHBalance) internal view returns (uint256) { ILiquidityPoolLike liquidityPool = ILiquidityPoolLike(redemptionManager.liquidityPool()); uint256 requiredNetShares = liquidityPool.sharesForWithdrawalAmount(shortfall); uint256 requiredGrossShares = Math.mulDiv(requiredNetShares, BPS, BPS - exitFeeInBps, Math.Rounding.Ceil); uint256 grossRedeemAmount = liquidityPool.amountForShare(requiredGrossShares); uint256 weETHToRedeem = _weETHForGrossRedeem(grossRedeemAmount, weETHBalance); require(_previewNetEthFromWeETH(weETHToRedeem) >= shortfall, "Insufficient ETH redeemed"); return weETHToRedeem; } function _previewNetEthFromWeETH(uint256 weETHAmount) internal view returns (uint256) { uint256 eETHAmount = weETH.getEETHByWeETH(weETHAmount); uint256 shares = ILiquidityPoolLike(redemptionManager.liquidityPool()).sharesForAmount(eETHAmount); return redemptionManager.previewRedeem(shares, ETH); } function _oracleToken() internal view override returns (address) { return address(weETH); } function _positionBalance() internal view override returns (uint256) { return weETH.balanceOf(address(this)); } function _prepareOracleTokenForSwap(uint256 maxOracleTokenIn) internal override returns (uint256) { uint256 weETHBalance = weETH.balanceOf(address(this)); return maxOracleTokenIn > weETHBalance ? weETHBalance : maxOracleTokenIn; } receive() external payable {} } ================================================ FILE: src/strategies/MoonwellStrategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {MYTStrategy} from "../MYTStrategy.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; interface IMToken { function mint(uint256 mintAmount) external returns (uint256); function redeem(uint256 redeemTokens) external returns (uint256); function balanceOf(address owner) external view returns (uint256); function exchangeRateStored() external view returns (uint256); function accrueInterest() external returns (uint); } interface IComptroller { function claimReward() external; } interface IWETH { function deposit() external payable; } /** * @title MoonwellStrategy * @notice Generic deployable strategy for Moonwell mToken integrations. */ contract MoonwellStrategy is MYTStrategy { using Math for uint256; IERC20 public immutable mytAsset; IMToken public immutable mToken; IERC20 public immutable rewardToken; IComptroller public immutable comptroller; bool public immutable usePostRedeemETHWrap; error MoonwellStrategyMintFailed(uint256 errorCode); error MoonwellStrategyRedeemFailed(uint256 errorCode); constructor( address _myt, StrategyParams memory _params, address _mytAsset, address _mToken, address _comptroller, address _rewardToken, bool _usePostRedeemETHWrap ) MYTStrategy(_myt, _params) { mytAsset = IERC20(_mytAsset); mToken = IMToken(_mToken); comptroller = IComptroller(_comptroller); rewardToken = IERC20(_rewardToken); usePostRedeemETHWrap = _usePostRedeemETHWrap; } function _allocate(uint256 amount) internal virtual override returns (uint256) { _ensureIdleBalance(address(mytAsset), amount); TokenUtils.safeApprove(address(mytAsset), address(mToken), amount); uint256 mTokenBalanceBefore = mToken.balanceOf(address(this)); uint256 errorCode = mToken.mint(amount); if (errorCode != 0) revert MoonwellStrategyMintFailed(errorCode); uint256 mTokensMinted = mToken.balanceOf(address(this)) - mTokenBalanceBefore; return (mTokensMinted * mToken.exchangeRateStored()) / 1e18; } function _deallocate(uint256 amount) internal virtual override returns (uint256) { require(mToken.accrueInterest() == uint(0), "interest"); // 0 is Error.NO_ERROR uint256 idleBalance = _idleAssets(); if (idleBalance < amount) { uint256 shortfall = amount - idleBalance; uint256 mTokensNeeded = (shortfall * 1e18).ceilDiv(mToken.exchangeRateStored()); uint256 errorCode = mToken.redeem(mTokensNeeded); if (errorCode != 0) revert MoonwellStrategyRedeemFailed(errorCode); } _afterRedeem(); _ensureIdleBalance(address(mytAsset), amount); TokenUtils.safeApprove(address(mytAsset), msg.sender, amount); return amount; } function _afterRedeem() internal virtual { // Moonwell WETH can return native ETH. Wrap it to MYT asset (WETH) when enabled. if (usePostRedeemETHWrap && address(this).balance > 0) { IWETH(address(mytAsset)).deposit{value: address(this).balance}(); } } function _rate() internal view returns (uint256) { return mToken.exchangeRateStored(); } function _totalValue() internal view virtual override returns (uint256) { uint256 idleUnderlying = _idleAssets(); uint256 mTokenBalance = mToken.balanceOf(address(this)); if (mTokenBalance == 0) return idleUnderlying; return idleUnderlying + (mTokenBalance * _rate()) / 1e18; } function _idleAssets() internal view virtual override returns (uint256) { return TokenUtils.safeBalanceOf(address(mytAsset), address(this)); } function _previewAdjustedWithdraw(uint256 amount) internal view virtual override returns (uint256) { uint256 rate = _rate(); uint256 sharesNoFee = (amount * 1e18) / rate; uint256 sharesWithFee = (amount * 1e18).ceilDiv(rate); uint256 feeShares = sharesWithFee > sharesNoFee ? sharesWithFee - sharesNoFee : 0; uint256 feeAssets = (feeShares * rate) / 1e18; uint256 netAssets = amount > feeAssets ? amount - feeAssets : 0; uint256 slippage = Math.ceilDiv(netAssets * params.slippageBPS, 10_000); return netAssets > slippage ? netAssets - slippage : 0; } function _claimRewards(address token, bytes memory quote, uint256 minAmountOut) internal virtual override returns (uint256) { require(token == address(rewardToken), "Invalid Token"); uint256 rewardBefore = rewardToken.balanceOf(address(this)); comptroller.claimReward(); uint256 rewardReceived = rewardToken.balanceOf(address(this)) - rewardBefore; if (rewardReceived == 0) return 0; emit RewardsClaimed(address(rewardToken), rewardReceived); uint256 assetsReceived = dexSwap(address(MYT.asset()), address(rewardToken), rewardReceived, minAmountOut, quote); TokenUtils.safeTransfer(address(MYT.asset()), address(MYT), assetsReceived); return assetsReceived; } function _isProtectedToken(address token) internal view virtual override returns (bool) { return token == MYT.asset() || token == address(mToken); } receive() external payable {} } ================================================ FILE: src/strategies/OraclePricedSwapStrategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {MYTStrategy} from "../MYTStrategy.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {AggregatorV3Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; abstract contract OraclePricedSwapStrategy is MYTStrategy { uint256 public MAX_ORACLE_STALENESS; AggregatorV3Interface public pricedTokenOracle; uint8 public pricedTokenOracleDecimals; event PricedTokenOracleUpdated(address indexed pricedTokenOracle, uint8 decimals); event MaxOracleStalenessUpdated(uint256 maxOracleStaleness); constructor( address _myt, StrategyParams memory _params, address _pricedTokenOracle, uint256 _maxOracleStaleness ) MYTStrategy(_myt, _params) { _setPricedTokenOracle(_pricedTokenOracle); _setMaxOracleStaleness(_maxOracleStaleness); } function _allocate(uint256 amount, bytes memory callData) internal virtual override returns (uint256) { _ensureIdleBalance(_asset(), amount); uint256 minOracleTokenOut = _assetToOracleTokenDown((amount * (10_000 - params.slippageBPS)) / 10_000); if (minOracleTokenOut == 0) minOracleTokenOut = 1; uint256 oracleTokenReceived = dexSwap(_oracleToken(), _asset(), amount, minOracleTokenOut, callData); _allocationSwapGuard(amount, minOracleTokenOut, oracleTokenReceived); _afterAllocationSwap(oracleTokenReceived); return amount; } function _deallocate(uint256 amount, bytes memory callData) internal virtual override returns (uint256) { return _deallocateViaOracleTokenSwap(amount, callData); } function _deallocate(uint256 amount, bytes memory callData, uint256 minIntermediateOutAmount) internal virtual override returns (uint256) { return _deallocateViaUnwrapAndSwap(amount, callData, minIntermediateOutAmount); } function _deallocateViaOracleTokenSwap(uint256 amount, bytes memory callData) internal returns (uint256) { uint256 idleBalance = _idleAssets(); if (idleBalance >= amount) { TokenUtils.safeApprove(_asset(), msg.sender, amount); return amount; } uint256 shortfall = amount - idleBalance; uint256 maxAssetIn = _roundUpMulDiv(shortfall, 10_000, 10_000 - params.slippageBPS); uint256 maxOracleTokenIn = _assetToOracleTokenUp(maxAssetIn); if (maxOracleTokenIn == 0) maxOracleTokenIn = 1; uint256 oracleTokenToSwap = _prepareOracleTokenForSwap(maxOracleTokenIn); require(oracleTokenToSwap > 0, "No oracle token to swap"); dexSwap(_asset(), _oracleToken(), oracleTokenToSwap, shortfall, callData); uint256 receivedAssets = _idleAssets(); if (receivedAssets < amount) revert InsufficientBalance(amount, receivedAssets); TokenUtils.safeApprove(_asset(), msg.sender, amount); return amount; } function _deallocateViaUnwrapAndSwap(uint256 amount, bytes memory callData, uint256 minIntermediateOutAmount) internal returns (uint256) { uint256 idleBalance = _idleAssets(); if (idleBalance >= amount) { TokenUtils.safeApprove(_asset(), msg.sender, amount); return amount; } uint256 shortfall = amount - idleBalance; uint256 maxAssetIn = _roundUpMulDiv(shortfall, 10_000, 10_000 - params.slippageBPS); uint256 maxOracleTokenIn = _assetToOracleTokenUp(maxAssetIn); if (maxOracleTokenIn == 0) maxOracleTokenIn = 1; (address sellToken, uint256 sellAmount) = _prepareIntermediateForSwap(maxOracleTokenIn, minIntermediateOutAmount); require(sellToken != address(0), "No intermediate token"); require(sellAmount > 0, "No intermediate amount"); dexSwap(_asset(), sellToken, sellAmount, shortfall, callData); uint256 receivedAssets = _idleAssets(); if (receivedAssets < amount) revert InsufficientBalance(amount, receivedAssets); TokenUtils.safeApprove(_asset(), msg.sender, amount); return amount; } function _totalValue() internal view virtual override returns (uint256) { return _idleAssets() + _oracleTokenToAsset(_positionBalance()); } function _idleAssets() internal view virtual override returns (uint256) { return TokenUtils.safeBalanceOf(_asset(), address(this)); } function _previewAdjustedWithdraw(uint256 amount) internal view virtual override returns (uint256) { uint256 idleBalance = _idleAssets(); uint256 fromIdle = amount <= idleBalance ? amount : idleBalance; if (fromIdle == amount) { return amount; } uint256 remaining = amount - fromIdle; uint256 maxAsset = _oracleTokenToAsset(_positionBalance()); uint256 fundableFromPosition = remaining <= maxAsset ? remaining : maxAsset; return fromIdle + (fundableFromPosition * (10_000 - params.slippageBPS)) / 10_000; } function _oracleTokenToAsset(uint256 oracleTokenAmount) internal view returns (uint256) { return oracleTokenAmount * _oracleAnswer() / (10 ** pricedTokenOracleDecimals); } function _assetToOracleTokenDown(uint256 assetAmount) internal view returns (uint256) { return assetAmount * (10 ** pricedTokenOracleDecimals) / _oracleAnswer(); } function _assetToOracleTokenUp(uint256 assetAmount) internal view returns (uint256) { uint256 scale = 10 ** pricedTokenOracleDecimals; uint256 answer = _oracleAnswer(); return (assetAmount * scale + answer - 1) / answer; } function _oracleAnswer() internal view returns (uint256 answer) { (, int256 raw,, uint256 updatedAt,) = pricedTokenOracle.latestRoundData(); require(raw > 0 && updatedAt != 0, "Invalid oracle answer"); require(updatedAt <= block.timestamp && block.timestamp - updatedAt <= MAX_ORACLE_STALENESS, "Stale oracle answer"); answer = uint256(raw); } function _roundUpMulDiv(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256) { return (x * y + denominator - 1) / denominator; } function setPricedTokenOracle(address _pricedTokenOracle) external onlyOwner { _setPricedTokenOracle(_pricedTokenOracle); } function setMaxOracleStaleness(uint256 maxOracleStaleness) external onlyOwner { _setMaxOracleStaleness(maxOracleStaleness); } function _setPricedTokenOracle(address _pricedTokenOracle) internal { require(_pricedTokenOracle != address(0), "Zero oracle address"); AggregatorV3Interface newOracle = AggregatorV3Interface(_pricedTokenOracle); uint8 newDecimals = newOracle.decimals(); pricedTokenOracle = newOracle; pricedTokenOracleDecimals = newDecimals; emit PricedTokenOracleUpdated(_pricedTokenOracle, newDecimals); } function _setMaxOracleStaleness(uint256 maxOracleStaleness) internal { require(maxOracleStaleness > 0, "Zero oracle staleness"); MAX_ORACLE_STALENESS = maxOracleStaleness; emit MaxOracleStalenessUpdated(maxOracleStaleness); } /// @notice Returns the vault asset managed by the parent MYT. function _asset() internal view returns (address) { return MYT.asset(); } /// @notice Validates the result of an allocation swap before any post-swap processing occurs. /// @dev Override in child strategies to apply strategy-specific post-swap output guards. /// @param assetAmountIn The vault asset amount spent in the swap. /// @param minOracleTokenOut The oracle-priced minimum output passed to the swap executor. /// @param oracleTokenReceived The amount of oracle token received from the swap. function _allocationSwapGuard(uint256 assetAmountIn, uint256 minOracleTokenOut, uint256 oracleTokenReceived) internal view virtual {} /// @notice Optional hook for child strategies to transform or stake the received oracle token after allocation. /// @param oracleTokenReceived The oracle token amount returned by the allocation swap. function _afterAllocationSwap(uint256 oracleTokenReceived) internal virtual {} /// @notice Returns the token used in allocation/deallocation swap flows. /// @dev This token may be the same as the oracle-priced unit, or a wrapped form that must be converted /// into oracle-compatible units by `_positionBalance()` and `_prepareOracleTokenForSwap()`. function _oracleToken() internal view virtual returns (address); /// @notice Returns the strategy's deployed position balance in units consumable by the oracle pricing math. /// @dev This may be the raw oracle token balance, or an oracle-token-equivalent amount derived from wrapped shares. function _positionBalance() internal view virtual returns (uint256); /// @notice Prepares the oracle token amount that will be sold in a one-hop swap deallocation. /// @param maxOracleTokenIn The maximum oracle token amount permitted by oracle and slippage math. function _prepareOracleTokenForSwap(uint256 maxOracleTokenIn) internal virtual returns (uint256); /// @notice Prepares an intermediate token for unwrap-and-swap deallocation flows. /// @dev Child strategies should unwrap or redeem into the sell token and return the token plus amount to swap. /// @param maxOracleTokenIn The maximum oracle-token-equivalent amount permitted by oracle and slippage math. /// @param minIntermediateOutAmount The minimum intermediate token amount the caller expects to produce before swapping. /// @return sellToken The intermediate token that should be sold into the vault asset. /// @return sellAmount The amount of the intermediate token available to sell. function _prepareIntermediateForSwap(uint256 maxOracleTokenIn, uint256 minIntermediateOutAmount) internal virtual returns (address sellToken, uint256 sellAmount) { revert ActionNotSupported(); } } ================================================ FILE: src/strategies/SFraxETHStrategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IWETH} from "../interfaces/IWETH.sol"; import {OraclePricedSwapStrategy} from "./OraclePricedSwapStrategy.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; interface IFraxMinter { function submitAndDeposit(address recipient) external payable returns (uint256 shares); } interface ISfrxETH { function balanceOf(address account) external view returns (uint256); function convertToAssets(uint256 shares) external view returns (uint256 assets); function deposit(uint256 assets, address receiver) external returns (uint256 shares); function previewWithdraw(uint256 assets) external view returns (uint256 shares); function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares); } contract SFraxETHStrategy is OraclePricedSwapStrategy { event MinFrxEthOutBpsUpdated(uint256 indexed newMinFrxEthOutBps); IFraxMinter public immutable minter; IERC20 public immutable frxETH; ISfrxETH public immutable sfrxETH; uint256 public minFrxEthOutBps; constructor( address _myt, StrategyParams memory _params, address _minter, address _frxETH, address _sfrxETH, address _pricedTokenOracle, uint256 _minFrxEthOutBps, uint256 _maxOracleStaleness ) OraclePricedSwapStrategy(_myt, _params, _pricedTokenOracle, _maxOracleStaleness) { require(_minter != address(0), "Zero minter address"); require(_frxETH != address(0), "Zero frxETH address"); require(_sfrxETH != address(0), "Zero sfrxETH address"); require(_minFrxEthOutBps <= 10_000, "Invalid min frxETH out bps"); minter = IFraxMinter(_minter); frxETH = IERC20(_frxETH); sfrxETH = ISfrxETH(_sfrxETH); minFrxEthOutBps = _minFrxEthOutBps; } function _allocate(uint256 amount) internal override returns (uint256) { _ensureIdleBalance(_asset(), amount); IWETH(_asset()).withdraw(amount); uint256 sharesReceived = minter.submitAndDeposit{value: amount}(address(this)); require(sharesReceived > 0, "No sfrxETH received"); return amount; } function _afterAllocationSwap(uint256 oracleTokenReceived) internal override { TokenUtils.safeApprove(address(frxETH), address(sfrxETH), oracleTokenReceived); uint256 sharesReceived = sfrxETH.deposit(oracleTokenReceived, address(this)); TokenUtils.safeApprove(address(frxETH), address(sfrxETH), 0); require(sharesReceived > 0, "No sfrxETH received"); } /// @notice Updates the minimum raw frxETH output floor enforced after swap-based allocations. /// @dev This is an oracle-independent downside guard against pathological quotes or severe frxETH depegs. function setMinFrxEthOutBps(uint256 newMinFrxEthOutBps) external onlyOwner { require(newMinFrxEthOutBps <= 10_000, "Invalid min frxETH out bps"); minFrxEthOutBps = newMinFrxEthOutBps; emit MinFrxEthOutBpsUpdated(newMinFrxEthOutBps); } function _deallocate(uint256, bytes memory) internal pure override returns (uint256) { revert ActionNotSupported(); } function _isProtectedToken(address token) internal view override returns (bool) { return token == MYT.asset() || token == address(sfrxETH) || token == address(frxETH); } function _oracleToken() internal view override returns (address) { return address(frxETH); } function _positionBalance() internal view override returns (uint256) { return frxETH.balanceOf(address(this)) + sfrxETH.convertToAssets(sfrxETH.balanceOf(address(this))); } function _allocationSwapGuard(uint256 assetAmountIn, uint256, uint256 oracleTokenReceived) internal view override { if (minFrxEthOutBps == 0) return; uint256 minFrxEthOut = (assetAmountIn * minFrxEthOutBps) / 10_000; if (oracleTokenReceived < minFrxEthOut) revert InvalidAmount(minFrxEthOut, oracleTokenReceived); } function _prepareOracleTokenForSwap(uint256) internal pure override returns (uint256) { revert ActionNotSupported(); } function _prepareIntermediateForSwap(uint256 maxOracleTokenIn, uint256 minIntermediateOutAmount) internal override returns (address sellToken, uint256 sellAmount) { require(minIntermediateOutAmount > 0, "Invalid intermediate amount"); uint256 sharesNeeded = sfrxETH.previewWithdraw(minIntermediateOutAmount); uint256 sharesBalance = sfrxETH.balanceOf(address(this)); require(sharesNeeded > 0, "No sfrxETH to unwrap"); require(sharesNeeded <= sharesBalance, "Insufficient sfrxETH balance"); require(minIntermediateOutAmount <= maxOracleTokenIn, "Intermediate exceeds max oracle token in"); uint256 frxETHBefore = frxETH.balanceOf(address(this)); sfrxETH.withdraw(minIntermediateOutAmount, address(this), address(this)); sellAmount = frxETH.balanceOf(address(this)) - frxETHBefore; require(sellAmount >= minIntermediateOutAmount, "Insufficient intermediate out"); sellToken = address(frxETH); } receive() external payable {} } ================================================ FILE: src/strategies/SiUSDStrategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {OraclePricedSwapStrategy} from "./OraclePricedSwapStrategy.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; interface IMintController { function assetToReceipt(uint256 assetAmount) external view returns (uint256); } interface ISIUSD { function balanceOf(address account) external view returns (uint256); function convertToAssets(uint256 shares) external view returns (uint256 assets); function previewWithdraw(uint256 assets) external view returns (uint256 shares); } interface IRedeemController { function receiptToAsset(uint256 receiptAmount) external view returns (uint256); } interface IInfiniFiGateway { function mintAndStake(address to, uint256 amount) external returns (uint256); function unstake(address to, uint256 stakedTokens) external returns (uint256); function redeem(address to, uint256 amount, uint256 minAssetsOut) external returns (uint256); } /** * @title SiUSDStrategy * @notice Allocates USDC into staked InfiniFi siUSD shares and supports: * 1. direct deallocation via siUSD -> iUSD -> USDC redeem * 2. unwrap-and-swap fallback via siUSD -> iUSD -> USDC swap */ contract SiUSDStrategy is OraclePricedSwapStrategy { uint256 internal constant DIRECT_PREVIEW_BUFFER = 100; IERC20 public immutable usdc; IERC20 public immutable iUSD; ISIUSD public immutable siUSD; IMintController public immutable mintController; IRedeemController public immutable redeemController; IInfiniFiGateway public immutable gateway; constructor( address _myt, StrategyParams memory _params, address _usdc, address _iUSD, address _siUSD, address _gateway, address _mintController, address _redeemController, address _pricedTokenOracle, uint256 _maxOracleStaleness ) OraclePricedSwapStrategy(_myt, _params, _pricedTokenOracle, _maxOracleStaleness) { require(_usdc != address(0), "Zero USDC address"); require(_iUSD != address(0), "Zero iUSD address"); require(_siUSD != address(0), "Zero siUSD address"); require(_gateway != address(0), "Zero gateway address"); require(_mintController != address(0), "Zero mint controller address"); require(_redeemController != address(0), "Zero redeem controller address"); require(_usdc == MYT.asset(), "Vault asset != MYT asset"); usdc = IERC20(_usdc); iUSD = IERC20(_iUSD); siUSD = ISIUSD(_siUSD); mintController = IMintController(_mintController); redeemController = IRedeemController(_redeemController); gateway = IInfiniFiGateway(_gateway); } function _allocate(uint256 amount) internal override returns (uint256) { _ensureIdleBalance(_asset(), amount); TokenUtils.safeApprove(_asset(), address(gateway), 0); TokenUtils.safeApprove(_asset(), address(gateway), amount); uint256 sharesReceived = gateway.mintAndStake(address(this), amount); TokenUtils.safeApprove(_asset(), address(gateway), 0); require(sharesReceived > 0, "No siUSD received"); return amount; } function _allocate(uint256, bytes memory) internal pure override returns (uint256) { revert ActionNotSupported(); } function _deallocate(uint256 amount) internal override returns (uint256) { uint256 idleBalance = _idleAssets(); if (idleBalance < amount) { uint256 shortfall = amount - idleBalance; uint256 iUsdNeeded = mintController.assetToReceipt(shortfall); uint256 sharesToUnstake = siUSD.previewWithdraw(iUsdNeeded); uint256 siUsdBalance = siUSD.balanceOf(address(this)); if (sharesToUnstake > siUsdBalance) sharesToUnstake = siUsdBalance; require(sharesToUnstake > 0, "No siUSD to unstake"); TokenUtils.safeApprove(address(siUSD), address(gateway), 0); TokenUtils.safeApprove(address(siUSD), address(gateway), sharesToUnstake); gateway.unstake(address(this), sharesToUnstake); TokenUtils.safeApprove(address(siUSD), address(gateway), 0); uint256 iUsdBalance = TokenUtils.safeBalanceOf(address(iUSD), address(this)); uint256 iUsdToRedeem = iUsdNeeded > iUsdBalance ? iUsdBalance : iUsdNeeded; require(iUsdToRedeem > 0, "No iUSD to redeem"); TokenUtils.safeApprove(address(iUSD), address(gateway), 0); TokenUtils.safeApprove(address(iUSD), address(gateway), iUsdToRedeem); gateway.redeem(address(this), iUsdToRedeem, shortfall); TokenUtils.safeApprove(address(iUSD), address(gateway), 0); idleBalance = _idleAssets(); if (idleBalance < amount) revert InsufficientBalance(amount, idleBalance); } TokenUtils.safeApprove(address(usdc), msg.sender, amount); return amount; } function _deallocate(uint256, bytes memory) internal pure override returns (uint256) { revert ActionNotSupported(); } function _oracleToken() internal view override returns (address) { return address(iUSD); } function _positionBalance() internal view override returns (uint256) { return TokenUtils.safeBalanceOf(address(iUSD), address(this)) + siUSD.convertToAssets(siUSD.balanceOf(address(this))); } function _totalValue() internal view override returns (uint256) { uint256 idleUsdc = _idleAssets(); uint256 totalReceiptBalance = _positionBalance(); if (totalReceiptBalance == 0) return idleUsdc; return idleUsdc + redeemController.receiptToAsset(totalReceiptBalance); } function _previewAdjustedWithdraw(uint256 amount) internal view override returns (uint256) { uint256 idleBalance = _idleAssets(); if (idleBalance >= amount) return amount; uint256 availableReceipts = _positionBalance(); if (availableReceipts == 0) return idleBalance; uint256 shortfall = amount - idleBalance; uint256 receiptsNeeded = mintController.assetToReceipt(shortfall); uint256 receiptsToRedeem = receiptsNeeded < availableReceipts ? receiptsNeeded : availableReceipts; if (receiptsToRedeem == 0) return idleBalance; uint256 redeemableAssets = _applyDirectPreviewDiscount(redeemController.receiptToAsset(receiptsToRedeem)); uint256 totalAvailable = idleBalance + redeemableAssets; uint256 maxPreview = amount > DIRECT_PREVIEW_BUFFER + 1 ? amount - DIRECT_PREVIEW_BUFFER - 1 : idleBalance; return totalAvailable < maxPreview ? totalAvailable : maxPreview; } function _applyDirectPreviewDiscount(uint256 assetAmount) internal view returns (uint256) { uint256 discounted = assetAmount - (assetAmount * params.slippageBPS / 10_000); if (discounted <= DIRECT_PREVIEW_BUFFER + 1) return 0; return discounted - DIRECT_PREVIEW_BUFFER - 1; } function _prepareOracleTokenForSwap(uint256) internal pure override returns (uint256) { revert ActionNotSupported(); } function _prepareIntermediateForSwap(uint256 maxOracleTokenIn, uint256 minIntermediateOutAmount) internal override returns (address sellToken, uint256 sellAmount) { if (minIntermediateOutAmount == 0) { sellToken = address(iUSD); return (sellToken, 0); } require(minIntermediateOutAmount <= maxOracleTokenIn, "Intermediate exceeds max oracle token in"); uint256 iUsdBalance = TokenUtils.safeBalanceOf(address(iUSD), address(this)); sellAmount = iUsdBalance; if (sellAmount < minIntermediateOutAmount) { uint256 missingIUsd = minIntermediateOutAmount - sellAmount; uint256 sharesNeeded = siUSD.previewWithdraw(missingIUsd); uint256 sharesBalance = siUSD.balanceOf(address(this)); require(sharesNeeded > 0, "No siUSD to unstake"); require(sharesNeeded <= sharesBalance, "Insufficient siUSD balance"); TokenUtils.safeApprove(address(siUSD), address(gateway), 0); TokenUtils.safeApprove(address(siUSD), address(gateway), sharesNeeded); gateway.unstake(address(this), sharesNeeded); TokenUtils.safeApprove(address(siUSD), address(gateway), 0); sellAmount = TokenUtils.safeBalanceOf(address(iUSD), address(this)); } require(sellAmount >= minIntermediateOutAmount, "Insufficient intermediate out"); sellToken = address(iUSD); } function _isProtectedToken(address token) internal view override returns (bool) { return token == MYT.asset() || token == address(iUSD) || token == address(siUSD); } } ================================================ FILE: src/strategies/TokeAutoStrategy.sol ================================================ // 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 {MYTStrategy} from "../MYTStrategy.sol"; import {IMainRewarder} from "./interfaces/ITokemac.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; interface IERC4626Like is IERC4626 { function convertToShares(uint256 assets, uint256 totalAssetsForPurpose, uint256 supply, Rounding rounding) external view returns (uint256 shares); function convertToAssets(uint256 shares, uint256 totalAssetsForPurpose, uint256 supply, Rounding rounding) external view returns (uint256 assets); function totalAssets(TotalAssetPurpose purpose) external view returns (uint256); enum Rounding { Down, // Toward negative infinity Up, // Toward infinity Zero // Toward zero } enum TotalAssetPurpose { Global, Deposit, Withdraw } } /** * @title TokeAutoStrategy * @notice Generic Tokemak auto-vault strategy with rewarder staking. */ contract TokeAutoStrategy is MYTStrategy { using Math for uint256; uint256 internal constant BASIS_POINTS = 10_000; /// @dev Minimum shares to ensure possibleAssets > 0 in TokeAutoETH.redeem uint256 internal constant MIN_SHARES = 1e15; IERC20 public immutable mytAsset; IERC4626Like public immutable autoVault; IMainRewarder public immutable rewarder; address public immutable tokeRewardsToken; constructor( address _myt, StrategyParams memory _params, address _asset, address _autoVault, address _rewarder, address _tokeRewardsToken ) MYTStrategy(_myt, _params) { require(_asset == MYT.asset(), "Vault asset != MYT asset"); require(_tokeRewardsToken != address(0), "Invalid rewards token"); mytAsset = IERC20(_asset); autoVault = IERC4626Like(_autoVault); rewarder = IMainRewarder(_rewarder); tokeRewardsToken = _tokeRewardsToken; } function _allocate(uint256 amount) internal virtual override returns (uint256) { _ensureIdleBalance(address(mytAsset), amount); TokenUtils.safeApprove(address(mytAsset), address(autoVault), amount); uint256 shares = autoVault.deposit(amount, address(this)); uint256 assetsReceived = autoVault.convertToAssets( shares, autoVault.totalAssets(IERC4626Like.TotalAssetPurpose.Withdraw), autoVault.totalSupply(), IERC4626Like.Rounding.Down ); require(assetsReceived >= amount * (BASIS_POINTS - params.slippageBPS) / BASIS_POINTS, "Deposit value below minimum"); TokenUtils.safeApprove(address(autoVault), address(rewarder), shares); rewarder.stake(address(this), shares); return assetsReceived; } function _deallocate(uint256 amount) internal virtual override returns (uint256) { uint256 assetBalance = _idleAssets(); if (assetBalance < amount) { uint256 shortfall = amount - assetBalance; uint256 totalPulled; // Iteratively redeem until we've pulled enough assets. // TokeAutoETH.redeem may return fewer assets than convertToAssets suggests due to slippage/recoup. for (totalPulled = 0; totalPulled < shortfall;) { uint256 totalAssetsForWithdraw = autoVault.totalAssets(IERC4626Like.TotalAssetPurpose.Withdraw); uint256 totalSupply = autoVault.totalSupply(); uint256 sharesNeeded = autoVault.convertToShares( shortfall - totalPulled, totalAssetsForWithdraw, totalSupply, IERC4626Like.Rounding.Up ); // Ensure minimum shares and cap to available uint256 directShares = autoVault.balanceOf(address(this)); uint256 totalSharesAvailable = directShares + rewarder.balanceOf(address(this)); sharesNeeded = Math.max(sharesNeeded, MIN_SHARES); if (sharesNeeded > totalSharesAvailable) sharesNeeded = totalSharesAvailable; // Break if no shares or would result in zero possibleAssets if (sharesNeeded == 0) break; if (autoVault.convertToAssets(sharesNeeded, totalAssetsForWithdraw, totalSupply, IERC4626Like.Rounding.Down) == 0) break; // Unstake if needed if (sharesNeeded > directShares) { rewarder.withdraw(address(this), sharesNeeded - directShares, false); } uint256 balanceBefore = TokenUtils.safeBalanceOf(address(mytAsset), address(this)); autoVault.redeem(sharesNeeded, address(this), address(this)); uint256 pulled = TokenUtils.safeBalanceOf(address(mytAsset), address(this)) - balanceBefore; if (pulled == 0) break; totalPulled += pulled; } } require(TokenUtils.safeBalanceOf(address(mytAsset), address(this)) >= amount, "Withdraw amount insufficient"); TokenUtils.safeApprove(address(mytAsset), msg.sender, amount); return amount; } function _totalValue() internal view virtual override returns (uint256) { uint256 shares = rewarder.balanceOf(address(this)) + autoVault.balanceOf(address(this)); if (shares == 0) return _idleAssets(); uint256 assets = autoVault.convertToAssets( shares, autoVault.totalAssets(IERC4626Like.TotalAssetPurpose.Withdraw), autoVault.totalSupply(), IERC4626Like.Rounding.Down ); return _idleAssets() + assets; } function _idleAssets() internal view virtual override returns (uint256) { return TokenUtils.safeBalanceOf(address(mytAsset), address(this)); } function _previewAdjustedWithdraw(uint256 amount) internal view virtual override returns (uint256) { uint256 sharesNeeded = autoVault.convertToShares( amount, autoVault.totalAssets(IERC4626Like.TotalAssetPurpose.Withdraw), autoVault.totalSupply(), IERC4626Like.Rounding.Up ); uint256 totalShares = rewarder.balanceOf(address(this)) + autoVault.balanceOf(address(this)); if (sharesNeeded > totalShares) sharesNeeded = totalShares; uint256 assets = autoVault.convertToAssets( sharesNeeded, autoVault.totalAssets(IERC4626Like.TotalAssetPurpose.Withdraw), autoVault.totalSupply(), IERC4626Like.Rounding.Down ); return assets - (assets * params.slippageBPS / BASIS_POINTS); } function _claimRewards(address token, bytes memory quote, uint256 minAmountOut) internal virtual override returns (uint256 rewardsClaimed) { require(token == tokeRewardsToken && quote.length > 0, "params"); uint256 rewardsBalanceBefore = TokenUtils.safeBalanceOf(token, address(this)); bool claimExtra = rewarder.allowExtraRewards(); rewarder.getReward(address(this), address(this), claimExtra); uint256 rewardsReceived = TokenUtils.safeBalanceOf(token, address(this)) - rewardsBalanceBefore; if (rewardsReceived == 0) return 0; bool stakingDisabled = rewarder.rewardToken() != tokeRewardsToken || rewarder.tokeLockDuration() == 0; if (!stakingDisabled) return 0; emit RewardsClaimed(address(token), rewardsReceived); uint256 amountOut = dexSwap(MYT.asset(), token, IERC20(token).balanceOf(address(this)), minAmountOut, quote); TokenUtils.safeTransfer(address(MYT.asset()), address(MYT), amountOut); return amountOut; } function _isProtectedToken(address token) internal view virtual override returns (bool) { return token == MYT.asset() || token == address(autoVault); } } ================================================ FILE: src/strategies/WstETHEthereumStrategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {OraclePricedSwapStrategy} from "./OraclePricedSwapStrategy.sol"; import {IWETH} from "../interfaces/IWETH.sol"; import {IWstETHLike} from "../interfaces/IWstETHLike.sol"; contract WstETHEthereumStrategy is OraclePricedSwapStrategy { IWstETHLike public immutable wsteth; constructor( address _myt, StrategyParams memory _params, address _wstETH, address _stEthEthOracle, uint256 _maxOracleStaleness ) OraclePricedSwapStrategy(_myt, _params, _stEthEthOracle, _maxOracleStaleness) { wsteth = IWstETHLike(_wstETH); } function _allocate(uint256 amount) internal override returns (uint256) { _ensureIdleBalance(_asset(), amount); uint256 wstETHBefore = wsteth.balanceOf(address(this)); IWETH(MYT.asset()).withdraw(amount); (bool success,) = address(wsteth).call{value: amount}(""); require(success, "wstETH deposit failed"); uint256 wstETHReceived = wsteth.balanceOf(address(this)) - wstETHBefore; require(wstETHReceived > 0, "No wstETH received"); return amount; } function _allocate(uint256 amount, bytes memory callData) internal override returns (uint256) { _ensureIdleBalance(_asset(), amount); uint256 minStEthOut = _assetToOracleTokenDown((amount * (10_000 - params.slippageBPS)) / 10_000); if (minStEthOut == 0) minStEthOut = 1; uint256 minWstETHOut = _wstEthFromStEthUp(minStEthOut); dexSwap(address(wsteth), _asset(), amount, minWstETHOut, callData); return amount; } receive() external payable {} function _isProtectedToken(address token) internal view override returns (bool) { return token == MYT.asset() || token == address(wsteth); } function _oracleToken() internal view override returns (address) { return address(wsteth); } function _positionBalance() internal view override returns (uint256) { // Mainnet pricing uses a stETH/ETH oracle, so holdings are valued in stETH-equivalent units. return wsteth.getStETHByWstETH(wsteth.balanceOf(address(this))); } function _prepareOracleTokenForSwap(uint256 maxOracleTokenIn) internal view override returns (uint256) { uint256 wstETHBalance = wsteth.balanceOf(address(this)); uint256 maxWstETHIn = wsteth.getWstETHByStETH(maxOracleTokenIn); return maxWstETHIn > wstETHBalance ? wstETHBalance : maxWstETHIn; } function _wstEthFromStEthUp(uint256 stETHAmount) internal view returns (uint256 wstETHAmount) { wstETHAmount = wsteth.getWstETHByStETH(stETHAmount); if (wsteth.getStETHByWstETH(wstETHAmount) < stETHAmount) { wstETHAmount += 1; } } } ================================================ FILE: src/strategies/WstETHL2Strategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {OraclePricedSwapStrategy} from "./OraclePricedSwapStrategy.sol"; import {IWstETHLike} from "../interfaces/IWstETHLike.sol"; contract WstETHL2Strategy is OraclePricedSwapStrategy { IWstETHLike public immutable wsteth; constructor( address _myt, StrategyParams memory _params, address _wstETH, address _wstEthEthOracle, uint256 _maxOracleStaleness ) OraclePricedSwapStrategy(_myt, _params, _wstEthEthOracle, _maxOracleStaleness) { wsteth = IWstETHLike(_wstETH); } function _allocate(uint256) internal pure override returns (uint256) { revert ActionNotSupported(); } function _isProtectedToken(address token) internal view override returns (bool) { return token == MYT.asset() || token == address(wsteth); } function _oracleToken() internal view override returns (address) { return address(wsteth); } function _positionBalance() internal view override returns (uint256) { return wsteth.balanceOf(address(this)); } function _prepareOracleTokenForSwap(uint256 maxOracleTokenIn) internal view override returns (uint256) { uint256 wstETHBalance = wsteth.balanceOf(address(this)); return maxOracleTokenIn > wstETHBalance ? wstETHBalance : maxOracleTokenIn; } } ================================================ FILE: src/strategies/interfaces/ITokemac.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; interface IBaseRewarder { // actions function getReward() external; // claim for msg.sender function stake(address account, uint256 amount) external; // stake vault shares on behalf of `account` // views function earned(address account) external view returns (uint256); function rewardPerToken() external view returns (uint256); function rewardRate() external view returns (uint256); // rate-per-block style function tokeLockDuration() external view returns (uint256); function lastBlockRewardApplicable() external view returns (uint256); function totalSupply() external view returns (uint256); // total staked function balanceOf(address account) external view returns (uint256); function rewardToken() external view returns (address); // e.g., TOKE function allowExtraRewards() external view returns (bool); // admin/ops (usually not needed by integrators, left for completeness) function queueNewRewards(uint256 newRewards) external; function addToWhitelist(address wallet) external; function removeFromWhitelist(address wallet) external; function recover(address token, address recipient) external; function isWhitelisted(address wallet) external view returns (bool); } interface IExtraRewarder is IBaseRewarder { // extra rewarder variant (claim-only / separate withdraw) function withdraw(address account, uint256 amount) external; function getReward(address account, address recipient) external; } interface IMainRewarder is IBaseRewarder { // full withdraw that can optionally claim extras too function withdraw(address account, uint256 amount, bool claim) external; function stake(address account, uint256 amount) external; // claim to a recipient; toggle whether to also pull from linked extra rewarders function getReward(address account, address recipient, bool claimExtras) external; // optional discovery helpers function extraRewardsLength() external view returns (uint256); function extraRewards() external view returns (address[] memory); function getExtraRewarder(uint256 index) external view returns (IExtraRewarder); } interface IAutopilotRouter { function depositMax(IERC4626 vault, address to, uint256 minSharesOut) external payable returns (uint256 sharesOut); function depositBalance(IERC4626 vault, address to, uint256 minSharesOut) external payable returns (uint256 sharesOut); function stakeVaultToken(IERC4626 vault, uint256 maxAmount) external payable returns (uint256 staked); function withdrawVaultToken(IERC4626 vault, IMainRewarder rewarder, uint256 maxAmount, bool claim) external payable returns (uint256 withdrawn); } ================================================ FILE: src/test/AlchemistAllocator.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {TestYieldToken} from "./mocks/TestYieldToken.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {MockYieldToken} from "./mocks/MockYieldToken.sol"; import {IMockYieldToken} from "./mocks/MockYieldToken.sol"; import {MYTTestHelper} from "./libraries/MYTTestHelper.sol"; import {MockMYTStrategy} from "./mocks/MockMYTStrategy.sol"; import {AlchemistAllocator} from "../AlchemistAllocator.sol"; import {IAllocator} from "../interfaces/IAllocator.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {console} from "forge-std/console.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; contract MockAlchemistAllocator is AlchemistAllocator { constructor(address _myt, address _admin, address _operator, address _classifier) AlchemistAllocator(_myt, _admin, _operator, _classifier) {} } contract AlchemistAllocatorTest is Test { using MYTTestHelper for *; MockAlchemistAllocator public allocator; AlchemistStrategyClassifier public classifier; VaultV2 public vault; address public admin = address(0x2222222222222222222222222222222222222222); address public operator = address(0x3333333333333333333333333333333333333333); address public curator = address(0x8888888888888888888888888888888888888888); address public user1 = address(0x5555555555555555555555555555555555555555); address public mockVaultCollateral = address(new TestERC20(100e18, uint8(18))); address public mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); uint256 public defaultStrategyAbsoluteCap = 200 ether; uint256 public defaultStrategyRelativeCap = 0.8e18; // 80% MockMYTStrategy public mytStrategy; function setUp() public { vm.startPrank(admin); vault = MYTTestHelper._setupVault(mockVaultCollateral, admin, curator); mytStrategy = MYTTestHelper._setupStrategy(address(vault), mockStrategyYieldToken, admin, "MockToken", "MockTokenProtocol", IMYTStrategy.RiskClass.LOW); classifier = new AlchemistStrategyClassifier(admin); // Set up risk classes matching constructor defaults (WAD: 1e18 = 100%) classifier.setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% classifier.setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% classifier.setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% // Assign risk level to the mock strategy bytes32 strategyId = mytStrategy.adapterId(); classifier.assignStrategyRiskLevel(uint256(strategyId), uint8(IMYTStrategy.RiskClass.LOW)); allocator = new MockAlchemistAllocator(address(vault), admin, operator, address(classifier)); vm.stopPrank(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (address(allocator), true))); vault.setIsAllocator(address(allocator), true); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, address(mytStrategy))); vault.addAdapter(address(mytStrategy)); // bytes memory idData = abi.encode("MockTokenProtocol", address(mytStrategy)); bytes memory idData = mytStrategy.getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, defaultStrategyAbsoluteCap))); vault.increaseAbsoluteCap(idData, defaultStrategyAbsoluteCap); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, defaultStrategyRelativeCap))); vault.increaseRelativeCap(idData, defaultStrategyRelativeCap); vm.stopPrank(); } function testProxyBlockedSelectors() public { _magicDepositToVault(address(vault), user1, 150 ether); // Get selectors for blocked functions bytes4 allocateSelector = bytes4(keccak256("allocate(address,bytes,uint256)")); bytes4 deallocateSelector = bytes4(keccak256("deallocate(address,bytes,uint256)")); bytes4 multicallSelector = bytes4(keccak256("multicall(bytes[])")); vm.startPrank(operator); bytes memory idData = mytStrategy.getIdData(); // Test that allocate selector is blocked bytes memory allocateData = abi.encodeCall(IVaultV2.allocate, (address(mytStrategy), idData, 100 ether)); vm.expectRevert(abi.encode("PD")); allocator.proxy(address(vault), allocateData); // Test that deallocate selector is blocked bytes memory deallocateData = abi.encodeCall(IVaultV2.deallocate, (address(mytStrategy), idData, 50 ether)); vm.expectRevert(abi.encode("PD")); allocator.proxy(address(vault), deallocateData); // Test that multicall selector is blocked (prevents tunneling blocked calls) bytes[] memory innerCalls = new bytes[](1); innerCalls[0] = abi.encodeCall(IVaultV2.allocate, (address(mytStrategy), idData, 100 ether)); bytes memory multicallData = abi.encodeCall(IVaultV2.multicall, (innerCalls)); vm.expectRevert(abi.encode("PD")); allocator.proxy(address(vault), multicallData); vm.stopPrank(); } function testAllocateUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); allocator.allocate(address(0x4444444444444444444444444444444444444444), 0); } function testDeallocateUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); allocator.deallocate(address(0x4444444444444444444444444444444444444444), 0); } function testSetLiquidityAdapterUnauthorizedAccessRevert() public { bytes memory directData = _directLiquidityData(); vm.expectRevert(abi.encode("PD")); allocator.setLiquidityAdapter(address(mytStrategy), directData); } function testSetLiquidityAdapter() public { bytes memory data = _directLiquidityData(); vm.startPrank(operator); allocator.setLiquidityAdapter(address(mytStrategy), data); vm.stopPrank(); assertEq(vault.liquidityAdapter(), address(mytStrategy)); assertEq(keccak256(vault.liquidityData()), keccak256(data)); } function _directLiquidityData() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; return abi.encode(params); } function testAllocateRevertIfAboveAbsoluteCap() public { _magicDepositToVault(address(vault), user1, 1000 ether); bytes32 strategyId = mytStrategy.adapterId(); uint256 absoluteCap = vault.absoluteCap(strategyId); vm.startPrank(admin); vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, absoluteCap + 1, absoluteCap)); allocator.allocate(address(mytStrategy), absoluteCap + 1); vm.stopPrank(); } function testAllocateRevertIfAboveRelativeCap() public { _magicDepositToVault(address(vault), user1, 1000 ether); bytes32 strategyId = mytStrategy.adapterId(); uint256 absoluteCap = vault.absoluteCap(strategyId); uint256 relativeCap = vault.relativeCap(strategyId); // Max allocation = totalAssets * relativeCap / 1e18 uint256 totalAssets = vault.totalAssets(); uint256 maxAllocation = (totalAssets * relativeCap) / 1e18; // The effective limit is the minimum of absolute and relative cap (absolute is lower: 200 ether) uint256 effectiveLimit = absoluteCap < maxAllocation ? absoluteCap : maxAllocation; vm.startPrank(admin); vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, maxAllocation + 1, effectiveLimit)); allocator.allocate(address(mytStrategy), maxAllocation + 1); vm.stopPrank(); } function testAllocateRelativeCapOne_RevertWhenTotalAssetsAboveAbsoluteCap() public { bytes memory idData = mytStrategy.getIdData(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, 1e18))); vault.increaseRelativeCap(idData, 1e18); vm.stopPrank(); _magicDepositToVault(address(vault), user1, 300 ether); bytes32 strategyId = mytStrategy.adapterId(); uint256 totalAssets = vault.totalAssets(); uint256 absoluteCap = vault.absoluteCap(strategyId); assertGt(totalAssets, absoluteCap); vm.startPrank(admin); vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, totalAssets, absoluteCap)); allocator.allocate(address(mytStrategy), totalAssets); vm.stopPrank(); } function testAllocateRelativeCapOne_SucceedsWhenTotalAssetsBelowAbsoluteCap() public { bytes memory idData = mytStrategy.getIdData(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, 1e18))); vault.increaseRelativeCap(idData, 1e18); vm.stopPrank(); _magicDepositToVault(address(vault), user1, 150 ether); bytes32 strategyId = mytStrategy.adapterId(); uint256 totalAssets = vault.totalAssets(); uint256 absoluteCap = vault.absoluteCap(strategyId); assertLt(totalAssets, absoluteCap); vm.startPrank(admin); allocator.allocate(address(mytStrategy), totalAssets); vm.stopPrank(); assertEq(vault.allocation(strategyId), totalAssets); } function testAllocateRevertIfAboveRiskGlobalCap() public { _magicDepositToVault(address(vault), user1, 1000 ether); bytes32 strategyId = mytStrategy.adapterId(); uint256 globalCapPct = 0.1e18; // 10% of totalAssets = 100 ether uint256 expectedAbsoluteCap = 100 ether; vm.startPrank(admin); classifier.setRiskClass(0, globalCapPct, 1e18); vm.stopPrank(); vm.startPrank(admin); vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, expectedAbsoluteCap + 1, expectedAbsoluteCap)); allocator.allocate(address(mytStrategy), expectedAbsoluteCap + 1); vm.stopPrank(); } function testAllocateRevertIfAboveRiskIndividualCap() public { _magicDepositToVault(address(vault), user1, 1000 ether); bytes32 strategyId = mytStrategy.adapterId(); uint256 individualCapPct = 0.05e18; // 5% of totalAssets = 50 ether uint256 expectedAbsoluteCap = 50 ether; vm.startPrank(admin); classifier.setRiskClass(0, 1e18, individualCapPct); vm.stopPrank(); vm.startPrank(operator); vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, expectedAbsoluteCap + 1, expectedAbsoluteCap)); allocator.allocate(address(mytStrategy), expectedAbsoluteCap + 1); vm.stopPrank(); } /// @notice Verifies that cumulative allocations are properly checked against risk caps /// @dev After fix, _validateCaps should check currentAllocation + amount <= limit function testAllocateRevertIfCumulativeAllocationExceedsRiskCap() public { _magicDepositToVault(address(vault), user1, 1000 ether); bytes32 strategyId = mytStrategy.adapterId(); uint256 riskCapPct = 0.1e18; // 10% of totalAssets = 100 ether uint256 expectedAbsoluteCap = 100 ether; vm.startPrank(curator); // Increase vault caps to ensure only risk caps are the limiting factor bytes memory idData = mytStrategy.getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, 10000 ether))); vault.increaseAbsoluteCap(idData, 10000 ether); vm.stopPrank(); vm.startPrank(admin); // Set risk cap for LOW risk class (10% global = 100 ether, 10% local = 100 ether) classifier.setRiskClass(0, riskCapPct, riskCapPct); vm.stopPrank(); vm.startPrank(operator); // First allocation: 60 ether (should succeed, 60 <= 100) allocator.allocate(address(mytStrategy), 60 ether); assertEq(vault.allocation(strategyId), 60 ether, "First allocation should succeed"); // Second allocation: 50 ether should FAIL because remaining global capacity is 40 ether // This verifies the fix: cumulative allocation is properly checked against remaining global capacity vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, 50 ether, 40 ether)); allocator.allocate(address(mytStrategy), 50 ether); vm.stopPrank(); // Verify total allocation remains at 60 ether (second allocation was rejected) assertEq(vault.allocation(strategyId), 60 ether, "Allocation should remain at 60 ether"); } /// @notice Verifies that global risk caps are enforced across multiple strategies in the same risk class /// @dev Audit fix: Ensure aggregate allocation of a risk class does not exceed globalCap function testAllocateRevertIfGlobalRiskCapExceededByMultipleStrategies() public { _magicDepositToVault(address(vault), user1, 1000 ether); // Setup Strategy 2 (same risk class LOW) address mockStrategyYieldToken2 = address(new MockYieldToken(mockVaultCollateral)); MockMYTStrategy mytStrategy2 = MYTTestHelper._setupStrategy(address(vault), mockStrategyYieldToken2, admin, "MockToken2", "MockTokenProtocol2", IMYTStrategy.RiskClass.LOW); vm.startPrank(admin); bytes32 strategyId2 = mytStrategy2.adapterId(); classifier.assignStrategyRiskLevel(uint256(strategyId2), uint8(IMYTStrategy.RiskClass.LOW)); vm.stopPrank(); vm.startPrank(curator); bytes memory idData2 = mytStrategy2.getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, address(mytStrategy2))); vault.addAdapter(address(mytStrategy2)); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData2, 10000 ether))); vault.increaseAbsoluteCap(idData2, 10000 ether); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData2, 1e18))); vault.increaseRelativeCap(idData2, 1e18); vm.stopPrank(); // Set Global Cap for LOW risk to 10% (100 ether absolute at 1000 totalAssets) vm.startPrank(admin); classifier.setRiskClass(0, 0.1e18, 0.1e18); // global=10%, local=10% vm.stopPrank(); vm.startPrank(operator); // Allocate 60 ether to Strategy 1 (should succeed) allocator.allocate(address(mytStrategy), 60 ether); assertEq(vault.allocation(mytStrategy.adapterId()), 60 ether); // Allocate 50 ether to Strategy 2 (should FAIL: 60 + 50 = 110 > 100 globalCap) // Remaining capacity = 100 - 60 = 40. Requesting 50. vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, 50 ether, 40 ether)); allocator.allocate(address(mytStrategy2), 50 ether); // Verify Strategy 2 allocation is 0 assertEq(vault.allocation(strategyId2), 0 ether, "Strategy 2 allocation should be 0"); // Allocate 40 ether to Strategy 2 (should succeed: 60 + 40 = 100 <= 100) allocator.allocate(address(mytStrategy2), 40 ether); assertEq(vault.allocation(strategyId2), 40 ether); vm.stopPrank(); } function testAllocate() public { require(vault.adaptersLength() == 1, "adaptersLength is must be 1"); _magicDepositToVault(address(vault), user1, 150 ether); vm.startPrank(admin); bytes32 allocationId = mytStrategy.adapterId(); allocator.allocate(address(mytStrategy), 100 ether); uint256 mytStrategyYieldTokenBalance = IMockYieldToken(mockStrategyYieldToken).balanceOf(address(mytStrategy)); (uint256 newTotalAssets, uint256 performanceFeeShares, uint256 managementFeeShares) = vault.accrueInterestView(); uint256 mytStrategyYieldTokenRealAssets = mytStrategy.realAssets(); // verify all state state changes that happen after an allocation assertEq(mytStrategyYieldTokenBalance, 100 ether); assertEq(mytStrategyYieldTokenRealAssets, 100 ether); assertEq(newTotalAssets, 150 ether); assertEq(performanceFeeShares, 0); assertEq(managementFeeShares, 0); assertEq(vault._totalAssets(), 150 ether); assertEq(vault.allocation(allocationId), 100 ether); vm.stopPrank(); } function testDeallocate() public { _magicDepositToVault(address(vault), user1, 150 ether); vm.startPrank(admin); allocator.allocate(address(mytStrategy), 100 ether); bytes32 allocationId = mytStrategy.adapterId(); uint256 allocation = vault.allocation(allocationId); assertEq(allocation, 100 ether); allocator.deallocate(address(mytStrategy), 50 ether); allocation = vault.allocation(allocationId); console.log("allocation is reset to", allocation); (uint256 newTotalAssets, uint256 performanceFeeShares, uint256 managementFeeShares) = vault.accrueInterestView(); uint256 mytStrategyYieldTokenBalance = IMockYieldToken(mockStrategyYieldToken).balanceOf(address(mytStrategy)); uint256 mytStrategyYieldTokenRealAssets = mytStrategy.realAssets(); // verify all state state changes that happen after a deallocation assertEq(mytStrategyYieldTokenBalance, 50 ether); assertEq(mytStrategyYieldTokenRealAssets, 50 ether); assertEq(newTotalAssets, 150 ether); assertEq(performanceFeeShares, 0); assertEq(managementFeeShares, 0); assertEq(vault._totalAssets(), 150 ether); assertEq(allocation, 50 ether); vm.stopPrank(); } function testDeallocateWithYield() public { _seedYieldToken(1_000_000 ether); uint256 initialYieldTokenSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); require(initialYieldTokenSupply == 1_000_000 ether, "initial yield token supply is not 1_000_000 ether"); _magicDepositToVault(address(vault), user1, 400 ether); vm.startPrank(admin); // allocate 200 vault tokens to the strategy allocator.allocate(address(mytStrategy), 200 ether); bytes32 allocationId = mytStrategy.adapterId(); uint256 allocation = vault.allocation(allocationId); require(allocation == 200 ether, "allocation is not 200 ether"); // Baseline price before simulating yield uint256 initialYieldTokenPrice = IMockYieldToken(mockStrategyYieldToken).price(); // now mock update supply of yield token to increase price (via reducing supply) uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); // Small price increase (50%): reduce mocked supply, keeping underlying constant uint256 modifiedVaultSupply = initialVaultSupply - (initialVaultSupply * 5000 / 10_000); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // get current real assets of the strategy uint256 currentRealAssets = mytStrategy.realAssets(); require(IMockYieldToken(mockStrategyYieldToken).price() > initialYieldTokenPrice, "price is not greater than initial yield token price"); require(currentRealAssets > allocation, "current real assets is not greater than allocation"); // ensure requested amount > previous allocation. e.g. amount is the allocation + 20% of the allocation uint256 deallocateAmount = allocation + (allocation * 2000 / 10_000); require(deallocateAmount > allocation, "deallocate amount is not greater than allocation"); // deallocate the amount from the strategy allocator.deallocate(address(mytStrategy), deallocateAmount); allocation = vault.allocation(allocationId); (uint256 newTotalAssets, uint256 performanceFeeShares, uint256 managementFeeShares) = vault.accrueInterestView(); uint256 mytStrategyYieldTokenBalance = IMockYieldToken(mockStrategyYieldToken).balanceOf(address(mytStrategy)); uint256 mytStrategyYieldTokenRealAssets = mytStrategy.realAssets(); // verify all state state changes that happen after a deallocation // Expected remaining real assets are determined by remaining shares * post-deallocation price. uint256 priceAfter = IMockYieldToken(mockStrategyYieldToken).price(); uint256 expectedRemainingRealAssets = (mytStrategyYieldTokenBalance * priceAfter) / 1e18; // precision loss! assertApproxEqRel(mytStrategyYieldTokenRealAssets, expectedRemainingRealAssets, 1e14); // 0.01% rounding headroom assertEq(newTotalAssets, 400 ether); assertEq(performanceFeeShares, 0); assertEq(managementFeeShares, 0); assertEq(vault._totalAssets(), 400 ether); assertApproxEqRel(allocation, expectedRemainingRealAssets, 1e14); // 0.01% rounding headroom vm.stopPrank(); } function testReclassifyMidLifecycle_StricterCapsApplyToNewAllocations() public { _magicDepositToVault(address(vault), user1, 1000 ether); bytes32 strategyId = mytStrategy.adapterId(); // Lift vault caps so only risk classifier caps are binding vm.startPrank(curator); bytes memory idData = mytStrategy.getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, 10000 ether))); vault.increaseAbsoluteCap(idData, 10000 ether); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, 1e18))); vault.increaseRelativeCap(idData, 1e18); vm.stopPrank(); // Phase 1: Strategy is LOW (100%/100%) — allocate 300 ether freely vm.startPrank(admin); assertEq(classifier.getStrategyRiskLevel(uint256(strategyId)), uint8(IMYTStrategy.RiskClass.LOW)); allocator.allocate(address(mytStrategy), 300 ether); assertEq(vault.allocation(strategyId), 300 ether, "Phase 1: LOW allocation should succeed"); vm.stopPrank(); // Phase 2: Reclassify to MEDIUM (40% global = 400, 25% local = 250) vm.startPrank(admin); classifier.assignStrategyRiskLevel(uint256(strategyId), uint8(IMYTStrategy.RiskClass.MEDIUM)); vm.stopPrank(); assertEq(classifier.getStrategyRiskLevel(uint256(strategyId)), uint8(IMYTStrategy.RiskClass.MEDIUM)); // Existing 300 ether allocation is NOT force-liquidated assertEq(vault.allocation(strategyId), 300 ether, "Existing allocation must persist after reclassify"); // Operator blocked by local cap: check is currentAllocation(300) + amount(1) <= localCap(250) vm.startPrank(operator); vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, 1, 250 ether)); allocator.allocate(address(mytStrategy), 1); vm.stopPrank(); // Admin bypasses local cap but constrained by global cap // Remaining global = (1000 * 0.4) - 300 = 100 ether vm.startPrank(admin); vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, 101 ether, 100 ether)); allocator.allocate(address(mytStrategy), 101 ether); allocator.allocate(address(mytStrategy), 100 ether); assertEq(vault.allocation(strategyId), 400 ether, "Admin should fill remaining global headroom"); vm.stopPrank(); // Phase 3: Reclassify to HIGH (10% global = 100, 10% local = 100) // Current allocation 400 already exceeds both caps vm.startPrank(admin); classifier.assignStrategyRiskLevel(uint256(strategyId), uint8(IMYTStrategy.RiskClass.HIGH)); vm.stopPrank(); // Neither admin nor operator can allocate (global headroom = 100 - 400 → 0) vm.startPrank(admin); vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, 1, 0)); allocator.allocate(address(mytStrategy), 1); vm.stopPrank(); vm.startPrank(operator); vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, 1, 0)); allocator.allocate(address(mytStrategy), 1); vm.stopPrank(); // Phase 4: Deallocate below new cap, then verify allocation resumes under HIGH rules vm.startPrank(admin); allocator.deallocate(address(mytStrategy), 350 ether); assertEq(vault.allocation(strategyId), 50 ether, "Deallocate ignores caps"); vm.stopPrank(); // Now HIGH: remaining global = 100 - 50 = 50, local = 100 // Operator: limit = min(1000 vault_rel, 100 local) = 100, global headroom = 50 vm.startPrank(operator); allocator.allocate(address(mytStrategy), 50 ether); assertEq(vault.allocation(strategyId), 100 ether, "Operator fills remaining global headroom"); vm.stopPrank(); // Saturated — no more room under HIGH global cap vm.startPrank(operator); vm.expectRevert(abi.encodeWithSelector(IAllocator.EffectiveCap.selector, 1, 0)); allocator.allocate(address(mytStrategy), 1); vm.stopPrank(); } function _magicDepositToVault(address _vault, address depositor, uint256 amount) internal { deal(address(mockVaultCollateral), address(depositor), amount); vm.startPrank(depositor); TokenUtils.safeApprove(address(mockVaultCollateral), address(vault), amount); IVaultV2(address(vault)).deposit(amount, address(vault)); vm.stopPrank(); } function _vaultSubmitAndFastForward(bytes memory data) internal { vault.submit(data); bytes4 selector = bytes4(data); vm.warp(block.timestamp + vault.timelock(selector)); } function _seedYieldToken(uint256 seedUnderlying) internal { address yieldWhale = address(0x7777); // Give whale underlying and have it mint yield shares to itself deal(mockVaultCollateral, yieldWhale, seedUnderlying); vm.startPrank(yieldWhale); TokenUtils.safeApprove(mockVaultCollateral, mockStrategyYieldToken, seedUnderlying); IMockYieldToken(mockStrategyYieldToken).mint(seedUnderlying, yieldWhale); vm.stopPrank(); } } contract AlchemistAllocatorPerformanceFeeTest is Test { using MYTTestHelper for *; MockAlchemistAllocator public allocator; AlchemistStrategyClassifier public classifier; VaultV2 public vault; address public admin = address(0x2222222222222222222222222222222222222222); address public operator = address(0x3333333333333333333333333333333333333333); address public curator = address(0x8888888888888888888888888888888888888888); address public user1 = address(0x5555555555555555555555555555555555555555); address public mockVaultCollateral = address(new TestERC20(100e18, uint8(18))); address public mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); MockMYTStrategy public mytStrategy; function setUp() public { vm.startPrank(admin); vault = MYTTestHelper._setupVault(mockVaultCollateral, admin, curator); mytStrategy = MYTTestHelper._setupStrategy( address(vault), mockStrategyYieldToken, admin, "MockToken", "MockTokenProtocol", IMYTStrategy.RiskClass.LOW ); classifier = new AlchemistStrategyClassifier(admin); classifier.setRiskClass(0, 1e18, 1e18); classifier.setRiskClass(1, 0.4e18, 0.25e18); classifier.setRiskClass(2, 0.1e18, 0.1e18); bytes32 strategyId = mytStrategy.adapterId(); classifier.assignStrategyRiskLevel(uint256(strategyId), uint8(IMYTStrategy.RiskClass.LOW)); allocator = new MockAlchemistAllocator(address(vault), admin, operator, address(classifier)); vm.stopPrank(); vm.startPrank(curator); vault.submit(abi.encodeCall(IVaultV2.setIsAllocator, (address(allocator), true))); vault.setIsAllocator(address(allocator), true); vault.submit(abi.encodeCall(IVaultV2.addAdapter, address(mytStrategy))); vault.addAdapter(address(mytStrategy)); bytes memory idData = mytStrategy.getIdData(); vault.submit(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, 500 ether))); vault.increaseAbsoluteCap(idData, 500 ether); vault.submit(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, 1e18))); vault.increaseRelativeCap(idData, 1e18); vm.stopPrank(); _seedYieldToken(1_000_000 ether); _magicDepositToVault(address(vault), user1, 400 ether); vm.prank(admin); allocator.allocate(address(mytStrategy), 200 ether); uint256 supply = IERC20(mockStrategyYieldToken).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(supply / 2); vm.prank(admin); allocator.setMaxRate(200e16 / uint256(365 days)); vm.warp(block.timestamp + 365 days); } function testPerformanceFeeCollectedOnYield() public { // With --isolate, transient firstTotalAssets resets between setUp and this test function. // The first accrueInterest in this tx will see the yield and collect the 15% fee. uint256 idle = IERC20(mockVaultCollateral).balanceOf(address(vault)); uint256 realAssets = mytStrategy.realAssets(); uint256 newTotalAssets = idle + realAssets; uint256 interest = newTotalAssets - vault._totalAssets(); uint256 expectedFeeAssets = interest * 15e16 / 1e18; uint256 expectedFeeShares = expectedFeeAssets * (vault.totalSupply() + 1) / (newTotalAssets - expectedFeeAssets + 1); uint256 adminSharesBefore = vault.balanceOf(admin); vault.accrueInterest(); assertEq( vault.balanceOf(admin) - adminSharesBefore, expectedFeeShares, "performance fee shares mismatch" ); } function _magicDepositToVault(address _vault, address depositor, uint256 amount) internal { deal(mockVaultCollateral, depositor, amount); vm.startPrank(depositor); TokenUtils.safeApprove(mockVaultCollateral, _vault, amount); IVaultV2(_vault).deposit(amount, depositor); vm.stopPrank(); } function _seedYieldToken(uint256 seedUnderlying) internal { address yieldWhale = address(0x7777); deal(mockVaultCollateral, yieldWhale, seedUnderlying); vm.startPrank(yieldWhale); TokenUtils.safeApprove(mockVaultCollateral, mockStrategyYieldToken, seedUnderlying); IMockYieldToken(mockStrategyYieldToken).mint(seedUnderlying, yieldWhale); vm.stopPrank(); } } ================================================ FILE: src/test/AlchemistCurator.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {TestYieldToken} from "./mocks/TestYieldToken.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {MockYieldToken} from "./mocks/MockYieldToken.sol"; import {IMockYieldToken} from "./mocks/MockYieldToken.sol"; import {MockMYTStrategy} from "./mocks/MockMYTStrategy.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {MYTTestHelper} from "./libraries/MYTTestHelper.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; contract AlchemistCuratorTest is Test { using MYTTestHelper for *; AlchemistCurator public mytCuratorProxy; VaultV2 public vault; address public operator = address(0x2222222222222222222222222222222222222222); // default operator address public admin = address(0x4444444444444444444444444444444444444444); // DAO OSX address public mockVaultCollateral = address(new TestERC20(100e18, uint8(18))); address public mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); uint256 public defaultStrategyAbsoluteCap = 200 ether; uint256 public defaultStrategyRelativeCap = 1e18; // 100% MockMYTStrategy public mytStrategy; function setUp() public { vm.startPrank(admin); mytCuratorProxy = new AlchemistCurator(admin, operator); vault = MYTTestHelper._setupVault(mockVaultCollateral, admin, address(mytCuratorProxy)); mytStrategy = MYTTestHelper._setupStrategy(address(vault), mockStrategyYieldToken, admin, "MockToken", "MockTokenProtocol", IMYTStrategy.RiskClass.LOW); vm.stopPrank(); } // basic success case tests function testSubmitSetStrategy() public { vm.startPrank(operator); mytCuratorProxy.submitSetStrategy(address(mytStrategy), address(vault)); vm.stopPrank(); } function testSetStrategy() public { vm.startPrank(operator); mytCuratorProxy.submitSetStrategy(address(mytStrategy), address(vault)); _vaultFastForward(abi.encodeCall(IVaultV2.addAdapter, address(mytStrategy))); mytCuratorProxy.setStrategy(address(mytStrategy), address(vault)); vm.stopPrank(); } function testDecreaseAbsoluteCap() public { _submitAndSetStrategy(address(mytStrategy), address(vault)); vm.startPrank(admin); mytCuratorProxy.submitIncreaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); _vaultFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (mytStrategy.getIdData(), defaultStrategyAbsoluteCap))); mytCuratorProxy.increaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); mytCuratorProxy.decreaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap / 2); // verify absolute cap has decreased assertEq(vault.absoluteCap(IMYTStrategy(address(mytStrategy)).adapterId()), defaultStrategyAbsoluteCap - (defaultStrategyAbsoluteCap / 2)); vm.stopPrank(); } function testDecreaseRelativeCap() public { _submitAndSetStrategy(address(mytStrategy), address(vault)); vm.startPrank(admin); mytCuratorProxy.submitIncreaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap); _vaultFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (mytStrategy.getIdData(), defaultStrategyRelativeCap))); mytCuratorProxy.increaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap); mytCuratorProxy.decreaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap / 2); // verify relative cap has decreased assertEq(vault.relativeCap(IMYTStrategy(address(mytStrategy)).adapterId()), defaultStrategyRelativeCap / 2); vm.stopPrank(); } function testSubmitIncreaseAbsoluteCap() public { _submitAndSetStrategy(address(mytStrategy), address(vault)); vm.startPrank(admin); mytCuratorProxy.submitIncreaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); vm.stopPrank(); } function testSubmitIncreaseRelativeCap() public { _submitAndSetStrategy(address(mytStrategy), address(vault)); vm.startPrank(admin); mytCuratorProxy.submitIncreaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap); vm.stopPrank(); } function testIncreaseAbsoluteCap() public { _submitAndSetStrategy(address(mytStrategy), address(vault)); vm.startPrank(admin); mytCuratorProxy.submitIncreaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); _vaultFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (mytStrategy.getIdData(), defaultStrategyAbsoluteCap))); mytCuratorProxy.increaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); // verify absolute cap has increased assertEq(vault.absoluteCap(IMYTStrategy(address(mytStrategy)).adapterId()), defaultStrategyAbsoluteCap); vm.stopPrank(); } function testIncreaseRelativeCap() public { _submitAndSetStrategy(address(mytStrategy), address(vault)); vm.startPrank(admin); mytCuratorProxy.submitIncreaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap); _vaultFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (mytStrategy.getIdData(), defaultStrategyRelativeCap))); mytCuratorProxy.increaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap); // verify relative cap has increased assertEq(vault.relativeCap(IMYTStrategy(address(mytStrategy)).adapterId()), defaultStrategyRelativeCap); vm.stopPrank(); } /// access control tests function testDecreaseRelativeCapUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.decreaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); vm.stopPrank(); } function testDecreaseAbsoluteCapUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.decreaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); vm.stopPrank(); } function testIncreaseAbsoluteCapUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.increaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); } function testIncreaseRelativeCapUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.increaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap); } function testSubmitIncreaseAbsoluteCapUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.submitIncreaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); } function testSubmitIncreaseRelativeCapUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.submitIncreaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap); } function testTransferAdminOwnerShipUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.transferAdminOwnerShip(address(0x4444444444444444444444444444444444444444)); } function testAcceptAdminOwnershipUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.acceptAdminOwnership(); } function testSetStrategyUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.setStrategy(address(mytStrategy), address(vault)); } function testSetStrategyInvalidAdapterRevert() public { vm.prank(operator); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.setStrategy(address(0), address(vault)); } function testSetStrategyInvalidMYTRevert() public { vm.startPrank(operator); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.setStrategy(address(mytStrategy), address(0)); vm.expectRevert(); mytCuratorProxy.setStrategy(address(mytStrategy), address(0x1234567890123456789012345678901234567890)); vm.stopPrank(); } /// revert on invalid address tests function testSubmitIncreaseAbsoluteCapReverOnInvalidAdapter() public { vm.startPrank(admin); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.submitIncreaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); vm.stopPrank(); } function testSubmitIncreaseRelativeCapReverOnInvalidAdapter() public { vm.startPrank(admin); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.submitIncreaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap); vm.stopPrank(); } function testIncreaseAbsoluteCapReverOnInvalidAdapter() public { vm.startPrank(admin); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.increaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); vm.stopPrank(); } function testIncreaseRelativeCapReverOnInvalidAdapter() public { vm.startPrank(admin); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.increaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap); vm.stopPrank(); } function testDecreaseAbsoluteCapReverOnInvalidAdapter() public { vm.startPrank(admin); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.decreaseAbsoluteCap(address(mytStrategy), defaultStrategyAbsoluteCap); vm.stopPrank(); } function testDecreaseRelativeCapReverOnInvalidAdapter() public { vm.startPrank(admin); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.decreaseRelativeCap(address(mytStrategy), defaultStrategyRelativeCap); vm.stopPrank(); } /// helpers function _vaultFastForward(bytes memory data) internal { bytes4 selector = bytes4(data); vm.warp(block.timestamp + vault.timelock(selector)); } function _submitAndSetStrategy(address adapter, address myt) internal { vm.startPrank(operator); mytCuratorProxy.submitSetStrategy(adapter, myt); _vaultFastForward(abi.encodeCall(IVaultV2.addAdapter, adapter)); mytCuratorProxy.setStrategy(adapter, myt); vm.stopPrank(); } /// remove strategy tests function testSubmitRemoveStrategy() public { _submitAndSetStrategy(address(mytStrategy), address(vault)); vm.prank(operator); mytCuratorProxy.submitRemoveStrategy(address(mytStrategy), address(vault)); } function testRemoveStrategy() public { _submitAndSetStrategy(address(mytStrategy), address(vault)); vm.startPrank(operator); mytCuratorProxy.submitRemoveStrategy(address(mytStrategy), address(vault)); _vaultFastForward(abi.encodeCall(IVaultV2.removeAdapter, address(mytStrategy))); mytCuratorProxy.removeStrategy(address(mytStrategy), address(vault)); vm.stopPrank(); assertFalse(vault.isAdapter(address(mytStrategy)), "adapter should be removed"); assertEq(mytCuratorProxy.adapterToMYT(address(mytStrategy)), address(0), "adapterToMYT should be cleared"); } function testSubmitRemoveStrategyRevertsOnInvalidAdapter() public { vm.prank(operator); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.submitRemoveStrategy(address(0), address(vault)); } function testSubmitRemoveStrategyRevertsOnInvalidMYT() public { vm.startPrank(operator); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.submitRemoveStrategy(address(mytStrategy), address(0)); vm.stopPrank(); } function testRemoveStrategyRevertsOnInvalidAdapter() public { vm.prank(operator); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.removeStrategy(address(0), address(vault)); } function testRemoveStrategyRevertsOnInvalidMYT() public { vm.startPrank(operator); vm.expectRevert(abi.encode("INVALID_ADDRESS")); mytCuratorProxy.removeStrategy(address(mytStrategy), address(0)); vm.stopPrank(); } function testRemoveStrategyUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.removeStrategy(address(mytStrategy), address(vault)); } function testSubmitRemoveStrategyUnauthorizedAccessRevert() public { vm.expectRevert(abi.encode("PD")); mytCuratorProxy.submitRemoveStrategy(address(mytStrategy), address(vault)); } function testRemoveStrategyClearsAdapterMapping() public { _submitAndSetStrategy(address(mytStrategy), address(vault)); assertEq(mytCuratorProxy.adapterToMYT(address(mytStrategy)), address(vault)); bytes32 allocationId = IMYTStrategy(address(mytStrategy)).adapterId(); assertEq(vault.isAdapter(address(mytStrategy)), true); assertEq(vault.adaptersLength(), 1); // remove vm.startPrank(operator); mytCuratorProxy.submitRemoveStrategy(address(mytStrategy), address(vault)); _vaultFastForward(abi.encodeCall(IVaultV2.removeAdapter, address(mytStrategy))); mytCuratorProxy.removeStrategy(address(mytStrategy), address(vault)); vm.stopPrank(); // verify vault no longer recognizes the adapter assertEq(vault.isAdapter(address(mytStrategy)), false, "adapter should be removed from vault"); assertEq(vault.adaptersLength(), 0, "adapters length should be 0"); // verify the removed adapter is not iterated in totalAssets (no realAssets query) // allocation caps remain but the adapter is excluded from adapters array uint256 totalAssets = vault.totalAssets(); assertEq(totalAssets, TestERC20(mockVaultCollateral).balanceOf(address(vault)), "totalAssets should only reflect idle balance"); // verify curator mapping cleared assertEq(mytCuratorProxy.adapterToMYT(address(mytStrategy)), address(0), "adapterToMYT should be cleared"); } } ================================================ FILE: src/test/AlchemistETHVault.t.sol ================================================ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.28; import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {TransparentUpgradeableProxy} from "../../lib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {SafeCast} from "../libraries/SafeCast.sol"; import {Test} from "../../lib/forge-std/src/Test.sol"; import {SafeERC20} from "../libraries/SafeERC20.sol"; import {console} from "../../lib/forge-std/src/console.sol"; import {AlchemistV3} from "../AlchemistV3.sol"; import {AlchemicTokenV3} from "../test/mocks/AlchemicTokenV3.sol"; import {Transmuter} from "../Transmuter.sol"; import {AlchemistV3Position} from "../AlchemistV3Position.sol"; import {Whitelist} from "../utils/Whitelist.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; import {TestYieldToken} from "./mocks/TestYieldToken.sol"; import {TokenAdapterMock} from "./mocks/TokenAdapterMock.sol"; import {IAlchemistV3, IAlchemistV3Errors, AlchemistInitializationParams} from "../interfaces/IAlchemistV3.sol"; import {ITransmuter} from "../interfaces/ITransmuter.sol"; import {ITestYieldToken} from "../interfaces/test/ITestYieldToken.sol"; import {InsufficientAllowance} from "../base/Errors.sol"; import {Unauthorized, IllegalArgument, IllegalState, MissingInputData} from "../base/Errors.sol"; import {AlchemistNFTHelper} from "./libraries/AlchemistNFTHelper.sol"; import {IAlchemistV3Position} from "../interfaces/IAlchemistV3Position.sol"; import {AlchemistETHVault} from "../AlchemistETHVault.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {IWETH} from "../interfaces/IWETH.sol"; import {VmSafe} from "../../lib/forge-std/src/Vm.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {AbstractFeeVault} from "../adapters/AbstractFeeVault.sol"; import {MockWETH} from "./mocks/MockWETH.sol"; contract AlchemistETHVaultTest is Test { AlchemistETHVault public ethVault; MockWETH public wethContract; address public owner = address(1); address public alchemist = address(2); address public user = address(3); address public otherUser = address(4); address public weth; uint256 public constant AMOUNT = 100 * 10 ** 18; function setUp() external { // Deploy mock WETH wethContract = new MockWETH(); weth = address(wethContract); // Deploy vault vm.prank(owner); ethVault = new AlchemistETHVault(weth, alchemist, owner); } // === CONSTRUCTOR TESTS === function testConstructor() public view { assertEq(ethVault.token(), weth); assertEq(ethVault.authorized(address(alchemist)), true); } function testConstructorZeroAddressReverts() public { vm.expectRevert(AbstractFeeVault.ZeroAddress.selector); new AlchemistETHVault(address(0), address(alchemist), owner); } function testDeposit() public { uint256 amount = 1 ether; uint256 startingAmount = 10 ether; // Give some ETH to the external user vm.deal(user, startingAmount); uint256 initialBalance = address(user).balance; vm.startPrank(user); // Deposit ETH ethVault.deposit{value: amount}(); // Verify the user's ETH balance decreased assertEq(address(user).balance, initialBalance - amount); assertEq(ethVault.totalDeposits(), amount); vm.stopPrank(); } function testSendETHRawCall() public { uint256 amount = 1 ether; uint256 startingAmount = 10 ether; // Give some ETH to the external user vm.deal(user, startingAmount); uint256 initialBalance = address(user).balance; vm.startPrank(user); // Deposit ETH to ethVault instead of back to self (bool success,) = address(ethVault).call{value: amount}(""); assertTrue(success, "ETH transfer failed"); vm.stopPrank(); // Verify the user's ETH balance decreased assertEq(address(user).balance, initialBalance - amount); // Verify the vault received the ETH assertEq(ethVault.totalDeposits(), amount); vm.stopPrank(); } function testWithdrawETH() public { uint256 amount = 2 ether; uint256 initialBalance = address(user).balance; vm.startPrank(otherUser); // Give some ETH to the external user vm.deal(otherUser, 10 ether); // Deposit ETH (bool success,) = address(ethVault).call{value: amount}(""); assertTrue(success, "ETH transfer failed"); vm.stopPrank(); // Set up the vault with some ETH vm.deal(address(ethVault), amount); // Verify the user's ETH balance decreased assertEq(ethVault.totalDeposits(), amount); vm.startPrank(address(alchemist)); // Withdraw ETH ethVault.withdraw(user, amount / 2); // Verify the user's ETH balance increased assertEq(address(user).balance, initialBalance + amount / 2); vm.stopPrank(); } function testWithdrawETHRevertsUnauthorized() public { uint256 withdrawAmount = 1 ether; // Set up the vault with some ETH vm.deal(address(ethVault), withdrawAmount); vm.startPrank(user); vm.expectRevert(); // Withdraw ETH ethVault.withdraw(user, withdrawAmount); vm.stopPrank(); } function testOnlyOwnerFunctions() public { // Test setting a new alchemist address address newAlchemist = address(0x123); // Non-owner tries to call an owner-only function vm.startPrank(user); vm.expectRevert(); ethVault.setAuthorization(newAlchemist, true); vm.stopPrank(); // Owner calls the same function vm.startPrank(owner); ethVault.setAuthorization(newAlchemist, true); assertEq(ethVault.authorized(newAlchemist), true); vm.stopPrank(); } function testDepositETHWithZeroAmountReverts() public { vm.startPrank(user); vm.expectRevert(); ethVault.depositWETH(0); vm.stopPrank(); } function testETHReceivedViaCallback() public { uint256 amount = 1 ether; // Give ETH to the test contract vm.deal(address(this), amount); // Mock a callback from the alchemist (e.g., after withdrawing WETH) // First, ensure the vault has no ETH assertEq(ethVault.totalDeposits(), 0); // Send ETH to the vault as if it's a callback (bool success,) = address(ethVault).call{value: amount}(""); assertTrue(success, "ETH transfer failed"); // Verify the vault received the ETH assertEq(ethVault.totalDeposits(), amount); } function testWithdrawETHRevertsZeroRecipient() public { uint256 amount = 1 ether; vm.deal(address(ethVault), amount); vm.prank(address(alchemist)); vm.expectRevert(AbstractFeeVault.ZeroAddress.selector); ethVault.withdraw(address(0), amount); } function testDepositWETH() public { uint256 amount = 1 ether; uint256 startingAmount = 10 ether; // Give some ETH to the external user vm.deal(user, startingAmount); uint256 initialBalance = address(user).balance; vm.startPrank(user); IWETH(weth).deposit{value: amount}(); IERC20(weth).approve(address(ethVault), amount); // Expect the correct event with the right parameters vm.expectEmit(true, true, true, true); emit AbstractFeeVault.Deposited(user, amount); // Start recording logs to count events vm.recordLogs(); // Make the deposit ethVault.depositWETH(amount); // Get logs and count Deposited events VmSafe.Log[] memory logs = vm.getRecordedLogs(); bytes32 depositedEventSignature = keccak256("Deposited(address,uint256)"); uint256 eventCount = 0; for (uint256 i = 0; i < logs.length; i++) { if (logs[i].topics[0] == depositedEventSignature) { eventCount++; } } assertEq(address(user).balance, initialBalance - amount); assertEq(ethVault.totalDeposits(), amount); // Verify only one event was emitted assertEq(eventCount, 1, "Deposited event should be emitted exactly once"); vm.stopPrank(); } } ================================================ FILE: src/test/AlchemistStrategyClassifier.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; contract MockStrategyClassifier is AlchemistStrategyClassifier { constructor(address _admin) AlchemistStrategyClassifier(_admin) {} } contract AlchemistStrategyClassifierTest is Test { MockStrategyClassifier public classifier; address public admin = address(0x1111111111111111111111111111111111111111); address public newAdmin = address(0x2222222222222222222222222222222222222222); address public unauthorized = address(0x3333333333333333333333333333333333333333); uint256 public constant STRATEGY_ID_1 = 1; uint256 public constant STRATEGY_ID_2 = 2; uint8 public constant RISK_LEVEL_LOW = 0; uint8 public constant RISK_LEVEL_MEDIUM = 1; uint8 public constant RISK_LEVEL_HIGH = 2; event AdminChanged(address indexed admin); event RiskClassModified(uint256 indexed class, uint256 indexed globalCap, uint256 indexed localCap); function setUp() public { vm.startPrank(admin); classifier = new MockStrategyClassifier(admin); vm.stopPrank(); } // ===== setRiskClass Tests ===== function testSetRiskClass() public { vm.startPrank(admin); uint256 globalCap = 1000e18; uint256 localCap = 100e18; vm.expectEmit(true, true, true, true); emit RiskClassModified(RISK_LEVEL_LOW, globalCap, localCap); classifier.setRiskClass(RISK_LEVEL_LOW, globalCap, localCap); vm.stopPrank(); assertEq(classifier.getGlobalCap(RISK_LEVEL_LOW), globalCap); (uint256 storedGlobalCap, uint256 storedLocalCap) = classifier.riskClasses(RISK_LEVEL_LOW); assertEq(storedGlobalCap, globalCap); assertEq(storedLocalCap, localCap); } function testSetRiskClassMultipleClasses() public { uint256 lowGlobalCap = 1000e18; uint256 lowLocalCap = 100e18; uint256 highGlobalCap = 500e18; uint256 highLocalCap = 50e18; vm.startPrank(admin); classifier.setRiskClass(RISK_LEVEL_LOW, lowGlobalCap, lowLocalCap); classifier.setRiskClass(RISK_LEVEL_HIGH, highGlobalCap, highLocalCap); vm.stopPrank(); assertEq(classifier.getGlobalCap(RISK_LEVEL_LOW), lowGlobalCap); assertEq(classifier.getGlobalCap(RISK_LEVEL_HIGH), highGlobalCap); } // ===== assignStrategyRiskLevel Tests ===== function testAssignStrategyRiskLevel() public { vm.startPrank(admin); classifier.assignStrategyRiskLevel(STRATEGY_ID_1, RISK_LEVEL_MEDIUM); vm.stopPrank(); assertEq(classifier.getStrategyRiskLevel(STRATEGY_ID_1), RISK_LEVEL_MEDIUM); assertEq(classifier.strategyRiskLevel(STRATEGY_ID_1), RISK_LEVEL_MEDIUM); } function testAssignStrategyRiskLevelMultipleStrategies() public { vm.startPrank(admin); classifier.assignStrategyRiskLevel(STRATEGY_ID_1, RISK_LEVEL_LOW); classifier.assignStrategyRiskLevel(STRATEGY_ID_2, RISK_LEVEL_HIGH); vm.stopPrank(); assertEq(classifier.getStrategyRiskLevel(STRATEGY_ID_1), RISK_LEVEL_LOW); assertEq(classifier.getStrategyRiskLevel(STRATEGY_ID_2), RISK_LEVEL_HIGH); } function testAssignStrategyRiskLevelUnauthorizedRevert() public { vm.startPrank(unauthorized); vm.expectRevert(abi.encode("PD")); classifier.assignStrategyRiskLevel(STRATEGY_ID_1, RISK_LEVEL_MEDIUM); vm.stopPrank(); } // ===== transferOwnership Tests ===== function testTransferOwnership() public { vm.startPrank(admin); classifier.transferOwnership(newAdmin); vm.stopPrank(); assertEq(classifier.pendingAdmin(), newAdmin); assertEq(classifier.admin(), admin); // Should still be old admin until accepted } function testTransferOwnershipUnauthorizedRevert() public { vm.startPrank(unauthorized); vm.expectRevert(abi.encode("PD")); classifier.transferOwnership(newAdmin); vm.stopPrank(); } // ===== acceptOwnership Tests ===== function testAcceptOwnership() public { // First transfer ownership vm.startPrank(admin); classifier.transferOwnership(newAdmin); vm.stopPrank(); // Then accept it vm.startPrank(newAdmin); vm.expectEmit(true, false, false, false); emit AdminChanged(newAdmin); classifier.acceptOwnership(); vm.stopPrank(); assertEq(classifier.admin(), newAdmin); assertEq(classifier.pendingAdmin(), address(0)); } function testAcceptOwnershipUnauthorizedRevert() public { // Transfer ownership to newAdmin vm.startPrank(admin); classifier.transferOwnership(newAdmin); vm.stopPrank(); // Try to accept with unauthorized address vm.startPrank(unauthorized); vm.expectRevert(abi.encode("PD")); classifier.acceptOwnership(); vm.stopPrank(); } function testAcceptOwnershipNoPendingAdminRevert() public { // Try to accept ownership when no transfer was initiated vm.startPrank(newAdmin); vm.expectRevert(abi.encode("PD")); classifier.acceptOwnership(); vm.stopPrank(); } } ================================================ FILE: src/test/AlchemistTokenVault.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "forge-std/Test.sol"; import "../AlchemistTokenVault.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "../base/Errors.sol"; // Simple ERC20 token for testing contract MockToken is ERC20 { constructor() ERC20("Mock Token", "MOCK") { _mint(msg.sender, 1_000_000 * 10 ** 18); } function mint(address to, uint256 amount) external { _mint(to, amount); } } contract AlchemistTokenVaultTest is Test { AlchemistTokenVault public vault; MockToken public token; address public owner = address(1); address public alchemist = address(2); address public user = address(3); address public withdrawer = address(4); address public unauthorizedUser = address(5); uint256 public constant AMOUNT = 100 * 10 ** 18; function setUp() public { // Deploy token and mint to user token = new MockToken(); token.mint(user, AMOUNT * 2); // Deploy vault vm.prank(owner); vault = new AlchemistTokenVault(address(token), alchemist, owner); // Setup authorized withdrawer vm.prank(owner); vault.setAuthorization(withdrawer, true); // Approve vault to spend user's tokens vm.prank(user); token.approve(address(vault), AMOUNT * 2); } function testDeposit() public { uint256 initialBalance = token.balanceOf(address(vault)); // User deposits tokens vm.prank(user); vault.deposit(AMOUNT); // Check balances assertEq(token.balanceOf(address(vault)), initialBalance + AMOUNT, "Vault balance should increase"); assertEq(token.balanceOf(user), AMOUNT, "User balance should decrease"); } function testWithdrawByAlchemist() public { // First deposit tokens vm.prank(user); vault.deposit(AMOUNT); address recipient = address(10); uint256 initialVaultBalance = token.balanceOf(address(vault)); uint256 initialRecipientBalance = token.balanceOf(recipient); // Alchemist withdraws tokens vm.prank(alchemist); vault.withdraw(recipient, AMOUNT / 2); // Check balances assertEq(token.balanceOf(address(vault)), initialVaultBalance - AMOUNT / 2, "Vault balance should decrease"); assertEq(token.balanceOf(recipient), initialRecipientBalance + AMOUNT / 2, "Recipient balance should increase"); } function testWithdrawByAuthorizedWithdrawer() public { // First deposit tokens vm.prank(user); vault.deposit(AMOUNT); address recipient = address(11); uint256 initialVaultBalance = token.balanceOf(address(vault)); uint256 initialRecipientBalance = token.balanceOf(recipient); // Authorized withdrawer withdraws tokens vm.prank(withdrawer); vault.withdraw(recipient, AMOUNT / 2); // Check balances assertEq(token.balanceOf(address(vault)), initialVaultBalance - AMOUNT / 2, "Vault balance should decrease"); assertEq(token.balanceOf(recipient), initialRecipientBalance + AMOUNT / 2, "Recipient balance should increase"); } function testUnauthorizedWithdrawReverts() public { // First deposit tokens vm.prank(user); vault.deposit(AMOUNT); // Unauthorized user attempts to withdraw vm.prank(unauthorizedUser); vm.expectRevert(Unauthorized.selector); vault.withdraw(unauthorizedUser, AMOUNT); } function testOwnerCanAddNewAlchemist() public { // First deposit tokens vm.prank(user); vault.deposit(AMOUNT); // New alchemist address address newAlchemist = address(12); vm.prank(owner); // Owner adds new alchemist vault.setAuthorization(newAlchemist, true); vm.prank(newAlchemist); vault.withdraw(address(13), AMOUNT / 2); // Should succeed assertEq(token.balanceOf(address(13)), AMOUNT / 2, "Recipient should receive tokens"); } function testRevokeAuthorizedAccount() public { // First deposit tokens vm.prank(user); vault.deposit(AMOUNT); // New alchemist address address newAlchemist = address(12); vm.prank(owner); // Owner adds new alchemist vault.setAuthorization(newAlchemist, true); vm.prank(newAlchemist); vault.withdraw(newAlchemist, AMOUNT / 2); // Should succeed vm.prank(owner); // Owner adds new alchemist vault.setAuthorization(newAlchemist, false); vm.prank(newAlchemist); vm.expectRevert(Unauthorized.selector); vault.withdraw(newAlchemist, AMOUNT / 2); // Should now revert } function testZeroAmountDepositReverts() public { vm.prank(user); vm.expectRevert(); vault.deposit(0); } function testZeroAmountWithdrawReverts() public { vm.prank(alchemist); vm.expectRevert(ZeroAmount.selector); vault.withdraw(address(10), 0); } function testWithdrawToZeroAddressReverts() public { vm.prank(alchemist); vm.expectRevert(ZeroAddress.selector); vault.withdraw(address(0), AMOUNT); } } ================================================ FILE: src/test/AlchemistV3.t.sol ================================================ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.28; import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {TransparentUpgradeableProxy} from "lib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {SafeCast} from "../libraries/SafeCast.sol"; import {Test} from "lib/forge-std/src/Test.sol"; import {Vm} from "lib/forge-std/src/Vm.sol"; import {SafeERC20} from "../libraries/SafeERC20.sol"; import {console} from "lib/forge-std/src/console.sol"; import {AlchemistV3} from "../AlchemistV3.sol"; import {AlchemicTokenV3} from "./mocks/AlchemicTokenV3.sol"; import {Transmuter} from "../Transmuter.sol"; import {AlchemistV3Position} from "../AlchemistV3Position.sol"; import {AlchemistV3PositionRenderer} from "../AlchemistV3PositionRenderer.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {Whitelist} from "../utils/Whitelist.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; import {TestYieldToken} from "./mocks/TestYieldToken.sol"; import {TokenAdapterMock} from "./mocks/TokenAdapterMock.sol"; import {IAlchemistV3, IAlchemistV3Errors, AlchemistInitializationParams} from "../interfaces/IAlchemistV3.sol"; import {ITransmuter} from "../interfaces/ITransmuter.sol"; import {ITestYieldToken} from "../interfaces/test/ITestYieldToken.sol"; import {InsufficientAllowance} from "../base/Errors.sol"; import {Unauthorized, IllegalArgument, IllegalState, MissingInputData} from "../base/Errors.sol"; import {AlchemistNFTHelper} from "./libraries/AlchemistNFTHelper.sol"; import {IAlchemistV3Position} from "../interfaces/IAlchemistV3Position.sol"; import {AggregatorV3Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {AlchemistTokenVault} from "../AlchemistTokenVault.sol"; import {MockMYTStrategy} from "./mocks/MockMYTStrategy.sol"; import {MYTTestHelper} from "./libraries/MYTTestHelper.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {MockAlchemistAllocator} from "./mocks/MockAlchemistAllocator.sol"; import {IMockYieldToken} from "./mocks/MockYieldToken.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {MockYieldToken} from "./mocks/MockYieldToken.sol"; import {HardenedInvariantHandler} from "./Invariants/HardenedInvariantsTest.sol"; contract AlchemistV3Test is Test { // ----- [SETUP] Variables for setting up a minimal CDP ----- // Callable contract variables AlchemistV3 alchemist; Transmuter transmuter; AlchemistV3Position alchemistNFT; AlchemistTokenVault alchemistFeeVault; // // Proxy variables TransparentUpgradeableProxy proxyAlchemist; TransparentUpgradeableProxy proxyTransmuter; // // Contract variables // CheatCodes cheats = CheatCodes(HEVM_ADDRESS); AlchemistV3 alchemistLogic; Transmuter transmuterLogic; AlchemicTokenV3 alToken; Whitelist whitelist; // Parameters for AlchemicTokenV2 string public _name; string public _symbol; uint256 public _flashFee; address public alOwner; uint256 internal constant ONE_Q128 = uint256(1) << 128; mapping(address => bool) users; uint256 public constant FIXED_POINT_SCALAR = 1e18; uint256 public constant BPS = 10_000; uint256 public protocolFee = 100; uint256 public liquidatorFeeBPS = 300; // in BPS, 3% uint256 public repaymentFeeBPS = 100; uint256 public minimumCollateralization = uint256(FIXED_POINT_SCALAR * FIXED_POINT_SCALAR) / 9e17; uint256 public liquidationTargetCollateralization = uint256(1e36) / 88e16; // ~113.63% (88% LTV) // ----- Variables for deposits & withdrawals ----- // account funds to make deposits/test with uint256 accountFunds; // large amount to test with uint256 whaleSupply; // amount of yield/underlying token to deposit uint256 depositAmount; // minimum amount of yield/underlying token to deposit uint256 minimumDeposit = 1000e18; // minimum amount of yield/underlying token to deposit uint256 minimumDepositOrWithdrawalLoss = FIXED_POINT_SCALAR; // random EOA for testing address externalUser = address(0x69E8cE9bFc01AA33cD2d02Ed91c72224481Fa420); // another random EOA for testing address anotherExternalUser = address(0x420Ab24368E5bA8b727E9B8aB967073Ff9316969); // another random EOA for testing address yetAnotherExternalUser = address(0x520aB24368e5Ba8B727E9b8aB967073Ff9316961); // another random EOA for testing address someWhale = address(0x521aB24368E5Ba8b727e9b8AB967073fF9316961); // WETH address address public weth = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); address public protocolFeeReceiver = address(10); // MYT variables VaultV2 vault; MockAlchemistAllocator allocator; MockMYTStrategy mytStrategy; address public operator = address(0x2222222222222222222222222222222222222222); // default operator address public admin = address(0x4444444444444444444444444444444444444444); // DAO OSX address public curator = address(0x8888888888888888888888888888888888888888); address public mockVaultCollateral = address(new TestERC20(100e18, uint8(18))); address public mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); uint256 public defaultStrategyAbsoluteCap = 2_000_000_000e18; uint256 public defaultStrategyRelativeCap = 1e18; // 100% struct CalculateLiquidationResult { uint256 liquidationAmountInYield; uint256 debtToBurn; uint256 outSourcedFee; uint256 baseFeeInYield; } struct AccountPosition { address user; uint256 collateral; uint256 debt; uint256 tokenId; } function setUp() external { adJustTestFunds(18); setUpMYT(18); deployCoreContracts(18); } function adJustTestFunds(uint256 alchemistUnderlyingTokenDecimals) public { accountFunds = 200_000 * 10 ** alchemistUnderlyingTokenDecimals; whaleSupply = 20_000_000_000 * 10 ** alchemistUnderlyingTokenDecimals; depositAmount = 200_000 * 10 ** alchemistUnderlyingTokenDecimals; } function setUpMYT(uint256 alchemistUnderlyingTokenDecimals) public { vm.startPrank(admin); uint256 TOKEN_AMOUNT = 1_000_000; // Base token amount uint256 initialSupply = TOKEN_AMOUNT * 10 ** alchemistUnderlyingTokenDecimals; mockVaultCollateral = address(new TestERC20(initialSupply, uint8(alchemistUnderlyingTokenDecimals))); mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); vault = MYTTestHelper._setupVault(mockVaultCollateral, admin, curator); mytStrategy = MYTTestHelper._setupStrategy(address(vault), mockStrategyYieldToken, admin, "MockToken", "MockTokenProtocol", IMYTStrategy.RiskClass.LOW); allocator = new MockAlchemistAllocator(address(vault), admin, operator, address(new AlchemistStrategyClassifier(admin))); vm.stopPrank(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (address(allocator), true))); vault.setIsAllocator(address(allocator), true); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, address(mytStrategy))); vault.addAdapter(address(mytStrategy)); bytes memory idData = mytStrategy.getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, defaultStrategyAbsoluteCap))); vault.increaseAbsoluteCap(idData, defaultStrategyAbsoluteCap); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, defaultStrategyRelativeCap))); vault.increaseRelativeCap(idData, defaultStrategyRelativeCap); vm.stopPrank(); } function _magicDepositToVault(address vault, address depositor, uint256 amount) internal returns (uint256) { deal(address(mockVaultCollateral), address(depositor), amount); vm.startPrank(depositor); TokenUtils.safeApprove(address(mockVaultCollateral), vault, amount); uint256 shares = IVaultV2(vault).deposit(amount, depositor); vm.stopPrank(); return shares; } function _vaultSubmitAndFastForward(bytes memory data) internal { vault.submit(data); bytes4 selector = bytes4(data); vm.warp(block.timestamp + vault.timelock(selector)); } function deployCoreContracts(uint256 alchemistUnderlyingTokenDecimals) public { // test maniplulation for convenience address caller = address(0xdead); address proxyOwner = address(this); vm.assume(caller != address(0)); vm.assume(proxyOwner != address(0)); vm.assume(caller != proxyOwner); vm.startPrank(caller); // Fake tokens alToken = new AlchemicTokenV3(_name, _symbol, _flashFee); ITransmuter.TransmuterInitializationParams memory transParams = ITransmuter.TransmuterInitializationParams({ syntheticToken: address(alToken), feeReceiver: address(this), timeToTransmute: 5_256_000, transmutationFee: 10, exitFee: 20, graphSize: 52_560_000 }); // Contracts and logic contracts alOwner = caller; transmuterLogic = new Transmuter(transParams); alchemistLogic = new AlchemistV3(); whitelist = new Whitelist(); // AlchemistV3 proxy AlchemistInitializationParams memory params = AlchemistInitializationParams({ admin: alOwner, debtToken: address(alToken), underlyingToken: address(vault.asset()), depositCap: type(uint256).max, minimumCollateralization: minimumCollateralization, collateralizationLowerBound: 1_052_631_578_950_000_000, // 1.05 collateralization globalMinimumCollateralization: 1_111_111_111_111_111_111, // 1.1 liquidationTargetCollateralization: liquidationTargetCollateralization, transmuter: address(transmuterLogic), protocolFee: 0, protocolFeeReceiver: protocolFeeReceiver, liquidatorFee: liquidatorFeeBPS, repaymentFee: repaymentFeeBPS, myt: address(vault) }); bytes memory alchemParams = abi.encodeWithSelector(AlchemistV3.initialize.selector, params); proxyAlchemist = new TransparentUpgradeableProxy(address(alchemistLogic), proxyOwner, alchemParams); alchemist = AlchemistV3(address(proxyAlchemist)); // Whitelist alchemist proxy for minting tokens alToken.setWhitelist(address(proxyAlchemist), true); whitelist.add(address(0xbeef)); whitelist.add(externalUser); whitelist.add(anotherExternalUser); transmuterLogic.setAlchemist(address(alchemist)); transmuterLogic.setDepositCap(uint256(type(int256).max)); alchemistNFT = new AlchemistV3Position(address(alchemist), alOwner); alchemistNFT.setMetadataRenderer(address(new AlchemistV3PositionRenderer())); alchemist.setAlchemistPositionNFT(address(alchemistNFT)); alchemistFeeVault = new AlchemistTokenVault(address(vault.asset()), address(alchemist), alOwner); alchemistFeeVault.setAuthorization(address(alchemist), true); alchemist.setAlchemistFeeVault(address(alchemistFeeVault)); _magicDepositToVault(address(vault), address(0xbeef), accountFunds); _magicDepositToVault(address(vault), address(0xdad), accountFunds); _magicDepositToVault(address(vault), externalUser, accountFunds); _magicDepositToVault(address(vault), yetAnotherExternalUser, accountFunds); _magicDepositToVault(address(vault), anotherExternalUser, accountFunds); vm.stopPrank(); vm.startPrank(address(admin)); allocator.allocate(address(mytStrategy), vault.convertToAssets(vault.totalSupply())); vm.stopPrank(); deal(address(alToken), address(0xdad), accountFunds); deal(address(alToken), address(anotherExternalUser), accountFunds); deal(address(vault.asset()), address(0xbeef), accountFunds); deal(address(vault.asset()), externalUser, accountFunds); deal(address(vault.asset()), yetAnotherExternalUser, accountFunds); deal(address(vault.asset()), anotherExternalUser, accountFunds); deal(address(vault.asset()), alchemist.alchemistFeeVault(), 10_000 * (10 ** alchemistUnderlyingTokenDecimals)); vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(vault.asset()), address(vault), accountFunds); vm.stopPrank(); vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault.asset()), address(vault), accountFunds); vm.stopPrank(); vm.startPrank(someWhale); deal(address(vault), someWhale, whaleSupply); deal(address(vault.asset()), someWhale, whaleSupply); SafeERC20.safeApprove(address(vault.asset()), address(mockStrategyYieldToken), whaleSupply); vm.stopPrank(); } function test_Liquidate_and_ForceRepay_Global_MYTSharesDeposited_Updated() external { uint256 amount = 200_000e18; // 200,000 yvdai // uint256 protocolFee = 100; // 10% vm.prank(alOwner); alchemist.setProtocolFee(protocolFee); vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount * 2); alchemist.deposit(amount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); // Need to start a transmutator deposit, to start earmarking debt vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount); transmuterLogic.createRedemption(mintAmount, anotherExternalUser); vm.stopPrank(); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); // skip to a future block. Lets say 60% of the way through the transmutation period (5_256_000 blocks) vm.roll(block.number + (5_256_000 * 60 / 100)); // Earmarked debt should be 60% of the total debt (uint256 prevCollateral, uint256 prevDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); require(earmarked == prevDebt * 60 / 100, "Earmarked debt should be 60% of the total debt"); // modify yield token price via modifying underlying token supply uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 59 bps or 5.9% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 credit = earmarked > prevDebt ? prevDebt : earmarked; uint256 creditToYield = alchemist.convertDebtTokensToYield(credit); uint256 protocolFeeInYield = (creditToYield * protocolFee / BPS); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 prevVaultBalance = alchemistFeeVault.totalDeposits(); uint256 mytBefore = IERC20(address(vault)).balanceOf(address(alchemist)); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); uint256 collateralAfterRepayment = prevCollateral - creditToYield - protocolFeeInYield; // Debt after earmarked repayment (in debt tokens) uint256 debtAfterRepayment = prevDebt - earmarked; // Repayment fee is repay-proportional, then sourced all-or-nothing: // pay from account only if fully safe, otherwise pay from fee vault. uint256 collateralAfterRepaymentInDebt = alchemist.convertYieldTokensToDebt(collateralAfterRepayment); uint256 requiredByLowerBoundInDebt = (debtAfterRepayment * alchemist.collateralizationLowerBound() + FIXED_POINT_SCALAR - 1) / FIXED_POINT_SCALAR; uint256 targetRepaymentFeeInYield = assets * repaymentFeeBPS / BPS; uint256 minRequiredPostFeeInDebt = requiredByLowerBoundInDebt + 1; uint256 maxRemovableInDebt = collateralAfterRepaymentInDebt > minRequiredPostFeeInDebt ? collateralAfterRepaymentInDebt - minRequiredPostFeeInDebt : 0; uint256 maxRepaymentFeeInYield = alchemist.convertDebtTokensToYield(maxRemovableInDebt); uint256 expectedFeeInYield = targetRepaymentFeeInYield; uint256 expectedFeeInUnderlying = 0; if (targetRepaymentFeeInYield > maxRepaymentFeeInYield) { expectedFeeInYield = 0; uint256 targetFeeInUnderlying = alchemist.convertYieldTokensToUnderlying(targetRepaymentFeeInYield); expectedFeeInUnderlying = targetFeeInUnderlying > prevVaultBalance ? prevVaultBalance : targetFeeInUnderlying; } vm.stopPrank(); // ensure debt is reduced only by the repayment of max earmarked amount vm.assertApproxEqAbs(debt, prevDebt - earmarked, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced only by the repayment of max earmarked amount vm.assertApproxEqAbs( depositedCollateral, prevCollateral - alchemist.convertDebtTokensToYield(earmarked) - protocolFeeInYield - expectedFeeInYield, minimumDepositOrWithdrawalLoss ); // ensure assets is equal to repayment of max earmarked amount // vm.assertApproxEqAbs(assets, alchemist.convertDebtTokensToYield(earmarked), minimumDepositOrWithdrawalLoss); // ensure liquidator fee is correct (i.e.0, since only a repayment is done) vm.assertApproxEqAbs(feeInYield, expectedFeeInYield, 1e18); vm.assertEq(feeInUnderlying, expectedFeeInUnderlying); // liquidator gets correct amount of fee, i.e. 0 _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, alchemist.convertDebtTokensToYield(earmarked) ); vm.assertEq(alchemistFeeVault.totalDeposits(), prevVaultBalance - expectedFeeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + alchemist.convertDebtTokensToYield(earmarked), 1e18 ); // check protocolfeereciever received the protocl fee transfer from _forceRepay vm.assertApproxEqAbs(IERC20(address(vault)).balanceOf(address(protocolFeeReceiver)), protocolFeeInYield, 1e18); uint256 mytAfter = IERC20(address(vault)).balanceOf(address(alchemist)); assertLt(mytAfter, mytBefore, "MYT should decrease after liquidation"); uint256 reportedTVL = alchemist.getTotalUnderlyingValue(); uint256 reportedGlobalCR = alchemist.normalizeUnderlyingTokensToDebt(reportedTVL) * FIXED_POINT_SCALAR / alchemist.totalDebt(); uint256 expectedTVL = vault.convertToAssets(mytAfter); // 4626 assets for actual shares in contract uint256 expectedGlobalCR = alchemist.normalizeUnderlyingTokensToDebt(expectedTVL) * FIXED_POINT_SCALAR / alchemist.totalDebt(); assertEq(reportedGlobalCR, expectedGlobalCR, "reported global CR should be the same if _mytSharesDeposited is updated correctly"); } function test_Liquidate_Global_MYTSharesDeposited_Updated() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensuring global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); // Open debt position for yetAnotherExternalUser to ensure totalDebt after full liquidation > 0 uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(yetAnotherExternalUser, address(alchemistNFT)); alchemist.mint(tokenId, alchemist.totalValue(tokenId)/10 * FIXED_POINT_SCALAR / minimumCollateralization, yetAnotherExternalUser); // Don't want user to be fully liquidatable vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); // modify yield token price via modifying underlying token supply (, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 1200 bps or 12% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 1200 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 prevVaultBalance = alchemistFeeVault.totalDeposits(); uint256 alchemistCurrentCollateralization = alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt(); (uint256 liquidationAmount, uint256 expectedDebtToBurn, uint256 expectedBaseFee,) = alchemist.calculateLiquidation( alchemist.totalValue(tokenIdFor0xBeef), prevDebt, alchemist.liquidationTargetCollateralization(), alchemistCurrentCollateralization, alchemist.globalMinimumCollateralization(), liquidatorFeeBPS ); uint256 expectedLiquidationAmountInYield = alchemist.convertDebtTokensToYield(liquidationAmount); uint256 expectedBaseFeeInYield = alchemist.convertDebtTokensToYield(expectedBaseFee); uint256 expectedFeeInUnderlying = expectedDebtToBurn * liquidatorFeeBPS / 10_000; uint256 mytBefore = IERC20(address(vault)).balanceOf(address(alchemist)); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); vm.stopPrank(); // ensure debt is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(debt, 0, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(depositedCollateral, 0, minimumDepositOrWithdrawalLoss); // ensure liquidator fee is correct (3% of 0 if collateral fully liquidated as a result of bad debt) vm.assertApproxEqAbs(feeInYield, 0, 1e18); vm.assertEq(feeInUnderlying, expectedFeeInUnderlying); // liquidator gets correct amount of fee _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, expectedLiquidationAmountInYield ); vm.assertEq(alchemistFeeVault.totalDeposits(), 10_000 ether - feeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + expectedLiquidationAmountInYield - expectedBaseFeeInYield, 1e18 ); uint256 mytAfter = IERC20(address(vault)).balanceOf(address(alchemist)); assertLt(mytAfter, mytBefore, "MYT should decrease after liquidation"); uint256 reportedTVL = alchemist.getTotalUnderlyingValue(); uint256 reportedGlobalCR = alchemist.normalizeUnderlyingTokensToDebt(reportedTVL) * FIXED_POINT_SCALAR / alchemist.totalDebt(); uint256 expectedTVL = vault.convertToAssets(mytAfter); // 4626 assets for actual shares in contract uint256 expectedGlobalCR = alchemist.normalizeUnderlyingTokensToDebt(expectedTVL) * FIXED_POINT_SCALAR / alchemist.totalDebt(); assertEq(reportedGlobalCR, expectedGlobalCR, "reported global CR should be the same if _mytSharesDeposited is updated correctly"); } function testSetV3PositionNFTAlreadySetRevert() public { vm.startPrank(alOwner); vm.expectRevert(); alchemist.setAlchemistPositionNFT(address(0xdBdb4d16EdA451D0503b854CF79D55697F90c8DF)); vm.stopPrank(); } function testSetProtocolFeeTooHigh() public { vm.startPrank(alOwner); vm.expectRevert(); alchemist.setProtocolFee(10_001); vm.stopPrank(); } function testSetLiquidationFeeTooHigh() public { vm.startPrank(alOwner); vm.expectRevert(); alchemist.setLiquidatorFee(10_001); vm.stopPrank(); } function testSetRepaymentFeeTooHigh() public { vm.startPrank(alOwner); vm.expectRevert(); alchemist.setRepaymentFee(10_001); vm.stopPrank(); } function testSetProtocolFee() public { vm.startPrank(alOwner); alchemist.setProtocolFee(100); vm.stopPrank(); assertEq(alchemist.protocolFee(), 100); } function testSetLiquidationFee() public { vm.startPrank(alOwner); alchemist.setLiquidatorFee(100); vm.stopPrank(); assertEq(alchemist.liquidatorFee(), 100); } function testSetRepaymentFee() public { vm.startPrank(alOwner); alchemist.setRepaymentFee(100); vm.stopPrank(); assertEq(alchemist.repaymentFee(), 100); } function testSetMinimumCollaterization_Invalid_Ratio_Below_One(uint256 collateralizationRatio) external { // ~ all possible ratios below 1 vm.assume(collateralizationRatio < FIXED_POINT_SCALAR); vm.startPrank(alOwner); vm.expectRevert(IllegalArgument.selector); alchemist.setMinimumCollateralization(collateralizationRatio); vm.stopPrank(); } function testSetCollateralizationLowerBound_Variable_Upper_Bound(uint256 collateralizationRatio) external { collateralizationRatio = bound(collateralizationRatio, FIXED_POINT_SCALAR, minimumCollateralization - 1); vm.startPrank(alOwner); alchemist.setCollateralizationLowerBound(collateralizationRatio); vm.assertApproxEqAbs(alchemist.collateralizationLowerBound(), collateralizationRatio, minimumDepositOrWithdrawalLoss); vm.stopPrank(); } function testSetCollateralizationLowerBound_Invalid_Above_Minimumcollaterization(uint256 collateralizationRatio) external { // ~ all possible ratios above minimum collaterization ratio vm.assume(collateralizationRatio > minimumCollateralization); vm.startPrank(alOwner); vm.expectRevert(IllegalArgument.selector); alchemist.setCollateralizationLowerBound(collateralizationRatio); vm.stopPrank(); } function testSetCollateralizationLowerBound_Invalid_Below_One(uint256 collateralizationRatio) external { // ~ all possible ratios below minimum collaterization ratio vm.assume(collateralizationRatio < FIXED_POINT_SCALAR); vm.startPrank(alOwner); vm.expectRevert(IllegalArgument.selector); alchemist.setCollateralizationLowerBound(collateralizationRatio); vm.stopPrank(); } function testSetGlobalMinimumCollateralization_Variable_Ratio(uint256 collateralizationRatio) external { vm.assume(collateralizationRatio >= minimumCollateralization); vm.startPrank(alOwner); alchemist.setGlobalMinimumCollateralization(collateralizationRatio); vm.assertApproxEqAbs(alchemist.globalMinimumCollateralization(), collateralizationRatio, minimumDepositOrWithdrawalLoss); vm.stopPrank(); } function testSetGlobalMinimumCollateralization_Invalid_Below_Minimumcollaterization(uint256 collateralizationRatio) external { // ~ all possible ratios above minimum collaterization ratio vm.assume(collateralizationRatio < minimumCollateralization); vm.startPrank(alOwner); vm.expectRevert(IllegalArgument.selector); alchemist.setGlobalMinimumCollateralization(collateralizationRatio); vm.stopPrank(); } function testSetLiquidationTargetCollateralization_Success() external { // Set to a valid value between minimumCollateralization and 2x uint256 newTarget = uint256(FIXED_POINT_SCALAR * FIXED_POINT_SCALAR) / 85e16; // ~117.6% (85% LTV) vm.startPrank(alOwner); alchemist.setLiquidationTargetCollateralization(newTarget); assertEq(alchemist.liquidationTargetCollateralization(), newTarget); vm.stopPrank(); } function testSetLiquidationTargetCollateralization_Revert_Below_MinimumCollateralization() external { // Value below minimumCollateralization should revert uint256 belowMinimum = minimumCollateralization - 1; vm.startPrank(alOwner); vm.expectRevert(IllegalArgument.selector); alchemist.setLiquidationTargetCollateralization(belowMinimum); vm.stopPrank(); } function testSetLiquidationTargetCollateralization_Revert_Below_One() external { vm.startPrank(alOwner); vm.expectRevert(IllegalArgument.selector); alchemist.setLiquidationTargetCollateralization(FIXED_POINT_SCALAR); vm.stopPrank(); } function testSetLiquidationTargetCollateralization_Revert_Above_Upper_Bound() external { // Value above 2x should revert uint256 aboveUpperBound = 2 * FIXED_POINT_SCALAR + 1; vm.startPrank(alOwner); vm.expectRevert(IllegalArgument.selector); alchemist.setLiquidationTargetCollateralization(aboveUpperBound); vm.stopPrank(); } function testSetLiquidationTargetCollateralization_Revert_NotAdmin() external { vm.expectRevert(); alchemist.setLiquidationTargetCollateralization(liquidationTargetCollateralization); } function testSetNewAdmin() external { vm.prank(alOwner); alchemist.setPendingAdmin(address(0xbeef)); vm.prank(address(0xbeef)); alchemist.acceptAdmin(); assertEq(alchemist.admin(), address(0xbeef)); } function testSetNewAdminNotPendingAdmin() external { vm.prank(alOwner); alchemist.setPendingAdmin(address(0xbeef)); vm.startPrank(address(0xdad)); vm.expectRevert(); alchemist.acceptAdmin(); vm.stopPrank(); } function testSetNewAdminNotCurrentAdmin() external { vm.expectRevert(); alchemist.setPendingAdmin(address(0xbeef)); } function testSetNewAdminZeroAddress() external { vm.expectRevert(); alchemist.acceptAdmin(); assertEq(alchemist.pendingAdmin(), address(0)); } function testSetAlchemistFeeVault_Revert_If_Vault_Token_Mismatch() external { vm.startPrank(alOwner); AlchemistTokenVault vault = new AlchemistTokenVault(address(vault), address(alchemist), alOwner); vault.setAuthorization(address(alchemist), true); vm.expectRevert(); alchemist.setAlchemistFeeVault(address(vault)); vm.stopPrank(); } function testSetGuardianAndRemove() external { assertEq(alchemist.guardians(address(0xbad)), false); vm.prank(alOwner); alchemist.setGuardian(address(0xbad), true); assertEq(alchemist.guardians(address(0xbad)), true); vm.prank(alOwner); alchemist.setGuardian(address(0xbad), false); assertEq(alchemist.guardians(address(0xbad)), false); } function testSetProtocolFeeReceiver() external { vm.prank(alOwner); alchemist.setProtocolFeeReceiver(address(0xbeef)); assertEq(alchemist.protocolFeeReceiver(), address(0xbeef)); } function testSetProtocolFeeReceiveZeroAddress() external { vm.startPrank(alOwner); vm.expectRevert(); alchemist.setProtocolFeeReceiver(address(0)); vm.stopPrank(); assertEq(alchemist.protocolFeeReceiver(), address(10)); } function testSetProtocolFeeReceiverNotAdmin() external { vm.expectRevert(); alchemist.setProtocolFeeReceiver(address(0xbeef)); } function testSetMinCollateralization_Variable_Collateralization(uint256 collateralization) external { collateralization = bound(collateralization, alchemist.minimumCollateralization(), 2 * FIXED_POINT_SCALAR); vm.startPrank(address(0xdead)); alchemist.setGlobalMinimumCollateralization(collateralization); alchemist.setLiquidationTargetCollateralization(collateralization); alchemist.setMinimumCollateralization(collateralization); vm.assertApproxEqAbs(alchemist.minimumCollateralization(), collateralization, minimumDepositOrWithdrawalLoss); vm.stopPrank(); } function testSetMinCollateralization_Invalid_Collateralization_Zero() external { uint256 collateralization = 0; vm.startPrank(address(0xdead)); vm.expectRevert(IllegalArgument.selector); alchemist.setMinimumCollateralization(collateralization); vm.stopPrank(); } function testSetMinimumCollateralizationNotAdmin() external { vm.expectRevert(); alchemist.setMinimumCollateralization(0); } function testPauseDeposits() external { assertEq(alchemist.depositsPaused(), false); vm.prank(alOwner); alchemist.pauseDeposits(true); assertEq(alchemist.depositsPaused(), true); vm.prank(alOwner); alchemist.setGuardian(address(0xbad), true); vm.prank(address(0xbad)); alchemist.pauseDeposits(false); assertEq(alchemist.depositsPaused(), false); // Test for onlyAdminOrGuardian modifier vm.expectRevert(); alchemist.pauseDeposits(true); assertEq(alchemist.depositsPaused(), false); } function testPauseLoans() external { assertEq(alchemist.loansPaused(), false); vm.prank(alOwner); alchemist.pauseLoans(true); assertEq(alchemist.loansPaused(), true); vm.prank(alOwner); alchemist.setGuardian(address(0xbad), true); vm.prank(address(0xbad)); alchemist.pauseLoans(false); assertEq(alchemist.loansPaused(), false); // Test for onlyAdminOrGuardian modifier vm.expectRevert(); alchemist.pauseLoans(true); assertEq(alchemist.loansPaused(), false); } function testDeposit_New_Position(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, 1000e18); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); (uint256 depositedCollateral,,) = alchemist.getCDP(tokenId); vm.assertApproxEqAbs(depositedCollateral, amount, minimumDepositOrWithdrawalLoss); vm.stopPrank(); assertEq(alchemist.getTotalDeposited(), amount); (uint256 deposited, uint256 userDebt,) = alchemist.getCDP(tokenId); assertEq(deposited, amount); assertEq(userDebt, 0); assertEq(alchemist.getMaxBorrowable(tokenId), (alchemist.convertYieldTokensToDebt(amount) * FIXED_POINT_SCALAR) / alchemist.minimumCollateralization()); assertEq(alchemist.getTotalUnderlyingValue(), alchemist.convertYieldTokensToUnderlying(amount)); assertEq(alchemist.totalValue(tokenId), alchemist.getTotalUnderlyingValue()); } function testDeposit_ReturnsTokenIdAndDebt() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); (uint256 tokenId, uint256 debtValue) = alchemist.deposit(amount, address(0xbeef), 0); vm.stopPrank(); // Verify token id returned from deposit is the newly minted position id. uint256 expectedTokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); assertEq(tokenId, expectedTokenId); assertEq(debtValue, alchemist.convertYieldTokensToDebt(amount)); } function testDeposit_ExistingPositionReturnsTokenIdAndDebt() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), (amount * 2) + 100e18); (uint256 tokenId,) = alchemist.deposit(amount, address(0xbeef), 0); (uint256 returnedTokenId, uint256 debtValue) = alchemist.deposit(amount, address(0xbeef), tokenId); vm.stopPrank(); assertEq(returnedTokenId, tokenId); assertEq(debtValue, alchemist.convertYieldTokensToDebt(amount)); } function testDeposit_ExistingPosition(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, 1000e18); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), (amount * 2) + 100e18); // first deposit alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); // second deposit to existing position with tokenId alchemist.deposit(amount, address(0xbeef), tokenId); (uint256 depositedCollateral,,) = alchemist.getCDP(tokenId); vm.assertApproxEqAbs(depositedCollateral, (amount * 2), minimumDepositOrWithdrawalLoss); vm.stopPrank(); assertEq(alchemist.getTotalDeposited(), (amount * 2)); assertEq( alchemist.getMaxBorrowable(tokenId), (alchemist.convertYieldTokensToDebt(amount * 2) * FIXED_POINT_SCALAR) / alchemist.minimumCollateralization() ); assertEq(alchemist.getTotalUnderlyingValue(), alchemist.convertYieldTokensToUnderlying((amount * 2))); assertEq(alchemist.totalValue(tokenId), alchemist.getTotalUnderlyingValue()); } function testDepositZeroAmount() external { vm.startPrank(address(0xbeef)); vm.expectRevert(); alchemist.deposit(0, address(0xbeef), 0); vm.stopPrank(); } function testDepositZeroAddress() external { vm.startPrank(address(0xbeef)); vm.expectRevert(); alchemist.deposit(10e18, address(0), 0); vm.stopPrank(); } function testDepositPaused() external { vm.prank(alOwner); alchemist.pauseDeposits(true); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); vm.expectRevert(IllegalState.selector); alchemist.deposit(100e18, address(0xbeef), 0); vm.stopPrank(); } function testWithdrawZeroIdRevert() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); vm.expectRevert(); alchemist.withdraw(amount / 2, address(0xbeef), 0); vm.stopPrank(); } function testWithdrawInvalidIdRevert(uint256 tokenId) external { vm.assume(tokenId > 1); uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); vm.expectRevert(); alchemist.withdraw(0, address(0xbeef), tokenId); vm.stopPrank(); } function testWithdraw(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.withdraw(amount / 2, address(0xbeef), tokenId); (uint256 depositedCollateral,,) = alchemist.getCDP(tokenId); vm.assertApproxEqAbs(depositedCollateral, amount / 2, minimumDepositOrWithdrawalLoss); vm.stopPrank(); assertApproxEqAbs(alchemist.getTotalDeposited(), amount / 2, 1); (uint256 deposited, uint256 userDebt,) = alchemist.getCDP(tokenId); assertApproxEqAbs(deposited, amount / 2, 1); assertApproxEqAbs(userDebt, 0, 1); assertApproxEqAbs( alchemist.getMaxBorrowable(tokenId), alchemist.convertYieldTokensToDebt(amount / 2) * FIXED_POINT_SCALAR / alchemist.minimumCollateralization(), 1 ); assertApproxEqAbs(alchemist.getTotalUnderlyingValue(), alchemist.convertYieldTokensToUnderlying(amount / 2), 1); } function testWithdrawUndercollateralilzed() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); vm.expectRevert(); alchemist.withdraw(amount, address(0xbeef), tokenId); vm.stopPrank(); } function testWithdrawMoreThanPosition() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.expectRevert(); alchemist.withdraw(amount * 2, address(0xbeef), tokenId); vm.stopPrank(); } function testWithdrawZeroAmount() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.expectRevert(); alchemist.withdraw(0, address(0xbeef), tokenId); vm.stopPrank(); } function testWithdrawZeroAddress() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.expectRevert(); alchemist.withdraw(amount / 2, address(0), tokenId); vm.stopPrank(); } function testWithdrawUnauthorizedUserRevert() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.stopPrank(); vm.startPrank(externalUser); vm.expectRevert(); alchemist.withdraw(amount / 2, externalUser, tokenId); vm.stopPrank(); } function testOwnershipTransferBeforeWithdraw(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); // tranferring ownership to externalUser IERC721(address(alchemistNFT)).safeTransferFrom(address(0xbeef), externalUser, tokenId); vm.stopPrank(); vm.startPrank(externalUser); alchemist.withdraw(amount / 2, externalUser, tokenId); vm.stopPrank(); (uint256 depositedCollateral,,) = alchemist.getCDP(tokenId); vm.assertApproxEqAbs(depositedCollateral, amount / 2, minimumDepositOrWithdrawalLoss); assertApproxEqAbs(alchemist.getTotalDeposited(), amount / 2, 1); (uint256 deposited, uint256 userDebt,) = alchemist.getCDP(tokenId); assertApproxEqAbs(deposited, amount / 2, 1); assertApproxEqAbs(userDebt, 0, 1); assertApproxEqAbs( alchemist.getMaxBorrowable(tokenId), alchemist.convertYieldTokensToDebt(amount / 2) * FIXED_POINT_SCALAR / alchemist.minimumCollateralization(), 1 ); assertApproxEqAbs(alchemist.getTotalUnderlyingValue(), alchemist.convertYieldTokensToUnderlying(amount / 2), 1); } function testOwnershipTransferBeforeWithdrawUnauthorizedRevert(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); // tranferring ownership to externalUser IERC721(address(alchemistNFT)).safeTransferFrom(address(0xbeef), externalUser, tokenId); vm.expectRevert(); // 0xbeef no longer has ownership of this account/tokenId alchemist.withdraw(amount / 2, address(0xbeef), tokenId); vm.stopPrank(); } function testMintUnauthorizedUserRevert() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.stopPrank(); vm.startPrank(externalUser); vm.expectRevert(); alchemist.mint(tokenId, 10e18, externalUser); vm.stopPrank(); } function testApproveMintUnauthorizedUserRevert() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.stopPrank(); vm.startPrank(externalUser); vm.expectRevert(); alchemist.approveMint(tokenId, externalUser, 100e18); vm.stopPrank(); } function testOwnership_Transfer_Before_Mint_Variable_Amount(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); uint256 ltv = 2e17; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); // tranferring ownership to externalUser IERC721(address(alchemistNFT)).safeTransferFrom(address(0xbeef), externalUser, tokenId); vm.stopPrank(); vm.startPrank(externalUser); alchemist.mint(tokenId, (amount * ltv) / FIXED_POINT_SCALAR, externalUser); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(externalUser), (amount * ltv) / FIXED_POINT_SCALAR, minimumDepositOrWithdrawalLoss); vm.stopPrank(); (uint256 deposited, uint256 userDebt,) = alchemist.getCDP(tokenId); assertApproxEqAbs(deposited, amount, 1); assertApproxEqAbs(userDebt, amount * ltv / FIXED_POINT_SCALAR, 1); assertApproxEqAbs( alchemist.getMaxBorrowable(tokenId), (alchemist.convertYieldTokensToDebt(amount) * FIXED_POINT_SCALAR / alchemist.minimumCollateralization()) - (amount * ltv) / FIXED_POINT_SCALAR, 1 ); assertApproxEqAbs(alchemist.getTotalUnderlyingValue(), alchemist.convertYieldTokensToUnderlying(amount), 1); } function testOwnership_Transfer_Before_Mint_UnauthorizedRevert(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); uint256 ltv = 2e17; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); // tranferring ownership to externalUser IERC721(address(alchemistNFT)).safeTransferFrom(address(0xbeef), externalUser, tokenId); vm.expectRevert(); alchemist.mint(tokenId, (amount * ltv) / FIXED_POINT_SCALAR, externalUser); vm.stopPrank(); } function testOwnership_Transfer_Before_ApproveMint_UnauthorizedRevert() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); // tranferring ownership to externalUser IERC721(address(alchemistNFT)).safeTransferFrom(address(0xbeef), externalUser, tokenId); vm.expectRevert(); alchemist.approveMint(tokenId, yetAnotherExternalUser, 100e18); vm.stopPrank(); } function testResetMintAllowances_UnauthorizedRevert() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.stopPrank(); // Caller that isnt the owner of the token id vm.startPrank(externalUser); vm.expectRevert(); alchemist.resetMintAllowances(tokenId); vm.stopPrank(); } function testResetMintAllowancesOnUserCall() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.approveMint(tokenId, externalUser, 50e18); vm.stopPrank(); uint256 allowanceBeforeReset = alchemist.mintAllowance(tokenId, externalUser); vm.startPrank(address(0xbeef)); alchemist.resetMintAllowances(tokenId); vm.stopPrank(); uint256 allowanceAfterReset = alchemist.mintAllowance(tokenId, externalUser); assertEq(allowanceBeforeReset, 50e18); assertEq(allowanceAfterReset, 0); } function testResetMintAllowancesOnTransfer() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.approveMint(tokenId, externalUser, 50e18); uint256 allowanceBeforeTransfer = alchemist.mintAllowance(tokenId, externalUser); IERC721(address(alchemistNFT)).safeTransferFrom(address(0xbeef), anotherExternalUser, tokenId); vm.stopPrank(); uint256 allowanceAfterTransfer = alchemist.mintAllowance(tokenId, externalUser); assertEq(allowanceBeforeTransfer, 50e18); assertEq(allowanceAfterTransfer, 0); } function testMint_Variable_Amount(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); uint256 ltv = 2e17; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, (amount * ltv) / FIXED_POINT_SCALAR, address(0xbeef)); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(address(0xbeef)), (amount * ltv) / FIXED_POINT_SCALAR, minimumDepositOrWithdrawalLoss); vm.stopPrank(); (uint256 deposited, uint256 userDebt,) = alchemist.getCDP(tokenId); assertApproxEqAbs(deposited, amount, 1); assertApproxEqAbs(userDebt, amount * ltv / FIXED_POINT_SCALAR, 1); assertApproxEqAbs( alchemist.getMaxBorrowable(tokenId), (alchemist.convertYieldTokensToDebt(amount) * FIXED_POINT_SCALAR / alchemist.minimumCollateralization()) - (amount * ltv) / FIXED_POINT_SCALAR, 1 ); assertApproxEqAbs(alchemist.getTotalUnderlyingValue(), alchemist.convertYieldTokensToUnderlying(amount), 1); } function testMint_Revert_Exceeds_Min_Collateralization(uint256 amount, uint256 collateralization) external { amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); collateralization = bound(collateralization, alchemist.globalMinimumCollateralization(), 100e18); vm.prank(address(0xdead)); alchemist.setGlobalMinimumCollateralization(collateralization); vm.prank(address(0xdead)); alchemist.setMinimumCollateralization(collateralization); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = ((alchemist.totalValue(tokenId) * FIXED_POINT_SCALAR) / collateralization); alchemist.mint(tokenId, mintAmount, address(0xbeef)); vm.stopPrank(); } function testMintFrom_Variable_Amount_Revert_No_Allowance(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); uint256 minCollateralization = 2e18; vm.startPrank(externalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); /// Make deposit for external user alchemist.deposit(amount, externalUser, 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(externalUser, address(alchemistNFT)); vm.stopPrank(); vm.startPrank(address(0xbeef)); /// 0xbeef mints tokens from `externalUser` account, to be recieved by `externalUser`. /// 0xbeef however, has not been approved for any mint amount for `externalUsers` account. vm.expectRevert(); alchemist.mintFrom(tokenId, ((amount * minCollateralization) / FIXED_POINT_SCALAR), externalUser); vm.stopPrank(); } function testMintFrom_Variable_Amount(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); uint256 ltv = 2e17; vm.startPrank(externalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); /// Make deposit for external user alchemist.deposit(amount, externalUser, 0); // a single position nft would have been minted to externalUser uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(externalUser, address(alchemistNFT)); /// 0xbeef has been approved up to a mint amount for minting from `externalUser` account. alchemist.approveMint(tokenId, address(0xbeef), amount + 100e18); vm.stopPrank(); assertEq(alchemist.mintAllowance(tokenId, address(0xbeef)), amount + 100e18); vm.startPrank(address(0xbeef)); alchemist.mintFrom(tokenId, ((amount * ltv) / FIXED_POINT_SCALAR), externalUser); assertEq(alchemist.mintAllowance(tokenId, address(0xbeef)), (amount + 100e18) - (amount * ltv) / FIXED_POINT_SCALAR); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(externalUser), (amount * ltv) / FIXED_POINT_SCALAR, minimumDepositOrWithdrawalLoss); vm.stopPrank(); } function testMintPaused() external { vm.prank(alOwner); alchemist.pauseLoans(true); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.expectRevert(IllegalState.selector); alchemist.mint(tokenId, 10e18, address(0xbeef)); vm.stopPrank(); } function testMintZeroIdRevert() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); vm.expectRevert(); alchemist.mint(0, 10e18, address(0xbeef)); vm.stopPrank(); } function testMintInvalidIdRevert(uint256 tokenId) external { vm.assume(tokenId > 1); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); vm.expectRevert(); alchemist.mint(tokenId, 10e18, address(0xbeef)); vm.stopPrank(); } function testDepositInvalidIdRevert(uint256 tokenId) external { vm.assume(tokenId > 1); vm.startPrank(address(0xbeef)); vm.expectRevert(); alchemist.deposit(100, address(0xbeef), tokenId); vm.stopPrank(); } function testMintFrom_InvalidIdRevert(uint256 amount, uint256 tokenId) external { vm.assume(tokenId > 1); amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); uint256 ltv = 2e17; vm.startPrank(externalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); /// Make deposit for external user alchemist.deposit(amount, externalUser, 0); // a single position nft would have been minted to externalUser uint256 realTokenId = AlchemistNFTHelper.getFirstTokenId(externalUser, address(alchemistNFT)); /// 0xbeef has been approved up to a mint amount for minting from `externalUser` account. alchemist.approveMint(realTokenId, address(0xbeef), amount + 100e18); vm.stopPrank(); assertEq(alchemist.mintAllowance(realTokenId, address(0xbeef)), amount + 100e18); vm.startPrank(address(0xbeef)); vm.expectRevert(); alchemist.mintFrom(tokenId, ((amount * ltv) / FIXED_POINT_SCALAR), externalUser); vm.stopPrank(); } function testMintFeeOnDebt() external { vm.prank(alOwner); // 1% alchemist.setProtocolFee(100); uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, (amount / 2), address(0xbeef)); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(address(0xbeef)), (amount / 2), minimumDepositOrWithdrawalLoss); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000); (uint256 collateral, uint256 userDebt,) = alchemist.getCDP(tokenId); assertEq(userDebt, (amount / 2)); assertApproxEqAbs(collateral, amount, 0); vm.startPrank(address(0xdad)); transmuterLogic.claimRedemption(1); vm.stopPrank(); assertApproxEqAbs(collateral, amount, 0); (collateral, userDebt,) = alchemist.getCDP(tokenId); assertEq(userDebt, 0); assertApproxEqAbs(collateral, (amount / 2) - (amount / 2) * 100 / 10_000, 1); } function testMintFeeOnDebtPartial() external { vm.prank(alOwner); // 1% alchemist.setProtocolFee(100); uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, (amount / 2), address(0xbeef)); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(address(0xbeef)), (amount / 2), minimumDepositOrWithdrawalLoss); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000 / 2); (uint256 collateral, uint256 userDebt,) = alchemist.getCDP(tokenId); assertEq(userDebt, (amount / 2)); assertApproxEqAbs(collateral, amount, 0); vm.startPrank(address(0xdad)); transmuterLogic.claimRedemption(1); vm.stopPrank(); assertApproxEqAbs(collateral, amount, 0); (collateral, userDebt,) = alchemist.getCDP(tokenId); assertEq(userDebt, amount / 4); assertApproxEqAbs(collateral, (3 * amount / 4) - (amount / 4) * 100 / 10_000, 1); } function testMintFeeOnDebtMultipleUsers() external { vm.prank(alOwner); // 1% alchemist.setProtocolFee(100); uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, (amount / 2), address(0xbeef)); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(address(0xbeef)), (amount / 2), minimumDepositOrWithdrawalLoss); vm.stopPrank(); vm.startPrank(externalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, externalUser, 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdForExternalUser = AlchemistNFTHelper.getFirstTokenId(externalUser, address(alchemistNFT)); alchemist.mint(tokenIdForExternalUser, (amount / 2), externalUser); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(externalUser), (amount / 2), minimumDepositOrWithdrawalLoss); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000); vm.startPrank(address(0xdad)); transmuterLogic.claimRedemption(1); vm.stopPrank(); (uint256 collateral, uint256 userDebt,) = alchemist.getCDP(tokenIdFor0xBeef); (uint256 collateral2, uint256 userDebt2,) = alchemist.getCDP(tokenIdForExternalUser); assertEq(userDebt, amount / 4); assertApproxEqAbs(collateral, (3 * amount / 4) - (amount / 4) * 100 / 10_000, 1); assertEq(userDebt2, amount / 4); assertApproxEqAbs(collateral2, (3 * amount / 4) - (amount / 4) * 100 / 10_000, 1); } function testMintFeeOnDebtPartialMultipleUsers() external { vm.prank(alOwner); // 1% alchemist.setProtocolFee(100); uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, (amount / 2), address(0xbeef)); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(address(0xbeef)), (amount / 2), minimumDepositOrWithdrawalLoss); vm.stopPrank(); vm.startPrank(externalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, externalUser, 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdForExternalUser = AlchemistNFTHelper.getFirstTokenId(externalUser, address(alchemistNFT)); alchemist.mint(tokenIdForExternalUser, (amount / 2), externalUser); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(externalUser), (amount / 2), minimumDepositOrWithdrawalLoss); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000 / 2); vm.startPrank(address(0xdad)); transmuterLogic.claimRedemption(1); vm.stopPrank(); (uint256 collateral, uint256 userDebt,) = alchemist.getCDP(tokenIdFor0xBeef); (uint256 collateral2, uint256 userDebt2,) = alchemist.getCDP(tokenIdForExternalUser); assertEq(userDebt, 3 * amount / 8); assertApproxEqAbs(collateral, (7 * amount / 8) - (amount / 8) * 100 / 10_000, 1); assertEq(userDebt2, 3 * amount / 8); assertApproxEqAbs(collateral2, (7 * amount / 8) - (amount / 8) * 100 / 10_000, 1); } function testRepayUnearmarkedDebtOnly() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); uint256 preRepayBalance = vault.balanceOf(address(0xbeef)); vm.roll(block.number + 1); alchemist.repay(100e18, tokenId); vm.stopPrank(); (, uint256 userDebt,) = alchemist.getCDP(tokenId); assertEq(userDebt, 0); // Test that transmuter received funds assertEq(vault.balanceOf(address(transmuterLogic)), alchemist.convertDebtTokensToYield(amount / 2)); // Test that overpayment was not taken from user assertEq(vault.balanceOf(address(0xbeef)), preRepayBalance - alchemist.convertDebtTokensToYield(amount / 2)); } function testRepaySameBlock() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); uint256 preRepayBalance = vault.balanceOf(address(0xbeef)); vm.expectRevert(IAlchemistV3Errors.CannotRepayOnMintBlock.selector); alchemist.repay(100e18, tokenId); vm.stopPrank(); } function testRepayUnearmarkedDebtOnly_Variable_Amount(uint256 repayAmount) external { repayAmount = bound(repayAmount, FIXED_POINT_SCALAR, accountFunds / 2); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 200e18 + repayAmount); alchemist.deposit(100e18, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, 100e18 / 2, address(0xbeef)); uint256 preRepayBalance = vault.balanceOf(address(0xbeef)); vm.roll(block.number + 1); alchemist.repay(repayAmount, tokenId); vm.stopPrank(); (, uint256 userDebt,) = alchemist.getCDP(tokenId); uint256 repaidAmount = alchemist.convertYieldTokensToDebt(repayAmount) > 100e18 / 2 ? 100e18 / 2 : alchemist.convertYieldTokensToDebt(repayAmount); assertEq(userDebt, (100e18 / 2) - repaidAmount); // Test that transmuter received funds assertEq(vault.balanceOf(address(transmuterLogic)), repaidAmount); // Test that overpayment was not taken from user assertEq(vault.balanceOf(address(0xbeef)), preRepayBalance - repaidAmount); } function testRepayWithEarmarkedDebt() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, (amount / 2), address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000); vm.prank(address(0xbeef)); alchemist.repay(25e18, tokenId); (, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); // All debt is earmarked at this point so these values should be the same assertEq(debt, (amount / 2) - (amount / 4)); assertEq(earmarked, (amount / 2) - (amount / 4)); } function testRepayWithEarmarkedDebtWithFee() external { vm.prank(alOwner); // 1% alchemist.setProtocolFee(100); uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, (amount / 2), address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000); vm.prank(address(0xbeef)); alchemist.repay(25e18, tokenId); (, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); // All debt is earmarked at this point so these values should be the same assertEq(debt, (amount / 2) - (amount / 4)); assertEq(earmarked, (amount / 2) - (amount / 4)); assertEq(IERC20(address(vault)).balanceOf(address(10)), alchemist.convertYieldTokensToDebt(25e18) * 100 / 10_000); } function testRepayWithEarmarkedDebtPartial() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, (amount / 2), address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000 / 2); vm.prank(address(0xbeef)); alchemist.repay(25e18, tokenId); (, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); // 50 debt / 2 - 25 repaid assertEq(debt, (amount / 2) - (amount / 4)); // Half of all debt was earmarked which is 25 // Repay of 25 will pay off all earmarked debt assertEq(earmarked, 0); } function testRegression_EarmarkedRepaySyncsBaselineAndPreservesNextGraphWindow() external { uint256 debtAmount = 100e18; uint256 firstRedemptionAmount = 30e18; uint256 transmutationTime = transmuterLogic.timeToTransmute(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), type(uint256).max); alchemist.deposit(200e18, address(0xbeef), 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, debtAmount, address(0xbeef)); transmuterLogic.createRedemption(firstRedemptionAmount, address(0xbeef)); vm.stopPrank(); // Start from a synced baseline so only the earmarked repay transfer can become "new cover". uint256 initialTransmuterBalance = vault.balanceOf(address(transmuterLogic)); vm.prank(address(transmuterLogic)); alchemist.setTransmuterTokenBalance(initialTransmuterBalance); vm.roll(block.number + transmutationTime); uint256 firstWindowDemand = transmuterLogic.queryGraph(alchemist.lastEarmarkBlock() + 1, block.number); alchemist.poke(tokenId); (, uint256 debtAfterFirstWindow, uint256 earmarkedAfterFirstWindow) = alchemist.getCDP(tokenId); assertEq(debtAfterFirstWindow, debtAmount, "first earmark should not change debt"); assertApproxEqAbs( earmarkedAfterFirstWindow, firstWindowDemand, 1, "first real graph window should earmark its full demand" ); assertApproxEqAbs( alchemist.cumulativeEarmarked(), firstWindowDemand, 1, "global earmark should match the first real graph window" ); uint256 repayShares = alchemist.convertDebtTokensToYield(earmarkedAfterFirstWindow); vm.prank(address(0xbeef)); alchemist.repay(repayShares, tokenId); assertEq(alchemist.cumulativeEarmarked(), 0, "repay should clear the old earmark"); assertEq(alchemist.totalDebt(), debtAmount - earmarkedAfterFirstWindow, "repay should retire the earmarked debt"); uint256 transmuterBalanceAfterRepay = vault.balanceOf(address(transmuterLogic)); assertEq( transmuterBalanceAfterRepay - initialTransmuterBalance, repayShares, "repay should move the old earmark amount into the transmuter" ); assertEq( alchemist.lastTransmuterTokenBalance(), transmuterBalanceAfterRepay, "repay should sync the earmarked transfer into the transmuter baseline" ); // The next real graph window should earmark normally without an extra transmuter sync call. vm.prank(address(0xbeef)); transmuterLogic.createRedemption(earmarkedAfterFirstWindow, address(0xbeef)); vm.roll(block.number + transmutationTime); uint256 secondWindowDemand = transmuterLogic.queryGraph(alchemist.lastEarmarkBlock() + 1, block.number); assertGt(secondWindowDemand, 0, "second graph window should have demand"); alchemist.poke(tokenId); assertApproxEqAbs( alchemist.cumulativeEarmarked(), secondWindowDemand, 1, "the next real graph window should be preserved after earmarked repay" ); (, , uint256 syncedAccountEarmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs( syncedAccountEarmarked, secondWindowDemand, 1, "account should receive the next real graph earmark" ); } function testRepayZeroAmount() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); vm.expectRevert(); alchemist.repay(0, tokenId); vm.stopPrank(); } function testRepayZeroTokenIdRevert() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); vm.expectRevert(); alchemist.repay(100e18, 0); vm.stopPrank(); } function testRepayInvalidIdRevert(uint256 tokenId) external { vm.assume(tokenId > 1); uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 realTokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(realTokenId, amount / 2, address(0xbeef)); vm.expectRevert(); alchemist.repay(100e18, tokenId); vm.stopPrank(); } function testBurn() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); vm.roll(block.number + 1); SafeERC20.safeApprove(address(alToken), address(alchemist), amount / 2); alchemist.burn(amount / 2, tokenId); vm.stopPrank(); (, uint256 userDebt,) = alchemist.getCDP(tokenId); assertEq(userDebt, 0); } function testBurnSameBlock() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); SafeERC20.safeApprove(address(alToken), address(alchemist), amount / 2); vm.expectRevert(IAlchemistV3Errors.CannotRepayOnMintBlock.selector); alchemist.burn(amount / 2, tokenId); vm.stopPrank(); } function testBurn_variable_burn_amounts(uint256 burnAmount) external { deal(address(alToken), address(0xbeef), 1000e18); uint256 amount = 100e18; burnAmount = bound(burnAmount, 1, 1000e18); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); vm.roll(block.number + 1); SafeERC20.safeApprove(address(alToken), address(alchemist), amount / 2); alchemist.burn(burnAmount, tokenId); vm.stopPrank(); (, uint256 userDebt,) = alchemist.getCDP(tokenId); uint256 burnedAmount = burnAmount > amount / 2 ? amount / 2 : burnAmount; // Test that amount is burned and any extra tokens are not taken from user assertEq(userDebt, (amount / 2) - burnedAmount); assertEq(alToken.balanceOf(address(0xbeef)) - amount / 2, 1000e18 - burnedAmount); } function testBurnZeroAmount() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); SafeERC20.safeApprove(address(alToken), address(alchemist), amount / 2); vm.expectRevert(); alchemist.burn(0, tokenId); vm.stopPrank(); } function testBurnZeroIdRevert() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); SafeERC20.safeApprove(address(alToken), address(alchemist), amount / 2); vm.expectRevert(); alchemist.burn(amount / 2, 0); vm.stopPrank(); } function testBurnWithEarmarkedDebtFullyEarmarked() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + (5_256_000)); // Will fail since all debt is earmarked and cannot be repaid with burn vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(alToken), address(alchemist), amount / 2); vm.expectRevert(IllegalState.selector); alchemist.burn(amount / 8, tokenId); vm.stopPrank(); } function testBurnWithEarmarkedDebt() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); vm.stopPrank(); // Deposit and borrow from another position so there is allowance to burn vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xdad), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId2 = AlchemistNFTHelper.getFirstTokenId(address(0xdad), address(alchemistNFT)); alchemist.mint(tokenId2, amount / 2, address(0xdad)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + (5_256_000 / 2)); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(alToken), address(alchemist), amount / 2); alchemist.burn(amount, tokenId); vm.stopPrank(); (, uint256 userDebt, uint256 earmarked) = alchemist.getCDP(tokenId); // Only 3/4 debt can be paid off since the rest is earmarked assertEq(userDebt, (amount / 8)); // Burn doesn't repay earmarked debt. assertEq(earmarked, (amount / 8)); } function testBurnNoLimit() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, amount / 2, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + (5_256_000 / 2)); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(alToken), address(alchemist), amount / 2); vm.expectRevert(); alchemist.burn(amount, tokenId); vm.stopPrank(); } function testLiquidate_Revert_If_Invalid_Token_Id(uint256 amount, uint256 tokenId) external { vm.assume(tokenId > 1); amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 realTokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(realTokenId, alchemist.totalValue(realTokenId) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); // let another user liquidate the previous user position vm.startPrank(externalUser); vm.expectRevert(); alchemist.liquidate(tokenId); vm.stopPrank(); } function testLiquidate_Undercollateralized_Position() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); uint256 sharesBalance = IERC20(address(vault)).balanceOf(address(yetAnotherExternalUser)); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); // modify yield token price via modifying underlying token supply (uint256 prevCollateral, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 59 bps or 5.9% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 alchemistCurrentCollateralization = alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt(); (uint256 liquidationAmount, uint256 expectedDebtToBurn, uint256 expectedBaseFee,) = alchemist.calculateLiquidation( alchemist.totalValue(tokenIdFor0xBeef), prevDebt, alchemist.liquidationTargetCollateralization(), alchemistCurrentCollateralization, alchemist.globalMinimumCollateralization(), liquidatorFeeBPS ); uint256 expectedLiquidationAmountInYield = alchemist.convertDebtTokensToYield(liquidationAmount); uint256 expectedBaseFeeInYield = alchemist.convertDebtTokensToYield(expectedBaseFee); // Account is still collateralized, so not pulling from the fee vault for underlying uint256 expectedFeeInUnderlying = 0; (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); vm.stopPrank(); // ensure debt is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(debt, prevDebt - expectedDebtToBurn, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(depositedCollateral, prevCollateral - expectedLiquidationAmountInYield, minimumDepositOrWithdrawalLoss); // ensure assets is equal to liquidation amount i.e. y in (collateral - y)/(debt - y) = minimum collateral ratio // vm.assertApproxEqAbs(assets, expectedLiquidationAmountInYield, minimumDepositOrWithdrawalLoss); // ensure liquidator fee is correct (3% of liquidation amount) vm.assertApproxEqAbs(feeInYield, expectedBaseFeeInYield, 1e18); vm.assertEq(feeInUnderlying, expectedFeeInUnderlying); // liquidator gets correct amount of fee _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, expectedLiquidationAmountInYield ); vm.assertEq(alchemistFeeVault.totalDeposits(), 10_000 ether - feeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + expectedLiquidationAmountInYield - expectedBaseFeeInYield, 1e18 ); } function testLiquidate_Restores_To_LiquidationTargetCollateralization() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // Ensure global alchemist collateralization stays above the minimum for regular liquidations vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); // Manipulate yield token price to push account into liquidation zone (5.9% increase in yield token supply) uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // Liquidate vm.startPrank(externalUser); alchemist.liquidate(tokenIdFor0xBeef); vm.stopPrank(); // Verify post-liquidation collateralization ratio matches liquidationTargetCollateralization (uint256 postCollateral, uint256 postDebt,) = alchemist.getCDP(tokenIdFor0xBeef); // This is a partial liquidation — debt must remain assertGt(postDebt, 0, "Partial liquidation should leave remaining debt"); assertGt(postCollateral, 0, "Partial liquidation should leave remaining collateral"); uint256 postCollateralInUnderlying = alchemist.totalValue(tokenIdFor0xBeef); uint256 postCollateralizationRatio = postCollateralInUnderlying * FIXED_POINT_SCALAR / postDebt; // Post-liquidation CR should match liquidationTargetCollateralization (~113.63%), not minimumCollateralization (~111.11%) vm.assertApproxEqAbs( postCollateralizationRatio, alchemist.liquidationTargetCollateralization(), 1e16, // 0.01 tolerance for rounding "Post-liquidation CR should match liquidationTargetCollateralization" ); // Explicitly verify it's higher than minimumCollateralization assertGt( postCollateralizationRatio, alchemist.minimumCollateralization(), "Post-liquidation CR should be above minimumCollateralization" ); } function testLiquidate_Aggressive_Target_60_Percent_LTV_Nearly_Full_Liquidation() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // Set aggressive liquidation target: 60% LTV (~166.67% CR) uint256 aggressiveTarget = uint256(FIXED_POINT_SCALAR * FIXED_POINT_SCALAR) / 60e16; vm.prank(alOwner); alchemist.setLiquidationTargetCollateralization(aggressiveTarget); assertEq(alchemist.liquidationTargetCollateralization(), aggressiveTarget); // Ensure global alchemist collateralization stays above the minimum for regular liquidations vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); (uint256 prevCollateral, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); // Manipulate yield token price to push account into liquidation zone (5.9% increase in yield token supply) uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // Liquidate vm.startPrank(externalUser); alchemist.liquidate(tokenIdFor0xBeef); vm.stopPrank(); (uint256 postCollateral, uint256 postDebt,) = alchemist.getCDP(tokenIdFor0xBeef); // This is a partial liquidation — debt and collateral must remain assertGt(postDebt, 0, "Partial liquidation should leave remaining debt"); assertGt(postCollateral, 0, "Partial liquidation should leave remaining collateral"); uint256 postCollateralInUnderlying = alchemist.totalValue(tokenIdFor0xBeef); uint256 postCollateralizationRatio = postCollateralInUnderlying * FIXED_POINT_SCALAR / postDebt; // Post-liquidation CR should match the aggressive target (~166.67%) vm.assertApproxEqAbs( postCollateralizationRatio, aggressiveTarget, 1e16, "Post-liquidation CR should match aggressive 60% LTV target" ); // Verify the vast majority of the position was liquidated (>90% of debt burned) assertGt( prevDebt - postDebt, prevDebt * 90 / 100, "Aggressive target should liquidate >90% of debt" ); // Verify the vast majority of collateral was seized assertGt( prevCollateral - postCollateral, prevCollateral * 85 / 100, "Aggressive target should seize >85% of collateral" ); } function testLiquidate_Undercollateralized_Position_All_Fees_From_Fee_Vault() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); // modify yield token price via modifying underlying token supply (, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 4000 bps or 40% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 4000 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 alchemistCurrentCollateralization = alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt(); (uint256 liquidationAmount, uint256 expectedDebtToBurn,,) = alchemist.calculateLiquidation( alchemist.totalValue(tokenIdFor0xBeef), prevDebt, alchemist.liquidationTargetCollateralization(), alchemistCurrentCollateralization, alchemist.globalMinimumCollateralization(), liquidatorFeeBPS ); uint256 expectedFeeInUnderlying = expectedDebtToBurn * liquidatorFeeBPS / 10_000; uint256 expectedLiquidationAmountInYield = alchemist.convertDebtTokensToYield(liquidationAmount); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); // (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); vm.stopPrank(); // ensure liquidator fee is correct (3% of surplus (account collateral - debt) vm.assertApproxEqAbs(feeInYield, 0, 1e18); vm.assertEq(feeInUnderlying, expectedFeeInUnderlying); // liquidator gets correct amount of fee _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, expectedLiquidationAmountInYield ); vm.assertApproxEqAbs(alchemistFeeVault.totalDeposits(), 10_000 ether - feeInUnderlying, 1e18); } function testLiquidate_Full_Liquidation_Bad_Debt() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); // modify yield token price via modifying underlying token supply (, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 1200 bps or 12% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 1200 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 alchemistCurrentCollateralization = alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt(); (uint256 liquidationAmount, uint256 expectedDebtToBurn, uint256 expectedBaseFee,) = alchemist.calculateLiquidation( alchemist.totalValue(tokenIdFor0xBeef), prevDebt, alchemist.liquidationTargetCollateralization(), alchemistCurrentCollateralization, alchemist.globalMinimumCollateralization(), liquidatorFeeBPS ); uint256 expectedLiquidationAmountInYield = alchemist.convertDebtTokensToYield(liquidationAmount); uint256 expectedBaseFeeInYield = alchemist.convertDebtTokensToYield(expectedBaseFee); uint256 expectedFeeInUnderlying = expectedDebtToBurn * liquidatorFeeBPS / 10_000; (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); vm.stopPrank(); // ensure debt is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(debt, 0, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(depositedCollateral, 0, minimumDepositOrWithdrawalLoss); // ensure assets liquidated is equal (collateral - (90% of collateral)) // vm.assertApproxEqAbs(assets, expectedLiquidationAmountInYield, minimumDepositOrWithdrawalLoss); // ensure liquidator fee is correct (3% of 0 if collateral fully liquidated as a result of bad debt) vm.assertApproxEqAbs(feeInYield, 0, 1e18); vm.assertEq(feeInUnderlying, expectedFeeInUnderlying); // liquidator gets correct amount of fee _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, expectedLiquidationAmountInYield ); vm.assertEq(alchemistFeeVault.totalDeposits(), 10_000 ether - feeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + expectedLiquidationAmountInYield - expectedBaseFeeInYield, 1e18 ); } function testLiquidate_Bad_Debt_With_Unset_FeeVault_Returns_Zero_FeeInUnderlying() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // Ensure global alchemist collateralization stays above minimum for regular liquidations vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); // Create bad debt: increase yield token supply by 12% while keeping underlying unchanged uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); uint256 modifiedVaultSupply = (initialVaultSupply * 1200 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // Unset the fee vault by writing address(0) to storage slot 1 (alchemistFeeVault) vm.store(address(alchemist), bytes32(uint256(1)), bytes32(0)); assertEq(alchemist.alchemistFeeVault(), address(0)); // Liquidate with no fee vault set vm.startPrank(externalUser); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); vm.recordLogs(); (, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); Vm.Log[] memory logs = vm.getRecordedLogs(); vm.stopPrank(); bytes32 feeShortfallSig = keccak256("FeeShortfall(address,uint256,uint256)"); bool sawFeeShortfall = false; for (uint256 i = 0; i < logs.length; i++) { if ( logs[i].emitter == address(alchemist) && logs[i].topics.length > 1 && logs[i].topics[0] == feeShortfallSig && logs[i].topics[1] == bytes32(uint256(uint160(externalUser))) ) { sawFeeShortfall = true; break; } } assertTrue(sawFeeShortfall, "FeeShortfall event not emitted"); // _payWithFeeVault should return 0 when alchemistFeeVault is address(0) assertEq(feeInUnderlying, 0); // feeInYield should be 0 in bad debt scenario (all collateral seized) assertEq(feeInYield, 0); // Liquidator should not have received any underlying tokens assertEq(IERC20(vault.asset()).balanceOf(address(externalUser)), liquidatorPrevUnderlyingBalance); } function testLiquidate_Bad_Debt_With_Insufficient_FeeVault_Balance_Emits_FeeShortfall() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // Ensure global alchemist collateralization stays above minimum for regular liquidations vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); // Create bad debt: increase yield token supply by 12% while keeping underlying unchanged uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); uint256 modifiedVaultSupply = (initialVaultSupply * 1200 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // Leave the fee vault set, but cap its balance below the requested payout. uint256 limitedVaultBalance = 1e18; deal(address(vault.asset()), alchemist.alchemistFeeVault(), limitedVaultBalance); vm.startPrank(externalUser); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); vm.recordLogs(); (, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); Vm.Log[] memory logs = vm.getRecordedLogs(); vm.stopPrank(); bytes32 feeShortfallSig = keccak256("FeeShortfall(address,uint256,uint256)"); bool sawFeeShortfall = false; uint256 requested; uint256 paid; for (uint256 i = 0; i < logs.length; i++) { if ( logs[i].emitter == address(alchemist) && logs[i].topics.length > 1 && logs[i].topics[0] == feeShortfallSig && logs[i].topics[1] == bytes32(uint256(uint160(externalUser))) ) { (requested, paid) = abi.decode(logs[i].data, (uint256, uint256)); sawFeeShortfall = true; break; } } assertTrue(sawFeeShortfall, "FeeShortfall event not emitted"); assertGt(requested, paid); assertEq(paid, limitedVaultBalance); // Requested fee is larger than vault balance, so payout is capped by vault balance. assertEq(feeInUnderlying, limitedVaultBalance); assertEq(feeInYield, 0); assertEq(IERC20(vault.asset()).balanceOf(address(externalUser)), liquidatorPrevUnderlyingBalance + limitedVaultBalance); } function testLiquidate_Full_Liquidation_Globally_Undercollateralized() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); // modify yield token price via modifying underlying token supply (uint256 prevCollateral, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 59 bps or 5.9% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 alchemistCurrentCollateralization = alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt(); (uint256 liquidationAmount, uint256 expectedDebtToBurn,,) = alchemist.calculateLiquidation( alchemist.totalValue(tokenIdFor0xBeef), prevDebt, alchemist.liquidationTargetCollateralization(), alchemistCurrentCollateralization, alchemist.globalMinimumCollateralization(), liquidatorFeeBPS ); uint256 expectedLiquidationAmountInYield = alchemist.convertDebtTokensToYield(liquidationAmount); uint256 expectedBaseFeeInYield = 0; // Account is still collateralized, but pulling from fee vault for globally bad debt scenario uint256 expectedFeeInUnderlying = expectedDebtToBurn * liquidatorFeeBPS / 10_000; (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); vm.stopPrank(); // ensure debt is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(debt, 0, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(depositedCollateral, prevCollateral - expectedLiquidationAmountInYield, minimumDepositOrWithdrawalLoss); // ensure assets liquidated is equal (collateral - (90% of collateral)) vm.assertApproxEqAbs(assets, expectedLiquidationAmountInYield, minimumDepositOrWithdrawalLoss); // ensure liquidator fee in yeild is correct (0 in globally undercollateralized environment, fee will come from external vaults) vm.assertApproxEqAbs(feeInYield, expectedBaseFeeInYield, 1e18); vm.assertEq(feeInUnderlying, expectedFeeInUnderlying); // liquidator gets correct amount of fee _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, expectedLiquidationAmountInYield ); vm.assertEq(alchemistFeeVault.totalDeposits(), 10_000 ether - feeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + expectedLiquidationAmountInYield - expectedBaseFeeInYield, 1e18 ); } function testLiquidate_Revert_If_Overcollateralized_Position(uint256 amount) external { amount = bound(amount, 1e18, accountFunds); vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); // let another user liquidate the previous user position vm.startPrank(externalUser); vm.expectRevert(IAlchemistV3Errors.LiquidationError.selector); alchemist.liquidate(tokenIdFor0xBeef); vm.stopPrank(); } function testLiquidate_Revert_If_Zero_Debt(uint256 amount) external { amount = bound(amount, FIXED_POINT_SCALAR, accountFunds); vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.stopPrank(); // let another user liquidate the previous user position vm.startPrank(externalUser); vm.expectRevert(IAlchemistV3Errors.LiquidationError.selector); alchemist.liquidate(tokenIdFor0xBeef); vm.stopPrank(); } function testEarmarkDebtAndRedeem() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, (amount / 2), address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000); (uint256 deposited, uint256 userDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); assertApproxEqAbs(earmarked, amount / 2, 1); vm.startPrank(address(0xdad)); transmuterLogic.claimRedemption(1); vm.stopPrank(); (deposited, userDebt, earmarked) = alchemist.getCDP(tokenIdFor0xBeef); assertApproxEqAbs(userDebt, 0, 1); assertApproxEqAbs(earmarked, 0, 1); alchemist.poke(tokenIdFor0xBeef); (deposited, userDebt, earmarked) = alchemist.getCDP(tokenIdFor0xBeef); assertApproxEqAbs(userDebt, 0, 1); assertApproxEqAbs(earmarked, 0, 1); uint256 yieldBalance = alchemist.getTotalDeposited(); uint256 borrowable = alchemist.getMaxBorrowable(tokenIdFor0xBeef); assertApproxEqAbs(yieldBalance, 50e18, 1); assertApproxEqAbs(deposited, 50e18, 1); assertApproxEqAbs(borrowable, 50e18 * FIXED_POINT_SCALAR / alchemist.minimumCollateralization(), 1); } function testEarmarkDebtAndRedeemPartial() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, (amount / 2), address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + (5_256_000 / 2)); (uint256 deposited, uint256 userDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); assertApproxEqAbs(earmarked, amount / 4, 1); assertApproxEqAbs(userDebt, amount / 2, 1); alchemist.poke(tokenIdFor0xBeef); // Partial redemption halfway through transmutation period vm.startPrank(address(0xdad)); transmuterLogic.claimRedemption(1); vm.stopPrank(); alchemist.poke(tokenIdFor0xBeef); (deposited, userDebt, earmarked) = alchemist.getCDP(tokenIdFor0xBeef); // User should have half of their previous debt and none earmarked assertApproxEqAbs(userDebt, amount / 4, 1); assertApproxEqAbs(earmarked, 0, 1); uint256 yieldBalance = alchemist.getTotalDeposited(); uint256 borrowable = alchemist.getMaxBorrowable(tokenIdFor0xBeef); assertApproxEqAbs(yieldBalance, 75e18, 1); assertApproxEqAbs(deposited, 75e18, 1); assertApproxEqAbs(borrowable, (75e18 * FIXED_POINT_SCALAR / alchemist.minimumCollateralization()) - 25e18, 1); } function testRedemptionNotTransmuter() external { vm.expectRevert(); alchemist.redeem(20e18); } function testUnauthorizedAlchmistV3PositionNFTMint() external { vm.startPrank(address(0xbeef)); vm.expectRevert(); IAlchemistV3Position(address(alchemistNFT)).mint(address(0xbeef)); vm.stopPrank(); } function testCreateRedemptionAfterRepay() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, (amount / 2), address(0xbeef)); vm.roll(block.number + 1); alchemist.repay(alchemist.convertDebtTokensToYield(amount / 2), tokenIdFor0xBeef); vm.stopPrank(); assertEq(alchemist.totalSyntheticsIssued(), amount / 2); assertEq(alchemist.totalDebt(), 0); // Test that even though there is no active debt, that we can still create a position with the collateral sent to the transmuter. vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); } function testContractSize() external view { // Get size of deployed contract uint256 size = address(alchemist).code.length; // Log the size console.log("Contract size:", size, "bytes"); // Optional: Assert size is under EIP-170 limit (24576 bytes) assertTrue(size <= 24_576, "Contract too large"); } function testAlchemistV3PositionTokenUri() public { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.stopPrank(); // Get the token URI string memory uri = alchemistNFT.tokenURI(tokenIdFor0xBeef); // Verify it starts with the data URI prefix assertEq(AlchemistNFTHelper.slice(uri, 0, 29), "data:application/json;base64,", "URI should start with data:application/json;base64,"); // Extract and decode the JSON content string memory jsonContent = AlchemistNFTHelper.jsonContent(uri); // Verify JSON contains expected fields assertTrue(AlchemistNFTHelper.contains(jsonContent, '"name": "AlchemistV3 Position #1"'), "JSON should contain the name field"); assertTrue(AlchemistNFTHelper.contains(jsonContent, '"description": "Position token for Alchemist V3"'), "JSON should contain the description field"); assertTrue(AlchemistNFTHelper.contains(jsonContent, '"image": "data:image/svg+xml;base64,'), "JSON should contain the image data URI"); // revert if the token does not exist vm.expectRevert(); alchemistNFT.tokenURI(2); } function testAlchemistV3PositionSetMetadataRenderer_PostDeployment() public { uint256 amount = 100e18; // Mint a position so we have a token to test tokenURI against vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.stopPrank(); // Capture the original tokenURI string memory originalUri = alchemistNFT.tokenURI(tokenId); // Deploy a new renderer and swap it in AlchemistV3PositionRenderer newRenderer = new AlchemistV3PositionRenderer(); address originalRenderer = alchemistNFT.metadataRenderer(); vm.prank(alOwner); alchemistNFT.setMetadataRenderer(address(newRenderer)); // Verify the renderer address was updated assertEq(alchemistNFT.metadataRenderer(), address(newRenderer)); assertTrue(alchemistNFT.metadataRenderer() != originalRenderer, "Renderer address should have changed"); // Verify tokenURI still works after renderer swap string memory newUri = alchemistNFT.tokenURI(tokenId); assertEq( AlchemistNFTHelper.slice(newUri, 0, 29), "data:application/json;base64,", "URI should start with data:application/json;base64, after renderer swap" ); // Since both renderers use the same NFTMetadataGenerator, output should match assertEq(keccak256(bytes(newUri)), keccak256(bytes(originalUri)), "URI content should match with same renderer logic"); } function testAlchemistV3PositionSetMetadataRenderer_RevertsForNonAdmin() public { AlchemistV3PositionRenderer newRenderer = new AlchemistV3PositionRenderer(); // Non-admin should revert vm.prank(address(0xbeef)); vm.expectRevert(AlchemistV3Position.CallerNotAdmin.selector); alchemistNFT.setMetadataRenderer(address(newRenderer)); } function testAlchemistV3PositionSetAdmin_RevertsForNonAdmin() public { vm.prank(address(0xbeef)); vm.expectRevert(AlchemistV3Position.CallerNotAdmin.selector); alchemistNFT.setAdmin(address(0xbeef)); } function testAlchemistV3PositionSetAdmin_TransfersAdminRights() public { address newAdmin = address(0xbeef02); vm.prank(alOwner); alchemistNFT.setAdmin(newAdmin); assertEq(alchemistNFT.admin(), newAdmin); // Old admin can no longer set renderer AlchemistV3PositionRenderer newRenderer = new AlchemistV3PositionRenderer(); vm.prank(alOwner); vm.expectRevert(AlchemistV3Position.CallerNotAdmin.selector); alchemistNFT.setMetadataRenderer(address(newRenderer)); // New admin can set renderer vm.prank(newAdmin); alchemistNFT.setMetadataRenderer(address(newRenderer)); assertEq(alchemistNFT.metadataRenderer(), address(newRenderer)); } function testAlchemistV3PositionTokenURI_RevertsWhenRendererNotSet() public { // Set the renderer to address(0) to simulate no renderer vm.prank(alOwner); alchemistNFT.setMetadataRenderer(address(0)); // Mint a token vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); vm.stopPrank(); // tokenURI should revert because no renderer is set vm.expectRevert(AlchemistV3Position.MetadataRendererNotSet.selector); alchemistNFT.tokenURI(tokenId); } function testLiquidate_Undercollateralized_Position_With_Earmarked_Debt_Sufficient_Repayment() external { vm.prank(alOwner); alchemist.setProtocolFee(protocolFee); vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); // Need to start a transmutator deposit, to start earmarking debt vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount); transmuterLogic.createRedemption(mintAmount, anotherExternalUser); vm.stopPrank(); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); // skip to a future block. Lets say 60% of the way through the transmutation period (5_256_000 blocks) vm.roll(block.number + (5_256_000 * 60 / 100)); // Earmarked debt should be 60% of the total debt (uint256 prevCollateral, uint256 prevDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); require(earmarked == prevDebt * 60 / 100, "Earmarked debt should be 60% of the total debt"); // modify yield token price via modifying underlying token supply uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 59 bps or 5.9% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 prevVaultBalance = alchemistFeeVault.totalDeposits(); uint256 credit = earmarked > prevDebt ? prevDebt : earmarked; uint256 creditToYield = alchemist.convertDebtTokensToYield(credit); uint256 protocolFeeInYield = (creditToYield * alchemist.protocolFee() / BPS); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); vm.stopPrank(); uint256 collateralAfterRepayment = prevCollateral - creditToYield - protocolFeeInYield; // Debt after earmarked repayment (in debt tokens) uint256 debtAfterRepayment = prevDebt - earmarked; // Repayment fee is repay-proportional, then sourced all-or-nothing: // pay from account only if fully safe, otherwise pay from fee vault. uint256 collateralAfterRepaymentInDebt = alchemist.convertYieldTokensToDebt(collateralAfterRepayment); uint256 requiredByLowerBoundInDebt = (debtAfterRepayment * alchemist.collateralizationLowerBound() + FIXED_POINT_SCALAR - 1) / FIXED_POINT_SCALAR; uint256 targetRepaymentFeeInYield = assets * repaymentFeeBPS / BPS; uint256 minRequiredPostFeeInDebt = requiredByLowerBoundInDebt + 1; uint256 maxRemovableInDebt = collateralAfterRepaymentInDebt > minRequiredPostFeeInDebt ? collateralAfterRepaymentInDebt - minRequiredPostFeeInDebt : 0; uint256 maxRepaymentFeeInYield = alchemist.convertDebtTokensToYield(maxRemovableInDebt); uint256 expectedFeeInYield = targetRepaymentFeeInYield; uint256 expectedFeeInUnderlying = 0; if (targetRepaymentFeeInYield > maxRepaymentFeeInYield) { expectedFeeInYield = 0; uint256 targetFeeInUnderlying = alchemist.convertYieldTokensToUnderlying(targetRepaymentFeeInYield); expectedFeeInUnderlying = targetFeeInUnderlying > prevVaultBalance ? prevVaultBalance : targetFeeInUnderlying; } // ensure debt is reduced only by the repayment of max earmarked amount vm.assertApproxEqAbs(debt, prevDebt - earmarked, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced only by the repayment of max earmarked amount vm.assertApproxEqAbs( depositedCollateral, prevCollateral - creditToYield - expectedFeeInYield - protocolFeeInYield, minimumDepositOrWithdrawalLoss ); // ensure assets is equal to repayment of max earmarked amount vm.assertApproxEqAbs(assets, alchemist.convertDebtTokensToYield(earmarked), minimumDepositOrWithdrawalLoss); // ensure liquidator fee is correct (i.e.0, since only a repayment is done) vm.assertApproxEqAbs(feeInYield, expectedFeeInYield, 1e18); vm.assertEq(feeInUnderlying, expectedFeeInUnderlying); // liquidator gets correct amount of fee, i.e. 0 _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, alchemist.convertDebtTokensToYield(earmarked) ); vm.assertEq(alchemistFeeVault.totalDeposits(), prevVaultBalance - expectedFeeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + alchemist.convertDebtTokensToYield(earmarked), 1e18 ); } function testLiquidate_with_force_repay_and_successive_account_syncing() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); // modify yield token price via modifying underlying token supply (, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // create a redemption to start earmarking debt vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 1200 bps or 12% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 1200 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); vm.roll(block.number + 5_256_000); // let another user liquidate the previous user position vm.startPrank(externalUser); alchemist.liquidate(tokenIdFor0xBeef); // Syncing succeeeds, no reverts alchemist.poke(tokenIdFor0xBeef); } function test1Liquidate_Undercollateralized_Position_With_Earmarked_Debt_Liquidation_50Percent_Yield_Price_Drop() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); // Need to start a transmutator deposit, to start earmarking debt vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount); transmuterLogic.createRedemption(mintAmount, anotherExternalUser); vm.stopPrank(); // skip to a future block. Lets say 5% of the way through the transmutation period (5_256_000 blocks) // This should result in the account still being undercollateralized, if the liquidation collateralization ratio is 100/95 // Which means the minimum amount of collateral needed to reduce collateral/debt by is ~ > 5% of the collateral vm.roll(block.number + (5_256_000 * 5 / 100)); // Earmarked debt should be 60% of the total debt (, uint256 prevDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); // modify yield token price via modifying underlying token supply uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // decreasing yeild token suppy by 50% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 5000 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); // Get initial values - Capture prevDebt here as well (uint256 prevCollateral, uint256 prevDebtAtLiq, uint256 earmarkedBeforeLiquidation) = alchemist.getCDP(tokenIdFor0xBeef); // Calculate what will happen during force repay // Use fresh earmarkedBeforeLiquidation and prevDebtAtLiq uint256 credit = earmarkedBeforeLiquidation > prevDebtAtLiq ? prevDebtAtLiq : earmarkedBeforeLiquidation; uint256 creditToYield = alchemist.convertDebtTokensToYield(credit); creditToYield = creditToYield > prevCollateral ? prevCollateral : creditToYield; // Protocol fee calculation (will be 0 in this scenario) uint256 targetProtocolFee = (creditToYield * alchemist.protocolFee() / BPS); uint256 collAfterRepayment = prevCollateral - creditToYield; uint256 protocolFeeInYield = targetProtocolFee > collAfterRepayment ? collAfterRepayment : targetProtocolFee; // Calculate repayment fee (need to convert debt to yield for comparison) // Use fresh earmarkedBeforeLiquidation and prevDebtAtLiq uint256 debtAfterRepayment = prevDebtAtLiq - earmarkedBeforeLiquidation; uint256 collAfterProtocolFee = collAfterRepayment - protocolFeeInYield; uint256 debtInYield = alchemist.convertDebtTokensToYield(debtAfterRepayment); uint256 surplus = collAfterProtocolFee > debtInYield ? collAfterProtocolFee - debtInYield : 0; uint256 repaymentFeeInYield = surplus > 0 ? (surplus * repaymentFeeBPS / BPS) : 0; // Collateral after all fees (this is what _doLiquidation will see) uint256 collAfterAllFees = collAfterProtocolFee - repaymentFeeInYield; // Convert to underlying for liquidation calculation uint256 collateralInUnderlyingForLiquidation = alchemist.convertYieldTokensToUnderlying(collAfterAllFees); uint256 alchemistCurrentCollateralization = alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt(); // Calculate liquidation with the correct post-fee collateral (uint256 liquidationAmount, uint256 expectedDebtToBurn, uint256 expectedBaseFee,) = alchemist.calculateLiquidation( collateralInUnderlyingForLiquidation, debtAfterRepayment, alchemist.liquidationTargetCollateralization(), alchemistCurrentCollateralization, alchemist.globalMinimumCollateralization(), liquidatorFeeBPS ); uint256 expectedBaseFeeInYield = alchemist.convertDebtTokensToYield(expectedBaseFee); uint256 outsourcedFee = expectedDebtToBurn * liquidatorFeeBPS / 10_000; // fee from external vault uint256 targetFeeInUnderlying = alchemist.normalizeDebtTokensToUnderlying(outsourcedFee); uint256 vaultBalance = alchemistFeeVault.totalDeposits(); uint256 expectedFeeInUnderlying = vaultBalance > targetFeeInUnderlying ? targetFeeInUnderlying : vaultBalance; uint256 expectedLiquidationAmountInYield = alchemist.convertDebtTokensToYield(liquidationAmount); // Cap by actual available collateral expectedLiquidationAmountInYield = expectedLiquidationAmountInYield > collAfterAllFees ? collAfterAllFees : expectedLiquidationAmountInYield; (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); vm.stopPrank(); // ensure debt is reduced only by the repayment of max earmarked amount vm.assertApproxEqAbs(debt, debtAfterRepayment - expectedDebtToBurn, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced only by the repayment of max earmarked amount vm.assertApproxEqAbs(depositedCollateral, 0, minimumDepositOrWithdrawalLoss); // ensure assets is equal to the entire collateral of the account - any protocol fee vm.assertApproxEqAbs(assets, expectedLiquidationAmountInYield, minimumDepositOrWithdrawalLoss); // ensure liquidator fee is correct (i.e.0, since only a repayment is done) vm.assertApproxEqAbs(feeInYield, expectedBaseFeeInYield, 1e18); vm.assertApproxEqAbs(feeInUnderlying, expectedFeeInUnderlying, 1e18); // liquidator gets correct amount of fee, i.e. (3% of liquidation amount) _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, expectedLiquidationAmountInYield ); vm.assertEq(alchemistFeeVault.totalDeposits(), 10_000 ether - expectedFeeInUnderlying); } /// @notice Regression test: repayment fee must not be stranded when forced repayment /// does not restore health and _doLiquidation proceeds. function testLiquidate_Earmarked_Repayment_Fee_Not_Stranded_When_Liquidation_Continues() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // Ensure global collateralization stays above the minimum for regular liquidations vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); // 0xbeef opens a position at max borrow vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); // Create transmuter redemption to start earmarking debt vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount); transmuterLogic.createRedemption(mintAmount, anotherExternalUser); vm.stopPrank(); // Advance ~5% through the transmutation period so only a small portion is earmarked vm.roll(block.number + (5_256_000 * 5 / 100)); // Verify earmarked debt exists (, uint256 prevDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); require(earmarked > 0, "Must have earmarked debt"); // Apply a large price drop (50%) so forced repayment does NOT restore health uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); uint256 modifiedVaultSupply = (initialVaultSupply * 5000 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // Capture accounting state before liquidation uint256 mytBalanceBefore = IERC20(address(vault)).balanceOf(address(alchemist)); uint256 accountedBefore = alchemist.getTotalDeposited(); uint256 driftBefore = mytBalanceBefore - accountedBefore; // Liquidate (forced repayment insufficient → _doLiquidation runs) vm.startPrank(externalUser); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); vm.stopPrank(); // Capture accounting state after liquidation uint256 mytBalanceAfter = IERC20(address(vault)).balanceOf(address(alchemist)); uint256 accountedAfter = alchemist.getTotalDeposited(); uint256 driftAfter = mytBalanceAfter - accountedAfter; // The delta between actual MYT balance and tracked _mytSharesDeposited must not increase. // If it increased, the repayment fee was deducted from accounting but never transferred out. assertEq(driftAfter, driftBefore, "Repayment fee shares must not be stranded in the contract"); } function testLiquidate_Debt_Exceeds_Collateral_Shortfall_Absorbed_By_Healthy_Account() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // 1. Create a healthy account with no debt, but enough collateral to cover shortfall vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); uint256 tokenIdHealthy = AlchemistNFTHelper.getFirstTokenId(yetAnotherExternalUser, address(alchemistNFT)); (uint256 healthyInitialCollateral, uint256 healthyInitialDebt,) = alchemist.getCDP(tokenIdHealthy); require(healthyInitialDebt == 0); vm.stopPrank(); // 2. Create the undercollateralized account vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdBad = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); // Mint so that debt is just below collateral alchemist.mint(tokenIdBad, alchemist.totalValue(tokenIdBad) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); // 3. Drop price so that account debt > account collateral, but system collateral is still enough (, uint256 badInitialDebt,) = alchemist.getCDP(tokenIdBad); uint256 initialSystemCollateral = alchemist.getTotalUnderlyingValue(); // Drop price so that bad account's collateral is less than its debt, but system collateral is still enough uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // Drop price by 70% uint256 modifiedVaultSupply = (initialVaultSupply * 7000 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); uint256 badCollateralAfterDrop = alchemist.totalValue(tokenIdBad); (uint256 liquidationAmount,,,) = alchemist.calculateLiquidation( badCollateralAfterDrop, badInitialDebt, alchemist.liquidationTargetCollateralization(), alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt(), alchemist.globalMinimumCollateralization(), liquidatorFeeBPS ); // Convert liquidationAmount from debt tokens to underlying tokens for comparison uint256 liquidationAmountInUnderlying = alchemist.normalizeDebtTokensToUnderlying(liquidationAmount); // Confirm test preconditions require(badInitialDebt > badCollateralAfterDrop, "Account debt should exceed collateral after price drop"); require(alchemist.getTotalUnderlyingValue() > liquidationAmountInUnderlying, "System collateral should be enough to cover liquidation"); // health account total value uint256 healthyTotalValueBefore = alchemist.totalValue(tokenIdHealthy); // 4. Liquidate the undercollateralized account vm.startPrank(externalUser); alchemist.liquidate(tokenIdBad); vm.stopPrank(); // healthy account total value uint256 healthyTotalValueAfter = alchemist.totalValue(tokenIdHealthy); uint256 healthyCollateralLoss = healthyTotalValueBefore - healthyTotalValueAfter; // 5. Check that the bad account is fully liquidated (uint256 badFinalCollateral, uint256 badFinalDebt,) = alchemist.getCDP(tokenIdBad); vm.assertApproxEqAbs(badFinalCollateral, 0, minimumDepositOrWithdrawalLoss); vm.assertApproxEqAbs(badFinalDebt, 0, minimumDepositOrWithdrawalLoss); // 6. Check that the healthy account did not lose any collateral vm.assertEq(healthyCollateralLoss, 0); vm.prank(yetAnotherExternalUser); uint256 withdrawn = alchemist.withdraw(healthyInitialCollateral, yetAnotherExternalUser, tokenIdHealthy); vm.assertEq(withdrawn, healthyInitialCollateral); } function test_Liquidate_Undercollateralized_Position_With_Earmarked_Debt_Sufficient_Repayment_With_Protocol_Fee() external { uint256 amount = 200_000e18; // 200,000 yvdai // uint256 protocolFee = 100; // 10% vm.prank(alOwner); alchemist.setProtocolFee(protocolFee); vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount * 2); alchemist.deposit(amount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); // Need to start a transmutator deposit, to start earmarking debt vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount); transmuterLogic.createRedemption(mintAmount, anotherExternalUser); vm.stopPrank(); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); // skip to a future block. Lets say 60% of the way through the transmutation period (5_256_000 blocks) vm.roll(block.number + (5_256_000 * 60 / 100)); // Earmarked debt should be 60% of the total debt (uint256 prevCollateral, uint256 prevDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); require(earmarked == prevDebt * 60 / 100, "Earmarked debt should be 60% of the total debt"); // modify yield token price via modifying underlying token supply uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 59 bps or 5.9% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 credit = earmarked > prevDebt ? prevDebt : earmarked; uint256 creditToYield = alchemist.convertDebtTokensToYield(credit); uint256 protocolFeeInYield = (creditToYield * protocolFee / BPS); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 prevVaultBalance = alchemistFeeVault.totalDeposits(); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); uint256 collateralAfterRepayment = prevCollateral - creditToYield - protocolFeeInYield; // Debt after earmarked repayment (in debt tokens) uint256 debtAfterRepayment = prevDebt - earmarked; // Repayment fee is repay-proportional, then sourced all-or-nothing: // pay from account only if fully safe, otherwise pay from fee vault. uint256 collateralAfterRepaymentInDebt = alchemist.convertYieldTokensToDebt(collateralAfterRepayment); uint256 requiredByLowerBoundInDebt = (debtAfterRepayment * alchemist.collateralizationLowerBound() + FIXED_POINT_SCALAR - 1) / FIXED_POINT_SCALAR; uint256 targetRepaymentFeeInYield = assets * repaymentFeeBPS / BPS; uint256 minRequiredPostFeeInDebt = requiredByLowerBoundInDebt + 1; uint256 maxRemovableInDebt = collateralAfterRepaymentInDebt > minRequiredPostFeeInDebt ? collateralAfterRepaymentInDebt - minRequiredPostFeeInDebt : 0; uint256 maxRepaymentFeeInYield = alchemist.convertDebtTokensToYield(maxRemovableInDebt); uint256 expectedFeeInYield = targetRepaymentFeeInYield; uint256 expectedFeeInUnderlying = 0; if (targetRepaymentFeeInYield > maxRepaymentFeeInYield) { expectedFeeInYield = 0; uint256 targetFeeInUnderlying = alchemist.convertYieldTokensToUnderlying(targetRepaymentFeeInYield); expectedFeeInUnderlying = targetFeeInUnderlying > prevVaultBalance ? prevVaultBalance : targetFeeInUnderlying; } vm.stopPrank(); // ensure debt is reduced only by the repayment of max earmarked amount vm.assertApproxEqAbs(debt, prevDebt - earmarked, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced only by the repayment of max earmarked amount vm.assertApproxEqAbs( depositedCollateral, prevCollateral - alchemist.convertDebtTokensToYield(earmarked) - protocolFeeInYield - expectedFeeInYield, minimumDepositOrWithdrawalLoss ); // ensure assets is equal to repayment of max earmarked amount // vm.assertApproxEqAbs(assets, alchemist.convertDebtTokensToYield(earmarked), minimumDepositOrWithdrawalLoss); // ensure liquidator fee is correct (i.e.0, since only a repayment is done) vm.assertApproxEqAbs(feeInYield, expectedFeeInYield, 1e18); vm.assertEq(feeInUnderlying, expectedFeeInUnderlying); // liquidator gets correct amount of fee, i.e. 0 _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, alchemist.convertDebtTokensToYield(earmarked) ); vm.assertEq(alchemistFeeVault.totalDeposits(), prevVaultBalance - expectedFeeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + alchemist.convertDebtTokensToYield(earmarked), 1e18 ); // check protocolfeereciever received the protocl fee transfer from _forceRepay vm.assertApproxEqAbs(IERC20(address(vault)).balanceOf(address(protocolFeeReceiver)), protocolFeeInYield, 1e18); } function test_Liquidate_Repayment_Clears_Collateral_Balance() external { uint256 amount = 200_000e18; // 200,000 yvdai vm.prank(alOwner); alchemist.setProtocolFee(protocolFee); vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount * 2); alchemist.deposit(amount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); // Need to start a transmutator deposit, to start earmarking debt vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount); transmuterLogic.createRedemption(mintAmount, anotherExternalUser); vm.stopPrank(); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); // skip to a future block. Lets say 100% of the way through the transmutation period (5_256_000 blocks) vm.roll(block.number + 5_256_000); // Earmarked debt should be 100% of the total debt (uint256 prevCollateral, uint256 prevDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); require(earmarked == prevDebt, "Earmarked debt should be 100% of the total debt"); // modify yield token price via modifying underlying token supply uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 1000 bps or 10% while keeping the unederlying supply unchanged // to create the scenario where collateral balance == debt uint256 modifiedVaultSupply = (initialVaultSupply * 1111111111111111111)/1e18; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); vm.assertApproxEqAbs(alchemist.convertYieldTokensToDebt(prevCollateral), prevDebt, minimumDepositOrWithdrawalLoss); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 credit = earmarked > prevDebt ? prevDebt : earmarked; uint256 creditToYield = alchemist.convertDebtTokensToYield(credit); creditToYield = creditToYield > prevCollateral ? prevCollateral : creditToYield; uint256 targetProtocolFee = (creditToYield * protocolFee / BPS); uint256 collAfterRepayment = prevCollateral - creditToYield; uint256 protocolFeeInYield = targetProtocolFee > collAfterRepayment ? collAfterRepayment : targetProtocolFee; uint256 prevVaultBalance = alchemistFeeVault.totalDeposits(); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); (uint256 depositedCollateral, uint256 debt, uint256 updatedEarmarked) = alchemist.getCDP(tokenIdFor0xBeef); require(depositedCollateral == 0, "collateral should be 0"); require(debt == 0, "Debt should be 0"); require(updatedEarmarked == 0, "Updated earmarked should be 0"); // fee from external vault uint256 targetFee = creditToYield * repaymentFeeBPS / BPS; uint256 targetFeeInUnderlying = alchemist.convertYieldTokensToUnderlying(targetFee); uint256 vaultBalance = alchemistFeeVault.totalDeposits(); uint256 expectedFeeInUnderlying = prevVaultBalance > targetFeeInUnderlying ? targetFeeInUnderlying : prevVaultBalance; vm.stopPrank(); // ensure depositedCollateral is reduced by the repayment of max earmarked amount and a protocol fee() // which should be zero) vm.assertApproxEqAbs( depositedCollateral, prevCollateral - creditToYield - protocolFeeInYield, minimumDepositOrWithdrawalLoss ); // ensure assets is equal to repayment of max earmarked amount vm.assertApproxEqAbs(assets, creditToYield, 1e18); // ensure liquidator fee is 0 since collateral balance had been fully cleared in repayment vm.assertEq(feeInYield, 0); // underlying fee should come from external fee vault vm.assertApproxEqAbs(feeInUnderlying, expectedFeeInUnderlying, 1e18); // liquidator gets correct amount of fee, i.e. only the repayment fee in underlying _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, creditToYield ); // ensure fee vault balance is reduced by the expected fee in underlying vm.assertEq(alchemistFeeVault.totalDeposits(), prevVaultBalance - expectedFeeInUnderlying); // transmuter recieves the repayment amount in yield token vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + creditToYield, 1e18 ); // check protocolfeereciever received the protocol fee transfer from _forceRepay. // In this case, protocol fee is zero vm.assertEq(IERC20(address(vault)).balanceOf(address(protocolFeeReceiver)), protocolFeeInYield); } function testLiquidate_with_force_repay_and_insolvent_position() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); // modify yield token price via modifying underlying token supply (, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // create a redemption to start earmarking debt vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 9900 bps or 99% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * (10_000 * FIXED_POINT_SCALAR) / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); vm.roll(block.number + 5_256_000); // let another user liquidate the previous user position vm.startPrank(externalUser); // check that the position is insolvent uint256 totalValue = alchemist.totalValue(tokenIdFor0xBeef); require(totalValue < 1, "Position should be insolvent"); // Share price is forced to zero in this setup, so liquidate should short-circuit and revert. vm.expectRevert(IAlchemistV3Errors.LiquidationError.selector); alchemist.liquidate(tokenIdFor0xBeef); } function testLiquidate_Undercollateralized_Position_With_Earmarked_Debt_Sufficient_Repayment_Clears_Total_Debt() external { vm.prank(alOwner); alchemist.setProtocolFee(protocolFee); vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); // Need to start a transmutator deposit, to start earmarking debt vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount); transmuterLogic.createRedemption(mintAmount, anotherExternalUser); vm.stopPrank(); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); // skip to a future block. Lets say 100% of the way through the transmutation period (5_256_000 blocks) vm.roll(block.number + (5_256_000)); // Earmarked debt should be 100% of the total debt (uint256 prevCollateral, uint256 prevDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); require(earmarked == prevDebt, "Earmarked debt should be 60% of the total debt"); // modify yield token price via modifying underlying token supply uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 59 bps or 5.9% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 prevVaultBalance = alchemistFeeVault.totalDeposits(); uint256 credit = earmarked > prevDebt ? prevDebt : earmarked; uint256 creditToYield = alchemist.convertDebtTokensToYield(credit); uint256 protocolFeeInYield = (creditToYield * protocolFee / BPS); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); uint256 collateralAfterRepayment = prevCollateral - creditToYield - protocolFeeInYield; uint256 debtAfterRepayment = prevDebt - earmarked; uint256 collateralAfterRepaymentInDebt = alchemist.convertYieldTokensToDebt(collateralAfterRepayment); uint256 requiredByLowerBoundInDebt = (debtAfterRepayment * alchemist.collateralizationLowerBound() + FIXED_POINT_SCALAR - 1) / FIXED_POINT_SCALAR; uint256 targetRepaymentFeeInYield = assets * repaymentFeeBPS / BPS; uint256 minRequiredPostFeeInDebt = requiredByLowerBoundInDebt + 1; uint256 maxRemovableInDebt = collateralAfterRepaymentInDebt > minRequiredPostFeeInDebt ? collateralAfterRepaymentInDebt - minRequiredPostFeeInDebt : 0; uint256 maxRepaymentFeeInYield = alchemist.convertDebtTokensToYield(maxRemovableInDebt); uint256 expectedFeeInYield = targetRepaymentFeeInYield; uint256 expectedFeeInUnderlying = 0; if (targetRepaymentFeeInYield > maxRepaymentFeeInYield) { expectedFeeInYield = 0; uint256 targetFeeInUnderlying = alchemist.convertYieldTokensToUnderlying(targetRepaymentFeeInYield); expectedFeeInUnderlying = targetFeeInUnderlying > prevVaultBalance ? prevVaultBalance : targetFeeInUnderlying; } vm.stopPrank(); // ensure debt is reduced only by the repayment of max earmarked amount vm.assertApproxEqAbs(debt, prevDebt - earmarked, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced only by the repayment of max earmarked amount vm.assertApproxEqAbs( depositedCollateral, prevCollateral - alchemist.convertDebtTokensToYield(earmarked) - expectedFeeInYield - protocolFeeInYield, minimumDepositOrWithdrawalLoss ); // ensure assets is equal to repayment of max earmarked amount // vm.assertApproxEqAbs(assets, alchemist.convertDebtTokensToYield(earmarked), minimumDepositOrWithdrawalLoss); // ensure liquidator fee is correct (i.e. only repayment fee, since only a repayment is done) vm.assertApproxEqAbs(feeInYield, expectedFeeInYield, 1e18); vm.assertEq(feeInUnderlying, expectedFeeInUnderlying); // liquidator gets correct amount of fee, i.e. repayment fee > 0 _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, alchemist.convertDebtTokensToYield(earmarked) ); vm.assertEq(alchemistFeeVault.totalDeposits(), prevVaultBalance - expectedFeeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + alchemist.convertDebtTokensToYield(earmarked), 1e18 ); } function testSelfLiquidate_Healthy_Account_Succeeds() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // Setup: deposit and mint at max LTV (account is healthy but at minimum collateralization) vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); (uint256 prevCollateral, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); uint256 recipientPreviousBalance = IERC20(address(vault)).balanceOf(address(0xbeef)); // Account is healthy (at minimum collateralization), selfLiquidate should succeed address recipient = address(0xbeef); uint256 amountLiquidated = alchemist.selfLiquidate(tokenIdFor0xBeef, recipient); vm.stopPrank(); // Verify account is cleared (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); vm.assertEq(debt, 0, "Debt should be cleared"); vm.assertEq(depositedCollateral, 0, "Collateral should be cleared"); // Verify transmuter received the debt repayment collateral uint256 expectedDebtInYield = alchemist.convertDebtTokensToYield(prevDebt); vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + expectedDebtInYield, minimumDepositOrWithdrawalLoss, "Transmuter should receive debt collateral" ); // Verify recipient received remaining collateral uint256 expectedRemainingCollateral = prevCollateral - expectedDebtInYield; vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(recipient), recipientPreviousBalance + expectedRemainingCollateral, minimumDepositOrWithdrawalLoss, "Recipient should receive remaining collateral" ); // Verify return value vm.assertApproxEqAbs(amountLiquidated, expectedDebtInYield, minimumDepositOrWithdrawalLoss, "Return value should match debt in yield"); } function testSelfLiquidate_Revert_If_Unhealthy_Account() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // Ensure global collateralization stays healthy vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); // Setup: deposit and mint at max LTV vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); // Manipulate yield token price to make account undercollateralized // Increasing yield token supply by 5.9% while keeping underlying supply unchanged _manipulateYieldTokenPrice(590); // Account is now unhealthy, selfLiquidate should revert vm.startPrank(address(0xbeef)); vm.expectRevert(IAlchemistV3Errors.AccountNotHealthy.selector); alchemist.selfLiquidate(tokenIdFor0xBeef, address(0xbeef)); vm.stopPrank(); } function testSelfLiquidate_With_Partial_Earmarked_Debt() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // Setup: deposit and mint at max LTV vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); // Setup earmarked debt: create a redemption in transmuter vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount); transmuterLogic.createRedemption(mintAmount, anotherExternalUser); vm.stopPrank(); // Roll forward ~50% of the transmutation period to get partial earmarking vm.roll(block.number + (5_256_000 / 2)); (uint256 prevCollateral, uint256 prevDebt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); require(earmarked > 0 && earmarked < prevDebt, "Should have partial earmarked debt"); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); uint256 recipientPreviousBalance = IERC20(address(vault)).balanceOf(address(0xbeef)); vm.prank(address(transmuterLogic)); alchemist.setTransmuterTokenBalance(transmuterPreviousBalance); // Self liquidate with partial earmarked debt vm.startPrank(address(0xbeef)); uint256 amountLiquidated = alchemist.selfLiquidate(tokenIdFor0xBeef, address(0xbeef)); vm.stopPrank(); // Verify account is fully cleared (uint256 depositedCollateral, uint256 debt, uint256 finalEarmarked) = alchemist.getCDP(tokenIdFor0xBeef); vm.assertEq(debt, 0, "Debt should be cleared"); vm.assertEq(depositedCollateral, 0, "Collateral should be cleared"); vm.assertEq(finalEarmarked, 0, "Earmarked should be cleared"); // Verify transmuter received all debt repayment collateral (earmarked + remaining) uint256 expectedTotalDebtInYield = alchemist.convertDebtTokensToYield(prevDebt); vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + expectedTotalDebtInYield, minimumDepositOrWithdrawalLoss * 2, "Transmuter should receive all debt collateral" ); uint256 expectedEarmarkedInYield = alchemist.convertDebtTokensToYield(earmarked); vm.assertApproxEqAbs( alchemist.lastTransmuterTokenBalance(), transmuterPreviousBalance + expectedEarmarkedInYield, minimumDepositOrWithdrawalLoss * 2, "Only the earmarked self-liquidation transfer should sync the baseline" ); assertGt( IERC20(address(vault)).balanceOf(address(transmuterLogic)), alchemist.lastTransmuterTokenBalance(), "The unearmarked self-liquidation transfer should remain available as cover" ); // Verify recipient received remaining collateral uint256 expectedRemainingCollateral = prevCollateral - expectedTotalDebtInYield; vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(0xbeef)), recipientPreviousBalance + expectedRemainingCollateral, minimumDepositOrWithdrawalLoss * 2, "Recipient should receive remaining collateral" ); } function testSelfLiquidate_Revert_If_No_Debt() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // Setup: deposit only, no minting (no debt) vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); (uint256 prevCollateral, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); require(prevDebt == 0, "Debt should be zero"); require(prevCollateral > 0, "Collateral should exist"); // Self liquidate with no debt should revert with IllegalState vm.expectRevert(IllegalState.selector); alchemist.selfLiquidate(tokenIdFor0xBeef, address(0xbeef)); vm.stopPrank(); } function testSelfLiquidate_Revert_If_Not_Owner() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // Setup: 0xbeef creates an account vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount + 100e18); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); // Try to selfLiquidate as a different user (not the owner) vm.startPrank(externalUser); vm.expectRevert(IAlchemistV3Errors.UnauthorizedAccountAccessError.selector); alchemist.selfLiquidate(tokenIdFor0xBeef, externalUser); vm.stopPrank(); } function testBatch_Liquidate_Undercollateralized_Position() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); AccountPosition memory position1 = _setAccountPosition(address(0xbeef), depositAmount, true, minimumCollateralization); AccountPosition memory position2 = _setAccountPosition(anotherExternalUser, depositAmount, true, minimumCollateralization); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); _setAccountPosition(yetAnotherExternalUser, depositAmount, false, minimumCollateralization); _manipulateYieldTokenPrice(590); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(externalUser); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(externalUser); // Batch Liquidation for 2 user addresses uint256[] memory accountsToLiquidate = new uint256[](2); accountsToLiquidate[0] = position1.tokenId; accountsToLiquidate[1] = position2.tokenId; // get expected liquidation results for each account CalculateLiquidationResult memory expectedResult1 = _calculateLiquidationForAccount(position1); CalculateLiquidationResult memory expectedResult2 = _calculateLiquidationForAccount(position2); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.batchLiquidate(accountsToLiquidate); vm.stopPrank(); /// Tests for first liquidated User /// _validateLiquidatedAccountState( position1.tokenId, position1.collateral, position1.debt, expectedResult1.debtToBurn, expectedResult1.liquidationAmountInYield ); /// Tests for second liquidated User /// _validateLiquidatedAccountState( position2.tokenId, position2.collateral, position2.debt, expectedResult2.debtToBurn, expectedResult2.liquidationAmountInYield ); // Tests for Liquidator /// _valudateLiquidationFees( feeInYield, feeInUnderlying, expectedResult1.baseFeeInYield + expectedResult2.baseFeeInYield, expectedResult1.outSourcedFee + expectedResult2.outSourcedFee ); // liquidator gets correct amount of fee _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, expectedResult1.liquidationAmountInYield + expectedResult2.liquidationAmountInYield ); vm.assertEq(alchemistFeeVault.totalDeposits(), 10_000 ether - feeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + expectedResult1.liquidationAmountInYield + expectedResult2.liquidationAmountInYield - expectedResult1.baseFeeInYield - expectedResult2.baseFeeInYield, 1e18 ); } function testBatch_Liquidate_Undercollateralized_Position_And_Skip_Healthy_Position() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); AccountPosition memory position1 = _setAccountPosition(address(0xbeef), depositAmount, true, minimumCollateralization); AccountPosition memory position2 = _setAccountPosition(anotherExternalUser, depositAmount, true, 15e17); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything _setAccountPosition(yetAnotherExternalUser, depositAmount, false, minimumCollateralization); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); _manipulateYieldTokenPrice(590); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(externalUser); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(externalUser); // Batch Liquidation for 2 user addresses uint256[] memory accountsToLiquidate = new uint256[](2); accountsToLiquidate[0] = position1.tokenId; accountsToLiquidate[1] = position2.tokenId; CalculateLiquidationResult memory expectedResult1 = _calculateLiquidationForAccount(position1); // CalculateLiquidationResult memory expectedResult2 = _calculateLiquidationForAccount(position2); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.batchLiquidate(accountsToLiquidate); vm.stopPrank(); /// Tests for first liquidated User /// // ensure debt is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio _validateLiquidatedAccountState( position1.tokenId, position1.collateral, position1.debt, expectedResult1.debtToBurn, expectedResult1.liquidationAmountInYield ); /// Tests for second liquidated User /// _validateLiquidatedAccountState(position2.tokenId, position2.collateral, position2.debt, 0, 0); // Tests for Liquidator /// // ensure liquidator fee is correct (3% of liquidation amount) _valudateLiquidationFees(feeInYield, feeInUnderlying, expectedResult1.baseFeeInYield, expectedResult1.outSourcedFee); // liquidator gets correct amount of fee _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, expectedResult1.liquidationAmountInYield ); vm.assertEq(alchemistFeeVault.totalDeposits(), 10_000 ether - feeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + expectedResult1.liquidationAmountInYield - expectedResult1.baseFeeInYield, 1e18 ); } function testBatch_Liquidate_Undercollateralized_Position_And_Skip_Zero_Ids() external { vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); AccountPosition memory position1 = _setAccountPosition(address(0xbeef), depositAmount, true, minimumCollateralization); AccountPosition memory position2 = _setAccountPosition(anotherExternalUser, depositAmount, true, minimumCollateralization); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything _setAccountPosition(yetAnotherExternalUser, depositAmount, false, minimumCollateralization); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); _manipulateYieldTokenPrice(590); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(externalUser); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(externalUser); // Batch Liquidation for 2 user addresses uint256[] memory accountsToLiquidate = new uint256[](3); accountsToLiquidate[0] = position1.tokenId; accountsToLiquidate[1] = 0; // invalid zero ids accountsToLiquidate[2] = position2.tokenId; // Calculate liquidation amount for 0xBeef CalculateLiquidationResult memory expectedResult1 = _calculateLiquidationForAccount(position1); CalculateLiquidationResult memory expectedResult2 = _calculateLiquidationForAccount(position2); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.batchLiquidate(accountsToLiquidate); vm.stopPrank(); /// Tests for first liquidated User /// _validateLiquidatedAccountState( position1.tokenId, position1.collateral, position1.debt, expectedResult1.debtToBurn, expectedResult1.liquidationAmountInYield ); /// Tests for second liquidated User /// _validateLiquidatedAccountState( position2.tokenId, position2.collateral, position2.debt, expectedResult2.debtToBurn, expectedResult2.liquidationAmountInYield ); // Tests for Liquidator /// // ensure liquidator fee is correct (3% of liquidation amount) _valudateLiquidationFees( feeInYield, feeInUnderlying, expectedResult1.baseFeeInYield + expectedResult2.baseFeeInYield, expectedResult1.outSourcedFee + expectedResult2.outSourcedFee ); // liquidator gets correct amount of fee _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, expectedResult1.liquidationAmountInYield + expectedResult2.liquidationAmountInYield ); vm.assertEq(alchemistFeeVault.totalDeposits(), 10_000 ether - feeInUnderlying); // transmuter recieves the liquidation amount in yield token minus the fee vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + expectedResult1.liquidationAmountInYield + expectedResult2.liquidationAmountInYield - expectedResult1.baseFeeInYield - expectedResult2.baseFeeInYield, 1e18 ); } function testBatch_Liquidate_Revert_If_Overcollateralized_Position(uint256 amount) external { amount = bound(amount, 1e18, accountFunds); vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, anotherExternalUser, 0); // a single position nft would have been minted to anotherExternalUser uint256 tokenIdForExternalUser = AlchemistNFTHelper.getFirstTokenId(anotherExternalUser, address(alchemistNFT)); alchemist.mint( tokenIdForExternalUser, alchemist.totalValue(tokenIdForExternalUser) * FIXED_POINT_SCALAR / minimumCollateralization, anotherExternalUser ); vm.stopPrank(); // let another user liquidate the previous user position vm.startPrank(externalUser); vm.expectRevert(IAlchemistV3Errors.LiquidationError.selector); // Batch Liquidation for 2 user addresses uint256[] memory accountsToLiquidate = new uint256[](2); accountsToLiquidate[0] = tokenIdFor0xBeef; accountsToLiquidate[1] = tokenIdForExternalUser; alchemist.batchLiquidate(accountsToLiquidate); vm.stopPrank(); } function testBatch_Liquidate_Revert_If_Missing_Data(uint256 amount) external { amount = bound(amount, 1e18, accountFunds); vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, anotherExternalUser, 0); // a single position nft would have been minted to anotherExternalUser uint256 tokenIdForExternalUser = AlchemistNFTHelper.getFirstTokenId(anotherExternalUser, address(alchemistNFT)); alchemist.mint( tokenIdForExternalUser, alchemist.totalValue(tokenIdForExternalUser) * FIXED_POINT_SCALAR / minimumCollateralization, anotherExternalUser ); vm.stopPrank(); // let another user batch liquidate with an empty array vm.startPrank(externalUser); vm.expectRevert(MissingInputData.selector); // Batch Liquidation for empty array uint256[] memory accountsToLiquidate = new uint256[](0); alchemist.batchLiquidate(accountsToLiquidate); vm.stopPrank(); } function _calculateLiquidationForAccount(AccountPosition memory position) internal view returns (CalculateLiquidationResult memory result) { uint256 alchemistCurrentCollateralization = alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt(); (uint256 liquidationAmount, uint256 debtToBurn, uint256 baseFee, uint256 outSourcedFee) = alchemist.calculateLiquidation( alchemist.totalValue(position.tokenId), position.debt, alchemist.liquidationTargetCollateralization(), alchemistCurrentCollateralization, alchemist.globalMinimumCollateralization(), liquidatorFeeBPS ); uint256 liquidationAmountInYield = alchemist.convertDebtTokensToYield(liquidationAmount); uint256 baseFeeInYield = alchemist.convertDebtTokensToYield(baseFee); result = CalculateLiquidationResult({ liquidationAmountInYield: liquidationAmountInYield, debtToBurn: debtToBurn, outSourcedFee: outSourcedFee, baseFeeInYield: baseFeeInYield }); return result; } /// helper functions to simplify batch liquidation tests function _manipulateYieldTokenPrice(uint256 tokenySupplyBPSIncrease) internal { uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 59 bps or 5.9% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * tokenySupplyBPSIncrease / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); } function _setAccountPosition(address user, uint256 deposit, bool doMint, uint256 ltv) internal returns (AccountPosition memory) { vm.startPrank(user); SafeERC20.safeApprove(address(vault), address(alchemist), deposit + 100e18); alchemist.deposit(deposit, user, 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(alchemistNFT)); if (doMint) { // default max mint alchemist.mint(tokenId, alchemist.totalValue(tokenId) * FIXED_POINT_SCALAR / ltv, user); } (uint256 collateral, uint256 debt,) = alchemist.getCDP(tokenId); AccountPosition memory position = AccountPosition({user: user, collateral: collateral, debt: debt, tokenId: tokenId}); vm.stopPrank(); return position; } function _valudateLiquidationFees(uint256 feeInYield, uint256 feeInUnderlying, uint256 expectedFeeInYield, uint256 expectedFeeInUnderlying) internal pure { // ensure liquidator fee is correct (3% of liquidation amount) vm.assertApproxEqAbs(feeInYield, expectedFeeInYield, 1e18); vm.assertEq(feeInUnderlying, expectedFeeInUnderlying); } function _validateLiquidatedAccountState( uint256 tokenId, uint256 prevCollateral, uint256 prevDebt, uint256 expectedDebtToBurn, uint256 expectedLiquidationAmountInYield ) internal view { (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenId); // ensure debt is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(debt, prevDebt - expectedDebtToBurn, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(depositedCollateral, prevCollateral - expectedLiquidationAmountInYield, minimumDepositOrWithdrawalLoss); } function _validateLiquidiatorState( address user, uint256 prevTokenBalance, uint256 prevUnderlyingBalance, uint256 feeInYield, uint256 feeInUnderlying, uint256 assets, uint256 exepctedLiquidationTotalAmountInYield ) internal view { uint256 liquidatorPostTokenBalance = IERC20(address(vault)).balanceOf(user); uint256 liquidatorPostUnderlyingBalance = IERC20(vault.asset()).balanceOf(user); vm.assertApproxEqAbs(liquidatorPostTokenBalance, prevTokenBalance + feeInYield, 1e18); vm.assertApproxEqAbs(liquidatorPostUnderlyingBalance, prevUnderlyingBalance + feeInUnderlying, 1e18); vm.assertApproxEqAbs(assets, exepctedLiquidationTotalAmountInYield, minimumDepositOrWithdrawalLoss); } function testPoc_Invariant_TotalDebt_Vs_CumulativeEarmark_Broken_After_FullRepay() external { uint256 debtAmountToMint = 50e18; // 0xbeef mints 50 alToken uint256 transmuterRedemptionAmount = 30e18; // 0xdad creates redemption for 30 alToken vm.startPrank(address(0xbeef)); uint256 yieldToDeposit = 100e18; uint256 yieldToRepayFullDebt = alchemist.convertDebtTokensToYield(debtAmountToMint); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); // Approve for alchemist.deposit(100e18, address(0xbeef), 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, debtAmountToMint, address(0xbeef)); vm.stopPrank(); assertEq(alchemist.totalDebt(), debtAmountToMint, "Initial total debt mismatch"); uint256 initialCumulativeEarmarked = alchemist.cumulativeEarmarked(); // Should be 0 if no prior activity // --- Setup: 0xdad creates redemption in Transmuter --- deal(address(alToken), address(0xdad), transmuterRedemptionAmount); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), type(uint256).max); transmuterLogic.createRedemption(transmuterRedemptionAmount, address(0xdad)); vm.stopPrank(); // --- Advance time to allow earmarking --- vm.roll(block.number + 100); // Advance some blocks // --- 0xbeef fully repays debt --- vm.startPrank(address(0xbeef)); uint256 preRepayBalance = vault.balanceOf(address(0xbeef)); alchemist.repay(yieldToRepayFullDebt, tokenId); vm.stopPrank(); vm.roll(block.number + 1); alchemist.poke(tokenId); } function test_poc_badDebtRatioIncreaseFasterAtClaimRedemption() external { uint256 amount = 200_000e18; // 200,000 yvdai vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); // 0xbeef transfer some synthetic to 0xdad uint256 amountToRedeem = 100_000e18; uint256 amountToRedeem2 = 10_000e18; alToken.transfer(address(0xdad), amountToRedeem + amountToRedeem2); vm.stopPrank(); // 0xdad create redemption, here we create multiple redemptions to test the poc vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), amountToRedeem + amountToRedeem2); transmuterLogic.createRedemption(amountToRedeem, address(0xdad)); transmuterLogic.createRedemption(amountToRedeem2, address(0xdad)); vm.stopPrank(); // lets full mature the redemption vm.roll(block.number + (5_256_000) + 1); // create global system bad debt // modify yield token price via modifying underlying token supply (uint256 prevCollateral, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 12% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 1200 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); for (uint256 i = 1; i <= 2; i++) { console.log("[*] redemption no: ", i); // calculate bad debt ratio uint256 currentBadDebt = alchemist.totalSyntheticsIssued() * 10 ** TokenUtils.expectDecimals(alchemist.myt()) / alchemist.getTotalUnderlyingValue(); console.log("current bad debt ratio before redemption: ", currentBadDebt); // 0xdad claim redemption vm.startPrank(address(0xdad)); transmuterLogic.claimRedemption(i); vm.stopPrank(); // calculate bad debt ratio currentBadDebt = alchemist.totalSyntheticsIssued() * 10 ** TokenUtils.expectDecimals(alchemist.myt()) / alchemist.getTotalUnderlyingValue(); console.log("current bad debt ratio after redemption: ", currentBadDebt); } } function testClaimRdemtionNotDebtTokensburned() external { //@audit medium 12 vm.prank(alOwner); // 1% alchemist.setProtocolFee(100); uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, (amount / 2), address(0xbeef)); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(address(0xbeef)), (amount / 2), minimumDepositOrWithdrawalLoss); vm.stopPrank(); vm.startPrank(externalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, externalUser, 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdForExternalUser = AlchemistNFTHelper.getFirstTokenId(externalUser, address(alchemistNFT)); alchemist.mint(tokenIdForExternalUser, (amount / 2), externalUser); vm.assertApproxEqAbs(IERC20(alToken).balanceOf(externalUser), (amount / 2), minimumDepositOrWithdrawalLoss); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000 / 2); uint256 synctectiAssetBefore = alchemist.totalSyntheticsIssued(); vm.startPrank(address(0xdad)); vault.transfer(address(transmuterLogic), amount); transmuterLogic.claimRedemption(1); vm.stopPrank(); uint256 synctectiAssetAfter = alchemist.totalSyntheticsIssued(); assertEq(synctectiAssetBefore - (25e18), synctectiAssetAfter); } function testCrashDueToWeightIncrementCheck() external { bytes memory expectedError = "WeightIncrement: increment > total"; // 1. Create a position uint256 amount = 100e18; address user = address(0xbeef); vm.startPrank(user); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(amount, user, 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(alchemistNFT)); uint256 borrowedAmount = amount / 2; // Arbitrary, can be fuzzed over. alchemist.mint(tokenId, borrowedAmount, user); vm.stopPrank(); // 2. Create a redemption // This populates the queryGraph with values. // After timeToTransmute has passed, the amount to pull with earmarking vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), borrowedAmount); transmuterLogic.createRedemption(borrowedAmount, address(0xdad)); vm.stopPrank(); // 3. Repay any amount. // This sends yield tokens to the transmuter and reduces total debt. // It does not affect what is in the queryGraph. vm.startPrank(user); vm.roll(block.number + 1); alchemist.repay(1, tokenId); vm.stopPrank(); // 4. Let the claim mature. vm.roll(block.number + 5_256_000); vm.startPrank(address(0xdad)); transmuterLogic.claimRedemption(1); vm.stopPrank(); // All regular Alchemist operations still succeed vm.startPrank(address(0xbeef)); alchemist.poke(tokenId); alchemist.withdraw(1, user, tokenId); alchemist.mint(tokenId, 1, user); vm.roll(block.number + 1); alchemist.repay(1, tokenId); vm.stopPrank(); alchemist.getCDP(tokenId); } function testDebtMintingRedemptionWithdraw() external { uint256 amount = 100e18; address debtor = address(0xbeef); address redeemer = address(0xdad); // Mint debt tokens vm.startPrank(debtor); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, debtor, 0); uint256 tokenId = 1; uint256 maxBorrowable = alchemist.getMaxBorrowable(tokenId); alchemist.mint(tokenId, maxBorrowable, debtor); vm.stopPrank(); // Create Redemption vm.startPrank(redeemer); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), maxBorrowable); transmuterLogic.createRedemption(maxBorrowable, redeemer); vm.stopPrank(); // Advance time to complete redemption vm.roll(block.number + 5_256_000); // Claim Redemption vm.startPrank(redeemer); transmuterLogic.claimRedemption(1); vm.stopPrank(); // Check debt has been reduced to zero (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, 0, 1); assertApproxEqAbs(earmarked, 0, 1); // Attempt to withdraw remaining collateral assertTrue(collateral > 0); console.log(collateral); vm.prank(debtor); alchemist.withdraw(collateral, debtor, tokenId); } function testIncrease_minimumCollateralization_DOS_Redemption() external { //set fee to 10% to compensate for wrong deduction of _totalLocked in `redeem()` vm.startPrank(alOwner); alchemist.setProtocolFee(1000); uint256 minimumCollateralizationBefore = alchemist.minimumCollateralization(); console.log("minimumCollateralization before", minimumCollateralizationBefore); //deposit some tokens vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 100e18); alchemist.deposit(100e18, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); //mint some alTokens alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); //skip a block to be able to repay vm.roll(block.number + 1); //admit increase minimumCollateralization vm.startPrank(alOwner); alchemist.setGlobalMinimumCollateralization(uint256(FIXED_POINT_SCALAR * FIXED_POINT_SCALAR) / 88e16); alchemist.setMinimumCollateralization(uint256(FIXED_POINT_SCALAR * FIXED_POINT_SCALAR) / 88e16); // 88% collateralization uint256 minimumCollateralizationAfter = alchemist.minimumCollateralization(); assertGt(minimumCollateralizationAfter, minimumCollateralizationBefore, "minimumCollateralization should be increased"); console.log("minimumCollateralization after", minimumCollateralizationAfter); //try to repay vm.startPrank(address(0xbeef)); uint256 alTokenBalanceBeef = alToken.balanceOf(address(0xbeef)); //give alowance to alchemist to burn SafeERC20.safeApprove(address(alToken), address(alchemist), alTokenBalanceBeef / 2); alchemist.burn(alTokenBalanceBeef / 2, tokenIdFor0xBeef); //create a redemption request for 50% of the alToken balance vm.startPrank(address(0xbeef)); //give alowance to transmuter to burn alToken.approve(address(transmuterLogic), alTokenBalanceBeef / 2); transmuterLogic.createRedemption(alTokenBalanceBeef / 2, address(0xbeef)); //make sure redemption can be claimed in full vm.roll(block.number + 6_256_000); transmuterLogic.claimRedemption(1); } /// TODO: Fix this test, might need to exepct a revert /* function testDepositCanBeDoSed() external { // Initial setup - deposit and borrow uint256 depositAmount = 1000e18; uint256 borrowAmount = 900e18; //Malicious user directly transfering token address attacker = makeAddr("attacker"); uint256 depositCap = alchemist.depositCap(); deal(address(vault), attacker, depositCap); vm.prank(attacker); vault.transfer(address(alchemist), depositCap); // User makes a deposit and borrows vm.startPrank(address(0xbeef)); // deal(address(vault), address(0xbeef), depositAmount); _magicDepositToVault(address(vault), address(0xbeef), depositAmount); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, address(0xbeef), 0); vm.stopPrank(); } */ function test_Burn() external { uint256 depositAmount = 1000e18; // Each user deposits 1,000 uint256 mintAmount = 500e18; // Each user mints 500 uint256 repayAmount = 500e18; // User2 repays 500 uint256 redemptionAmount = 500e18; // User3 creates redemption for 500 uint256 burnAmount = 400e18; // User1 tries to burn 400 // Step 1: User1 deposits and mints console.log("Step 1: User1 deposits and mints"); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdForUser1 = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdForUser1, mintAmount, address(0xbeef)); vm.stopPrank(); // Step 2: User2 deposits and mints console.log("Step 2: User2 deposits and mints"); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount); alchemist.deposit(depositAmount, address(0xdad), 0); uint256 tokenIdForUser2 = AlchemistNFTHelper.getFirstTokenId(address(0xdad), address(alchemistNFT)); alchemist.mint(tokenIdForUser2, mintAmount, address(0xdad)); vm.stopPrank(); // Step 3: User2 repays all debts console.log("Step 3: User2 repays all debts"); vm.roll(block.number + 1000); // Simulate time passing vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(vault), address(alchemist), repayAmount); alchemist.repay(repayAmount, tokenIdForUser2); vm.stopPrank(); // Step 4: User3 creates redemption // Now transmuter has enough yield tokens to cover the redemption console.log("Step 4: User3 creates redemption"); vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), redemptionAmount); transmuterLogic.createRedemption(redemptionAmount, anotherExternalUser); vm.stopPrank(); // Step 5: User1 tries to burn his debt // This should succeed because transmuter has enough yield tokens to cover the redemption, // However it fails console.log("Step 5: User1 tries to burn his debt"); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(alToken), address(alchemist), burnAmount); alchemist.burn(burnAmount, tokenIdForUser1); vm.stopPrank(); } function testBDR_price_drop() external { uint256 amount = 1e18; address debtor = address(0xbeef); address alice = address(0xdad); vm.startPrank(address(someWhale)); IMockYieldToken(mockStrategyYieldToken).mint(amount, address(someWhale)); vm.stopPrank(); // Mint debt tokens to debtor vm.startPrank(debtor); SafeERC20.safeApprove(address(vault), address(alchemist), amount * 2); alchemist.deposit(amount, debtor, 0); uint256 tokenDebtor = 1; uint256 maxBorrowable = alchemist.getMaxBorrowable(tokenDebtor); alchemist.mint(tokenDebtor, maxBorrowable, debtor); vm.stopPrank(); (, uint256 debt,) = alchemist.getCDP(tokenDebtor); // Create Redemption vm.startPrank(alice); uint256 redemption = debt / 2; SafeERC20.safeApprove(address(alToken), address(transmuterLogic), amount); transmuterLogic.createRedemption(redemption, alice); uint256 aliceId = 1; vm.stopPrank(); address admin = transmuterLogic.admin(); vm.startPrank(admin); transmuterLogic.setTransmutationFee(0); vm.stopPrank(); // Advance time to complete redemption vm.roll(block.number + 5_256_000); // Mimick bad debt IMockYieldToken(mockStrategyYieldToken).siphon(5e17); // Check balances after claim uint256 alchemistYTBefore = vault.balanceOf(address(alchemist)); vm.startPrank(alice); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), amount); transmuterLogic.claimRedemption(aliceId); vm.stopPrank(); uint256 alchemistYTAfter = vault.balanceOf(address(alchemist)); // Since half of debt has been transmuted then half of collateral should be taken despite the price drop // If price drops then 4.5e17 debt tokens would need more collateral to be fulfilled // Bad debt ratio of 1.2 makes the redeemed amount equal to 3.75e17 instead // Increase in collateral needed from price drop is offset with adjusted redemption amount // Half of collateral is redeemed alongside half of debt // assertEq(alchemistYTAfter, amount / 2); assertEq(alchemistYTAfter, 549_999_775_000_112_500); } function testClaimRedemptionRoundUp() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), 99_999e18); alchemist.deposit(amount, address(0xbeef), 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, 80e18, address(0xbeef)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 9999e18); for (uint256 i = 1; i < 4; i++) { transmuterLogic.createRedemption(1e18, address(0xbeef)); } vm.roll(block.number + 1); for (uint256 i = 1; i < 4; i++) { transmuterLogic.claimRedemption(i); } vm.stopPrank(); } function testRepayWithEarmarkedDebt_MultiplePoke_Broken() external { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, (amount / 2), address(0xbeef)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), 50e18); transmuterLogic.createRedemption(50e18, address(0xbeef)); vm.stopPrank(); vm.roll(block.number + 1); alchemist.poke(tokenId); vm.roll(block.number + 5_256_000); vm.prank(address(0xbeef)); alchemist.repay(25e18, tokenId); } function testLiquidate_WrongTokenTransfer() external { uint256 amount = 200_000e18; // 200,000 yvdai vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); // just ensureing global alchemist collateralization stays above the minimum required for regular // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount * 2); alchemist.deposit(amount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); // modify yield token price via modifying underlying token supply (uint256 prevCollateral, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 59 bps or 5.9% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // ensure initial debt is correct vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 alchemistCurrentCollateralization = alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt(); (uint256 liquidationAmount, uint256 expectedDebtToBurn, uint256 expectedBaseFee, uint256 outsourcedFee) = alchemist.calculateLiquidation( alchemist.totalValue(tokenIdFor0xBeef), prevDebt, alchemist.liquidationTargetCollateralization(), alchemistCurrentCollateralization, alchemist.globalMinimumCollateralization(), liquidatorFeeBPS ); uint256 expectedLiquidationAmountInYield = alchemist.convertDebtTokensToYield(liquidationAmount); uint256 expectedBaseFeeInYield = alchemist.convertDebtTokensToYield(expectedBaseFee); uint256 expectedFeeInUnderlying = expectedDebtToBurn * liquidatorFeeBPS / 10_000; uint256 transmuterBefore = vault.balanceOf(address(transmuter)); console.log("transmuterBefore", transmuterBefore); (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); uint256 liquidatorPostTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPostUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); uint256 transmuterAfter = vault.balanceOf(address(transmuter)); console.log("transmuterAfter", transmuterAfter); assertEq(transmuterBefore, transmuterAfter); vm.stopPrank(); // ensure debt is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(debt, prevDebt - expectedDebtToBurn, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced by the result of (collateral - y)/(debt - y) = minimum collateral vm.assertApproxEqAbs(depositedCollateral, prevCollateral - expectedLiquidationAmountInYield, minimumDepositOrWithdrawalLoss); // ensure assets is equal to liquidation amount i.e. y in (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(assets, expectedLiquidationAmountInYield, minimumDepositOrWithdrawalLoss); // ensure liquidator fee is correct (3% of liquidation amount) vm.assertApproxEqAbs(feeInYield, expectedBaseFeeInYield, 1e18); // liquidator gets correct amount of fee vm.assertApproxEqAbs(liquidatorPostTokenBalance, liquidatorPrevTokenBalance + feeInYield, 1e18); vm.assertEq(liquidatorPostUnderlyingBalance, liquidatorPrevUnderlyingBalance + feeInUnderlying); vm.assertEq(alchemistFeeVault.totalDeposits(), 10_000 ether - feeInUnderlying); } function testRepayWithDifferentPrice() external { uint256 depositAmount = 100e18; uint256 debtAmount = depositAmount / 2; uint256 initialFund = depositAmount * 2; address alice = makeAddr("alice"); vm.startPrank(alice); // alice has 200 ETH of yield token deal(address(vault.asset()), alice, initialFund); TokenUtils.safeApprove(address(vault.asset()), address(vault), initialFund); uint256 shares = IVaultV2(vault).deposit(initialFund, alice); // alice deposits 100 ETH to Alchemix SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount); alchemist.deposit(depositAmount, address(alice), 0); // alice mints 50 ETH of debt token uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(alice, address(alchemistNFT)); alchemist.mint(tokenId, debtAmount, alice); // forward block number so that alice can repay vm.roll(block.number + 1); // yield token price increased a little in the meantime uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); uint256 modifiedVaultSupply = initialVaultSupply - (initialVaultSupply * 590 / 10_000); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // alice fully repays her debt TokenUtils.safeApprove(address(vault), address(alchemist), debtAmount); alchemist.repay(debtAmount, tokenId); // verify all debt are cleared (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertEq(debt, 0, "debt == 0"); assertEq(earmarked, 0, "earmarked == 0"); assertEq(collateral, depositAmount, "depositAmount == collateral"); alchemist.withdraw(collateral, alice, tokenId); vm.stopPrank(); } function test_Poc_claimRedemption_error() external { uint256 amount = 200_000e18; // 200,000 yvdai vm.startPrank(someWhale); IMockYieldToken(mockStrategyYieldToken).mint(whaleSupply, someWhale); vm.stopPrank(); //////////////////////////////////////////////// // yetAnotherExternalUser deposits 200_000e18 // //////////////////////////////////////////////// vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), amount); alchemist.deposit(amount, yetAnotherExternalUser, 0); vm.stopPrank(); //////////////////////////////// // 0xbeef deposits 200_000e18 // //////////////////////////////// vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 mintAmount = alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization; //////////////////////////// // 0xbeef mints debtToken // //////////////////////////// alchemist.mint(tokenIdFor0xBeef, mintAmount, address(0xbeef)); vm.stopPrank(); (, uint256 debt,) = alchemist.getCDP(tokenIdFor0xBeef); // check assertEq(debt, mintAmount); assertEq(alchemist.totalDebt(), mintAmount); // Need to start a transmutator deposit, to start earmarking debt vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), mintAmount); transmuterLogic.createRedemption(mintAmount, anotherExternalUser); vm.stopPrank(); vm.roll(block.number + (5_256_000)); // modify yield token price via modifying underlying token supply uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 59 bps or 5.9% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 590 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); //////////////////////////////// // liquidate tokenIdFor0xBeef // //////////////////////////////// // let another user liquidate the previous user position vm.startPrank(externalUser); alchemist.liquidate(tokenIdFor0xBeef); vm.stopPrank(); console.log("IERC20(alchemist.myt()).balanceOf(address(transmuterLogic)):", IERC20(alchemist.myt()).balanceOf(address(transmuterLogic))); /////////////////////////////// // claimRedemption() success // /////////////////////////////// vm.startPrank(anotherExternalUser); transmuterLogic.claimRedemption(1); vm.stopPrank(); } function testRedeemTwiceBetweenSync() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xbeef), 0); uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, 8500e18, address(0xbeef)); alchemist.mint(tokenIdFor0xBeef, 1000e18, address(0xaaaa)); alchemist.mint(tokenIdFor0xBeef, 500e18, address(0xbbbb)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), type(uint256).max); transmuterLogic.createRedemption(3500e18, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xaaaa)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), type(uint256).max); transmuterLogic.createRedemption(1000e18, address(0xaaaa)); vm.stopPrank(); vm.startPrank(address(0xbbbb)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), type(uint256).max); transmuterLogic.createRedemption(500e18, address(0xbbbb)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xdad), 0); uint256 tokenIdFor0xdad = AlchemistNFTHelper.getFirstTokenId(address(0xdad), address(alchemistNFT)); alchemist.mint(tokenIdFor0xdad, 100e18, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000 * 2 / 5); alchemist.poke(tokenIdFor0xdad); alchemist.poke(tokenIdFor0xBeef); (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xdad); (uint256 collateralBeef, uint256 debtBeef, uint256 earmarkedBeef) = alchemist.getCDP(tokenIdFor0xBeef); // The first redemption vm.startPrank(address(0xaaaa)); transmuterLogic.claimRedemption(2); vm.stopPrank(); vm.roll(block.number + 5_256_000 / 10); // The second redemption vm.startPrank(address(0xbbbb)); transmuterLogic.claimRedemption(3); vm.stopPrank(); alchemist.poke(tokenIdFor0xdad); alchemist.poke(tokenIdFor0xBeef); (collateral, debt, earmarked) = alchemist.getCDP(tokenIdFor0xdad); (collateralBeef, debtBeef, earmarkedBeef) = alchemist.getCDP(tokenIdFor0xBeef); assertApproxEqAbs(earmarked + earmarkedBeef, alchemist.cumulativeEarmarked(), 2); assertApproxEqAbs(debt + debtBeef, alchemist.totalDebt(), 2); } function testRedeemTwiceBetweenSyncUnredeemedFirst() external { // This test fails because we do not have proper handling of redemptions that fully consume available earmark vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xbeef), 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, 10_000e18, address(0xbeef)); vm.stopPrank(); deal(address(alToken), address(0xdad), 10_000e18); vm.startPrank(address(0xdad)); IERC20(alToken).approve(address(transmuterLogic), 4000e18); // Create redemption for 1_000 alUSD and claim transmuterLogic.createRedemption(100e18, address(0xdad)); transmuterLogic.createRedemption(1000e18, address(0xdad)); vm.roll(vm.getBlockNumber() + 5_256_000); transmuterLogic.claimRedemption(2); // Create redemption for 1_000 alUSD transmuterLogic.createRedemption(1000e18, address(0xdad)); vm.roll(vm.getBlockNumber() + 5_256_000 / 2); // Create another redemption for 1_000 alUSD after passing half period transmuterLogic.createRedemption(1000e18, address(0xdad)); vm.roll(vm.getBlockNumber() + 5_256_000 / 2); // Claim the second redemption transmuterLogic.claimRedemption(3); vm.stopPrank(); (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, 10_000e18 - 2000e18, 1); assertApproxEqAbs(earmarked, 600e18, 1); } function testAudit_RedemptionWeight() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xbeef), 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, 10_000e18, address(0xbeef)); vm.stopPrank(); deal(address(alToken), address(0xdad), 10_000e18); vm.startPrank(address(0xdad)); IERC20(alToken).approve(address(transmuterLogic), 3000e18); // Create redemption for 1_000 alUSD and claim transmuterLogic.createRedemption(1000e18, address(0xdad)); vm.roll(vm.getBlockNumber() + 5_256_000); transmuterLogic.claimRedemption(1); // Create redemption for 1_000 alUSD transmuterLogic.createRedemption(1000e18, address(0xdad)); vm.roll(vm.getBlockNumber() + 5_256_000 / 2); // Create another redemption for 1_000 alUSD after passing half period transmuterLogic.createRedemption(1000e18, address(0xdad)); vm.roll(vm.getBlockNumber() + 5_256_000 / 2); // Claim the second redemption transmuterLogic.claimRedemption(2); vm.stopPrank(); (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, 10_000e18 - 2000e18, 1); assertApproxEqAbs(earmarked, 500e18, 1); } function test_getTotalDeposited_FailsToDeliver() public { uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xbeef), 0); console.log("alchemist.getTotalDeposited()", alchemist.getTotalDeposited()); // transfer some tokens to break the correct getTotalDeposited deal(address(vault), address(0xbeef), 1e18); SafeERC20.safeTransfer(address(vault), address(alchemist), 1e18); assertEq(alchemist.getTotalDeposited(), amount); } function test_Regression_StaleAccountAcrossEpochsThenRedeem_TracksGlobalDebt() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xbeef), 0); uint256 beefId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(beefId, 10_000e18, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xdad), 0); uint256 dadId = AlchemistNFTHelper.getFirstTokenId(address(0xdad), address(alchemistNFT)); alchemist.mint(dadId, 1_000e18, address(0xdad)); vm.stopPrank(); // Force full earmark on every _earmark() call to drive epoch transitions quickly. vm.mockCall(address(transmuterLogic), abi.encodeWithSelector(ITransmuter.queryGraph.selector), abi.encode(type(uint256).max)); // Epoch +1 while beef account remains unsynced/stale. vm.roll(block.number + 1); alchemist.poke(dadId); // Create fresh unearmarked debt after first epoch. vm.roll(block.number + 1); vm.prank(address(0xdad)); alchemist.mint(dadId, 1_000e18, address(0xdad)); // Epoch +1 again (beef is now stale across multiple earmark epochs). vm.roll(block.number + 1); alchemist.poke(dadId); uint256 debtBefore = alchemist.totalDebt(); assertGt(debtBefore, 0); uint256 redeemAmount = debtBefore / 3; assertGt(redeemAmount, 0); vm.prank(address(transmuterLogic)); uint256 redeemedShares = alchemist.redeem(redeemAmount); assertGt(redeemedShares, 0); (, uint256 beefDebt, uint256 beefEarmarked) = alchemist.getCDP(beefId); (, uint256 dadDebt, uint256 dadEarmarked) = alchemist.getCDP(dadId); uint256 sumDebt = beefDebt + dadDebt; uint256 sumEarmarked = beefEarmarked + dadEarmarked; uint256 totalDebt = alchemist.totalDebt(); uint256 cumEarmarked = alchemist.cumulativeEarmarked(); // Rounding noise can exist, but drift must remain tiny and bounded. assertApproxEqAbs(sumDebt, totalDebt, 10); assertApproxEqAbs(sumEarmarked, cumEarmarked, 10); assertLe(cumEarmarked, totalDebt); assertLe(sumEarmarked, sumDebt); } function test_Regression_StaleAccountSingleEpochThenRedeem_TracksGlobalDebt() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xbeef), 0); uint256 beefId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(beefId, 10_000e18, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xdad), 0); uint256 dadId = AlchemistNFTHelper.getFirstTokenId(address(0xdad), address(alchemistNFT)); alchemist.mint(dadId, 1_000e18, address(0xdad)); vm.stopPrank(); vm.mockCall(address(transmuterLogic), abi.encodeWithSelector(ITransmuter.queryGraph.selector), abi.encode(type(uint256).max)); // Exactly one stale epoch for beef account. vm.roll(block.number + 1); alchemist.poke(dadId); uint256 debtBefore = alchemist.totalDebt(); assertGt(debtBefore, 0); uint256 redeemAmount = debtBefore / 3; assertGt(redeemAmount, 0); vm.prank(address(transmuterLogic)); uint256 redeemedShares = alchemist.redeem(redeemAmount); assertGt(redeemedShares, 0); (, uint256 beefDebt, uint256 beefEarmarked) = alchemist.getCDP(beefId); (, uint256 dadDebt, uint256 dadEarmarked) = alchemist.getCDP(dadId); uint256 sumDebt = beefDebt + dadDebt; uint256 sumEarmarked = beefEarmarked + dadEarmarked; uint256 totalDebt = alchemist.totalDebt(); uint256 cumEarmarked = alchemist.cumulativeEarmarked(); assertApproxEqAbs(sumDebt, totalDebt, 10); assertApproxEqAbs(sumEarmarked, cumEarmarked, 10); assertLe(cumEarmarked, totalDebt); assertLe(sumEarmarked, sumDebt); } function test_Regression_StaleAccountSingleEpochWithPreBoundaryRedemption_TracksGlobalDebt() external { vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xbeef), 0); uint256 beefId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(beefId, 10_000e18, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xdad), 0); uint256 dadId = AlchemistNFTHelper.getFirstTokenId(address(0xdad), address(alchemistNFT)); alchemist.mint(dadId, 1_000e18, address(0xdad)); vm.stopPrank(); // Step 1: partial earmark (no epoch advance yet). vm.mockCall(address(transmuterLogic), abi.encodeWithSelector(ITransmuter.queryGraph.selector), abi.encode(1_000e18)); vm.roll(block.number + 1); alchemist.poke(dadId); // Redemption happens before stale account crosses into the next earmark epoch. vm.prank(address(transmuterLogic)); alchemist.redeem(500e18); // Step 2: force full earmark to cross exactly one epoch for stale beef account. vm.mockCall(address(transmuterLogic), abi.encodeWithSelector(ITransmuter.queryGraph.selector), abi.encode(type(uint256).max)); vm.roll(block.number + 1); alchemist.poke(dadId); uint256 debtBefore = alchemist.totalDebt(); assertGt(debtBefore, 0); vm.prank(address(transmuterLogic)); uint256 redeemedShares = alchemist.redeem(debtBefore / 4); assertGt(redeemedShares, 0); (, uint256 beefDebt, uint256 beefEarmarked) = alchemist.getCDP(beefId); (, uint256 dadDebt, uint256 dadEarmarked) = alchemist.getCDP(dadId); uint256 sumDebt = beefDebt + dadDebt; uint256 sumEarmarked = beefEarmarked + dadEarmarked; uint256 totalDebt = alchemist.totalDebt(); uint256 cumEarmarked = alchemist.cumulativeEarmarked(); assertApproxEqAbs(sumDebt, totalDebt, 10); assertApproxEqAbs(sumEarmarked, cumEarmarked, 10); assertLe(cumEarmarked, totalDebt); assertLe(sumEarmarked, sumDebt); } function test_Regression_InvariantReplay_SecondClaimTracksGlobals() external { _applyHardenedInvariantEconomicParams(); address[] memory actors = _makeInvariantReplayActors(); _prepareInvariantReplayActors(actors); HardenedInvariantHandler handler = new HardenedInvariantHandler( alchemist, transmuterLogic, alchemistNFT, alToken, IVaultV2(address(vault)), mockVaultCollateral, mockStrategyYieldToken, actors ); // Exact 8-call shrink from cache/invariant/failures/HardenedInvariantsTest/invariantStorageDebtConsistency. handler.depositCollateral(50656196122083666101050802168726, 2307710199772021343146531995881073408591); handler.borrowCollateral( 6898614042340823311443164314616101103946273311309345106222312434892064, 10342624683033276849786914209664490721770558159855212392 ); handler.transmuterStake( 115792089237316195423570985008687907853269984665640564039457584007913129639934, 73354180014973063332737046566626065127071582521983296318296535285 ); handler.transmuterClaim(99999000000000000000000); handler.borrowCollateral( 28070098845813904317817776102929512538107600590, 1947135037254001479810614350013618995486761510397454874914886440968 ); handler.transmuterStake(25e18, 90e18); handler.transmuterStake(8779635673169312679884516201897249313134705047968610450959861163, 89280301420392); handler.transmuterClaim(112713400); uint256 active; uint256 sumCollateral; uint256 sumDebt; uint256 sumEarmarked; for (uint256 i = 0; i < actors.length; ++i) { uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(actors[i], address(alchemistNFT)); if (tokenId != 0) { alchemist.poke(tokenId); } } for (uint256 i = 0; i < actors.length; ++i) { uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(actors[i], address(alchemistNFT)); if (tokenId == 0) continue; (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); active++; sumCollateral += collateral; sumDebt += debt; sumEarmarked += earmarked; } assertEq(active, 1, "expected one live replay account"); uint256 totalDebt = alchemist.totalDebt(); uint256 cumulativeEarmarked = alchemist.cumulativeEarmarked(); uint256 totalDeposited = alchemist.getTotalDeposited(); assertEq(sumDebt, totalDebt, "replay debt drift"); assertEq(sumEarmarked, cumulativeEarmarked, "replay earmark drift"); assertEq(sumCollateral, totalDeposited, "replay collateral drift"); } struct ReplayState { uint256 collateral; uint256 debt; uint256 earmarked; uint256 totalDebt; uint256 cumulativeEarmarked; } struct PrecisionAggregate { uint256 sumCollateral; uint256 sumDebt; uint256 sumEarmarked; uint256 totalDebt; uint256 cumulativeEarmarked; } /// @dev Cross-check stale sync math across many epoch crossings: /// path A syncs the account every cycle; path B leaves it stale and syncs once at the end. /// Results should match up to tiny rounding noise. function test_Regression_StaleAccountManyEpochsMatchesReplayModel() external { uint256 root = vm.snapshotState(); _assertStaleEpochReplayEquivalence(5); vm.revertTo(root); _assertStaleEpochReplayEquivalence(20); vm.revertTo(root); _assertStaleEpochReplayEquivalence(100); } /// @dev Precision fairness check: /// Compare one large position vs many split positions under the same global /// earmark/redeem path. Aggregate outcomes should match up to dust. function test_Regression_PrecisionFairness_SplitVsUnsplit() external { uint256 root = vm.snapshotState(); uint256 comparedDeposit = 120_000e18; uint256 comparedDebt = 30_000e18; uint256 controlDeposit = 10_000e18; uint256 controlDebt = 1_000e18; uint256 steps = 180; uint256 earmarkBps = 2_500; // 25% of live unearmarked each step uint256 redeemBps = 2_000; // 20% of live earmarked each step // Path A: one large position. uint256[] memory unsplitIds = new uint256[](1); unsplitIds[0] = _openPrecisionPosition(address(0xbeef), comparedDeposit, comparedDebt); uint256 unsplitControlId = _openPrecisionPosition(anotherExternalUser, controlDeposit, controlDebt); _runPrecisionFairnessStress(unsplitIds, unsplitControlId, steps, earmarkBps, redeemBps); PrecisionAggregate memory unsplit = _capturePrecisionAggregate(unsplitIds); vm.revertTo(root); // Path B: split the same exposure across 4 accounts. address[] memory splitUsers = new address[](4); splitUsers[0] = address(0xbeef); splitUsers[1] = address(0xdad); splitUsers[2] = externalUser; splitUsers[3] = yetAnotherExternalUser; uint256[] memory splitIds = new uint256[](4); uint256 perDeposit = comparedDeposit / splitUsers.length; uint256 perDebt = comparedDebt / splitUsers.length; for (uint256 i = 0; i < splitUsers.length; ++i) { splitIds[i] = _openPrecisionPosition(splitUsers[i], perDeposit, perDebt); } uint256 splitControlId = _openPrecisionPosition(anotherExternalUser, controlDeposit, controlDebt); _runPrecisionFairnessStress(splitIds, splitControlId, steps, earmarkBps, redeemBps); PrecisionAggregate memory split = _capturePrecisionAggregate(splitIds); // Global process should be identical between both paths. assertEq(split.totalDebt, unsplit.totalDebt, "global totalDebt mismatch"); assertEq(split.cumulativeEarmarked, unsplit.cumulativeEarmarked, "global cumulativeEarmarked mismatch"); // Partitioning should not materially change aggregate outcomes. uint256 tol = steps * 300 + 5_000; assertApproxEqAbs(split.sumDebt, unsplit.sumDebt, tol, "split-vs-unsplit debt mismatch"); assertApproxEqAbs(split.sumEarmarked, unsplit.sumEarmarked, tol, "split-vs-unsplit earmarked mismatch"); assertApproxEqAbs(split.sumCollateral, unsplit.sumCollateral, tol, "split-vs-unsplit collateral mismatch"); } function _applyHardenedInvariantEconomicParams() internal { vm.startPrank(alOwner); alchemist.setProtocolFee(25); alchemist.setLiquidatorFee(300); alchemist.setRepaymentFee(0); alchemist.setMinimumCollateralization(1_111_111_111_111_111_111); alchemist.setCollateralizationLowerBound(1_052_631_578_950_000_000); alchemist.setLiquidationTargetCollateralization(1_111_111_111_111_111_111); transmuterLogic.setProtocolFeeReceiver(protocolFeeReceiver); transmuterLogic.setTransmutationFee(0); transmuterLogic.setExitFee(100); transmuterLogic.setTransmutationTime(604_800); vm.stopPrank(); } function _makeInvariantReplayActors() internal returns (address[] memory actors) { actors = new address[](8); actors[0] = makeAddr("Sender1"); actors[1] = makeAddr("Sender2"); actors[2] = makeAddr("Sender3"); actors[3] = makeAddr("Sender4"); actors[4] = makeAddr("Sender5"); actors[5] = makeAddr("Sender6"); actors[6] = makeAddr("Sender7"); actors[7] = makeAddr("Sender8"); } function _prepareInvariantReplayActors(address[] memory actors) internal { for (uint256 i = 0; i < actors.length; ++i) { vm.prank(address(0xdead)); alToken.setWhitelist(actors[i], true); vm.startPrank(actors[i]); TokenUtils.safeApprove(address(alToken), address(alchemist), type(uint256).max); TokenUtils.safeApprove(address(alchemist.myt()), address(alchemist), type(uint256).max); vm.stopPrank(); } } function _assertStaleEpochReplayEquivalence(uint256 staleEpochs) internal { // Base fixture: one target account (beef) + one control account (dad). vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xbeef), 0); uint256 beefId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(beefId, 10_000e18, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(100_000e18, address(0xdad), 0); uint256 dadId = AlchemistNFTHelper.getFirstTokenId(address(0xdad), address(alchemistNFT)); alchemist.mint(dadId, 1_000e18, address(0xdad)); vm.stopPrank(); // Force full earmark in each _earmark() call, which drives epoch advancement aggressively. vm.mockCall(address(transmuterLogic), abi.encodeWithSelector(ITransmuter.queryGraph.selector), abi.encode(type(uint256).max)); // Keep transmuter balance baseline synced so redeemed transfers are not re-counted as cover. address mytToken = alchemist.myt(); uint256 transmuterMytBalance = IERC20(mytToken).balanceOf(address(transmuterLogic)); vm.prank(address(transmuterLogic)); alchemist.setTransmuterTokenBalance(transmuterMytBalance); uint256 snap = vm.snapshotState(); // Path A: replay model (sync beef each cycle). for (uint256 i = 0; i < staleEpochs; ++i) { if (!_stepEpochAndRedeemThenSync(beefId)) break; } ReplayState memory replay = _captureReplayState(beefId); vm.revertTo(snap); // Path B: stale model (sync dad each cycle, sync beef once at the end). for (uint256 i = 0; i < staleEpochs; ++i) { if (!_stepEpochAndRedeemThenSync(dadId)) break; } // Same block as the last step sync call => no extra _earmark window opened. alchemist.poke(beefId); ReplayState memory stale = _captureReplayState(beefId); // Global state should be identical between paths. assertEq(stale.totalDebt, replay.totalDebt, "global totalDebt mismatch"); assertEq(stale.cumulativeEarmarked, replay.cumulativeEarmarked, "global cumulativeEarmarked mismatch"); // Per-account state can differ by tiny rounding drift only. uint256 tol = staleEpochs * 10 + 100; assertApproxEqAbs(stale.debt, replay.debt, tol, "debt mismatch beyond rounding"); assertApproxEqAbs(stale.earmarked, replay.earmarked, tol, "earmarked mismatch beyond rounding"); assertApproxEqAbs(stale.collateral, replay.collateral, tol, "collateral mismatch beyond rounding"); } function _stepEpochAndRedeemThenSync(uint256 syncTokenId) internal returns (bool) { // Move to a new block so _earmark can process this window. vm.roll(block.number + 1); // Keep transmuter baseline aligned before the next _earmark(). address mytToken = alchemist.myt(); uint256 transmuterMytBalance = IERC20(mytToken).balanceOf(address(transmuterLogic)); vm.prank(address(transmuterLogic)); alchemist.setTransmuterTokenBalance(transmuterMytBalance); uint256 totalDebtBefore = alchemist.totalDebt(); if (totalDebtBefore == 0) return false; // Redeem a small deterministic fraction each cycle so debt survives many cycles. uint256 redeemAmount = totalDebtBefore / 20; if (redeemAmount == 0) redeemAmount = totalDebtBefore; vm.prank(address(transmuterLogic)); alchemist.redeem(redeemAmount); // Sync selected account in the SAME block; poke's _earmark is block-gated and no-ops. alchemist.poke(syncTokenId); return true; } function _captureReplayState(uint256 tokenId) internal view returns (ReplayState memory s) { (s.collateral, s.debt, s.earmarked) = alchemist.getCDP(tokenId); s.totalDebt = alchemist.totalDebt(); s.cumulativeEarmarked = alchemist.cumulativeEarmarked(); } function _openPrecisionPosition(address user, uint256 depositAmount_, uint256 debtAmount_) internal returns (uint256 tokenId) { vm.startPrank(user); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(depositAmount_, user, 0); tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(alchemistNFT)); if (debtAmount_ != 0) { alchemist.mint(tokenId, debtAmount_, user); } vm.stopPrank(); } function _runPrecisionFairnessStress( uint256[] memory tokenIds, uint256 controlTokenId, uint256 steps, uint256 earmarkBps, uint256 redeemBps ) internal { for (uint256 i = 0; i < steps; ++i) { uint256 totalDebtBefore = alchemist.totalDebt(); if (totalDebtBefore == 0) break; vm.roll(block.number + 1); // Keep transmuter balance baseline synced so redeemed transfers are not re-counted as cover. address mytToken = alchemist.myt(); uint256 transmuterMytBalance = IERC20(mytToken).balanceOf(address(transmuterLogic)); vm.prank(address(transmuterLogic)); alchemist.setTransmuterTokenBalance(transmuterMytBalance); uint256 liveUnearmarked = totalDebtBefore - alchemist.cumulativeEarmarked(); if (liveUnearmarked == 0) break; uint256 earmarkAmount = liveUnearmarked * earmarkBps / BPS; if (earmarkAmount == 0) earmarkAmount = 1; vm.mockCall(address(transmuterLogic), abi.encodeWithSelector(ITransmuter.queryGraph.selector), abi.encode(earmarkAmount)); // Commit this block's earmark using a dedicated control account. alchemist.poke(controlTokenId); uint256 liveEarmarked = alchemist.cumulativeEarmarked(); if (liveEarmarked != 0) { uint256 redeemAmount = liveEarmarked * redeemBps / BPS; if (redeemAmount == 0) redeemAmount = 1; vm.prank(address(transmuterLogic)); alchemist.redeem(redeemAmount); } for (uint256 j = 0; j < tokenIds.length; ++j) { alchemist.poke(tokenIds[j]); } } } function _capturePrecisionAggregate(uint256[] memory tokenIds) internal view returns (PrecisionAggregate memory s) { for (uint256 i = 0; i < tokenIds.length; ++i) { (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenIds[i]); s.sumCollateral += collateral; s.sumDebt += debt; s.sumEarmarked += earmarked; } s.totalDebt = alchemist.totalDebt(); s.cumulativeEarmarked = alchemist.cumulativeEarmarked(); } function test_QueryGraphBug_ConsecutiveBlocksUnderearmarksCausesRedemptionLoss() external { uint256 depositAmount = 10_000_000e18; uint256 borrowAmount = 5_256_000e18; // whale borrows 5_256_000e18 vm.startPrank(someWhale); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount); alchemist.deposit(depositAmount, someWhale, 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(someWhale, address(alchemistNFT)); alchemist.mint(tokenId, borrowAmount, someWhale); vm.stopPrank(); uint256 totalDebt = alchemist.totalDebt(); assertEq(totalDebt, borrowAmount, "Total debt should be 5_256_000e18"); // Create transmuter redemption for full debt amount vm.startPrank(someWhale); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), borrowAmount); transmuterLogic.createRedemption(borrowAmount, someWhale); vm.stopPrank(); uint256 startEarmarkBlock = block.number + 1; vm.roll(startEarmarkBlock + 9); alchemist.poke(tokenId); uint256 earmarkedStep1 = alchemist.cumulativeEarmarked(); // Each block 1e18 debt is earmarked // From startEarmarkBlock to startEarmarkBlock + 9, there are 10 blocks // Therefore, the total earmarked should be 10e18 assertEq(earmarkedStep1, 10e18, "Earmarked should be 10e18"); vm.roll(startEarmarkBlock + 10); alchemist.poke(tokenId); uint256 earmarkedStep2 = alchemist.cumulativeEarmarked(); assertEq(earmarkedStep2, 11e18, "Earmarked should not be the same"); // Full redemption vm.roll(startEarmarkBlock + 5_256_000 + 10); uint256 mytTokenBefore = IERC20(alchemist.myt()).balanceOf(address(alchemist)); vm.prank(someWhale); transmuterLogic.claimRedemption(tokenId); uint256 mytTokenAfter = IERC20(alchemist.myt()).balanceOf(address(alchemist)); uint256 mytTokenRedeemed = mytTokenBefore - mytTokenAfter; assertEq(mytTokenRedeemed, borrowAmount, "Myt token should be redeemed"); } function test_excessAlAssetFromCappedRedeemDoesNotReturnedToUser() public { // 1. create position uint256 amount = 100e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(amount, address(0xbeef), 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 borrowedAmount = alchemist.getMaxBorrowable(tokenId); alchemist.mint(tokenId, borrowedAmount, address(0xbeef)); vm.stopPrank(); // 2. redemption using the same amount vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), borrowedAmount); transmuterLogic.createRedemption(borrowedAmount, address(0xdad)); vm.stopPrank(); // 3. maturing 2/3 transmute amount vm.roll(block.number + 5_256_000 * 2 / 3); // 4. 0xbeef repay debt the position vm.prank(address(0xbeef)); alchemist.repay(borrowedAmount, tokenId); // 5. simulate price drop console.log("price", IMockYieldToken(mockStrategyYieldToken).price()); deal(address(IMockYieldToken(mockStrategyYieldToken).underlyingToken()), address(mockStrategyYieldToken), amount / 2); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(amount); console.log("price", IMockYieldToken(mockStrategyYieldToken).price()); // 6. Redeem. uint256 dadAlAssetBalBefore = alToken.balanceOf(address(0xdad)); uint256 dadMYTBalBefore = vault.balanceOf(address(0xdad)); address feeReceiver = transmuterLogic.protocolFeeReceiver(); uint256 feeReceiverAlAssetBalBefore = alToken.balanceOf(feeReceiver); uint256 feeReceiverMYTBalBefore = vault.balanceOf(feeReceiver); vm.prank(address(0xdad)); transmuterLogic.claimRedemption(1); // 7. get how many alAsset and MYT 0xdad receive back, and the one get sent to protocolFeeReceiver uint256 dadAlAssetBalAfter = alToken.balanceOf(address(0xdad)); uint256 dadMYTBalAfter = vault.balanceOf(address(0xdad)); uint256 feeReceiverAlAssetBalAfter = alToken.balanceOf(feeReceiver); uint256 feeReceiverMYTBalAfter = vault.balanceOf(feeReceiver); uint256 alAssetReturned = (dadAlAssetBalAfter - dadAlAssetBalBefore) + (feeReceiverAlAssetBalAfter - feeReceiverAlAssetBalBefore); uint256 mytOut = (dadMYTBalAfter - dadMYTBalBefore) + (feeReceiverMYTBalAfter - feeReceiverMYTBalBefore); // 8. compare mytOut with actual alAsset that get burned, by converting it to current conversion to yield // we can get this by removing alAssetReturned from total amount that is used when creating position, which is borrowedAmount uint256 alAssetBurnedInYield = alchemist.convertDebtTokensToYield(borrowedAmount - alAssetReturned); assertApproxEqAbs(mytOut, alAssetBurnedInYield / 2, 1e18); } function test_underflowOnSync() external { uint256 amount = 100e18; uint256 mintAmount = 89e18; vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(amount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); // mint for 0xdad so we only use the minted amount of alAsset alchemist.mint(tokenIdFor0xBeef, (mintAmount), address(0xdad)); vm.stopPrank(); vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), type(uint256).max); // create two redemption position transmuterLogic.createRedemption(mintAmount/2, address(0xdad)); transmuterLogic.createRedemption(mintAmount/2, address(0xdad)); vm.stopPrank(); // maturing the redemption and maxing the earmarked vm.roll(block.number + 5_256_000); (,, uint256 earmarked) = alchemist.getCDP(tokenIdFor0xBeef); assertApproxEqAbs(earmarked, mintAmount, 1); // yield price drop console.log("price", IMockYieldToken(mockStrategyYieldToken).price()); deal(address(IMockYieldToken(mockStrategyYieldToken).underlyingToken()), address(mockStrategyYieldToken), amount / 2); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(amount); console.log("price", IMockYieldToken(mockStrategyYieldToken).price()); // claim the first redemption position vm.prank(address(0xdad)); transmuterLogic.claimRedemption(1); // poke to update the account.rawLocked, this should be bigger than before // before it is 9.88e19 but because of yield price drop, by poke() the account.rawLocked recalculated = 1.422e20 alchemist.poke(tokenIdFor0xBeef); // claim second redemption position, this would increase the collateral weight vm.prank(address(0xdad)); transmuterLogic.claimRedemption(2); // yield price back to normal x 2 // console.log("price", IMockYieldToken(mockStrategyYieldToken).price()); // deal(address(IMockYieldToken(mockStrategyYieldToken).underlyingToken()), address(mockStrategyYieldToken), amount * 2); // IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(amount); // console.log("price", IMockYieldToken(mockStrategyYieldToken).price()); // when user wants to invoke anything that include _sync it would underflow // because account.collateralBalance < collateralToRemove vm.prank(address(0xbeef)); alchemist.repay(100e18, tokenIdFor0xBeef); } function testPOC_AccountCanEndUpInUnliquidatableState() external { console.log("\n=== POC: UnLiqudatable Account Test ===\n"); vm.prank(alOwner); alchemist.setProtocolFee(protocolFee); uint256 depositAmount = 1000e18; //981920193698630136722 // Step 1: deposits and borrows maximum debt vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount); //vault.transfer(address(alchemist), 600); alchemist.deposit(depositAmount, address(0xbeef), 0); uint256 tokenIdAttacker = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); // Borrow maximum (collateral / minimumCollateralization) uint256 maxBorrowable = alchemist.getMaxBorrowable(tokenIdAttacker); alchemist.mint(tokenIdAttacker, maxBorrowable, address(0xbeef)); console.log("Step 1: Initial Position"); console.log(" Collateral:", depositAmount); console.log(" Debt borrowed:", maxBorrowable); console.log(" Initial collateralization:", depositAmount * FIXED_POINT_SCALAR / maxBorrowable / 1e16, "%"); console.log(" Collateral value (underlying):", alchemist.totalValue(tokenIdAttacker)); vm.stopPrank(); vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount); alchemist.deposit(depositAmount, anotherExternalUser, 0); vm.stopPrank(); // Step 2: Attacker creates redemption with ALL borrowed debt vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(alToken), address(transmuterLogic), maxBorrowable); transmuterLogic.createRedemption(maxBorrowable, address(0xbeef)); console.log("\nStep 2: creates redemption"); vm.stopPrank(); vm.roll(block.number + 1); alchemist.poke(tokenIdAttacker); // Step 3: Advance time to mature redemption (100% maturity) vm.roll(block.number + 5_256_000); console.log("\nStep 3: Fast forward to full maturity (2 years)"); // Step 4: Simulate 10% price crash console.log("\nStep 4: PRICE CRASH - MYT drops 10%"); // Increase mocked supply 10x = price drops to 10% of original uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 1000 bps or 10% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 1000 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); (uint256 collateralBefore, uint256 debtBefore, uint256 earmarkedBefore) = alchemist.getCDP(tokenIdAttacker); console.log("\nPosition after price crash:"); console.log(" Collateral (shares):", collateralBefore); console.log(" Collateral value (underlying):", alchemist.totalValue(tokenIdAttacker)); console.log(" Debt:", debtBefore); console.log(" Earmarked:", earmarkedBefore); uint256 collateralizationAfterCrash = alchemist.totalValue(tokenIdAttacker) * FIXED_POINT_SCALAR / debtBefore; console.log(" Collateralization ratio:", collateralizationAfterCrash / 1e16, "%"); alchemist.liquidate(tokenIdAttacker); (collateralBefore, debtBefore, earmarkedBefore) = alchemist.getCDP(tokenIdAttacker); console.log("\nPosition after liquidation crash:"); console.log(" Collateral (shares):", collateralBefore); console.log(" Collateral value (underlying):", alchemist.totalValue(tokenIdAttacker)); console.log(" Debt:", debtBefore); console.log(" Earmarked:", earmarkedBefore); // Step 5: if any residual debt remains, a second liquidation must clear it; // otherwise liquidation should revert because nothing is left to liquidate. console.log("\nStep 5: liquidating the second time"); if (debtBefore > 0) { alchemist.liquidate(tokenIdAttacker); } else { vm.expectRevert(IAlchemistV3Errors.LiquidationError.selector); alchemist.liquidate(tokenIdAttacker); } (collateralBefore, debtBefore, earmarkedBefore) = alchemist.getCDP(tokenIdAttacker); console.log("\nPosition after Second liquidation crash:"); console.log(" Collateral (shares):", collateralBefore); console.log(" Collateral value (underlying):", alchemist.totalValue(tokenIdAttacker)); console.log(" Debt:", debtBefore); console.log(" Earmarked:", earmarkedBefore); assertEq(debtBefore, 0); } function testSmallAmountsLiquidatedWithNoDustDebt() external { address user1 = makeAddr("user1"); address user2 = makeAddr("user2"); deal(address(vault), address(user1), 10e18); vm.startPrank(address(user1)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(10e18, address(user1), 0); vm.stopPrank(); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(user1), address(alchemistNFT)); vm.prank(user1); alchemist.mint(tokenId, 9e18, user1); vm.startPrank(user1); IERC20(alToken).approve(address(transmuterLogic), 3000e18); IERC20(address(vault)).approve(address(alchemist), 100_000e18); transmuterLogic.createRedemption(9e18 - 100, user1); vm.roll(vm.getBlockNumber() + transmuterLogic.timeToTransmute()); // full dulration of the redemption. IERC20(address(vault)).approve(address(alchemist), 100_000e18); // modify yield token price via modifying underlying token supply uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 2000 bps or 20% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = ((initialVaultSupply * 2000) / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); vm.startPrank(user1); // get account cdp (uint256 collateral, uint256 debt,) = alchemist.getCDP(tokenId); uint256 accountCollatRatio = alchemist.totalValue(tokenId) * FIXED_POINT_SCALAR / debt; console.log("test underlying value", alchemist.totalValue(tokenId)); console.log("test debt", debt); console.log("test accountCollatRatio", accountCollatRatio); require(accountCollatRatio < alchemist.minimumCollateralization(), "Account should be undercollateralized"); // vm.expectRevert("ZeroAmount()"); (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenId); (uint256 collateralAfter, uint256 debtAfter,) = alchemist.getCDP(tokenId); assertEq(debtAfter, 0); assertEq(collateralAfter, 0); } function test_PoC_LiquidationFeeDoubleDip() public { address alice = externalUser; address liquidator = anotherExternalUser; uint256 lowerBound = 1_050_000_000_000_000_000; // 1.05e18 // Pin all parameters needed by this PoC (admin-only setters). vm.startPrank(alOwner); alchemist.setRepaymentFee(repaymentFeeBPS); // 1% alchemist.setLiquidatorFee(liquidatorFeeBPS); // 3% // Invariant-safe ordering: // 1) lower bound must be <= minimum // 2) minimum may be clamped by global minimum / liquidation target // 3) global minimum must be >= minimum alchemist.setCollateralizationLowerBound(lowerBound); alchemist.setMinimumCollateralization(lowerBound); alchemist.setGlobalMinimumCollateralization(lowerBound); alchemist.setLiquidationTargetCollateralization(1_100_000_000_000_000_000); // 1.10e18 vm.stopPrank(); assertEq(alchemist.collateralizationLowerBound(), lowerBound); assertEq(alchemist.minimumCollateralization(), lowerBound); assertEq(alchemist.globalMinimumCollateralization(), lowerBound); // === 1) Alice opens a position at exactly the minimum collateralization (1.05x) === vm.startPrank(alice); uint256 depositAmount = 105e18; IERC20(address(vault)).approve(address(alchemist), depositAmount); alchemist.deposit(depositAmount, alice, 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(alice, address(alchemistNFT)); uint256 borrowAmount = 100e18; alchemist.mint(tokenId, borrowAmount, alice); vm.stopPrank(); // Sanity: account is unhealthy (ratio == lowerBound is not strictly >) (, uint256 initialDebt,) = alchemist.getCDP(tokenId); assertFalse(alchemist.totalValue(tokenId) * FIXED_POINT_SCALAR / initialDebt > lowerBound); // === 2) Create a redemption so 0.1 debt gets earmarked next block === vm.startPrank(alice); alToken.approve(address(transmuterLogic), type(uint256).max); transmuterLogic.createRedemption(1e17, alice); // 0.1 debt vm.roll(block.number + 1); vm.stopPrank(); uint256 yieldBalBefore = vault.balanceOf(liquidator); uint256 underlyingBalBefore = IERC20(mockVaultCollateral).balanceOf(liquidator); // === 3) First liquidation: earmark repayment + repayment fee === vm.prank(liquidator); (, uint256 firstFeeYield, uint256 firstFeeUnderlying) = alchemist.liquidate(tokenId); // Liquidator receives repayment fee from exactly one source (all-or-nothing switch). assertGt(firstFeeYield + firstFeeUnderlying, 0, "repayment fee not paid"); assertEq(vault.balanceOf(liquidator) - yieldBalBefore, firstFeeYield, "liquidator received expected yield fee"); assertEq( IERC20(mockVaultCollateral).balanceOf(liquidator) - underlyingBalBefore, firstFeeUnderlying, "liquidator received expected underlying fee" ); // Fee deduction should preserve strict health with lower-bound surplus + clamp. uint256 collateralValueAfterFee = alchemist.totalValue(tokenId); (, uint256 debtAfterFee,) = alchemist.getCDP(tokenId); assertGt(collateralValueAfterFee * FIXED_POINT_SCALAR / debtAfterFee, lowerBound, "account should remain healthy after fee"); // Track liquidator's underlying token balance before second liquidation uint256 liquidatorUnderlyingBefore = IERC20(mockVaultCollateral).balanceOf(liquidator); // === 4) Second liquidation should fail; no double-dip path remains === vm.prank(liquidator); vm.expectRevert(IAlchemistV3Errors.LiquidationError.selector); alchemist.liquidate(tokenId); uint256 liquidatorUnderlyingAfter = IERC20(mockVaultCollateral).balanceOf(liquidator); assertEq(liquidatorUnderlyingAfter, liquidatorUnderlyingBefore, "no second fee should be paid"); assertGt(firstFeeYield + firstFeeUnderlying, 0, "first repayment fee was paid"); } function _abs(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a - b : b - a; } } ================================================ FILE: src/test/AlchemistV3_6_decimals.t.sol ================================================ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.28; import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {TransparentUpgradeableProxy} from "lib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {SafeCast} from "../libraries/SafeCast.sol"; import {Test} from "lib/forge-std/src/Test.sol"; import {SafeERC20} from "../libraries/SafeERC20.sol"; import {console} from "lib/forge-std/src/console.sol"; import {AlchemistV3} from "../AlchemistV3.sol"; import {AlchemicTokenV3} from "./mocks/AlchemicTokenV3.sol"; import {Transmuter} from "../Transmuter.sol"; import {AlchemistV3Position} from "../AlchemistV3Position.sol"; import {AlchemistV3PositionRenderer} from "../AlchemistV3PositionRenderer.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {Whitelist} from "../utils/Whitelist.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; import {TestYieldToken} from "./mocks/TestYieldToken.sol"; import {TokenAdapterMock} from "./mocks/TokenAdapterMock.sol"; import {IAlchemistV3, IAlchemistV3Errors, AlchemistInitializationParams} from "../interfaces/IAlchemistV3.sol"; import {ITransmuter} from "../interfaces/ITransmuter.sol"; import {ITestYieldToken} from "../interfaces/test/ITestYieldToken.sol"; import {InsufficientAllowance} from "../base/Errors.sol"; import {Unauthorized, IllegalArgument, IllegalState, MissingInputData} from "../base/Errors.sol"; import {AlchemistNFTHelper} from "./libraries/AlchemistNFTHelper.sol"; import {IAlchemistV3Position} from "../interfaces/IAlchemistV3Position.sol"; import {AggregatorV3Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {AlchemistTokenVault} from "../AlchemistTokenVault.sol"; import {MockMYTStrategy} from "./mocks/MockMYTStrategy.sol"; import {MYTTestHelper} from "./libraries/MYTTestHelper.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {MockAlchemistAllocator} from "./mocks/MockAlchemistAllocator.sol"; import {IMockYieldToken} from "./mocks/MockYieldToken.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {MockYieldToken} from "./mocks/MockYieldToken.sol"; contract AlchemistV3Test is Test { // ----- [SETUP] Variables for setting up a minimal CDP ----- // Callable contract variables AlchemistV3 alchemist; Transmuter transmuter; AlchemistV3Position alchemistNFT; AlchemistTokenVault alchemistFeeVault; // // Proxy variables TransparentUpgradeableProxy proxyAlchemist; TransparentUpgradeableProxy proxyTransmuter; // // Contract variables // CheatCodes cheats = CheatCodes(HEVM_ADDRESS); AlchemistV3 alchemistLogic; Transmuter transmuterLogic; AlchemicTokenV3 alToken; Whitelist whitelist; // Parameters for AlchemicTokenV2 string public _name; string public _symbol; uint256 public _flashFee; address public alOwner; mapping(address => bool) users; uint256 public constant FIXED_POINT_SCALAR = 1e18; uint256 public constant BPS = 10_000; uint256 public protocolFee = 100; uint256 public liquidatorFeeBPS = 300; // in BPS, 3% uint256 public minimumCollateralization = uint256(FIXED_POINT_SCALAR * FIXED_POINT_SCALAR) / 9e17; // ----- Variables for deposits & withdrawals ----- // account funds to make deposits/test with uint256 accountFunds; // deposit to vault funds to make deposits/test with uint256 depositToVaultFunds; // large amount to test with uint256 whaleSupply; // MYT shares are always 18 decimals uint256 depositAmount = 100_000e18; // minimum amount of yield/underlying token to deposit uint256 minimumDeposit = 1000e18; // minimum amount of yield/underlying token to deposit uint256 minimumDepositOrWithdrawalLoss = FIXED_POINT_SCALAR; // random EOA for testing address externalUser = address(0x69E8cE9bFc01AA33cD2d02Ed91c72224481Fa420); // another random EOA for testing address anotherExternalUser = address(0x420Ab24368E5bA8b727E9B8aB967073Ff9316969); // another random EOA for testing address yetAnotherExternalUser = address(0x520aB24368e5Ba8B727E9b8aB967073Ff9316961); // another random EOA for testing address someWhale = address(0x521aB24368E5Ba8b727e9b8AB967073fF9316961); // WETH address address public weth = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); address public protocolFeeReceiver = address(10); // MYT variables VaultV2 vault; MockAlchemistAllocator allocator; MockMYTStrategy mytStrategy; address public operator = address(0x2222222222222222222222222222222222222222); // default operator address public admin = address(0x4444444444444444444444444444444444444444); // DAO OSX address public curator = address(0x8888888888888888888888888888888888888888); address public mockVaultCollateral = address(new TestERC20(100e18, uint8(18))); address public mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); uint256 public defaultStrategyAbsoluteCap = 2_000_000_000e18; uint256 public defaultStrategyRelativeCap = 1e18; // 100% function setUp() external { adJustTestFunds(6); setUpMYT(6); deployCoreContracts(6); } function adJustTestFunds(uint256 alchemistUnderlyingTokenDecimals) public { accountFunds = 200_000 * 10 ** alchemistUnderlyingTokenDecimals; whaleSupply = 20_000_000_000 * 10 ** alchemistUnderlyingTokenDecimals; } function setUpMYT(uint256 alchemistUnderlyingTokenDecimals) public { vm.startPrank(admin); uint256 TOKEN_AMOUNT = 1_000_000; // Base token amount uint256 initialSupply = TOKEN_AMOUNT * 10 ** alchemistUnderlyingTokenDecimals; mockVaultCollateral = address(new TestERC20(initialSupply, uint8(alchemistUnderlyingTokenDecimals))); mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); vault = MYTTestHelper._setupVault(mockVaultCollateral, admin, curator); mytStrategy = MYTTestHelper._setupStrategy(address(vault), mockStrategyYieldToken, admin, "MockToken", "MockTokenProtocol", IMYTStrategy.RiskClass.LOW); allocator = new MockAlchemistAllocator(address(vault), admin, operator, address(new AlchemistStrategyClassifier(admin))); vm.stopPrank(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (address(allocator), true))); vault.setIsAllocator(address(allocator), true); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, address(mytStrategy))); vault.addAdapter(address(mytStrategy)); bytes memory idData = mytStrategy.getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, defaultStrategyAbsoluteCap))); vault.increaseAbsoluteCap(idData, defaultStrategyAbsoluteCap); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, defaultStrategyRelativeCap))); vault.increaseRelativeCap(idData, defaultStrategyRelativeCap); vm.stopPrank(); } function _magicDepositToVault(address vault, address depositor, uint256 amount) internal returns (uint256) { deal(address(mockVaultCollateral), address(depositor), amount); vm.startPrank(depositor); TokenUtils.safeApprove(address(mockVaultCollateral), vault, amount); uint256 shares = IVaultV2(vault).deposit(amount, depositor); vm.stopPrank(); return shares; } function _vaultSubmitAndFastForward(bytes memory data) internal { vault.submit(data); bytes4 selector = bytes4(data); vm.warp(block.timestamp + vault.timelock(selector)); } function deployCoreContracts(uint256 alchemistUnderlyingTokenDecimals) public { // test maniplulation for convenience address caller = address(0xdead); address proxyOwner = address(this); vm.assume(caller != address(0)); vm.assume(proxyOwner != address(0)); vm.assume(caller != proxyOwner); vm.startPrank(caller); // Fake tokens alToken = new AlchemicTokenV3(_name, _symbol, _flashFee); ITransmuter.TransmuterInitializationParams memory transParams = ITransmuter.TransmuterInitializationParams({ syntheticToken: address(alToken), feeReceiver: address(this), timeToTransmute: 5_256_000, transmutationFee: 10, exitFee: 20, graphSize: 52_560_000 }); // Contracts and logic contracts alOwner = caller; transmuterLogic = new Transmuter(transParams); alchemistLogic = new AlchemistV3(); whitelist = new Whitelist(); // AlchemistV3 proxy AlchemistInitializationParams memory params = AlchemistInitializationParams({ admin: alOwner, debtToken: address(alToken), underlyingToken: address(vault.asset()), depositCap: type(uint256).max, minimumCollateralization: minimumCollateralization, collateralizationLowerBound: 1_052_631_578_950_000_000, // 1.05 collateralization globalMinimumCollateralization: 1_111_111_111_111_111_111, // 1.1 liquidationTargetCollateralization: uint256(1e36) / 88e16, // ~113.63% (88% LTV) transmuter: address(transmuterLogic), protocolFee: 0, protocolFeeReceiver: protocolFeeReceiver, liquidatorFee: liquidatorFeeBPS, repaymentFee: 100, myt: address(vault) }); bytes memory alchemParams = abi.encodeWithSelector(AlchemistV3.initialize.selector, params); proxyAlchemist = new TransparentUpgradeableProxy(address(alchemistLogic), proxyOwner, alchemParams); alchemist = AlchemistV3(address(proxyAlchemist)); // Whitelist alchemist proxy for minting tokens alToken.setWhitelist(address(proxyAlchemist), true); whitelist.add(address(0xbeef)); whitelist.add(externalUser); whitelist.add(anotherExternalUser); transmuterLogic.setAlchemist(address(alchemist)); transmuterLogic.setDepositCap(uint256(type(int256).max)); alchemistNFT = new AlchemistV3Position(address(alchemist), alOwner); alchemistNFT.setMetadataRenderer(address(new AlchemistV3PositionRenderer())); alchemist.setAlchemistPositionNFT(address(alchemistNFT)); alchemistFeeVault = new AlchemistTokenVault(address(vault.asset()), address(alchemist), alOwner); alchemistFeeVault.setAuthorization(address(alchemist), true); alchemist.setAlchemistFeeVault(address(alchemistFeeVault)); vm.stopPrank(); _magicDepositToVault(address(vault), address(0xbeef), accountFunds); _magicDepositToVault(address(vault), address(0xdad), accountFunds); _magicDepositToVault(address(vault), externalUser, accountFunds); _magicDepositToVault(address(vault), yetAnotherExternalUser, accountFunds); _magicDepositToVault(address(vault), anotherExternalUser, accountFunds); vm.startPrank(address(admin)); allocator.allocate(address(mytStrategy), vault.convertToAssets(vault.totalSupply())); vm.stopPrank(); deal(address(vault.asset()), alchemist.alchemistFeeVault(), 10_000 * (10 ** alchemistUnderlyingTokenDecimals)); vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(vault.asset()), address(vault), accountFunds); vm.stopPrank(); vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault.asset()), address(vault), accountFunds); vm.stopPrank(); } function testLiquidate_Undercollateralized_Position_Underlying_Token_6_Decimals() external { require(TokenUtils.expectDecimals(alchemist.underlyingToken()) == 6); require(TokenUtils.expectDecimals(vault.asset()) == 6); // just ensureing global alchemist collateralization stays above the minimum required for regular liquidations // no need to mint anything vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, yetAnotherExternalUser, 0); vm.stopPrank(); vm.startPrank(address(0xbeef)); SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount * 2); alchemist.deposit(depositAmount, address(0xbeef), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xBeef = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenIdFor0xBeef, alchemist.totalValue(tokenIdFor0xBeef) * FIXED_POINT_SCALAR / minimumCollateralization, address(0xbeef)); vm.stopPrank(); uint256 feeVaultPreviousBalance = alchemistFeeVault.totalDeposits(); // modify yield token price via modifying underlying token supply (uint256 prevCollateral, uint256 prevDebt,) = alchemist.getCDP(tokenIdFor0xBeef); uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 4000 bps or 40% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = (initialVaultSupply * 4000 / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); // ensure initial debt is correct // vm.assertApproxEqAbs(prevDebt, 180_000_000_000_000_000_018_000, minimumDepositOrWithdrawalLoss); // let another user liquidate the previous user position vm.startPrank(externalUser); uint256 liquidatorPrevTokenBalance = IERC20(address(vault)).balanceOf(address(externalUser)); uint256 liquidatorPrevUnderlyingBalance = IERC20(vault.asset()).balanceOf(address(externalUser)); uint256 alchemistCurrentCollateralization = alchemist.normalizeUnderlyingTokensToDebt(alchemist.getTotalUnderlyingValue()) * FIXED_POINT_SCALAR / alchemist.totalDebt(); (uint256 liquidationAmount, uint256 expectedDebtToBurn, uint256 expectedBaseFee,) = alchemist.calculateLiquidation( alchemist.totalValue(tokenIdFor0xBeef), prevDebt, alchemist.minimumCollateralization(), alchemistCurrentCollateralization, alchemist.globalMinimumCollateralization(), liquidatorFeeBPS ); uint256 transmuterPreviousBalance = IERC20(address(vault)).balanceOf(address(transmuterLogic)); uint256 expectedLiquidationAmountInYield = alchemist.convertDebtTokensToYield(liquidationAmount); uint256 expectedFeeInDebtTokens = expectedDebtToBurn * liquidatorFeeBPS / 10_000; // expected debt to burn is in debt tokens. converting to underlying for testing uint256 expectedFeeInUnderlying = alchemist.normalizeDebtTokensToUnderlying(expectedFeeInDebtTokens); uint256 adjustedExpectedFeeInUnderlying = feeVaultPreviousBalance > expectedFeeInUnderlying ? expectedFeeInUnderlying : feeVaultPreviousBalance; (uint256 assets, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenIdFor0xBeef); uint256 expectedBaseFeeInYield = alchemist.convertDebtTokensToYield(expectedBaseFee); (uint256 postCollateral, uint256 postDebt,) = alchemist.getCDP(tokenIdFor0xBeef); vm.stopPrank(); // ensure liquidator fee is correct (3% of surplus (account collateral - debt) vm.assertApproxEqAbs(feeInYield, 0, 1e18); vm.assertEq(feeInUnderlying, adjustedExpectedFeeInUnderlying); // liquidator gets correct amount of fee _validateLiquidiatorState( externalUser, liquidatorPrevTokenBalance, liquidatorPrevUnderlyingBalance, feeInYield, feeInUnderlying, assets, expectedLiquidationAmountInYield ); _validateLiquidatedAccountState(tokenIdFor0xBeef, prevCollateral, prevDebt, expectedDebtToBurn, expectedLiquidationAmountInYield); vm.assertApproxEqAbs(alchemistFeeVault.totalDeposits(), feeVaultPreviousBalance - adjustedExpectedFeeInUnderlying, 1e18); vm.assertApproxEqAbs( IERC20(address(vault)).balanceOf(address(transmuterLogic)), transmuterPreviousBalance + expectedLiquidationAmountInYield - expectedBaseFeeInYield, 1e18 ); } function testSmallOutsourcedFeeRoundingWithdrawalSuccess() external { require(TokenUtils.expectDecimals(alchemist.underlyingToken()) == 6); require(TokenUtils.expectDecimals(vault.asset()) == 6); address user1 = makeAddr("user1"); address user2 = makeAddr("user2"); deal(address(vault), address(user1), 10e18); vm.startPrank(address(user1)); SafeERC20.safeApprove(address(vault), address(alchemist), type(uint256).max); alchemist.deposit(10e18, address(user1), 0); vm.stopPrank(); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(user1), address(alchemistNFT)); vm.prank(user1); alchemist.mint(tokenId, 9e18, user1); vm.startPrank(user1); IERC20(alToken).approve(address(transmuterLogic), 3000e18); IERC20(address(vault)).approve(address(alchemist), 100_000e18); transmuterLogic.createRedemption(9e18 - 100, user1); vm.roll(vm.getBlockNumber() + transmuterLogic.timeToTransmute()); // full dulration of the redemption. IERC20(address(vault)).approve(address(alchemist), 100_000e18); // modify yield token price via modifying underlying token supply uint256 initialVaultSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialVaultSupply); // increasing yeild token suppy by 2000 bps or 20% while keeping the unederlying supply unchanged uint256 modifiedVaultSupply = ((initialVaultSupply * 2000) / 10_000) + initialVaultSupply; IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(modifiedVaultSupply); vm.startPrank(user1); // get account cdp (uint256 collateral, uint256 debt,) = alchemist.getCDP(tokenId); uint256 accountCollatRatio = alchemist.totalValue(tokenId) * FIXED_POINT_SCALAR / debt; console.log("test underlying value", alchemist.totalValue(tokenId)); console.log("test debt", debt); console.log("test accountCollatRatio", accountCollatRatio); require(accountCollatRatio < alchemist.minimumCollateralization(), "Account should be undercollateralized"); // vm.expectRevert("ZeroAmount()"); (uint256 amountLiquidated, uint256 feeInYield, uint256 feeInUnderlying) = alchemist.liquidate(tokenId); (uint256 collateralAfter, uint256 debtAfter,) = alchemist.getCDP(tokenId); console.log("test underlying value after", alchemist.totalValue(tokenId)); console.log("test debtAfter", debtAfter); console.log("test amountLiquidated", amountLiquidated); console.log("test feeInYield", feeInYield); console.log("test feeInUnderlying", feeInUnderlying); } function _validateLiquidiatorState( address user, uint256 prevTokenBalance, uint256 prevUnderlyingBalance, uint256 feeInYield, uint256 feeInUnderlying, uint256 assets, uint256 exepctedLiquidationTotalAmountInYield ) internal view { uint256 liquidatorPostTokenBalance = IERC20(address(vault)).balanceOf(user); uint256 liquidatorPostUnderlyingBalance = IERC20(vault.asset()).balanceOf(user); vm.assertApproxEqAbs(liquidatorPostTokenBalance, prevTokenBalance + feeInYield, 1e18); vm.assertApproxEqAbs(liquidatorPostUnderlyingBalance, prevUnderlyingBalance + feeInUnderlying, 1e18); vm.assertApproxEqAbs(assets, exepctedLiquidationTotalAmountInYield, minimumDepositOrWithdrawalLoss); } function _validateLiquidatedAccountState( uint256 tokenId, uint256 prevCollateral, uint256 prevDebt, uint256 expectedDebtToBurn, uint256 expectedLiquidationAmountInYield ) internal view { (uint256 depositedCollateral, uint256 debt,) = alchemist.getCDP(tokenId); // ensure debt is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(debt, prevDebt - expectedDebtToBurn, minimumDepositOrWithdrawalLoss); // ensure depositedCollateral is reduced by the result of (collateral - y)/(debt - y) = minimum collateral ratio vm.assertApproxEqAbs(depositedCollateral, prevCollateral - expectedLiquidationAmountInYield, minimumDepositOrWithdrawalLoss); } } ================================================ FILE: src/test/BaseStrategyTest.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {AlchemistAllocator} from "../AlchemistAllocator.sol"; import {IAllocator} from "../interfaces/IAllocator.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {MYTTestHelper} from "./libraries/MYTTestHelper.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {RevertContext, IRevertAllowlistProvider} from "./base/StrategyTypes.sol"; import {StrategyHandler} from "./base/StrategyHandler.sol"; import {BaseStrategySimple} from "./base/BaseStrategySimple.sol"; import {BaseStrategyMulti} from "./base/BaseStrategyMulti.sol"; /// @notice Compatibility entrypoint for strategy test inheritance. /// @dev Strategy-specific test files should inherit this contract; internals are composed from `test/base/*`. abstract contract BaseStrategyTest is BaseStrategySimple, BaseStrategyMulti { // Re-export `RevertContext`, `IRevertAllowlistProvider`, and `StrategyHandler` for compatibility. } ================================================ FILE: src/test/DeploySFraxETHStrategyScript.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {DeploySFraxETHStrategyScript} from "../../script/DeploySFraxETHStrategy.s.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {SFraxETHStrategy} from "../strategies/SFraxETHStrategy.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; contract MockOracleForSFraxETHDeployTest { function decimals() external pure returns (uint8) { return 18; } } contract MockMYTForSFraxETHDeployTest { address public asset; constructor(address _asset) { asset = _asset; } receive() external payable {} fallback() external payable {} } contract DeploySFraxETHStrategyScriptTest is Test { uint256 internal constant MAX_ORACLE_STALENESS = 24 hours; DeploySFraxETHStrategyScript internal deployScript; AlchemistCurator internal curator; TestERC20 internal assetToken; MockMYTForSFraxETHDeployTest internal myt; address internal newOwner; address internal minter; address internal frxETH; address internal sfrxETH; MockOracleForSFraxETHDeployTest internal oracle; uint256 internal constant MIN_FRXETH_OUT_BPS = 9000; function setUp() public { deployScript = new DeploySFraxETHStrategyScript(); curator = new AlchemistCurator(address(deployScript), address(deployScript)); assetToken = new TestERC20(1_000_000e18, 18); myt = new MockMYTForSFraxETHDeployTest(address(assetToken)); newOwner = makeAddr("newOwner"); minter = makeAddr("minter"); frxETH = makeAddr("frxETH"); sfrxETH = makeAddr("sfrxETH"); oracle = new MockOracleForSFraxETHDeployTest(); } function test_deploySFraxETHStrategy_setsCoreAddressesAndFloor() public { DeploySFraxETHStrategyScript.SFraxETHDeployConfig memory config = DeploySFraxETHStrategyScript .SFraxETHDeployConfig({ myt: address(myt), minter: minter, frxETH: frxETH, sfrxETH: sfrxETH, pricedTokenOracle: address(oracle), minFrxEthOutBps: MIN_FRXETH_OUT_BPS, maxOracleStaleness: MAX_ORACLE_STALENESS, params: _buildParams("sfrxETH Mainnet", "Frax") }); address strategyAddr = deployScript.deploySFraxETHStrategy(curator, newOwner, config); SFraxETHStrategy strategy = SFraxETHStrategy(payable(strategyAddr)); assertEq(address(strategy.MYT()), address(myt), "unexpected MYT address"); assertEq(address(strategy.minter()), minter, "unexpected minter"); assertEq(address(strategy.frxETH()), frxETH, "unexpected frxETH"); assertEq(address(strategy.sfrxETH()), sfrxETH, "unexpected sfrxETH"); assertEq(address(strategy.pricedTokenOracle()), address(oracle), "unexpected oracle"); assertEq(strategy.minFrxEthOutBps(), MIN_FRXETH_OUT_BPS, "unexpected minFrxEthOutBps"); assertEq(strategy.MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS, "unexpected max oracle staleness"); assertEq(strategy.owner(), newOwner, "unexpected owner"); assertEq(curator.adapterToMYT(strategyAddr), address(myt), "unexpected curator adapter mapping"); (, string memory strategyName, string memory protocol,,,,,,) = strategy.params(); assertEq(strategyName, "sfrxETH Mainnet", "unexpected strategy name"); assertEq(protocol, "Frax", "unexpected protocol"); } function _buildParams(string memory name, string memory protocol) internal view returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(deployScript), name: name, protocol: protocol, riskClass: IMYTStrategy.RiskClass.LOW, cap: 1_000e18, globalCap: 0.5e18, estimatedYield: 500, additionalIncentives: false, slippageBPS: 50 }); } } ================================================ FILE: src/test/DeploySiUSDStrategiesScript.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {DeploySiUSDStrategiesScript} from "../../script/DeploySiUSDStrategies.s.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {SiUSDStrategy} from "../strategies/SiUSDStrategy.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; contract MockMYTForSiUSDDeployTest { address public asset; constructor(address _asset) { asset = _asset; } receive() external payable {} fallback() external payable {} } contract MockDeployOracle { uint8 internal immutable _decimals; constructor(uint8 decimals_) { _decimals = decimals_; } function decimals() external view returns (uint8) { return _decimals; } } contract DeploySiUSDStrategiesScriptTest is Test { uint256 internal constant MAX_ORACLE_STALENESS_A = 6 hours; uint256 internal constant MAX_ORACLE_STALENESS_B = 12 hours; DeploySiUSDStrategiesScript internal deployScript; AlchemistCurator internal curator; TestERC20 internal assetToken; MockMYTForSiUSDDeployTest internal myt; address internal newOwner; address internal iUSD; address internal siUSD; address internal gateway; address internal mintControllerA; address internal mintControllerB; address internal redeemControllerA; address internal redeemControllerB; MockDeployOracle internal iUsdUsdcOracleA; MockDeployOracle internal iUsdUsdcOracleB; function setUp() public { deployScript = new DeploySiUSDStrategiesScript(); curator = new AlchemistCurator(address(deployScript), address(deployScript)); assetToken = new TestERC20(1_000_000e6, 6); myt = new MockMYTForSiUSDDeployTest(address(assetToken)); newOwner = makeAddr("newOwner"); iUSD = makeAddr("iUSD"); siUSD = makeAddr("siUSD"); gateway = makeAddr("gateway"); mintControllerA = makeAddr("mintControllerA"); mintControllerB = makeAddr("mintControllerB"); redeemControllerA = makeAddr("redeemControllerA"); redeemControllerB = makeAddr("redeemControllerB"); iUsdUsdcOracleA = new MockDeployOracle(18); iUsdUsdcOracleB = new MockDeployOracle(18); } function test_deploySiUSDStrategy_setsCoreAddressesAndName() public { DeploySiUSDStrategiesScript.SiUSDDeployConfig memory config = DeploySiUSDStrategiesScript.SiUSDDeployConfig({ myt: address(myt), usdc: address(assetToken), iUSD: iUSD, siUSD: siUSD, gateway: gateway, mintController: mintControllerA, redeemController: redeemControllerA, iUsdUsdcOracle: address(iUsdUsdcOracleA), maxOracleStaleness: MAX_ORACLE_STALENESS_A, params: _buildParams("SiUSD Mainnet USDC", "InfiniFi") }); address strategyAddr = deployScript.deploySiUSDStrategy(curator, newOwner, config); SiUSDStrategy strategy = SiUSDStrategy(strategyAddr); assertEq(address(strategy.MYT()), address(myt), "unexpected MYT address"); assertEq(address(strategy.usdc()), address(assetToken), "unexpected usdc"); assertEq(address(strategy.iUSD()), iUSD, "unexpected iUSD"); assertEq(address(strategy.siUSD()), siUSD, "unexpected siUSD"); assertEq(address(strategy.gateway()), gateway, "unexpected gateway"); assertEq(address(strategy.mintController()), mintControllerA, "unexpected mint controller"); assertEq(address(strategy.redeemController()), redeemControllerA, "unexpected redeem controller"); assertEq(address(strategy.pricedTokenOracle()), address(iUsdUsdcOracleA), "unexpected iUSD oracle"); assertEq(strategy.MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS_A, "unexpected max oracle staleness"); (, string memory strategyName,,,,,,,) = strategy.params(); assertEq(strategyName, "SiUSD Mainnet USDC", "unexpected strategy name"); } function test_deployBatch_deploysAllConfigsWithExpectedValues() public { DeploySiUSDStrategiesScript.SiUSDDeployConfig[] memory configs = new DeploySiUSDStrategiesScript.SiUSDDeployConfig[](2); configs[0] = DeploySiUSDStrategiesScript.SiUSDDeployConfig({ myt: address(myt), usdc: address(assetToken), iUSD: iUSD, siUSD: siUSD, gateway: gateway, mintController: mintControllerA, redeemController: redeemControllerA, iUsdUsdcOracle: address(iUsdUsdcOracleA), maxOracleStaleness: MAX_ORACLE_STALENESS_A, params: _buildParams("SiUSD Mainnet USDC", "InfiniFi") }); configs[1] = DeploySiUSDStrategiesScript.SiUSDDeployConfig({ myt: address(myt), usdc: address(assetToken), iUSD: makeAddr("iUSDB"), siUSD: makeAddr("siUSDB"), gateway: makeAddr("gatewayB"), mintController: mintControllerB, redeemController: redeemControllerB, iUsdUsdcOracle: address(iUsdUsdcOracleB), maxOracleStaleness: MAX_ORACLE_STALENESS_B, params: _buildParams("SiUSD Alternate USDC", "InfiniFi") }); address[] memory deployed = deployScript.deployBatch(curator, newOwner, configs); assertEq(deployed.length, 2, "unexpected deployed strategies length"); SiUSDStrategy strategy0 = SiUSDStrategy(deployed[0]); assertEq(address(strategy0.MYT()), address(myt), "strategy0 unexpected MYT"); assertEq(address(strategy0.usdc()), address(assetToken), "strategy0 unexpected usdc"); assertEq(address(strategy0.iUSD()), iUSD, "strategy0 unexpected iUSD"); assertEq(address(strategy0.siUSD()), siUSD, "strategy0 unexpected siUSD"); assertEq(address(strategy0.gateway()), gateway, "strategy0 unexpected gateway"); assertEq(address(strategy0.mintController()), mintControllerA, "strategy0 unexpected mint controller"); assertEq(address(strategy0.redeemController()), redeemControllerA, "strategy0 unexpected redeem controller"); assertEq(address(strategy0.pricedTokenOracle()), address(iUsdUsdcOracleA), "strategy0 unexpected iUSD oracle"); assertEq(strategy0.MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS_A, "strategy0 unexpected max oracle staleness"); (, string memory name0,,,,,,,) = strategy0.params(); assertEq(name0, "SiUSD Mainnet USDC", "strategy0 unexpected name"); SiUSDStrategy strategy1 = SiUSDStrategy(deployed[1]); assertEq(address(strategy1.MYT()), address(myt), "strategy1 unexpected MYT"); assertEq(address(strategy1.usdc()), address(assetToken), "strategy1 unexpected usdc"); assertEq(address(strategy1.iUSD()), configs[1].iUSD, "strategy1 unexpected iUSD"); assertEq(address(strategy1.siUSD()), configs[1].siUSD, "strategy1 unexpected siUSD"); assertEq(address(strategy1.gateway()), configs[1].gateway, "strategy1 unexpected gateway"); assertEq(address(strategy1.mintController()), configs[1].mintController, "strategy1 unexpected mint controller"); assertEq( address(strategy1.redeemController()), configs[1].redeemController, "strategy1 unexpected redeem controller" ); assertEq(address(strategy1.pricedTokenOracle()), configs[1].iUsdUsdcOracle, "strategy1 unexpected iUSD oracle"); assertEq(strategy1.MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS_B, "strategy1 unexpected max oracle staleness"); (, string memory name1,,,,,,,) = strategy1.params(); assertEq(name1, "SiUSD Alternate USDC", "strategy1 unexpected name"); } function _buildParams(string memory name, string memory protocol) internal view returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(deployScript), name: name, protocol: protocol, riskClass: IMYTStrategy.RiskClass.LOW, cap: 1_000e6, globalCap: 0.5e18, estimatedYield: 500, additionalIncentives: false, slippageBPS: 50 }); } } ================================================ FILE: src/test/DeployWstETHEthereumStrategyScript.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {DeployWstETHEthereumStrategyScript} from "../../script/DeployWstETHEthereumStrategy.s.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {WstETHEthereumStrategy} from "../strategies/WstETHEthereumStrategy.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; contract MockOracleForWstETHEthereumDeployTest { function decimals() external pure returns (uint8) { return 18; } } contract MockMYTForWstETHEthereumDeployTest { address public asset; constructor(address _asset) { asset = _asset; } receive() external payable {} fallback() external payable {} } contract DeployWstETHEthereumStrategyScriptTest is Test { uint256 internal constant MAX_ORACLE_STALENESS = 24 hours; DeployWstETHEthereumStrategyScript internal deployScript; AlchemistCurator internal curator; TestERC20 internal assetToken; MockMYTForWstETHEthereumDeployTest internal myt; address internal newOwner; address internal wstETH; MockOracleForWstETHEthereumDeployTest internal oracle; function setUp() public { deployScript = new DeployWstETHEthereumStrategyScript(); curator = new AlchemistCurator(address(deployScript), address(deployScript)); assetToken = new TestERC20(1_000_000e18, 18); myt = new MockMYTForWstETHEthereumDeployTest(address(assetToken)); newOwner = makeAddr("newOwner"); wstETH = makeAddr("wstETH"); oracle = new MockOracleForWstETHEthereumDeployTest(); } function test_deployWstETHEthereumStrategy_setsCoreAddressesAndConfig() public { address strategyAddr = address( deployScript.deployWstEthStrategy( address(myt), curator, newOwner, wstETH, address(oracle), MAX_ORACLE_STALENESS, _buildParams("WstETH Mainnet", "WstETH") ) ); WstETHEthereumStrategy strategy = WstETHEthereumStrategy(payable(strategyAddr)); assertEq(address(strategy.MYT()), address(myt), "unexpected MYT address"); assertEq(address(strategy.wsteth()), wstETH, "unexpected wstETH"); assertEq(address(strategy.pricedTokenOracle()), address(oracle), "unexpected oracle"); assertEq(strategy.MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS, "unexpected max oracle staleness"); assertEq(strategy.owner(), newOwner, "unexpected owner"); assertTrue(strategy.killSwitch(), "kill switch should be enabled"); assertEq(curator.adapterToMYT(strategyAddr), address(myt), "unexpected curator adapter mapping"); (, string memory strategyName, string memory protocol,,,,,,) = strategy.params(); assertEq(strategyName, "WstETH Mainnet", "unexpected strategy name"); assertEq(protocol, "WstETH", "unexpected protocol"); } function _buildParams(string memory name, string memory protocol) internal view returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(deployScript), name: name, protocol: protocol, riskClass: IMYTStrategy.RiskClass.LOW, cap: 1_000e18, globalCap: 0.5e18, estimatedYield: 350, additionalIncentives: false, slippageBPS: 50 }); } } ================================================ FILE: src/test/DeployWstETHL2StrategyScript.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {DeployWstETHL2StrategyScript} from "../../script/DeployWstETHL2Strategy.s.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {WstETHL2Strategy} from "../strategies/WstETHL2Strategy.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; contract MockOracleForWstETHL2DeployTest { function decimals() external pure returns (uint8) { return 18; } } contract MockMYTForWstETHL2DeployTest { address public asset; constructor(address _asset) { asset = _asset; } receive() external payable {} fallback() external payable {} } contract DeployWstETHL2StrategyScriptTest is Test { uint256 internal constant MAX_ORACLE_STALENESS = 1 hours; DeployWstETHL2StrategyScript internal deployScript; AlchemistCurator internal curator; TestERC20 internal assetToken; MockMYTForWstETHL2DeployTest internal myt; address internal newOwner; address internal wstETH; MockOracleForWstETHL2DeployTest internal oracle; function setUp() public { deployScript = new DeployWstETHL2StrategyScript(); curator = new AlchemistCurator(address(deployScript), address(deployScript)); assetToken = new TestERC20(1_000_000e18, 18); myt = new MockMYTForWstETHL2DeployTest(address(assetToken)); newOwner = makeAddr("newOwner"); wstETH = makeAddr("wstETH"); oracle = new MockOracleForWstETHL2DeployTest(); } function test_deployWstETHL2Strategy_setsCoreAddressesAndConfig() public { address strategyAddr = address( deployScript.deployWstEthOptimismStrategy( address(myt), curator, newOwner, wstETH, address(oracle), MAX_ORACLE_STALENESS, _buildParams("WstETH Optimism", "WstETH") ) ); WstETHL2Strategy strategy = WstETHL2Strategy(strategyAddr); assertEq(address(strategy.MYT()), address(myt), "unexpected MYT address"); assertEq(address(strategy.wsteth()), wstETH, "unexpected wstETH"); assertEq(address(strategy.pricedTokenOracle()), address(oracle), "unexpected oracle"); assertEq(strategy.MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS, "unexpected max oracle staleness"); assertEq(strategy.owner(), newOwner, "unexpected owner"); assertTrue(strategy.killSwitch(), "kill switch should be enabled"); assertEq(curator.adapterToMYT(strategyAddr), address(myt), "unexpected curator adapter mapping"); (, string memory strategyName, string memory protocol,,,,,,) = strategy.params(); assertEq(strategyName, "WstETH Optimism", "unexpected strategy name"); assertEq(protocol, "WstETH", "unexpected protocol"); } function _buildParams(string memory name, string memory protocol) internal view returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(deployScript), name: name, protocol: protocol, riskClass: IMYTStrategy.RiskClass.MEDIUM, cap: 1_000e18, globalCap: 0.3e18, estimatedYield: 350, additionalIncentives: false, slippageBPS: 50 }); } } ================================================ FILE: src/test/DeployYvWETHStrategyScript.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {ERC4626Mock} from "@openzeppelin/contracts/mocks/token/ERC4626Mock.sol"; import {DeployYvWETHStrategyScript} from "../../script/DeployYvWETHStrategy.s.sol"; import {IAllocator} from "../interfaces/IAllocator.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {ERC4626Strategy} from "../strategies/ERC4626Strategy.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; contract MockMYTForYvWETHDeployTest { address public asset; constructor(address _asset) { asset = _asset; } receive() external payable {} fallback() external payable {} } contract DeployYvWETHStrategyScriptTest is Test { address internal constant DEPLOYER = 0xf456A36B04B0951Cd19d6D8aA0c0b3b0a07f9fF2; address internal constant MAINNET_NEW_OWNER = 0xF56D660138815fC5d7a06cd0E1630225E788293D; address internal constant MAINNET_WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address internal constant CURATOR_ADDR = 0x7d61E3cDe8B58C4be192a7A35E9d626c419302A4; address internal constant ETH_MYT = 0x29bcfeD246ce37319d94eBa107db90C453D4c43D; address internal constant ETH_ALLOCATOR = 0x23a3C27Bb007887FD8CbfEaF323799093a450e7e; uint256 internal constant MAINNET_FORK_BLOCK = 24_980_826; uint256 internal constant ALLOCATION_AMOUNT = 100e18; uint256 internal constant DEALLOCATION_AMOUNT = ((ALLOCATION_AMOUNT * 95) / 100); DeployYvWETHStrategyScript internal deployScript; AlchemistCurator internal curator; TestERC20 internal weth; ERC4626Mock internal yearnVault; MockMYTForYvWETHDeployTest internal myt; address internal newOwner; function setUp() public { deployScript = new DeployYvWETHStrategyScript(); curator = new AlchemistCurator(address(deployScript), address(deployScript)); weth = new TestERC20(1_000_000e18, 18); yearnVault = new ERC4626Mock(address(weth)); myt = new MockMYTForYvWETHDeployTest(address(weth)); newOwner = makeAddr("newOwner"); } function test_deployYvWETHStrategy_setsCoreAddressesAndParams() public { IMYTStrategy.StrategyParams memory params = deployScript.defaultParams(); params.owner = address(deployScript); DeployYvWETHStrategyScript.YvWETHDeployConfig memory config = DeployYvWETHStrategyScript.YvWETHDeployConfig({myt: address(myt), yearnVault: address(yearnVault), params: params}); address strategyAddr = deployScript.deployYvWETHStrategy(curator, newOwner, config); ERC4626Strategy strategy = ERC4626Strategy(strategyAddr); assertEq(address(strategy.MYT()), address(myt), "unexpected MYT address"); assertEq(address(strategy.mytAsset()), address(weth), "unexpected MYT asset"); assertEq(address(strategy.vault()), address(yearnVault), "unexpected Yearn vault"); assertEq(strategy.owner(), newOwner, "unexpected owner"); assertTrue(strategy.killSwitch(), "kill switch should be enabled after deploy"); assertEq(curator.adapterToMYT(strategyAddr), address(myt), "unexpected curator adapter mapping"); (, string memory name, string memory protocol,,,,,,) = strategy.params(); assertEq(name, "Yearn Mainnet WETH-1", "unexpected strategy name"); assertEq(protocol, "Yearn", "unexpected protocol"); } function test_run_fork_allocatorCanAllocateAndDeallocateYvWETH() public { vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), MAINNET_FORK_BLOCK); vm.deal(DEPLOYER, 10 ether); // The script performs admin-only curator cap updates, so mirror that authority on the fork. vm.store(CURATOR_ADDR, bytes32(0), bytes32(uint256(uint160(DEPLOYER)))); DeployYvWETHStrategyScript forkDeployScript = new DeployYvWETHStrategyScript(); address strategyAddr = forkDeployScript.run(); ERC4626Strategy strategy = ERC4626Strategy(strategyAddr); IVaultV2 vault = IVaultV2(ETH_MYT); assertEq(address(strategy.MYT()), ETH_MYT, "unexpected MYT"); assertEq(address(strategy.mytAsset()), MAINNET_WETH, "unexpected MYT asset"); assertEq(address(strategy.vault()), forkDeployScript.YV_WETH_VAULT(), "unexpected Yearn vault"); assertEq(strategy.owner(), MAINNET_NEW_OWNER, "unexpected strategy owner"); assertTrue(vault.isAdapter(strategyAddr), "strategy not registered"); assertEq(vault.absoluteCap(strategy.adapterId()), 10_000e18, "absolute cap not set"); assertEq(vault.relativeCap(strategy.adapterId()), 1e18, "relative cap not set"); uint256 vaultWethBalanceBefore = IERC20(MAINNET_WETH).balanceOf(ETH_MYT); uint256 depositAmount = 10e18; // Fund the live MYT directly to avoid its preconfigured liquidity adapter on deposit. deal(MAINNET_WETH, ETH_MYT, vaultWethBalanceBefore + depositAmount); // The deploy script intentionally leaves the strategy paused until governance enables it. vm.prank(MAINNET_NEW_OWNER); strategy.setKillSwitch(false); vm.prank(DEPLOYER); IAllocator(ETH_ALLOCATOR).allocate(strategyAddr, ALLOCATION_AMOUNT); assertGt(strategy.realAssets(), 0, "strategy did not receive assets"); assertGt(vault.allocation(strategy.adapterId()), 0, "vault allocation not updated"); uint256 vaultWethBefore = IERC20(MAINNET_WETH).balanceOf(ETH_MYT); uint256 deallocationAmount = strategy.previewAdjustedWithdraw(DEALLOCATION_AMOUNT); vm.prank(DEPLOYER); IAllocator(ETH_ALLOCATOR).deallocate(strategyAddr, deallocationAmount); uint256 expectedRemainingAssets = ALLOCATION_AMOUNT - deallocationAmount; assertApproxEqAbs(strategy.realAssets(), expectedRemainingAssets, 1e15, "unexpected remaining strategy assets"); assertGt(IERC20(MAINNET_WETH).balanceOf(ETH_MYT), vaultWethBefore, "vault did not receive WETH back"); } function test_defaultParams_usesMainnetYearnWETHDefaults() public view { IMYTStrategy.StrategyParams memory params = deployScript.defaultParams(); assertEq(deployScript.curatorAddr(), 0x7d61E3cDe8B58C4be192a7A35E9d626c419302A4, "unexpected curator"); assertEq(deployScript.ethMYT(), 0x29bcfeD246ce37319d94eBa107db90C453D4c43D, "unexpected ETH MYT"); assertEq(params.owner, 0xf456A36B04B0951Cd19d6D8aA0c0b3b0a07f9fF2, "unexpected owner"); assertEq(params.name, "Yearn Mainnet WETH-1", "unexpected name"); assertEq(params.protocol, "Yearn", "unexpected protocol"); assertEq(uint256(params.riskClass), uint256(IMYTStrategy.RiskClass.LOW), "unexpected risk class"); assertEq(params.cap, 10_000e18, "unexpected cap"); assertEq(params.globalCap, 1e18, "unexpected global cap"); assertEq(params.estimatedYield, 500, "unexpected estimated yield"); assertFalse(params.additionalIncentives, "unexpected incentives flag"); assertEq(params.slippageBPS, 1, "unexpected slippage"); } } ================================================ FILE: src/test/FrxEthEthDualOracleAggregatorAdapter.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {FrxEthEthDualOracleAggregatorAdapter} from "../FrxEthEthDualOracleAggregatorAdapter.sol"; contract MockFrxEthEthDualOracle { bool internal _isBadData; uint256 internal _priceLow; uint256 internal _priceHigh; function setPrices(bool isBadData_, uint256 priceLow_, uint256 priceHigh_) external { _isBadData = isBadData_; _priceLow = priceLow_; _priceHigh = priceHigh_; } function getPrices() external view returns (bool isBadData, uint256 priceLow, uint256 priceHigh) { return (_isBadData, _priceLow, _priceHigh); } } contract FrxEthEthDualOracleAggregatorAdapterTest is Test { MockFrxEthEthDualOracle internal dualOracle; FrxEthEthDualOracleAggregatorAdapter internal adapter; function setUp() public { dualOracle = new MockFrxEthEthDualOracle(); adapter = new FrxEthEthDualOracleAggregatorAdapter(address(dualOracle)); } function test_constructor_setsDualOracleAddress() public view { assertEq(address(adapter.dualOracle()), address(dualOracle), "unexpected dual oracle address"); } function test_decimals_returns18() public view { assertEq(adapter.decimals(), 18, "unexpected decimals"); } function test_latestRoundData_returnsAveragePrice() public { dualOracle.setPrices(false, 0.999e18, 1.001e18); (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = adapter.latestRoundData(); assertEq(roundId, uint80(block.number), "unexpected roundId"); assertEq(answer, int256(1e18), "unexpected average price"); assertEq(startedAt, block.timestamp, "unexpected startedAt"); assertEq(updatedAt, block.timestamp, "unexpected updatedAt"); assertEq(answeredInRound, uint80(block.number), "unexpected answeredInRound"); } function test_latestRoundData_revertsOnBadData() public { dualOracle.setPrices(true, 1e18, 1e18); vm.expectRevert(bytes("Bad dual oracle data")); adapter.latestRoundData(); } function test_latestRoundData_revertsOnZeroAveragePrice() public { dualOracle.setPrices(false, 0, 0); vm.expectRevert(bytes("Invalid dual oracle price")); adapter.latestRoundData(); } } ================================================ FILE: src/test/IntegrationTest.t.sol ================================================ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.28; import "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import "lib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import "../libraries/SafeCast.sol"; import "lib/forge-std/src/Test.sol"; import {SafeERC20} from "../libraries/SafeERC20.sol"; import {console} from "lib/forge-std/src/console.sol"; import {AlchemistV3} from "../AlchemistV3.sol"; import {AlchemicTokenV3} from "./mocks/AlchemicTokenV3.sol"; import {EulerUSDCAdapter} from "../adapters/EulerUSDCAdapter.sol"; import {Transmuter} from "../Transmuter.sol"; import {Whitelist} from "../utils/Whitelist.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; import {TestYieldToken} from "./mocks/TestYieldToken.sol"; import {TokenAdapterMock} from "./mocks/TokenAdapterMock.sol"; import {IAlchemistV3, IAlchemistV3Errors, AlchemistInitializationParams} from "../interfaces/IAlchemistV3.sol"; import {IAlchemicToken} from "../interfaces/IAlchemicToken.sol"; import {ITransmuter} from "../interfaces/ITransmuter.sol"; import {ITestYieldToken} from "../interfaces/test/ITestYieldToken.sol"; import {InsufficientAllowance} from "../base/Errors.sol"; import {Unauthorized, IllegalArgument, IllegalState, MissingInputData} from "../base/Errors.sol"; import {AlchemistNFTHelper} from "./libraries/AlchemistNFTHelper.sol"; import {AlchemistV3Position} from "../AlchemistV3Position.sol"; import {AlchemistV3PositionRenderer} from "../AlchemistV3PositionRenderer.sol"; import {AlchemistETHVault} from "../AlchemistETHVault.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {MYTTestHelper} from "./libraries/MYTTestHelper.sol"; import {MockAlchemistAllocator} from "./mocks/MockAlchemistAllocator.sol"; import {MockMYTStrategy} from "./mocks/MockMYTStrategy.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {MockYieldToken} from "./mocks/MockYieldToken.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; // Tests for integration with Euler V2 Earn Vault contract IntegrationTest is Test { // Callable contract variables AlchemistV3 alchemist; Transmuter transmuter; AlchemistV3Position alchemistNFT; // // Proxy variables TransparentUpgradeableProxy proxyAlchemist; TransparentUpgradeableProxy proxyTransmuter; // // Contract variables // CheatCodes cheats = CheatCodes(HEVM_ADDRESS); AlchemistV3 alchemistLogic; Transmuter transmuterLogic; AlchemicTokenV3 alToken; Whitelist whitelist; // Total minted debt uint256 public minted; // Total debt burned uint256 public burned; // Total tokens sent to transmuter uint256 public sentToTransmuter; address weth = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); address ETH_USD_PRICE_FEED_MAINNET = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; uint256 ETH_USD_UPDATE_TIME_MAINNET = 3600 seconds; // Parameters for AlchemicTokenV2 string public _name; string public _symbol; uint256 public _flashFee; address public alOwner; mapping(address => bool) users; uint256 public constant FIXED_POINT_SCALAR = 1e18; uint256 public minimumCollateralization = uint256(FIXED_POINT_SCALAR * FIXED_POINT_SCALAR) / 9e17; // ----- Variables for deposits & withdrawals ----- // account funds to make deposits/test with uint256 accountFunds = 2_000_000_000e18; // amount of yield/underlying token to deposit uint256 depositAmount = 100_000e18; // minimum amount of yield/underlying token to deposit uint256 minimumDeposit = 1000e18; // minimum amount of yield/underlying token to deposit uint256 minimumDepositOrWithdrawalLoss = FIXED_POINT_SCALAR; // Fee receiver address receiver = address(0x521aB24368E5Ba8b727e9b8AB967073fF9316961); address alUSD = 0xBC6DA0FE9aD5f3b0d58160288917AA56653660E9; address USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address EULER_USDC = 0x797DD80692c3b2dAdabCe8e30C07fDE5307D48a9; // another random EOA for testing address anotherExternalUser = address(0x420Ab24368E5bA8b727E9B8aB967073Ff9316969); // another random EOA for testing address yetAnotherExternalUser = address(0x520aB24368e5Ba8B727E9b8aB967073Ff9316961); // MYT variables VaultV2 vault; MockAlchemistAllocator allocator; MockMYTStrategy mytStrategy; address public operator = address(20); // default operator address public admin = address(21); // DAO OSX address public curator = address(22); address public mockVaultCollateral; address public mockStrategyYieldToken; uint256 public defaultStrategyAbsoluteCap = 2_000_000_000e18; uint256 public defaultStrategyRelativeCap = 1e18; // 100% event TestIntegrationLog(string message, uint256 value); function setUp() external { string memory rpc = vm.envString("MAINNET_RPC_URL"); uint256 forkId = vm.createFork(rpc); vm.selectFork(forkId); // test maniplulation for convenience address caller = address(0xdead); address proxyOwner = address(this); vm.assume(caller != address(0)); vm.assume(proxyOwner != address(0)); vm.assume(caller != proxyOwner); setUpMYT(6); // 6 decimals for USDC underlying token addDepositsToMYT(); vm.startPrank(caller); /* deal(EULER_USDC, address(0xbeef), 100_000e18); deal(EULER_USDC, address(0xdad), 100_000e18); */ deal(alUSD, address(0xdad), 100_000e18); deal(alUSD, address(0xdead), 100_000e18); ITransmuter.TransmuterInitializationParams memory transParams = ITransmuter.TransmuterInitializationParams({ syntheticToken: alUSD, feeReceiver: receiver, timeToTransmute: 5_256_000, transmutationFee: 100, exitFee: 200, graphSize: 52_560_000 }); // Contracts and logic contracts alOwner = caller; transmuterLogic = new Transmuter(transParams); alchemistLogic = new AlchemistV3(); whitelist = new Whitelist(); // AlchemistV3 proxy AlchemistInitializationParams memory params = AlchemistInitializationParams({ admin: alOwner, debtToken: alUSD, underlyingToken: USDC, depositCap: type(uint256).max, minimumCollateralization: minimumCollateralization, collateralizationLowerBound: 1_052_631_578_950_000_000, // 1.05 collateralization globalMinimumCollateralization: 1_111_111_111_111_111_111, // 1.1 liquidationTargetCollateralization: uint256(1e36) / 88e16, // ~113.63% (88% LTV) transmuter: address(transmuterLogic), protocolFee: 100, protocolFeeReceiver: receiver, liquidatorFee: 300, // in bps? 3% repaymentFee: 100, myt: address(vault) }); bytes memory alchemParams = abi.encodeWithSelector(AlchemistV3.initialize.selector, params); proxyAlchemist = new TransparentUpgradeableProxy(address(alchemistLogic), proxyOwner, alchemParams); alchemist = AlchemistV3(address(proxyAlchemist)); transmuterLogic.setDepositCap(uint256(type(int256).max)); transmuterLogic.setAlchemist(address(alchemist)); alchemistNFT = new AlchemistV3Position(address(alchemist), alOwner); alchemistNFT.setMetadataRenderer(address(new AlchemistV3PositionRenderer())); alchemist.setAlchemistPositionNFT(address(alchemistNFT)); vm.stopPrank(); vm.startPrank(0x8392F6669292fA56123F71949B52d883aE57e225); IAlchemicToken(alUSD).setWhitelist(address(alchemist), true); IAlchemicToken(alUSD).setCeiling(address(alchemist), type(uint256).max); vm.stopPrank(); } function setUpMYT(uint256 alchemistUnderlyingTokenDecimals) public { vm.startPrank(admin); uint256 TOKEN_AMOUNT = 1_000_000; // Base token amount uint256 initialSupply = TOKEN_AMOUNT * 10 ** alchemistUnderlyingTokenDecimals; mockVaultCollateral = USDC; mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); vault = MYTTestHelper._setupVault(mockVaultCollateral, admin, curator); mytStrategy = MYTTestHelper._setupStrategy(address(vault), mockStrategyYieldToken, admin, "MockToken", "MockTokenProtocol", IMYTStrategy.RiskClass.LOW); allocator = new MockAlchemistAllocator(address(vault), admin, operator, address(new AlchemistStrategyClassifier(admin))); vm.stopPrank(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (address(allocator), true))); vault.setIsAllocator(address(allocator), true); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, address(mytStrategy))); vault.addAdapter(address(mytStrategy)); bytes memory idData = mytStrategy.getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, defaultStrategyAbsoluteCap))); vault.increaseAbsoluteCap(idData, defaultStrategyAbsoluteCap); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, defaultStrategyRelativeCap))); vault.increaseRelativeCap(idData, defaultStrategyRelativeCap); vm.stopPrank(); } function addDepositsToMYT() public { uint256 shares = _magicDepositToVault(address(vault), address(0xbeef), 1_000_000e6); emit TestIntegrationLog("0xbeef shares", shares); shares = _magicDepositToVault(address(vault), address(0xdad), 1_000_000e6); emit TestIntegrationLog("0xdad shares", shares); // then allocate to the strategy vm.startPrank(address(admin)); allocator.allocate(address(mytStrategy), vault.convertToAssets(vault.totalSupply())); vm.stopPrank(); } function _magicDepositToVault(address vault, address depositor, uint256 amount) internal returns (uint256) { deal(USDC, address(depositor), amount); vm.startPrank(depositor); TokenUtils.safeApprove(USDC, vault, amount); uint256 shares = IVaultV2(vault).deposit(amount, depositor); vm.stopPrank(); return shares; } function _vaultSubmitAndFastForward(bytes memory data) internal { vault.submit(data); bytes4 selector = bytes4(data); vm.warp(block.timestamp + vault.timelock(selector)); } function testRoundTrip() external { vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); (uint256 collateral,,) = alchemist.getCDP(tokenId); assertEq(collateral, 100_000e18); assertEq(IERC20(address(vault)).balanceOf(address(alchemist)), 100_000e18); alchemist.withdraw(100_000e18, address(0xbeef), tokenId); vm.stopPrank(); (collateral,,) = alchemist.getCDP(tokenId); assertEq(collateral, 0); assertEq(IERC20(address(vault)).balanceOf(address(0xbeef)), 1_000_000e18); } function testMint() external { vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, alchemist.getMaxBorrowable(tokenId), address(0xbeef)); (uint256 collateral, uint256 debt,) = alchemist.getCDP(tokenId); assertEq(collateral, 100_000e18); assertEq(debt, alchemist.convertYieldTokensToDebt(100_000e18) * FIXED_POINT_SCALAR / 1_111_111_111_111_111_111); vm.stopPrank(); } function testRepay() external { vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); alchemist.mint(tokenId, maxBorrow, address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); vm.roll(block.number + 1); alchemist.repay(alchemist.convertDebtTokensToYield(maxBorrow), tokenId); vm.stopPrank(); (uint256 collateral, uint256 debt,) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, 0, 9201); assertEq(collateral, 100_000e18); assertEq(IERC20(address(vault)).balanceOf(receiver), 0); } // ├─ emit TestIntegrationLog(message: "0xdad shares", value: 100000000000000000000000 [1e23]) function testRepayEarmarkedFull() external { uint256 debtAmount = alchemist.convertYieldTokensToDebt(100_000e18) * FIXED_POINT_SCALAR / 1_111_111_111_111_111_111; vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); alchemist.mint(tokenId, maxBorrow, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); IERC20(alUSD).approve(address(transmuterLogic), debtAmount); transmuterLogic.createRedemption(debtAmount, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000); (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, maxBorrow, 1); assertEq(collateral, 100_000e18); assertApproxEqAbs(earmarked, maxBorrow, 1); vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.repay(alchemist.convertDebtTokensToYield(maxBorrow), tokenId); vm.stopPrank(); (collateral, debt, earmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, 0, 9201); assertEq(collateral, 100_000e18 - alchemist.convertDebtTokensToYield(maxBorrow) * 100 / 10_000); assertApproxEqAbs(earmarked, 0, 9201); assertApproxEqAbs(IERC20(alchemist.myt()).balanceOf(address(transmuterLogic)), alchemist.convertDebtTokensToYield(maxBorrow), 1); assertEq(IERC20(address(vault)).balanceOf(receiver), alchemist.convertDebtTokensToYield(maxBorrow) * 100 / 10_000); } function testRepayEarmarkedPartialEarmarked() external { uint256 debtAmount = alchemist.convertYieldTokensToDebt(100_000e18) * FIXED_POINT_SCALAR / 1_111_111_111_111_111_111; vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); alchemist.mint(tokenId, maxBorrow, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); IERC20(alUSD).approve(address(transmuterLogic), debtAmount); transmuterLogic.createRedemption(debtAmount, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000 / 2); (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, maxBorrow, 1); assertApproxEqAbs(collateral, 100_000e18, 1); assertApproxEqAbs(earmarked, maxBorrow / 2, 1); vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.repay(alchemist.convertDebtTokensToYield(maxBorrow), tokenId); vm.stopPrank(); (collateral, debt, earmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, 0, 9201); uint256 expectedProtocolFee = alchemist.convertDebtTokensToYield(maxBorrow / 2) * 100 / 10_000; assertApproxEqAbs(collateral, 100_000e18 - expectedProtocolFee, 1); assertApproxEqAbs(earmarked, 0, 9201); assertApproxEqAbs(IERC20(alchemist.myt()).balanceOf(address(transmuterLogic)), alchemist.convertDebtTokensToYield(maxBorrow), 1); assertEq(IERC20(address(vault)).balanceOf(receiver), expectedProtocolFee); } function testRepayEarmarkedPartialRepayment() external { uint256 debtAmount = alchemist.convertYieldTokensToDebt(100_000e18) * FIXED_POINT_SCALAR / 1_111_111_111_111_111_111; vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); alchemist.mint(tokenId, maxBorrow, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); IERC20(alUSD).approve(address(transmuterLogic), debtAmount); transmuterLogic.createRedemption(debtAmount, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000 / 2); (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, maxBorrow, 1); assertApproxEqAbs(collateral, 100_000e18, 1); assertApproxEqAbs(earmarked, maxBorrow / 2, 1); vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.repay(alchemist.convertDebtTokensToYield(maxBorrow) / 2, tokenId); vm.stopPrank(); (collateral, debt, earmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, (maxBorrow / 2), 9201); assertApproxEqAbs(collateral, 100_000e18 - (alchemist.convertDebtTokensToYield(maxBorrow) / 2) * 100 / 10_000, 1); assertApproxEqAbs(earmarked, 0, 9201); assertApproxEqAbs(IERC20(alchemist.myt()).balanceOf(address(transmuterLogic)), alchemist.convertDebtTokensToYield(maxBorrow) / 2, 1); assertEq(IERC20(address(vault)).balanceOf(receiver), (alchemist.convertDebtTokensToYield(maxBorrow) * 100 / 10_000) / 2); } function testRepayEarmarkedOverRepayment() external { uint256 debtAmount = alchemist.convertYieldTokensToDebt(100_000e18) * FIXED_POINT_SCALAR / 1_111_111_111_111_111_111; vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); alchemist.mint(tokenId, maxBorrow, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); IERC20(alUSD).approve(address(transmuterLogic), debtAmount); transmuterLogic.createRedemption(debtAmount, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000 / 2); (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertApproxEqAbs(debt, maxBorrow, 1); assertApproxEqAbs(collateral, 100_000e18, 1); assertApproxEqAbs(earmarked, maxBorrow / 2, 1); uint256 beefStartingBalance = IERC20(alchemist.myt()).balanceOf(address(0xbeef)); vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.repay(alchemist.convertDebtTokensToYield(maxBorrow) * 2, tokenId); vm.stopPrank(); (collateral, debt, earmarked) = alchemist.getCDP(tokenId); uint256 beefEndBalance = IERC20(alchemist.myt()).balanceOf(address(0xbeef)); // Loss of precision. Small, but consider using LTV rather than minimum collateralization assertApproxEqAbs(debt, 0, 1); uint256 expectedProtocolFee = alchemist.convertDebtTokensToYield(maxBorrow / 2) * 100 / 10_000; assertEq(collateral, 100_000e18 - expectedProtocolFee); assertApproxEqAbs(earmarked, 0, 9201); // Overpayment sent back to user and transmuter received what was credited // uint256 amountSpent = maxBorrow / 2; // assertApproxEqAbs(beefStartingBalance - beefEndBalance, alchemist.convertDebtTokensToYield(amountSpent), 1); // assertApproxEqAbs(IERC20(alchemist.yieldToken()).balanceOf(address(transmuterLogic)), alchemist.convertDebtTokensToYield(amountSpent), 1); } function test_target_Burn() external { vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); alchemist.mint(tokenId, maxBorrow, address(0xbeef)); IERC20(alUSD).approve(address(alchemist), maxBorrow); vm.roll(block.number + 1); alchemist.burn(maxBorrow, tokenId); vm.stopPrank(); (uint256 collateral, uint256 debt,) = alchemist.getCDP(tokenId); assertEq(debt, 0); assertEq(collateral, 100_000e18); assertEq(IERC20(address(vault)).balanceOf(receiver), 0); } function testBurnWithEarmarkPartial() external { uint256 debtAmount = alchemist.convertYieldTokensToDebt(100_000e18) * FIXED_POINT_SCALAR / 1_111_111_111_111_111_111; vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); alchemist.mint(tokenId, maxBorrow, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xdad), 0); // a single position nft would have been minted to address(0xdad) uint256 tokenId2 = AlchemistNFTHelper.getFirstTokenId(address(0xdad), address(alchemistNFT)); uint256 maxBorrow2 = alchemist.getMaxBorrowable(tokenId2); alchemist.mint(tokenId2, maxBorrow2, address(0xdad)); vm.stopPrank(); vm.startPrank(address(0xdad)); IERC20(alUSD).approve(address(transmuterLogic), debtAmount); transmuterLogic.createRedemption(debtAmount, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000 / 2); vm.startPrank(address(0xbeef)); IERC20(alUSD).approve(address(alchemist), maxBorrow); alchemist.burn(maxBorrow, tokenId); vm.stopPrank(); (uint256 collateral, uint256 debt,) = alchemist.getCDP(tokenId); // Make sure only unEarmarked debt is repaid assertApproxEqAbs(debt, maxBorrow / 4, 2); // assertEq(collateral, 100_000e18); // // Make sure 0xbeef get remaining tokens back // // Overpayment goes towards fees accrued as well // assertApproxEqAbs(IERC20(alUSD).balanceOf(address(0xbeef)), maxBorrow / 4 - (debtAmount * 5_256_000 / 2_600_000 * 100 / 10_000) / 2, 1); } function testBurnFullyEarmarked() external { uint256 debtAmount = alchemist.convertYieldTokensToDebt(100_000e18) * FIXED_POINT_SCALAR / 1_111_111_111_111_111_111; vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); alchemist.mint(tokenId, maxBorrow, address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); IERC20(alUSD).approve(address(transmuterLogic), debtAmount); transmuterLogic.createRedemption(debtAmount, address(0xdad)); vm.stopPrank(); vm.roll(block.number + 5_256_000); vm.startPrank(address(0xbeef)); IERC20(alUSD).approve(address(alchemist), maxBorrow); vm.expectRevert(); alchemist.burn(maxBorrow, tokenId); vm.stopPrank(); } function testPositionToFullMaturity() external { uint256 debtAmount = alchemist.convertYieldTokensToDebt(100_000e18) * FIXED_POINT_SCALAR / 1_111_111_111_111_111_111; vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); // a single position nft would have been minted to address(0xbeef) uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, alchemist.getMaxBorrowable(tokenId), address(0xbeef)); vm.stopPrank(); vm.startPrank(address(0xdad)); IERC20(alUSD).approve(address(transmuterLogic), debtAmount); transmuterLogic.createRedemption(debtAmount, address(0xdad)); vm.stopPrank(); (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertEq(collateral, 100_000e18); assertEq(debt, debtAmount); // Transmuter Cycle vm.roll(block.number + 5_256_000); vm.startPrank(address(0xdad)); transmuterLogic.claimRedemption(1); vm.stopPrank(); (collateral, debt, earmarked) = alchemist.getCDP(tokenId); // 9% remaining since 90% was borrowed against initially + fee assertApproxEqAbs(collateral, 100_000e18 - alchemist.convertDebtTokensToYield(debtAmount) - (alchemist.convertDebtTokensToYield(debtAmount) * 100 / 10_000), 1); // Only remaining debt should be from the fees paid on debt assertApproxEqAbs(debt, 0, 1); assertEq(earmarked, 0); } function testAudit_Sync_IncorrectEarmarkWeightUpdate() external { uint256 bn = block.number; // 1. Add collateral and mints 10,000 alUSD as debt vm.startPrank(address(0xbeef)); IERC20(address(vault)).approve(address(alchemist), 100_000e18); alchemist.deposit(100_000e18, address(0xbeef), 0); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(address(0xbeef), address(alchemistNFT)); alchemist.mint(tokenId, 10_000e18, address(0xbeef)); vm.stopPrank(); // 2. Create a redemption for 1,000 alUSD vm.startPrank(address(0xdad)); IERC20(alUSD).approve(address(transmuterLogic), 1000e18); transmuterLogic.createRedemption(1000e18, address(0xdad)); vm.stopPrank(); vm.roll(bn += 5_256_000); // 3. Claim redemption vm.prank(address(0xdad)); transmuterLogic.claimRedemption(1); vm.roll(bn += 1); // 4. Update debt and earmark vm.prank(address(0xbeef)); alchemist.poke(tokenId); (, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertEq(debt, 10_000e18 - 1000e18); // 10,000 - 1,000 assertEq(earmarked, 0); // 5. Create another redemption for 1,000 alUSD vm.startPrank(address(0xdad)); IERC20(alUSD).approve(address(transmuterLogic), 1000e18); transmuterLogic.createRedemption(1000e18, address(0xdad)); vm.stopPrank(); vm.roll(bn += 5_256_000); // 6. Update debt and earmark vm.prank(address(0xbeef)); alchemist.poke(tokenId); // 7. Create another redemption for 1,000 alUSD vm.startPrank(address(0xdad)); IERC20(alUSD).approve(address(transmuterLogic), 1000e18); transmuterLogic.createRedemption(1000e18, address(0xdad)); vm.stopPrank(); vm.roll(bn += 5_256_000); // 8. Update debt and earmark vm.prank(address(0xbeef)); alchemist.poke(tokenId); } function ClaimResdemptionTransmuter(address user) internal { vm.startPrank(user); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(transmuterLogic)); transmuterLogic.claimRedemption(tokenId); vm.stopPrank(); } function depositToAlchemix(uint256 shares, address user) internal { vm.startPrank(user); IERC20(address(vault)).approve(address(alchemist),shares); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(alchemistNFT)); alchemist.deposit(shares,user, tokenId); vm.stopPrank(); } function MintOnAlchemix(uint256 toMint, address user) internal { vm.startPrank(user); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(alchemistNFT)); assertGt(tokenId,0,"cannot mint to user with no positions"); alchemist.mint(tokenId, toMint, user); vm.stopPrank(); } function RedeemOnTransmuter(address user, uint256 debtAmount) internal { vm.startPrank(user); IERC20(alUSD).approve(address(transmuterLogic), debtAmount); transmuterLogic.createRedemption(debtAmount, user); vm.stopPrank(); } function moveTime(uint256 blocks) internal { vm.warp(block.timestamp+blocks*12); vm.roll(block.number+blocks); } function printState(address user, string memory message) internal { uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(alchemistNFT)); //poke to refresh _earmark and sync alchemist.poke(tokenId); uint256 totalDebt = alchemist.totalDebt(); uint256 synthIssued = alchemist.totalSyntheticsIssued(); uint256 transmuterCollBalance = IERC20(address(alchemist.myt())).balanceOf(address(transmuterLogic)); uint256 transmuterDebtCoverage = alchemist.convertYieldTokensToDebt(transmuterCollBalance); uint256 transmuterLocked = transmuterLogic.totalLocked(); (uint256 collateral, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); uint256 alchemistCollBalance = IERC20(address(alchemist.myt())).balanceOf(address(alchemist)); uint256 alchemistCollInDebt = alchemist.convertYieldTokensToDebt(alchemistCollBalance); console.log("%s",message); console.log("Alchemist Total Debt: %s",totalDebt/1e18); console.log("Alchemist cumulative earmarked: %s", alchemist.cumulativeEarmarked() / 1e18); console.log("Alchemist getTotalUnderlyingValue (_mytSharesDeposited value in debt tokens)", alchemist.getTotalUnderlyingValue()/1e6); console.log("Alchemist Actual Collateral balance value in debt tokens", alchemistCollInDebt / 1e18); console.log("Transmuter Debt Coverage (collateral balance value in debt tokens): %s",transmuterDebtCoverage/1e18); console.log("Transmuter Locked Synthetic tokens: %s",transmuterLocked/1e18); console.log("Total Synthetic token Issuance: %s",synthIssued/1e18); console.log("CDP info - collateral: %s, debt %s, earmarked %s\n\n", collateral/1e18, debt/1e18, earmarked/1e18); } function testEarmarkTransmuterIncreasePOC() public { address bob = makeAddr("bob"); address redeemer1 = makeAddr("redeemer1"); //deposit 300 underlying to vault uint256 sharesBob = _magicDepositToVault(address(vault), bob, 300e6); //deposit 115 vault shares to alchemix uint256 depositedCollateral = 115e18; depositToAlchemix(depositedCollateral, bob); //mint a debt of 100 uint256 mintAmount = 100e18; MintOnAlchemix(mintAmount,bob); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(bob, address(alchemistNFT)); //transfer 50 to redeemer vm.startPrank(bob); IERC20(alUSD).transfer(redeemer1, mintAmount /2); vm.stopPrank(); //donation of 50 coll tokens to Transmuter vm.startPrank(bob); vault.transfer(address(transmuterLogic), 50e18); vm.stopPrank(); // make sure _earmark can run in a new block vm.roll(block.number + 1); // IMPORTANT: force Alchemist to observe the cover *before* the redemption exists alchemist.poke(tokenId); //create, vest and redeem RedeemOnTransmuter(redeemer1, mintAmount / 2); moveTime(transmuterLogic.timeToTransmute()); ClaimResdemptionTransmuter(redeemer1); printState(bob,"System final state"); } function testVulnerability_Repay_PrecisionLoss_LocksCollateral_FullTrigger() external { // 1. ARRANGE: Ensure we are in a 6-decimal underlying token environment require(TokenUtils.expectDecimals(alchemist.underlyingToken()) == 6, "Test setup failed: Underlying token is not 6 decimals"); require(TokenUtils.expectDecimals(alchemist.debtToken()) == 18, "Test setup failed: Debt token is not 18 decimals"); // Participants: address userA_victim = yetAnotherExternalUser; // The victim whose funds will be locked address userB_burner = anotherExternalUser; // The burner who sets the trap address userC_global = address(0xbeef); // Another user to ensure global state is redeemable deal(address(vault), yetAnotherExternalUser, 100_000e18); deal(address(vault), anotherExternalUser, 100_000e18); // 2. ARRANGE: Set up the scenario // User A (Victim) deposits and mints vm.startPrank(userA_victim); uint256 depositAmountA = 100_000e18; // 100,000 MYT SafeERC20.safeApprove(address(vault), address(alchemist), depositAmountA); alchemist.deposit(depositAmountA, userA_victim, 0); // tokenId 1 uint256 tokenId_A = AlchemistNFTHelper.getFirstTokenId(userA_victim, address(alchemistNFT)); uint256 debtToMintA = 50_000e18; // 50,000 alToken alchemist.mint(tokenId_A, debtToMintA, userB_burner); // Send the debtToken to User B vm.stopPrank(); // User C (Global participant) deposits, mints, and *repays* to fund the Transmuter vm.startPrank(userC_global); uint256 depositAmountC = 100_000e18; // 100,000 MYT SafeERC20.safeApprove(address(vault), address(alchemist), depositAmountC); alchemist.deposit(depositAmountC, userC_global, 0); // tokenId 2 uint256 tokenId_C = AlchemistNFTHelper.getFirstTokenId(userC_global, address(alchemistNFT)); uint256 debtToMintC = 50_000e18; // 50,000 alToken alchemist.mint(tokenId_C, debtToMintC, userC_global); uint256 repayAmountC = 10_000e18; // 10,000 MYT SafeERC20.safeApprove(address(vault), address(alchemist), repayAmountC); // We must advance one block to avoid the `CannotRepayOnMintBlock` check on `repay` vm.roll(block.number + 1); alchemist.repay(repayAmountC, tokenId_C); vm.stopPrank(); // [FIX]: // 2.5 ARRANGE: // The `repay` call sent 1e22 (repayAmountC) of MYT to the transmuter. // We must simulate the transmuter calling setTransmuterTokenBalance // to update Alchemist's `lastTransmuterTokenBalance` accounting. // Otherwise, this 1e22 MYT will be counted as "cover" during `_earmark` // and offset our simulated yield. vm.startPrank(address(transmuterLogic)); alchemist.setTransmuterTokenBalance(repayAmountC); vm.stopPrank(); // 3. ARRANGE: Calculate the `amountToBurn` needed to trigger the vulnerability uint256 conversionFactor = 10**(TokenUtils.expectDecimals(alchemist.debtToken()) - TokenUtils.expectDecimals(alchemist.underlyingToken())); uint256 amountToBurn = conversionFactor - 1; // Key: amount is less than the conversion factor // 4. ARRANGE: Warp time forward significantly vm.roll(block.number + 5_256_000 / 2); // Warp forward half a year // 4.5 ARRANGE: Simulate Transmuter yield generation // Explanation: This is a valid prerequisite for testing the AlchemistV3 vulnerability. uint256 simulatedYieldInDebt = 10_000e18; // Simulate 10,000 alToken of yield // Mock *any* call to the `queryGraph(uint256,uint256)` function vm.mockCall( address(transmuterLogic), abi.encodeWithSelector(ITransmuter.queryGraph.selector, 0, 0), // Match any arguments abi.encode(simulatedYieldInDebt) // The mocked return value ); // Clear previous mocks, just keep this one vm.clearMockedCalls(); vm.mockCall( address(transmuterLogic), abi.encodeWithSelector(ITransmuter.queryGraph.selector), // Match selector with no args (just in case) abi.encode(simulatedYieldInDebt) ); // **The most critical mock rule**: // AlchemistV3 calls `queryGraph` with arguments vm.mockCall( address(transmuterLogic), abi.encodeWithSelector( ITransmuter.queryGraph.selector, block.number, // lastEarmarkBlock + 1 block.number + 1 // block.number (inside burn) ), abi.encode(simulatedYieldInDebt) ); // **Wildcard mock (most reliable)** vm.mockCall( address(transmuterLogic), bytes4(keccak256("queryGraph(uint256,uint256)")), abi.encode(simulatedYieldInDebt) ); // 5. ACT: (Set the trap) User B burns a tiny amount of debt vm.startPrank(userB_burner); TokenUtils.safeApprove(alchemist.debtToken(), address(alchemist), amountToBurn); alchemist.burn(amountToBurn, tokenId_A); // This will call _earmark vm.stopPrank(); // 6. ASSERT: (Verify the trap is set) (uint256 collateralBefore, uint256 debtAfterBurn,) = alchemist.getCDP(tokenId_A); assertEq(debtAfterBurn, debtToMintA - amountToBurn, "Debt was not reduced correctly after burn"); // 7. ARRANGE: (Trigger global event) Transmuter executes redeem uint256 earmarked = alchemist.cumulativeEarmarked(); // Assert Earmark was successful assertTrue(earmarked > 0, "Earmark failed, no funds to redeem. Transmuter did not generate yield."); (uint256 collBefore, uint256 debtBefore, ) = alchemist.getCDP(tokenId_A); uint256 totalDebtBeforeRedeem = alchemist.totalDebt(); uint256 bps = 10_000; uint256 feeBps = alchemist.protocolFee(); vm.startPrank(address(transmuterLogic)); alchemist.redeem(earmarked); vm.stopPrank(); (uint256 collAfter, uint256 debtAfter, ) = alchemist.getCDP(tokenId_A); // Basic sanity: redeem must change something assertLt(debtAfter, debtBefore, "Debt did not decrease after redeem (this would be suspicious)"); assertLt(collAfter, collBefore, "Collateral did not decrease after redeem (unexpected for redemption)"); // Actual deltas for the victim uint256 debtDelta = debtBefore - debtAfter; uint256 collDelta = collBefore - collAfter; // How much debt was actually redeemed globally (redeem() does: totalDebt -= amount) uint256 totalDebtAfterRedeem = alchemist.totalDebt(); uint256 redeemedDebt = totalDebtBeforeRedeem - totalDebtAfterRedeem; // In this test, you pass `earmarked` and there is no clamp expected assertEq(redeemedDebt, earmarked, "Redeemed debt != earmarked (unexpected clamp/change)"); // ---- Expected pro‑rata behavior checks ---- // Victim should be debited collateral proportional to their debt forgiven, including protocol fee. // redeem() transfers out: // collRedeemed = convertDebtTokensToYield(redeemedDebt) // feeCollateral = collRedeemed * protocolFee / BPS // totalOut = collRedeemed + feeCollateral uint256 collRedeemed = alchemist.convertDebtTokensToYield(redeemedDebt); uint256 totalOut = collRedeemed + (collRedeemed * feeBps) / bps; // In _sync(), per-account collateral debit is: // sharesToDebit = mulDivUp(redeemedTotal, globalSharesDelta, globalDebtDelta) // where globalSharesDelta == totalOut, globalDebtDelta == redeemedDebt, // and redeemedTotal == debtDelta (the victim’s debt reduction from redemption). uint256 expectedCollDelta = (debtDelta * totalOut + redeemedDebt - 1) / redeemedDebt; // mulDivUp assertEq( collDelta, expectedCollDelta, "Collateral delta does not match redemption accounting (principal + fee)" ); // Optional but useful: debtDelta should be ~ pro‑rata share of redeemedDebt based on victim debt // (allow a tolerance of `conversionFactor` because of flooring to underlying units). uint256 expectedDebtDelta = (redeemedDebt * debtBefore) / totalDebtBeforeRedeem; uint256 tol = conversionFactor; // you already computed this above as 10^(debtDecimals-underlyingDecimals) assertApproxEqAbs( debtDelta, expectedDebtDelta, tol, "Debt delta is not roughly prorata to redeemed amount" ); // Optional: equity loss should be ~ protocol fee only (not principal). // Equity (in debt units) = collateralValueInDebt - debt. // redemption reduces debt by debtDelta, and collateral by ~ (debtDelta + fee) uint256 valueBefore = alchemist.convertYieldTokensToDebt(collBefore); uint256 valueAfter = alchemist.convertYieldTokensToDebt(collAfter); uint256 equityBefore = valueBefore - debtBefore; uint256 equityAfter = valueAfter - debtAfter; uint256 equityLoss = equityBefore - equityAfter; uint256 expectedEquityLoss = (debtDelta * feeBps) / bps; assertApproxEqAbs( equityLoss, expectedEquityLoss, tol, "Equity loss not approximately equal to protocol fee" ); } function test_claimRedemption_locked_POC() external { deal(alUSD, address(0xdad), 0); deal(alUSD, address(0xdead), 0); uint256 amount = 100e18; vm.startPrank(address(0xdad)); SafeERC20.safeApprove(address(vault), address(alchemist), amount + 100e18); alchemist.deposit(amount, address(0xdad), 0); // a single position nft would have been minted to 0xbeef uint256 tokenIdFor0xDad = AlchemistNFTHelper.getFirstTokenId(address(0xdad), address(alchemistNFT)); alchemist.mint(tokenIdFor0xDad, ((amount *1e18)/ alchemist.minimumCollateralization()), address(0xdad)); SafeERC20.safeApprove(address(alUSD), address(transmuterLogic), alchemist.totalSyntheticsIssued()); transmuterLogic.createRedemption(IERC20(alUSD).balanceOf(address(0xdad)), address(0xdad)); vm.roll(block.number + transmuterLogic.timeToTransmute()); alchemist.poke(tokenIdFor0xDad); uint256 stateBefore = vm.snapshotState(); SafeERC20.safeTransfer(address(vault), address(transmuterLogic), 91e18); transmuterLogic.claimRedemption(1); // SafeERC20.safeTransfer(address(vault), address(transmuterLogic), 40e18); uint256 cumulativeEarmark_After_Claim_With_Transfer=alchemist.cumulativeEarmarked(); uint256 totalDebt_After_Claim_With_Transfer=alchemist.totalDebt(); uint256 totalSyntheticsIssued_After_Claim_With_Transfer=alchemist.totalSyntheticsIssued(); vm.roll(block.number + 1); vm.expectRevert(IllegalArgument.selector); uint256 leave = 1e12; // smallest unit that becomes >=1 underlying “microunit” in convertToAssets alchemist.withdraw(9.1e18 - leave, address(0xdad), tokenIdFor0xDad); vm.revertTo(stateBefore); transmuterLogic.claimRedemption(1); uint256 cumulativeEarmark_After_Claim_Without_Transfer=alchemist.cumulativeEarmarked(); uint256 totalDebt_After_Claim_Without_Transfer=alchemist.totalDebt(); uint256 totalSyntheticsIssued_After_Claim_Without_Transfer=alchemist.totalSyntheticsIssued(); assertEq(cumulativeEarmark_After_Claim_Without_Transfer,0); assertEq(totalDebt_After_Claim_Without_Transfer,0); assertLt(totalSyntheticsIssued_After_Claim_With_Transfer, 1e12); assertLt(totalSyntheticsIssued_After_Claim_Without_Transfer, 1e12); assertEq(cumulativeEarmark_After_Claim_With_Transfer,90000000000000000009); assertEq(totalDebt_After_Claim_With_Transfer,90000000000000000009); vm.roll(block.number + 1); alchemist.withdraw(9.1e18 - leave, address(0xdad), tokenIdFor0xDad); vm.stopPrank(); } } ================================================ FILE: src/test/Invariants/CrucibleTest.sol ================================================ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.0; import "../InvariantsTest.t.sol"; import {ITestYieldToken} from "../../interfaces/test/ITestYieldToken.sol"; /// @title CrucibleTest — Extreme stress-testing for yield loss, cascading liquidation, and recovery /// @notice Standalone invariant suite (does NOT inherit HardenedInvariantsTest). /// Extends InvariantsTest for base infrastructure only. /// /// Purpose: /// Test the full lifecycle: normal → yield accrual → catastrophic loss → cascading /// liquidations → bad debt socialization via transmuter → recovery through new yield. /// /// Handlers (position-building): /// - depositCollateral: create/grow positions /// - borrowCollateral: take on debt /// - repayDebt: repay with yield tokens /// - transmuterStake: lock synthetics in transmuter /// - mine: advance blocks /// /// Handlers (stress): /// - accrueYield: steady share-price increases (0.01-0.5%) /// - realizeLargeValueLoss: catastrophic loss events (5-30%) /// - cascadeLiquidations: sweep all undercollateralized positions /// - transmuterClaimDuringBadDebt: exercise badDebtRatio scaling path /// - recoverFromLoss: inject recovery yield (10-50%) /// /// Invariants: /// - C1: Storage debt consistency (sum(account.debt) ≈ totalDebt after poke) /// - C2: Share price > 0 (no total wipeout) /// - C3: Bad debt bounded by realized losses /// - C4: Post-liquidation positions are healthy or zeroed /// - C5: Recovery works (net positive yield → no bad debt) /// - C6: Synthetics balance (totalSyntheticsIssued >= transmuter.totalLocked) /// - C7: cumulativeEarmarked <= totalDebt contract CrucibleTest is InvariantsTest { // ═══════ Ghost tracking ═══════ uint256 public totalYieldAccrued; uint256 public totalLossRealized; uint256 public cascadingLiquidationRounds; uint256 public badDebtEvents; uint256 public recoveryEvents; uint256 public liquidationAttempts; uint256 public liquidationSuccesses; uint256 public yieldAccruals; uint256 public handlerSkips; // Debt consistency tracking uint256 public maxDebtDelta; uint256 public maxEarmarkDelta; uint256 public maxCollateralDelta; bool public inBadDebtState; bool public lossAfterLastLiquidation; // tracks if a loss occurred after the most recent cascade bool public recoverySinceLastStress; // successful recovery checkpoint still current for C5 uint256 internal constant MAX_TEST_VALUE = 1e28; bytes4 internal constant ILLEGAL_STATE_SELECTOR = bytes4(keccak256("IllegalState()")); function setUp() public virtual override { // Position-building handlers selectors.push(this.depositCollateral.selector); selectors.push(this.borrowCollateral.selector); selectors.push(this.repayDebt.selector); selectors.push(this.transmuterStake.selector); selectors.push(this.transmuterClaim.selector); selectors.push(this.mine.selector); // Stress handlers selectors.push(this.accrueYield.selector); selectors.push(this.realizeLargeValueLoss.selector); selectors.push(this.cascadeLiquidations.selector); selectors.push(this.transmuterClaimDuringBadDebt.selector); selectors.push(this.recoverFromLoss.selector); super.setUp(); } // ═══════════════════════════════════════════════════════════════ // HELPERS (from HardenedInvariantsTest — duplicated here for // standalone operation without inheriting that test suite) // ═══════════════════════════════════════════════════════════════ /// @dev Returns first tokenId for `user`, or 0 if no position NFT. function _getTokenId(address user) internal view returns (uint256) { if (IERC721Enumerable(address(alchemistNFT)).balanceOf(user) == 0) return 0; return IERC721Enumerable(address(alchemistNFT)).tokenOfOwnerByIndex(user, 0); } function _findMinter(address[] memory users, uint256 seed) internal view returns (address) { address[] memory candidates = new address[](users.length); for (uint256 i; i < users.length; ++i) { uint256 tokenId = _getTokenId(users[i]); if (tokenId != 0 && alchemist.getMaxBorrowable(tokenId) > 0) { candidates[i] = users[i]; } } return _randomNonZero(candidates, seed); } function _findRepayer(address[] memory users, uint256 seed) internal view returns (address) { address[] memory candidates = new address[](users.length); for (uint256 i; i < users.length; ++i) { uint256 tokenId = _getTokenId(users[i]); if (tokenId != 0) { (, uint256 debt,) = alchemist.getCDP(tokenId); if (debt > 0) candidates[i] = users[i]; } } return _randomNonZero(candidates, seed); } function _findClaimer(address[] memory users, uint256 seed) internal view returns (address) { address[] memory candidates = new address[](users.length); for (uint256 i; i < users.length; ++i) { if (IERC721Enumerable(address(transmuterLogic)).balanceOf(users[i]) > 0) { candidates[i] = users[i]; } } return _randomNonZero(candidates, seed); } function _absDiff(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a - b : b - a; } function _max(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a : b; } function _handleExpectedBadDebtRevert(bytes memory reason) internal { bytes4 selector; if (reason.length >= 4) { assembly { selector := mload(add(reason, 32)) } } // Handlers are allowed to no-op when deposit/mint are blocked by protocol bad debt. if (_isBadDebt() && selector == ILLEGAL_STATE_SELECTOR) { handlerSkips++; return; } if (reason.length == 0) revert(); assembly { revert(add(reason, 32), mload(reason)) } } // ═══════════════════════════════════════════════════════════════ // BAD DEBT HELPERS // ═══════════════════════════════════════════════════════════════ function _isBadDebt() internal view returns (bool) { uint256 totalSynthetics = alchemist.totalSyntheticsIssued(); if (totalSynthetics == 0) return false; address myt = alchemist.myt(); uint256 transmuterShares = IERC20(myt).balanceOf(address(transmuterLogic)); uint256 backingUnderlying = alchemist.getTotalLockedUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(transmuterShares); uint256 backingDebt = alchemist.normalizeUnderlyingTokensToDebt(backingUnderlying); return totalSynthetics > backingDebt; } function _checkBadDebtState() internal { inBadDebtState = _isBadDebt(); } // ═══════════════════════════════════════════════════════════════ // HANDLER: Deposit Collateral // Creates/grows positions. Uses previewMint for correct // underlying amount at current share price. // ═══════════════════════════════════════════════════════════════ function depositCollateral(uint256 amount, uint256 onBehalfSeed) external { address onBehalf = _randomNonZero(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) { handlerSkips++; return; } amount = bound(amount, 1, MAX_TEST_VALUE); uint256 tokenId = _getTokenId(onBehalf); uint256 underlyingNeeded = vault.previewMint(amount); if (underlyingNeeded == 0) { handlerSkips++; return; } deal(mockVaultCollateral, onBehalf, underlyingNeeded); vm.startPrank(onBehalf); IERC20(mockVaultCollateral).approve(address(vault), underlyingNeeded); vault.mint(amount, onBehalf); try alchemist.deposit(amount, onBehalf, tokenId) returns (uint256, uint256) { vm.stopPrank(); } catch (bytes memory reason) { vm.stopPrank(); _handleExpectedBadDebtRevert(reason); } } // ═══════════════════════════════════════════════════════════════ // HANDLER: Borrow (Mint Debt) // mint() does NOT call _sync() — poke AFTER vm.roll to sync. // ═══════════════════════════════════════════════════════════════ function borrowCollateral(uint256 amount, uint256 onBehalfSeed) external { address onBehalf = _findMinter(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) { handlerSkips++; return; } vm.roll(block.number + 1); uint256 tokenId = _getTokenId(onBehalf); if (tokenId == 0) { handlerSkips++; return; } alchemist.poke(tokenId); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); if (maxBorrow == 0) { handlerSkips++; return; } amount = bound(amount, 1, maxBorrow); vm.prank(onBehalf); try alchemist.mint(tokenId, amount, onBehalf) { recoverySinceLastStress = false; } catch (bytes memory reason) { _handleExpectedBadDebtRevert(reason); } } // ═══════════════════════════════════════════════════════════════ // HANDLER: Repay Debt (with yield tokens) // Uses previewMint for correct underlying at current price. // ═══════════════════════════════════════════════════════════════ function repayDebt(uint256 amount, uint256 onBehalfSeed) external { address onBehalf = _findRepayer(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) { handlerSkips++; return; } uint256 tokenId = _getTokenId(onBehalf); if (tokenId == 0) { handlerSkips++; return; } (, uint256 debt,) = alchemist.getCDP(tokenId); if (debt == 0) { handlerSkips++; return; } uint256 maxRepayShares = alchemist.convertDebtTokensToYield(debt); if (maxRepayShares == 0) { handlerSkips++; return; } amount = bound(amount, 1, maxRepayShares); uint256 underlyingNeeded = vault.previewMint(amount); if (underlyingNeeded == 0) { handlerSkips++; return; } vm.roll(block.number + 1); deal(mockVaultCollateral, onBehalf, underlyingNeeded); vm.startPrank(onBehalf); IERC20(mockVaultCollateral).approve(address(vault), underlyingNeeded); vault.mint(amount, onBehalf); alchemist.repay(amount, tokenId); vm.stopPrank(); } // ═══════════════════════════════════════════════════════════════ // HANDLER: Transmuter Stake // Uses existing alToken balance (from borrowing) or borrows // through the alchemist. Never mints alTokens directly — that // would bypass totalSyntheticsIssued tracking and corrupt C6/C7. // ═══════════════════════════════════════════════════════════════ function transmuterStake(uint256 amount, uint256 onBehalfSeed) external { uint256 totalIssued = alchemist.totalSyntheticsIssued(); uint256 totalLocked = transmuterLogic.totalLocked(); if (totalIssued <= totalLocked) { handlerSkips++; return; } uint256 maxStakeable = totalIssued - totalLocked; address[] memory senders = targetSenders(); // Find a user with alToken balance (from prior borrowing) address staker; uint256 available; uint256 startIdx = onBehalfSeed % senders.length; for (uint256 i; i < senders.length; ++i) { address candidate = senders[(startIdx + i) % senders.length]; uint256 bal = alToken.balanceOf(candidate); if (bal > 0) { staker = candidate; available = bal; break; } } // If nobody has balance, borrow through alchemist to create alTokens if (staker == address(0)) { address minter = _findMinter(senders, onBehalfSeed); if (minter == address(0)) { handlerSkips++; return; } uint256 tokenId = _getTokenId(minter); if (tokenId == 0) { handlerSkips++; return; } vm.roll(block.number + 1); alchemist.poke(tokenId); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); if (maxBorrow == 0) { handlerSkips++; return; } uint256 borrowAmt = bound(amount, 1, maxBorrow); vm.prank(minter); try alchemist.mint(tokenId, borrowAmt, minter) { recoverySinceLastStress = false; } catch (bytes memory reason) { _handleExpectedBadDebtRevert(reason); return; } staker = minter; available = alToken.balanceOf(staker); // Re-read after mint (totalSyntheticsIssued changed) totalIssued = alchemist.totalSyntheticsIssued(); totalLocked = transmuterLogic.totalLocked(); if (totalIssued <= totalLocked) { handlerSkips++; return; } maxStakeable = totalIssued - totalLocked; } if (available == 0 || maxStakeable == 0) { handlerSkips++; return; } uint256 cap = available > maxStakeable ? maxStakeable : available; amount = bound(amount, 1, cap); vm.startPrank(staker); alToken.approve(address(transmuterLogic), amount); transmuterLogic.createRedemption(amount, staker); vm.stopPrank(); } // ═══════════════════════════════════════════════════════════════ // HANDLER: Transmuter Claim // ═══════════════════════════════════════════════════════════════ function transmuterClaim(uint256 onBehalfSeed) external { address onBehalf = _findClaimer(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) { handlerSkips++; return; } uint256 tid = IERC721Enumerable(address(transmuterLogic)).tokenOfOwnerByIndex(onBehalf, 0); vm.roll(block.number + 10000); vm.startPrank(onBehalf); transmuterLogic.claimRedemption(tid); vm.stopPrank(); recoverySinceLastStress = false; } // ═══════════════════════════════════════════════════════════════ // STRESS HANDLER: Steady Yield Accrual (0.01-0.5%) // Simulates real strategy harvest → pricePerShare increases. // Positions become healthier, earmark capacity increases. // ═══════════════════════════════════════════════════════════════ function accrueYield(uint256 yieldBps) external { yieldBps = bound(yieldBps, 1, 50); uint256 currentUnderlying = IERC20(mockVaultCollateral).balanceOf(mockStrategyYieldToken); uint256 yieldAmount = currentUnderlying * yieldBps / 10000; if (yieldAmount == 0) { handlerSkips++; return; } deal(mockVaultCollateral, address(this), yieldAmount); IERC20(mockVaultCollateral).approve(mockStrategyYieldToken, yieldAmount); ITestYieldToken(mockStrategyYieldToken).slurp(yieldAmount); totalYieldAccrued += yieldAmount; yieldAccruals++; vm.roll(block.number + bound(yieldBps, 10, 1000)); } // ═══════════════════════════════════════════════════════════════ // STRESS HANDLER: Large Value Loss (5-30%) // Simulates hack/depeg/slashing. Drops share price significantly, // potentially pushing multiple positions below liquidation threshold. // Floor: won't drain below 5% to avoid total wipeout. // ═══════════════════════════════════════════════════════════════ function realizeLargeValueLoss(uint256 lossBps) external { lossBps = bound(lossBps, 500, 3000); uint256 currentUnderlying = IERC20(mockVaultCollateral).balanceOf(mockStrategyYieldToken); uint256 lossAmount = currentUnderlying * lossBps / 10000; // Don't drain below 5% uint256 remaining = currentUnderlying - lossAmount; if (remaining < currentUnderlying / 20) { lossAmount = currentUnderlying - currentUnderlying / 20; } if (lossAmount == 0) { handlerSkips++; return; } ITestYieldToken(mockStrategyYieldToken).siphon(lossAmount); totalLossRealized += lossAmount; lossAfterLastLiquidation = true; recoverySinceLastStress = false; _checkBadDebtState(); } // ═══════════════════════════════════════════════════════════════ // STRESS HANDLER: Cascade Liquidations // Iterates all positions. Pokes each, then liquidates any that // are below collateralizationLowerBound. Tracks multi-position // cascade rounds separately. // // Tests: sequential liquidations, _subDebt under rapid state // changes, global accounting consistency after multiple liquidations. // ═══════════════════════════════════════════════════════════════ function cascadeLiquidations() external { // Guard: share price must be > 0, otherwise _liquidate early-returns (0,0,0) → LiquidationError if (vault.convertToAssets(1e18) == 0) { handlerSkips++; return; } // Guard: totalDebt must be > 0, otherwise _doLiquidation divides by zero in // normalizeUnderlyingTokensToDebt(...) * FIXED_POINT_SCALAR / totalDebt if (alchemist.totalDebt() == 0) { handlerSkips++; return; } address[] memory senders = targetSenders(); uint256 liquidatedCount; for (uint256 i; i < senders.length; ++i) { uint256 tid = _getTokenId(senders[i]); if (tid == 0) continue; alchemist.poke(tid); (, uint256 debt,) = alchemist.getCDP(tid); if (debt == 0) continue; uint256 collateralValue = alchemist.totalValue(tid); uint256 lowerBound = alchemist.collateralizationLowerBound(); uint256 requiredCollateral = (debt * lowerBound) / FIXED_POINT_SCALAR; if (collateralValue <= requiredCollateral) { // Re-check totalDebt after poke — prior liquidations in this loop // may have clamped totalDebt to 0, which would cause overflow in // _doLiquidation's global collateralization calculation. if (alchemist.totalDebt() == 0) break; // Guard: if the product normalizeUnderlyingTokensToDebt(totalUnderlying) * 1e18 // would overflow uint256 when divided by totalDebt, skip this liquidation. // This happens when totalDebt is dust-level after cascading liquidations. uint256 totalUnderlying = alchemist.convertYieldTokensToUnderlying( IERC20(address(vault)).balanceOf(address(alchemist)) ); uint256 totalDebtNormalized = alchemist.normalizeUnderlyingTokensToDebt(totalUnderlying); // Check if totalDebtNormalized * FIXED_POINT_SCALAR would overflow if (totalDebtNormalized > type(uint256).max / FIXED_POINT_SCALAR) break; liquidationAttempts++; try alchemist.liquidate(tid) returns (uint256 amt, uint256, uint256) { if (amt > 0) { liquidationSuccesses++; liquidatedCount++; } } catch (bytes memory reason) { bytes4 selector; if (reason.length >= 4) { assembly { selector := mload(add(reason, 32)) } } // LiquidationError() means nothing was liquidatable after sync/rounding. // This is an expected no-op path for the stress harness. if (selector == 0xf478bcad) { handlerSkips++; } else { if (reason.length == 0) revert(); assembly { revert(add(reason, 32), mload(reason)) } } } } } if (liquidatedCount > 0) { lossAfterLastLiquidation = false; // reset — we just cleaned up recoverySinceLastStress = false; if (liquidatedCount > 1) { cascadingLiquidationRounds++; } } else { handlerSkips++; } } // ═══════════════════════════════════════════════════════════════ // STRESS HANDLER: Transmuter Claim During Bad Debt // Exercises the badDebtRatio scaling path in claimRedemption(). // When totalSynthetics > backing, claimant receives less yield // than their transmuted amount — socializes the loss. // // If nobody has a transmuter position yet, this handler creates // one: mints synthetics, deposits to transmuter (creating an NFT), // advances blocks, then claims. No more skipping. // ═══════════════════════════════════════════════════════════════ function transmuterClaimDuringBadDebt(uint256 onBehalfSeed, uint256 stakeAmount) external { address[] memory senders = targetSenders(); address onBehalf = _findClaimer(senders, onBehalfSeed); // If nobody has a transmuter position, create one properly (no direct alToken.mint!) if (onBehalf == address(0)) { uint256 totalIssued = alchemist.totalSyntheticsIssued(); uint256 totalLocked = transmuterLogic.totalLocked(); // Find alToken balance to stake address staker; uint256 available; uint256 startIdx = onBehalfSeed % senders.length; for (uint256 i; i < senders.length; ++i) { address candidate = senders[(startIdx + i) % senders.length]; uint256 bal = alToken.balanceOf(candidate); if (bal > 0) { staker = candidate; available = bal; break; } } // If nobody has balance, borrow through alchemist if (staker == address(0)) { address minter = _findMinter(senders, onBehalfSeed); if (minter == address(0)) { handlerSkips++; return; } uint256 tokenId = _getTokenId(minter); if (tokenId == 0) { handlerSkips++; return; } vm.roll(block.number + 1); alchemist.poke(tokenId); uint256 maxBorrow = alchemist.getMaxBorrowable(tokenId); if (maxBorrow == 0) { handlerSkips++; return; } uint256 borrowAmt = bound(stakeAmount, 1, maxBorrow); vm.prank(minter); try alchemist.mint(tokenId, borrowAmt, minter) { // no-op } catch (bytes memory reason) { _handleExpectedBadDebtRevert(reason); return; } staker = minter; available = alToken.balanceOf(staker); } // Re-read after potential borrow totalIssued = alchemist.totalSyntheticsIssued(); totalLocked = transmuterLogic.totalLocked(); if (totalIssued <= totalLocked) { handlerSkips++; return; } uint256 maxStakeable = totalIssued - totalLocked; if (maxStakeable == 0 || available == 0) { handlerSkips++; return; } uint256 cap = available > maxStakeable ? maxStakeable : available; stakeAmount = bound(stakeAmount, 1, cap); vm.startPrank(staker); alToken.approve(address(transmuterLogic), stakeAmount); transmuterLogic.createRedemption(stakeAmount, staker); vm.stopPrank(); onBehalf = staker; } uint256 tid = IERC721Enumerable(address(transmuterLogic)).tokenOfOwnerByIndex(onBehalf, 0); bool wasBadDebt = _isBadDebt(); if (wasBadDebt) badDebtEvents++; vm.roll(block.number + 10000); vm.prank(onBehalf); transmuterLogic.claimRedemption(tid); recoverySinceLastStress = false; } // ═══════════════════════════════════════════════════════════════ // STRESS HANDLER: Recovery From Loss (10-50% yield injection) // Simulates system recovery. Only fires when system has // experienced loss. Tracks transitions out of bad debt. // ═══════════════════════════════════════════════════════════════ function recoverFromLoss(uint256 recoveryBps) external { if (!_isBadDebt() && totalLossRealized == 0) { handlerSkips++; return; } recoveryBps = bound(recoveryBps, 1000, 5000); uint256 currentUnderlying = IERC20(mockVaultCollateral).balanceOf(mockStrategyYieldToken); uint256 recoveryAmount = currentUnderlying * recoveryBps / 10000; if (recoveryAmount == 0) { handlerSkips++; return; } deal(mockVaultCollateral, address(this), recoveryAmount); IERC20(mockVaultCollateral).approve(mockStrategyYieldToken, recoveryAmount); ITestYieldToken(mockStrategyYieldToken).slurp(recoveryAmount); totalYieldAccrued += recoveryAmount; bool wasInBadDebt = inBadDebtState; _checkBadDebtState(); if (wasInBadDebt && !inBadDebtState) { recoveryEvents++; recoverySinceLastStress = true; } } // ═══════════════════════════════════════════════════════════════ // INVARIANT C1: Storage Debt Consistency // After poking all positions, sum(account.debt) ≈ totalDebt // and sum(account.earmarked) ≈ cumulativeEarmarked. // Tolerance accounts for Q128 rounding drift in _sync(). // ═══════════════════════════════════════════════════════════════ function test_Regression_LiquidationClamp_PreventsUnderflow() public { this.depositCollateral(2005632228859, 32050235932314973874844605524603652259934571675798953); this.transmuterClaimDuringBadDebt( 115792089237316195423570985008687907853269984665640564039457584007913129639932, 1087715915021040162320100296756322045 ); this.repayDebt(4533, 2191105088); this.borrowCollateral(9999000000000000000000, 15265); this.realizeLargeValueLoss(1598288580650331967); this.cascadeLiquidations(); this.invariantDebtConsistency(); assertLe(alchemist.cumulativeEarmarked(), alchemist.totalDebt(), "global earmark must remain bounded by debt"); } function test_Regression_BadDebtClaimLossCollateralDriftWithinTolerance() public { this.depositCollateral(52442825112839348585061025984960103989962694978198461973028259411208, 3); this.transmuterClaimDuringBadDebt(3468, 4387425010760926899844490068); this.depositCollateral(8052470305035150090755704148969369914834259484690729, 200); this.borrowCollateral(70145030891776661966643435961501872124, 0); this.realizeLargeValueLoss(84475498225639534990814110233184881838435220661872913905); this.transmuterStake(33965530496226231438924998564746689922, 365704568792561798); this.transmuterClaim(5951); this.invariantDebtConsistency(); } function test_Regression_RecoveryInvariant_IgnoresStaleRecoveryAfterLaterStress() public { this.depositCollateral(2372, 1e24); this.realizeLargeValueLoss(5_184_000); this.transmuterClaimDuringBadDebt(16032393237489252879, 12080589133847099991); this.realizeLargeValueLoss(15023546907106063555193036943180048415051158506344677672114535166); this.recoverFromLoss(1_000_000); this.realizeLargeValueLoss(9662); this.realizeLargeValueLoss(535473456517434585642216277857931979893938385580); this.depositCollateral(type(uint256).max, 8272684481589); this.recoverFromLoss(88909182746640073509994862822485359098259267332); this.recoverFromLoss(2572098644657603845274017501833254511490950969702); this.recoverFromLoss(type(uint256).max); assertFalse(recoverySinceLastStress, "later stress should invalidate any prior recovery checkpoint"); assertGt(totalYieldAccrued, totalLossRealized, "lifetime net yield should be positive"); this.invariantRecoveryWorks(); } function invariantDebtConsistency() public { address[] memory senders = targetSenders(); for (uint256 i; i < senders.length; ++i) { uint256 tokenId = _getTokenId(senders[i]); if (tokenId != 0) alchemist.poke(tokenId); } uint256 sumDebt; uint256 sumEarmarked; uint256 sumCollateral; uint256 active; for (uint256 i; i < senders.length; ++i) { uint256 tokenId = _getTokenId(senders[i]); if (tokenId != 0) { (uint256 col, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); active++; sumDebt += debt; sumEarmarked += earmarked; sumCollateral += col; } } uint256 totalDebt = alchemist.totalDebt(); uint256 cumEarmarked = alchemist.cumulativeEarmarked(); uint256 totalDeposited = alchemist.getTotalDeposited(); uint256 debtDelta = _absDiff(sumDebt, totalDebt); uint256 earmarkDelta = _absDiff(sumEarmarked, cumEarmarked); uint256 colDelta = _absDiff(sumCollateral, totalDeposited); if (debtDelta > maxDebtDelta) maxDebtDelta = debtDelta; if (earmarkDelta > maxEarmarkDelta) maxEarmarkDelta = earmarkDelta; if (colDelta > maxCollateralDelta) maxCollateralDelta = colDelta; uint256 cf = alchemist.underlyingConversionFactor(); // Q128 rounding produces O(1) wei drift per account per sync cycle. // Floor of 1e6 gives ~40x headroom; cf scaling handles 6-decimal tokens. uint256 debtTol = _max(1e6, cf * _max(active, 1)); // Higher collateral tolerance: mulDivUp + weighted-average redemption // accounting accumulates divergence in share units when loss/yield cycles // move the share price between redemptions. // A replayed bad-debt claim + loss sequence drifted by ~1.28e18 shares // on ~6.84e27 tracked shares (~1.86e-10 relative), so use a modest // relative tolerance of 2e-10 plus a small floor instead of a fixed // multi-ETH absolute value. // NOTE: collateral values here are vault shares, not underlying units. uint256 colTol = _max(1e15, totalDeposited / 5_000_000_000); assertLe(debtDelta, debtTol, "C1a: stored debt sum != totalDebt after full sync"); assertLe(earmarkDelta, debtTol, "C1b: stored earmark sum != cumulativeEarmarked after full sync"); assertLe(colDelta, colTol, "C1c: stored collateral sum != totalDeposited after full sync"); } // ═══════════════════════════════════════════════════════════════ // INVARIANT C2: Share Price Non-Zero // The yield token share price must never hit zero. // A zero share price means total wipeout — separate concern from // loss recovery (we floor at 5% in the loss handler). // ═══════════════════════════════════════════════════════════════ function invariantSharePriceNonZero() public view { uint256 sharePrice = vault.convertToAssets(1e18); assertGt(sharePrice, 0, "C2: share price is zero - total wipeout"); } // ═══════════════════════════════════════════════════════════════ // INVARIANT C3: Bad Debt Bounded by Realized Losses // When totalSynthetics > backing (bad debt), the shortfall // must not exceed total realized losses (+ tolerance for // conversion rounding and liquidation fees). // ═══════════════════════════════════════════════════════════════ function invariantBadDebtBounded() public view { uint256 totalSynthetics = alchemist.totalSyntheticsIssued(); if (totalSynthetics == 0) return; address myt = alchemist.myt(); uint256 transmuterYield = IERC20(myt).balanceOf(address(transmuterLogic)); uint256 backingUnderlying = alchemist.getTotalLockedUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(transmuterYield); if (totalSynthetics > backingUnderlying) { uint256 badDebt = totalSynthetics - backingUnderlying; // Tolerance: 1% of loss + 1e18 absolute for rounding uint256 tolerance = totalLossRealized / 100 + 1e18; assertLe( badDebt, totalLossRealized + tolerance, "C3: bad debt exceeds total loss realized" ); } } // ═══════════════════════════════════════════════════════════════ // INVARIANT C4: Post-Liquidation Health // After a liquidation round, every position with remaining debt // must be above collateralizationLowerBound (or fully zeroed). // 1% tolerance for liquidation target calculation rounding. // ═══════════════════════════════════════════════════════════════ function invariantPostLiquidationHealth() public { if (liquidationSuccesses == 0) return; // Skip if a loss event occurred after the last liquidation round — // new losses can push positions back below threshold, that's expected // behavior, not a liquidation bug. if (lossAfterLastLiquidation) return; address[] memory senders = targetSenders(); for (uint256 i; i < senders.length; ++i) { uint256 tid = _getTokenId(senders[i]); if (tid == 0) continue; alchemist.poke(tid); (, uint256 debt,) = alchemist.getCDP(tid); if (debt == 0) continue; uint256 collateralValue = alchemist.totalValue(tid); // Dust-only residue can remain after aggressive cascades due to integer conversion // boundaries (e.g. debt=1, collateral=0). Treat up to one underlying unit as closed. uint256 debtDustTol = alchemist.underlyingConversionFactor(); if (collateralValue == 0 && debt <= debtDustTol) continue; uint256 lowerBound = alchemist.collateralizationLowerBound(); uint256 required = (debt * lowerBound) / FIXED_POINT_SCALAR; assertGe( collateralValue * 100, required * 99, "C4: position still undercollateralized after liquidation round" ); } } // ═══════════════════════════════════════════════════════════════ // INVARIANT C5: Recovery Works // After recovery events with net positive yield, the system // should not be catastrophically underbacked. 25% tolerance because: // - Losses compound on a shrinking base (30% of 1000 != 30% of 700) // - Recoveries add to the already-reduced base // - Liquidation penalties erode backing further (~3% per liquidation) // - Transmuter claims distribute yield tokens out of the system // - Multiple loss-recovery cycles amplify the divergence // Only enforce this while the latest successful recovery has not been // superseded by a later stress event (loss, claim, liquidation, or new debt). // This is a sanity bound, not a solvency guarantee. The protocol's // real defense is the badDebtRatio scaling in the transmuter. // ═══════════════════════════════════════════════════════════════ function invariantRecoveryWorks() public view { if (recoveryEvents == 0) return; if (!recoverySinceLastStress) return; if (totalYieldAccrued > totalLossRealized) { uint256 totalSynthetics = alchemist.totalSyntheticsIssued(); if (totalSynthetics > 0) { address myt = alchemist.myt(); uint256 transmuterYield = IERC20(myt).balanceOf(address(transmuterLogic)); uint256 backing = alchemist.getTotalLockedUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(transmuterYield); assertGe( backing * 100, totalSynthetics * 75, "C5: system catastrophically underbacked despite net positive yield" ); } } } // ═══════════════════════════════════════════════════════════════ // INVARIANT C6: Synthetics Balance // totalSyntheticsIssued must always >= transmuter.totalLocked // ═══════════════════════════════════════════════════════════════ function invariantSyntheticsBalance() public view { assertGe( alchemist.totalSyntheticsIssued(), transmuterLogic.totalLocked(), "C6: totalSyntheticsIssued < transmuter.totalLocked" ); } // ═══════════════════════════════════════════════════════════════ // INVARIANT C7: No Orphaned Earmarks // cumulativeEarmarked can never exceed totalDebt // ═══════════════════════════════════════════════════════════════ function invariantNoOrphanedEarmarks() public view { assertLe( alchemist.cumulativeEarmarked(), alchemist.totalDebt(), "C7: cumulativeEarmarked > totalDebt" ); } } ================================================ FILE: src/test/Invariants/FullSystemInvariantsTest.sol ================================================ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.0; import "./InvariantBaseTest.t.sol"; contract FullSystemInvariantsTest is InvariantBaseTest { uint256 public maxDebtDeltaSeen; uint256 public maxEarmarkDeltaSeen; function setUp() public virtual override { super.setUp(); } /* INVARIANTS */ // Total debt in the system is equal to sum of all user debts function invariantConsistentDebtAndEarmark() public { address[] memory users = targetSenders(); uint256 sumDebt; uint256 sumEarmarked; uint256 active; for (uint256 i; i < users.length; ++i) { uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(users[i], address(alchemistNFT)); if (tokenId != 0) { active++; (, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); sumDebt += debt; sumEarmarked += earmarked; } } uint256 totalDebt = alchemist.totalDebt(); uint256 cumEarmarked = alchemist.getUnrealizedCumulativeEarmarked(); uint256 debtDelta = _absDiff(sumDebt, totalDebt); uint256 earmarkDelta = _absDiff(sumEarmarked, cumEarmarked); if (debtDelta > maxDebtDeltaSeen) maxDebtDeltaSeen = debtDelta; if (earmarkDelta > maxEarmarkDeltaSeen) maxEarmarkDeltaSeen = earmarkDelta; // Tolerance: // - base: 100 // - plus: conversionFactor * active positions uint256 cf = alchemist.underlyingConversionFactor(); uint256 tol = _max(100, cf * _max(active, 1)); if (debtDelta > tol || earmarkDelta > tol) { emit log_named_uint("debtDelta", debtDelta); emit log_named_uint("earmarkDelta", earmarkDelta); emit log_named_uint("sumDebt", sumDebt); emit log_named_uint("totalDebt", totalDebt); emit log_named_uint("sumEarmarked", sumEarmarked); emit log_named_uint("cumEarmarked", cumEarmarked); emit log_named_uint("tol", tol); } assertLe(debtDelta, tol); assertLe(earmarkDelta, tol); // Sanity invariants that should ALWAYS hold assertLe(cumEarmarked, totalDebt); assertLe(sumEarmarked, sumDebt); } function _absDiff(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a - b : b - a; } function _max(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a : b; } } ================================================ FILE: src/test/Invariants/HardenedInvariantsTest.sol ================================================ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity 0.8.28; import "../InvariantsTest.t.sol"; import {ITestYieldToken} from "../../interfaces/test/ITestYieldToken.sol"; import {ITransmuter} from "../../interfaces/ITransmuter.sol"; import {IVaultV2} from "../../../lib/vault-v2/src/interfaces/IVaultV2.sol"; contract HardenedInvariantHandler is Test { struct Snapshot { uint256 totalDebt; uint256 cumulativeEarmarked; uint256 totalDeposited; uint256 totalSyntheticsIssued; uint256 totalLocked; uint256 strategyUnderlying; } uint256 internal constant FIXED_POINT_SCALAR = 1e18; uint256 internal constant MAX_TEST_VALUE = 1e28; bytes4 internal constant LIQUIDATION_ERROR_SELECTOR = bytes4(keccak256("LiquidationError()")); bytes4 internal constant UNDERCOLLATERALIZED_SELECTOR = bytes4(keccak256("Undercollateralized()")); bytes4 internal constant ILLEGAL_STATE_SELECTOR = bytes4(keccak256("IllegalState()")); bytes4 internal constant ILLEGAL_ARGUMENT_SELECTOR = bytes4(keccak256("IllegalArgument()")); bytes4 internal constant BURN_LIMIT_EXCEEDED_SELECTOR = bytes4(keccak256("BurnLimitExceeded(uint256,uint256)")); bytes4 internal constant CANNOT_REPAY_ON_MINT_BLOCK_SELECTOR = bytes4(keccak256("CannotRepayOnMintBlock()")); AlchemistV3 public immutable alchemist; Transmuter public immutable transmuterLogic; AlchemistV3Position public immutable alchemistNFT; AlchemicTokenV3 public immutable alToken; IVaultV2 public immutable vault; address public immutable mockVaultCollateral; address public immutable mockStrategyYieldToken; address[] internal actors; uint256 public skippedCalls; uint256 public ghostDeposits; uint256 public ghostWithdrawals; uint256 public ghostBorrows; uint256 public ghostRepays; uint256 public ghostBurnRepays; uint256 public ghostStakes; uint256 public ghostClaims; uint256 public ghostLiquidationAttempts; uint256 public ghostLiquidationSuccesses; uint256 public ghostYieldEvents; uint256 public ghostValueLossEvents; uint256 public ghostPokeCalls; uint256 public ghostBlocksAdvanced; uint256 public ghostDepositedShares; uint256 public ghostWithdrawnShares; uint256 public ghostMintedDebt; uint256 public ghostRepaidShares; uint256 public ghostBurnedDebt; uint256 public ghostStakedDebt; uint256 public ghostClaimedDebt; uint256 public ghostLiquidatedShares; uint256 public ghostYieldAddedUnderlying; uint256 public ghostLossSiphonedUnderlying; uint256 public initialTotalDebt; uint256 public initialCumulativeEarmarked; uint256 public initialTotalDeposited; uint256 public initialTotalSyntheticsIssued; uint256 public initialTransmuterLocked; uint256 public initialStrategyUnderlying; uint256 public ghostTotalDebtIncrease; uint256 public ghostTotalDebtDecrease; uint256 public ghostCumulativeEarmarkedIncrease; uint256 public ghostCumulativeEarmarkedDecrease; uint256 public ghostTotalDepositedIncrease; uint256 public ghostTotalDepositedDecrease; uint256 public ghostTotalSyntheticsIssuedIncrease; uint256 public ghostTotalSyntheticsIssuedDecrease; uint256 public ghostTransmuterLockedIncrease; uint256 public ghostTransmuterLockedDecrease; uint256 public ghostStrategyUnderlyingIncrease; uint256 public ghostStrategyUnderlyingDecrease; constructor( AlchemistV3 _alchemist, Transmuter _transmuterLogic, AlchemistV3Position _alchemistNFT, AlchemicTokenV3 _alToken, IVaultV2 _vault, address _mockVaultCollateral, address _mockStrategyYieldToken, address[] memory _actors ) { alchemist = _alchemist; transmuterLogic = _transmuterLogic; alchemistNFT = _alchemistNFT; alToken = _alToken; vault = _vault; mockVaultCollateral = _mockVaultCollateral; mockStrategyYieldToken = _mockStrategyYieldToken; for (uint256 i; i < _actors.length; ++i) { actors.push(_actors[i]); } Snapshot memory snap = _snapshot(); initialTotalDebt = snap.totalDebt; initialCumulativeEarmarked = snap.cumulativeEarmarked; initialTotalDeposited = snap.totalDeposited; initialTotalSyntheticsIssued = snap.totalSyntheticsIssued; initialTransmuterLocked = snap.totalLocked; initialStrategyUnderlying = snap.strategyUnderlying; } modifier tracked(bytes4 selector) { Snapshot memory beforeState = _snapshot(); _; Snapshot memory afterState = _snapshot(); if (afterState.totalDebt >= beforeState.totalDebt) { ghostTotalDebtIncrease += afterState.totalDebt - beforeState.totalDebt; } else { ghostTotalDebtDecrease += beforeState.totalDebt - afterState.totalDebt; } if (afterState.cumulativeEarmarked >= beforeState.cumulativeEarmarked) { ghostCumulativeEarmarkedIncrease += afterState.cumulativeEarmarked - beforeState.cumulativeEarmarked; } else { ghostCumulativeEarmarkedDecrease += beforeState.cumulativeEarmarked - afterState.cumulativeEarmarked; } if (afterState.totalDeposited >= beforeState.totalDeposited) { ghostTotalDepositedIncrease += afterState.totalDeposited - beforeState.totalDeposited; } else { ghostTotalDepositedDecrease += beforeState.totalDeposited - afterState.totalDeposited; } if (afterState.totalSyntheticsIssued >= beforeState.totalSyntheticsIssued) { ghostTotalSyntheticsIssuedIncrease += afterState.totalSyntheticsIssued - beforeState.totalSyntheticsIssued; } else { ghostTotalSyntheticsIssuedDecrease += beforeState.totalSyntheticsIssued - afterState.totalSyntheticsIssued; } if (afterState.totalLocked >= beforeState.totalLocked) { ghostTransmuterLockedIncrease += afterState.totalLocked - beforeState.totalLocked; } else { ghostTransmuterLockedDecrease += beforeState.totalLocked - afterState.totalLocked; } if (afterState.strategyUnderlying >= beforeState.strategyUnderlying) { ghostStrategyUnderlyingIncrease += afterState.strategyUnderlying - beforeState.strategyUnderlying; } else { ghostStrategyUnderlyingDecrease += beforeState.strategyUnderlying - afterState.strategyUnderlying; } } function actorCount() external view returns (uint256) { return actors.length; } function actorAt(uint256 i) external view returns (address) { return actors[i]; } function depositCollateral(uint256 amount, uint256 actorSeed) external tracked(this.depositCollateral.selector) { if (_isBadDebt()) { skippedCalls++; return; } address actor = _actor(actorSeed); uint256 depositCap = alchemist.depositCap(); uint256 totalDeposited = alchemist.getTotalDeposited(); if (depositCap <= totalDeposited) { skippedCalls++; return; } uint256 maxDepositable = depositCap - totalDeposited; uint256 maxAmount = _min(MAX_TEST_VALUE, maxDepositable); amount = bound(amount, 1, maxAmount); uint256 tokenId = _tokenId(actor); uint256 underlyingNeeded = vault.previewMint(amount); if (underlyingNeeded == 0) { skippedCalls++; return; } deal(mockVaultCollateral, actor, underlyingNeeded); vm.startPrank(actor); IERC20(mockVaultCollateral).approve(address(vault), underlyingNeeded); vault.mint(amount, actor); try alchemist.deposit(amount, actor, tokenId) { ghostDeposits++; ghostDepositedShares += amount; } catch (bytes memory errData) { bytes4 sel = _selector(errData); // deposit() throws IllegalState for three reasons (AlchemistV3.sol L420-422): // 1. depositsPaused (admin toggled between handler precondition check and call) // 2. protocol is in bad debt (share price moved since _isBadDebt() check) // 3. deposit cap exceeded (another deposit filled the gap) // Assert that at least one of these holds — any other IllegalState is a bug. if (sel == ILLEGAL_STATE_SELECTOR) { require( alchemist.depositsPaused() || _isBadDebt() || alchemist.getTotalDeposited() + amount > alchemist.depositCap(), "deposit: IllegalState with no known justification" ); skippedCalls++; vm.stopPrank(); return; } vm.stopPrank(); _bubble(errData); } vm.stopPrank(); } function withdrawCollateral(uint256 amount, uint256 actorSeed) external tracked(this.withdrawCollateral.selector) { (address actor, uint256 tokenId, uint256 maxWithdraw) = _findWithdrawer(actorSeed); if (actor == address(0)) { skippedCalls++; return; } amount = bound(amount, 1, maxWithdraw); vm.prank(actor); try alchemist.withdraw(amount, actor, tokenId) { ghostWithdrawals++; ghostWithdrawnShares += amount; } catch (bytes memory errData) { bytes4 sel = _selector(errData); // withdraw() can revert after internal _earmark()+_sync() changes state: // IllegalArgument (L460): collateralBalance - lockedCollateral < amount // (stale getMaxWithdrawable view vs post-sync reality) // Undercollateralized (L464 via _validate): withdrawal made position unhealthy // after sync repriced collateral/debt // Both are stale-view races — the handler read getMaxWithdrawable() before // _earmark/_sync ran inside withdraw(), and state shifted. if (sel == ILLEGAL_ARGUMENT_SELECTOR || sel == UNDERCOLLATERALIZED_SELECTOR) { // The position must have debt for sync to change the picture. // A debt-free position has lockedCollateral=0 and _validate always passes, // so these errors should never fire for debt-free positions. (, uint256 debt,) = alchemist.getCDP(tokenId); require(debt > 0, "withdraw: revert on debt-free position is a bug"); skippedCalls++; return; } _bubble(errData); } } function borrowCollateral(uint256 amount, uint256 actorSeed) external tracked(this.borrowCollateral.selector) { if (_isBadDebt()) { skippedCalls++; return; } vm.roll(block.number + 1); (address actor, uint256 tokenId, uint256 maxBorrow) = _findBorrower(actorSeed); if (actor == address(0)) { skippedCalls++; return; } amount = bound(amount, 1, maxBorrow); vm.prank(actor); try alchemist.mint(tokenId, amount, actor) { ghostBorrows++; ghostMintedDebt += amount; } catch (bytes memory errData) { bytes4 sel = _selector(errData); // mint() throws Undercollateralized (L1279 _addDebt, L1413 _validate) when // _earmark+_sync changed debt/collateral since getMaxBorrowable() view call. if (sel == UNDERCOLLATERALIZED_SELECTOR) { skippedCalls++; return; } // mint() throws IllegalState for (AlchemistV3.sol L479, L484): // 1. loansPaused (admin toggled between handler check and call) // 2. protocol entered bad debt (share price moved since _isBadDebt() check) if (sel == ILLEGAL_STATE_SELECTOR) { require( alchemist.loansPaused() || _isBadDebt(), "mint: IllegalState with no known justification" ); skippedCalls++; return; } _bubble(errData); } } function repayDebt(uint256 amount, uint256 actorSeed) external tracked(this.repayDebt.selector) { vm.roll(block.number + 1); (address actor, uint256 tokenId, uint256 maxRepayShares) = _findRepayer(actorSeed); if (actor == address(0)) { skippedCalls++; return; } amount = bound(amount, 1, maxRepayShares); uint256 underlyingNeeded = vault.previewMint(amount); if (underlyingNeeded == 0) { skippedCalls++; return; } deal(mockVaultCollateral, actor, underlyingNeeded); vm.startPrank(actor); IERC20(mockVaultCollateral).approve(address(vault), underlyingNeeded); vault.mint(amount, actor); try alchemist.repay(amount, tokenId) returns (uint256 repaid) { ghostRepays++; ghostRepaidShares += repaid; } catch (bytes memory errData) { bytes4 sel = _selector(errData); // repay() throws IllegalState for (AlchemistV3.sol L570, L587): // 1. account.debt == 0 after _sync (debt was repaid by redemption since view) // 2. feeAmount > account.collateralBalance (fee on earmarked portion exceeds // collateral after sync repriced things) if (sel == ILLEGAL_STATE_SELECTOR) { // Verify: either debt is now 0, or the account's collateral is very small // relative to its earmarked debt (fee could exceed collateral). (, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); (uint256 col,,) = alchemist.getCDP(tokenId); require( debt == 0 || (earmarked > 0 && col < alchemist.convertDebtTokensToYield(earmarked)), "repay: IllegalState with no known justification" ); skippedCalls++; vm.stopPrank(); return; } // CannotRepayOnMintBlock (L559): handler does vm.roll(block.number+1) but // another handler could have minted on the new block in the same sequence. // This is a legitimate test-infrastructure artifact, not a protocol bug. if (sel == CANNOT_REPAY_ON_MINT_BLOCK_SELECTOR) { skippedCalls++; vm.stopPrank(); return; } vm.stopPrank(); _bubble(errData); } vm.stopPrank(); } function repayDebtViaBurn(uint256 amount, uint256 actorSeed) external tracked(this.repayDebtViaBurn.selector) { vm.roll(block.number + 1); (address actor, uint256 tokenId, uint256 burnable) = _findBurner(actorSeed); if (actor == address(0)) { skippedCalls++; return; } amount = bound(amount, 1, burnable); vm.prank(actor); try alchemist.burn(amount, tokenId) { ghostBurnRepays++; ghostBurnedDebt += amount; } catch (bytes memory errData) { bytes4 sel = _selector(errData); // burn() throws IllegalState (L526) when unearmarked debt is 0 after _sync. // This happens if _sync earmarked all remaining debt since the view call. if (sel == ILLEGAL_STATE_SELECTOR) { (, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); require( debt == 0 || earmarked >= debt, "burn: IllegalState but position still has unearmarked debt" ); skippedCalls++; return; } // BurnLimitExceeded (L533): credit > totalSyntheticsIssued - totalLocked. // Transmuter state changed between _findBurner view and the actual call. if (sel == BURN_LIMIT_EXCEEDED_SELECTOR) { uint256 freeSynthetics = alchemist.totalSyntheticsIssued() - transmuterLogic.totalLocked(); require( amount > freeSynthetics, "burn: BurnLimitExceeded but amount <= free synthetics" ); skippedCalls++; return; } // Undercollateralized (L546 via _validate): extremely rare — sync could // dramatically change collateral. Only valid if position has debt. if (sel == UNDERCOLLATERALIZED_SELECTOR) { (, uint256 debt,) = alchemist.getCDP(tokenId); require(debt > 0, "burn: Undercollateralized on debt-free position is a bug"); skippedCalls++; return; } _bubble(errData); } } function transmuterStake(uint256 amount, uint256 actorSeed) external tracked(this.transmuterStake.selector) { uint256 totalIssued = alchemist.totalSyntheticsIssued(); uint256 totalLocked = transmuterLogic.totalLocked(); if (totalIssued <= totalLocked) { skippedCalls++; return; } uint256 depositCap = transmuterLogic.depositCap(); uint256 totalActiveLocked = transmuterLogic.totalActiveLocked(); if (depositCap <= totalActiveLocked) { skippedCalls++; return; } (address actor, uint256 actorBalance) = _findActorWithAlToken(actorSeed); if (actor == address(0)) { skippedCalls++; return; } uint256 maxStakeable = totalIssued - totalLocked; uint256 maxByDepositCap = depositCap - totalActiveLocked; uint256 cap = _min(_min(actorBalance, maxStakeable), maxByDepositCap); if (cap == 0) { skippedCalls++; return; } amount = bound(amount, 1, cap); vm.startPrank(actor); alToken.approve(address(transmuterLogic), amount); transmuterLogic.createRedemption(amount, actor); vm.stopPrank(); ghostStakes++; ghostStakedDebt += amount; } function transmuterClaim(uint256 actorSeed) external tracked(this.transmuterClaim.selector) { (address actor, uint256 redemptionTokenId) = _findClaimer(actorSeed); if (actor == address(0)) { skippedCalls++; return; } ITransmuter.StakingPosition memory position = transmuterLogic.getPosition(redemptionTokenId); if (position.maturationBlock == 0) { skippedCalls++; return; } if (block.number < position.maturationBlock) { vm.roll(position.maturationBlock); } uint256 lockedBefore = transmuterLogic.totalLocked(); vm.prank(actor); transmuterLogic.claimRedemption(redemptionTokenId); uint256 lockedAfter = transmuterLogic.totalLocked(); ghostClaims++; if (lockedBefore > lockedAfter) { ghostClaimedDebt += lockedBefore - lockedAfter; } } function triggerLiquidation(uint256 seed) external tracked(this.triggerLiquidation.selector) { if (vault.convertToAssets(1e18) == 0 || alchemist.totalDebt() == 0) { skippedCalls++; return; } uint256 start = seed % actors.length; for (uint256 i; i < actors.length; ++i) { address actor = actors[(start + i) % actors.length]; uint256 tokenId = _tokenId(actor); if (tokenId == 0) continue; alchemist.poke(tokenId); (, uint256 debt,) = alchemist.getCDP(tokenId); if (debt == 0) continue; uint256 collateralValue = alchemist.totalValue(tokenId); uint256 requiredCollateral = debt * alchemist.collateralizationLowerBound() / FIXED_POINT_SCALAR; if (collateralValue > requiredCollateral) continue; ghostLiquidationAttempts++; try alchemist.liquidate(tokenId) returns (uint256 liquidatedShares, uint256, uint256) { if (liquidatedShares > 0) { ghostLiquidationSuccesses++; ghostLiquidatedShares += liquidatedShares; } } catch (bytes memory errData) { bytes4 sel = _selector(errData); // LiquidationError (L614): liquidation made no progress. This can happen // when the position was borderline — _earmark+_sync inside liquidate() // pushed it back to healthy, or the position's debt was fully redeemed. if (sel == LIQUIDATION_ERROR_SELECTOR) { // After the failed liquidate, the position should be healthy or have no debt. (, uint256 debtAfter,) = alchemist.getCDP(tokenId); if (debtAfter > 0) { uint256 colVal = alchemist.totalValue(tokenId); uint256 lowerBound = alchemist.collateralizationLowerBound(); require( colVal * FIXED_POINT_SCALAR >= debtAfter * lowerBound, "liquidate: LiquidationError but position is still unhealthy" ); } skippedCalls++; return; } _bubble(errData); } return; } skippedCalls++; } function simulateValueLoss(uint256 lossBps) external tracked(this.simulateValueLoss.selector) { lossBps = bound(lossBps, 10, 200); uint256 strategyUnderlying = IERC20(mockVaultCollateral).balanceOf(mockStrategyYieldToken); uint256 lossAmount = strategyUnderlying * lossBps / 10_000; if (lossAmount == 0 || strategyUnderlying - lossAmount <= strategyUnderlying / 10) { skippedCalls++; return; } ITestYieldToken(mockStrategyYieldToken).siphon(lossAmount); ghostValueLossEvents++; ghostLossSiphonedUnderlying += lossAmount; } function simulateYield(uint256 yieldBps) external tracked(this.simulateYield.selector) { yieldBps = bound(yieldBps, 1, 300); uint256 strategyUnderlying = IERC20(mockVaultCollateral).balanceOf(mockStrategyYieldToken); uint256 yieldAmount = strategyUnderlying * yieldBps / 10_000; if (yieldAmount == 0) { skippedCalls++; return; } deal(mockVaultCollateral, address(this), yieldAmount); IERC20(mockVaultCollateral).approve(mockStrategyYieldToken, yieldAmount); ITestYieldToken(mockStrategyYieldToken).slurp(yieldAmount); ghostYieldEvents++; ghostYieldAddedUnderlying += yieldAmount; } function pokeAll() external tracked(this.pokeAll.selector) { uint256 poked; for (uint256 i; i < actors.length; ++i) { uint256 tokenId = _tokenId(actors[i]); if (tokenId != 0) { alchemist.poke(tokenId); poked++; } } if (poked == 0) { skippedCalls++; return; } ghostPokeCalls += poked; } function pokeRandom(uint256 seed) external tracked(this.pokeRandom.selector) { address actor = _actor(seed); uint256 tokenId = _tokenId(actor); if (tokenId == 0) { skippedCalls++; return; } alchemist.poke(tokenId); ghostPokeCalls++; } function mine(uint256 blocksToAdvance) external tracked(this.mine.selector) { blocksToAdvance = bound(blocksToAdvance, 1, 72_000); vm.roll(block.number + blocksToAdvance); ghostBlocksAdvanced += blocksToAdvance; } function expectedTotalDebt() external view returns (uint256) { return initialTotalDebt + ghostTotalDebtIncrease - ghostTotalDebtDecrease; } function expectedCumulativeEarmarked() external view returns (uint256) { return initialCumulativeEarmarked + ghostCumulativeEarmarkedIncrease - ghostCumulativeEarmarkedDecrease; } function expectedTotalDeposited() external view returns (uint256) { return initialTotalDeposited + ghostTotalDepositedIncrease - ghostTotalDepositedDecrease; } function expectedTotalSyntheticsIssued() external view returns (uint256) { return initialTotalSyntheticsIssued + ghostTotalSyntheticsIssuedIncrease - ghostTotalSyntheticsIssuedDecrease; } function expectedTransmuterLocked() external view returns (uint256) { return initialTransmuterLocked + ghostTransmuterLockedIncrease - ghostTransmuterLockedDecrease; } function expectedStrategyUnderlying() external view returns (uint256) { return initialStrategyUnderlying + ghostStrategyUnderlyingIncrease - ghostStrategyUnderlyingDecrease; } function _snapshot() internal view returns (Snapshot memory s) { s.totalDebt = alchemist.totalDebt(); s.cumulativeEarmarked = alchemist.cumulativeEarmarked(); s.totalDeposited = alchemist.getTotalDeposited(); s.totalSyntheticsIssued = alchemist.totalSyntheticsIssued(); s.totalLocked = transmuterLogic.totalLocked(); s.strategyUnderlying = IERC20(mockVaultCollateral).balanceOf(mockStrategyYieldToken); } function _actor(uint256 seed) internal view returns (address) { return actors[seed % actors.length]; } function _tokenId(address user) internal view returns (uint256) { uint256 count = IERC721Enumerable(address(alchemistNFT)).balanceOf(user); if (count == 0) return 0; return IERC721Enumerable(address(alchemistNFT)).tokenOfOwnerByIndex(user, 0); } function _findWithdrawer(uint256 seed) internal view returns (address actor, uint256 tokenId, uint256 maxWithdraw) { uint256 start = seed % actors.length; for (uint256 i; i < actors.length; ++i) { actor = actors[(start + i) % actors.length]; tokenId = _tokenId(actor); if (tokenId == 0) continue; maxWithdraw = alchemist.getMaxWithdrawable(tokenId); if (maxWithdraw > 0) return (actor, tokenId, maxWithdraw); } return (address(0), 0, 0); } function _findBorrower(uint256 seed) internal view returns (address actor, uint256 tokenId, uint256 maxBorrow) { uint256 start = seed % actors.length; for (uint256 i; i < actors.length; ++i) { actor = actors[(start + i) % actors.length]; tokenId = _tokenId(actor); if (tokenId == 0) continue; maxBorrow = alchemist.getMaxBorrowable(tokenId); if (maxBorrow > 0) return (actor, tokenId, maxBorrow); } return (address(0), 0, 0); } function _findRepayer(uint256 seed) internal view returns (address actor, uint256 tokenId, uint256 maxRepayShares) { uint256 start = seed % actors.length; for (uint256 i; i < actors.length; ++i) { actor = actors[(start + i) % actors.length]; tokenId = _tokenId(actor); if (tokenId == 0) continue; (, uint256 debt,) = alchemist.getCDP(tokenId); if (debt == 0) continue; maxRepayShares = alchemist.convertDebtTokensToYield(debt); if (maxRepayShares > 0) return (actor, tokenId, maxRepayShares); } return (address(0), 0, 0); } function _findBurner(uint256 seed) internal view returns (address actor, uint256 tokenId, uint256 burnable) { uint256 start = seed % actors.length; for (uint256 i; i < actors.length; ++i) { actor = actors[(start + i) % actors.length]; tokenId = _tokenId(actor); if (tokenId == 0) continue; (, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); if (debt <= earmarked) continue; uint256 freeSynthetics = alchemist.totalSyntheticsIssued() - transmuterLogic.totalLocked(); uint256 actorBalance = alToken.balanceOf(actor); uint256 debtHeadroom = debt - earmarked; burnable = _min(_min(debtHeadroom, freeSynthetics), actorBalance); if (burnable > 0) return (actor, tokenId, burnable); } return (address(0), 0, 0); } function _findActorWithAlToken(uint256 seed) internal view returns (address actor, uint256 balance) { uint256 start = seed % actors.length; for (uint256 i; i < actors.length; ++i) { actor = actors[(start + i) % actors.length]; balance = alToken.balanceOf(actor); if (balance > 0) return (actor, balance); } return (address(0), 0); } function _findClaimer(uint256 seed) internal view returns (address actor, uint256 redemptionTokenId) { uint256 start = seed % actors.length; IERC721Enumerable redemptions = IERC721Enumerable(address(transmuterLogic)); for (uint256 i; i < actors.length; ++i) { actor = actors[(start + i) % actors.length]; uint256 count = redemptions.balanceOf(actor); if (count == 0) continue; redemptionTokenId = redemptions.tokenOfOwnerByIndex(actor, count - 1); return (actor, redemptionTokenId); } return (address(0), 0); } function _min(uint256 a, uint256 b) internal pure returns (uint256) { return a < b ? a : b; } function _isBadDebt() internal view returns (bool) { uint256 totalSynthetics = alchemist.totalSyntheticsIssued(); if (totalSynthetics == 0) return false; uint256 transmuterYield = IERC20(alchemist.myt()).balanceOf(address(transmuterLogic)); uint256 backingUnderlying = alchemist.getTotalLockedUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(transmuterYield); uint256 backingDebt = alchemist.normalizeUnderlyingTokensToDebt(backingUnderlying); return totalSynthetics > backingDebt; } function _selector(bytes memory errData) internal pure returns (bytes4 sel) { if (errData.length < 4) return bytes4(0); assembly { sel := mload(add(errData, 32)) } } function _bubble(bytes memory errData) internal pure { if (errData.length == 0) revert(); assembly { revert(add(errData, 32), mload(errData)) } } } contract HardenedInvariantsTest is InvariantsTest { uint256 internal constant DEPLOY_MIN_COLLATERALIZATION = 1_111_111_111_111_111_111; uint256 internal constant DEPLOY_COLLATERALIZATION_LOWER_BOUND = 1_052_631_578_950_000_000; uint256 internal constant DEPLOY_LIQUIDATION_TARGET = 1_111_111_111_111_111_111; uint256 internal constant DEPLOY_PROTOCOL_FEE = 25; uint256 internal constant DEPLOY_LIQUIDATOR_FEE = 300; uint256 internal constant DEPLOY_REPAYMENT_FEE = 0; uint256 internal constant DEPLOY_TRANSMUTATION_TIME = 604_800; uint256 internal constant DEPLOY_TRANSMUTATION_FEE = 0; uint256 internal constant DEPLOY_EXIT_FEE = 100; uint256 public maxDebtDelta; uint256 public maxEarmarkDelta; uint256 public maxCollateralDelta; HardenedInvariantHandler public handler; function setUp() public virtual override { selectors.push(this.noop.selector); super.setUp(); _applyDeployV3ETHEconomicParams(); handler = new HardenedInvariantHandler( alchemist, transmuterLogic, alchemistNFT, alToken, IVaultV2(address(vault)), mockVaultCollateral, mockStrategyYieldToken, targetSenders() ); excludeContract(address(this)); targetContract(address(handler)); bytes4[] memory handlerSelectors = new bytes4[](13); handlerSelectors[0] = handler.depositCollateral.selector; handlerSelectors[1] = handler.withdrawCollateral.selector; handlerSelectors[2] = handler.borrowCollateral.selector; handlerSelectors[3] = handler.repayDebt.selector; handlerSelectors[4] = handler.repayDebtViaBurn.selector; handlerSelectors[5] = handler.transmuterStake.selector; handlerSelectors[6] = handler.transmuterClaim.selector; handlerSelectors[7] = handler.triggerLiquidation.selector; handlerSelectors[8] = handler.simulateValueLoss.selector; handlerSelectors[9] = handler.simulateYield.selector; handlerSelectors[10] = handler.pokeAll.selector; handlerSelectors[11] = handler.pokeRandom.selector; handlerSelectors[12] = handler.mine.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: handlerSelectors})); } // this is just to rate-limit the state changing functions, we cannot realistically expect 100k liquidations in a row... function noop() external {} function _applyDeployV3ETHEconomicParams() internal { vm.startPrank(alOwner); alchemist.setProtocolFee(DEPLOY_PROTOCOL_FEE); alchemist.setLiquidatorFee(DEPLOY_LIQUIDATOR_FEE); alchemist.setRepaymentFee(DEPLOY_REPAYMENT_FEE); alchemist.setMinimumCollateralization(DEPLOY_MIN_COLLATERALIZATION); alchemist.setCollateralizationLowerBound(DEPLOY_COLLATERALIZATION_LOWER_BOUND); alchemist.setLiquidationTargetCollateralization(DEPLOY_LIQUIDATION_TARGET); transmuterLogic.setProtocolFeeReceiver(protocolFeeReceiver); transmuterLogic.setTransmutationFee(DEPLOY_TRANSMUTATION_FEE); transmuterLogic.setExitFee(DEPLOY_EXIT_FEE); transmuterLogic.setTransmutationTime(DEPLOY_TRANSMUTATION_TIME); vm.stopPrank(); } function invariantStorageDebtConsistency() public { address[] memory senders = targetSenders(); for (uint256 i; i < senders.length; ++i) { uint256 tokenId = _tokenId(senders[i]); if (tokenId != 0) { alchemist.poke(tokenId); } } uint256 sumDebt; uint256 sumEarmarked; uint256 sumCollateral; uint256 active; for (uint256 i; i < senders.length; ++i) { uint256 tokenId = _tokenId(senders[i]); if (tokenId == 0) continue; (uint256 col, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); active++; sumDebt += debt; sumEarmarked += earmarked; sumCollateral += col; } uint256 totalDebt = alchemist.totalDebt(); uint256 cumEarmarked = alchemist.cumulativeEarmarked(); uint256 totalDeposited = alchemist.getTotalDeposited(); uint256 debtDelta = _absDiff(sumDebt, totalDebt); uint256 earmarkDelta = _absDiff(sumEarmarked, cumEarmarked); uint256 colDelta = _absDiff(sumCollateral, totalDeposited); if (debtDelta > maxDebtDelta) maxDebtDelta = debtDelta; if (earmarkDelta > maxEarmarkDelta) maxEarmarkDelta = earmarkDelta; if (colDelta > maxCollateralDelta) maxCollateralDelta = colDelta; uint256 cf = alchemist.underlyingConversionFactor(); // `getCDP()` is the most up-to-date account view, but the account/global // redemption math can still diverge slightly after an explicit `poke()` // due to mixed Q128 ceil/floor rounding across earmark and redemption // survival updates. // A replayed 8-step counterexample reached ~8.15e15 debt drift with a // single live account, so keep a small fixed floor with modest headroom // while still scaling for decimal normalization. uint256 debtTol = _max(5e12, cf * _max(active, 1)); // Collateral drift is structurally larger: lazy sync uses a weighted- // average shares/debt ratio across redemptions, diverging from per- // redemption exact debits when share prices shift between redemptions. // Use a relative tolerance (2e-11 of total tracked shares) plus a tiny // absolute floor. Avoids hard-coding multi-ETH absolute slack while // still allowing deep-state fuzz sequences to pass. // NOTE: collateral values here are vault shares, not underlying units. uint256 colTol = _max(1e15, totalDeposited / 50_000_000_000); assertLe(debtDelta, debtTol, "H1a: stored debt sum != totalDebt after full sync"); assertLe(earmarkDelta, debtTol, "H1b: stored earmark sum != cumulativeEarmarked after full sync"); assertLe(colDelta, colTol, "H1c: stored collateral sum != totalDeposited after full sync"); assertLe(cumEarmarked, totalDebt, "H1d: cumulativeEarmarked > totalDebt"); } function invariantPerPositionSanity() public { address[] memory senders = targetSenders(); for (uint256 i; i < senders.length; ++i) { uint256 tokenId = _tokenId(senders[i]); if (tokenId == 0) continue; alchemist.poke(tokenId); (uint256 col, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); assertLe(earmarked, debt, "H2a: earmarked > debt"); if (debt == 0) { assertEq(earmarked, 0, "H2b: zero debt but nonzero earmarked"); } uint256 totalDeposited = alchemist.getTotalDeposited(); if (col > totalDeposited) { assertLe(col - totalDeposited, 2, "H2c: position collateral exceeds total deposited by >2 wei"); } } } function invariantMYTTokenAccounting() public view { uint256 contractBalance = IERC20(alchemist.myt()).balanceOf(address(alchemist)); uint256 tracked = alchemist.getTotalDeposited(); uint256 active; address[] memory senders = targetSenders(); for (uint256 i; i < senders.length; ++i) { if (_tokenId(senders[i]) != 0) active++; } uint256 drift = _absDiff(contractBalance, tracked); uint256 tolerance = _max(100, active * 4); assertLe(drift, tolerance, "H3: MYT balance drift exceeds rounding tolerance"); } function invariantPokeIdempotent() public { address[] memory senders = targetSenders(); for (uint256 i; i < senders.length; ++i) { uint256 tokenId = _tokenId(senders[i]); if (tokenId == 0) continue; alchemist.poke(tokenId); (uint256 col1, uint256 debt1, uint256 ear1) = alchemist.getCDP(tokenId); uint256 td1 = alchemist.totalDebt(); uint256 ce1 = alchemist.cumulativeEarmarked(); alchemist.poke(tokenId); (uint256 col2, uint256 debt2, uint256 ear2) = alchemist.getCDP(tokenId); uint256 td2 = alchemist.totalDebt(); uint256 ce2 = alchemist.cumulativeEarmarked(); assertEq(debt1, debt2, "H4a: poke not idempotent (debt)"); assertEq(ear1, ear2, "H4b: poke not idempotent (earmarked)"); assertEq(col1, col2, "H4c: poke not idempotent (collateral)"); assertEq(td1, td2, "H4d: poke not idempotent (totalDebt)"); assertEq(ce1, ce2, "H4e: poke not idempotent (cumulativeEarmarked)"); } } function invariantSyntheticsBalance() public view { assertGe(alchemist.totalSyntheticsIssued(), transmuterLogic.totalLocked(), "H5: issued synthetics < locked"); } function invariantNoOrphanedEarmarks() public view { assertLe(alchemist.cumulativeEarmarked(), alchemist.totalDebt(), "H6: cumulativeEarmarked > totalDebt"); } function invariantSharePriceNonZero() public view { assertGt(vault.convertToAssets(1e18), 0, "H7: share price is zero"); } function invariantLiquidationAccounting() public view { assertLe( handler.ghostLiquidationSuccesses(), handler.ghostLiquidationAttempts(), "H8: liquidation successes exceed attempts" ); } function invariantGhostMirrorsProtocolTotals() public view { // debt/earmark/deposits can drift during invariant-side poke() sync calls, // so exact equality is asserted only for metrics not mutated by those checks. assertEq( alchemist.totalSyntheticsIssued(), handler.expectedTotalSyntheticsIssued(), "H9a: synthetics ghost mismatch" ); assertEq(transmuterLogic.totalLocked(), handler.expectedTransmuterLocked(), "H9b: transmuter ghost mismatch"); assertEq( IERC20(mockVaultCollateral).balanceOf(mockStrategyYieldToken), handler.expectedStrategyUnderlying(), "H9c: strategy underlying ghost mismatch" ); assertGe(handler.ghostDepositedShares(), handler.ghostWithdrawnShares(), "H9d: withdrawn shares exceed deposits"); assertGe(handler.ghostStakedDebt(), handler.ghostClaimedDebt(), "H9e: claimed debt exceeds staked debt"); } function invariantTransmuterLockedBoundedBySupply() public view { assertGe(alToken.totalSupply(), transmuterLogic.totalLocked(), "H10: transmuter locked exceeds alToken supply"); } function _tokenId(address user) internal view returns (uint256) { uint256 count = IERC721Enumerable(address(alchemistNFT)).balanceOf(user); if (count == 0) return 0; return IERC721Enumerable(address(alchemistNFT)).tokenOfOwnerByIndex(user, 0); } function _absDiff(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a - b : b - a; } function _max(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a : b; } } ================================================ FILE: src/test/Invariants/InvariantBaseTest.t.sol ================================================ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.0; import "../InvariantsTest.t.sol"; contract InvariantBaseTest is InvariantsTest { address internal immutable USER; uint256 internal immutable MAX_TEST_VALUE = 1e28; uint256 public maxTokenIdMinted; bytes32 internal constant POS_MINT_SIG = keccak256("AlchemistV3PositionNFTMinted(address,uint256)"); constructor() { USER = makeAddr("User"); } function setUp() public virtual override { selectors.push(this.depositCollateral.selector); selectors.push(this.withdrawCollateral.selector); selectors.push(this.borrowCollateral.selector); selectors.push(this.repayDebt.selector); selectors.push(this.repayDebtViaBurn.selector); selectors.push(this.transmuterStake.selector); selectors.push(this.transmuterClaim.selector); selectors.push(this.mine.selector); super.setUp(); } function _targetSenders() internal virtual override { _targetSender(makeAddr("Sender1")); _targetSender(makeAddr("Sender2")); _targetSender(makeAddr("Sender3")); _targetSender(makeAddr("Sender4")); _targetSender(makeAddr("Sender5")); _targetSender(makeAddr("Sender6")); _targetSender(makeAddr("Sender7")); _targetSender(makeAddr("Sender8")); } function _updateMaxTokenIdFromLogs() internal { Vm.Log[] memory logs = vm.getRecordedLogs(); for (uint256 i; i < logs.length; ++i) { // Event is emitted by the Alchemist contract (NOT the NFT contract). if (logs[i].emitter != address(alchemist)) continue; if (logs[i].topics.length != 3) continue; if (logs[i].topics[0] != POS_MINT_SIG) continue; // topics[2] is the indexed tokenId uint256 tokenId = uint256(logs[i].topics[2]); if (tokenId > maxTokenIdMinted) maxTokenIdMinted = tokenId; } } function _deposit(uint256 tokenId, uint256 amount, address onBehalf) internal logCall(onBehalf, "deposit") { vm.recordLogs(); deal(mockVaultCollateral, onBehalf, amount); vm.startPrank(onBehalf); IERC20(mockVaultCollateral).approve(address(vault), amount * 2); vault.mint(amount, onBehalf); alchemist.deposit(amount, onBehalf, tokenId); vm.stopPrank(); _updateMaxTokenIdFromLogs(); _checkDebtInvariant("deposit"); } function _borrow(uint256 tokenId, uint256 amount, address onBehalf) internal logCall(onBehalf, "borrow") { vm.prank(onBehalf); alchemist.mint(tokenId, amount, onBehalf); _checkDebtInvariant("borrow"); } function _withdraw(uint256 tokenId, uint256 amount, address onBehalf) internal logCall(onBehalf, "withdraw") { vm.prank(onBehalf); alchemist.withdraw(amount, onBehalf, tokenId); _checkDebtInvariant("withdraw"); } function _repay(uint256 tokenId, uint256 amount, address onBehalf) internal logCall(onBehalf, "repay") { vm.roll(block.number + 1); deal(mockVaultCollateral, onBehalf, amount * 2); vm.startPrank(onBehalf); IERC20(mockVaultCollateral).approve(address(vault), amount); vault.mint(amount, onBehalf); alchemist.repay(amount, tokenId); vm.stopPrank(); _checkDebtInvariant("repay"); } function _burn(uint256 tokenId, uint256 amount, address onBehalf) internal logCall(onBehalf, "burn") { vm.prank(onBehalf); alchemist.burn(amount, tokenId); _checkDebtInvariant("burn"); } function _stake(uint256 amount, address onBehalf) internal logCall(onBehalf, "stake") { vm.startPrank(onBehalf); alToken.mint(onBehalf, amount); alToken.approve(address(transmuterLogic), amount); transmuterLogic.createRedemption(amount, onBehalf); vm.stopPrank(); _checkDebtInvariant("stake"); } function _claim(uint256 tokenId, address onBehalf) internal logCall(onBehalf, "claim") { vm.roll(block.number + (1000000)); vm.startPrank(onBehalf); _logDebts("BEFORE_CLAIM"); transmuterLogic.claimRedemption(tokenId); _logDebts("AFTER_CLAIM"); vm.stopPrank(); _checkDebtInvariant("claim"); } /* HANDLERS */ function depositCollateral(uint256 amount, uint256 onBehalfSeed) external { address onBehalf = _randomDepositor(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) return; amount = bound(amount, 0, MAX_TEST_VALUE); if (amount == 0) return; uint256 tokenId; try AlchemistNFTHelper.getFirstTokenId(onBehalf, address(alchemistNFT)) { tokenId = AlchemistNFTHelper.getFirstTokenId(onBehalf, address(alchemistNFT)); } catch { tokenId = 0; } _deposit(tokenId, amount, onBehalf); } function withdrawCollateral(uint256 amount, uint256 onBehalfSeed) external { address onBehalf = _randomWithdrawer(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) return; uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(onBehalf, address(alchemistNFT)); (uint256 collat, uint256 debt,) = alchemist.getCDP(tokenId); uint256 debtToCollateral = alchemist.convertDebtTokensToYield(debt); uint256 maxWithdraw = alchemist.getMaxWithdrawable(tokenId); amount = bound(amount, 0, maxWithdraw); if (amount == 0) return; _withdraw(tokenId, amount, onBehalf); } function borrowCollateral(uint256 amount, uint256 onBehalfSeed) external { address onBehalf = _randomMinter(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) return; // To ensure no repay or mint on the same block which is not allowed vm.roll(block.number + 1); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(onBehalf, address(alchemistNFT)); amount = bound(amount, 0, alchemist.getMaxBorrowable(tokenId)); if (amount == 0) return; _borrow(tokenId, amount, onBehalf); } function repayDebt(uint256 amount, uint256 onBehalfSeed) external { address onBehalf = _randomRepayer(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) return; uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(onBehalf, address(alchemistNFT)); (, uint256 debt,) = alchemist.getCDP(tokenId); uint256 maxRepayShares = alchemist.convertDebtTokensToYield(debt); amount = bound(amount, 0, maxRepayShares); if (amount == 0) return; _repay(tokenId, amount, onBehalf); } function repayDebtViaBurn(uint256 amount, uint256 onBehalfSeed) external { address onBehalf = _randomBurner(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) return; // Roll before we check CDP so new earmark does not accumulate and cause illegal state after checking account vm.roll(block.number + 1); uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(onBehalf, address(alchemistNFT)); (, uint256 debt, uint256 earmarked) = alchemist.getCDP(tokenId); uint256 burnable = (debt - earmarked) > (alchemist.totalSyntheticsIssued() - transmuterLogic.totalLocked()) ? (alchemist.totalSyntheticsIssued() - transmuterLogic.totalLocked()) : (debt - earmarked); amount = bound(amount, 0, burnable); if (amount == 0) return; _burn(tokenId, amount, onBehalf); } function transmuterStake(uint256 amount, uint256 onBehalfSeed) external { address onBehalf = _randomNonZero(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) return; uint256 maxStakeable = alchemist.totalSyntheticsIssued() - transmuterLogic.totalLocked(); amount = bound(amount, 0, maxStakeable); if (amount == 0) return; _stake(amount, onBehalf); } function transmuterClaim(uint256 onBehalfSeed) external { address onBehalf = _randomClaimer(targetSenders(), onBehalfSeed); if (onBehalf == address(0)) return; _claim(IERC721Enumerable(address(transmuterLogic)).tokenOfOwnerByIndex(onBehalf, 0), onBehalf); } function _sumDebts() internal view returns (uint256 total) { address[] memory users = targetSenders(); for (uint256 i; i < users.length; ++i) { uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(users[i], address(alchemistNFT)); if (tokenId != 0) { (, uint256 debt,) = alchemist.getCDP(tokenId); total += debt; } } } function _checkDebtInvariant(string memory where) internal view { uint256 sum = _sumDebts(); uint256 tot = alchemist.totalDebt(); if (sum > tot ? (sum - tot) > 1e12 : (tot - sum) > 1e12) { console2.log("Debt invariant broke at:", where); console2.log("sumDebts:", sum); console2.log("totalDebt:", tot); console2.log("delta:", sum > tot ? sum - tot : tot - sum); } } function _logDebts(string memory tag) internal { uint256 sum; for (uint256 i = 1; i <= 10; i++) { (uint256 col, uint256 debt, uint256 earmarked) = alchemist.getCDP(i); if (col == 0 && debt == 0 && earmarked == 0) continue; console2.log(tag, "cdp", i); console2.log("debt", debt); console2.log("earmarked", earmarked); console2.log("col", col); sum += debt; } console2.log(tag, "sumDebts", sum); console2.log(tag, "totalDebt", alchemist.totalDebt()); console2.log(tag, "cumulativeEarmarked", alchemist.cumulativeEarmarked()); console2.log(tag, "lastEarmarkBlock", alchemist.lastEarmarkBlock()); console2.log(tag, "lastRedemptionBlock", alchemist.lastRedemptionBlock()); } } ================================================ FILE: src/test/InvariantsTest.t.sol ================================================ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.28; import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {IERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol"; import {TransparentUpgradeableProxy} from "lib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {SafeCast} from "../libraries/SafeCast.sol"; import {Test, Vm} from "lib/forge-std/src/Test.sol"; import {SafeERC20} from "../libraries/SafeERC20.sol"; import {console} from "lib/forge-std/src/console.sol"; import {console2} from "lib/forge-std/src/console2.sol"; import {AlchemistV3} from "../AlchemistV3.sol"; import {AlchemicTokenV3} from "./mocks/AlchemicTokenV3.sol"; import {Transmuter} from "../Transmuter.sol"; import {AlchemistV3Position} from "../AlchemistV3Position.sol"; import {AlchemistV3PositionRenderer} from "../AlchemistV3PositionRenderer.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {Whitelist} from "../utils/Whitelist.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; import {TestYieldToken} from "./mocks/TestYieldToken.sol"; import {TokenAdapterMock} from "./mocks/TokenAdapterMock.sol"; import {IAlchemistV3, IAlchemistV3Errors, AlchemistInitializationParams} from "../interfaces/IAlchemistV3.sol"; import {ITransmuter} from "../interfaces/ITransmuter.sol"; import {ITestYieldToken} from "../interfaces/test/ITestYieldToken.sol"; import {InsufficientAllowance} from "../base/Errors.sol"; import {Unauthorized, IllegalArgument, IllegalState, MissingInputData} from "../base/Errors.sol"; import {AlchemistNFTHelper} from "./libraries/AlchemistNFTHelper.sol"; import {IAlchemistV3Position} from "../interfaces/IAlchemistV3Position.sol"; import {AggregatorV3Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {AlchemistTokenVault} from "../AlchemistTokenVault.sol"; import {MockMYTStrategy} from "./mocks/MockMYTStrategy.sol"; import {MYTTestHelper} from "./libraries/MYTTestHelper.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {MockAlchemistAllocator} from "./mocks/MockAlchemistAllocator.sol"; import {IMockYieldToken} from "./mocks/MockYieldToken.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {MockYieldToken} from "./mocks/MockYieldToken.sol"; contract InvariantsTest is Test { bytes4[] internal selectors; // ----- [SETUP] Variables for setting up a minimal CDP ----- // Callable contract variables AlchemistV3 alchemist; Transmuter transmuter; AlchemistV3Position alchemistNFT; AlchemistTokenVault alchemistFeeVault; // // Proxy variables TransparentUpgradeableProxy proxyAlchemist; TransparentUpgradeableProxy proxyTransmuter; // // Contract variables // CheatCodes cheats = CheatCodes(HEVM_ADDRESS); AlchemistV3 alchemistLogic; Transmuter transmuterLogic; AlchemicTokenV3 alToken; Whitelist whitelist; // Parameters for AlchemicTokenV2 string public _name; string public _symbol; uint256 public _flashFee; address public alOwner; mapping(address => bool) users; uint256 public constant FIXED_POINT_SCALAR = 1e18; uint256 public constant BPS = 10_000; uint256 public protocolFee = 100; uint256 public liquidatorFeeBPS = 300; // in BPS, 3% uint256 public minimumCollateralization = uint256(FIXED_POINT_SCALAR * FIXED_POINT_SCALAR) / 9e17; // ----- Variables for deposits & withdrawals ----- // account funds to make deposits/test with uint256 accountFunds; // large amount to test with uint256 whaleSupply; // amount of yield/underlying token to deposit uint256 depositAmount; // minimum amount of yield/underlying token to deposit uint256 minimumDeposit = 1000e18; // minimum amount of yield/underlying token to deposit uint256 minimumDepositOrWithdrawalLoss = FIXED_POINT_SCALAR; // random EOA for testing address externalUser = address(0x69E8cE9bFc01AA33cD2d02Ed91c72224481Fa420); // another random EOA for testing address anotherExternalUser = address(0x420Ab24368E5bA8b727E9B8aB967073Ff9316969); // another random EOA for testing address yetAnotherExternalUser = address(0x520aB24368e5Ba8B727E9b8aB967073Ff9316961); // another random EOA for testing address someWhale = address(0x521aB24368E5Ba8b727e9b8AB967073fF9316961); // WETH address address public weth = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); address public protocolFeeReceiver = address(10); // MYT variables VaultV2 vault; MockAlchemistAllocator allocator; MockMYTStrategy mytStrategy; address public operator = address(0x2222222222222222222222222222222222222222); // default operator address public admin = address(0x4444444444444444444444444444444444444444); // DAO OSX address public curator = address(0x8888888888888888888888888888888888888888); address public mockVaultCollateral = address(new TestERC20(100e18, uint8(18))); address public mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); uint256 public defaultStrategyAbsoluteCap = 2_000_000_000e18; uint256 public defaultStrategyRelativeCap = 1e18; // 100% struct CalculateLiquidationResult { uint256 liquidationAmountInYield; uint256 debtToBurn; uint256 outSourcedFee; uint256 baseFeeInYield; } struct AccountPosition { address user; uint256 collateral; uint256 debt; uint256 tokenId; } function setUp() public virtual { adJustTestFunds(18); setUpMYT(18); deployCoreContracts(18); } function adJustTestFunds(uint256 alchemistUnderlyingTokenDecimals) public { accountFunds = 200_000 * 10 ** alchemistUnderlyingTokenDecimals; whaleSupply = 20_000_000_000 * 10 ** alchemistUnderlyingTokenDecimals; depositAmount = 200_000 * 10 ** alchemistUnderlyingTokenDecimals; } function setUpMYT(uint256 alchemistUnderlyingTokenDecimals) public { vm.startPrank(admin); uint256 TOKEN_AMOUNT = 1_000_000; // Base token amount uint256 initialSupply = TOKEN_AMOUNT * 10 ** alchemistUnderlyingTokenDecimals; mockVaultCollateral = address(new TestERC20(initialSupply, uint8(alchemistUnderlyingTokenDecimals))); mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); vault = MYTTestHelper._setupVault(mockVaultCollateral, admin, curator); mytStrategy = MYTTestHelper._setupStrategy(address(vault), mockStrategyYieldToken, admin, "MockToken", "MockTokenProtocol", IMYTStrategy.RiskClass.LOW); allocator = new MockAlchemistAllocator(address(vault), admin, operator, address(new AlchemistStrategyClassifier(admin))); vm.stopPrank(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (address(allocator), true))); vault.setIsAllocator(address(allocator), true); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, address(mytStrategy))); vault.addAdapter(address(mytStrategy)); bytes memory idData = mytStrategy.getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, defaultStrategyAbsoluteCap))); vault.increaseAbsoluteCap(idData, defaultStrategyAbsoluteCap); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, defaultStrategyRelativeCap))); vault.increaseRelativeCap(idData, defaultStrategyRelativeCap); vm.stopPrank(); } function _magicDepositToVault(address vault, address depositor, uint256 amount) internal returns (uint256) { deal(address(mockVaultCollateral), address(depositor), amount); vm.startPrank(depositor); TokenUtils.safeApprove(address(mockVaultCollateral), vault, amount); uint256 shares = IVaultV2(vault).deposit(amount, depositor); vm.stopPrank(); return shares; } function _vaultSubmitAndFastForward(bytes memory data) internal { vault.submit(data); bytes4 selector = bytes4(data); vm.warp(block.timestamp + vault.timelock(selector)); } function deployCoreContracts(uint256 alchemistUnderlyingTokenDecimals) public { // test maniplulation for convenience address caller = address(0xdead); address proxyOwner = address(this); vm.assume(caller != address(0)); vm.assume(proxyOwner != address(0)); vm.assume(caller != proxyOwner); vm.startPrank(caller); // Fake tokens alToken = new AlchemicTokenV3(_name, _symbol, _flashFee); ITransmuter.TransmuterInitializationParams memory transParams = ITransmuter.TransmuterInitializationParams({ syntheticToken: address(alToken), feeReceiver: address(this), timeToTransmute: 5_256_000, transmutationFee: 10, exitFee: 20, graphSize: 52_560_000 }); // Contracts and logic contracts alOwner = caller; transmuterLogic = new Transmuter(transParams); alchemistLogic = new AlchemistV3(); whitelist = new Whitelist(); // AlchemistV3 proxy AlchemistInitializationParams memory params = AlchemistInitializationParams({ admin: alOwner, debtToken: address(alToken), underlyingToken: address(vault.asset()), depositCap: type(uint256).max, minimumCollateralization: minimumCollateralization, collateralizationLowerBound: 1_052_631_578_950_000_000, // 1.05 collateralization globalMinimumCollateralization: 1_111_111_111_111_111_111, // 1.1 liquidationTargetCollateralization: uint256(1e36) / 88e16, // ~113.63% (88% LTV) transmuter: address(transmuterLogic), protocolFee: 0, protocolFeeReceiver: protocolFeeReceiver, liquidatorFee: liquidatorFeeBPS, repaymentFee: 100, myt: address(vault) }); bytes memory alchemParams = abi.encodeWithSelector(AlchemistV3.initialize.selector, params); proxyAlchemist = new TransparentUpgradeableProxy(address(alchemistLogic), proxyOwner, alchemParams); alchemist = AlchemistV3(address(proxyAlchemist)); // Whitelist alchemist proxy for minting tokens alToken.setWhitelist(address(proxyAlchemist), true); whitelist.add(address(0xbeef)); whitelist.add(externalUser); whitelist.add(anotherExternalUser); transmuterLogic.setAlchemist(address(alchemist)); transmuterLogic.setDepositCap(uint256(type(int256).max)); alchemistNFT = new AlchemistV3Position(address(alchemist), alOwner); alchemistNFT.setMetadataRenderer(address(new AlchemistV3PositionRenderer())); alchemist.setAlchemistPositionNFT(address(alchemistNFT)); alchemistFeeVault = new AlchemistTokenVault(address(vault.asset()), address(alchemist), alOwner); alchemistFeeVault.setAuthorization(address(alchemist), true); alchemist.setAlchemistFeeVault(address(alchemistFeeVault)); _magicDepositToVault(address(vault), address(0xbeef), accountFunds); _magicDepositToVault(address(vault), address(0xdad), accountFunds); _magicDepositToVault(address(vault), externalUser, accountFunds); _magicDepositToVault(address(vault), yetAnotherExternalUser, accountFunds); _magicDepositToVault(address(vault), anotherExternalUser, accountFunds); vm.stopPrank(); vm.startPrank(address(admin)); allocator.allocate(address(mytStrategy), vault.convertToAssets(vault.totalSupply())); vm.stopPrank(); deal(address(alToken), address(0xdad), accountFunds); deal(address(alToken), address(anotherExternalUser), accountFunds); deal(address(vault.asset()), address(0xbeef), accountFunds); deal(address(vault.asset()), externalUser, accountFunds); deal(address(vault.asset()), yetAnotherExternalUser, accountFunds); deal(address(vault.asset()), anotherExternalUser, accountFunds); deal(address(vault.asset()), alchemist.alchemistFeeVault(), 10_000 * (10 ** alchemistUnderlyingTokenDecimals)); vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(vault.asset()), address(vault), accountFunds); vm.stopPrank(); vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault.asset()), address(vault), accountFunds); vm.stopPrank(); vm.startPrank(someWhale); deal(address(vault), someWhale, whaleSupply); deal(address(vault.asset()), someWhale, whaleSupply); SafeERC20.safeApprove(address(vault.asset()), address(mockStrategyYieldToken), whaleSupply); vm.stopPrank(); _targetSenders(); targetContract(address(this)); targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); } modifier logCall(address onBehalf, string memory name) { console2.log(onBehalf, "->", name); _; } function _targetSenders() internal virtual { _targetSender(makeAddr("Sender1")); _targetSender(makeAddr("Sender2")); _targetSender(makeAddr("Sender3")); _targetSender(makeAddr("Sender4")); _targetSender(makeAddr("Sender5")); _targetSender(makeAddr("Sender6")); _targetSender(makeAddr("Sender7")); _targetSender(makeAddr("Sender8")); } function _targetSender(address sender) internal { targetSender(sender); vm.prank(address(0xdead)); alToken.setWhitelist(sender, true); vm.startPrank(sender); TokenUtils.safeApprove(address(alToken), address(alchemist), type(uint256).max); TokenUtils.safeApprove(address(alchemist.myt()), address(alchemist), type(uint256).max); vm.stopPrank(); } /* HANDLERS */ function mine(uint256 blocks) external { blocks = bound(blocks, 1, 72_000); console2.log("block number ->", block.number + blocks); vm.roll(block.number + blocks); } /* UTILS */ function _randomDepositor(address[] memory users, uint256 seed) internal pure returns (address) { return _randomNonZero(users, seed); } function _randomWithdrawer(address[] memory users, uint256 seed) internal view returns (address) { address[] memory candidates = new address[](users.length); for (uint256 i; i < users.length; ++i) { address user = users[i]; uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(alchemistNFT)); uint256 withdrawable = 0; if (tokenId != 0) withdrawable = alchemist.getMaxWithdrawable(tokenId); if (withdrawable > 0) candidates[i] = user; } return _randomNonZero(candidates, seed); } function _randomMinter(address[] memory users, uint256 seed) internal view returns (address) { address[] memory candidates = new address[](users.length); for (uint256 i; i < users.length; ++i) { address user = users[i]; uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(alchemistNFT)); uint256 borrowable; if (tokenId != 0) borrowable = alchemist.getMaxBorrowable(tokenId); if (borrowable > 0) { candidates[i] = user; } } return _randomNonZero(candidates, seed); } function _randomRepayer(address[] memory users, uint256 seed) internal view returns (address) { address[] memory candidates = new address[](users.length); for (uint256 i; i < users.length; ++i) { address user = users[i]; uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(alchemistNFT)); uint256 collateral; uint256 debt; if (tokenId != 0) (collateral, debt,) = alchemist.getCDP(tokenId); if (debt > 0) { candidates[i] = user; } } return _randomNonZero(candidates, seed); } function _randomBurner(address[] memory users, uint256 seed) internal view returns (address) { address[] memory candidates = new address[](users.length); for (uint256 i; i < users.length; ++i) { address user = users[i]; uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(user, address(alchemistNFT)); uint256 collateral; uint256 debt; uint256 earmarked; if (tokenId != 0) (collateral, debt, earmarked) = alchemist.getCDP(tokenId); if (debt > 0 && debt > earmarked) { candidates[i] = user; } } return _randomNonZero(candidates, seed); } function _randomStaker(address[] memory users, uint256 seed) internal pure returns (address) { return _randomNonZero(users, seed); } function _randomClaimer(address[] memory users, uint256 seed) internal view returns (address) { address[] memory candidates = new address[](users.length); for (uint256 i = 0; i < users.length; ++i) { address user = users[i]; uint256 tokenCount = IERC721Enumerable(address(transmuterLogic)).balanceOf(user); uint256[] memory tokenIds = new uint256[](tokenCount); if (tokenCount > 0) { // Loop through each token and retrieve its token ID via the enumerable interface. for (uint256 j = 0; j < tokenCount; j++) { tokenIds[j] = IERC721Enumerable(address(transmuterLogic)).tokenOfOwnerByIndex(user, j); } if (tokenIds[0] != 0) candidates[i] = user; } } return _randomNonZero(candidates, seed); } function _randomNonZero(address[] memory users, uint256 seed) internal pure returns (address) { users = _removeAll(users, address(0)); return _randomCandidate(users, seed); } function _randomCandidate(address[] memory candidates, uint256 seed) internal pure returns (address) { if (candidates.length == 0) return address(0); return candidates[seed % candidates.length]; } function _removeAll(address[] memory inputs, address removed) internal pure returns (address[] memory result) { result = new address[](inputs.length); uint256 nbAddresses; for (uint256 i; i < inputs.length; ++i) { address input = inputs[i]; if (input != removed) { result[nbAddresses] = input; ++nbAddresses; } } assembly { mstore(result, nbAddresses) } } } ================================================ FILE: src/test/MYTStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test, console} from "forge-std/Test.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {AggregatorV3Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; import {VaultV2Factory} from "lib/vault-v2/src/VaultV2Factory.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {MYTStrategy} from "../MYTStrategy.sol"; import {AlchemistAllocator} from "../AlchemistAllocator.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {AlchemistV3} from "../AlchemistV3.sol"; import {AlchemistInitializationParams} from "../interfaces/IAlchemistV3.sol"; import {AlchemistV3Position} from "../AlchemistV3Position.sol"; import {Transmuter} from "../Transmuter.sol"; import {AlchemicTokenV3} from "./mocks/AlchemicTokenV3.sol"; import {AlchemistTokenVault} from "../AlchemistTokenVault.sol"; import {IAllocator} from "../interfaces/IAllocator.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {IAlchemistV3Errors, AlchemistInitializationParams} from "../interfaces/IAlchemistV3.sol"; import {ITransmuter} from "../interfaces/ITransmuter.sol"; import {ITestYieldToken} from "../interfaces/test/ITestYieldToken.sol"; import {IAlchemistV3Position} from "../interfaces/IAlchemistV3Position.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; import {TestYieldToken} from "./mocks/TestYieldToken.sol"; import {TokenAdapterMock} from "./mocks/TokenAdapterMock.sol"; import {InsufficientAllowance} from "../base/Errors.sol"; import {Unauthorized, IllegalArgument, IllegalState, MissingInputData} from "../base/Errors.sol"; import {AlchemistNFTHelper} from "../test/libraries/AlchemistNFTHelper.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; /// @notice Exposes the internal dexSwap function for testing. contract DexSwapHarness is MYTStrategy { constructor(address _myt, StrategyParams memory _params) MYTStrategy(_myt, _params) {} function exposedDexSwap(address to, address from, uint256 amount, uint256 minAmountOut, bytes memory callData) external returns (uint256) { return dexSwap(to, from, amount, minAmountOut, callData); } } /// @notice Mock allowance holder that simulates a successful swap by /// transferring toToken to the caller. contract MockAllowanceHolderSuccess { IERC20 public immutable toToken; uint256 public immutable transferAmount; constructor(address _toToken, uint256 _transferAmount) { toToken = IERC20(_toToken); transferAmount = _transferAmount; } fallback() external { toToken.transfer(msg.sender, transferAmount); } } /// @notice Mock allowance holder that succeeds but does not transfer any tokens. contract MockAllowanceHolderNoOp { fallback() external {} } /// @notice Mock allowance holder that always reverts, simulating a failed swap. contract MockAllowanceHolderFail { fallback() external { revert("intentional failure"); } } contract MYTStrategyTest is Test { using SafeERC20 for IERC20; // Addresses address admin = makeAddr("admin"); address operator = makeAddr("operator"); address user = makeAddr("user"); address alOwner = makeAddr("alOwner"); address proxyOwner = makeAddr("proxyOwner"); // Tokens TestERC20 public fakeUnderlyingToken; AlchemicTokenV3 public alToken; // Contracts AlchemistV3 public alchemist; IVaultV2 public vault; MYTStrategy public strategy; AlchemistAllocator public allocator; address public classifier; Transmuter public transmuterLogic; AlchemistV3Position public alchemistNFT; VaultV2Factory public vaultFactory; // Additional addresses for Alchemist initialization address public protocolFeeReceiver; uint256 public minimumCollateralization = 1_052_631_578_950_000_000; // 1.05 collateralization uint256 public liquidatorFeeBPS = 1000; // 10% liquidator fee // Strategy parameters IMYTStrategy.StrategyParams public strategyParams = IMYTStrategy.StrategyParams({ owner: admin, name: "Test Strategy", protocol: "Test Protocol", riskClass: IMYTStrategy.RiskClass.LOW, cap: 1000e18, globalCap: 5000e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 1 }); uint256 public constant FIXED_POINT_SCALAR = 1e18; uint256 public constant BPS = 10_000; function setUp() public { string memory rpc = vm.envString("MAINNET_RPC_URL"); uint256 forkId = vm.createFork(rpc, 23567434); vm.selectFork(forkId); deployCoreContracts(18); } function deployCoreContracts(uint256 alchemistUnderlyingTokenDecimals) public { vm.startPrank(alOwner); // Fake tokens fakeUnderlyingToken = new TestERC20(100e18, uint8(alchemistUnderlyingTokenDecimals)); vaultFactory = new VaultV2Factory(); alToken = new AlchemicTokenV3("Alchemic Token", "AL", 0); // Transmuter initialization params ITransmuter.TransmuterInitializationParams memory transParams = ITransmuter.TransmuterInitializationParams({ syntheticToken: address(alToken), feeReceiver: address(this), timeToTransmute: 5_256_000, transmutationFee: 10, exitFee: 20, graphSize: 52_560_000 }); // Contracts and logic contracts transmuterLogic = new Transmuter(transParams); AlchemistV3 alchemistLogic = new AlchemistV3(); vault = IVaultV2(vaultFactory.createVaultV2(address(proxyOwner), address(fakeUnderlyingToken), bytes32("strategy-vault"))); vm.stopPrank(); vm.startPrank(proxyOwner); VaultV2(address(vault)).setCurator(alOwner); vm.stopPrank(); vm.startPrank(alOwner); vault.submit(abi.encodeCall(IVaultV2.setPerformanceFeeRecipient, (alOwner))); vault.setPerformanceFeeRecipient(alOwner); vault.submit(abi.encodeCall(IVaultV2.setPerformanceFee, (15e16))); vault.setPerformanceFee(15e16); vm.stopPrank(); vm.startPrank(alOwner); // AlchemistV3 proxy AlchemistInitializationParams memory params = AlchemistInitializationParams({ admin: alOwner, debtToken: address(alToken), underlyingToken: address(vault.asset()), depositCap: type(uint256).max, minimumCollateralization: minimumCollateralization, collateralizationLowerBound: 1_052_631_578_950_000_000, // 1.05 collateralization globalMinimumCollateralization: 1_111_111_111_111_111_111, // 1.1 liquidationTargetCollateralization: uint256(1e36) / 88e16, // ~113.63% (88% LTV) transmuter: address(transmuterLogic), protocolFee: 0, protocolFeeReceiver: protocolFeeReceiver, liquidatorFee: liquidatorFeeBPS, repaymentFee: 100, myt: address(vault) }); bytes memory alchemParams = abi.encodeWithSelector(AlchemistV3.initialize.selector, params); TransparentUpgradeableProxy proxyAlchemist = new TransparentUpgradeableProxy(address(alchemistLogic), proxyOwner, alchemParams); alchemist = AlchemistV3(address(proxyAlchemist)); // Whitelist alchemist proxy for minting tokens alToken.setWhitelist(address(proxyAlchemist), true); transmuterLogic.setAlchemist(address(alchemist)); transmuterLogic.setDepositCap(uint256(type(int256).max)); alchemistNFT = new AlchemistV3Position(address(alchemist), address(this)); alchemist.setAlchemistPositionNFT(address(alchemistNFT)); protocolFeeReceiver = address(this); // Add funds to test accounts deal(address(vault), address(0xbeef), 1000e18); deal(address(vault), user, 1000e18); deal(address(alToken), address(0xdad), 1000e18); deal(address(alToken), user, 1000e18); deal(address(fakeUnderlyingToken), address(0xbeef), 1000e18); deal(address(fakeUnderlyingToken), user, 1000e18); deal(address(fakeUnderlyingToken), alchemist.alchemistFeeVault(), 10_000 ether); // Set up classifier classifier = address(new AlchemistStrategyClassifier(admin)); vm.startPrank(admin); // Set up risk classes matching constructor defaults (WAD: 1e18 = 100%) AlchemistStrategyClassifier(classifier).setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% AlchemistStrategyClassifier(classifier).setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% AlchemistStrategyClassifier(classifier).setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% vm.stopPrank(); vm.startPrank(user); IERC20(address(fakeUnderlyingToken)).approve(address(vault), 1000e18); vm.stopPrank(); strategy = new MYTStrategy(address(vault), strategyParams); // Assign risk level to the strategy bytes32 strategyId = strategy.adapterId(); vm.prank(admin); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel(uint256(strategyId), uint8(strategyParams.riskClass)); // Create allocator allocator = new AlchemistAllocator(address(vault), admin, operator, classifier); } // Test that allocator can allocate and deallocate /* function test_allocatorCanAllocateAndDeallocate() public { // Vault allocates vm.prank(address(vault)); strategy.allocate(getVaultParams(), 100e18, bytes4(0x00000000), address(allocator)); // Vault deallocates vm.prank(address(vault)); strategy.deallocate(getVaultParams(), 50e18, bytes4(0x00000000), address(allocator)); } */ // Test that strategy kill switch works // function test_killSwitchPreventsAllocation() public { // // Enable kill switch // vm.prank(admin); // strategy.setKillSwitch(true); // // Vault should fail to allocate // vm.prank(address(vault)); // vm.expectRevert(bytes("emergency")); // strategy.allocate(abi.encode(0), 100e18, bytes4(0x00000000), address(allocator)); // // Disable kill switch // vm.prank(admin); // strategy.setKillSwitch(false); // // Vault should succeed // vm.prank(address(vault)); // strategy.allocate(abi.encode(0), 100e18, bytes4(0x00000000), address(allocator)); // } // Test that strategy parameters can be updated function test_strategyParametersCanBeUpdated() public { // Update risk class vm.prank(admin); strategy.setRiskClass(IMYTStrategy.RiskClass.HIGH); // Update incentives vm.prank(admin); strategy.setAdditionalIncentives(true); // Verify updates by reading from storage directly // Access strategy parameters directly from storage ( address owner, string memory name, string memory protocol, IMYTStrategy.RiskClass riskClass, uint256 cap, uint256 globalCap, uint256 estimatedYield, bool additionalIncentives, uint256 slippageBPS ) = strategy.params(); assertEq(uint8(riskClass), uint8(IMYTStrategy.RiskClass.HIGH)); assertEq(additionalIncentives, true); } // Test that strategy can interact with Alchemist system properly function test_strategyIntegrationWithAlchemist() public { // User deposits into yield token vault first vm.prank(user); vault.deposit(100e18, user); // User approves yield token for Alchemist vm.prank(user); vault.approve(address(alchemist), 100e18); // User deposits into Alchemist vm.prank(user); alchemist.deposit(10e18, user, 0); // Verify that allocator was called to allocate console.log("Deposit completed - allocation should have been triggered"); } // Test that strategy respects Alchemist pause states function test_strategyRespectsAlchemistPauseStates() public { // Pause Alchemist deposits vm.prank(alOwner); alchemist.pauseDeposits(true); // User should not be able to deposit vm.prank(user); vault.approve(address(alchemist), 100e18); vm.expectRevert(IllegalState.selector); alchemist.deposit(100e18, user, 0); // Unpause deposits vm.prank(alOwner); alchemist.pauseDeposits(false); // Now deposit should work vm.startPrank(user); vault.approve(address(alchemist), 100e18); alchemist.deposit(10e18, user, 0); vm.stopPrank(); } function getVaultParams() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; return abi.encode(params); } function getSwapParams() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.swap; params.swapParams = IMYTStrategy.SwapParams({ txData: hex"1234", minIntermediateOut: 0 }); return abi.encode(params); } function getUnwrapAndSwapParams() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.unwrapAndSwap; params.swapParams = IMYTStrategy.SwapParams({ txData: hex"1234", minIntermediateOut: 100e18 }); return abi.encode(params); } // Test that base strategy reverts for unsupported direct allocate function test_baseStrategy_allocateDirect_reverts() public { vm.prank(address(vault)); vm.expectRevert(IMYTStrategy.ActionNotSupported.selector); strategy.allocate(getVaultParams(), 100e18, bytes4(0x00000000), address(allocator)); } // Test that base strategy reverts for unsupported swap allocate function test_baseStrategy_allocateSwap_reverts() public { vm.prank(address(vault)); vm.expectRevert(IMYTStrategy.ActionNotSupported.selector); strategy.allocate(getSwapParams(), 100e18, bytes4(0x00000000), address(allocator)); } // Test that base strategy reverts for unsupported direct deallocate function test_baseStrategy_deallocateDirect_reverts() public { vm.prank(address(vault)); vm.expectRevert(IMYTStrategy.ActionNotSupported.selector); strategy.deallocate(getVaultParams(), 100e18, bytes4(0x00000000), address(allocator)); } // Test that base strategy reverts for unsupported swap deallocate function test_baseStrategy_deallocateSwap_reverts() public { vm.prank(address(vault)); vm.expectRevert(IMYTStrategy.ActionNotSupported.selector); strategy.deallocate(getSwapParams(), 100e18, bytes4(0x00000000), address(allocator)); } // Test that base strategy reverts for unsupported unwrapAndSwap deallocate function test_baseStrategy_deallocateUnwrapAndSwap_reverts() public { vm.prank(address(vault)); vm.expectRevert(IMYTStrategy.ActionNotSupported.selector); strategy.deallocate(getUnwrapAndSwapParams(), 100e18, bytes4(0x00000000), address(allocator)); } // ─── dexSwap tests ─────────────────────────────────────────────────── function _deployHarness() internal returns (DexSwapHarness) { return new DexSwapHarness(address(vault), strategyParams); } function test_dexSwap_reverts_on_amount_less_than_minAmountOut() public { DexSwapHarness harness = _deployHarness(); // Mock allowance holder that succeeds but transfers nothing MockAllowanceHolderNoOp noOp = new MockAllowanceHolderNoOp(); vm.prank(admin); harness.setAllowanceHolder(address(noOp)); ERC20Mock fromToken = new ERC20Mock(); ERC20Mock toToken = new ERC20Mock(); // Give the harness enough `from` tokens for the approve inside dexSwap deal(address(fromToken), address(harness), 100e18); // minAmountOut > 0 but swap returns 0 → should revert uint256 minAmountOut = 50e18; vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.InvalidAmount.selector, minAmountOut, 0)); harness.exposedDexSwap(address(toToken), address(fromToken), 100e18, minAmountOut, hex"01"); } function test_dexSwap_strategy_receives_to_asset() public { DexSwapHarness harness = _deployHarness(); ERC20Mock fromToken = new ERC20Mock(); ERC20Mock toToken = new ERC20Mock(); uint256 swapReturn = 75e18; MockAllowanceHolderSuccess mockSwap = new MockAllowanceHolderSuccess(address(toToken), swapReturn); deal(address(toToken), address(mockSwap), swapReturn); vm.prank(admin); harness.setAllowanceHolder(address(mockSwap)); // Give the harness enough `from` tokens for the approve deal(address(fromToken), address(harness), 100e18); uint256 balanceBefore = toToken.balanceOf(address(harness)); uint256 received = harness.exposedDexSwap(address(toToken), address(fromToken), 100e18, 0, hex"01"); uint256 balanceAfter = toToken.balanceOf(address(harness)); assertEq(received, swapReturn, "Received amount mismatch"); assertEq(balanceAfter - balanceBefore, swapReturn, "Balance change mismatch"); } function test_dexSwap_reverts_on_allowanceHolder_call_failure() public { DexSwapHarness harness = _deployHarness(); MockAllowanceHolderFail mockFail = new MockAllowanceHolderFail(); vm.prank(admin); harness.setAllowanceHolder(address(mockFail)); ERC20Mock fromToken = new ERC20Mock(); ERC20Mock toToken = new ERC20Mock(); deal(address(fromToken), address(harness), 100e18); vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.CounterfeitSettler.selector, address(mockFail))); harness.exposedDexSwap(address(toToken), address(fromToken), 100e18, 0, hex"01"); } function test_rescueTokens_rescues_arbitrary_token() public { ERC20Mock randomToken = new ERC20Mock(); deal(address(randomToken), address(strategy), 100e18); uint256 balanceBefore = randomToken.balanceOf(admin); vm.prank(admin); strategy.rescueTokens(address(randomToken), admin, 100e18); uint256 balanceAfter = randomToken.balanceOf(admin); assertEq(balanceAfter - balanceBefore, 100e18, "Rescue failed"); assertEq(randomToken.balanceOf(address(strategy)), 0, "Strategy still has tokens"); } function test_rescueTokens_reverts_on_protected_token() public { // MYT.asset() is protected by default vm.expectRevert(bytes("Protected token")); vm.prank(admin); strategy.rescueTokens(address(fakeUnderlyingToken), admin, 10e18); } function test_rescueTokens_reverts_on_zero_recipient() public { ERC20Mock randomToken = new ERC20Mock(); deal(address(randomToken), address(strategy), 100e18); vm.expectRevert(bytes("Invalid recipient")); vm.prank(admin); strategy.rescueTokens(address(randomToken), address(0), 100e18); } function test_rescueTokens_reverts_on_insufficient_balance() public { ERC20Mock randomToken = new ERC20Mock(); deal(address(randomToken), address(strategy), 50e18); vm.expectRevert(bytes("Insufficient balance")); vm.prank(admin); strategy.rescueTokens(address(randomToken), admin, 100e18); } function test_rescueTokens_onlyOwner() public { ERC20Mock randomToken = new ERC20Mock(); deal(address(randomToken), address(strategy), 100e18); vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("OwnableUnauthorizedAccount(address)")), user)); vm.prank(user); strategy.rescueTokens(address(randomToken), user, 100e18); } function test_rescueTokens_partial_amount() public { ERC20Mock randomToken = new ERC20Mock(); deal(address(randomToken), address(strategy), 100e18); uint256 balanceBefore = randomToken.balanceOf(admin); vm.prank(admin); strategy.rescueTokens(address(randomToken), admin, 30e18); uint256 balanceAfter = randomToken.balanceOf(admin); assertEq(balanceAfter - balanceBefore, 30e18, "Partial rescue failed"); assertEq(randomToken.balanceOf(address(strategy)), 70e18, "Remaining balance wrong"); } function test_rescueTokens_emits_event() public { ERC20Mock randomToken = new ERC20Mock(); deal(address(randomToken), address(strategy), 100e18); vm.expectEmit(true, true, false, true); emit IMYTStrategy.TokensRescued(address(randomToken), admin, 50e18); vm.prank(admin); strategy.rescueTokens(address(randomToken), admin, 50e18); } } ================================================ FILE: src/test/MultiStrategyARBETH.invariant.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "forge-std/Test.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {VaultV2Factory} from "lib/vault-v2/src/VaultV2Factory.sol"; import {AlchemistAllocator} from "../AlchemistAllocator.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {IAllocator} from "../interfaces/IAllocator.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {AaveStrategy} from "../strategies/AaveStrategy.sol"; import {ERC4626Strategy} from "../strategies/ERC4626Strategy.sol"; /// @title MultiStrategyARBETHHandler /// @notice Handler for invariant testing multiple ETH strategies on Arbitrum contract MultiStrategyARBETHHandler is Test { IVaultV2 public vault; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin; address public operator; address public asset; // Actors for user operations address[] public actors; address internal currentActor; // Ghost variables for tracking cumulative state uint256 public ghost_totalDeposited; uint256 public ghost_totalWithdrawn; uint256 public ghost_totalAllocated; uint256 public ghost_totalDeallocated; mapping(address => uint256) public ghost_userDeposits; mapping(address => uint256) public ghost_strategyAllocations; mapping(uint8 => uint256) public ghost_liquidityAdapterBypass; // Call counters mapping(bytes4 => uint256) public calls; mapping(bytes4 => uint256) public opAttempts; mapping(bytes4 => uint256) public opSuccesses; mapping(bytes4 => uint256) public opReverts; mapping(bytes4 => uint256) public opNoops; mapping(address => uint256) public allocatorRoleAttempts; uint256 internal allocatorRoleNonce; // Strategy name tracking for debugging mapping(address => string) public strategyNames; // Minimum amounts for operations uint256 public constant MIN_DEPOSIT = 1e15; // 0.001 ETH uint256 public constant MIN_ALLOCATE = 1e14; // 0.0001 ETH uint256 public constant MAX_USERS = 10; modifier countCall(bytes4 selector) { calls[selector]++; _; } modifier useActor(uint256 actorSeed) { currentActor = actors[bound(actorSeed, 0, actors.length - 1)]; vm.startPrank(currentActor); _; vm.stopPrank(); } function _markNoop(bytes4 selector) internal { opNoops[selector]++; } function _markAttempt(bytes4 selector) internal { opAttempts[selector]++; } function _markSuccess(bytes4 selector) internal { opSuccesses[selector]++; } function _markRevert(bytes4 selector) internal { opReverts[selector]++; } function _pickAllocatorCaller(uint256 seed) internal returns (address caller) { seed; caller = allocatorRoleNonce % 2 == 0 ? admin : operator; allocatorRoleNonce++; allocatorRoleAttempts[caller]++; } constructor( address _vault, address[] memory _strategies, address _allocator, address _classifier, address _curatorContract, address _admin, address _operator, string[] memory _strategyNames ) { vault = IVaultV2(_vault); strategies = _strategies; allocator = _allocator; classifier = _classifier; curatorContract = _curatorContract; admin = _admin; operator = _operator; asset = vault.asset(); // Initialize actors with varying balances for (uint256 i = 0; i < MAX_USERS; i++) { address actor = makeAddr(string(abi.encodePacked("arbEthActor", i))); actors.push(actor); // Give actors different initial balances for position size variation deal(asset, actor, (i + 1) * 100 ether); // 100 to 1000 ETH } // Map strategy names for debugging for (uint256 i = 0; i < _strategies.length; i++) { strategyNames[_strategies[i]] = _strategyNames[i]; } } // ============ USER OPERATIONS ============ /// @notice User deposits WETH into the vault function deposit(uint256 amount, uint256 actorSeed) external countCall(this.deposit.selector) useActor(actorSeed) { bytes4 selector = this.deposit.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } amount = bound(amount, MIN_DEPOSIT, balance); IERC20(asset).approve(address(vault), amount); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.deposit(amount, currentActor) { _markSuccess(selector); ghost_totalDeposited += amount; ghost_userDeposits[currentActor] += amount; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Deposit reduced total allocations"); } } catch { _markRevert(selector); } } /// @notice User withdraws WETH from the vault function withdraw(uint256 amount, uint256 actorSeed) external countCall(this.withdraw.selector) useActor(actorSeed) { bytes4 selector = this.withdraw.selector; uint256 shares = vault.balanceOf(currentActor); if (shares == 0) { _markNoop(selector); return; } uint256 maxAssets = vault.convertToAssets(shares); if (maxAssets == 0) { _markNoop(selector); return; } amount = bound(amount, 1, maxAssets); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.withdraw(amount, currentActor, currentActor) { _markSuccess(selector); ghost_totalWithdrawn += amount; if (ghost_userDeposits[currentActor] >= amount) { ghost_userDeposits[currentActor] -= amount; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Withdraw increased total allocations"); } } catch { _markRevert(selector); } } /// @notice User mints shares function mint(uint256 shares, uint256 actorSeed) external countCall(this.mint.selector) useActor(actorSeed) { bytes4 selector = this.mint.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } uint256 maxShares = vault.convertToShares(balance); if (maxShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, maxShares); IERC20(asset).approve(address(vault), balance); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.mint(shares, currentActor) returns (uint256 assetsDeposited) { _markSuccess(selector); ghost_totalDeposited += assetsDeposited; ghost_userDeposits[currentActor] += assetsDeposited; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Mint reduced total allocations"); } } catch { _markRevert(selector); } } /// @notice User redeems shares function redeem(uint256 shares, uint256 actorSeed) external countCall(this.redeem.selector) useActor(actorSeed) { bytes4 selector = this.redeem.selector; uint256 userShares = vault.balanceOf(currentActor); if (userShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, userShares); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.redeem(shares, currentActor, currentActor) returns (uint256 assetsRedeemed) { _markSuccess(selector); ghost_totalWithdrawn += assetsRedeemed; if (ghost_userDeposits[currentActor] >= assetsRedeemed) { ghost_userDeposits[currentActor] -= assetsRedeemed; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Redeem increased total allocations"); } } catch { _markRevert(selector); } } // ============ ADMIN OPERATIONS ============ function _remainingGlobalRiskHeadroom(uint8 riskLevel, address strategyToAllocate) internal view returns (uint256) { uint256 globalRiskCapPct = AlchemistStrategyClassifier(classifier).getGlobalCap(riskLevel); uint256 globalRiskCap = (vault.totalAssets() * globalRiskCapPct) / 1e18; uint256 currentRiskAllocation = 0; uint256 pendingYield = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(strategyId)) == riskLevel) { uint256 alloc = vault.allocation(strategyId); currentRiskAllocation += alloc; if (strategies[i] == strategyToAllocate) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (realAssets > alloc) { pendingYield = realAssets - alloc; } } } } uint256 effectiveAllocation = currentRiskAllocation + pendingYield; if (effectiveAllocation >= globalRiskCap) return 0; return globalRiskCap - effectiveAllocation; } /// @notice Admin allocates assets to a specific strategy function allocate(uint256 strategyIndexSeed, uint256 amount) external countCall(this.allocate.selector) { uint256 strategiesLen = strategies.length; if (strategiesLen == 0) { _markNoop(this.allocate.selector); return; } uint256 strategyIndex = strategyIndexSeed % strategiesLen; (bool success, uint256 allocatedAmount) = _tryAllocate(strategies[strategyIndex], amount, strategyIndexSeed); if (success) { assertGt(allocatedAmount, 0, "Allocate succeeded without allocation delta"); ghost_totalAllocated += allocatedAmount; ghost_strategyAllocations[strategies[strategyIndex]] += allocatedAmount; } } /// @notice Attempts to allocate to a specific strategy function _tryAllocate( address strategy, uint256 amount, uint256 roleSeed ) internal returns (bool success, uint256 allocatedAmount) { bytes4 selector = this.allocate.selector; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 globalRiskHeadroom = _remainingGlobalRiskHeadroom(riskLevel, strategy); uint256 idleVaultBalance = IERC20(asset).balanceOf(address(vault)); if (idleVaultBalance < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } if (currentAllocation >= absoluteCap) { _markNoop(selector); return (false, 0); } if (globalRiskHeadroom < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } uint256 underlyingMaxDeposit = _getUnderlyingMaxDeposit(strategy); if (underlyingMaxDeposit < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } uint256 maxByAbsolute = absoluteCap - currentAllocation; uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } // Account for yield captured in the allocation change. // The adapter returns change = _totalValue() - allocation(), so effective allocation // after allocate() will be currentAllocation + pendingYield + amount. uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); uint256 pendingYield = currentRealAssets > currentAllocation ? currentRealAssets - currentAllocation : 0; uint256 effectiveAllocation = currentAllocation + pendingYield; uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 maxByAllocatorRelative = allocatorRelativeCapValue > effectiveAllocation ? allocatorRelativeCapValue - effectiveAllocation : 0; uint256 maxByVaultRelative = vaultRelativeCapValue > effectiveAllocation ? vaultRelativeCapValue - effectiveAllocation : 0; uint256 maxByRelativeCap = maxByAllocatorRelative < maxByVaultRelative ? maxByAllocatorRelative : maxByVaultRelative; uint256 maxByAbsoluteRemaining = absoluteCap > effectiveAllocation ? absoluteCap - effectiveAllocation : 0; uint256 maxAllocate = maxByAbsoluteRemaining < maxByRelativeCap ? maxByAbsoluteRemaining : maxByRelativeCap; maxAllocate = maxAllocate < globalRiskHeadroom ? maxAllocate : globalRiskHeadroom; maxAllocate = maxAllocate < idleVaultBalance ? maxAllocate : idleVaultBalance; maxAllocate = maxAllocate < underlyingMaxDeposit ? maxAllocate : underlyingMaxDeposit; address allocatorCaller = _pickAllocatorCaller(roleSeed); if (allocatorCaller == operator) { uint256 individualCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualCap = (totalAssets * individualCapPct) / 1e18; uint256 individualRemaining = individualCap > effectiveAllocation ? individualCap - effectiveAllocation : 0; maxAllocate = maxAllocate < individualRemaining ? maxAllocate : individualRemaining; } if (maxAllocate < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } amount = bound(amount, MIN_ALLOCATE, maxAllocate); _markAttempt(selector); vm.prank(allocatorCaller); try IAllocator(allocator).allocate(strategy, amount) { uint256 newAllocation = vault.allocation(allocationId); if (newAllocation <= currentAllocation) { _markRevert(selector); return (false, 0); } _markSuccess(selector); return (true, newAllocation - currentAllocation); } catch { _markRevert(selector); return (false, 0); } } /// @notice Admin deallocates assets from a specific strategy function deallocate(uint256 strategyIndex, uint256 amount) external countCall(this.deallocate.selector) { bytes4 selector = this.deallocate.selector; strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); if (currentAllocation < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = currentAllocation; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } amount = bound(amount, MIN_ALLOCATE, maxDeallocate); uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(amount); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(amount); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } /// @notice Deallocate all assets from a specific strategy function deallocateAll(uint256 strategyIndex) external countCall(this.deallocateAll.selector) { bytes4 selector = this.deallocateAll.selector; strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 allocationBefore = vault.allocation(allocationId); if (allocationBefore < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = allocationBefore; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(maxDeallocate); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(strategyIndex); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } function setLiquidityAdapter(uint256 strategySeed, uint256 modeSeed) external countCall(this.setLiquidityAdapter.selector) { bytes4 selector = this.setLiquidityAdapter.selector; if (strategies.length == 0) { _markNoop(selector); return; } address newLiquidityAdapter = address(0); if (modeSeed % 3 != 0) { address candidate = strategies[strategySeed % strategies.length]; if (!_liquidityAdapterHasHeadroom(candidate)) { _markNoop(selector); return; } newLiquidityAdapter = candidate; } _markAttempt(selector); address allocatorCaller = _pickAllocatorCaller(modeSeed); vm.prank(allocatorCaller); try IAllocator(allocator).setLiquidityAdapter(newLiquidityAdapter, _directLiquidityData()) { _markSuccess(selector); } catch { _markRevert(selector); } } function _directLiquidityData() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; return abi.encode(params); } function _snapshotAllocations() internal view returns (uint256[] memory snapshot, uint256 totalBefore, uint256 totalYield) { uint256 len = strategies.length; snapshot = new uint256[](len); for (uint256 i = 0; i < len; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); snapshot[i] = allocation; totalBefore += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) totalYield += ra - allocation; } } function _recordAllocationDeltas(uint256[] memory beforeAllocations) internal returns (uint256 totalAfter) { uint256 len = strategies.length; for (uint256 i = 0; i < len; i++) { address strategy = strategies[i]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint256 beforeAllocation = beforeAllocations[i]; totalAfter += afterAllocation; if (afterAllocation >= beforeAllocation) { uint256 deltaUp = afterAllocation - beforeAllocation; if (deltaUp > 0) { ghost_totalAllocated += deltaUp; ghost_strategyAllocations[strategy] += deltaUp; } } else { uint256 deltaDown = beforeAllocation - afterAllocation; ghost_totalDeallocated += deltaDown; if (ghost_strategyAllocations[strategy] >= deltaDown) { ghost_strategyAllocations[strategy] -= deltaDown; } else { ghost_strategyAllocations[strategy] = 0; } } } } function _recordLiquidityAdapterBypass(uint256[] memory beforeAllocations) internal { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); if (afterAllocation > beforeAllocations[i]) { ghost_liquidityAdapterBypass[riskLevel] += afterAllocation - beforeAllocations[i]; } else if (beforeAllocations[i] > afterAllocation) { uint256 decrease = beforeAllocations[i] - afterAllocation; ghost_liquidityAdapterBypass[riskLevel] = ghost_liquidityAdapterBypass[riskLevel] > decrease ? ghost_liquidityAdapterBypass[riskLevel] - decrease : 0; } } } function _assertTotalAllocationDirection(uint256 totalBefore, uint256 totalAfter, bool expectIncrease, uint256 yieldTolerance, string memory errorMessage) internal pure { if (expectIncrease) { require(totalAfter + yieldTolerance >= totalBefore, errorMessage); } else { require(totalAfter <= totalBefore + yieldTolerance, errorMessage); } } function _liquidityAdapterHasHeadroom(address strategy) internal view returns (bool) { bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 relativeLimit = allocatorRelativeCapValue < vaultRelativeCapValue ? allocatorRelativeCapValue : vaultRelativeCapValue; uint256 hardLimit = absoluteCap < relativeLimit ? absoluteCap : relativeLimit; if (hardLimit <= currentAllocation) return false; uint256 headroom = hardLimit - currentAllocation; return headroom >= MIN_DEPOSIT * 10; } // ============ ADMIN RISK CONFIG OPERATIONS ============ /// @notice Reclassify a strategy to a different risk level (low frequency ~10%) /// @dev Only reclassifies if the target risk class's global cap can accommodate /// the strategy's existing allocation plus current aggregate in that class. function reclassifyStrategy(uint256 strategyIndexSeed, uint256 newRiskClassSeed) external countCall(this.reclassifyStrategy.selector) { bytes4 selector = this.reclassifyStrategy.selector; if (strategies.length == 0) { _markNoop(selector); return; } if (newRiskClassSeed % 100 != 0) { _markNoop(selector); return; } uint256 idx = strategyIndexSeed % strategies.length; address strategy = strategies[idx]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint8 currentRisk = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint8 newRisk = uint8((newRiskClassSeed / 10) % 3); if (newRisk == currentRisk) { newRisk = (newRisk + 1) % 3; } uint256 totalAssets = vault.totalAssets(); uint256 newGlobalCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(newRisk)) / 1e18; uint256 existingInNewClass = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == newRisk) { existingInNewClass += vault.allocation(stratId); } } uint256 strategyAllocation = vault.allocation(allocationId); if (existingInNewClass + strategyAllocation > newGlobalCap) { _markNoop(selector); return; } _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel(uint256(allocationId), newRisk); _markSuccess(selector); } /// @notice Modify the caps of a risk class (low frequency ~10%) /// @dev Only tightens caps to levels that still accommodate existing allocations. /// New global cap >= current aggregate allocation in that class + MIN_ALLOCATE. /// New local cap >= largest individual allocation in that class. function modifyRiskClassCaps(uint256 riskClassSeed, uint256 capSeed) external countCall(this.modifyRiskClassCaps.selector) { bytes4 selector = this.modifyRiskClassCaps.selector; if (capSeed % 100 != 0) { _markNoop(selector); return; } uint8 riskClass = uint8(riskClassSeed % 3); uint256 totalAssets = vault.totalAssets(); uint256 currentAggregate = 0; uint256 maxIndividual = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == riskClass) { uint256 alloc = vault.allocation(stratId); currentAggregate += alloc; if (alloc > maxIndividual) maxIndividual = alloc; } } uint256 minGlobalPct = currentAggregate > 0 ? ((currentAggregate + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxGlobalPct = 1e18; if (minGlobalPct > maxGlobalPct) { _markNoop(selector); return; } uint256 minLocalPct = maxIndividual > 0 ? ((maxIndividual + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxLocalPct = 1e18; if (minLocalPct > maxLocalPct) { _markNoop(selector); return; } uint256 newGlobalPct = bound(capSeed / 10, minGlobalPct, maxGlobalPct); uint256 newLocalPct = bound(capSeed / 100, minLocalPct, maxLocalPct); _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).setRiskClass(riskClass, newGlobalPct, newLocalPct); _markSuccess(selector); } // ============ TIME OPERATIONS ============ function changePerformanceFee(uint256 feeSeed) external countCall(this.changePerformanceFee.selector) { bytes4 selector = this.changePerformanceFee.selector; if (feeSeed % 200 != 0) { _markNoop(selector); return; } uint256 newFee = bound(feeSeed / 200, 0, 0.5e18); _markAttempt(selector); vm.prank(admin); AlchemistCurator(curatorContract).submitSetPerformanceFee(address(vault), newFee); vm.prank(admin); vault.setPerformanceFee(newFee); _markSuccess(selector); } function warpTime(uint256 timeDelta) external countCall(this.warpTime.selector) { timeDelta = bound(timeDelta, 1 hours, 365 days); vm.warp(block.timestamp + timeDelta); } // ============ HELPER FUNCTIONS ============ function getStrategyCount() external view returns (uint256) { return strategies.length; } /// @notice Returns the net allocated amount from ghost variables function ghost_netAllocated() external view returns (uint256) { if (ghost_totalAllocated >= ghost_totalDeallocated) { return ghost_totalAllocated - ghost_totalDeallocated; } return 0; } /// @notice Returns the sum of all ghost strategy allocations function ghost_sumStrategyAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { sum += ghost_strategyAllocations[strategies[i]]; } return sum; } /// @notice Returns the sum of actual vault allocations function vault_totalAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); sum += vault.allocation(allocationId); } return sum; } function _getUnderlyingMaxDeposit(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxDeposit(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } function _getUnderlyingMaxWithdraw(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxWithdraw(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } function _resolveUnderlyingVault(address strategy) internal view returns (address underlyingVault) { (bool ok, bytes memory data) = strategy.staticcall(abi.encodeWithSignature("vault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); if (underlyingVault != address(0)) return underlyingVault; } (ok, data) = strategy.staticcall(abi.encodeWithSignature("autoVault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); } } function getCalls(bytes4 selector) external view returns (uint256) { return calls[selector]; } function getOperationStats(bytes4 selector) external view returns (uint256 attempts_, uint256 successes_, uint256 reverts_, uint256 noops_) { return (opAttempts[selector], opSuccesses[selector], opReverts[selector], opNoops[selector]); } function getAllocatorRoleAttempts(address role) external view returns (uint256) { return allocatorRoleAttempts[role]; } function _logOperationStats(string memory label, bytes4 selector) internal view { console.log(label); console.log(" attempts:", opAttempts[selector]); console.log(" successes:", opSuccesses[selector]); console.log(" reverts:", opReverts[selector]); console.log(" noops:", opNoops[selector]); } function callSummary() external view { console.log("=== ARB ETH Multi-Strategy Handler Call Summary ==="); console.log("User Operations:"); console.log(" deposit calls:", calls[this.deposit.selector]); console.log(" withdraw calls:", calls[this.withdraw.selector]); console.log(" mint calls:", calls[this.mint.selector]); console.log(" redeem calls:", calls[this.redeem.selector]); console.log("Admin Operations:"); console.log(" allocate calls:", calls[this.allocate.selector]); console.log(" deallocate calls:", calls[this.deallocate.selector]); console.log(" deallocateAll calls:", calls[this.deallocateAll.selector]); console.log("Admin Risk Config Operations:"); console.log(" reclassifyStrategy calls:", calls[this.reclassifyStrategy.selector]); console.log(" modifyRiskClassCaps calls:", calls[this.modifyRiskClassCaps.selector]); console.log(" changePerformanceFee calls:", calls[this.changePerformanceFee.selector]); console.log("Time Operations:"); console.log(" warpTime calls:", calls[this.warpTime.selector]); console.log("Ghost Variables:"); console.log(" totalDeposited:", ghost_totalDeposited); console.log(" totalWithdrawn:", ghost_totalWithdrawn); console.log(" totalAllocated:", ghost_totalAllocated); console.log(" totalDeallocated:", ghost_totalDeallocated); console.log("Operation Stats:"); _logOperationStats(" allocate", this.allocate.selector); _logOperationStats(" deallocate", this.deallocate.selector); _logOperationStats(" deallocateAll", this.deallocateAll.selector); _logOperationStats(" setLiquidityAdapter", this.setLiquidityAdapter.selector); _logOperationStats(" withdraw", this.withdraw.selector); _logOperationStats(" redeem", this.redeem.selector); console.log("Allocator Role Attempts:"); console.log(" admin:", allocatorRoleAttempts[admin]); console.log(" operator:", allocatorRoleAttempts[operator]); } } /// @title MultiStrategyARBETHInvariantTest /// @notice Invariant tests for ETH strategies on Arbitrum contract MultiStrategyARBETHInvariantTest is Test { IVaultV2 public vault; MultiStrategyARBETHHandler public handler; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin = address(0x1); address public operator = address(0x3); // Arbitrum addresses address public constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1; address public constant AAVE_POOL_ARB = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb; address public constant AWETH_ARB = 0xe50fA9b3c56FfB159cB0FCA61F5c9D750e8128c8; address public constant AAVE_REWARDS_CONTROLLER = 0x929EC64c34a17401F460460D4B9390518E5B473e; address public constant ARB = 0x912CE59144191C1204E64559FE8253a0e49E6548; address public constant EULER_WETH_VAULT_ARB = 0x78E3E051D32157AACD550fBB78458762d8f7edFF; uint256 public constant INITIAL_VAULT_DEPOSIT = 10_000 ether; uint256 public constant ABSOLUTE_CAP = 50_000 ether; uint256 public constant RELATIVE_CAP = 0.5e18; uint256 public initialSharePrice; uint256 private forkId; function setUp() public { // Fork Arbitrum string memory rpc = vm.envString("ARBITRUM_RPC_URL"); forkId = vm.createFork(rpc); vm.selectFork(forkId); // Setup vault vm.startPrank(admin); vault = _setupVault(WETH); // Setup strategies string[] memory strategyNames = new string[](2); strategyNames[0] = "Aave V3 ARB WETH"; strategyNames[1] = "Euler ARB WETH"; // Deploy Aave WETH Strategy strategies.push(_deployAaveWETHStrategy()); // Deploy Euler WETH Strategy strategies.push(_deployEulerWETHStrategy()); // Setup classifier and allocator _setupClassifierAndAllocator(); // Add strategies to vault _addStrategiesToVault(); // Make initial deposit to vault _makeInitialDeposit(); initialSharePrice = (vault.totalAssets() * 1e18) / vault.totalSupply(); vm.stopPrank(); // Create handler handler = new MultiStrategyARBETHHandler( address(vault), strategies, allocator, classifier, curatorContract, admin, operator, strategyNames ); // Target the handler targetContract(address(handler)); // Target specific functions bytes4[] memory selectors = new bytes4[](12); selectors[0] = handler.deposit.selector; selectors[1] = handler.withdraw.selector; selectors[2] = handler.mint.selector; selectors[3] = handler.redeem.selector; selectors[4] = handler.allocate.selector; selectors[5] = handler.deallocate.selector; selectors[6] = handler.deallocateAll.selector; selectors[7] = handler.setLiquidityAdapter.selector; selectors[8] = handler.warpTime.selector; selectors[9] = handler.reclassifyStrategy.selector; selectors[10] = handler.modifyRiskClassCaps.selector; selectors[11] = handler.changePerformanceFee.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } function _setupVault(address asset) internal returns (IVaultV2) { VaultV2Factory factory = new VaultV2Factory(); return IVaultV2(factory.createVaultV2(admin, asset, bytes32(0))); } function _deployAaveWETHStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Aave V3 ARB WETH", protocol: "AaveV3", riskClass: IMYTStrategy.RiskClass.LOW, cap: 1 ether, globalCap: 0.5e18, estimatedYield: 500, additionalIncentives: false, slippageBPS: 50 }); return address(new AaveStrategy( address(vault), params, WETH, AWETH_ARB, AAVE_POOL_ARB, AAVE_REWARDS_CONTROLLER, ARB )); } function _deployEulerWETHStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Euler ARB WETH", protocol: "Euler", riskClass: IMYTStrategy.RiskClass.LOW, cap: 1 ether, globalCap: 0.5e18, estimatedYield: 600, additionalIncentives: false, slippageBPS: 50 }); return address(new ERC4626Strategy( address(vault), params, EULER_WETH_VAULT_ARB )); } function _setupClassifierAndAllocator() internal { classifier = address(new AlchemistStrategyClassifier(admin)); // Set up risk classes matching constructor defaults (WAD: 1e18 = 100%) AlchemistStrategyClassifier(classifier).setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% AlchemistStrategyClassifier(classifier).setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% AlchemistStrategyClassifier(classifier).setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% // Assign risk levels for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); (,,,IMYTStrategy.RiskClass riskClass,,,,,) = IMYTStrategy(strategies[i]).params(); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel( uint256(strategyId), uint8(riskClass) ); } // Deploy curator for timelocked operations curatorContract = address(new AlchemistCurator(admin, admin)); // Set curator on vault VaultV2(address(vault)).setCurator(curatorContract); _setPerformanceFee(curatorContract); allocator = address(new AlchemistAllocator(address(vault), admin, operator, classifier)); } function _setPerformanceFee(address _curator) internal { AlchemistCurator curator = AlchemistCurator(_curator); curator.submitSetPerformanceFeeRecipient(address(vault), admin); vault.setPerformanceFeeRecipient(admin); curator.submitSetPerformanceFee(address(vault), 15e16); vault.setPerformanceFee(15e16); } function _addStrategiesToVault() internal { AlchemistCurator curator = AlchemistCurator(curatorContract); curator.submitSetAllocator(address(vault), allocator, true); vault.setIsAllocator(allocator, true); for (uint256 i = 0; i < strategies.length; i++) { curator.submitSetStrategy(strategies[i], address(vault)); curator.setStrategy(strategies[i], address(vault)); curator.submitIncreaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); curator.increaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); (,,,,, uint256 strategyRelativeCap,,,) = IMYTStrategy(strategies[i]).params(); curator.submitIncreaseRelativeCap(strategies[i], strategyRelativeCap); curator.increaseRelativeCap(strategies[i], strategyRelativeCap); } } function _makeInitialDeposit() internal { deal(WETH, admin, INITIAL_VAULT_DEPOSIT); IERC20(WETH).approve(address(vault), INITIAL_VAULT_DEPOSIT); vault.deposit(INITIAL_VAULT_DEPOSIT, admin); } // ============ INVARIANTS ============ function invariant_realAssets_nonNegative() public view { for (uint256 i = 0; i < strategies.length; i++) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); assertGe(realAssets, 0, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " has negative real assets"))); } } function invariant_allocationWithinAbsoluteCap() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 ra = IMYTStrategy(strategies[i]).realAssets(); uint256 yieldGap = ra > allocation ? ra - allocation : 0; uint256 tolerance = absoluteCap / 20 + yieldGap; assertLe(allocation, absoluteCap + tolerance, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds absolute cap"))); } } function invariant_allocationWithinRelativeCap() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); if (relativeCap == 1e18) continue; uint256 maxAllowed = (firstTotalAssets * relativeCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% assertLe(allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds relative cap"))); } } function invariant_allocationWithinGlobalRiskCap() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 allocation = vault.allocation(allocationId); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } function invariant_allocationWithinIndividualRiskCap() public view { if (handler.getAllocatorRoleAttempts(admin) > 0) return; uint256 totalAssets = vault.totalAssets(); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 individualRiskCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualRiskCap = (totalAssets * individualRiskCapPct) / 1e18; assertLe(allocation, individualRiskCap, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds individual risk cap"))); } } function invariant_riskLevelAggregateCaps() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } function invariant_totalAllocationsBounded() public view { uint256 totalAllocations = 0; uint256 totalRealAssets = IERC20(vault.asset()).balanceOf(address(vault)); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); totalAllocations += vault.allocation(allocationId); totalRealAssets += IMYTStrategy(strategies[i]).realAssets(); } assertLe(totalAllocations, totalRealAssets * 110 / 100 + 1, "Total allocations exceed real assets by more than 10%"); } function invariant_realAssetsConsistentWithAllocation() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (allocation > 1e15) { uint256 minExpected = allocation * 90 / 100; //uint256 maxExpected = allocation * 110 / 100; assertGe(realAssets, minExpected, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets below allocation"))); //assertLe(realAssets, maxExpected, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets above allocation"))); } } } function invariant_sharePriceNonDecreasing() public view { uint256 totalSupply = vault.totalSupply(); if (totalSupply == 0) return; uint256 totalAssets = vault.totalAssets(); uint256 sharePrice = (totalAssets * 1e18) / totalSupply; assertGt(sharePrice, 0, "Share price collapsed to zero"); } function invariant_userBalanceConsistency() public view { uint256 totalUserDeposits = handler.ghost_totalDeposited(); uint256 totalUserWithdrawals = handler.ghost_totalWithdrawn(); uint256 netDeposits = totalUserDeposits > totalUserWithdrawals ? totalUserDeposits - totalUserWithdrawals : 0; uint256 vaultBalance = IERC20(WETH).balanceOf(address(vault)); uint256 totalStrategyValue = 0; for (uint256 i = 0; i < strategies.length; i++) { totalStrategyValue += IMYTStrategy(strategies[i]).realAssets(); } uint256 totalValue = vaultBalance + totalStrategyValue; uint256 totalExpected = INITIAL_VAULT_DEPOSIT + netDeposits; if (totalExpected > 1e15) { assertGe(totalValue, totalExpected * 90 / 100, "Total value significantly less than expected deposits"); } } /// @notice Invariant: Ghost allocations match actual vault allocations per strategy function invariant_ghostAllocationsMatchVault() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 actualAllocation = vault.allocation(allocationId); uint256 ghostAllocation = handler.ghost_strategyAllocations(strategies[i]); // Allow 5% tolerance for yield/rounding differences if (actualAllocation > 1e15) { uint256 minExpected = actualAllocation * 95 / 100; uint256 maxExpected = actualAllocation * 105 / 100; assertGe(ghostAllocation, minExpected, string(abi.encodePacked("Ghost allocation below actual for ", handler.strategyNames(strategies[i])))); assertLe(ghostAllocation, maxExpected, string(abi.encodePacked("Ghost allocation above actual for ", handler.strategyNames(strategies[i])))); } } } /// @notice Invariant: Net ghost allocations match sum of vault allocations function invariant_netAllocationsConsistent() public view { uint256 ghostNet = handler.ghost_netAllocated(); uint256 vaultTotal = handler.vault_totalAllocations(); // Allow 10% tolerance for yield accumulation and rounding if (vaultTotal > 1e15) { assertGe(ghostNet, vaultTotal * 90 / 100, "Ghost net allocations below vault total"); assertLe(ghostNet, vaultTotal * 110 / 100, "Ghost net allocations above vault total"); } } /// @notice Invariant: Ghost sum of strategy allocations is internally consistent function invariant_ghostSumConsistent() public view { uint256 ghostSum = handler.ghost_sumStrategyAllocations(); uint256 ghostNet = handler.ghost_netAllocated(); // ghost_sumStrategyAllocations should equal ghost_netAllocated // Allow small tolerance for rounding if (ghostNet > 1e15) { assertGe(ghostSum, ghostNet * 95 / 100, "Ghost sum inconsistent with net"); assertLe(ghostSum, ghostNet * 105 / 100, "Ghost sum inconsistent with net"); } } function invariant_noStrategyDominance() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); if (allocation == 0) continue; (,,,,, uint256 strategyGlobalCap,,,) = IMYTStrategy(strategies[i]).params(); uint256 maxAllowed = (firstTotalAssets * strategyGlobalCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% assertLe( allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds configured globalCap")) ); } } function invariant_allocatePathHasProgress() public view { uint256 allocateCalls = handler.getCalls(handler.allocate.selector); (uint256 allocateAttempts, uint256 allocateSuccesses, uint256 allocateReverts, uint256 allocateNoops) = handler.getOperationStats(handler.allocate.selector); assertEq(allocateCalls, allocateAttempts + allocateNoops, "Allocate call accounting mismatch"); assertEq(allocateAttempts, allocateSuccesses + allocateReverts, "Allocate attempt accounting mismatch"); if (allocateCalls >= strategies.length) { assertGt(handler.ghost_totalAllocated(), 0, "Allocate path made no progress"); } if (allocateAttempts >= strategies.length) { assertGt(allocateSuccesses, 0, "Allocate attempts made but none succeeded"); assertLt(allocateReverts, allocateAttempts, "Allocate attempts always reverted"); } } function invariant_handlerOperationAccounting() public view { bytes4[6] memory selectors = [ handler.allocate.selector, handler.deallocate.selector, handler.deallocateAll.selector, handler.setLiquidityAdapter.selector, handler.withdraw.selector, handler.redeem.selector ]; for (uint256 i = 0; i < selectors.length; i++) { bytes4 selector = selectors[i]; uint256 calls = handler.getCalls(selector); (uint256 attempts, uint256 successes, uint256 reverts_, uint256 noops) = handler.getOperationStats(selector); assertEq(calls, attempts + noops, "Operation call accounting mismatch"); assertEq(attempts, successes + reverts_, "Operation attempt accounting mismatch"); } } function invariant_userPathIsNotSilentlyReverting() public view { (uint256 withdrawAttempts, uint256 withdrawSuccesses, uint256 withdrawReverts, ) = handler.getOperationStats(handler.withdraw.selector); (uint256 redeemAttempts, uint256 redeemSuccesses, uint256 redeemReverts, ) = handler.getOperationStats(handler.redeem.selector); if (withdrawAttempts >= 5) { assertGt(withdrawSuccesses, 0, "Withdraw attempted repeatedly but never succeeded"); assertLt(withdrawReverts, withdrawAttempts, "Withdraw attempts always reverted"); } if (redeemAttempts >= 5) { assertGt(redeemSuccesses, 0, "Redeem attempted repeatedly but never succeeded"); assertLt(redeemReverts, redeemAttempts, "Redeem attempts always reverted"); } } function invariant_allocatorRolesExercised() public view { uint256 allocateAttempts; uint256 deallocateAttempts; uint256 deallocateAllAttempts; uint256 setLiquidityAttempts; (allocateAttempts, , , ) = handler.getOperationStats(handler.allocate.selector); (deallocateAttempts, , , ) = handler.getOperationStats(handler.deallocate.selector); (deallocateAllAttempts, , , ) = handler.getOperationStats(handler.deallocateAll.selector); (setLiquidityAttempts, , , ) = handler.getOperationStats(handler.setLiquidityAdapter.selector); uint256 totalAllocatorAttempts = allocateAttempts + deallocateAttempts + deallocateAllAttempts + setLiquidityAttempts; if (totalAllocatorAttempts >= 10) { assertGt(handler.getAllocatorRoleAttempts(admin), 0, "Admin allocator path not exercised"); assertGt(handler.getAllocatorRoleAttempts(operator), 0, "Operator allocator path not exercised"); } } function debugCallSummary() public view { handler.callSummary(); } } ================================================ FILE: src/test/MultiStrategyARBUSDC.invariant.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "forge-std/Test.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {VaultV2Factory} from "lib/vault-v2/src/VaultV2Factory.sol"; import {AlchemistAllocator} from "../AlchemistAllocator.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {IAllocator} from "../interfaces/IAllocator.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {AaveStrategy} from "../strategies/AaveStrategy.sol"; import {ERC4626Strategy} from "../strategies/ERC4626Strategy.sol"; /// @title MultiStrategyARBUSDCCHandler /// @notice Handler for invariant testing multiple USDC strategies on Arbitrum contract MultiStrategyARBUSDCCHandler is Test { IVaultV2 public vault; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin; address public operator; address public asset; // Actors for user operations address[] public actors; address internal currentActor; // Ghost variables for tracking cumulative state uint256 public ghost_totalDeposited; uint256 public ghost_totalWithdrawn; uint256 public ghost_totalAllocated; uint256 public ghost_totalDeallocated; mapping(address => uint256) public ghost_userDeposits; mapping(address => uint256) public ghost_strategyAllocations; mapping(uint8 => uint256) public ghost_liquidityAdapterBypass; // Call counters mapping(bytes4 => uint256) public calls; mapping(bytes4 => uint256) public opAttempts; mapping(bytes4 => uint256) public opSuccesses; mapping(bytes4 => uint256) public opReverts; mapping(bytes4 => uint256) public opNoops; mapping(address => uint256) public allocatorRoleAttempts; uint256 internal allocatorRoleNonce; // Strategy name tracking for debugging mapping(address => string) public strategyNames; // Minimum amounts for operations uint256 public constant MIN_DEPOSIT = 1e6; // 1 USDC uint256 public constant MIN_ALLOCATE = 1e5; // 0.1 USDC uint256 public constant MAX_USERS = 10; modifier countCall(bytes4 selector) { calls[selector]++; _; } modifier useActor(uint256 actorSeed) { currentActor = actors[bound(actorSeed, 0, actors.length - 1)]; vm.startPrank(currentActor); _; vm.stopPrank(); } function _markNoop(bytes4 selector) internal { opNoops[selector]++; } function _markAttempt(bytes4 selector) internal { opAttempts[selector]++; } function _markSuccess(bytes4 selector) internal { opSuccesses[selector]++; } function _markRevert(bytes4 selector) internal { opReverts[selector]++; } function _pickAllocatorCaller(uint256 seed) internal returns (address caller) { seed; caller = allocatorRoleNonce % 2 == 0 ? admin : operator; allocatorRoleNonce++; allocatorRoleAttempts[caller]++; } constructor( address _vault, address[] memory _strategies, address _allocator, address _classifier, address _curatorContract, address _admin, address _operator, string[] memory _strategyNames ) { vault = IVaultV2(_vault); strategies = _strategies; allocator = _allocator; classifier = _classifier; curatorContract = _curatorContract; admin = _admin; operator = _operator; asset = vault.asset(); // Initialize actors with varying balances for (uint256 i = 0; i < MAX_USERS; i++) { address actor = makeAddr(string(abi.encodePacked("arbUsdcActor", i))); actors.push(actor); // Give actors different initial balances for position size variation deal(asset, actor, (i + 1) * 100_000e6); // 100k to 1M USDC } // Map strategy names for debugging for (uint256 i = 0; i < _strategies.length; i++) { strategyNames[_strategies[i]] = _strategyNames[i]; } } // ============ USER OPERATIONS ============ function deposit(uint256 amount, uint256 actorSeed) external countCall(this.deposit.selector) useActor(actorSeed) { bytes4 selector = this.deposit.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } amount = bound(amount, MIN_DEPOSIT, balance); IERC20(asset).approve(address(vault), amount); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.deposit(amount, currentActor) { _markSuccess(selector); ghost_totalDeposited += amount; ghost_userDeposits[currentActor] += amount; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Deposit reduced total allocations"); } } catch { _markRevert(selector); } } function withdraw(uint256 amount, uint256 actorSeed) external countCall(this.withdraw.selector) useActor(actorSeed) { bytes4 selector = this.withdraw.selector; uint256 shares = vault.balanceOf(currentActor); if (shares == 0) { _markNoop(selector); return; } uint256 maxAssets = vault.convertToAssets(shares); if (maxAssets == 0) { _markNoop(selector); return; } amount = bound(amount, 1, maxAssets); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.withdraw(amount, currentActor, currentActor) { _markSuccess(selector); ghost_totalWithdrawn += amount; if (ghost_userDeposits[currentActor] >= amount) { ghost_userDeposits[currentActor] -= amount; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Withdraw increased total allocations"); } } catch { _markRevert(selector); } } function mint(uint256 shares, uint256 actorSeed) external countCall(this.mint.selector) useActor(actorSeed) { bytes4 selector = this.mint.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } uint256 maxShares = vault.convertToShares(balance); if (maxShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, maxShares); IERC20(asset).approve(address(vault), balance); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.mint(shares, currentActor) returns (uint256 assetsDeposited) { _markSuccess(selector); ghost_totalDeposited += assetsDeposited; ghost_userDeposits[currentActor] += assetsDeposited; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Mint reduced total allocations"); } } catch { _markRevert(selector); } } function redeem(uint256 shares, uint256 actorSeed) external countCall(this.redeem.selector) useActor(actorSeed) { bytes4 selector = this.redeem.selector; uint256 userShares = vault.balanceOf(currentActor); if (userShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, userShares); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.redeem(shares, currentActor, currentActor) returns (uint256 assetsRedeemed) { _markSuccess(selector); ghost_totalWithdrawn += assetsRedeemed; if (ghost_userDeposits[currentActor] >= assetsRedeemed) { ghost_userDeposits[currentActor] -= assetsRedeemed; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Redeem increased total allocations"); } } catch { _markRevert(selector); } } // ============ ADMIN OPERATIONS ============ function _remainingGlobalRiskHeadroom(uint8 riskLevel, address strategyToAllocate) internal view returns (uint256) { uint256 globalRiskCapPct = AlchemistStrategyClassifier(classifier).getGlobalCap(riskLevel); uint256 globalRiskCap = (vault.totalAssets() * globalRiskCapPct) / 1e18; uint256 currentRiskAllocation = 0; uint256 pendingYield = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(strategyId)) == riskLevel) { uint256 alloc = vault.allocation(strategyId); currentRiskAllocation += alloc; if (strategies[i] == strategyToAllocate) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (realAssets > alloc) { pendingYield = realAssets - alloc; } } } } uint256 effectiveAllocation = currentRiskAllocation + pendingYield; if (effectiveAllocation >= globalRiskCap) return 0; return globalRiskCap - effectiveAllocation; } function allocate(uint256 strategyIndexSeed, uint256 amount) external countCall(this.allocate.selector) { uint256 strategiesLen = strategies.length; if (strategiesLen == 0) { _markNoop(this.allocate.selector); return; } uint256 strategyIndex = strategyIndexSeed % strategiesLen; (bool success, uint256 allocatedAmount) = _tryAllocate(strategies[strategyIndex], amount, strategyIndexSeed); if (success) { assertGt(allocatedAmount, 0, "Allocate succeeded without allocation delta"); ghost_totalAllocated += allocatedAmount; ghost_strategyAllocations[strategies[strategyIndex]] += allocatedAmount; } } function _tryAllocate( address strategy, uint256 amount, uint256 roleSeed ) internal returns (bool success, uint256 allocatedAmount) { bytes4 selector = this.allocate.selector; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 globalRiskHeadroom = _remainingGlobalRiskHeadroom(riskLevel, strategy); uint256 idleVaultBalance = IERC20(asset).balanceOf(address(vault)); if (idleVaultBalance < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } if (currentAllocation >= absoluteCap) { _markNoop(selector); return (false, 0); } if (globalRiskHeadroom < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } uint256 underlyingMaxDeposit = _getUnderlyingMaxDeposit(strategy); if (underlyingMaxDeposit < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } uint256 maxByAbsolute = absoluteCap - currentAllocation; uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } // Account for yield captured in the allocation change. // The adapter returns change = _totalValue() - allocation(), so effective allocation // after allocate() will be currentAllocation + pendingYield + amount. uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); uint256 pendingYield = currentRealAssets > currentAllocation ? currentRealAssets - currentAllocation : 0; uint256 effectiveAllocation = currentAllocation + pendingYield; uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 maxByAllocatorRelative = allocatorRelativeCapValue > effectiveAllocation ? allocatorRelativeCapValue - effectiveAllocation : 0; uint256 maxByVaultRelative = vaultRelativeCapValue > effectiveAllocation ? vaultRelativeCapValue - effectiveAllocation : 0; uint256 maxByRelativeCap = maxByAllocatorRelative < maxByVaultRelative ? maxByAllocatorRelative : maxByVaultRelative; uint256 maxByAbsoluteRemaining = absoluteCap > effectiveAllocation ? absoluteCap - effectiveAllocation : 0; uint256 maxAllocate = maxByAbsoluteRemaining < maxByRelativeCap ? maxByAbsoluteRemaining : maxByRelativeCap; maxAllocate = maxAllocate < globalRiskHeadroom ? maxAllocate : globalRiskHeadroom; maxAllocate = maxAllocate < idleVaultBalance ? maxAllocate : idleVaultBalance; maxAllocate = maxAllocate < underlyingMaxDeposit ? maxAllocate : underlyingMaxDeposit; address allocatorCaller = _pickAllocatorCaller(roleSeed); if (allocatorCaller == operator) { uint256 individualCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualCap = (totalAssets * individualCapPct) / 1e18; uint256 individualRemaining = individualCap > effectiveAllocation ? individualCap - effectiveAllocation : 0; maxAllocate = maxAllocate < individualRemaining ? maxAllocate : individualRemaining; } if (maxAllocate < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } amount = bound(amount, MIN_ALLOCATE, maxAllocate); _markAttempt(selector); vm.prank(allocatorCaller); try IAllocator(allocator).allocate(strategy, amount) { uint256 newAllocation = vault.allocation(allocationId); if (newAllocation <= currentAllocation) { _markRevert(selector); return (false, 0); } _markSuccess(selector); return (true, newAllocation - currentAllocation); } catch { _markRevert(selector); return (false, 0); } } function deallocate(uint256 strategyIndex, uint256 amount) external countCall(this.deallocate.selector) { bytes4 selector = this.deallocate.selector; strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); if (currentAllocation < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = currentAllocation; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } amount = bound(amount, MIN_ALLOCATE, maxDeallocate); uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(amount); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(amount); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } function deallocateAll(uint256 strategyIndex) external countCall(this.deallocateAll.selector) { bytes4 selector = this.deallocateAll.selector; strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 allocationBefore = vault.allocation(allocationId); if (allocationBefore < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = allocationBefore; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(maxDeallocate); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(strategyIndex); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } function setLiquidityAdapter(uint256 strategySeed, uint256 modeSeed) external countCall(this.setLiquidityAdapter.selector) { bytes4 selector = this.setLiquidityAdapter.selector; if (strategies.length == 0) { _markNoop(selector); return; } address newLiquidityAdapter = address(0); if (modeSeed % 3 != 0) { address candidate = strategies[strategySeed % strategies.length]; if (!_liquidityAdapterHasHeadroom(candidate)) { _markNoop(selector); return; } newLiquidityAdapter = candidate; } _markAttempt(selector); address allocatorCaller = _pickAllocatorCaller(modeSeed); vm.prank(allocatorCaller); try IAllocator(allocator).setLiquidityAdapter(newLiquidityAdapter, _directLiquidityData()) { _markSuccess(selector); } catch { _markRevert(selector); } } function _directLiquidityData() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; return abi.encode(params); } function _snapshotAllocations() internal view returns (uint256[] memory snapshot, uint256 totalBefore, uint256 totalYield) { uint256 len = strategies.length; snapshot = new uint256[](len); for (uint256 i = 0; i < len; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); snapshot[i] = allocation; totalBefore += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) totalYield += ra - allocation; } } function _recordAllocationDeltas(uint256[] memory beforeAllocations) internal returns (uint256 totalAfter) { uint256 len = strategies.length; for (uint256 i = 0; i < len; i++) { address strategy = strategies[i]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint256 beforeAllocation = beforeAllocations[i]; totalAfter += afterAllocation; if (afterAllocation >= beforeAllocation) { uint256 deltaUp = afterAllocation - beforeAllocation; if (deltaUp > 0) { ghost_totalAllocated += deltaUp; ghost_strategyAllocations[strategy] += deltaUp; } } else { uint256 deltaDown = beforeAllocation - afterAllocation; ghost_totalDeallocated += deltaDown; if (ghost_strategyAllocations[strategy] >= deltaDown) { ghost_strategyAllocations[strategy] -= deltaDown; } else { ghost_strategyAllocations[strategy] = 0; } } } } function _recordLiquidityAdapterBypass(uint256[] memory beforeAllocations) internal { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); if (afterAllocation > beforeAllocations[i]) { ghost_liquidityAdapterBypass[riskLevel] += afterAllocation - beforeAllocations[i]; } else if (beforeAllocations[i] > afterAllocation) { uint256 decrease = beforeAllocations[i] - afterAllocation; ghost_liquidityAdapterBypass[riskLevel] = ghost_liquidityAdapterBypass[riskLevel] > decrease ? ghost_liquidityAdapterBypass[riskLevel] - decrease : 0; } } } function _assertTotalAllocationDirection(uint256 totalBefore, uint256 totalAfter, bool expectIncrease, uint256 yieldTolerance, string memory errorMessage) internal pure { if (expectIncrease) { require(totalAfter + yieldTolerance >= totalBefore, errorMessage); } else { require(totalAfter <= totalBefore + yieldTolerance, errorMessage); } } function _liquidityAdapterHasHeadroom(address strategy) internal view returns (bool) { bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 relativeLimit = allocatorRelativeCapValue < vaultRelativeCapValue ? allocatorRelativeCapValue : vaultRelativeCapValue; uint256 hardLimit = absoluteCap < relativeLimit ? absoluteCap : relativeLimit; if (hardLimit <= currentAllocation) return false; uint256 headroom = hardLimit - currentAllocation; return headroom >= MIN_DEPOSIT * 10; } // ============ ADMIN RISK CONFIG OPERATIONS ============ /// @notice Reclassify a strategy to a different risk level (low frequency ~10%) /// @dev Only reclassifies if the target risk class's global cap can accommodate /// the strategy's existing allocation plus current aggregate in that class. function reclassifyStrategy(uint256 strategyIndexSeed, uint256 newRiskClassSeed) external countCall(this.reclassifyStrategy.selector) { bytes4 selector = this.reclassifyStrategy.selector; if (strategies.length == 0) { _markNoop(selector); return; } if (newRiskClassSeed % 100 != 0) { _markNoop(selector); return; } uint256 idx = strategyIndexSeed % strategies.length; address strategy = strategies[idx]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint8 currentRisk = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint8 newRisk = uint8((newRiskClassSeed / 10) % 3); if (newRisk == currentRisk) { newRisk = (newRisk + 1) % 3; } uint256 totalAssets = vault.totalAssets(); uint256 newGlobalCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(newRisk)) / 1e18; uint256 existingInNewClass = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == newRisk) { existingInNewClass += vault.allocation(stratId); } } uint256 strategyAllocation = vault.allocation(allocationId); if (existingInNewClass + strategyAllocation > newGlobalCap) { _markNoop(selector); return; } _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel(uint256(allocationId), newRisk); _markSuccess(selector); } /// @notice Modify the caps of a risk class (low frequency ~10%) /// @dev Only tightens caps to levels that still accommodate existing allocations. /// New global cap >= current aggregate allocation in that class + MIN_ALLOCATE. /// New local cap >= largest individual allocation in that class. function modifyRiskClassCaps(uint256 riskClassSeed, uint256 capSeed) external countCall(this.modifyRiskClassCaps.selector) { bytes4 selector = this.modifyRiskClassCaps.selector; if (capSeed % 100 != 0) { _markNoop(selector); return; } uint8 riskClass = uint8(riskClassSeed % 3); uint256 totalAssets = vault.totalAssets(); uint256 currentAggregate = 0; uint256 maxIndividual = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == riskClass) { uint256 alloc = vault.allocation(stratId); currentAggregate += alloc; if (alloc > maxIndividual) maxIndividual = alloc; } } uint256 minGlobalPct = currentAggregate > 0 ? ((currentAggregate + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxGlobalPct = 1e18; if (minGlobalPct > maxGlobalPct) { _markNoop(selector); return; } uint256 minLocalPct = maxIndividual > 0 ? ((maxIndividual + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxLocalPct = 1e18; if (minLocalPct > maxLocalPct) { _markNoop(selector); return; } uint256 newGlobalPct = bound(capSeed / 10, minGlobalPct, maxGlobalPct); uint256 newLocalPct = bound(capSeed / 100, minLocalPct, maxLocalPct); _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).setRiskClass(riskClass, newGlobalPct, newLocalPct); _markSuccess(selector); } // ============ TIME OPERATIONS ============ function changePerformanceFee(uint256 feeSeed) external countCall(this.changePerformanceFee.selector) { bytes4 selector = this.changePerformanceFee.selector; if (feeSeed % 200 != 0) { _markNoop(selector); return; } uint256 newFee = bound(feeSeed / 200, 0, 0.5e18); _markAttempt(selector); vm.prank(admin); AlchemistCurator(curatorContract).submitSetPerformanceFee(address(vault), newFee); vm.prank(admin); vault.setPerformanceFee(newFee); _markSuccess(selector); } function warpTime(uint256 timeDelta) external countCall(this.warpTime.selector) { timeDelta = bound(timeDelta, 1 hours, 365 days); vm.warp(block.timestamp + timeDelta); } // ============ HELPER FUNCTIONS ============ function getStrategyCount() external view returns (uint256) { return strategies.length; } /// @notice Returns the net allocated amount from ghost variables function ghost_netAllocated() external view returns (uint256) { if (ghost_totalAllocated >= ghost_totalDeallocated) { return ghost_totalAllocated - ghost_totalDeallocated; } return 0; } /// @notice Returns the sum of all ghost strategy allocations function ghost_sumStrategyAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { sum += ghost_strategyAllocations[strategies[i]]; } return sum; } /// @notice Returns the sum of actual vault allocations function vault_totalAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); sum += vault.allocation(allocationId); } return sum; } function _getUnderlyingMaxDeposit(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxDeposit(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } function _getUnderlyingMaxWithdraw(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxWithdraw(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } function _resolveUnderlyingVault(address strategy) internal view returns (address underlyingVault) { (bool ok, bytes memory data) = strategy.staticcall(abi.encodeWithSignature("vault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); if (underlyingVault != address(0)) return underlyingVault; } (ok, data) = strategy.staticcall(abi.encodeWithSignature("autoVault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); } } function getCalls(bytes4 selector) external view returns (uint256) { return calls[selector]; } function getOperationStats(bytes4 selector) external view returns (uint256 attempts_, uint256 successes_, uint256 reverts_, uint256 noops_) { return (opAttempts[selector], opSuccesses[selector], opReverts[selector], opNoops[selector]); } function getAllocatorRoleAttempts(address role) external view returns (uint256) { return allocatorRoleAttempts[role]; } function _logOperationStats(string memory label, bytes4 selector) internal view { console.log(label); console.log(" attempts:", opAttempts[selector]); console.log(" successes:", opSuccesses[selector]); console.log(" reverts:", opReverts[selector]); console.log(" noops:", opNoops[selector]); } function callSummary() external view { console.log("=== ARB USDC Multi-Strategy Handler Call Summary ==="); console.log("User Operations:"); console.log(" deposit calls:", calls[this.deposit.selector]); console.log(" withdraw calls:", calls[this.withdraw.selector]); console.log(" mint calls:", calls[this.mint.selector]); console.log(" redeem calls:", calls[this.redeem.selector]); console.log("Admin Operations:"); console.log(" allocate calls:", calls[this.allocate.selector]); console.log(" deallocate calls:", calls[this.deallocate.selector]); console.log(" deallocateAll calls:", calls[this.deallocateAll.selector]); console.log("Admin Risk Config Operations:"); console.log(" reclassifyStrategy calls:", calls[this.reclassifyStrategy.selector]); console.log(" modifyRiskClassCaps calls:", calls[this.modifyRiskClassCaps.selector]); console.log(" changePerformanceFee calls:", calls[this.changePerformanceFee.selector]); console.log("Time Operations:"); console.log(" warpTime calls:", calls[this.warpTime.selector]); console.log("Ghost Variables:"); console.log(" totalDeposited:", ghost_totalDeposited); console.log(" totalWithdrawn:", ghost_totalWithdrawn); console.log(" totalAllocated:", ghost_totalAllocated); console.log(" totalDeallocated:", ghost_totalDeallocated); console.log("Operation Stats:"); _logOperationStats(" allocate", this.allocate.selector); _logOperationStats(" deallocate", this.deallocate.selector); _logOperationStats(" deallocateAll", this.deallocateAll.selector); _logOperationStats(" setLiquidityAdapter", this.setLiquidityAdapter.selector); _logOperationStats(" withdraw", this.withdraw.selector); _logOperationStats(" redeem", this.redeem.selector); console.log("Allocator Role Attempts:"); console.log(" admin:", allocatorRoleAttempts[admin]); console.log(" operator:", allocatorRoleAttempts[operator]); } } /// @title MultiStrategyARBUSDCInvariantTest /// @notice Invariant tests for USDC strategies on Arbitrum contract MultiStrategyARBUSDCInvariantTest is Test { IVaultV2 public vault; MultiStrategyARBUSDCCHandler public handler; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin = address(0x1); address public operator = address(0x3); uint256 public initialSharePrice; // Arbitrum addresses address public constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; address public constant AAVE_POOL_ARB = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb; address public constant AUSDC_ARB = 0x724dc807b04555b71ed48a6896b6F41593b8C637; address public constant AAVE_REWARDS_CONTROLLER = 0x929EC64c34a17401F460460D4B9390518E5B473e; address public constant ARB = 0x912CE59144191C1204E64559FE8253a0e49E6548; address public constant EULER_USDC_VAULT_ARB = 0x0a1eCC5Fe8C9be3C809844fcBe615B46A869b899; address public constant FLUID_USDC_VAULT_ARB = 0x1A996cb54bb95462040408C06122D45D6Cdb6096; uint256 public constant INITIAL_VAULT_DEPOSIT = 10_000_000e6; // 10M USDC uint256 public constant ABSOLUTE_CAP = 50_000_000e6; // 50M USDC per strategy uint256 public constant RELATIVE_CAP = 0.5e18; uint256 private forkId; function setUp() public { // Fork Arbitrum string memory rpc = vm.envString("ARBITRUM_RPC_URL"); forkId = vm.createFork(rpc); vm.selectFork(forkId); // Setup vault vm.startPrank(admin); vault = _setupVault(USDC); // Setup strategies string[] memory strategyNames = new string[](3); strategyNames[0] = "Aave V3 ARB USDC"; strategyNames[1] = "Euler ARB USDC"; strategyNames[2] = "Fluid ARB USDC"; // Deploy strategies strategies.push(_deployAaveUSDCStrategy()); strategies.push(_deployEulerUSDCStrategy()); strategies.push(_deployFluidUSDCStrategy()); // Setup classifier and allocator _setupClassifierAndAllocator(); // Add strategies to vault _addStrategiesToVault(); // Make initial deposit to vault _makeInitialDeposit(); vm.stopPrank(); initialSharePrice = (vault.totalAssets() * 1e18) / vault.totalSupply(); // Create handler handler = new MultiStrategyARBUSDCCHandler( address(vault), strategies, allocator, classifier, curatorContract, admin, operator, strategyNames ); // Target the handler targetContract(address(handler)); // Target specific functions bytes4[] memory selectors = new bytes4[](12); selectors[0] = handler.deposit.selector; selectors[1] = handler.withdraw.selector; selectors[2] = handler.mint.selector; selectors[3] = handler.redeem.selector; selectors[4] = handler.allocate.selector; selectors[5] = handler.deallocate.selector; selectors[6] = handler.deallocateAll.selector; selectors[7] = handler.setLiquidityAdapter.selector; selectors[8] = handler.warpTime.selector; selectors[9] = handler.reclassifyStrategy.selector; selectors[10] = handler.modifyRiskClassCaps.selector; selectors[11] = handler.changePerformanceFee.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } function _setupVault(address asset) internal returns (IVaultV2) { VaultV2Factory factory = new VaultV2Factory(); return IVaultV2(factory.createVaultV2(admin, asset, bytes32(0))); } function _deployAaveUSDCStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Aave V3 ARB USDC", protocol: "AaveV3", riskClass: IMYTStrategy.RiskClass.LOW, cap: 1000 * 1e6, globalCap: 0.5e18, estimatedYield: 450, additionalIncentives: false, slippageBPS: 50 }); return address(new AaveStrategy( address(vault), params, USDC, AUSDC_ARB, AAVE_POOL_ARB, AAVE_REWARDS_CONTROLLER, ARB )); } function _deployEulerUSDCStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Euler ARB USDC", protocol: "Euler", riskClass: IMYTStrategy.RiskClass.LOW, cap: 1000 * 1e6, globalCap: 0.5e18, estimatedYield: 550, additionalIncentives: false, slippageBPS: 50 }); return address(new ERC4626Strategy( address(vault), params, EULER_USDC_VAULT_ARB )); } function _deployFluidUSDCStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Fluid ARB USDC", protocol: "Fluid", riskClass: IMYTStrategy.RiskClass.MEDIUM, cap: 0, globalCap: 0.3e18, estimatedYield: 525, additionalIncentives: false, slippageBPS: 50 }); return address(new ERC4626Strategy( address(vault), params, FLUID_USDC_VAULT_ARB )); } function _setupClassifierAndAllocator() internal { classifier = address(new AlchemistStrategyClassifier(admin)); // Set up risk classes matching constructor defaults (WAD: 1e18 = 100%) AlchemistStrategyClassifier(classifier).setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% AlchemistStrategyClassifier(classifier).setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% AlchemistStrategyClassifier(classifier).setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% // Assign risk levels for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); (,,,IMYTStrategy.RiskClass riskClass,,,,,) = IMYTStrategy(strategies[i]).params(); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel( uint256(strategyId), uint8(riskClass) ); } curatorContract = address(new AlchemistCurator(admin, admin)); VaultV2(address(vault)).setCurator(curatorContract); _setPerformanceFee(curatorContract); allocator = address(new AlchemistAllocator(address(vault), admin, operator, classifier)); } function _setPerformanceFee(address _curator) internal { AlchemistCurator curator = AlchemistCurator(_curator); curator.submitSetPerformanceFeeRecipient(address(vault), admin); vault.setPerformanceFeeRecipient(admin); curator.submitSetPerformanceFee(address(vault), 15e16); vault.setPerformanceFee(15e16); } function _addStrategiesToVault() internal { AlchemistCurator curator = AlchemistCurator(curatorContract); curator.submitSetAllocator(address(vault), allocator, true); vault.setIsAllocator(allocator, true); for (uint256 i = 0; i < strategies.length; i++) { curator.submitSetStrategy(strategies[i], address(vault)); curator.setStrategy(strategies[i], address(vault)); curator.submitIncreaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); curator.increaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); (,,,,, uint256 strategyRelativeCap,,,) = IMYTStrategy(strategies[i]).params(); curator.submitIncreaseRelativeCap(strategies[i], strategyRelativeCap); curator.increaseRelativeCap(strategies[i], strategyRelativeCap); } } function _makeInitialDeposit() internal { deal(USDC, admin, INITIAL_VAULT_DEPOSIT); IERC20(USDC).approve(address(vault), INITIAL_VAULT_DEPOSIT); vault.deposit(INITIAL_VAULT_DEPOSIT, admin); } // ============ INVARIANTS ============ function invariant_realAssets_nonNegative() public view { for (uint256 i = 0; i < strategies.length; i++) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); assertGe(realAssets, 0, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " has negative real assets"))); } } function invariant_allocationWithinAbsoluteCap() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 ra = IMYTStrategy(strategies[i]).realAssets(); uint256 yieldGap = ra > allocation ? ra - allocation : 0; uint256 tolerance = absoluteCap / 20 + yieldGap; assertLe(allocation, absoluteCap + tolerance, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds absolute cap"))); } } function invariant_allocationWithinRelativeCap() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); if (relativeCap == 1e18) continue; uint256 maxAllowed = (firstTotalAssets * relativeCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% assertLe(allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds relative cap"))); } } function invariant_allocationWithinGlobalRiskCap() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 allocation = vault.allocation(allocationId); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } function invariant_allocationWithinIndividualRiskCap() public view { if (handler.getAllocatorRoleAttempts(admin) > 0) return; uint256 totalAssets = vault.totalAssets(); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 individualRiskCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualRiskCap = (totalAssets * individualRiskCapPct) / 1e18; assertLe(allocation, individualRiskCap, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds individual risk cap"))); } } function invariant_riskLevelAggregateCaps() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } function invariant_totalAllocationsBounded() public view { uint256 totalAllocations = 0; uint256 totalRealAssets = IERC20(vault.asset()).balanceOf(address(vault)); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); totalAllocations += vault.allocation(allocationId); totalRealAssets += IMYTStrategy(strategies[i]).realAssets(); } assertLe(totalAllocations, totalRealAssets * 110 / 100 + 1, "Total allocations exceed real assets by more than 10%"); } function invariant_realAssetsConsistentWithAllocation() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (allocation > 0) { uint256 minExpected = allocation * 95 / 100; //uint256 maxExpected = allocation * 105 / 100; if (allocation > 1e6) { assertGe(realAssets, minExpected, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets below allocation"))); //assertLe(realAssets, maxExpected * 2, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets significantly above allocation"))); } } } } function invariant_sharePriceNonDecreasing() public view { uint256 totalSupply = vault.totalSupply(); if (totalSupply == 0) return; uint256 totalAssets = vault.totalAssets(); uint256 sharePrice = (totalAssets * 1e18) / totalSupply; assertGt(sharePrice, 0, "Share price collapsed to zero"); } function invariant_userBalanceConsistency() public view { uint256 totalUserDeposits = handler.ghost_totalDeposited(); uint256 totalUserWithdrawals = handler.ghost_totalWithdrawn(); uint256 netDeposits = totalUserDeposits > totalUserWithdrawals ? totalUserDeposits - totalUserWithdrawals : 0; uint256 vaultBalance = IERC20(USDC).balanceOf(address(vault)); uint256 totalStrategyValue = 0; for (uint256 i = 0; i < strategies.length; i++) { totalStrategyValue += IMYTStrategy(strategies[i]).realAssets(); } uint256 totalValue = vaultBalance + totalStrategyValue; uint256 totalExpected = INITIAL_VAULT_DEPOSIT + netDeposits; if (totalExpected > 1e6) { assertGe(totalValue, totalExpected * 90 / 100, "Total value significantly less than expected deposits"); } } /// @notice Invariant: Ghost allocations match actual vault allocations per strategy function invariant_ghostAllocationsMatchVault() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 actualAllocation = vault.allocation(allocationId); uint256 ghostAllocation = handler.ghost_strategyAllocations(strategies[i]); // Allow 5% tolerance for yield/rounding differences if (actualAllocation > 1e6) { uint256 minExpected = actualAllocation * 95 / 100; uint256 maxExpected = actualAllocation * 105 / 100; assertGe(ghostAllocation, minExpected, string(abi.encodePacked("Ghost allocation below actual for ", handler.strategyNames(strategies[i])))); assertLe(ghostAllocation, maxExpected, string(abi.encodePacked("Ghost allocation above actual for ", handler.strategyNames(strategies[i])))); } } } /// @notice Invariant: Net ghost allocations match sum of vault allocations function invariant_netAllocationsConsistent() public view { uint256 ghostNet = handler.ghost_netAllocated(); uint256 vaultTotal = handler.vault_totalAllocations(); // Allow 10% tolerance for yield accumulation and rounding if (vaultTotal > 1e6) { assertGe(ghostNet, vaultTotal * 90 / 100, "Ghost net allocations below vault total"); assertLe(ghostNet, vaultTotal * 110 / 100, "Ghost net allocations above vault total"); } } /// @notice Invariant: Ghost sum of strategy allocations is internally consistent function invariant_ghostSumConsistent() public view { uint256 ghostSum = handler.ghost_sumStrategyAllocations(); uint256 ghostNet = handler.ghost_netAllocated(); // ghost_sumStrategyAllocations should equal ghost_netAllocated // Allow small tolerance for rounding if (ghostNet > 1e6) { assertGe(ghostSum, ghostNet * 95 / 100, "Ghost sum inconsistent with net"); assertLe(ghostSum, ghostNet * 105 / 100, "Ghost sum inconsistent with net"); } } function invariant_noStrategyDominance() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); if (allocation == 0) continue; (,,,,, uint256 strategyGlobalCap,,,) = IMYTStrategy(strategies[i]).params(); uint256 maxAllowed = (firstTotalAssets * strategyGlobalCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% assertLe( allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds configured globalCap")) ); } } function invariant_allocatePathHasProgress() public view { uint256 allocateCalls = handler.getCalls(handler.allocate.selector); (uint256 allocateAttempts, uint256 allocateSuccesses, uint256 allocateReverts, uint256 allocateNoops) = handler.getOperationStats(handler.allocate.selector); assertEq(allocateCalls, allocateAttempts + allocateNoops, "Allocate call accounting mismatch"); assertEq(allocateAttempts, allocateSuccesses + allocateReverts, "Allocate attempt accounting mismatch"); if (allocateCalls >= strategies.length) { assertGt(handler.ghost_totalAllocated(), 0, "Allocate path made no progress"); } if (allocateAttempts >= strategies.length) { assertGt(allocateSuccesses, 0, "Allocate attempts made but none succeeded"); assertLt(allocateReverts, allocateAttempts, "Allocate attempts always reverted"); } } function invariant_handlerOperationAccounting() public view { bytes4[6] memory selectors = [ handler.allocate.selector, handler.deallocate.selector, handler.deallocateAll.selector, handler.setLiquidityAdapter.selector, handler.withdraw.selector, handler.redeem.selector ]; for (uint256 i = 0; i < selectors.length; i++) { bytes4 selector = selectors[i]; uint256 calls = handler.getCalls(selector); (uint256 attempts, uint256 successes, uint256 reverts_, uint256 noops) = handler.getOperationStats(selector); assertEq(calls, attempts + noops, "Operation call accounting mismatch"); assertEq(attempts, successes + reverts_, "Operation attempt accounting mismatch"); } } function invariant_userPathIsNotSilentlyReverting() public view { (uint256 withdrawAttempts, uint256 withdrawSuccesses, uint256 withdrawReverts, ) = handler.getOperationStats(handler.withdraw.selector); (uint256 redeemAttempts, uint256 redeemSuccesses, uint256 redeemReverts, ) = handler.getOperationStats(handler.redeem.selector); if (withdrawAttempts >= 5) { assertGt(withdrawSuccesses, 0, "Withdraw attempted repeatedly but never succeeded"); assertLt(withdrawReverts, withdrawAttempts, "Withdraw attempts always reverted"); } if (redeemAttempts >= 5) { assertGt(redeemSuccesses, 0, "Redeem attempted repeatedly but never succeeded"); assertLt(redeemReverts, redeemAttempts, "Redeem attempts always reverted"); } } function invariant_allocatorRolesExercised() public view { uint256 allocateAttempts; uint256 deallocateAttempts; uint256 deallocateAllAttempts; uint256 setLiquidityAttempts; (allocateAttempts, , , ) = handler.getOperationStats(handler.allocate.selector); (deallocateAttempts, , , ) = handler.getOperationStats(handler.deallocate.selector); (deallocateAllAttempts, , , ) = handler.getOperationStats(handler.deallocateAll.selector); (setLiquidityAttempts, , , ) = handler.getOperationStats(handler.setLiquidityAdapter.selector); uint256 totalAllocatorAttempts = allocateAttempts + deallocateAttempts + deallocateAllAttempts + setLiquidityAttempts; if (totalAllocatorAttempts >= 10) { assertGt(handler.getAllocatorRoleAttempts(admin), 0, "Admin allocator path not exercised"); assertGt(handler.getAllocatorRoleAttempts(operator), 0, "Operator allocator path not exercised"); } } function debugCallSummary() public view { handler.callSummary(); } } ================================================ FILE: src/test/MultiStrategyETH.invariant.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "forge-std/Test.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {VaultV2Factory} from "lib/vault-v2/src/VaultV2Factory.sol"; import {AlchemistAllocator} from "../AlchemistAllocator.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {IAllocator} from "../interfaces/IAllocator.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {ERC4626Strategy} from "../strategies/ERC4626Strategy.sol"; import {TokeAutoStrategy} from "../strategies/TokeAutoStrategy.sol"; import {AaveStrategy} from "../strategies/AaveStrategy.sol"; /// @title MultiStrategyETHHandler /// @notice Handler for invariant testing multiple ETH strategies attached to a single vault contract MultiStrategyETHHandler is Test { IVaultV2 public vault; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin; address public operator; address public asset; // Actors for user operations address[] public actors; address internal currentActor; // Ghost variables for tracking cumulative state uint256 public ghost_totalDeposited; uint256 public ghost_totalWithdrawn; uint256 public ghost_totalAllocated; uint256 public ghost_totalDeallocated; mapping(address => uint256) public ghost_userDeposits; mapping(address => uint256) public ghost_strategyAllocations; mapping(uint8 => uint256) public ghost_liquidityAdapterBypass; // Call counters mapping(bytes4 => uint256) public calls; mapping(bytes4 => uint256) public opAttempts; mapping(bytes4 => uint256) public opSuccesses; mapping(bytes4 => uint256) public opReverts; mapping(bytes4 => uint256) public opNoops; mapping(address => uint256) public allocatorRoleAttempts; uint256 internal allocatorRoleNonce; // Strategy name tracking for debugging mapping(address => string) public strategyNames; // Minimum amounts for operations uint256 public constant MIN_DEPOSIT = 1e15; // 0.001 ETH uint256 public constant MIN_ALLOCATE = 1e14; // 0.0001 ETH uint256 public constant MAX_USERS = 10; modifier countCall(bytes4 selector) { calls[selector]++; _; } modifier useActor(uint256 actorSeed) { currentActor = actors[bound(actorSeed, 0, actors.length - 1)]; vm.startPrank(currentActor); _; vm.stopPrank(); } function _markNoop(bytes4 selector) internal { opNoops[selector]++; } function _markAttempt(bytes4 selector) internal { opAttempts[selector]++; } function _markSuccess(bytes4 selector) internal { opSuccesses[selector]++; } function _markRevert(bytes4 selector) internal { opReverts[selector]++; } function _pickAllocatorCaller(uint256 seed) internal returns (address caller) { seed; caller = allocatorRoleNonce % 2 == 0 ? admin : operator; allocatorRoleNonce++; allocatorRoleAttempts[caller]++; } constructor( address _vault, address[] memory _strategies, address _allocator, address _classifier, address _curatorContract, address _admin, address _operator, string[] memory _strategyNames ) { vault = IVaultV2(_vault); strategies = _strategies; allocator = _allocator; classifier = _classifier; curatorContract = _curatorContract; admin = _admin; operator = _operator; asset = vault.asset(); // Initialize actors with varying balances for (uint256 i = 0; i < MAX_USERS; i++) { address actor = makeAddr(string(abi.encodePacked("ethActor", i))); actors.push(actor); // Give actors different initial balances for position size variation deal(asset, actor, (i + 1) * 100 ether); // 100 to 1000 ETH } // Map strategy names for debugging for (uint256 i = 0; i < _strategies.length; i++) { strategyNames[_strategies[i]] = _strategyNames[i]; } } // ============ USER OPERATIONS ============ /// @notice User deposits WETH into the vault function deposit(uint256 amount, uint256 actorSeed) external countCall(this.deposit.selector) useActor(actorSeed) { bytes4 selector = this.deposit.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } amount = bound(amount, MIN_DEPOSIT, balance); IERC20(asset).approve(address(vault), amount); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.deposit(amount, currentActor) { _markSuccess(selector); ghost_totalDeposited += amount; ghost_userDeposits[currentActor] += amount; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Deposit reduced total allocations"); } } catch { _markRevert(selector); } } /// @notice User withdraws WETH from the vault function withdraw(uint256 amount, uint256 actorSeed) external countCall(this.withdraw.selector) useActor(actorSeed) { bytes4 selector = this.withdraw.selector; uint256 shares = vault.balanceOf(currentActor); if (shares == 0) { _markNoop(selector); return; } uint256 maxAssets = vault.convertToAssets(shares); if (maxAssets == 0) { _markNoop(selector); return; } amount = bound(amount, 1, maxAssets); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.withdraw(amount, currentActor, currentActor) { _markSuccess(selector); ghost_totalWithdrawn += amount; if (ghost_userDeposits[currentActor] >= amount) { ghost_userDeposits[currentActor] -= amount; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Withdraw increased total allocations"); } } catch { _markRevert(selector); } } /// @notice User mints shares function mint(uint256 shares, uint256 actorSeed) external countCall(this.mint.selector) useActor(actorSeed) { bytes4 selector = this.mint.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } uint256 maxShares = vault.convertToShares(balance); if (maxShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, maxShares); IERC20(asset).approve(address(vault), balance); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.mint(shares, currentActor) returns (uint256 assetsDeposited) { _markSuccess(selector); ghost_totalDeposited += assetsDeposited; ghost_userDeposits[currentActor] += assetsDeposited; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Mint reduced total allocations"); } } catch { _markRevert(selector); } } /// @notice User redeems shares function redeem(uint256 shares, uint256 actorSeed) external countCall(this.redeem.selector) useActor(actorSeed) { bytes4 selector = this.redeem.selector; uint256 userShares = vault.balanceOf(currentActor); if (userShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, userShares); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.redeem(shares, currentActor, currentActor) returns (uint256 assetsRedeemed) { _markSuccess(selector); ghost_totalWithdrawn += assetsRedeemed; if (ghost_userDeposits[currentActor] >= assetsRedeemed) { ghost_userDeposits[currentActor] -= assetsRedeemed; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Redeem increased total allocations"); } } catch { _markRevert(selector); } } // ============ ADMIN OPERATIONS ============ function _remainingGlobalRiskHeadroom(uint8 riskLevel, address strategyToAllocate) internal view returns (uint256) { uint256 globalRiskCapPct = AlchemistStrategyClassifier(classifier).getGlobalCap(riskLevel); uint256 globalRiskCap = (vault.totalAssets() * globalRiskCapPct) / 1e18; uint256 currentRiskAllocation = 0; uint256 pendingYield = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(strategyId)) == riskLevel) { uint256 alloc = vault.allocation(strategyId); currentRiskAllocation += alloc; if (strategies[i] == strategyToAllocate) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (realAssets > alloc) { pendingYield = realAssets - alloc; } } } } uint256 effectiveAllocation = currentRiskAllocation + pendingYield; if (effectiveAllocation >= globalRiskCap) return 0; return globalRiskCap - effectiveAllocation; } /// @notice Admin allocates assets to a specific strategy /// @dev Attempts adapters sequentially from a random start index. function allocate(uint256 strategyIndexSeed, uint256 amount) external countCall(this.allocate.selector) { uint256 strategiesLen = strategies.length; if (strategiesLen == 0) { _markNoop(this.allocate.selector); return; } uint256 strategyIndex = strategyIndexSeed % strategiesLen; (bool success, uint256 allocatedAmount) = _tryAllocate(strategies[strategyIndex], amount, strategyIndexSeed); if (success) { assertGt(allocatedAmount, 0, "Allocate succeeded without allocation delta"); ghost_totalAllocated += allocatedAmount; ghost_strategyAllocations[strategies[strategyIndex]] += allocatedAmount; } } /// @notice Attempts to allocate to a specific strategy, returns success and amount allocated function _tryAllocate( address strategy, uint256 amount, uint256 roleSeed ) internal returns (bool success, uint256 allocatedAmount) { bytes4 selector = this.allocate.selector; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 globalRiskHeadroom = _remainingGlobalRiskHeadroom(riskLevel, strategy); uint256 idleVaultBalance = IERC20(asset).balanceOf(address(vault)); if (idleVaultBalance < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } if (currentAllocation >= absoluteCap) { _markNoop(selector); return (false, 0); } if (globalRiskHeadroom < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } // Get the underlying vault's max deposit to respect protocol-level caps uint256 underlyingMaxDeposit = _getUnderlyingMaxDeposit(strategy); if (underlyingMaxDeposit < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } uint256 maxByAbsolute = absoluteCap - currentAllocation; uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } // Account for yield captured in the allocation change. // The adapter returns change = _totalValue() - allocation(), so effective allocation // after allocate() will be currentAllocation + pendingYield + amount. uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); uint256 pendingYield = currentRealAssets > currentAllocation ? currentRealAssets - currentAllocation : 0; uint256 effectiveAllocation = currentAllocation + pendingYield; uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 maxByAllocatorRelative = allocatorRelativeCapValue > effectiveAllocation ? allocatorRelativeCapValue - effectiveAllocation : 0; uint256 maxByVaultRelative = vaultRelativeCapValue > effectiveAllocation ? vaultRelativeCapValue - effectiveAllocation : 0; uint256 maxByRelativeCap = maxByAllocatorRelative < maxByVaultRelative ? maxByAllocatorRelative : maxByVaultRelative; uint256 maxByAbsoluteRemaining = absoluteCap > effectiveAllocation ? absoluteCap - effectiveAllocation : 0; uint256 maxAllocate = maxByAbsoluteRemaining < maxByRelativeCap ? maxByAbsoluteRemaining : maxByRelativeCap; maxAllocate = maxAllocate < globalRiskHeadroom ? maxAllocate : globalRiskHeadroom; maxAllocate = maxAllocate < idleVaultBalance ? maxAllocate : idleVaultBalance; maxAllocate = maxAllocate < underlyingMaxDeposit ? maxAllocate : underlyingMaxDeposit; address allocatorCaller = _pickAllocatorCaller(roleSeed); if (allocatorCaller == operator) { uint256 individualCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualCap = (totalAssets * individualCapPct) / 1e18; uint256 individualRemaining = individualCap > effectiveAllocation ? individualCap - effectiveAllocation : 0; maxAllocate = maxAllocate < individualRemaining ? maxAllocate : individualRemaining; } if (maxAllocate < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } amount = bound(amount, MIN_ALLOCATE, maxAllocate); _markAttempt(selector); vm.prank(allocatorCaller); try IAllocator(allocator).allocate(strategy, amount) { uint256 newAllocation = vault.allocation(allocationId); if (newAllocation <= currentAllocation) { _markRevert(selector); return (false, 0); } _markSuccess(selector); return (true, newAllocation - currentAllocation); } catch { _markRevert(selector); return (false, 0); } } /// @notice Admin deallocates assets from a specific strategy function deallocate(uint256 strategyIndex, uint256 amount) external countCall(this.deallocate.selector) { bytes4 selector = this.deallocate.selector; strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); if (currentAllocation < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = currentAllocation; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } amount = bound(amount, MIN_ALLOCATE, maxDeallocate); uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(amount); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(amount); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } /// @notice Deallocate all assets from a specific strategy function deallocateAll(uint256 strategyIndex) external countCall(this.deallocateAll.selector) { bytes4 selector = this.deallocateAll.selector; strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 allocationBefore = vault.allocation(allocationId); if (allocationBefore < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = allocationBefore; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(maxDeallocate); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(strategyIndex); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } function setLiquidityAdapter(uint256 strategySeed, uint256 modeSeed) external countCall(this.setLiquidityAdapter.selector) { bytes4 selector = this.setLiquidityAdapter.selector; if (strategies.length == 0) { _markNoop(selector); return; } address newLiquidityAdapter = address(0); if (modeSeed % 3 != 0) { address candidate = strategies[strategySeed % strategies.length]; if (!_liquidityAdapterHasHeadroom(candidate)) { _markNoop(selector); return; } newLiquidityAdapter = candidate; } _markAttempt(selector); address allocatorCaller = _pickAllocatorCaller(modeSeed); vm.prank(allocatorCaller); try IAllocator(allocator).setLiquidityAdapter(newLiquidityAdapter, _directLiquidityData()) { _markSuccess(selector); } catch { _markRevert(selector); } } function _directLiquidityData() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; return abi.encode(params); } function _snapshotAllocations() internal view returns (uint256[] memory snapshot, uint256 totalBefore, uint256 totalYield) { uint256 len = strategies.length; snapshot = new uint256[](len); for (uint256 i = 0; i < len; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); snapshot[i] = allocation; totalBefore += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) totalYield += ra - allocation; } } function _recordAllocationDeltas(uint256[] memory beforeAllocations) internal returns (uint256 totalAfter) { uint256 len = strategies.length; for (uint256 i = 0; i < len; i++) { address strategy = strategies[i]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint256 beforeAllocation = beforeAllocations[i]; totalAfter += afterAllocation; if (afterAllocation >= beforeAllocation) { uint256 deltaUp = afterAllocation - beforeAllocation; if (deltaUp > 0) { ghost_totalAllocated += deltaUp; ghost_strategyAllocations[strategy] += deltaUp; } } else { uint256 deltaDown = beforeAllocation - afterAllocation; ghost_totalDeallocated += deltaDown; if (ghost_strategyAllocations[strategy] >= deltaDown) { ghost_strategyAllocations[strategy] -= deltaDown; } else { ghost_strategyAllocations[strategy] = 0; } } } } function _recordLiquidityAdapterBypass(uint256[] memory beforeAllocations) internal { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); if (afterAllocation > beforeAllocations[i]) { ghost_liquidityAdapterBypass[riskLevel] += afterAllocation - beforeAllocations[i]; } else if (beforeAllocations[i] > afterAllocation) { uint256 decrease = beforeAllocations[i] - afterAllocation; ghost_liquidityAdapterBypass[riskLevel] = ghost_liquidityAdapterBypass[riskLevel] > decrease ? ghost_liquidityAdapterBypass[riskLevel] - decrease : 0; } } } function _assertTotalAllocationDirection(uint256 totalBefore, uint256 totalAfter, bool expectIncrease, uint256 yieldTolerance, string memory errorMessage) internal pure { if (expectIncrease) { require(totalAfter + yieldTolerance >= totalBefore, errorMessage); } else { require(totalAfter <= totalBefore + yieldTolerance, errorMessage); } } function _liquidityAdapterHasHeadroom(address strategy) internal view returns (bool) { bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 relativeLimit = allocatorRelativeCapValue < vaultRelativeCapValue ? allocatorRelativeCapValue : vaultRelativeCapValue; uint256 hardLimit = absoluteCap < relativeLimit ? absoluteCap : relativeLimit; if (hardLimit <= currentAllocation) return false; uint256 headroom = hardLimit - currentAllocation; return headroom >= MIN_DEPOSIT * 10; } // ============ ADMIN RISK CONFIG OPERATIONS ============ /// @notice Reclassify a strategy to a different risk level (low frequency ~10%) /// @dev Only reclassifies if the target risk class's global cap can accommodate /// the strategy's existing allocation plus current aggregate in that class. function reclassifyStrategy(uint256 strategyIndexSeed, uint256 newRiskClassSeed) external countCall(this.reclassifyStrategy.selector) { bytes4 selector = this.reclassifyStrategy.selector; if (strategies.length == 0) { _markNoop(selector); return; } if (newRiskClassSeed % 100 != 0) { _markNoop(selector); return; } uint256 idx = strategyIndexSeed % strategies.length; address strategy = strategies[idx]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint8 currentRisk = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint8 newRisk = uint8((newRiskClassSeed / 10) % 3); if (newRisk == currentRisk) { newRisk = (newRisk + 1) % 3; } uint256 totalAssets = vault.totalAssets(); uint256 newGlobalCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(newRisk)) / 1e18; uint256 existingInNewClass = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == newRisk) { existingInNewClass += vault.allocation(stratId); } } uint256 strategyAllocation = vault.allocation(allocationId); if (existingInNewClass + strategyAllocation > newGlobalCap) { _markNoop(selector); return; } _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel(uint256(allocationId), newRisk); _markSuccess(selector); } /// @notice Modify the caps of a risk class (low frequency ~10%) /// @dev Only tightens caps to levels that still accommodate existing allocations. /// New global cap >= current aggregate allocation in that class + MIN_ALLOCATE. /// New local cap >= largest individual allocation in that class. function modifyRiskClassCaps(uint256 riskClassSeed, uint256 capSeed) external countCall(this.modifyRiskClassCaps.selector) { bytes4 selector = this.modifyRiskClassCaps.selector; if (capSeed % 100 != 0) { _markNoop(selector); return; } uint8 riskClass = uint8(riskClassSeed % 3); uint256 totalAssets = vault.totalAssets(); uint256 currentAggregate = 0; uint256 maxIndividual = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == riskClass) { uint256 alloc = vault.allocation(stratId); currentAggregate += alloc; if (alloc > maxIndividual) maxIndividual = alloc; } } uint256 minGlobalPct = currentAggregate > 0 ? ((currentAggregate + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxGlobalPct = 1e18; if (minGlobalPct > maxGlobalPct) { _markNoop(selector); return; } uint256 minLocalPct = maxIndividual > 0 ? ((maxIndividual + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxLocalPct = 1e18; if (minLocalPct > maxLocalPct) { _markNoop(selector); return; } uint256 newGlobalPct = bound(capSeed / 10, minGlobalPct, maxGlobalPct); uint256 newLocalPct = bound(capSeed / 100, minLocalPct, maxLocalPct); _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).setRiskClass(riskClass, newGlobalPct, newLocalPct); _markSuccess(selector); } // ============ TIME OPERATIONS ============ function changePerformanceFee(uint256 feeSeed) external countCall(this.changePerformanceFee.selector) { bytes4 selector = this.changePerformanceFee.selector; if (feeSeed % 200 != 0) { _markNoop(selector); return; } uint256 newFee = bound(feeSeed / 200, 0, 0.5e18); _markAttempt(selector); vm.prank(admin); AlchemistCurator(curatorContract).submitSetPerformanceFee(address(vault), newFee); vm.prank(admin); vault.setPerformanceFee(newFee); _markSuccess(selector); } /// @notice Advance time for yield accumulation function warpTime(uint256 timeDelta) external countCall(this.warpTime.selector) { timeDelta = bound(timeDelta, 1 hours, 365 days); vm.warp(block.timestamp + timeDelta); } /// @notice Advance time with oracle mocking for Tokemak function warpTimeWithTokemakOracle(uint256 timeDelta) external countCall(this.warpTimeWithTokemakOracle.selector) { timeDelta = bound(timeDelta, 1 hours, 365 days); // Mock Tokemak oracle calls for strategies that need it _mockTokemakOracle(); vm.warp(block.timestamp + timeDelta); } // ============ REWARD OPERATIONS ============ /// @notice Claim rewards from Tokemak strategy (mocked) function claimTokemakRewards(uint256 strategyIndex, uint256 minAmountOut) external countCall(this.claimTokemakRewards.selector) { strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; // Only Tokemak strategies have rewards string memory name = strategyNames[strategy]; if (keccak256(bytes(name)) != keccak256(bytes("TokeAutoEth Mainnet"))) return; bytes memory quote = hex"01"; // Mock quote minAmountOut = bound(minAmountOut, 0, 1e18); // Reasonable min out vm.prank(admin); try IMYTStrategy(strategy).claimRewards( 0x2e9d63788249371f1DFC918a52f8d799F4a38C94, // TOKE quote, minAmountOut ) returns (uint256) { // Success } catch { // Expected if no rewards or swap fails } } // ============ HELPER FUNCTIONS ============ function _mockTokemakOracle() internal { address oracle = 0x61F8BE7FD721e80C0249829eaE6f0DAf21bc2CaC; // Mock oracle calls vm.mockCall( oracle, abi.encodeWithSignature("getPriceInEth(address)"), abi.encode(1e18) ); vm.mockCall( oracle, abi.encodeWithSignature("getCeilingPrice(address,address,address)"), abi.encode(1.1e18) ); vm.mockCall( oracle, abi.encodeWithSignature("getFloorPrice(address,address,address)"), abi.encode(0.9e18) ); } function getStrategyCount() external view returns (uint256) { return strategies.length; } /// @notice Returns the net allocated amount from ghost variables function ghost_netAllocated() external view returns (uint256) { if (ghost_totalAllocated >= ghost_totalDeallocated) { return ghost_totalAllocated - ghost_totalDeallocated; } return 0; } /// @notice Returns the sum of all ghost strategy allocations function ghost_sumStrategyAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { sum += ghost_strategyAllocations[strategies[i]]; } return sum; } /// @notice Returns the sum of actual vault allocations function vault_totalAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); sum += vault.allocation(allocationId); } return sum; } function getStrategy(uint256 index) external view returns (address) { return strategies[index]; } function getCalls(bytes4 selector) external view returns (uint256) { return calls[selector]; } function getOperationStats(bytes4 selector) external view returns (uint256 attempts_, uint256 successes_, uint256 reverts_, uint256 noops_) { return (opAttempts[selector], opSuccesses[selector], opReverts[selector], opNoops[selector]); } function getAllocatorRoleAttempts(address role) external view returns (uint256) { return allocatorRoleAttempts[role]; } function _logOperationStats(string memory label, bytes4 selector) internal view { console.log(label); console.log(" attempts:", opAttempts[selector]); console.log(" successes:", opSuccesses[selector]); console.log(" reverts:", opReverts[selector]); console.log(" noops:", opNoops[selector]); } /// @dev Get the maximum deposit amount for the underlying protocol vault /// This accounts for protocol-level supply caps (e.g., Euler's E_SupplyCapExceeded) function _getUnderlyingMaxDeposit(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxDeposit(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } /// @dev Get the maximum withdrawable amount for the underlying protocol vault. function _getUnderlyingMaxWithdraw(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxWithdraw(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } function _resolveUnderlyingVault(address strategy) internal view returns (address underlyingVault) { (bool ok, bytes memory data) = strategy.staticcall(abi.encodeWithSignature("vault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); if (underlyingVault != address(0)) return underlyingVault; } (ok, data) = strategy.staticcall(abi.encodeWithSignature("autoVault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); } } function callSummary() external view { console.log("=== ETH Multi-Strategy Handler Call Summary ==="); console.log("User Operations:"); console.log(" deposit calls:", calls[this.deposit.selector]); console.log(" withdraw calls:", calls[this.withdraw.selector]); console.log(" mint calls:", calls[this.mint.selector]); console.log(" redeem calls:", calls[this.redeem.selector]); console.log("Admin Operations:"); console.log(" allocate calls:", calls[this.allocate.selector]); console.log(" deallocate calls:", calls[this.deallocate.selector]); console.log(" deallocateAll calls:", calls[this.deallocateAll.selector]); console.log("Admin Risk Config Operations:"); console.log(" reclassifyStrategy calls:", calls[this.reclassifyStrategy.selector]); console.log(" modifyRiskClassCaps calls:", calls[this.modifyRiskClassCaps.selector]); console.log(" changePerformanceFee calls:", calls[this.changePerformanceFee.selector]); console.log("Time Operations:"); console.log(" warpTime calls:", calls[this.warpTime.selector]); console.log(" warpTimeWithTokemakOracle calls:", calls[this.warpTimeWithTokemakOracle.selector]); console.log("Reward Operations:"); console.log(" claimTokemakRewards calls:", calls[this.claimTokemakRewards.selector]); console.log("Ghost Variables:"); console.log(" totalDeposited:", ghost_totalDeposited); console.log(" totalWithdrawn:", ghost_totalWithdrawn); console.log(" totalAllocated:", ghost_totalAllocated); console.log(" totalDeallocated:", ghost_totalDeallocated); console.log("Operation Stats:"); _logOperationStats(" allocate", this.allocate.selector); _logOperationStats(" deallocate", this.deallocate.selector); _logOperationStats(" deallocateAll", this.deallocateAll.selector); _logOperationStats(" setLiquidityAdapter", this.setLiquidityAdapter.selector); _logOperationStats(" withdraw", this.withdraw.selector); _logOperationStats(" redeem", this.redeem.selector); console.log("Allocator Role Attempts:"); console.log(" admin:", allocatorRoleAttempts[admin]); console.log(" operator:", allocatorRoleAttempts[operator]); } } /// @title MultiStrategyETHInvariantTest /// @notice Invariant tests for ETH strategies attached to a single vault contract MultiStrategyETHInvariantTest is Test { IVaultV2 public vault; MultiStrategyETHHandler public handler; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin = address(0x1); address public operator = address(0x3); address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address public constant EULER_WETH_VAULT = 0xD8b27CF359b7D15710a5BE299AF6e7Bf904984C2; address public constant PEAPODS_ETH_VAULT = 0x9a42e1bEA03154c758BeC4866ec5AD214D4F2191; address public constant TOKE_AUTO_ETH_VAULT = 0x0A2b94F6871c1D7A32Fe58E1ab5e6deA2f114E56; address public constant TOKE_REWARDER_ETH = 0x60882D6f70857606Cdd37729ccCe882015d1755E; address public constant TOKE = 0x2e9d63788249371f1DFC918a52f8d799F4a38C94; address public constant TOKE_ORACLE = 0x61F8BE7FD721e80C0249829eaE6f0DAf21bc2CaC; address public constant AAVE_V3_ETH_WETH_ATOKEN = 0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8; address public constant AAVE_V3_ETH_POOL_ADDRESS_PROVIDER = 0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e; address public constant AAVE_REWARDS_CONTROLLER = 0x8164Cc65827dcFe994AB23944CBC90e0aa80bFcb; address public constant AAVE_REWARD_TOKEN = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; // wstETH uint256 public constant INITIAL_VAULT_DEPOSIT = 10_000 ether; uint256 public constant ABSOLUTE_CAP = 50_000 ether; uint256 public constant RELATIVE_CAP = 0.5e18; uint256 public initialSharePrice; uint256 private forkId; function setUp() public { // Fork mainnet at specific block string memory rpc = vm.envString("MAINNET_RPC_URL"); forkId = vm.createFork(rpc); vm.selectFork(forkId); // Setup vault vm.startPrank(admin); vault = _setupVault(WETH); // Setup strategies string[] memory strategyNames = new string[](4); strategyNames[0] = "Euler Mainnet WETH"; strategyNames[1] = "Peapods Mainnet ETH"; strategyNames[2] = "TokeAutoEth Mainnet"; strategyNames[3] = "AaveV3 Mainnet WETH"; // Deploy Euler WETH Strategy strategies.push(_deployEulerStrategy()); // Deploy Peapods ETH Strategy strategies.push(_deployPeapodsStrategy()); // Deploy TokeAuto ETH Strategy strategies.push(_deployTokeStrategy()); // Deploy Aave V3 WETH Strategy strategies.push(_deployAaveWethStrategy()); // Setup classifier and allocator _setupClassifierAndAllocator(); // Add strategies to vault _addStrategiesToVault(); // Make initial deposit to vault _makeInitialDeposit(); initialSharePrice = (vault.totalAssets() * 1e18) / vault.totalSupply(); vm.stopPrank(); // Create handler handler = new MultiStrategyETHHandler( address(vault), strategies, allocator, classifier, curatorContract, admin, operator, strategyNames ); // Target the handler targetContract(address(handler)); // Target specific functions bytes4[] memory selectors = new bytes4[](12); selectors[0] = handler.deposit.selector; selectors[1] = handler.withdraw.selector; selectors[2] = handler.mint.selector; selectors[3] = handler.redeem.selector; selectors[4] = handler.allocate.selector; selectors[5] = handler.deallocate.selector; selectors[6] = handler.deallocateAll.selector; selectors[7] = handler.setLiquidityAdapter.selector; selectors[8] = handler.warpTime.selector; selectors[9] = handler.reclassifyStrategy.selector; selectors[10] = handler.modifyRiskClassCaps.selector; selectors[11] = handler.changePerformanceFee.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } function _setupVault(address asset) internal returns (IVaultV2) { VaultV2Factory factory = new VaultV2Factory(); return IVaultV2(factory.createVaultV2(admin, asset, bytes32(0))); } function _deployEulerStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Euler Mainnet WETH", protocol: "Euler", riskClass: IMYTStrategy.RiskClass.MEDIUM, cap: 1 ether, globalCap: 0.3e18, estimatedYield: 600, additionalIncentives: false, slippageBPS: 50 }); return address(new ERC4626Strategy(address(vault), params, EULER_WETH_VAULT)); } function _deployPeapodsStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Peapods Mainnet ETH", protocol: "Peapods", riskClass: IMYTStrategy.RiskClass.HIGH, cap: 0, globalCap: 0.2e18, estimatedYield: 700, additionalIncentives: false, slippageBPS: 50 }); return address(new ERC4626Strategy(address(vault), params, PEAPODS_ETH_VAULT)); } function _deployTokeStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "TokeAutoEth Mainnet", protocol: "TokeAuto", riskClass: IMYTStrategy.RiskClass.MEDIUM, cap: 0, globalCap: 0.3e18, estimatedYield: 800, additionalIncentives: false, slippageBPS: 50 }); return address(new TokeAutoStrategy( address(vault), params, WETH, TOKE_AUTO_ETH_VAULT, TOKE_REWARDER_ETH, TOKE )); } function _deployAaveWethStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "AaveV3 Mainnet WETH", protocol: "AaveV3", riskClass: IMYTStrategy.RiskClass.LOW, cap: 0, globalCap: 0.3e18, estimatedYield: 400, additionalIncentives: false, slippageBPS: 1 }); return address(new AaveStrategy( address(vault), params, WETH, AAVE_V3_ETH_WETH_ATOKEN, AAVE_V3_ETH_POOL_ADDRESS_PROVIDER, AAVE_REWARDS_CONTROLLER, AAVE_REWARD_TOKEN )); } function _setupClassifierAndAllocator() internal { classifier = address(new AlchemistStrategyClassifier(admin)); // Set up risk classes matching constructor defaults (WAD: 1e18 = 100%) AlchemistStrategyClassifier(classifier).setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% AlchemistStrategyClassifier(classifier).setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% AlchemistStrategyClassifier(classifier).setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% // Assign risk levels for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); (,,,IMYTStrategy.RiskClass riskClass,,,,,) = IMYTStrategy(strategies[i]).params(); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel( uint256(strategyId), uint8(riskClass) ); } // Deploy curator for timelocked operations curatorContract = address(new AlchemistCurator(admin, admin)); // Set curator on vault (owner can do this directly) VaultV2(address(vault)).setCurator(curatorContract); _setPerformanceFee(curatorContract); allocator = address(new AlchemistAllocator(address(vault), admin, operator, classifier)); } function _setPerformanceFee(address _curator) internal { AlchemistCurator curator = AlchemistCurator(_curator); curator.submitSetPerformanceFeeRecipient(address(vault), admin); vault.setPerformanceFeeRecipient(admin); curator.submitSetPerformanceFee(address(vault), 15e16); vault.setPerformanceFee(15e16); } function _addStrategiesToVault() internal { // Use curator for timelocked operations AlchemistCurator curator = AlchemistCurator(curatorContract); // Submit and set allocator through curator curator.submitSetAllocator(address(vault), allocator, true); vault.setIsAllocator(allocator, true); for (uint256 i = 0; i < strategies.length; i++) { // Submit and add adapter through curator curator.submitSetStrategy(strategies[i], address(vault)); curator.setStrategy(strategies[i], address(vault)); // Submit and set caps through curator curator.submitIncreaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); curator.increaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); (,,,,, uint256 strategyRelativeCap,,,) = IMYTStrategy(strategies[i]).params(); curator.submitIncreaseRelativeCap(strategies[i], strategyRelativeCap); curator.increaseRelativeCap(strategies[i], strategyRelativeCap); } } function _makeInitialDeposit() internal { deal(WETH, admin, INITIAL_VAULT_DEPOSIT); IERC20(WETH).approve(address(vault), INITIAL_VAULT_DEPOSIT); vault.deposit(INITIAL_VAULT_DEPOSIT, admin); } // ============ INVARIANTS ============ /// @notice Invariant: All strategies must have non-negative real assets function invariant_realAssets_nonNegative() public view { for (uint256 i = 0; i < strategies.length; i++) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); assertGe(realAssets, 0, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " has negative real assets"))); } } /// @notice Invariant: No strategy allocation exceeds absolute cap function invariant_allocationWithinAbsoluteCap() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 ra = IMYTStrategy(strategies[i]).realAssets(); uint256 yieldGap = ra > allocation ? ra - allocation : 0; uint256 tolerance = absoluteCap / 20 + yieldGap; assertLe(allocation, absoluteCap + tolerance, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds absolute cap"))); } } /// @notice Invariant: No strategy allocation exceeds relative cap function invariant_allocationWithinRelativeCap() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); if (relativeCap == 1e18) continue; uint256 maxAllowed = (firstTotalAssets * relativeCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% assertLe(allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds relative cap"))); } } /// @notice Invariant: No strategy allocation exceeds global risk cap for its risk level function invariant_allocationWithinGlobalRiskCap() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 allocation = vault.allocation(allocationId); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } /// @notice Invariant: No strategy allocation exceeds individual/local risk cap function invariant_allocationWithinIndividualRiskCap() public view { if (handler.getAllocatorRoleAttempts(admin) > 0) return; uint256 totalAssets = vault.totalAssets(); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 individualRiskCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualRiskCap = (totalAssets * individualRiskCapPct) / 1e18; assertLe(allocation, individualRiskCap, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds individual risk cap"))); } } /// @notice Invariant: Total allocations per risk level don't exceed aggregate limits function invariant_riskLevelAggregateCaps() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } /// @notice Invariant: Sum of all allocations bounded by vault assets function invariant_totalAllocationsBounded() public view { uint256 totalAllocations = 0; uint256 totalRealAssets = IERC20(vault.asset()).balanceOf(address(vault)); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); totalAllocations += vault.allocation(allocationId); totalRealAssets += IMYTStrategy(strategies[i]).realAssets(); } assertLe(totalAllocations, totalRealAssets * 110 / 100 + 1, "Total allocations exceed real assets by more than 10%"); } /// @notice Invariant: Real assets consistent with allocation function invariant_realAssetsConsistentWithAllocation() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (allocation > 1e15) { uint256 minExpected = allocation * 90 / 100; assertGe(realAssets, minExpected, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets below allocation"))); } } } function invariant_sharePriceNonDecreasing() public view { uint256 totalSupply = vault.totalSupply(); if (totalSupply == 0) return; uint256 totalAssets = vault.totalAssets(); uint256 sharePrice = (totalAssets * 1e18) / totalSupply; assertGt(sharePrice, 0, "Share price collapsed to zero"); } /// @notice Invariant: User balance consistency function invariant_userBalanceConsistency() public view { uint256 totalUserDeposits = handler.ghost_totalDeposited(); uint256 totalUserWithdrawals = handler.ghost_totalWithdrawn(); uint256 netDeposits = totalUserDeposits > totalUserWithdrawals ? totalUserDeposits - totalUserWithdrawals : 0; uint256 vaultBalance = IERC20(WETH).balanceOf(address(vault)); uint256 totalStrategyValue = 0; for (uint256 i = 0; i < strategies.length; i++) { totalStrategyValue += IMYTStrategy(strategies[i]).realAssets(); } uint256 totalValue = vaultBalance + totalStrategyValue; uint256 totalExpected = INITIAL_VAULT_DEPOSIT + netDeposits; if (totalExpected > 1e15) { assertGe(totalValue, totalExpected * 90 / 100, "Total value significantly less than expected deposits"); } } /// @notice Invariant: Ghost allocations match actual vault allocations per strategy function invariant_ghostAllocationsMatchVault() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 actualAllocation = vault.allocation(allocationId); uint256 ghostAllocation = handler.ghost_strategyAllocations(strategies[i]); // Allow 5% tolerance for yield/rounding differences if (actualAllocation > 1e15) { uint256 minExpected = actualAllocation * 95 / 100; uint256 maxExpected = actualAllocation * 105 / 100; assertGe(ghostAllocation, minExpected, string(abi.encodePacked("Ghost allocation below actual for ", handler.strategyNames(strategies[i])))); assertLe(ghostAllocation, maxExpected, string(abi.encodePacked("Ghost allocation above actual for ", handler.strategyNames(strategies[i])))); } } } /// @notice Invariant: Net ghost allocations match sum of vault allocations function invariant_netAllocationsConsistent() public view { uint256 ghostNet = handler.ghost_netAllocated(); uint256 vaultTotal = handler.vault_totalAllocations(); // Allow 10% tolerance for yield accumulation and rounding if (vaultTotal > 1e15) { assertGe(ghostNet, vaultTotal * 90 / 100, "Ghost net allocations below vault total"); assertLe(ghostNet, vaultTotal * 110 / 100, "Ghost net allocations above vault total"); } } /// @notice Invariant: Ghost sum of strategy allocations is internally consistent function invariant_ghostSumConsistent() public view { uint256 ghostSum = handler.ghost_sumStrategyAllocations(); uint256 ghostNet = handler.ghost_netAllocated(); // ghost_sumStrategyAllocations should equal ghost_netAllocated // Allow small tolerance for rounding if (ghostNet > 1e15) { assertGe(ghostSum, ghostNet * 95 / 100, "Ghost sum inconsistent with net"); assertLe(ghostSum, ghostNet * 105 / 100, "Ghost sum inconsistent with net"); } } function invariant_noStrategyDominance() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); if (allocation == 0) continue; (,,,,, uint256 strategyGlobalCap,,,) = IMYTStrategy(strategies[i]).params(); uint256 maxAllowed = (firstTotalAssets * strategyGlobalCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% assertLe( allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds configured globalCap")) ); } } /// @notice Invariant: Strategy-specific checks for Tokemak function invariant_tokemakSharesConsistent() public view { for (uint256 i = 0; i < strategies.length; i++) { string memory name = handler.strategyNames(strategies[i]); if (keccak256(bytes(name)) == keccak256(bytes("TokeAutoEth Mainnet"))) { // Check that Tokemak shares are properly staked uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); if (allocation > 0) { // Real assets should exist for Tokemak assertGt(realAssets, 0, "Tokemak strategy has allocation but no real assets"); } } } } /// @notice Ensures allocate path is exercised and not a no-op. function invariant_allocatePathHasProgress() public view { uint256 allocateCalls = handler.getCalls(handler.allocate.selector); (uint256 allocateAttempts, uint256 allocateSuccesses, uint256 allocateReverts, uint256 allocateNoops) = handler.getOperationStats(handler.allocate.selector); assertEq(allocateCalls, allocateAttempts + allocateNoops, "Allocate call accounting mismatch"); assertEq(allocateAttempts, allocateSuccesses + allocateReverts, "Allocate attempt accounting mismatch"); if (allocateCalls >= strategies.length) { assertGt(handler.ghost_totalAllocated(), 0, "Allocate path made no progress"); } if (allocateAttempts >= strategies.length) { assertGt(allocateSuccesses, 0, "Allocate attempts made but none succeeded"); assertLt(allocateReverts, allocateAttempts, "Allocate attempts always reverted"); } } function invariant_handlerOperationAccounting() public view { bytes4[6] memory selectors = [ handler.allocate.selector, handler.deallocate.selector, handler.deallocateAll.selector, handler.setLiquidityAdapter.selector, handler.withdraw.selector, handler.redeem.selector ]; for (uint256 i = 0; i < selectors.length; i++) { bytes4 selector = selectors[i]; uint256 calls = handler.getCalls(selector); (uint256 attempts, uint256 successes, uint256 reverts_, uint256 noops) = handler.getOperationStats(selector); assertEq(calls, attempts + noops, "Operation call accounting mismatch"); assertEq(attempts, successes + reverts_, "Operation attempt accounting mismatch"); } } function invariant_userPathIsNotSilentlyReverting() public view { (uint256 withdrawAttempts, uint256 withdrawSuccesses, uint256 withdrawReverts, ) = handler.getOperationStats(handler.withdraw.selector); (uint256 redeemAttempts, uint256 redeemSuccesses, uint256 redeemReverts, ) = handler.getOperationStats(handler.redeem.selector); if (withdrawAttempts >= 5) { assertGt(withdrawSuccesses, 0, "Withdraw attempted repeatedly but never succeeded"); assertLt(withdrawReverts, withdrawAttempts, "Withdraw attempts always reverted"); } if (redeemAttempts >= 5) { assertGt(redeemSuccesses, 0, "Redeem attempted repeatedly but never succeeded"); assertLt(redeemReverts, redeemAttempts, "Redeem attempts always reverted"); } } function invariant_allocatorRolesExercised() public view { uint256 allocateAttempts; uint256 deallocateAttempts; uint256 deallocateAllAttempts; uint256 setLiquidityAttempts; (allocateAttempts, , , ) = handler.getOperationStats(handler.allocate.selector); (deallocateAttempts, , , ) = handler.getOperationStats(handler.deallocate.selector); (deallocateAllAttempts, , , ) = handler.getOperationStats(handler.deallocateAll.selector); (setLiquidityAttempts, , , ) = handler.getOperationStats(handler.setLiquidityAdapter.selector); uint256 totalAllocatorAttempts = allocateAttempts + deallocateAttempts + deallocateAllAttempts + setLiquidityAttempts; if (totalAllocatorAttempts >= 10) { assertGt(handler.getAllocatorRoleAttempts(admin), 0, "Admin allocator path not exercised"); assertGt(handler.getAllocatorRoleAttempts(operator), 0, "Operator allocator path not exercised"); } } function debugCallSummary() public view { handler.callSummary(); } } ================================================ FILE: src/test/MultiStrategyOPETH.invariant.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "forge-std/Test.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {VaultV2Factory} from "lib/vault-v2/src/VaultV2Factory.sol"; import {AlchemistAllocator} from "../AlchemistAllocator.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {IAllocator} from "../interfaces/IAllocator.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {MoonwellStrategy} from "../strategies/MoonwellStrategy.sol"; import {AaveStrategy} from "../strategies/AaveStrategy.sol"; /// @title MultiStrategyOPETHHandler /// @notice Handler for invariant testing ETH strategies on Optimism contract MultiStrategyOPETHHandler is Test { IVaultV2 public vault; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin; address public operator; address public asset; // Actors for user operations address[] public actors; address internal currentActor; // Ghost variables for tracking cumulative state uint256 public ghost_totalDeposited; uint256 public ghost_totalWithdrawn; uint256 public ghost_totalAllocated; uint256 public ghost_totalDeallocated; mapping(address => uint256) public ghost_userDeposits; mapping(address => uint256) public ghost_strategyAllocations; mapping(uint8 => uint256) public ghost_liquidityAdapterBypass; // Call counters mapping(bytes4 => uint256) public calls; mapping(bytes4 => uint256) public opAttempts; mapping(bytes4 => uint256) public opSuccesses; mapping(bytes4 => uint256) public opReverts; mapping(bytes4 => uint256) public opNoops; mapping(address => uint256) public allocatorRoleAttempts; uint256 internal allocatorRoleNonce; // Strategy name tracking for debugging mapping(address => string) public strategyNames; // Minimum amounts for operations uint256 public constant MIN_DEPOSIT = 1e15; // 0.001 ETH uint256 public constant MIN_ALLOCATE = 1e14; // 0.0001 ETH uint256 public constant MAX_USERS = 10; modifier countCall(bytes4 selector) { calls[selector]++; _; } modifier useActor(uint256 actorSeed) { currentActor = actors[bound(actorSeed, 0, actors.length - 1)]; vm.startPrank(currentActor); _; vm.stopPrank(); } function _markNoop(bytes4 selector) internal { opNoops[selector]++; } function _markAttempt(bytes4 selector) internal { opAttempts[selector]++; } function _markSuccess(bytes4 selector) internal { opSuccesses[selector]++; } function _markRevert(bytes4 selector) internal { opReverts[selector]++; } function _pickAllocatorCaller(uint256 seed) internal returns (address caller) { seed; caller = allocatorRoleNonce % 2 == 0 ? admin : operator; allocatorRoleNonce++; allocatorRoleAttempts[caller]++; } constructor( address _vault, address[] memory _strategies, address _allocator, address _classifier, address _curatorContract, address _admin, address _operator, string[] memory _strategyNames ) { vault = IVaultV2(_vault); strategies = _strategies; allocator = _allocator; classifier = _classifier; curatorContract = _curatorContract; admin = _admin; operator = _operator; asset = vault.asset(); // Initialize actors with varying balances for (uint256 i = 0; i < MAX_USERS; i++) { address actor = makeAddr(string(abi.encodePacked("opEthActor", i))); actors.push(actor); deal(asset, actor, (i + 1) * 100 ether); } for (uint256 i = 0; i < _strategies.length; i++) { strategyNames[_strategies[i]] = _strategyNames[i]; } } // ============ USER OPERATIONS ============ function deposit(uint256 amount, uint256 actorSeed) external countCall(this.deposit.selector) useActor(actorSeed) { bytes4 selector = this.deposit.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } amount = bound(amount, MIN_DEPOSIT, balance); IERC20(asset).approve(address(vault), amount); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.deposit(amount, currentActor) { _markSuccess(selector); ghost_totalDeposited += amount; ghost_userDeposits[currentActor] += amount; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Deposit reduced total allocations"); } } catch { _markRevert(selector); } } function withdraw(uint256 amount, uint256 actorSeed) external countCall(this.withdraw.selector) useActor(actorSeed) { bytes4 selector = this.withdraw.selector; uint256 shares = vault.balanceOf(currentActor); if (shares == 0) { _markNoop(selector); return; } uint256 maxAssets = vault.convertToAssets(shares); if (maxAssets == 0) { _markNoop(selector); return; } amount = bound(amount, 1, maxAssets); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.withdraw(amount, currentActor, currentActor) { _markSuccess(selector); ghost_totalWithdrawn += amount; if (ghost_userDeposits[currentActor] >= amount) { ghost_userDeposits[currentActor] -= amount; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Withdraw increased total allocations"); } } catch { _markRevert(selector); } } function mint(uint256 shares, uint256 actorSeed) external countCall(this.mint.selector) useActor(actorSeed) { bytes4 selector = this.mint.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } uint256 maxShares = vault.convertToShares(balance); if (maxShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, maxShares); IERC20(asset).approve(address(vault), balance); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.mint(shares, currentActor) returns (uint256 assetsDeposited) { _markSuccess(selector); ghost_totalDeposited += assetsDeposited; ghost_userDeposits[currentActor] += assetsDeposited; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Mint reduced total allocations"); } } catch { _markRevert(selector); } } function redeem(uint256 shares, uint256 actorSeed) external countCall(this.redeem.selector) useActor(actorSeed) { bytes4 selector = this.redeem.selector; uint256 userShares = vault.balanceOf(currentActor); if (userShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, userShares); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.redeem(shares, currentActor, currentActor) returns (uint256 assetsRedeemed) { _markSuccess(selector); ghost_totalWithdrawn += assetsRedeemed; if (ghost_userDeposits[currentActor] >= assetsRedeemed) { ghost_userDeposits[currentActor] -= assetsRedeemed; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Redeem increased total allocations"); } } catch { _markRevert(selector); } } // ============ ADMIN OPERATIONS ============ function _remainingGlobalRiskHeadroom(uint8 riskLevel, address strategyToAllocate) internal view returns (uint256) { uint256 globalRiskCapPct = AlchemistStrategyClassifier(classifier).getGlobalCap(riskLevel); uint256 globalRiskCap = (vault.totalAssets() * globalRiskCapPct) / 1e18; uint256 currentRiskAllocation = 0; uint256 pendingYield = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(strategyId)) == riskLevel) { uint256 alloc = vault.allocation(strategyId); currentRiskAllocation += alloc; if (strategies[i] == strategyToAllocate) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (realAssets > alloc) { pendingYield = realAssets - alloc; } } } } uint256 effectiveAllocation = currentRiskAllocation + pendingYield; if (effectiveAllocation >= globalRiskCap) return 0; return globalRiskCap - effectiveAllocation; } function allocate(uint256 strategyIndexSeed, uint256 amount) external countCall(this.allocate.selector) { uint256 strategiesLen = strategies.length; if (strategiesLen == 0) { _markNoop(this.allocate.selector); return; } uint256 strategyIndex = strategyIndexSeed % strategiesLen; (bool success, uint256 allocatedAmount) = _tryAllocate(strategies[strategyIndex], amount, strategyIndexSeed); if (success) { assertGt(allocatedAmount, 0, "Allocate succeeded without allocation delta"); ghost_totalAllocated += allocatedAmount; ghost_strategyAllocations[strategies[strategyIndex]] += allocatedAmount; } } function _tryAllocate( address strategy, uint256 amount, uint256 roleSeed ) internal returns (bool success, uint256 allocatedAmount) { bytes4 selector = this.allocate.selector; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 globalRiskHeadroom = _remainingGlobalRiskHeadroom(riskLevel, strategy); uint256 idleVaultBalance = IERC20(asset).balanceOf(address(vault)); if (idleVaultBalance < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } if (currentAllocation >= absoluteCap) { _markNoop(selector); return (false, 0); } if (globalRiskHeadroom < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } uint256 underlyingMaxDeposit = _getUnderlyingMaxDeposit(strategy); if (underlyingMaxDeposit < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } uint256 maxByAbsolute = absoluteCap - currentAllocation; uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } // Account for yield captured in the allocation change. // The adapter returns change = _totalValue() - allocation(), so effective allocation // after allocate() will be currentAllocation + pendingYield + amount. uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); uint256 pendingYield = currentRealAssets > currentAllocation ? currentRealAssets - currentAllocation : 0; uint256 effectiveAllocation = currentAllocation + pendingYield; uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 maxByAllocatorRelative = allocatorRelativeCapValue > effectiveAllocation ? allocatorRelativeCapValue - effectiveAllocation : 0; uint256 maxByVaultRelative = vaultRelativeCapValue > effectiveAllocation ? vaultRelativeCapValue - effectiveAllocation : 0; uint256 maxByRelativeCap = maxByAllocatorRelative < maxByVaultRelative ? maxByAllocatorRelative : maxByVaultRelative; uint256 maxByAbsoluteRemaining = absoluteCap > effectiveAllocation ? absoluteCap - effectiveAllocation : 0; uint256 maxAllocate = maxByAbsoluteRemaining < maxByRelativeCap ? maxByAbsoluteRemaining : maxByRelativeCap; maxAllocate = maxAllocate < globalRiskHeadroom ? maxAllocate : globalRiskHeadroom; maxAllocate = maxAllocate < idleVaultBalance ? maxAllocate : idleVaultBalance; maxAllocate = maxAllocate < underlyingMaxDeposit ? maxAllocate : underlyingMaxDeposit; address allocatorCaller = _pickAllocatorCaller(roleSeed); if (allocatorCaller == operator) { uint256 individualCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualCap = (totalAssets * individualCapPct) / 1e18; uint256 individualRemaining = individualCap > effectiveAllocation ? individualCap - effectiveAllocation : 0; maxAllocate = maxAllocate < individualRemaining ? maxAllocate : individualRemaining; } if (maxAllocate < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } amount = bound(amount, MIN_ALLOCATE, maxAllocate); _markAttempt(selector); vm.prank(allocatorCaller); try IAllocator(allocator).allocate(strategy, amount) { uint256 newAllocation = vault.allocation(allocationId); if (newAllocation <= currentAllocation) { _markRevert(selector); return (false, 0); } _markSuccess(selector); return (true, newAllocation - currentAllocation); } catch { _markRevert(selector); return (false, 0); } } function deallocate(uint256 strategyIndex, uint256 amount) external countCall(this.deallocate.selector) { bytes4 selector = this.deallocate.selector; if (strategies.length == 0) { _markNoop(selector); return; } strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); if (currentAllocation < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = currentAllocation; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } amount = bound(amount, MIN_ALLOCATE, maxDeallocate); uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(amount); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(amount); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } function deallocateAll(uint256 strategyIndex) external countCall(this.deallocateAll.selector) { bytes4 selector = this.deallocateAll.selector; if (strategies.length == 0) { _markNoop(selector); return; } strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 allocationBefore = vault.allocation(allocationId); if (allocationBefore < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = allocationBefore; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(maxDeallocate); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(strategyIndex); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } function setLiquidityAdapter(uint256 strategySeed, uint256 modeSeed) external countCall(this.setLiquidityAdapter.selector) { bytes4 selector = this.setLiquidityAdapter.selector; if (strategies.length == 0) { _markNoop(selector); return; } address newLiquidityAdapter = address(0); if (modeSeed % 3 != 0) { address candidate = strategies[strategySeed % strategies.length]; if (!_liquidityAdapterHasHeadroom(candidate)) { _markNoop(selector); return; } newLiquidityAdapter = candidate; } _markAttempt(selector); address allocatorCaller = _pickAllocatorCaller(modeSeed); vm.prank(allocatorCaller); try IAllocator(allocator).setLiquidityAdapter(newLiquidityAdapter, _directLiquidityData()) { _markSuccess(selector); } catch { _markRevert(selector); } } function _directLiquidityData() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; return abi.encode(params); } function _snapshotAllocations() internal view returns (uint256[] memory snapshot, uint256 totalBefore, uint256 totalYield) { uint256 len = strategies.length; snapshot = new uint256[](len); for (uint256 i = 0; i < len; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); snapshot[i] = allocation; totalBefore += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) totalYield += ra - allocation; } } function _recordAllocationDeltas(uint256[] memory beforeAllocations) internal returns (uint256 totalAfter) { uint256 len = strategies.length; for (uint256 i = 0; i < len; i++) { address strategy = strategies[i]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint256 beforeAllocation = beforeAllocations[i]; totalAfter += afterAllocation; if (afterAllocation >= beforeAllocation) { uint256 deltaUp = afterAllocation - beforeAllocation; if (deltaUp > 0) { ghost_totalAllocated += deltaUp; ghost_strategyAllocations[strategy] += deltaUp; } } else { uint256 deltaDown = beforeAllocation - afterAllocation; ghost_totalDeallocated += deltaDown; if (ghost_strategyAllocations[strategy] >= deltaDown) { ghost_strategyAllocations[strategy] -= deltaDown; } else { ghost_strategyAllocations[strategy] = 0; } } } } function _recordLiquidityAdapterBypass(uint256[] memory beforeAllocations) internal { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); if (afterAllocation > beforeAllocations[i]) { ghost_liquidityAdapterBypass[riskLevel] += afterAllocation - beforeAllocations[i]; } else if (beforeAllocations[i] > afterAllocation) { uint256 decrease = beforeAllocations[i] - afterAllocation; ghost_liquidityAdapterBypass[riskLevel] = ghost_liquidityAdapterBypass[riskLevel] > decrease ? ghost_liquidityAdapterBypass[riskLevel] - decrease : 0; } } } function _assertTotalAllocationDirection(uint256 totalBefore, uint256 totalAfter, bool expectIncrease, uint256 yieldTolerance, string memory errorMessage) internal pure { if (expectIncrease) { require(totalAfter + yieldTolerance >= totalBefore, errorMessage); } else { require(totalAfter <= totalBefore + yieldTolerance, errorMessage); } } function _liquidityAdapterHasHeadroom(address strategy) internal view returns (bool) { bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 relativeLimit = allocatorRelativeCapValue < vaultRelativeCapValue ? allocatorRelativeCapValue : vaultRelativeCapValue; uint256 hardLimit = absoluteCap < relativeLimit ? absoluteCap : relativeLimit; if (hardLimit <= currentAllocation) return false; uint256 headroom = hardLimit - currentAllocation; return headroom >= MIN_DEPOSIT * 10; } // ============ ADMIN RISK CONFIG OPERATIONS ============ /// @notice Reclassify a strategy to a different risk level (low frequency ~10%) /// @dev Only reclassifies if the target risk class's global cap can accommodate /// the strategy's existing allocation plus current aggregate in that class. function reclassifyStrategy(uint256 strategyIndexSeed, uint256 newRiskClassSeed) external countCall(this.reclassifyStrategy.selector) { bytes4 selector = this.reclassifyStrategy.selector; if (strategies.length == 0) { _markNoop(selector); return; } if (newRiskClassSeed % 100 != 0) { _markNoop(selector); return; } uint256 idx = strategyIndexSeed % strategies.length; address strategy = strategies[idx]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint8 currentRisk = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint8 newRisk = uint8((newRiskClassSeed / 10) % 3); if (newRisk == currentRisk) { newRisk = (newRisk + 1) % 3; } uint256 totalAssets = vault.totalAssets(); uint256 newGlobalCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(newRisk)) / 1e18; uint256 existingInNewClass = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == newRisk) { existingInNewClass += vault.allocation(stratId); } } uint256 strategyAllocation = vault.allocation(allocationId); if (existingInNewClass + strategyAllocation > newGlobalCap) { _markNoop(selector); return; } _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel(uint256(allocationId), newRisk); _markSuccess(selector); } /// @notice Modify the caps of a risk class (low frequency ~10%) /// @dev Only tightens caps to levels that still accommodate existing allocations. /// New global cap >= current aggregate allocation in that class + MIN_ALLOCATE. /// New local cap >= largest individual allocation in that class. function modifyRiskClassCaps(uint256 riskClassSeed, uint256 capSeed) external countCall(this.modifyRiskClassCaps.selector) { bytes4 selector = this.modifyRiskClassCaps.selector; if (capSeed % 100 != 0) { _markNoop(selector); return; } uint8 riskClass = uint8(riskClassSeed % 3); uint256 totalAssets = vault.totalAssets(); uint256 currentAggregate = 0; uint256 maxIndividual = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == riskClass) { uint256 alloc = vault.allocation(stratId); currentAggregate += alloc; if (alloc > maxIndividual) maxIndividual = alloc; } } uint256 minGlobalPct = currentAggregate > 0 ? ((currentAggregate + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxGlobalPct = 1e18; if (minGlobalPct > maxGlobalPct) { _markNoop(selector); return; } uint256 minLocalPct = maxIndividual > 0 ? ((maxIndividual + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxLocalPct = 1e18; if (minLocalPct > maxLocalPct) { _markNoop(selector); return; } uint256 newGlobalPct = bound(capSeed / 10, minGlobalPct, maxGlobalPct); uint256 newLocalPct = bound(capSeed / 100, minLocalPct, maxLocalPct); _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).setRiskClass(riskClass, newGlobalPct, newLocalPct); _markSuccess(selector); } // ============ TIME OPERATIONS ============ function changePerformanceFee(uint256 feeSeed) external countCall(this.changePerformanceFee.selector) { bytes4 selector = this.changePerformanceFee.selector; if (feeSeed % 200 != 0) { _markNoop(selector); return; } uint256 newFee = bound(feeSeed / 200, 0, 0.5e18); _markAttempt(selector); vm.prank(admin); AlchemistCurator(curatorContract).submitSetPerformanceFee(address(vault), newFee); vm.prank(admin); vault.setPerformanceFee(newFee); _markSuccess(selector); } function warpTime(uint256 timeDelta) external countCall(this.warpTime.selector) { timeDelta = bound(timeDelta, 1 hours, 365 days); vm.warp(block.timestamp + timeDelta); } function _getUnderlyingMaxWithdraw(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxWithdraw(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } function _getUnderlyingMaxDeposit(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxDeposit(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } function _resolveUnderlyingVault(address strategy) internal view returns (address underlyingVault) { (bool ok, bytes memory data) = strategy.staticcall(abi.encodeWithSignature("vault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); if (underlyingVault != address(0)) return underlyingVault; } (ok, data) = strategy.staticcall(abi.encodeWithSignature("autoVault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); } } // ============ HELPER FUNCTIONS ============ function getStrategyCount() external view returns (uint256) { return strategies.length; } /// @notice Returns the net allocated amount from ghost variables function ghost_netAllocated() external view returns (uint256) { if (ghost_totalAllocated >= ghost_totalDeallocated) { return ghost_totalAllocated - ghost_totalDeallocated; } return 0; } /// @notice Returns the sum of all ghost strategy allocations function ghost_sumStrategyAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { sum += ghost_strategyAllocations[strategies[i]]; } return sum; } /// @notice Returns the sum of actual vault allocations function vault_totalAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); sum += vault.allocation(allocationId); } return sum; } function getCalls(bytes4 selector) external view returns (uint256) { return calls[selector]; } function getOperationStats(bytes4 selector) external view returns (uint256 attempts_, uint256 successes_, uint256 reverts_, uint256 noops_) { return (opAttempts[selector], opSuccesses[selector], opReverts[selector], opNoops[selector]); } function getAllocatorRoleAttempts(address role) external view returns (uint256) { return allocatorRoleAttempts[role]; } function _logOperationStats(string memory label, bytes4 selector) internal view { console.log(label); console.log(" attempts:", opAttempts[selector]); console.log(" successes:", opSuccesses[selector]); console.log(" reverts:", opReverts[selector]); console.log(" noops:", opNoops[selector]); } function callSummary() external view { console.log("=== OP ETH Multi-Strategy Handler Call Summary ==="); console.log("User Operations:"); console.log(" deposit calls:", calls[this.deposit.selector]); console.log(" withdraw calls:", calls[this.withdraw.selector]); console.log(" mint calls:", calls[this.mint.selector]); console.log(" redeem calls:", calls[this.redeem.selector]); console.log("Admin Operations:"); console.log(" allocate calls:", calls[this.allocate.selector]); console.log(" deallocate calls:", calls[this.deallocate.selector]); console.log(" deallocateAll calls:", calls[this.deallocateAll.selector]); console.log("Admin Risk Config Operations:"); console.log(" reclassifyStrategy calls:", calls[this.reclassifyStrategy.selector]); console.log(" modifyRiskClassCaps calls:", calls[this.modifyRiskClassCaps.selector]); console.log(" changePerformanceFee calls:", calls[this.changePerformanceFee.selector]); console.log("Time Operations:"); console.log(" warpTime calls:", calls[this.warpTime.selector]); console.log("Ghost Variables:"); console.log(" totalDeposited:", ghost_totalDeposited); console.log(" totalWithdrawn:", ghost_totalWithdrawn); console.log(" totalAllocated:", ghost_totalAllocated); console.log(" totalDeallocated:", ghost_totalDeallocated); console.log("Operation Stats:"); _logOperationStats(" allocate", this.allocate.selector); _logOperationStats(" deallocate", this.deallocate.selector); _logOperationStats(" deallocateAll", this.deallocateAll.selector); _logOperationStats(" setLiquidityAdapter", this.setLiquidityAdapter.selector); _logOperationStats(" withdraw", this.withdraw.selector); _logOperationStats(" redeem", this.redeem.selector); console.log("Allocator Role Attempts:"); console.log(" admin:", allocatorRoleAttempts[admin]); console.log(" operator:", allocatorRoleAttempts[operator]); } } /// @title MultiStrategyOPETHInvariantTest /// @notice Invariant tests for ETH strategies on Optimism contract MultiStrategyOPETHInvariantTest is Test { IVaultV2 public vault; MultiStrategyOPETHHandler public handler; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin = address(0x1); address public operator = address(0x3); // Optimism addresses address public constant WETH = 0x4200000000000000000000000000000000000006; address public constant MOONWELL_MWETH = 0xb4104C02BBf4E9be85AAa41a62974E4e28D59A33; address public constant MOONWELL_COMPTROLLER = 0xCa889f40aae37FFf165BccF69aeF1E82b5C511B9; address public constant WELL = 0xA88594D404727625A9437C3f886C7643872296AE; address public constant AAVE_V3_OP_WETH_ATOKEN = 0xe50fA9b3c56FfB159cB0FCA61F5c9D750e8128c8; address public constant AAVE_V3_OP_POOL_ADDRESS_PROVIDER = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb; address public constant AAVE_REWARDS_CONTROLLER = 0x929EC64c34a17401F460460D4B9390518E5B473e; address public constant AAVE_REWARD_TOKEN = 0x4200000000000000000000000000000000000042; // OP uint256 public constant INITIAL_VAULT_DEPOSIT = 10_000 ether; uint256 public constant ABSOLUTE_CAP = 50_000 ether; uint256 public constant RELATIVE_CAP = 0.5e18; uint256 public initialSharePrice; uint256 private forkId; function setUp() public { // Fork Optimism string memory rpc = vm.envString("OPTIMISM_RPC_URL"); forkId = vm.createFork(rpc); vm.selectFork(forkId); // Setup vault vm.startPrank(admin); vault = _setupVault(WETH); // Setup strategies string[] memory strategyNames = new string[](2); strategyNames[0] = "Moonwell OP WETH"; strategyNames[1] = "AaveV3 OP WETH"; // Deploy Moonwell WETH Strategy strategies.push(_deployMoonwellWETHStrategy()); // Deploy Aave V3 WETH Strategy strategies.push(_deployAaveWethStrategy()); // Setup classifier and allocator _setupClassifierAndAllocator(); // Add strategies to vault _addStrategiesToVault(); // Make initial deposit to vault _makeInitialDeposit(); initialSharePrice = (vault.totalAssets() * 1e18) / vault.totalSupply(); vm.stopPrank(); // Create handler handler = new MultiStrategyOPETHHandler( address(vault), strategies, allocator, classifier, curatorContract, admin, operator, strategyNames ); // Target the handler targetContract(address(handler)); // Target specific functions bytes4[] memory selectors = new bytes4[](12); selectors[0] = handler.deposit.selector; selectors[1] = handler.withdraw.selector; selectors[2] = handler.mint.selector; selectors[3] = handler.redeem.selector; selectors[4] = handler.allocate.selector; selectors[5] = handler.deallocate.selector; selectors[6] = handler.deallocateAll.selector; selectors[7] = handler.setLiquidityAdapter.selector; selectors[8] = handler.warpTime.selector; selectors[9] = handler.reclassifyStrategy.selector; selectors[10] = handler.modifyRiskClassCaps.selector; selectors[11] = handler.changePerformanceFee.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } function _setupVault(address asset) internal returns (IVaultV2) { VaultV2Factory factory = new VaultV2Factory(); return IVaultV2(factory.createVaultV2(admin, asset, bytes32(0))); } function _deployMoonwellWETHStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Moonwell OP WETH", protocol: "Moonwell", riskClass: IMYTStrategy.RiskClass.LOW, cap: 0, globalCap: 0.5e18, estimatedYield: 600, additionalIncentives: false, slippageBPS: 50 }); return address(new MoonwellStrategy( address(vault), params, WETH, MOONWELL_MWETH, MOONWELL_COMPTROLLER, WELL, true )); } function _deployAaveWethStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "AaveV3 OP WETH", protocol: "AaveV3", riskClass: IMYTStrategy.RiskClass.LOW, cap: 0, globalCap: 0.3e18, estimatedYield: 400, additionalIncentives: false, slippageBPS: 1 }); return address(new AaveStrategy( address(vault), params, WETH, AAVE_V3_OP_WETH_ATOKEN, AAVE_V3_OP_POOL_ADDRESS_PROVIDER, AAVE_REWARDS_CONTROLLER, AAVE_REWARD_TOKEN )); } function _setupClassifierAndAllocator() internal { classifier = address(new AlchemistStrategyClassifier(admin)); // Set up risk classes matching constructor defaults (WAD: 1e18 = 100%) AlchemistStrategyClassifier(classifier).setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% AlchemistStrategyClassifier(classifier).setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% AlchemistStrategyClassifier(classifier).setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% // Assign risk levels for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); (,,,IMYTStrategy.RiskClass riskClass,,,,,) = IMYTStrategy(strategies[i]).params(); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel( uint256(strategyId), uint8(riskClass) ); } curatorContract = address(new AlchemistCurator(admin, admin)); VaultV2(address(vault)).setCurator(curatorContract); _setPerformanceFee(curatorContract); allocator = address(new AlchemistAllocator(address(vault), admin, operator, classifier)); } function _setPerformanceFee(address _curator) internal { AlchemistCurator curator = AlchemistCurator(_curator); curator.submitSetPerformanceFeeRecipient(address(vault), admin); vault.setPerformanceFeeRecipient(admin); curator.submitSetPerformanceFee(address(vault), 15e16); vault.setPerformanceFee(15e16); } function _addStrategiesToVault() internal { AlchemistCurator curator = AlchemistCurator(curatorContract); curator.submitSetAllocator(address(vault), allocator, true); vault.setIsAllocator(allocator, true); for (uint256 i = 0; i < strategies.length; i++) { curator.submitSetStrategy(strategies[i], address(vault)); curator.setStrategy(strategies[i], address(vault)); curator.submitIncreaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); curator.increaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); (,,,,, uint256 strategyRelativeCap,,,) = IMYTStrategy(strategies[i]).params(); curator.submitIncreaseRelativeCap(strategies[i], strategyRelativeCap); curator.increaseRelativeCap(strategies[i], strategyRelativeCap); } } function _makeInitialDeposit() internal { deal(WETH, admin, INITIAL_VAULT_DEPOSIT); IERC20(WETH).approve(address(vault), INITIAL_VAULT_DEPOSIT); vault.deposit(INITIAL_VAULT_DEPOSIT, admin); } // ============ INVARIANTS ============ function invariant_realAssets_nonNegative() public view { for (uint256 i = 0; i < strategies.length; i++) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); assertGe(realAssets, 0, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " has negative real assets"))); } } function invariant_allocationWithinAbsoluteCap() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 ra = IMYTStrategy(strategies[i]).realAssets(); uint256 yieldGap = ra > allocation ? ra - allocation : 0; uint256 tolerance = absoluteCap / 20 + yieldGap; assertLe(allocation, absoluteCap + tolerance, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds absolute cap"))); } } function invariant_allocationWithinRelativeCap() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); if (relativeCap == 1e18) continue; uint256 maxAllowed = (firstTotalAssets * relativeCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% assertLe(allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds relative cap"))); } } function invariant_allocationWithinGlobalRiskCap() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 allocation = vault.allocation(allocationId); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } function invariant_allocationWithinIndividualRiskCap() public view { if (handler.getAllocatorRoleAttempts(admin) > 0) return; uint256 totalAssets = vault.totalAssets(); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 individualRiskCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualRiskCap = (totalAssets * individualRiskCapPct) / 1e18; assertLe(allocation, individualRiskCap, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds individual risk cap"))); } } function invariant_totalAllocationsBounded() public view { uint256 totalAllocations = 0; uint256 totalRealAssets = IERC20(vault.asset()).balanceOf(address(vault)); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); totalAllocations += vault.allocation(allocationId); totalRealAssets += IMYTStrategy(strategies[i]).realAssets(); } assertLe(totalAllocations, totalRealAssets * 110 / 100 + 1, "Total allocations exceed real assets by more than 10%"); } function invariant_realAssetsConsistentWithAllocation() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (allocation > 1e15) { uint256 minExpected = allocation * 90 / 100; //uint256 maxExpected = allocation * 110 / 100; assertGe(realAssets, minExpected, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets below allocation"))); //assertLe(realAssets, maxExpected, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets above allocation"))); } } } function invariant_sharePriceNonDecreasing() public view { uint256 totalSupply = vault.totalSupply(); if (totalSupply == 0) return; uint256 totalAssets = vault.totalAssets(); uint256 sharePrice = (totalAssets * 1e18) / totalSupply; assertGt(sharePrice, 0, "Share price collapsed to zero"); } /// @notice Invariant: Ghost allocations match actual vault allocations per strategy function invariant_ghostAllocationsMatchVault() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 actualAllocation = vault.allocation(allocationId); uint256 ghostAllocation = handler.ghost_strategyAllocations(strategies[i]); // Allow 5% tolerance for yield/rounding differences if (actualAllocation > 1e15) { uint256 minExpected = actualAllocation * 95 / 100; uint256 maxExpected = actualAllocation * 105 / 100; assertGe(ghostAllocation, minExpected, string(abi.encodePacked("Ghost allocation below actual for ", handler.strategyNames(strategies[i])))); assertLe(ghostAllocation, maxExpected, string(abi.encodePacked("Ghost allocation above actual for ", handler.strategyNames(strategies[i])))); } } } /// @notice Invariant: Net ghost allocations match sum of vault allocations function invariant_netAllocationsConsistent() public view { uint256 ghostNet = handler.ghost_netAllocated(); uint256 vaultTotal = handler.vault_totalAllocations(); // Allow 10% tolerance for yield accumulation and rounding if (vaultTotal > 1e15) { assertGe(ghostNet, vaultTotal * 90 / 100, "Ghost net allocations below vault total"); assertLe(ghostNet, vaultTotal * 110 / 100, "Ghost net allocations above vault total"); } } /// @notice Invariant: Ghost sum of strategy allocations is internally consistent function invariant_ghostSumConsistent() public view { uint256 ghostSum = handler.ghost_sumStrategyAllocations(); uint256 ghostNet = handler.ghost_netAllocated(); // ghost_sumStrategyAllocations should equal ghost_netAllocated // Allow small tolerance for rounding if (ghostNet > 1e15) { assertGe(ghostSum, ghostNet * 95 / 100, "Ghost sum inconsistent with net"); assertLe(ghostSum, ghostNet * 105 / 100, "Ghost sum inconsistent with net"); } } function invariant_userBalanceConsistency() public view { uint256 totalUserDeposits = handler.ghost_totalDeposited(); uint256 totalUserWithdrawals = handler.ghost_totalWithdrawn(); uint256 netDeposits = totalUserDeposits > totalUserWithdrawals ? totalUserDeposits - totalUserWithdrawals : 0; uint256 vaultBalance = IERC20(WETH).balanceOf(address(vault)); uint256 totalStrategyValue = 0; for (uint256 i = 0; i < strategies.length; i++) { totalStrategyValue += IMYTStrategy(strategies[i]).realAssets(); } uint256 totalValue = vaultBalance + totalStrategyValue; uint256 totalExpected = INITIAL_VAULT_DEPOSIT + netDeposits; if (totalExpected > 1e15) { assertGe(totalValue, totalExpected * 90 / 100, "Total value significantly less than expected deposits"); } } function invariant_allocatePathHasProgress() public view { uint256 allocateCalls = handler.getCalls(handler.allocate.selector); (uint256 allocateAttempts, uint256 allocateSuccesses, uint256 allocateReverts, uint256 allocateNoops) = handler.getOperationStats(handler.allocate.selector); assertEq(allocateCalls, allocateAttempts + allocateNoops, "Allocate call accounting mismatch"); assertEq(allocateAttempts, allocateSuccesses + allocateReverts, "Allocate attempt accounting mismatch"); if (allocateCalls >= strategies.length) { assertGt(handler.ghost_totalAllocated(), 0, "Allocate path made no progress"); } if (allocateAttempts >= strategies.length) { assertGt(allocateSuccesses, 0, "Allocate attempts made but none succeeded"); assertLt(allocateReverts, allocateAttempts, "Allocate attempts always reverted"); } } function invariant_handlerOperationAccounting() public view { bytes4[6] memory selectors = [ handler.allocate.selector, handler.deallocate.selector, handler.deallocateAll.selector, handler.setLiquidityAdapter.selector, handler.withdraw.selector, handler.redeem.selector ]; for (uint256 i = 0; i < selectors.length; i++) { bytes4 selector = selectors[i]; uint256 calls = handler.getCalls(selector); (uint256 attempts, uint256 successes, uint256 reverts_, uint256 noops) = handler.getOperationStats(selector); assertEq(calls, attempts + noops, "Operation call accounting mismatch"); assertEq(attempts, successes + reverts_, "Operation attempt accounting mismatch"); } } function invariant_userPathIsNotSilentlyReverting() public view { (uint256 withdrawAttempts, uint256 withdrawSuccesses, uint256 withdrawReverts, ) = handler.getOperationStats(handler.withdraw.selector); (uint256 redeemAttempts, uint256 redeemSuccesses, uint256 redeemReverts, ) = handler.getOperationStats(handler.redeem.selector); if (withdrawAttempts >= 5) { assertGt(withdrawSuccesses, 0, "Withdraw attempted repeatedly but never succeeded"); assertLt(withdrawReverts, withdrawAttempts, "Withdraw attempts always reverted"); } if (redeemAttempts >= 5) { assertGt(redeemSuccesses, 0, "Redeem attempted repeatedly but never succeeded"); assertLt(redeemReverts, redeemAttempts, "Redeem attempts always reverted"); } } function invariant_allocatorRolesExercised() public view { uint256 allocateAttempts; uint256 deallocateAttempts; uint256 deallocateAllAttempts; uint256 setLiquidityAttempts; (allocateAttempts, , , ) = handler.getOperationStats(handler.allocate.selector); (deallocateAttempts, , , ) = handler.getOperationStats(handler.deallocate.selector); (deallocateAllAttempts, , , ) = handler.getOperationStats(handler.deallocateAll.selector); (setLiquidityAttempts, , , ) = handler.getOperationStats(handler.setLiquidityAdapter.selector); uint256 totalAllocatorAttempts = allocateAttempts + deallocateAttempts + deallocateAllAttempts + setLiquidityAttempts; if (totalAllocatorAttempts >= 10) { assertGt(handler.getAllocatorRoleAttempts(admin), 0, "Admin allocator path not exercised"); assertGt(handler.getAllocatorRoleAttempts(operator), 0, "Operator allocator path not exercised"); } } function debugCallSummary() public view { handler.callSummary(); } } ================================================ FILE: src/test/MultiStrategyOPUSDC.invariant.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "forge-std/Test.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {VaultV2Factory} from "lib/vault-v2/src/VaultV2Factory.sol"; import {AlchemistAllocator} from "../AlchemistAllocator.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {IAllocator} from "../interfaces/IAllocator.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {AaveStrategy} from "../strategies/AaveStrategy.sol"; import {MoonwellStrategy} from "../strategies/MoonwellStrategy.sol"; /// @title MultiStrategyOPUSDCHandler /// @notice Handler for invariant testing USDC strategies on Optimism contract MultiStrategyOPUSDCHandler is Test { IVaultV2 public vault; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin; address public operator; address public asset; // Actors for user operations address[] public actors; address internal currentActor; // Ghost variables for tracking cumulative state uint256 public ghost_totalDeposited; uint256 public ghost_totalWithdrawn; uint256 public ghost_totalAllocated; uint256 public ghost_totalDeallocated; mapping(address => uint256) public ghost_userDeposits; mapping(address => uint256) public ghost_strategyAllocations; mapping(uint8 => uint256) public ghost_liquidityAdapterBypass; // Call counters mapping(bytes4 => uint256) public calls; mapping(bytes4 => uint256) public opAttempts; mapping(bytes4 => uint256) public opSuccesses; mapping(bytes4 => uint256) public opReverts; mapping(bytes4 => uint256) public opNoops; mapping(address => uint256) public allocatorRoleAttempts; uint256 internal allocatorRoleNonce; // Strategy name tracking for debugging mapping(address => string) public strategyNames; // Minimum amounts for operations uint256 public constant MIN_DEPOSIT = 1e6; // 1 USDC uint256 public constant MIN_ALLOCATE = 1e5; // 0.1 USDC uint256 public constant MAX_USERS = 10; modifier countCall(bytes4 selector) { calls[selector]++; _; } modifier useActor(uint256 actorSeed) { currentActor = actors[bound(actorSeed, 0, actors.length - 1)]; vm.startPrank(currentActor); _; vm.stopPrank(); } function _markNoop(bytes4 selector) internal { opNoops[selector]++; } function _markAttempt(bytes4 selector) internal { opAttempts[selector]++; } function _markSuccess(bytes4 selector) internal { opSuccesses[selector]++; } function _markRevert(bytes4 selector) internal { opReverts[selector]++; } function _pickAllocatorCaller(uint256 seed) internal returns (address caller) { seed; caller = allocatorRoleNonce % 2 == 0 ? admin : operator; allocatorRoleNonce++; allocatorRoleAttempts[caller]++; } constructor( address _vault, address[] memory _strategies, address _allocator, address _classifier, address _curatorContract, address _admin, address _operator, string[] memory _strategyNames ) { vault = IVaultV2(_vault); strategies = _strategies; allocator = _allocator; classifier = _classifier; curatorContract = _curatorContract; admin = _admin; operator = _operator; asset = vault.asset(); // Initialize actors with varying balances for (uint256 i = 0; i < MAX_USERS; i++) { address actor = makeAddr(string(abi.encodePacked("opUsdcActor", i))); actors.push(actor); deal(asset, actor, (i + 1) * 100_000e6); // 100k to 1M USDC } for (uint256 i = 0; i < _strategies.length; i++) { strategyNames[_strategies[i]] = _strategyNames[i]; } } // ============ USER OPERATIONS ============ function deposit(uint256 amount, uint256 actorSeed) external countCall(this.deposit.selector) useActor(actorSeed) { bytes4 selector = this.deposit.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } amount = bound(amount, MIN_DEPOSIT, balance); IERC20(asset).approve(address(vault), amount); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.deposit(amount, currentActor) { _markSuccess(selector); ghost_totalDeposited += amount; ghost_userDeposits[currentActor] += amount; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Deposit reduced total allocations"); } } catch { _markRevert(selector); } } function withdraw(uint256 amount, uint256 actorSeed) external countCall(this.withdraw.selector) useActor(actorSeed) { bytes4 selector = this.withdraw.selector; uint256 shares = vault.balanceOf(currentActor); if (shares == 0) { _markNoop(selector); return; } uint256 maxAssets = vault.convertToAssets(shares); if (maxAssets == 0) { _markNoop(selector); return; } amount = bound(amount, 1, maxAssets); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.withdraw(amount, currentActor, currentActor) { _markSuccess(selector); ghost_totalWithdrawn += amount; if (ghost_userDeposits[currentActor] >= amount) { ghost_userDeposits[currentActor] -= amount; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Withdraw increased total allocations"); } } catch { _markRevert(selector); } } function mint(uint256 shares, uint256 actorSeed) external countCall(this.mint.selector) useActor(actorSeed) { bytes4 selector = this.mint.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } uint256 maxShares = vault.convertToShares(balance); if (maxShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, maxShares); IERC20(asset).approve(address(vault), balance); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.mint(shares, currentActor) returns (uint256 assetsDeposited) { _markSuccess(selector); ghost_totalDeposited += assetsDeposited; ghost_userDeposits[currentActor] += assetsDeposited; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Mint reduced total allocations"); } } catch { _markRevert(selector); } } function redeem(uint256 shares, uint256 actorSeed) external countCall(this.redeem.selector) useActor(actorSeed) { bytes4 selector = this.redeem.selector; uint256 userShares = vault.balanceOf(currentActor); if (userShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, userShares); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.redeem(shares, currentActor, currentActor) returns (uint256 assetsRedeemed) { _markSuccess(selector); ghost_totalWithdrawn += assetsRedeemed; if (ghost_userDeposits[currentActor] >= assetsRedeemed) { ghost_userDeposits[currentActor] -= assetsRedeemed; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Redeem increased total allocations"); } } catch { _markRevert(selector); } } // ============ ADMIN OPERATIONS ============ function _remainingGlobalRiskHeadroom(uint8 riskLevel, address strategyToAllocate) internal view returns (uint256) { uint256 globalRiskCapPct = AlchemistStrategyClassifier(classifier).getGlobalCap(riskLevel); uint256 globalRiskCap = (vault.totalAssets() * globalRiskCapPct) / 1e18; uint256 currentRiskAllocation = 0; uint256 pendingYield = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(strategyId)) == riskLevel) { uint256 alloc = vault.allocation(strategyId); currentRiskAllocation += alloc; if (strategies[i] == strategyToAllocate) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (realAssets > alloc) { pendingYield = realAssets - alloc; } } } } uint256 effectiveAllocation = currentRiskAllocation + pendingYield; if (effectiveAllocation >= globalRiskCap) return 0; return globalRiskCap - effectiveAllocation; } function allocate(uint256 strategyIndexSeed, uint256 amount) external countCall(this.allocate.selector) { uint256 strategiesLen = strategies.length; if (strategiesLen == 0) { _markNoop(this.allocate.selector); return; } uint256 strategyIndex = strategyIndexSeed % strategiesLen; (bool success, uint256 allocatedAmount) = _tryAllocate(strategies[strategyIndex], amount, strategyIndexSeed); if (success) { assertGt(allocatedAmount, 0, "Allocate succeeded without allocation delta"); ghost_totalAllocated += allocatedAmount; ghost_strategyAllocations[strategies[strategyIndex]] += allocatedAmount; } } function _tryAllocate( address strategy, uint256 amount, uint256 roleSeed ) internal returns (bool success, uint256 allocatedAmount) { bytes4 selector = this.allocate.selector; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 globalRiskHeadroom = _remainingGlobalRiskHeadroom(riskLevel, strategy); uint256 idleVaultBalance = IERC20(asset).balanceOf(address(vault)); if (idleVaultBalance < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } if (currentAllocation >= absoluteCap) { _markNoop(selector); return (false, 0); } if (globalRiskHeadroom < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } uint256 underlyingMaxDeposit = _getUnderlyingMaxDeposit(strategy); if (underlyingMaxDeposit < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } uint256 maxByAbsolute = absoluteCap - currentAllocation; uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } // Account for yield captured in the allocation change. // The adapter returns change = _totalValue() - allocation(), so effective allocation // after allocate() will be currentAllocation + pendingYield + amount. uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); uint256 pendingYield = currentRealAssets > currentAllocation ? currentRealAssets - currentAllocation : 0; uint256 effectiveAllocation = currentAllocation + pendingYield; uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 maxByAllocatorRelative = allocatorRelativeCapValue > effectiveAllocation ? allocatorRelativeCapValue - effectiveAllocation : 0; uint256 maxByVaultRelative = vaultRelativeCapValue > effectiveAllocation ? vaultRelativeCapValue - effectiveAllocation : 0; uint256 maxByRelativeCap = maxByAllocatorRelative < maxByVaultRelative ? maxByAllocatorRelative : maxByVaultRelative; uint256 maxByAbsoluteRemaining = absoluteCap > effectiveAllocation ? absoluteCap - effectiveAllocation : 0; uint256 maxAllocate = maxByAbsoluteRemaining < maxByRelativeCap ? maxByAbsoluteRemaining : maxByRelativeCap; maxAllocate = maxAllocate < globalRiskHeadroom ? maxAllocate : globalRiskHeadroom; maxAllocate = maxAllocate < idleVaultBalance ? maxAllocate : idleVaultBalance; maxAllocate = maxAllocate < underlyingMaxDeposit ? maxAllocate : underlyingMaxDeposit; address allocatorCaller = _pickAllocatorCaller(roleSeed); if (allocatorCaller == operator) { uint256 individualCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualCap = (totalAssets * individualCapPct) / 1e18; uint256 individualRemaining = individualCap > effectiveAllocation ? individualCap - effectiveAllocation : 0; maxAllocate = maxAllocate < individualRemaining ? maxAllocate : individualRemaining; } if (maxAllocate < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } amount = bound(amount, MIN_ALLOCATE, maxAllocate); _markAttempt(selector); vm.prank(allocatorCaller); try IAllocator(allocator).allocate(strategy, amount) { uint256 newAllocation = vault.allocation(allocationId); if (newAllocation <= currentAllocation) { _markRevert(selector); return (false, 0); } _markSuccess(selector); return (true, newAllocation - currentAllocation); } catch { _markRevert(selector); return (false, 0); } } function deallocate(uint256 strategyIndex, uint256 amount) external countCall(this.deallocate.selector) { bytes4 selector = this.deallocate.selector; strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); if (currentAllocation < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = currentAllocation; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } amount = bound(amount, MIN_ALLOCATE, maxDeallocate); uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(amount); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(amount); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } function deallocateAll(uint256 strategyIndex) external countCall(this.deallocateAll.selector) { bytes4 selector = this.deallocateAll.selector; strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 allocationBefore = vault.allocation(allocationId); if (allocationBefore < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = allocationBefore; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(maxDeallocate); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(strategyIndex); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } function setLiquidityAdapter(uint256 strategySeed, uint256 modeSeed) external countCall(this.setLiquidityAdapter.selector) { bytes4 selector = this.setLiquidityAdapter.selector; if (strategies.length == 0) { _markNoop(selector); return; } address newLiquidityAdapter = address(0); if (modeSeed % 3 != 0) { address candidate = strategies[strategySeed % strategies.length]; if (!_liquidityAdapterHasHeadroom(candidate)) { _markNoop(selector); return; } newLiquidityAdapter = candidate; } _markAttempt(selector); address allocatorCaller = _pickAllocatorCaller(modeSeed); vm.prank(allocatorCaller); try IAllocator(allocator).setLiquidityAdapter(newLiquidityAdapter, _directLiquidityData()) { _markSuccess(selector); } catch { _markRevert(selector); } } function _directLiquidityData() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; return abi.encode(params); } function _snapshotAllocations() internal view returns (uint256[] memory snapshot, uint256 totalBefore, uint256 totalYield) { uint256 len = strategies.length; snapshot = new uint256[](len); for (uint256 i = 0; i < len; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); snapshot[i] = allocation; totalBefore += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) totalYield += ra - allocation; } } function _recordAllocationDeltas(uint256[] memory beforeAllocations) internal returns (uint256 totalAfter) { uint256 len = strategies.length; for (uint256 i = 0; i < len; i++) { address strategy = strategies[i]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint256 beforeAllocation = beforeAllocations[i]; totalAfter += afterAllocation; if (afterAllocation >= beforeAllocation) { uint256 deltaUp = afterAllocation - beforeAllocation; if (deltaUp > 0) { ghost_totalAllocated += deltaUp; ghost_strategyAllocations[strategy] += deltaUp; } } else { uint256 deltaDown = beforeAllocation - afterAllocation; ghost_totalDeallocated += deltaDown; if (ghost_strategyAllocations[strategy] >= deltaDown) { ghost_strategyAllocations[strategy] -= deltaDown; } else { ghost_strategyAllocations[strategy] = 0; } } } } function _recordLiquidityAdapterBypass(uint256[] memory beforeAllocations) internal { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); if (afterAllocation > beforeAllocations[i]) { ghost_liquidityAdapterBypass[riskLevel] += afterAllocation - beforeAllocations[i]; } else if (beforeAllocations[i] > afterAllocation) { uint256 decrease = beforeAllocations[i] - afterAllocation; ghost_liquidityAdapterBypass[riskLevel] = ghost_liquidityAdapterBypass[riskLevel] > decrease ? ghost_liquidityAdapterBypass[riskLevel] - decrease : 0; } } } function _assertTotalAllocationDirection(uint256 totalBefore, uint256 totalAfter, bool expectIncrease, uint256 yieldTolerance, string memory errorMessage) internal pure { if (expectIncrease) { require(totalAfter + yieldTolerance >= totalBefore, errorMessage); } else { require(totalAfter <= totalBefore + yieldTolerance, errorMessage); } } function _liquidityAdapterHasHeadroom(address strategy) internal view returns (bool) { bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 relativeLimit = allocatorRelativeCapValue < vaultRelativeCapValue ? allocatorRelativeCapValue : vaultRelativeCapValue; uint256 hardLimit = absoluteCap < relativeLimit ? absoluteCap : relativeLimit; if (hardLimit <= currentAllocation) return false; uint256 headroom = hardLimit - currentAllocation; return headroom >= MIN_DEPOSIT * 10; } // ============ ADMIN RISK CONFIG OPERATIONS ============ /// @notice Reclassify a strategy to a different risk level (low frequency ~10%) /// @dev Only reclassifies if the target risk class's global cap can accommodate /// the strategy's existing allocation plus current aggregate in that class. function reclassifyStrategy(uint256 strategyIndexSeed, uint256 newRiskClassSeed) external countCall(this.reclassifyStrategy.selector) { bytes4 selector = this.reclassifyStrategy.selector; if (strategies.length == 0) { _markNoop(selector); return; } if (newRiskClassSeed % 100 != 0) { _markNoop(selector); return; } uint256 idx = strategyIndexSeed % strategies.length; address strategy = strategies[idx]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint8 currentRisk = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint8 newRisk = uint8((newRiskClassSeed / 10) % 3); if (newRisk == currentRisk) { newRisk = (newRisk + 1) % 3; } uint256 totalAssets = vault.totalAssets(); uint256 newGlobalCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(newRisk)) / 1e18; uint256 existingInNewClass = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == newRisk) { existingInNewClass += vault.allocation(stratId); } } uint256 strategyAllocation = vault.allocation(allocationId); if (existingInNewClass + strategyAllocation > newGlobalCap) { _markNoop(selector); return; } _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel(uint256(allocationId), newRisk); _markSuccess(selector); } /// @notice Modify the caps of a risk class (low frequency ~10%) /// @dev Only tightens caps to levels that still accommodate existing allocations. /// New global cap >= current aggregate allocation in that class + MIN_ALLOCATE. /// New local cap >= largest individual allocation in that class. function modifyRiskClassCaps(uint256 riskClassSeed, uint256 capSeed) external countCall(this.modifyRiskClassCaps.selector) { bytes4 selector = this.modifyRiskClassCaps.selector; if (capSeed % 100 != 0) { _markNoop(selector); return; } uint8 riskClass = uint8(riskClassSeed % 3); uint256 totalAssets = vault.totalAssets(); uint256 currentAggregate = 0; uint256 maxIndividual = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == riskClass) { uint256 alloc = vault.allocation(stratId); currentAggregate += alloc; if (alloc > maxIndividual) maxIndividual = alloc; } } uint256 minGlobalPct = currentAggregate > 0 ? ((currentAggregate + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxGlobalPct = 1e18; if (minGlobalPct > maxGlobalPct) { _markNoop(selector); return; } uint256 minLocalPct = maxIndividual > 0 ? ((maxIndividual + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxLocalPct = 1e18; if (minLocalPct > maxLocalPct) { _markNoop(selector); return; } uint256 newGlobalPct = bound(capSeed / 10, minGlobalPct, maxGlobalPct); uint256 newLocalPct = bound(capSeed / 100, minLocalPct, maxLocalPct); _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).setRiskClass(riskClass, newGlobalPct, newLocalPct); _markSuccess(selector); } // ============ TIME OPERATIONS ============ function changePerformanceFee(uint256 feeSeed) external countCall(this.changePerformanceFee.selector) { bytes4 selector = this.changePerformanceFee.selector; if (feeSeed % 200 != 0) { _markNoop(selector); return; } uint256 newFee = bound(feeSeed / 200, 0, 0.5e18); _markAttempt(selector); vm.prank(admin); AlchemistCurator(curatorContract).submitSetPerformanceFee(address(vault), newFee); vm.prank(admin); vault.setPerformanceFee(newFee); _markSuccess(selector); } function warpTime(uint256 timeDelta) external countCall(this.warpTime.selector) { timeDelta = bound(timeDelta, 1 hours, 365 days); vm.warp(block.timestamp + timeDelta); } function _getUnderlyingMaxWithdraw(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxWithdraw(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } function _getUnderlyingMaxDeposit(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxDeposit(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } function _resolveUnderlyingVault(address strategy) internal view returns (address underlyingVault) { (bool ok, bytes memory data) = strategy.staticcall(abi.encodeWithSignature("vault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); if (underlyingVault != address(0)) return underlyingVault; } (ok, data) = strategy.staticcall(abi.encodeWithSignature("autoVault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); } } // ============ HELPER FUNCTIONS ============ function getStrategyCount() external view returns (uint256) { return strategies.length; } /// @notice Returns the net allocated amount from ghost variables function ghost_netAllocated() external view returns (uint256) { if (ghost_totalAllocated >= ghost_totalDeallocated) { return ghost_totalAllocated - ghost_totalDeallocated; } return 0; } /// @notice Returns the sum of all ghost strategy allocations function ghost_sumStrategyAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { sum += ghost_strategyAllocations[strategies[i]]; } return sum; } /// @notice Returns the sum of actual vault allocations function vault_totalAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); sum += vault.allocation(allocationId); } return sum; } function getCalls(bytes4 selector) external view returns (uint256) { return calls[selector]; } function getOperationStats(bytes4 selector) external view returns (uint256 attempts_, uint256 successes_, uint256 reverts_, uint256 noops_) { return (opAttempts[selector], opSuccesses[selector], opReverts[selector], opNoops[selector]); } function getAllocatorRoleAttempts(address role) external view returns (uint256) { return allocatorRoleAttempts[role]; } function _logOperationStats(string memory label, bytes4 selector) internal view { console.log(label); console.log(" attempts:", opAttempts[selector]); console.log(" successes:", opSuccesses[selector]); console.log(" reverts:", opReverts[selector]); console.log(" noops:", opNoops[selector]); } function callSummary() external view { console.log("=== OP USDC Multi-Strategy Handler Call Summary ==="); console.log("User Operations:"); console.log(" deposit calls:", calls[this.deposit.selector]); console.log(" withdraw calls:", calls[this.withdraw.selector]); console.log(" mint calls:", calls[this.mint.selector]); console.log(" redeem calls:", calls[this.redeem.selector]); console.log("Admin Operations:"); console.log(" allocate calls:", calls[this.allocate.selector]); console.log(" deallocate calls:", calls[this.deallocate.selector]); console.log(" deallocateAll calls:", calls[this.deallocateAll.selector]); console.log("Admin Risk Config Operations:"); console.log(" reclassifyStrategy calls:", calls[this.reclassifyStrategy.selector]); console.log(" modifyRiskClassCaps calls:", calls[this.modifyRiskClassCaps.selector]); console.log(" changePerformanceFee calls:", calls[this.changePerformanceFee.selector]); console.log("Time Operations:"); console.log(" warpTime calls:", calls[this.warpTime.selector]); console.log("Ghost Variables:"); console.log(" totalDeposited:", ghost_totalDeposited); console.log(" totalWithdrawn:", ghost_totalWithdrawn); console.log(" totalAllocated:", ghost_totalAllocated); console.log(" totalDeallocated:", ghost_totalDeallocated); console.log("Operation Stats:"); _logOperationStats(" allocate", this.allocate.selector); _logOperationStats(" deallocate", this.deallocate.selector); _logOperationStats(" deallocateAll", this.deallocateAll.selector); _logOperationStats(" setLiquidityAdapter", this.setLiquidityAdapter.selector); _logOperationStats(" withdraw", this.withdraw.selector); _logOperationStats(" redeem", this.redeem.selector); console.log("Allocator Role Attempts:"); console.log(" admin:", allocatorRoleAttempts[admin]); console.log(" operator:", allocatorRoleAttempts[operator]); } } /// @title MultiStrategyOPUSDCInvariantTest /// @notice Invariant tests for USDC strategies on Optimism contract MultiStrategyOPUSDCInvariantTest is Test { IVaultV2 public vault; MultiStrategyOPUSDCHandler public handler; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin = address(0x1); address public operator = address(0x3); // Optimism addresses address public constant USDC = 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85; address public constant AUSDC = 0x38d693cE1dF5AaDF7bC62595A37D667aD57922e5; address public constant AAVE_POOL_PROVIDER = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb; address public constant AAVE_REWARDS_CONTROLLER = 0x929EC64c34a17401F460460D4B9390518E5B473e; address public constant OP = 0x4200000000000000000000000000000000000042; address public constant MOONWELL_MUSDC = 0x8E08617b0d66359D73Aa11E11017834C29155525; address public constant MOONWELL_COMPTROLLER = 0xCa889f40aae37FFf165BccF69aeF1E82b5C511B9; address public constant WELL = 0xA88594D404727625A9437C3f886C7643872296AE; uint256 public constant INITIAL_VAULT_DEPOSIT = 10_000_000e6; // 10M USDC uint256 public constant ABSOLUTE_CAP = 50_000_000e6; // 50M USDC per strategy uint256 public constant RELATIVE_CAP = 0.5e18; uint256 public initialSharePrice; uint256 private forkId; function setUp() public { // Fork Optimism string memory rpc = vm.envString("OPTIMISM_RPC_URL"); forkId = vm.createFork(rpc); vm.selectFork(forkId); // Setup vault vm.startPrank(admin); vault = _setupVault(USDC); // Setup strategies string[] memory strategyNames = new string[](2); strategyNames[0] = "Aave V3 OP USDC"; strategyNames[1] = "Moonwell OP USDC"; // Deploy Aave USDC Strategy strategies.push(_deployAaveUSDCStrategy()); // Deploy Moonwell USDC Strategy strategies.push(_deployMoonwellUSDCStrategy()); // Setup classifier and allocator _setupClassifierAndAllocator(); // Add strategies to vault _addStrategiesToVault(); // Make initial deposit to vault _makeInitialDeposit(); vm.stopPrank(); initialSharePrice = (vault.totalAssets() * 1e18) / vault.totalSupply(); // Create handler handler = new MultiStrategyOPUSDCHandler( address(vault), strategies, allocator, classifier, curatorContract, admin, operator, strategyNames ); // Target the handler targetContract(address(handler)); // Target specific functions bytes4[] memory selectors = new bytes4[](12); selectors[0] = handler.deposit.selector; selectors[1] = handler.withdraw.selector; selectors[2] = handler.mint.selector; selectors[3] = handler.redeem.selector; selectors[4] = handler.allocate.selector; selectors[5] = handler.deallocate.selector; selectors[6] = handler.deallocateAll.selector; selectors[7] = handler.setLiquidityAdapter.selector; selectors[8] = handler.warpTime.selector; selectors[9] = handler.reclassifyStrategy.selector; selectors[10] = handler.modifyRiskClassCaps.selector; selectors[11] = handler.changePerformanceFee.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } function _setupVault(address asset) internal returns (IVaultV2) { VaultV2Factory factory = new VaultV2Factory(); return IVaultV2(factory.createVaultV2(admin, asset, bytes32(0))); } function _deployAaveUSDCStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Aave V3 OP USDC", protocol: "AaveV3", riskClass: IMYTStrategy.RiskClass.LOW, cap: 1000 * 1e6, globalCap: 0.5e18, estimatedYield: 500, additionalIncentives: false, slippageBPS: 50 }); return address(new AaveStrategy( address(vault), params, USDC, AUSDC, AAVE_POOL_PROVIDER, AAVE_REWARDS_CONTROLLER, OP )); } function _deployMoonwellUSDCStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Moonwell OP USDC", protocol: "Moonwell", riskClass: IMYTStrategy.RiskClass.LOW, cap: 0, globalCap: 0.5e18, estimatedYield: 450, additionalIncentives: false, slippageBPS: 50 }); return address(new MoonwellStrategy( address(vault), params, USDC, MOONWELL_MUSDC, MOONWELL_COMPTROLLER, WELL, false )); } function _setupClassifierAndAllocator() internal { classifier = address(new AlchemistStrategyClassifier(admin)); // Set up risk classes matching constructor defaults (WAD: 1e18 = 100%) AlchemistStrategyClassifier(classifier).setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% AlchemistStrategyClassifier(classifier).setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% AlchemistStrategyClassifier(classifier).setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% // Assign risk levels for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); (,,,IMYTStrategy.RiskClass riskClass,,,,,) = IMYTStrategy(strategies[i]).params(); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel( uint256(strategyId), uint8(riskClass) ); } curatorContract = address(new AlchemistCurator(admin, admin)); VaultV2(address(vault)).setCurator(curatorContract); _setPerformanceFee(curatorContract); allocator = address(new AlchemistAllocator(address(vault), admin, operator, classifier)); } function _setPerformanceFee(address _curator) internal { AlchemistCurator curator = AlchemistCurator(_curator); curator.submitSetPerformanceFeeRecipient(address(vault), admin); vault.setPerformanceFeeRecipient(admin); curator.submitSetPerformanceFee(address(vault), 15e16); vault.setPerformanceFee(15e16); } function _addStrategiesToVault() internal { AlchemistCurator curator = AlchemistCurator(curatorContract); curator.submitSetAllocator(address(vault), allocator, true); vault.setIsAllocator(allocator, true); for (uint256 i = 0; i < strategies.length; i++) { curator.submitSetStrategy(strategies[i], address(vault)); curator.setStrategy(strategies[i], address(vault)); curator.submitIncreaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); curator.increaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); (,,,,, uint256 strategyRelativeCap,,,) = IMYTStrategy(strategies[i]).params(); curator.submitIncreaseRelativeCap(strategies[i], strategyRelativeCap); curator.increaseRelativeCap(strategies[i], strategyRelativeCap); } } function _makeInitialDeposit() internal { deal(USDC, admin, INITIAL_VAULT_DEPOSIT); IERC20(USDC).approve(address(vault), INITIAL_VAULT_DEPOSIT); vault.deposit(INITIAL_VAULT_DEPOSIT, admin); } // ============ INVARIANTS ============ function invariant_realAssets_nonNegative() public view { for (uint256 i = 0; i < strategies.length; i++) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); assertGe(realAssets, 0, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " has negative real assets"))); } } function invariant_allocationWithinAbsoluteCap() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 ra = IMYTStrategy(strategies[i]).realAssets(); uint256 yieldGap = ra > allocation ? ra - allocation : 0; uint256 tolerance = absoluteCap / 20 + yieldGap; assertLe(allocation, absoluteCap + tolerance, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds absolute cap"))); } } function invariant_allocationWithinRelativeCap() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); if (relativeCap == 1e18) continue; uint256 maxAllowed = (firstTotalAssets * relativeCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% assertLe(allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds relative cap"))); } } function invariant_allocationWithinGlobalRiskCap() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 allocation = vault.allocation(allocationId); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } function invariant_allocationWithinIndividualRiskCap() public view { if (handler.getAllocatorRoleAttempts(admin) > 0) return; uint256 totalAssets = vault.totalAssets(); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 individualRiskCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualRiskCap = (totalAssets * individualRiskCapPct) / 1e18; assertLe(allocation, individualRiskCap, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds individual risk cap"))); } } function invariant_riskLevelAggregateCaps() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } function invariant_totalAllocationsBounded() public view { uint256 totalAllocations = 0; uint256 totalRealAssets = IERC20(vault.asset()).balanceOf(address(vault)); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); totalAllocations += vault.allocation(allocationId); totalRealAssets += IMYTStrategy(strategies[i]).realAssets(); } assertLe(totalAllocations, totalRealAssets * 110 / 100 + 1, "Total allocations exceed real assets by more than 10%"); } function invariant_realAssetsConsistentWithAllocation() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (allocation > 0) { uint256 minExpected = allocation * 95 / 100; //uint256 maxExpected = allocation * 105 / 100; if (allocation > 1e6) { assertGe(realAssets, minExpected, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets below allocation"))); //assertLe(realAssets, maxExpected * 2, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets significantly above allocation"))); } } } } function invariant_sharePriceNonDecreasing() public view { uint256 totalSupply = vault.totalSupply(); if (totalSupply == 0) return; uint256 totalAssets = vault.totalAssets(); uint256 sharePrice = (totalAssets * 1e18) / totalSupply; assertGt(sharePrice, 0, "Share price collapsed to zero"); } function invariant_userBalanceConsistency() public view { uint256 totalUserDeposits = handler.ghost_totalDeposited(); uint256 totalUserWithdrawals = handler.ghost_totalWithdrawn(); uint256 netDeposits = totalUserDeposits > totalUserWithdrawals ? totalUserDeposits - totalUserWithdrawals : 0; uint256 vaultBalance = IERC20(USDC).balanceOf(address(vault)); uint256 totalStrategyValue = 0; for (uint256 i = 0; i < strategies.length; i++) { totalStrategyValue += IMYTStrategy(strategies[i]).realAssets(); } uint256 totalValue = vaultBalance + totalStrategyValue; uint256 totalExpected = INITIAL_VAULT_DEPOSIT + netDeposits; if (totalExpected > 1e6) { assertGe(totalValue, totalExpected * 90 / 100, "Total value significantly less than expected deposits"); } } /// @notice Invariant: Ghost allocations match actual vault allocations per strategy function invariant_ghostAllocationsMatchVault() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 actualAllocation = vault.allocation(allocationId); uint256 ghostAllocation = handler.ghost_strategyAllocations(strategies[i]); // Allow 5% tolerance for yield/rounding differences if (actualAllocation > 1e6) { uint256 minExpected = actualAllocation * 95 / 100; uint256 maxExpected = actualAllocation * 105 / 100; assertGe(ghostAllocation, minExpected, string(abi.encodePacked("Ghost allocation below actual for ", handler.strategyNames(strategies[i])))); assertLe(ghostAllocation, maxExpected, string(abi.encodePacked("Ghost allocation above actual for ", handler.strategyNames(strategies[i])))); } } } /// @notice Invariant: Net ghost allocations match sum of vault allocations function invariant_netAllocationsConsistent() public view { uint256 ghostNet = handler.ghost_netAllocated(); uint256 vaultTotal = handler.vault_totalAllocations(); // Allow 10% tolerance for yield accumulation and rounding if (vaultTotal > 1e6) { assertGe(ghostNet, vaultTotal * 90 / 100, "Ghost net allocations below vault total"); assertLe(ghostNet, vaultTotal * 110 / 100, "Ghost net allocations above vault total"); } } /// @notice Invariant: Ghost sum of strategy allocations is internally consistent function invariant_ghostSumConsistent() public view { uint256 ghostSum = handler.ghost_sumStrategyAllocations(); uint256 ghostNet = handler.ghost_netAllocated(); // ghost_sumStrategyAllocations should equal ghost_netAllocated // Allow small tolerance for rounding if (ghostNet > 1e6) { assertGe(ghostSum, ghostNet * 95 / 100, "Ghost sum inconsistent with net"); assertLe(ghostSum, ghostNet * 105 / 100, "Ghost sum inconsistent with net"); } } function invariant_noStrategyDominance() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); if (allocation == 0) continue; (,,,,, uint256 strategyGlobalCap,,,) = IMYTStrategy(strategies[i]).params(); uint256 maxAllowed = (firstTotalAssets * strategyGlobalCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% assertLe( allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds configured globalCap")) ); } } function invariant_allocatePathHasProgress() public view { uint256 allocateCalls = handler.getCalls(handler.allocate.selector); (uint256 allocateAttempts, uint256 allocateSuccesses, uint256 allocateReverts, uint256 allocateNoops) = handler.getOperationStats(handler.allocate.selector); assertEq(allocateCalls, allocateAttempts + allocateNoops, "Allocate call accounting mismatch"); assertEq(allocateAttempts, allocateSuccesses + allocateReverts, "Allocate attempt accounting mismatch"); if (allocateCalls >= strategies.length) { assertGt(handler.ghost_totalAllocated(), 0, "Allocate path made no progress"); } if (allocateAttempts >= strategies.length) { assertGt(allocateSuccesses, 0, "Allocate attempts made but none succeeded"); assertLt(allocateReverts, allocateAttempts, "Allocate attempts always reverted"); } } function invariant_handlerOperationAccounting() public view { bytes4[6] memory selectors = [ handler.allocate.selector, handler.deallocate.selector, handler.deallocateAll.selector, handler.setLiquidityAdapter.selector, handler.withdraw.selector, handler.redeem.selector ]; for (uint256 i = 0; i < selectors.length; i++) { bytes4 selector = selectors[i]; uint256 calls = handler.getCalls(selector); (uint256 attempts, uint256 successes, uint256 reverts_, uint256 noops) = handler.getOperationStats(selector); assertEq(calls, attempts + noops, "Operation call accounting mismatch"); assertEq(attempts, successes + reverts_, "Operation attempt accounting mismatch"); } } function invariant_userPathIsNotSilentlyReverting() public view { (uint256 withdrawAttempts, uint256 withdrawSuccesses, uint256 withdrawReverts, ) = handler.getOperationStats(handler.withdraw.selector); (uint256 redeemAttempts, uint256 redeemSuccesses, uint256 redeemReverts, ) = handler.getOperationStats(handler.redeem.selector); if (withdrawAttempts >= 5) { assertGt(withdrawSuccesses, 0, "Withdraw attempted repeatedly but never succeeded"); assertLt(withdrawReverts, withdrawAttempts, "Withdraw attempts always reverted"); } if (redeemAttempts >= 5) { assertGt(redeemSuccesses, 0, "Redeem attempted repeatedly but never succeeded"); assertLt(redeemReverts, redeemAttempts, "Redeem attempts always reverted"); } } function invariant_allocatorRolesExercised() public view { uint256 allocateAttempts; uint256 deallocateAttempts; uint256 deallocateAllAttempts; uint256 setLiquidityAttempts; (allocateAttempts, , , ) = handler.getOperationStats(handler.allocate.selector); (deallocateAttempts, , , ) = handler.getOperationStats(handler.deallocate.selector); (deallocateAllAttempts, , , ) = handler.getOperationStats(handler.deallocateAll.selector); (setLiquidityAttempts, , , ) = handler.getOperationStats(handler.setLiquidityAdapter.selector); uint256 totalAllocatorAttempts = allocateAttempts + deallocateAttempts + deallocateAllAttempts + setLiquidityAttempts; if (totalAllocatorAttempts >= 10) { assertGt(handler.getAllocatorRoleAttempts(admin), 0, "Admin allocator path not exercised"); assertGt(handler.getAllocatorRoleAttempts(operator), 0, "Operator allocator path not exercised"); } } function debugCallSummary() public view { handler.callSummary(); } } ================================================ FILE: src/test/MultiStrategyUSDC.invariant.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "forge-std/Test.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; import {VaultV2Factory} from "lib/vault-v2/src/VaultV2Factory.sol"; import {AlchemistAllocator} from "../AlchemistAllocator.sol"; import {AlchemistCurator} from "../AlchemistCurator.sol"; import {IAllocator} from "../interfaces/IAllocator.sol"; import {AlchemistStrategyClassifier} from "../AlchemistStrategyClassifier.sol"; import {IMYTStrategy} from "../interfaces/IMYTStrategy.sol"; import {TokenUtils} from "../libraries/TokenUtils.sol"; import {ERC4626Strategy} from "../strategies/ERC4626Strategy.sol"; import {TokeAutoStrategy} from "../strategies/TokeAutoStrategy.sol"; /// @title MultiStrategyUSDCHandler /// @notice Handler for invariant testing multiple USDC strategies attached to a single vault contract MultiStrategyUSDCHandler is Test { IVaultV2 public vault; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin; address public operator; address public asset; // Actors for user operations address[] public actors; address internal currentActor; // Ghost variables for tracking cumulative state uint256 public ghost_totalDeposited; uint256 public ghost_totalWithdrawn; uint256 public ghost_totalAllocated; uint256 public ghost_totalDeallocated; mapping(address => uint256) public ghost_userDeposits; mapping(address => uint256) public ghost_strategyAllocations; mapping(uint8 => uint256) public ghost_liquidityAdapterBypass; // Call counters mapping(bytes4 => uint256) public calls; mapping(bytes4 => uint256) public opAttempts; mapping(bytes4 => uint256) public opSuccesses; mapping(bytes4 => uint256) public opReverts; mapping(bytes4 => uint256) public opNoops; mapping(address => uint256) public allocatorRoleAttempts; uint256 internal allocatorRoleNonce; // Strategy name tracking for debugging mapping(address => string) public strategyNames; // Minimum amounts for operations uint256 public constant MIN_DEPOSIT = 1e6; // 1 USDC uint256 public constant MIN_ALLOCATE = 1e5; // 0.1 USDC uint256 public constant MAX_USERS = 10; modifier countCall(bytes4 selector) { calls[selector]++; _; } modifier useActor(uint256 actorSeed) { currentActor = actors[bound(actorSeed, 0, actors.length - 1)]; vm.startPrank(currentActor); _; vm.stopPrank(); } function _markNoop(bytes4 selector) internal { opNoops[selector]++; } function _markAttempt(bytes4 selector) internal { opAttempts[selector]++; } function _markSuccess(bytes4 selector) internal { opSuccesses[selector]++; } function _markRevert(bytes4 selector) internal { opReverts[selector]++; } function _pickAllocatorCaller(uint256 seed) internal returns (address caller) { seed; caller = allocatorRoleNonce % 2 == 0 ? admin : operator; allocatorRoleNonce++; allocatorRoleAttempts[caller]++; } constructor( address _vault, address[] memory _strategies, address _allocator, address _classifier, address _curatorContract, address _admin, address _operator, string[] memory _strategyNames ) { vault = IVaultV2(_vault); strategies = _strategies; allocator = _allocator; classifier = _classifier; curatorContract = _curatorContract; admin = _admin; operator = _operator; asset = vault.asset(); // Initialize actors with varying balances for (uint256 i = 0; i < MAX_USERS; i++) { address actor = makeAddr(string(abi.encodePacked("usdcActor", i))); actors.push(actor); // Give actors different initial balances for position size variation deal(asset, actor, (i + 1) * 100_000e6); // 100k to 1M USDC } // Map strategy names for debugging for (uint256 i = 0; i < _strategies.length; i++) { strategyNames[_strategies[i]] = _strategyNames[i]; } } // ============ USER OPERATIONS ============ /// @notice User deposits assets into the vault function deposit(uint256 amount, uint256 actorSeed) external countCall(this.deposit.selector) useActor(actorSeed) { bytes4 selector = this.deposit.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } amount = bound(amount, MIN_DEPOSIT, balance); IERC20(asset).approve(address(vault), amount); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.deposit(amount, currentActor) { _markSuccess(selector); ghost_totalDeposited += amount; ghost_userDeposits[currentActor] += amount; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Deposit reduced total allocations"); } } catch { _markRevert(selector); } } /// @notice User withdraws assets from the vault function withdraw(uint256 amount, uint256 actorSeed) external countCall(this.withdraw.selector) useActor(actorSeed) { bytes4 selector = this.withdraw.selector; uint256 shares = vault.balanceOf(currentActor); if (shares == 0) { _markNoop(selector); return; } uint256 maxAssets = vault.convertToAssets(shares); if (maxAssets == 0) { _markNoop(selector); return; } amount = bound(amount, 1, maxAssets); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.withdraw(amount, currentActor, currentActor) { _markSuccess(selector); ghost_totalWithdrawn += amount; if (ghost_userDeposits[currentActor] >= amount) { ghost_userDeposits[currentActor] -= amount; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Withdraw increased total allocations"); } } catch { _markRevert(selector); } } /// @notice User mints shares by providing exact assets function mint(uint256 shares, uint256 actorSeed) external countCall(this.mint.selector) useActor(actorSeed) { bytes4 selector = this.mint.selector; uint256 balance = IERC20(asset).balanceOf(currentActor); if (balance < MIN_DEPOSIT) { _markNoop(selector); return; } uint256 maxShares = vault.convertToShares(balance); if (maxShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, maxShares); IERC20(asset).approve(address(vault), balance); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.mint(shares, currentActor) returns (uint256 assetsDeposited) { _markSuccess(selector); ghost_totalDeposited += assetsDeposited; ghost_userDeposits[currentActor] += assetsDeposited; uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, true, totalYield, "Mint reduced total allocations"); } } catch { _markRevert(selector); } } /// @notice User redeems exact shares for assets function redeem(uint256 shares, uint256 actorSeed) external countCall(this.redeem.selector) useActor(actorSeed) { bytes4 selector = this.redeem.selector; uint256 userShares = vault.balanceOf(currentActor); if (userShares == 0) { _markNoop(selector); return; } shares = bound(shares, 1, userShares); (uint256[] memory allocationSnapshot, uint256 totalBefore, uint256 totalYield) = _snapshotAllocations(); _markAttempt(selector); try vault.redeem(shares, currentActor, currentActor) returns (uint256 assetsRedeemed) { _markSuccess(selector); ghost_totalWithdrawn += assetsRedeemed; if (ghost_userDeposits[currentActor] >= assetsRedeemed) { ghost_userDeposits[currentActor] -= assetsRedeemed; } uint256 totalAfter = _recordAllocationDeltas(allocationSnapshot); if (vault.liquidityAdapter() != address(0)) { _recordLiquidityAdapterBypass(allocationSnapshot); _assertTotalAllocationDirection(totalBefore, totalAfter, false, totalYield, "Redeem increased total allocations"); } } catch { _markRevert(selector); } } // ============ ADMIN OPERATIONS ============ function _remainingGlobalRiskHeadroom(uint8 riskLevel, address strategyToAllocate) internal view returns (uint256) { uint256 globalRiskCapPct = AlchemistStrategyClassifier(classifier).getGlobalCap(riskLevel); uint256 globalRiskCap = (vault.totalAssets() * globalRiskCapPct) / 1e18; uint256 currentRiskAllocation = 0; uint256 pendingYield = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(strategyId)) == riskLevel) { uint256 alloc = vault.allocation(strategyId); currentRiskAllocation += alloc; if (strategies[i] == strategyToAllocate) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); if (realAssets > alloc) { pendingYield = realAssets - alloc; } } } } uint256 effectiveAllocation = currentRiskAllocation + pendingYield; if (effectiveAllocation >= globalRiskCap) return 0; return globalRiskCap - effectiveAllocation; } /// @notice Admin allocates assets to a specific strategy /// @dev Attempts adapters sequentially from a random start index. function allocate(uint256 strategyIndexSeed, uint256 amount) external countCall(this.allocate.selector) { uint256 strategiesLen = strategies.length; if (strategiesLen == 0) { _markNoop(this.allocate.selector); return; } uint256 strategyIndex = strategyIndexSeed % strategiesLen; (bool success, uint256 allocatedAmount) = _tryAllocate(strategies[strategyIndex], amount, strategyIndexSeed); if (success) { assertGt(allocatedAmount, 0, "Allocate succeeded without allocation delta"); ghost_totalAllocated += allocatedAmount; ghost_strategyAllocations[strategies[strategyIndex]] += allocatedAmount; } } /// @notice Attempts to allocate to a specific strategy, returns success and amount allocated function _tryAllocate( address strategy, uint256 amount, uint256 roleSeed ) internal returns (bool success, uint256 allocatedAmount) { bytes4 selector = this.allocate.selector; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 globalRiskHeadroom = _remainingGlobalRiskHeadroom(riskLevel, strategy); uint256 idleVaultBalance = IERC20(asset).balanceOf(address(vault)); if (idleVaultBalance < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } if (currentAllocation >= absoluteCap) { _markNoop(selector); return (false, 0); } if (globalRiskHeadroom < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } // Get the underlying vault's max deposit to respect protocol-level caps uint256 underlyingMaxDeposit = _getUnderlyingMaxDeposit(strategy); if (underlyingMaxDeposit < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } uint256 maxByAbsolute = absoluteCap - currentAllocation; uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } // Account for yield captured in the allocation change. // The adapter returns change = _totalValue() - allocation(), so effective allocation // after allocate() will be currentAllocation + pendingYield + amount. uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); uint256 pendingYield = currentRealAssets > currentAllocation ? currentRealAssets - currentAllocation : 0; uint256 effectiveAllocation = currentAllocation + pendingYield; uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 maxByAllocatorRelative = allocatorRelativeCapValue > effectiveAllocation ? allocatorRelativeCapValue - effectiveAllocation : 0; uint256 maxByVaultRelative = vaultRelativeCapValue > effectiveAllocation ? vaultRelativeCapValue - effectiveAllocation : 0; uint256 maxByRelativeCap = maxByAllocatorRelative < maxByVaultRelative ? maxByAllocatorRelative : maxByVaultRelative; uint256 maxByAbsoluteRemaining = absoluteCap > effectiveAllocation ? absoluteCap - effectiveAllocation : 0; uint256 maxAllocate = maxByAbsoluteRemaining < maxByRelativeCap ? maxByAbsoluteRemaining : maxByRelativeCap; maxAllocate = maxAllocate < globalRiskHeadroom ? maxAllocate : globalRiskHeadroom; maxAllocate = maxAllocate < idleVaultBalance ? maxAllocate : idleVaultBalance; maxAllocate = maxAllocate < underlyingMaxDeposit ? maxAllocate : underlyingMaxDeposit; address allocatorCaller = _pickAllocatorCaller(roleSeed); if (allocatorCaller == operator) { uint256 individualCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualCap = (totalAssets * individualCapPct) / 1e18; uint256 individualRemaining = individualCap > effectiveAllocation ? individualCap - effectiveAllocation : 0; maxAllocate = maxAllocate < individualRemaining ? maxAllocate : individualRemaining; } if (maxAllocate < MIN_ALLOCATE) { _markNoop(selector); return (false, 0); } amount = bound(amount, MIN_ALLOCATE, maxAllocate); _markAttempt(selector); vm.prank(allocatorCaller); try IAllocator(allocator).allocate(strategy, amount) { uint256 newAllocation = vault.allocation(allocationId); if (newAllocation <= currentAllocation) { _markRevert(selector); return (false, 0); } _markSuccess(selector); return (true, newAllocation - currentAllocation); } catch { _markRevert(selector); return (false, 0); } } /// @notice Admin deallocates assets from a specific strategy function deallocate(uint256 strategyIndex, uint256 amount) external countCall(this.deallocate.selector) { bytes4 selector = this.deallocate.selector; strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); if (currentAllocation < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = currentAllocation; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } amount = bound(amount, MIN_ALLOCATE, maxDeallocate); // Get preview for adjusted withdraw uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(amount); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(amount); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } /// @notice Deallocaes all assets from a specific strategy function deallocateAll(uint256 strategyIndex) external countCall(this.deallocateAll.selector) { bytes4 selector = this.deallocateAll.selector; strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 allocationBefore = vault.allocation(allocationId); if (allocationBefore < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 protocolMaxWithdraw = _getUnderlyingMaxWithdraw(strategy); uint256 maxDeallocate = allocationBefore; if (protocolMaxWithdraw >= MIN_ALLOCATE && protocolMaxWithdraw < maxDeallocate) { maxDeallocate = protocolMaxWithdraw; } if (maxDeallocate < MIN_ALLOCATE) { _markNoop(selector); return; } uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(maxDeallocate); if (previewAmount == 0) { _markNoop(selector); return; } _markAttempt(selector); (uint256[] memory allocationSnapshot,,) = _snapshotAllocations(); address allocatorCaller = _pickAllocatorCaller(strategyIndex); vm.prank(allocatorCaller); try IAllocator(allocator).deallocate(strategy, previewAmount) { _recordAllocationDeltas(allocationSnapshot); _markSuccess(selector); } catch { _markRevert(selector); return; } } function setLiquidityAdapter(uint256 strategySeed, uint256 modeSeed) external countCall(this.setLiquidityAdapter.selector) { bytes4 selector = this.setLiquidityAdapter.selector; if (strategies.length == 0) { _markNoop(selector); return; } address newLiquidityAdapter = address(0); if (modeSeed % 3 != 0) { address candidate = strategies[strategySeed % strategies.length]; if (!_liquidityAdapterHasHeadroom(candidate)) { _markNoop(selector); return; } newLiquidityAdapter = candidate; } _markAttempt(selector); address allocatorCaller = _pickAllocatorCaller(modeSeed); vm.prank(allocatorCaller); try IAllocator(allocator).setLiquidityAdapter(newLiquidityAdapter, _directLiquidityData()) { _markSuccess(selector); } catch { _markRevert(selector); } } function _directLiquidityData() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; return abi.encode(params); } function _snapshotAllocations() internal view returns (uint256[] memory snapshot, uint256 totalBefore, uint256 totalYield) { uint256 len = strategies.length; snapshot = new uint256[](len); for (uint256 i = 0; i < len; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); snapshot[i] = allocation; totalBefore += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) totalYield += ra - allocation; } } function _recordAllocationDeltas(uint256[] memory beforeAllocations) internal returns (uint256 totalAfter) { uint256 len = strategies.length; for (uint256 i = 0; i < len; i++) { address strategy = strategies[i]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint256 beforeAllocation = beforeAllocations[i]; totalAfter += afterAllocation; if (afterAllocation >= beforeAllocation) { uint256 deltaUp = afterAllocation - beforeAllocation; if (deltaUp > 0) { ghost_totalAllocated += deltaUp; ghost_strategyAllocations[strategy] += deltaUp; } } else { uint256 deltaDown = beforeAllocation - afterAllocation; ghost_totalDeallocated += deltaDown; if (ghost_strategyAllocations[strategy] >= deltaDown) { ghost_strategyAllocations[strategy] -= deltaDown; } else { ghost_strategyAllocations[strategy] = 0; } } } } function _recordLiquidityAdapterBypass(uint256[] memory beforeAllocations) internal { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 afterAllocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); if (afterAllocation > beforeAllocations[i]) { ghost_liquidityAdapterBypass[riskLevel] += afterAllocation - beforeAllocations[i]; } else if (beforeAllocations[i] > afterAllocation) { uint256 decrease = beforeAllocations[i] - afterAllocation; ghost_liquidityAdapterBypass[riskLevel] = ghost_liquidityAdapterBypass[riskLevel] > decrease ? ghost_liquidityAdapterBypass[riskLevel] - decrease : 0; } } } function _assertTotalAllocationDirection(uint256 totalBefore, uint256 totalAfter, bool expectIncrease, uint256 yieldTolerance, string memory errorMessage) internal pure { if (expectIncrease) { require(totalAfter + yieldTolerance >= totalBefore, errorMessage); } else { require(totalAfter <= totalBefore + yieldTolerance, errorMessage); } } function _liquidityAdapterHasHeadroom(address strategy) internal view returns (bool) { bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); uint256 totalAssets = vault.totalAssets(); uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) { firstTotalAssets = totalAssets; } uint256 allocatorRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (totalAssets * relativeCap) / 1e18; uint256 vaultRelativeCapValue = relativeCap == type(uint256).max ? type(uint256).max : (firstTotalAssets * relativeCap) / 1e18; uint256 relativeLimit = allocatorRelativeCapValue < vaultRelativeCapValue ? allocatorRelativeCapValue : vaultRelativeCapValue; uint256 hardLimit = absoluteCap < relativeLimit ? absoluteCap : relativeLimit; if (hardLimit <= currentAllocation) return false; uint256 headroom = hardLimit - currentAllocation; return headroom >= MIN_DEPOSIT * 10; } // ============ ADMIN RISK CONFIG OPERATIONS ============ /// @notice Reclassify a strategy to a different risk level (low frequency ~10%) /// @dev Only reclassifies if the target risk class's global cap can accommodate /// the strategy's existing allocation plus current aggregate in that class. function reclassifyStrategy(uint256 strategyIndexSeed, uint256 newRiskClassSeed) external countCall(this.reclassifyStrategy.selector) { bytes4 selector = this.reclassifyStrategy.selector; if (strategies.length == 0) { _markNoop(selector); return; } if (newRiskClassSeed % 100 != 0) { _markNoop(selector); return; } uint256 idx = strategyIndexSeed % strategies.length; address strategy = strategies[idx]; bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint8 currentRisk = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint8 newRisk = uint8((newRiskClassSeed / 10) % 3); if (newRisk == currentRisk) { newRisk = (newRisk + 1) % 3; } uint256 totalAssets = vault.totalAssets(); uint256 newGlobalCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(newRisk)) / 1e18; uint256 existingInNewClass = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == newRisk) { existingInNewClass += vault.allocation(stratId); } } uint256 strategyAllocation = vault.allocation(allocationId); if (existingInNewClass + strategyAllocation > newGlobalCap) { _markNoop(selector); return; } _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel(uint256(allocationId), newRisk); _markSuccess(selector); } /// @notice Modify the caps of a risk class (low frequency ~10%) /// @dev Only tightens caps to levels that still accommodate existing allocations. /// New global cap >= current aggregate allocation in that class + MIN_ALLOCATE. /// New local cap >= largest individual allocation in that class. function modifyRiskClassCaps(uint256 riskClassSeed, uint256 capSeed) external countCall(this.modifyRiskClassCaps.selector) { bytes4 selector = this.modifyRiskClassCaps.selector; if (capSeed % 100 != 0) { _markNoop(selector); return; } uint8 riskClass = uint8(riskClassSeed % 3); uint256 totalAssets = vault.totalAssets(); uint256 currentAggregate = 0; uint256 maxIndividual = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 stratId = IMYTStrategy(strategies[i]).adapterId(); if (AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == riskClass) { uint256 alloc = vault.allocation(stratId); currentAggregate += alloc; if (alloc > maxIndividual) maxIndividual = alloc; } } uint256 minGlobalPct = currentAggregate > 0 ? ((currentAggregate + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxGlobalPct = 1e18; if (minGlobalPct > maxGlobalPct) { _markNoop(selector); return; } uint256 minLocalPct = maxIndividual > 0 ? ((maxIndividual + MIN_ALLOCATE) * 1e18 + totalAssets - 1) / totalAssets : 0.01e18; uint256 maxLocalPct = 1e18; if (minLocalPct > maxLocalPct) { _markNoop(selector); return; } uint256 newGlobalPct = bound(capSeed / 10, minGlobalPct, maxGlobalPct); uint256 newLocalPct = bound(capSeed / 100, minLocalPct, maxLocalPct); _markAttempt(selector); vm.prank(admin); AlchemistStrategyClassifier(classifier).setRiskClass(riskClass, newGlobalPct, newLocalPct); _markSuccess(selector); } // ============ TIME OPERATIONS ============ function changePerformanceFee(uint256 feeSeed) external countCall(this.changePerformanceFee.selector) { bytes4 selector = this.changePerformanceFee.selector; if (feeSeed % 200 != 0) { _markNoop(selector); return; } uint256 newFee = bound(feeSeed / 200, 0, 0.5e18); _markAttempt(selector); vm.prank(admin); AlchemistCurator(curatorContract).submitSetPerformanceFee(address(vault), newFee); vm.prank(admin); vault.setPerformanceFee(newFee); _markSuccess(selector); } /// @notice Advance time for yield accumulation function warpTime(uint256 timeDelta) external countCall(this.warpTime.selector) { timeDelta = bound(timeDelta, 1 hours, 365 days); vm.warp(block.timestamp + timeDelta); } /// @notice Advance time with strategy-specific hooks function warpTimeWithStrategyHook(uint256 timeDelta, uint256 strategyIndex) external countCall(this.warpTimeWithStrategyHook.selector) { timeDelta = bound(timeDelta, 1 hours, 365 days); strategyIndex = bound(strategyIndex, 0, strategies.length - 1); // Strategy-specific time hooks could be added here for protocols that need them // (e.g., Tokemak oracle mocking) vm.warp(block.timestamp + timeDelta); } // ============ REWARD OPERATIONS ============ /// @notice Claim rewards from a strategy (mocked swap) /// @dev This is a placeholder - actual implementation needs real swap calldata function claimRewards(uint256 strategyIndex, uint256 /* minAmountOut */) external countCall(this.claimRewards.selector) { strategyIndex = bound(strategyIndex, 0, strategies.length - 1); address strategy = strategies[strategyIndex]; // Check if strategy has rewards functionality try IMYTStrategy(strategy).claimRewards(address(0), "", 0) returns (uint256) { // If it doesn't revert, rewards might be available // In production, we'd need proper swap calldata } catch { // Expected for strategies without rewards or with bad calldata } } /// @dev Get the maximum deposit amount for the underlying protocol vault /// This accounts for protocol-level supply caps (e.g., Euler's E_SupplyCapExceeded) function _getUnderlyingMaxDeposit(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxDeposit(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } /// @dev Get the maximum withdrawable amount for the underlying protocol vault. function _getUnderlyingMaxWithdraw(address strategy) internal view returns (uint256) { address underlyingVault = _resolveUnderlyingVault(strategy); if (underlyingVault == address(0)) return type(uint256).max; (bool ok, bytes memory data) = underlyingVault.staticcall(abi.encodeWithSignature("maxWithdraw(address)", strategy)); if (!ok || data.length < 32) return type(uint256).max; return abi.decode(data, (uint256)); } function _resolveUnderlyingVault(address strategy) internal view returns (address underlyingVault) { (bool ok, bytes memory data) = strategy.staticcall(abi.encodeWithSignature("vault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); if (underlyingVault != address(0)) return underlyingVault; } (ok, data) = strategy.staticcall(abi.encodeWithSignature("autoVault()")); if (ok && data.length >= 32) { underlyingVault = abi.decode(data, (address)); } } function getStrategyCount() external view returns (uint256) { return strategies.length; } function getStrategy(uint256 index) external view returns (address) { return strategies[index]; } /// @notice Returns the net allocated amount from ghost variables function ghost_netAllocated() external view returns (uint256) { if (ghost_totalAllocated >= ghost_totalDeallocated) { return ghost_totalAllocated - ghost_totalDeallocated; } return 0; } /// @notice Returns the sum of all ghost strategy allocations function ghost_sumStrategyAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { sum += ghost_strategyAllocations[strategies[i]]; } return sum; } /// @notice Returns the sum of actual vault allocations function vault_totalAllocations() external view returns (uint256) { uint256 sum = 0; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); sum += vault.allocation(allocationId); } return sum; } function getCalls(bytes4 selector) external view returns (uint256) { return calls[selector]; } function getOperationStats(bytes4 selector) external view returns (uint256 attempts_, uint256 successes_, uint256 reverts_, uint256 noops_) { return (opAttempts[selector], opSuccesses[selector], opReverts[selector], opNoops[selector]); } function getAllocatorRoleAttempts(address role) external view returns (uint256) { return allocatorRoleAttempts[role]; } function _logOperationStats(string memory label, bytes4 selector) internal view { console.log(label); console.log(" attempts:", opAttempts[selector]); console.log(" successes:", opSuccesses[selector]); console.log(" reverts:", opReverts[selector]); console.log(" noops:", opNoops[selector]); } function callSummary() external view { console.log("=== USDC Multi-Strategy Handler Call Summary ==="); console.log("User Operations:"); console.log(" deposit calls:", calls[this.deposit.selector]); console.log(" withdraw calls:", calls[this.withdraw.selector]); console.log(" mint calls:", calls[this.mint.selector]); console.log(" redeem calls:", calls[this.redeem.selector]); console.log("Admin Operations:"); console.log(" allocate calls:", calls[this.allocate.selector]); console.log(" deallocate calls:", calls[this.deallocate.selector]); console.log(" deallocateAll calls:", calls[this.deallocateAll.selector]); console.log("Admin Risk Config Operations:"); console.log(" reclassifyStrategy calls:", calls[this.reclassifyStrategy.selector]); console.log(" modifyRiskClassCaps calls:", calls[this.modifyRiskClassCaps.selector]); console.log(" changePerformanceFee calls:", calls[this.changePerformanceFee.selector]); console.log("Time Operations:"); console.log(" warpTime calls:", calls[this.warpTime.selector]); console.log(" warpTimeWithStrategyHook calls:", calls[this.warpTimeWithStrategyHook.selector]); console.log("Reward Operations:"); console.log(" claimRewards calls:", calls[this.claimRewards.selector]); console.log("Ghost Variables:"); console.log(" totalDeposited:", ghost_totalDeposited); console.log(" totalWithdrawn:", ghost_totalWithdrawn); console.log(" totalAllocated:", ghost_totalAllocated); console.log(" totalDeallocated:", ghost_totalDeallocated); console.log("Operation Stats:"); _logOperationStats(" allocate", this.allocate.selector); _logOperationStats(" deallocate", this.deallocate.selector); _logOperationStats(" deallocateAll", this.deallocateAll.selector); _logOperationStats(" setLiquidityAdapter", this.setLiquidityAdapter.selector); _logOperationStats(" withdraw", this.withdraw.selector); _logOperationStats(" redeem", this.redeem.selector); console.log("Allocator Role Attempts:"); console.log(" admin:", allocatorRoleAttempts[admin]); console.log(" operator:", allocatorRoleAttempts[operator]); } } /// @title MultiStrategyUSDCInvariantTest /// @notice Invariant tests for USDC strategies attached to a single vault contract MultiStrategyUSDCInvariantTest is Test { IVaultV2 public vault; MultiStrategyUSDCHandler public handler; address[] public strategies; address public allocator; address public classifier; address public curatorContract; address public admin = address(0x1); address public operator = address(0x3); uint256 public initialSharePrice; address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address public constant YV_USDC_VAULT = 0x696d02Db93291651ED510704c9b286841d506987; address public constant EULER_USDC_VAULT = 0xe0a80d35bB6618CBA260120b279d357978c42BCE; address public constant PEAPODS_USDC_VAULT = 0x3717e340140D30F3A077Dd21fAc39A86ACe873AA; address public constant TOKE_AUTO_USD_VAULT = 0xa7569A44f348d3D70d8ad5889e50F78E33d80D35; address public constant TOKE_REWARDER_USD = 0x726104CfBd7ece2d1f5b3654a19109A9e2b6c27B; address public constant TOKE = 0x2e9d63788249371f1DFC918a52f8d799F4a38C94; uint256 public constant INITIAL_VAULT_DEPOSIT = 10_000_000e6; // 10M USDC uint256 public constant ABSOLUTE_CAP = 50_000_000e6; // 50M USDC per strategy uint256 public constant RELATIVE_CAP = 0.5e18; // 50% of vault assets uint256 private forkId; function setUp() public { // Fork mainnet at specific block string memory rpc = vm.envString("MAINNET_RPC_URL"); forkId = vm.createFork(rpc, 24_850_461); vm.selectFork(forkId); // Setup vault vm.startPrank(admin); vault = _setupVault(USDC); // Setup strategies string[] memory strategyNames = new string[](4); strategyNames[0] = "Yearn Mainnet USDC"; strategyNames[1] = "Euler Mainnet USDC"; strategyNames[2] = "Peapods Mainnet USDC"; strategyNames[3] = "TokeAutoUSD Mainnet"; // Deploy Yearn USDC Strategy strategies.push(_deployYvUSDCStrategy()); // Deploy Euler USDC Strategy strategies.push(_deployEulerStrategy()); // Deploy Peapods USDC Strategy strategies.push(_deployPeapodsStrategy()); // Deploy TokeAuto USD Strategy strategies.push(_deployTokeStrategy()); // Setup classifier and allocator _setupClassifierAndAllocator(); // Add strategies to vault _addStrategiesToVault(); // Make initial deposit to vault _makeInitialDeposit(); vm.stopPrank(); initialSharePrice = (vault.totalAssets() * 1e18) / vault.totalSupply(); // Create handler handler = new MultiStrategyUSDCHandler( address(vault), strategies, allocator, classifier, curatorContract, admin, operator, strategyNames ); // Target the handler targetContract(address(handler)); // Target specific functions bytes4[] memory selectors = new bytes4[](12); selectors[0] = handler.deposit.selector; selectors[1] = handler.withdraw.selector; selectors[2] = handler.mint.selector; selectors[3] = handler.redeem.selector; selectors[4] = handler.allocate.selector; selectors[5] = handler.deallocate.selector; selectors[6] = handler.deallocateAll.selector; selectors[7] = handler.setLiquidityAdapter.selector; selectors[8] = handler.warpTime.selector; selectors[9] = handler.reclassifyStrategy.selector; selectors[10] = handler.modifyRiskClassCaps.selector; selectors[11] = handler.changePerformanceFee.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } function _setupVault(address asset) internal returns (IVaultV2) { VaultV2Factory factory = new VaultV2Factory(); return IVaultV2(factory.createVaultV2(admin, asset, bytes32(0))); } function _deployYvUSDCStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Yearn Mainnet USDC", protocol: "Yearn", riskClass: IMYTStrategy.RiskClass.LOW, cap: 1000 * 1e6, globalCap: 1e18, estimatedYield: 500, additionalIncentives: false, slippageBPS: 50 }); return address(new ERC4626Strategy(address(vault), params, YV_USDC_VAULT)); } function _deployEulerStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Euler Mainnet USDC", protocol: "Euler", riskClass: IMYTStrategy.RiskClass.LOW, cap: 1e6 * 1e6, globalCap: 0.5e18, estimatedYield: 500, additionalIncentives: false, slippageBPS: 50 }); return address(new ERC4626Strategy(address(vault), params, EULER_USDC_VAULT)); } function _deployPeapodsStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "Peapods Mainnet USDC", protocol: "Peapods", riskClass: IMYTStrategy.RiskClass.HIGH, cap: 1e6 * 1e6, globalCap: 0.2e18, estimatedYield: 550, additionalIncentives: false, slippageBPS: 50 }); return address(new ERC4626Strategy(address(vault), params, PEAPODS_USDC_VAULT)); } function _deployTokeStrategy() internal returns (address) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "TokeAutoUSD Mainnet", protocol: "TokeAuto", riskClass: IMYTStrategy.RiskClass.MEDIUM, cap: 1e6 * 1e6, globalCap: 0.3e18, estimatedYield: 750, additionalIncentives: false, slippageBPS: 50 }); return address(new TokeAutoStrategy( address(vault), params, USDC, TOKE_AUTO_USD_VAULT, TOKE_REWARDER_USD, TOKE )); } function _setupClassifierAndAllocator() internal { classifier = address(new AlchemistStrategyClassifier(admin)); // Set up risk classes matching constructor defaults (WAD: 1e18 = 100%) AlchemistStrategyClassifier(classifier).setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% AlchemistStrategyClassifier(classifier).setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% AlchemistStrategyClassifier(classifier).setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% // Assign risk levels for (uint256 i = 0; i < strategies.length; i++) { bytes32 strategyId = IMYTStrategy(strategies[i]).adapterId(); (,,,IMYTStrategy.RiskClass riskClass,,,,,) = IMYTStrategy(strategies[i]).params(); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel( uint256(strategyId), uint8(riskClass) ); } // Deploy curator for timelocked operations curatorContract = address(new AlchemistCurator(admin, admin)); // Set curator on vault (owner can do this directly) VaultV2(address(vault)).setCurator(curatorContract); _setPerformanceFee(curatorContract); allocator = address(new AlchemistAllocator(address(vault), admin, operator, classifier)); } function _setPerformanceFee(address _curator) internal { AlchemistCurator curator = AlchemistCurator(_curator); curator.submitSetPerformanceFeeRecipient(address(vault), admin); vault.setPerformanceFeeRecipient(admin); curator.submitSetPerformanceFee(address(vault), 15e16); vault.setPerformanceFee(15e16); } function _addStrategiesToVault() internal { // Use curator for timelocked operations AlchemistCurator curator = AlchemistCurator(curatorContract); // Submit and set allocator through curator curator.submitSetAllocator(address(vault), allocator, true); vault.setIsAllocator(allocator, true); for (uint256 i = 0; i < strategies.length; i++) { // Submit and add adapter through curator curator.submitSetStrategy(strategies[i], address(vault)); curator.setStrategy(strategies[i], address(vault)); // Submit and set absolute cap through curator curator.submitIncreaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); curator.increaseAbsoluteCap(strategies[i], ABSOLUTE_CAP); // VaultV2 enforces relative cap from vault storage, not strategy params. // Mirror strategy globalCap into the vault cap configuration. (,,,,, uint256 strategyRelativeCap,,,) = IMYTStrategy(strategies[i]).params(); curator.submitIncreaseRelativeCap(strategies[i], strategyRelativeCap); curator.increaseRelativeCap(strategies[i], strategyRelativeCap); } } function _makeInitialDeposit() internal { deal(USDC, admin, INITIAL_VAULT_DEPOSIT); IERC20(USDC).approve(address(vault), INITIAL_VAULT_DEPOSIT); vault.deposit(INITIAL_VAULT_DEPOSIT, admin); } // ============ INVARIANTS ============ /// @notice Invariant: All strategies must have non-negative real assets function invariant_realAssets_nonNegative() public view { for (uint256 i = 0; i < strategies.length; i++) { uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); assertGe(realAssets, 0, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " has negative real assets"))); } } /// @notice Invariant: No strategy allocation exceeds absolute cap function invariant_allocationWithinAbsoluteCap() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 ra = IMYTStrategy(strategies[i]).realAssets(); uint256 yieldGap = ra > allocation ? ra - allocation : 0; uint256 tolerance = absoluteCap / 20 + yieldGap; assertLe(allocation, absoluteCap + tolerance, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds absolute cap"))); } } /// @notice Invariant: No strategy allocation exceeds relative cap function invariant_allocationWithinRelativeCap() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); if (relativeCap == 1e18) continue; uint256 maxAllowed = (firstTotalAssets * relativeCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% // Relative-cap checks are point-in-time checks and can drift slightly with asset movement. assertLe(allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds relative cap"))); } } /// @notice Invariant: No strategy allocation exceeds global risk cap for its risk level function invariant_allocationWithinGlobalRiskCap() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 allocation = vault.allocation(allocationId); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } /// @notice Invariant: No strategy allocation exceeds individual/local risk cap function invariant_allocationWithinIndividualRiskCap() public view { if (handler.getAllocatorRoleAttempts(admin) > 0) return; uint256 totalAssets = vault.totalAssets(); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 individualRiskCapPct = AlchemistStrategyClassifier(classifier).getIndividualCap(uint256(allocationId)); uint256 individualRiskCap = (totalAssets * individualRiskCapPct) / 1e18; assertLe(allocation, individualRiskCap, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds individual risk cap"))); } } /// @notice Invariant: Total allocations per risk level don't exceed aggregate limits function invariant_riskLevelAggregateCaps() public view { uint256 totalAssets = vault.totalAssets(); uint256[3] memory riskLevelAllocations; uint256[3] memory yieldGaps; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); riskLevelAllocations[riskLevel] += allocation; uint256 ra = IMYTStrategy(strategies[i]).realAssets(); if (ra > allocation) yieldGaps[riskLevel] += ra - allocation; } uint256 lowCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(0)) / 1e18; uint256 medCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(1)) / 1e18; uint256 highCap = (totalAssets * AlchemistStrategyClassifier(classifier).getGlobalCap(2)) / 1e18; assertLe(riskLevelAllocations[0], lowCap + handler.ghost_liquidityAdapterBypass(0) + yieldGaps[0], "LOW risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[1], medCap + handler.ghost_liquidityAdapterBypass(1) + yieldGaps[1], "MEDIUM risk aggregate exceeds global cap"); assertLe(riskLevelAllocations[2], highCap + handler.ghost_liquidityAdapterBypass(2) + yieldGaps[2], "HIGH risk aggregate exceeds global cap"); } /// @notice Invariant: Sum of all allocations should not exceed vault total assets significantly function invariant_totalAllocationsBounded() public view { uint256 totalAllocations = 0; uint256 totalRealAssets = IERC20(vault.asset()).balanceOf(address(vault)); for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); totalAllocations += vault.allocation(allocationId); totalRealAssets += IMYTStrategy(strategies[i]).realAssets(); } // Compare against real assets (idle + strategy realAssets), not capped accounting totalAssets. assertLe(totalAllocations, totalRealAssets * 110 / 100 + 1, "Total allocations exceed real assets by more than 10%"); } /// @notice Invariant: Each strategy's real assets should be consistent with vault allocation function invariant_realAssetsConsistentWithAllocation() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); uint256 realAssets = IMYTStrategy(strategies[i]).realAssets(); // Real assets should be within reasonable bounds of allocation // Allow 5% tolerance for yield/losses if (allocation > 0) { uint256 minExpected = allocation * 95 / 100; //uint256 maxExpected = allocation * 105 / 100; // Only assert if allocation is significant if (allocation > 1e6) { assertGe(realAssets, minExpected, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets below allocation"))); //assertLe(realAssets, maxExpected * 2, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " real assets significantly above allocation"))); } } } } function invariant_sharePriceNonDecreasing() public view { uint256 totalSupply = vault.totalSupply(); if (totalSupply == 0) return; uint256 totalAssets = vault.totalAssets(); uint256 sharePrice = (totalAssets * 1e18) / totalSupply; assertGt(sharePrice, 0, "Share price collapsed to zero"); } /// @notice Invariant: User deposits minus withdrawals should equal their share of vault function invariant_userBalanceConsistency() public view { uint256 totalUserDeposits = handler.ghost_totalDeposited(); uint256 totalUserWithdrawals = handler.ghost_totalWithdrawn(); uint256 netDeposits = totalUserDeposits > totalUserWithdrawals ? totalUserDeposits - totalUserWithdrawals : 0; uint256 vaultBalance = IERC20(USDC).balanceOf(address(vault)); uint256 totalStrategyValue = 0; for (uint256 i = 0; i < strategies.length; i++) { totalStrategyValue += IMYTStrategy(strategies[i]).realAssets(); } uint256 totalValue = vaultBalance + totalStrategyValue; uint256 totalExpected = INITIAL_VAULT_DEPOSIT + netDeposits; if (totalExpected > 1e6) { assertGe(totalValue, totalExpected * 90 / 100, "Total value significantly less than expected deposits"); } } /// @notice Invariant: Ghost allocations match actual vault allocations per strategy function invariant_ghostAllocationsMatchVault() public view { for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 actualAllocation = vault.allocation(allocationId); uint256 ghostAllocation = handler.ghost_strategyAllocations(strategies[i]); // Allow 5% tolerance for yield/rounding differences if (actualAllocation > 1e6) { uint256 minExpected = actualAllocation * 95 / 100; uint256 maxExpected = actualAllocation * 105 / 100; assertGe(ghostAllocation, minExpected, string(abi.encodePacked("Ghost allocation below actual for ", handler.strategyNames(strategies[i])))); assertLe(ghostAllocation, maxExpected, string(abi.encodePacked("Ghost allocation above actual for ", handler.strategyNames(strategies[i])))); } } } /// @notice Invariant: Net ghost allocations match sum of vault allocations function invariant_netAllocationsConsistent() public view { uint256 ghostNet = handler.ghost_netAllocated(); uint256 vaultTotal = handler.vault_totalAllocations(); // Allow 10% tolerance for yield accumulation and rounding if (vaultTotal > 1e6) { assertGe(ghostNet, vaultTotal * 90 / 100, "Ghost net allocations below vault total"); assertLe(ghostNet, vaultTotal * 110 / 100, "Ghost net allocations above vault total"); } } /// @notice Invariant: Ghost sum of strategy allocations is internally consistent function invariant_ghostSumConsistent() public view { uint256 ghostSum = handler.ghost_sumStrategyAllocations(); uint256 ghostNet = handler.ghost_netAllocated(); // ghost_sumStrategyAllocations should equal ghost_netAllocated // Allow small tolerance for rounding if (ghostNet > 1e6) { assertGe(ghostSum, ghostNet * 95 / 100, "Ghost sum inconsistent with net"); assertLe(ghostSum, ghostNet * 105 / 100, "Ghost sum inconsistent with net"); } } function invariant_noStrategyDominance() public view { uint256 firstTotalAssets = vault.firstTotalAssets(); if (firstTotalAssets == 0) return; for (uint256 i = 0; i < strategies.length; i++) { bytes32 allocationId = IMYTStrategy(strategies[i]).adapterId(); uint256 allocation = vault.allocation(allocationId); if (allocation == 0) continue; (,,,,, uint256 strategyGlobalCap,,,) = IMYTStrategy(strategies[i]).params(); uint256 maxAllowed = (firstTotalAssets * strategyGlobalCap) / 1e18; uint256 tolerance = maxAllowed / 100; // 1% assertLe( allocation, maxAllowed + tolerance + 1, string(abi.encodePacked("Strategy ", handler.strategyNames(strategies[i]), " exceeds configured globalCap")) ); } } /// @notice Ensures allocate path is exercised and not a no-op. function invariant_allocatePathHasProgress() public view { uint256 allocateCalls = handler.getCalls(handler.allocate.selector); (uint256 allocateAttempts, uint256 allocateSuccesses, uint256 allocateReverts, uint256 allocateNoops) = handler.getOperationStats(handler.allocate.selector); assertEq(allocateCalls, allocateAttempts + allocateNoops, "Allocate call accounting mismatch"); assertEq(allocateAttempts, allocateSuccesses + allocateReverts, "Allocate attempt accounting mismatch"); if (allocateCalls >= strategies.length) { assertGt(handler.ghost_totalAllocated(), 0, "Allocate path made no progress"); } if (allocateAttempts >= strategies.length) { assertGt(allocateSuccesses, 0, "Allocate attempts made but none succeeded"); assertLt(allocateReverts, allocateAttempts, "Allocate attempts always reverted"); } } function invariant_handlerOperationAccounting() public view { bytes4[6] memory selectors = [ handler.allocate.selector, handler.deallocate.selector, handler.deallocateAll.selector, handler.setLiquidityAdapter.selector, handler.withdraw.selector, handler.redeem.selector ]; for (uint256 i = 0; i < selectors.length; i++) { bytes4 selector = selectors[i]; uint256 calls = handler.getCalls(selector); (uint256 attempts, uint256 successes, uint256 reverts_, uint256 noops) = handler.getOperationStats(selector); assertEq(calls, attempts + noops, "Operation call accounting mismatch"); assertEq(attempts, successes + reverts_, "Operation attempt accounting mismatch"); } } function invariant_userPathIsNotSilentlyReverting() public view { (uint256 withdrawAttempts, uint256 withdrawSuccesses, uint256 withdrawReverts, ) = handler.getOperationStats(handler.withdraw.selector); (uint256 redeemAttempts, uint256 redeemSuccesses, uint256 redeemReverts, ) = handler.getOperationStats(handler.redeem.selector); if (withdrawAttempts >= 5) { assertGt(withdrawSuccesses, 0, "Withdraw attempted repeatedly but never succeeded"); assertLt(withdrawReverts, withdrawAttempts, "Withdraw attempts always reverted"); } if (redeemAttempts >= 5) { assertGt(redeemSuccesses, 0, "Redeem attempted repeatedly but never succeeded"); assertLt(redeemReverts, redeemAttempts, "Redeem attempts always reverted"); } } function invariant_allocatorRolesExercised() public view { uint256 allocateAttempts; uint256 deallocateAttempts; uint256 deallocateAllAttempts; uint256 setLiquidityAttempts; (allocateAttempts, , , ) = handler.getOperationStats(handler.allocate.selector); (deallocateAttempts, , , ) = handler.getOperationStats(handler.deallocate.selector); (deallocateAllAttempts, , , ) = handler.getOperationStats(handler.deallocateAll.selector); (setLiquidityAttempts, , , ) = handler.getOperationStats(handler.setLiquidityAdapter.selector); uint256 totalAllocatorAttempts = allocateAttempts + deallocateAttempts + deallocateAllAttempts + setLiquidityAttempts; if (totalAllocatorAttempts >= 10) { assertGt(handler.getAllocatorRoleAttempts(admin), 0, "Admin allocator path not exercised"); assertGt(handler.getAllocatorRoleAttempts(operator), 0, "Operator allocator path not exercised"); } } function debugCallSummary() public view { handler.callSummary(); } } ================================================ FILE: src/test/PerpetualGaugeTest.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import "forge-std/Test.sol"; import {PerpetualGauge} from "../PerpetualGauge.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; // --- Mock contracts --- contract MockERC20 is IERC20Metadata { string public name = "Mock Token"; string public symbol = "MCK"; uint8 public decimals = 18; uint256 public totalSupply = 1e24; // 1 million tokens (1e6 * 1e18) mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; constructor() { balanceOf[msg.sender] = totalSupply; } function transfer(address to, uint256 amount) external returns (bool) { require(balanceOf[msg.sender] >= amount, "Insufficient"); balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true; } function approve(address spender, uint256 amount) external returns (bool) { allowance[msg.sender][spender] = amount; return true; } function transferFrom(address from, address to, uint256 amount) external returns (bool) { require(balanceOf[from] >= amount, "Insufficient"); require(allowance[from][msg.sender] >= amount, "Not allowed"); balanceOf[from] -= amount; allowance[from][msg.sender] -= amount; balanceOf[to] += amount; return true; } } contract MockStrategyClassifier { mapping(uint256 => uint8) public risk; mapping(uint256 => uint256) public indivCap; mapping(uint8 => uint256) public globalCap; function setRisk(uint256 stratId, uint8 _risk) external { risk[stratId] = _risk; } function setIndivCap(uint256 stratId, uint256 cap) external { indivCap[stratId] = cap; } function setGlobalCap(uint8 riskLevel, uint256 cap) external { globalCap[riskLevel] = cap; } function getStrategyRiskLevel(uint256 stratId) external view returns (uint8) { return risk[stratId]; } function getIndividualCap(uint256 stratId) external view returns (uint256) { return indivCap[stratId]; } function getGlobalCap(uint8 riskLevel) external view returns (uint256) { return globalCap[riskLevel]; } } contract MockAllocatorProxy { event Allocated(uint256 strategyId, uint256 amount); function allocate(uint256 strategyId, uint256 amount) external { emit Allocated(strategyId, amount); } } /* contract MockERC20Test is Test { MockERC20 public mockERC20; address alice = address(0xA11CE); address bob = address(0xB0B); address charlie = address(0x0C0); function setUp() public { mockERC20 = new MockERC20(); mockERC20.transfer(alice, 1000e18); mockERC20.transfer(bob, 500e18); } function testMockERC20TransferFunctionSignature() public { // Test that the transfer function has the correct selector bytes4 expectedSelector = bytes4(keccak256("transfer(address,uint256)")); bytes4 actualSelector = mockERC20.transfer.selector; assertEq(actualSelector, expectedSelector); } function testMockERC20ApproveFunctionSignature() public { // Test that the approve function has the correct selector bytes4 expectedSelector = bytes4(keccak256("approve(address,uint256)")); bytes4 actualSelector = mockERC20.approve.selector; assertEq(actualSelector, expectedSelector); } function testMockERC20TransferFromFunctionSignature() public { // Test that the transferFrom function has the correct selector bytes4 expectedSelector = bytes4(keccak256("transferFrom(address,address,uint256)")); bytes4 actualSelector = mockERC20.transferFrom.selector; assertEq(actualSelector, expectedSelector); } function testMockERC20IERC20Compliance() public { // Test that MockERC20 implements IERC20Metadata interface IERC20Metadata ierc20 = IERC20Metadata(address(mockERC20)); // Test basic token properties assertEq(ierc20.name(), "Mock Token"); assertEq(ierc20.symbol(), "MCK"); assertEq(ierc20.decimals(), 18); // Test interface functions assertEq(ierc20.totalSupply(), 1e24); assertEq(ierc20.balanceOf(alice), 1000e18); assertEq(ierc20.allowance(alice, bob), 0); vm.startPrank(alice); assertTrue(ierc20.approve(bob, 100e18)); assertTrue(ierc20.transfer(charlie, 10e18)); assertTrue(ierc20.transferFrom(alice, bob, 10e18)); vm.stopPrank(); } } */ // --- Test Suite --- contract PerpetualGaugeTest is Test { PerpetualGauge gauge; MockERC20 token; MockStrategyClassifier classifier; MockAllocatorProxy allocator; address alice = address(0xA11CE); address bob = address(0xB0B); function setUp() public { token = new MockERC20(); classifier = new MockStrategyClassifier(); allocator = new MockAllocatorProxy(); gauge = new PerpetualGauge(address(classifier), address(allocator), address(token)); // Give tokens to Alice and Bob token.transfer(alice, 1e21); token.transfer(bob, 1e21); // Setup classifier caps classifier.setIndivCap(1, 5000); // 50% cap classifier.setGlobalCap(1, 8000); // 80% for risk group 1 classifier.setRisk(1, 1); } // --- Voting Tests --- /* function testVoteIncreasesWeights() public { vm.prank(alice); gauge.vote(1, _arr(1), _arr(100)); (uint256[] memory sIds, uint256[] memory weights) = gauge.getCurrentAllocations(1); assertEq(sIds.length, 0, "strategyList not populated yet"); // because registerNewStrategy not complete TODO in contract } function testVoteThenClear() public { vm.startPrank(alice); gauge.vote(1, _arr(1), _arr(100)); gauge.clearVote(1); vm.stopPrank(); } function testVoteExpiryLogic() public { vm.prank(alice); gauge.vote(1, _arr(1), _arr(100)); // Warp past expiry vm.warp(block.timestamp + 366 days); // Should reset expiry vm.prank(alice); gauge.vote(1, _arr(1), _arr(200)); } */ // --- Allocation Tests --- /* function testExecuteAllocationAppliesCaps() public { // Add strategy slot // FIXME gauge.strategyList(1).push(1); // direct storage modification in test (unsafe in prod) vm.prank(alice); gauge.vote(1, _arr(1), _arr(100)); // Force aggregator to contain weight uint256 idle = 1e18; vm.expectEmit(true, true, true, true); emit MockAllocatorProxy.Allocated(1, idle / 2); // since indivCap is 50% gauge.executeAllocation(1, idle); } */ /* function testMultipleVotersAggregate() public { // Add strategy slot // FIXME gauge.strategyList(1).push(1); vm.prank(alice); gauge.vote(1, _arr(1), _arr(100)); vm.prank(bob); gauge.vote(1, _arr(1), _arr(100)); (uint256[] memory sIds, uint256[] memory weights) = gauge.getCurrentAllocations(1); assertEq(sIds.length, 1); assertEq(weights[0], 1e18, "Full allocation weight"); } */ // Helper function for arrays function _arr(uint256 v) internal pure returns (uint256[] memory) { uint256[] memory arr = new uint256[](1); arr[0] = v; return arr; } } ================================================ FILE: src/test/README.md ================================================ ## Runing Tests - Copy .example.env to your own .env - Use your preferred RPC urls or the default - run "source .env" - forge test ### Note Integration tests and the AlchemistETHVault test are targetting mainnet by default. Run with you preferred mainnet RPC URL : - FOUNDRY_PROFILE=default forge test --fork-url https://mainnet.gateway.tenderly.co --match-path src/test/IntegrationTest.t.sol -vvvv --evm-version cancun - FOUNDRY_PROFILE=default forge test --fork-url https://mainnet.gateway.tenderly.co --match-path src/test/AlchemistETHVault.t.sol -vvvv --evm-version cancun ================================================ FILE: src/test/Transmuter.t.sol ================================================ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {StdCheats} from "forge-std/StdCheats.sol"; import {AlchemistV3} from "../AlchemistV3.sol"; import {AlEth} from "../external/AlEth.sol"; import {Transmuter} from "../Transmuter.sol"; import {StakingGraph} from "../libraries/StakingGraph.sol"; import {console} from "../../lib/forge-std/src/console.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "../interfaces/ITransmuter.sol"; import "../base/Errors.sol"; import "../base/TransmuterErrors.sol"; contract MockAlchemist { uint256 public constant FIXED_POINT_SCALAR = 1e18; uint256 public underlyingValue; uint256 public syntheticsIssued; address public myt; constructor(address _myt) { myt = _myt; } function setUnderlyingValue(uint256 amount) public { underlyingValue = amount; } function setSyntheticsIssued(uint256 amount) public { syntheticsIssued = amount; } function convertYieldTokensToUnderlying(uint256 amount) external pure returns (uint256) { return (amount * 2 * FIXED_POINT_SCALAR) / FIXED_POINT_SCALAR; } function convertUnderlyingTokensToYield(uint256 amount) public pure returns (uint256) { return amount * FIXED_POINT_SCALAR / (2 * FIXED_POINT_SCALAR); } function convertYieldTokensToDebt(uint256 amount) public pure returns (uint256) { return (amount * 2 * FIXED_POINT_SCALAR) / FIXED_POINT_SCALAR; } function convertDebtTokensToYield(uint256 amount) public pure returns (uint256) { return amount * FIXED_POINT_SCALAR / (2 * FIXED_POINT_SCALAR); } function redeem(uint256 underlying) external returns (uint256 sharesSent) { sharesSent = convertUnderlyingTokensToYield(underlying); IERC20(myt).transfer(msg.sender, sharesSent); } function totalDebt() external pure returns (uint256) { return type(uint256).max; } function totalSyntheticsIssued() external returns (uint256) { if (syntheticsIssued > 0) { return syntheticsIssued; } else { return type(uint256).max / 1e20; } } function reduceSyntheticsIssued(uint256 amount) external {} function setTransmuterTokenBalance(uint256 amount) external {} function yieldToken() external view returns (address) { return address(myt); } function underlyingToken() external view returns (address) { return address(myt); } function getTotalLockedUnderlyingValue() external view returns (uint256) { if (underlyingValue > 0) { return underlyingValue; } else { return type(uint256).max / 1e20; } } } contract MockMorphoV2Vault is ERC20 { // Simplied vault for testing // Shares are still treated as 18 decimal erc20 tokens // regardless of the underlying token decimals constructor() ERC20("Mock Myt Vault", "MMV") {} } contract TransmuterTest is Test { using StakingGraph for StakingGraph.Graph; AlEth public alETH; ERC20 public collateralToken; // morpho vault 2 shares AlEth public underlyingToken; Transmuter public transmuter; MockAlchemist public alchemist; StakingGraph.Graph private graph; MockMorphoV2Vault public vault; address public admin; address public curator; event TransmuterLog(string message, uint256 value); function setUp() public { alETH = new AlEth(); underlyingToken = new AlEth(); vault = new MockMorphoV2Vault(); collateralToken = ERC20(address(vault)); alchemist = new MockAlchemist(address(collateralToken)); transmuter = new Transmuter(ITransmuter.TransmuterInitializationParams(address(alETH), address(this), 5_256_000, 0, 0, 52_560_000 / 2)); transmuter.setAlchemist(address(alchemist)); transmuter.setDepositCap(uint256(type(int256).max)); deal(alchemist.myt(), address(alchemist), type(uint256).max); deal(address(alETH), address(0xbeef), type(uint256).max); vm.prank(address(alchemist)); IERC20(alchemist.myt()).approve(address(transmuter), type(uint256).max); vm.prank(address(0xbeef)); alETH.approve(address(transmuter), type(uint256).max); } function testSetAdmin() public { transmuter.setPendingAdmin(address(0xbeef)); vm.prank(address(0xbeef)); transmuter.acceptAdmin(); assertEq(address(0xbeef), transmuter.admin()); } function testSetAdminWrongAddress() public { transmuter.setPendingAdmin(address(0xbeef)); vm.startPrank(address(0xbeef123)); vm.expectRevert(); transmuter.acceptAdmin(); vm.stopPrank(); } function testURI() public { vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); Transmuter.StakingPosition memory position = transmuter.getPosition(1); transmuter.tokenURI(1); } function testSetTransmutaitonFeeTooHigh() public { vm.expectRevert(); transmuter.setTransmutationFee(10_001); } function testSetExitFeeTooHigh() public { vm.expectRevert(); transmuter.setExitFee(10_001); } function testSetTransmutationTime() public { transmuter.setTransmutationTime(20 days); assertEq(transmuter.timeToTransmute(), 20 days); } function testSetTransmutationTimeZeroReverts() public { vm.expectRevert(); transmuter.setTransmutationTime(0); } function testSetTransmutationTimeTooHighReverts() public { vm.expectRevert(); transmuter.setTransmutationTime(uint256(type(int256).max) + 1); } function testConstructorFeeReceiverZeroReverts() public { vm.expectRevert(); new Transmuter( ITransmuter.TransmuterInitializationParams(address(alETH), address(0), 5_256_000, 0, 0, 52_560_000 / 2) ); } function testConstructorTransmutationFeeTooHighReverts() public { vm.expectRevert(); new Transmuter( ITransmuter.TransmuterInitializationParams(address(alETH), address(this), 5_256_000, 10_001, 0, 52_560_000 / 2) ); } function testConstructorExitFeeTooHighReverts() public { vm.expectRevert(); new Transmuter( ITransmuter.TransmuterInitializationParams(address(alETH), address(this), 5_256_000, 0, 10_001, 52_560_000 / 2) ); } function testConstructorTransmutationTimeZeroReverts() public { vm.expectRevert(); new Transmuter(ITransmuter.TransmuterInitializationParams(address(alETH), address(this), 0, 0, 0, 52_560_000 / 2)); } function testConstructorTransmutationTimeTooHighReverts() public { vm.expectRevert(); new Transmuter( ITransmuter.TransmuterInitializationParams( address(alETH), address(this), uint256(type(int256).max) + 1, 0, 0, 52_560_000 / 2 ) ); } function testCreateRedemption() public { vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); Transmuter.StakingPosition memory position = transmuter.getPosition(1); assertEq(position.amount, 100e18); assertEq(transmuter.totalLocked(), 100e18); } function testCreateRedemptionForRecipient() public { address recipient = address(0xcafe); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, recipient); Transmuter.StakingPosition memory position = transmuter.getPosition(1); assertEq(position.amount, 100e18); assertEq(transmuter.ownerOf(1), recipient); assertEq(transmuter.totalLocked(), 100e18); } function testCreateRedemptionTooLarge() public { vm.startPrank(address(0xbeef)); vm.expectRevert(DepositCapReached.selector); transmuter.createRedemption(uint256(type(int256).max) + 1, address(0xbeef)); vm.stopPrank(); } function testFuzzCreateRedemption(uint256 amount) public { vm.assume(amount > 0); vm.assume(amount < uint256(type(int256).max) / 1e50); vm.prank(address(0xbeef)); transmuter.createRedemption(amount, address(0xbeef)); Transmuter.StakingPosition memory position = transmuter.getPosition(1); assertEq(position.amount, amount); assertEq(transmuter.totalLocked(), amount); } function testCreateRedemptionNoTokens() public { vm.expectRevert(DepositZeroAmount.selector); transmuter.createRedemption(0, address(this)); } function testCreateRedemptionZeroRecipientReverts() public { vm.prank(address(0xbeef)); vm.expectRevert(IllegalArgument.selector); transmuter.createRedemption(100e18, address(0)); } function testCreateRedemptionDepositCapReached() public { transmuter.setDepositCap(90e18); vm.expectRevert(DepositCapReached.selector); transmuter.createRedemption(100e18, address(this)); } function testCreateRedemptionDepositCapReachedSynthetic() public { transmuter.setDepositCap(110e18); alchemist.setSyntheticsIssued(90e18); vm.expectRevert(DepositCapReached.selector); transmuter.createRedemption(100e18, address(this)); } function testClaimRedemptionNoPosition() public { vm.expectRevert(PositionNotFound.selector); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); } function test_target_ClaimRedemption() public { deal(address(collateralToken), address(transmuter), uint256(type(int256).max) / 1e20); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.roll(block.number + 5_256_000); assertEq(collateralToken.balanceOf(address(0xbeef)), 0); assertEq(alETH.balanceOf(address(transmuter)), 100e18); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); assertEq(collateralToken.balanceOf(address(0xbeef)), alchemist.convertUnderlyingTokensToYield(100e18)); assertEq(alETH.balanceOf(address(transmuter)), 0); } function testClaimRedemptionBadDebt() public { deal(address(collateralToken), address(transmuter), 200e18); alchemist.setSyntheticsIssued(1200e18); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.roll(block.number + 5_256_000); assertEq(collateralToken.balanceOf(address(0xbeef)), 0); assertEq(alETH.balanceOf(address(transmuter)), 100e18); alchemist.setUnderlyingValue(200e18); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); assertEq(collateralToken.balanceOf(address(0xbeef)), alchemist.convertUnderlyingTokensToYield(100e18) / 2); assertEq(alETH.balanceOf(address(transmuter)), 0); } function testClaimRedemptionNotOwner() public { deal(address(collateralToken), address(transmuter), uint256(type(int256).max) / 1e20); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.roll(block.number + 5_256_000); assertEq(collateralToken.balanceOf(address(0xbeef)), 0); assertEq(alETH.balanceOf(address(transmuter)), 100e18); vm.startPrank(address(0xbeef123)); vm.expectRevert(CallerNotOwner.selector); transmuter.claimRedemption(1); vm.stopPrank(); } function testClaimRedemptionFromAlchemist() public { vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.roll(block.number + 5_256_000); assertEq(collateralToken.balanceOf(address(0xbeef)), 0); assertEq(alETH.balanceOf(address(transmuter)), 100e18); uint256 startingBalance = collateralToken.balanceOf(address(alchemist)); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); assertEq(collateralToken.balanceOf(address(0xbeef)), alchemist.convertUnderlyingTokensToYield(100e18)); assertEq(alETH.balanceOf(address(transmuter)), 0); assertEq(collateralToken.balanceOf(address(alchemist)), startingBalance - alchemist.convertUnderlyingTokensToYield(100e18)); } function testFuzzClaimRedemption(uint256 amount) public { deal(address(collateralToken), address(transmuter), uint256(type(int256).max) / 1e20); vm.assume(amount > 0); vm.assume(amount < uint256(type(int256).max) / 1e50); vm.prank(address(0xbeef)); transmuter.createRedemption(amount, address(0xbeef)); vm.roll(block.number + 5_256_000); assertEq(collateralToken.balanceOf(address(0xbeef)), 0); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); assertEq(collateralToken.balanceOf(address(0xbeef)), alchemist.convertUnderlyingTokensToYield(amount)); assertEq(alETH.balanceOf(address(transmuter)), 0); } function testClaimRedemptionWithTransmuterFee() public { deal(address(collateralToken), address(transmuter), uint256(type(int256).max) / 1e20); // 1% fee transmuter.setTransmutationFee(100); transmuter.setProtocolFeeReceiver(address(this)); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.roll(block.number + 5_256_000); assertEq(collateralToken.balanceOf(address(0xbeef)), 0); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); assertEq(collateralToken.balanceOf(address(0xbeef)), alchemist.convertUnderlyingTokensToYield(100e18) * 9900 / 10_000); assertEq(collateralToken.balanceOf(address(this)), alchemist.convertUnderlyingTokensToYield(100e18) * 100 / 10_000); assertEq(alETH.balanceOf(address(transmuter)), 0); } function testFuzzClaimRedemptionWithTransmuterFee(uint256 fee) public { deal(address(collateralToken), address(transmuter), uint256(type(int256).max) / 1e20); vm.assume(fee <= 10_000); transmuter.setTransmutationFee(fee); transmuter.setProtocolFeeReceiver(address(this)); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.roll(block.number + 5_256_000); assertEq(collateralToken.balanceOf(address(0xbeef)), 0); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); assertEq(collateralToken.balanceOf(address(0xbeef)), alchemist.convertUnderlyingTokensToYield(100e18) * (10_000 - fee) / 10_000); assertEq(collateralToken.balanceOf(address(this)), alchemist.convertUnderlyingTokensToYield(100e18) * fee / 10_000); assertEq(alETH.balanceOf(address(transmuter)), 0); } function testClaimRedemptionPremature() public { deal(address(collateralToken), address(transmuter), uint256(type(int256).max) / 1e20); uint256 balanceBefore = alETH.balanceOf(address(0xbeef)); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); // uint256 query = transmuter.queryGraph(block.number + 1, block.number + 5256000); // assertEq(query, 100e18); vm.roll(block.number + (5_256_000 / 2)); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); uint256 balanceAfter = alETH.balanceOf(address(0xbeef)); assertEq(collateralToken.balanceOf(address(0xbeef)), alchemist.convertUnderlyingTokensToYield(50e18)); assertEq(balanceBefore - balanceAfter, 50e18); assertEq(alETH.balanceOf(address(transmuter)), 0); // Make sure remaining graph is cleared uint256 query2 = transmuter.queryGraph(block.number + 1, block.number + (5_256_000 / 2)); assertApproxEqAbs(query2, 0, 1); } function testFuzzClaimRedemptionPremature(uint256 time) public { vm.assume(time > 0); vm.assume(time < 5_256_000); uint256 balanceBefore = alETH.balanceOf(address(0xbeef)); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); uint256 query = transmuter.queryGraph(block.number + 1, block.number + 5_256_000); assertEq(query, 100e18); vm.roll(block.number + time); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); uint256 balanceAfter = alETH.balanceOf(address(0xbeef)); assertApproxEqAbs(collateralToken.balanceOf(address(0xbeef)), alchemist.convertUnderlyingTokensToYield((100e18 * time) / 5_256_000), 1); assertApproxEqAbs(balanceBefore - balanceAfter, (100e18 * time) / 5_256_000, 1); assertEq(alETH.balanceOf(address(transmuter)), 0); // Make sure remaining graph is cleared // uint256 query2 = transmuter.queryGraph(block.number + 1, block.number + (5256000 - time)); // assertApproxEqAbs(query2, 0, 1); } function testClaimRedemptionPrematureWithFee() public { // 1% transmuter.setExitFee(100); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); uint256 balanceBefore = alETH.balanceOf(address(0xbeef)); vm.roll(block.number + (5_256_000 / 2)); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); uint256 balanceAfter = alETH.balanceOf(address(0xbeef)); assertEq(collateralToken.balanceOf(address(0xbeef)), alchemist.convertUnderlyingTokensToYield(50e18)); assertEq(balanceAfter - balanceBefore, 50e18 - (50e18 * 100 / 10_000)); assertEq(alETH.balanceOf(address(transmuter)), 0); assertEq(alETH.balanceOf(address(this)), 50e18 * 100 / 10_000); } function testQueryGraph() external { vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.roll(block.number + 5_256_000); uint256 treeQuery = transmuter.queryGraph(block.number - 5_256_000 + 1, block.number); assertApproxEqAbs(treeQuery, 100e18, 1); } function testQueryGraphPartial() external { vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.roll(block.number + (5_256_000 / 2)); uint256 treeQuery = transmuter.queryGraph(block.number - (5_256_000 / 2) + 1, block.number); assertApproxEqAbs(treeQuery, 50e18, 1); } function testClaimRedemption_division() public { deal(address(collateralToken), address(transmuter), uint256(type(int256).max) / 1e20); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.roll(block.number + 5_256_000); // Mature the staking position alchemist.setUnderlyingValue(0); // Simulate all users exiting with 0 underlying left emit log_named_uint("total token there", alchemist.getTotalLockedUnderlyingValue()); vm.prank(address(0xbeef)); transmuter.claimRedemption(1); } function test_delta_overflow() public { int256 amount = (2 ** 111) - 1; uint32 start = 1000; uint32 duration = 10; graph.addStake(amount / 10, start, duration); int256 result = graph.queryStake(start, start + duration); assertApproxEqAbs(result, amount, 10); } function test_negative_stake() public { // Add stake of 100 wei from block 25 to block 28 graph.addStake(100, 25, 3); assertEq(graph.size, 32, "Graph size should be 32 after stake"); // The current block is now greater than the last initialized block in the fenwick tree // Check that the graph queries only to its max size and does not return negative number int256 result = graph.queryStake(63, 63); assertEq(result, 0); } function test_queryStake_OutOfBoundsStartBeyondDoubleSize_ReturnsZero() public { // Build a small tree and then query far beyond its right boundary. // Without clamping the left prefix index, Fenwick traversal can miss the terminal node // and return a non-zero historical amount for an empty range. graph.addStake(100, 25, 3); assertEq(graph.size, 32, "Graph size should be 32 after stake"); int256 result = graph.queryStake(70, 70); assertEq(result, 0); } function testStakingGraph_Bounds_AcceptsDeltaMax() public { int256 deltaMax = (int256(1) << 111) - 1; this._addStakeExternal(deltaMax, 1, 1); int256 result = this._queryStakeExternal(1, 2); assertEq(result, deltaMax); } function testStakingGraph_Bounds_AcceptsDeltaMin() public { int256 deltaMin = -(int256(1) << 111); this._addStakeExternal(deltaMin, 1, 1); int256 result = this._queryStakeExternal(1, 2); assertEq(result, deltaMin); } function testStakingGraph_Bounds_RevertsAboveDeltaMax() public { // DELTA_BITS = 112 => max signed delta is 2^111 - 1 // so 2^111 must revert. int256 aboveMax = (int256(1) << 111); vm.expectRevert(); // hits require(amount <= DELTA_MAX && amount >= DELTA_MIN) this._addStakeExternal(aboveMax, 1, 1); } function testStakingGraph_Bounds_RevertsBelowDeltaMin() public { // min signed delta is -2^111, so -2^111 - 1 must revert. int256 deltaMin = -(int256(1) << 111); int256 belowMin = deltaMin - 1; vm.expectRevert(); this._addStakeExternal(belowMin, 1, 1); } function testStakingGraph_Bounds_MaxDelta_WithLargeStart_DoesNotRevert() public { int256 deltaMax = (int256(1) << 111) - 1; // GRAPH_MAX is intended to be 2^32 for the 112/144 split design. // start must satisfy start < GRAPH_MAX - 1, so pick something comfortably < 2^32 - 1. uint256 start = uint256(type(uint32).max) - 3; // ~ 2^32 - 4 uint256 duration = 1; // Should NOT revert graph.addStake(deltaMax, start, duration); int256 result = graph.queryStake(start, start + duration); assertEq(result, deltaMax, "deltaMax should work at large start without corruption/revert"); } // External wrappers so vm.expectRevert can catch internal/library reverts. function _addStakeExternal(int256 amount, uint256 start, uint256 duration) external { graph.addStake(amount, start, duration); } function _queryStakeExternal(uint256 start, uint256 end) external view returns (int256) { return graph.queryStake(start, end); } function testPokeMatured_FreesDepositCap() public { // cap only allows 100e18 in-flight transmuter.setDepositCap(100e18); vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); // should fail before poke (still in-flight, not matured) vm.prank(address(0xbeef)); vm.expectRevert(DepositCapReached.selector); transmuter.createRedemption(1e18, address(0xbeef)); // mature it vm.roll(block.number + transmuter.timeToTransmute()); // poke by anyone transmuter.pokeMatured(1); // now cap should be freed, deposit succeeds vm.prank(address(0xbeef)); transmuter.createRedemption(1e18, address(0xbeef)); } function testPokeMatured_RevertsIfNotMatured() public { vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.expectRevert(); // PositionNotMatured (custom error) transmuter.pokeMatured(1); } function testPokeMatured_RevertsIfAlreadyPoked() public { vm.prank(address(0xbeef)); transmuter.createRedemption(100e18, address(0xbeef)); vm.roll(block.number + transmuter.timeToTransmute()); transmuter.pokeMatured(1); vm.expectRevert(); // PositionAlreadyPoked transmuter.pokeMatured(1); } } ================================================ FILE: src/test/ZeroXSwapVerifier.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import {Test, console} from "forge-std/Test.sol"; import {ZeroXSwapVerifier} from "../utils/ZeroXSwapVerifier.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {TestERC20} from "./mocks/TestERC20.sol"; contract ZeroXSwapVerifierTest is Test { TestERC20 internal token; address constant owner = address(1); address constant spender = address(2); bytes4 private constant EXECUTE_SELECTOR = 0xcf71ff4f; // execute(SlippageAndActions,bytes[]) bytes4 private constant EXECUTE_META_TXN_SELECTOR = 0x0476baab; // executeMetaTxn(SlippageAndActions,bytes[],address,bytes) // Action selectors for different swap types bytes4 private constant BASIC_SELL_TO_POOL = 0x5228831d; bytes4 private constant UNISWAPV3_VIP = 0x9ebf8e8d; bytes4 private constant RFQ_VIP = 0x0dfeb419; bytes4 private constant METATXN_VIP = 0xc1fb425e; bytes4 private constant CURVE_TRICRYPTO_VIP = 0x103b48be; bytes4 private constant UNISWAPV4_VIP = 0x38c9c147; bytes4 private constant TRANSFER_FROM = 0x8d68a156; bytes4 private constant NATIVE_DEPOSIT = 0xc876d21d; bytes4 private constant SELL_TO_LIQUIDITY_PROVIDER = 0xf1e0a1c3; bytes4 private constant DODOV1_VIP = 0x40a07c6c; bytes4 private constant VELODROME_V2_VIP = 0xb8df6d4d; bytes4 private constant DODOV2_VIP = 0xd92aadfb; function setUp() public { token = new TestERC20(1000e18, 18); deal(address(token), owner, 100e18); deal(address(token), spender, 100e18); } // Test basic sell to pool with valid slippage function testVerifyBasicSellToPool() public { bytes memory _calldata = _buildBasicSellToPoolCalldata(token, spender, 500); // 500 bps = 5% slippage bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); assertTrue(verified); } // Test Uniswap V3 VIP with valid slippage function testVerifyUniswapV3VIP() public { bytes memory _calldata = _buildUniswapV3VIPCalldata(token, spender, 300); // 300 bps = 3% slippage bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); assertTrue(verified); } // Test RFQ VIP function testVerifyRFQVIP() public { bytes memory _calldata = _buildRFQVIPCalldata(token, spender); bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); assertTrue(verified); } // Test transfer from function testVerifyTransferFrom() public { bytes memory _calldata = _buildTransferFromCalldata(token, spender); bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); assertTrue(verified); } // Test sell to liquidity provider function testVerifySellToLiquidityProvider() public { bytes memory _calldata = _buildSellToLiquidityProviderCalldata(token, spender); bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); assertTrue(verified); } // Test Velodrome V2 VIP with valid slippage function testVerifyVelodromeV2VIP() public { bytes memory _calldata = _buildVelodromeV2VIPCalldata(token, spender, 200); // 200 bps = 2% slippage bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); assertTrue(verified); } // Test unsupported action function testVerifyUnsupportedAction() public { bytes memory _calldata = _buildUnsupportedActionCalldata(); vm.expectRevert(bytes("IAC")); bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); } // Test invalid selector function testVerifyInvalidSelector() public { bytes memory _calldata = _buildInvalidSelectorCalldata(); vm.expectRevert(bytes("IS")); bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); } // Test token mismatch function testVerifyTokenMismatch() public { TestERC20 anotherToken = new TestERC20(1000e18, 18); bytes memory _calldata = _buildBasicSellToPoolCalldata(token, spender, 500); vm.expectRevert(bytes("IT")); bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(anotherToken), 1000 // 1000 bps = 10% max slippage ); } // Test slippage too high function testVerifySlippageTooHigh() public { bytes memory _calldata = _buildBasicSellToPoolCalldata(token, spender, 1500); // 1500 bps = 15% slippage vm.expectRevert(bytes("Slippage too high")); bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); } // Test empty calldata function testVerifyEmptyCalldata() public { bytes memory _calldata = new bytes(0); bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); assertFalse(verified); } // Test calldata too short function testVerifyCalldataTooShort() public { bytes memory _calldata = new bytes(3); bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); assertFalse(verified); } // Test executeMetaTxn selector function testVerifyExecuteMetaTxn() public { bytes memory _calldata = _buildExecuteMetaTxnCalldata(token, spender, 500); // 500 bps = 5% slippage bool verified = ZeroXSwapVerifier.verifySwapCalldata( _calldata, owner, address(token), 1000 // 1000 bps = 10% max slippage ); assertTrue(verified); } // Helper functions to build calldata function _buildBasicSellToPoolCalldata(TestERC20 _token, address recipient, uint256 bps) internal pure returns (bytes memory) { bytes memory action = abi.encodeWithSelector( BASIC_SELL_TO_POOL, address(_token), bps, // bps recipient, 0, "" ); ZeroXSwapVerifier.SlippageAndActions memory saa = ZeroXSwapVerifier.SlippageAndActions({ recipient: recipient, buyToken: address(0), // Not used in this test minAmountOut: 0, actions: new bytes[](1) }); saa.actions[0] = action; return abi.encodeWithSelector(EXECUTE_SELECTOR, saa, new bytes[](0)); } function _buildUniswapV3VIPCalldata(TestERC20 _token, address recipient, uint256 bps) internal pure returns (bytes memory) { bytes memory fills = abi.encode(address(_token), 100e18); bytes memory action = abi.encodeWithSelector( UNISWAPV3_VIP, recipient, bps, // bps 3000, // feeOrTickSpacing false, // feeOnTransfer fills ); ZeroXSwapVerifier.SlippageAndActions memory saa = ZeroXSwapVerifier.SlippageAndActions({ recipient: recipient, buyToken: address(0), minAmountOut: 0, actions: new bytes[](1) }); saa.actions[0] = action; return abi.encodeWithSelector(EXECUTE_SELECTOR, saa, new bytes[](0)); } function _buildRFQVIPCalldata(TestERC20 _token, address recipient) internal pure returns (bytes memory) { bytes memory fillData = abi.encode(address(_token), 100e18); bytes memory action = abi.encodeWithSelector( RFQ_VIP, 0, // info fillData ); ZeroXSwapVerifier.SlippageAndActions memory saa = ZeroXSwapVerifier.SlippageAndActions({ recipient: recipient, buyToken: address(0), minAmountOut: 0, actions: new bytes[](1) }); saa.actions[0] = action; return abi.encodeWithSelector(EXECUTE_SELECTOR, saa, new bytes[](0)); } function _buildTransferFromCalldata(TestERC20 _token, address recipient) internal pure returns (bytes memory) { bytes memory action = abi.encodeWithSelector( TRANSFER_FROM, address(_token), owner, recipient, 100e18 ); ZeroXSwapVerifier.SlippageAndActions memory saa = ZeroXSwapVerifier.SlippageAndActions({ recipient: recipient, buyToken: address(0), minAmountOut: 0, actions: new bytes[](1) }); saa.actions[0] = action; return abi.encodeWithSelector(EXECUTE_SELECTOR, saa, new bytes[](0)); } function _buildSellToLiquidityProviderCalldata(TestERC20 _token, address recipient) internal pure returns (bytes memory) { bytes memory action = abi.encodeWithSelector( SELL_TO_LIQUIDITY_PROVIDER, address(_token), recipient, 100e18, 0, "" ); ZeroXSwapVerifier.SlippageAndActions memory saa = ZeroXSwapVerifier.SlippageAndActions({ recipient: recipient, buyToken: address(0), minAmountOut: 0, actions: new bytes[](1) }); saa.actions[0] = action; return abi.encodeWithSelector(EXECUTE_SELECTOR, saa, new bytes[](0)); } function _buildVelodromeV2VIPCalldata(TestERC20 _token, address recipient, uint256 bps) internal pure returns (bytes memory) { bytes memory action = abi.encodeWithSelector( VELODROME_V2_VIP, address(_token), bps, // bps false, // useEth 0, // minAmountOut 0, // deadline "" ); ZeroXSwapVerifier.SlippageAndActions memory saa = ZeroXSwapVerifier.SlippageAndActions({ recipient: recipient, buyToken: address(0), minAmountOut: 0, actions: new bytes[](1) }); saa.actions[0] = action; return abi.encodeWithSelector(EXECUTE_SELECTOR, saa, new bytes[](0)); } function _buildUnsupportedActionCalldata() internal pure returns (bytes memory) { bytes memory action = abi.encodeWithSelector(0x12345678, address(0), 0); ZeroXSwapVerifier.SlippageAndActions memory saa = ZeroXSwapVerifier.SlippageAndActions({ recipient: address(0), buyToken: address(0), minAmountOut: 0, actions: new bytes[](1) }); saa.actions[0] = action; return abi.encodeWithSelector(EXECUTE_SELECTOR, saa, new bytes[](0)); } function _buildInvalidSelectorCalldata() internal pure returns (bytes memory) { return abi.encodePacked(bytes4(0xffffffff)); } function _buildExecuteMetaTxnCalldata(TestERC20 _token, address recipient, uint256 bps) internal pure returns (bytes memory) { bytes memory action = abi.encodeWithSelector( BASIC_SELL_TO_POOL, address(_token), bps, // bps recipient, 0, "" ); ZeroXSwapVerifier.SlippageAndActions memory saa = ZeroXSwapVerifier.SlippageAndActions({ recipient: recipient, buyToken: address(0), minAmountOut: 0, actions: new bytes[](1) }); saa.actions[0] = action; return abi.encodeWithSelector( EXECUTE_META_TXN_SELECTOR, saa, new bytes[](0), address(0), "" ); } } ================================================ FILE: src/test/base/BaseStrategyMulti.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {IAllocator} from "../../interfaces/IAllocator.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {TokenUtils} from "../../libraries/TokenUtils.sol"; import {RevertContext} from "./StrategyTypes.sol"; import {StrategyOps} from "./StrategyOps.sol"; import "forge-std/console.sol"; /// @notice Multi-step/fuzz/loop-heavy base tests shared by strategy suites. /// @dev Keep stochastic, iterative, and invariant-like tests here; prefer allowlist-aware helper paths. abstract contract BaseStrategyMulti is StrategyOps { // Fuzz test: Multiple random allocations and deallocations function test_fuzz_multiple_allocations_deallocations(uint256[] calldata amounts, uint8[] calldata actions) public { uint256 numOps = bound(amounts.length, 1, 10); uint256 maxIterations = numOps < amounts.length ? numOps : amounts.length; vm.startPrank(admin); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); for (uint256 i = 0; i < maxIterations; i++) { bool isAllocate = i % 2 == 0; if (isAllocate) { (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); if (maxAlloc > 0 && maxAlloc >= minAlloc) { uint256 amount = bound(amounts[i], minAlloc, maxAlloc); if (amount > 0) { _prepareVaultAssets(amount); _allocateOrSkipWhitelisted(amount, RevertContext.FuzzAllocate); } } } else { uint256 currentAllocation = IVaultV2(vault).allocation(allocationId); uint256 minAlloc = _getMinAllocateAmount(); if (currentAllocation >= minAlloc) { uint256 maxDealloc = currentAllocation; uint256 amount = bound(amounts[i], minAlloc, maxDealloc); uint256 target = _effectiveDeallocateAmount(amount); if (target > 0) { _beforePreviewWithdraw(target); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(target); if (deallocPreview > 0) _deallocateOrSkipWhitelisted(deallocPreview, RevertContext.FuzzDeallocate); } } } uint256 timeWarp = bound(uint256(keccak256(abi.encodePacked(i, amounts, actions))), 1, 30 days); _warpWithHook(timeWarp); } uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); uint256 finalAllocation = IVaultV2(vault).allocation(allocationId); assertGe(finalRealAssets, 0, "Real assets should be non-negative"); assertGe(finalAllocation, 0, "Allocation should be non-negative"); vm.stopPrank(); } // End-to-end test: Full lifecycle with time accumulation function test_fuzz_full_lifecycle_with_time_accumulation( uint256 initialAlloc, uint256 allocIncrease, uint256 deallocationPercent ) public { bytes32 allocationId = IMYTStrategy(strategy).adapterId(); // Use handler for allocations - it handles cap validation and bounding internally // Just bound inputs to reasonable ranges for the lifecycle test deallocationPercent = bound(deallocationPercent, 1, 90); // 1-90% // Initial allocation using handler handler.allocate(initialAlloc); initialAlloc = handler.ghost_totalAllocated(); // Check if allocation succeeded (handler returns early if caps don't allow) uint256 realAssetsInitial = IMYTStrategy(strategy).realAssets(); if (realAssetsInitial == 0) return; // Warp 7 days _warpWithHook(7 days); // Increase allocation using handler handler.allocate(allocIncrease); allocIncrease = handler.ghost_totalAllocated() - initialAlloc; uint256 realAssetsAfterIncrease = IMYTStrategy(strategy).realAssets(); assertGe(realAssetsAfterIncrease, realAssetsInitial, "Real assets should not decrease after increase"); // Warp 14 days _warpWithHook(14 days); vm.startPrank(admin); // Partial deallocation uint256 totalAllocation = IVaultV2(vault).allocation(allocationId); uint256 deallocAmount = (totalAllocation * deallocationPercent) / 100; bool partialOk = _deallocateEstimate(deallocAmount, RevertContext.FuzzDeallocate); if (!partialOk) { vm.stopPrank(); return; } uint256 realAssetsAfterDealloc = IMYTStrategy(strategy).realAssets(); assertLt(realAssetsAfterDealloc, realAssetsAfterIncrease, "Real assets should decrease after deallocation"); // Warp 30 days _warpWithHook(30 days); // Final deallocation of remaining. Some strategies cap each deallocation step // (e.g. due to adapter accounting constraints), so iterate a few times. for (uint256 i = 0; i < 256; i++) { uint256 before = IMYTStrategy(strategy).realAssets(); if (before == 0) break; bool ok = _deallocateFromRealAssetsEstimate(RevertContext.FuzzDeallocate); if (!ok) break; uint256 after_ = IMYTStrategy(strategy).realAssets(); if (after_ >= before) break; } // Verify final state // Allow tolerance for slippage/rounding (up to 2% of vault initial deposit) assertApproxEqAbs( IMYTStrategy(strategy).realAssets(), 0, 2 * testConfig.vaultInitialDeposit / 100, "All real assets should be deallocated" ); assertApproxEqAbs(IVaultV2(vault).allocation(allocationId), 0, 2 * 10 ** testConfig.decimals); vm.stopPrank(); } /// @notice Fuzz test: Real assets should always be non-negative after any operation function test_fuzz_real_assets_non_negative(uint256[] calldata amounts, uint8[] calldata operations) public { vm.startPrank(admin); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); // Use operations array length for number of operations, but bound it uint256 numOps = bound(operations.length, 1, 20); for (uint256 i = 0; i < numOps; i++) { // Check array bounds before accessing amounts to prevent panic uint256 amount = i < amounts.length ? bound(amounts[i], 0, 1e6 * 10 ** testConfig.decimals) : 0; uint8 op = i < operations.length ? operations[i] % 3 : uint8(i % 3); if (op == 0) { // Allocate - bounded by effective cap uint256 effectiveCap = _getEffectiveCapHeadroom(allocationId); uint256 minAlloc = _getMinAllocateAmount(); if (effectiveCap >= minAlloc) { amount = bound(amount, minAlloc, effectiveCap); _prepareVaultAssets(amount); _allocateOrSkipWhitelisted(amount, RevertContext.FuzzAllocate); } } else if (op == 1) { // Deallocate uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); amount = bound(amount, 0, currentRealAssets); if (amount > 0) { uint256 target = _effectiveDeallocateAmount(amount); if (target == 0) continue; uint256 preview = IMYTStrategy(strategy).previewAdjustedWithdraw(target); if (preview > 0) _deallocateOrSkipWhitelisted(preview, RevertContext.FuzzDeallocate); } } else { // Time warp _warpWithHook(bound(amount, 0, 365 days)); } } vm.stopPrank(); } /// @notice Fuzz test: Allocation increases (or maintains) real assets function test_fuzz_allocation_increases_real_assets(uint256 amountToAllocate) public { vm.startPrank(admin); (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); if (maxAlloc == 0 || maxAlloc < minAlloc) return; amountToAllocate = bound(amountToAllocate, minAlloc, maxAlloc); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 realAssetsBefore = IMYTStrategy(strategy).realAssets(); uint256 allocationBefore = IVaultV2(vault).allocation(allocationId); _prepareVaultAssets(amountToAllocate); bool allocated = _allocateOrSkipWhitelisted(amountToAllocate, RevertContext.FuzzAllocate); if (!allocated) { vm.stopPrank(); return; } uint256 realAssetsAfter = IMYTStrategy(strategy).realAssets(); uint256 allocationAfter = IVaultV2(vault).allocation(allocationId); // Invariant: Real assets should increase (or stay same if rounding) assertGe(realAssetsAfter, realAssetsBefore, "Invariant violation: Real assets should not decrease on allocation"); // Invariant: Allocation should increase by at least amountToAllocate minus fees/slippage // Allow for small tolerance (1%) for protocol fees uint256 minExpectedIncrease = amountToAllocate * 99 / 100; assertGe( allocationAfter - allocationBefore, minExpectedIncrease, "Invariant violation: Allocation should increase appropriately" ); vm.stopPrank(); } /// @notice Fuzz test: Deallocation decreases real assets function test_fuzz_deallocation_decreases_real_assets(uint256 amountToAllocate, uint256 fractionToDeallocate) public { vm.startPrank(admin); (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); if (maxAlloc == 0 || maxAlloc < minAlloc) return; amountToAllocate = bound(amountToAllocate, minAlloc, maxAlloc); fractionToDeallocate = bound(fractionToDeallocate, 1, 100); // 1-100% bytes32 allocationId = IMYTStrategy(strategy).adapterId(); _prepareVaultAssets(amountToAllocate); bool allocated = _allocateOrSkipWhitelisted(amountToAllocate, RevertContext.FuzzAllocate); if (!allocated) { vm.stopPrank(); return; } uint256 realAssetsBefore = IMYTStrategy(strategy).realAssets(); uint256 allocationBefore = IVaultV2(vault).allocation(allocationId); // Deallocate uint256 amountToDeallocate = realAssetsBefore * fractionToDeallocate / 100; uint256 targetDeallocate = _effectiveDeallocateAmount(amountToDeallocate); if (targetDeallocate == 0) { vm.stopPrank(); return; } uint256 preview = IMYTStrategy(strategy).previewAdjustedWithdraw(targetDeallocate); if (preview > 0) { bool deallocated = _deallocateOrSkipWhitelisted(preview, RevertContext.FuzzDeallocate); if (!deallocated) { vm.stopPrank(); return; } } uint256 realAssetsAfter = IMYTStrategy(strategy).realAssets(); uint256 allocationAfter = IVaultV2(vault).allocation(allocationId); // Invariant: Real assets should decrease (or stay same for zero deallocation) assertLe(realAssetsAfter, realAssetsBefore, "Invariant violation: Real assets should not increase on deallocation"); // Invariant: Allocation should decrease by at least previewed amount minus tolerance // Allow for small tolerance (1%) for protocol fees uint256 expectedDecrease = preview * 99 / 100; uint256 actualDecrease = allocationBefore > allocationAfter ? allocationBefore - allocationAfter : 0; assertGe(actualDecrease, expectedDecrease, "Invariant violation: Allocation should decrease appropriately"); // After full deallocation (or nearly full), real assets should be close to zero if (fractionToDeallocate >= 99) { uint256 requested = realAssetsBefore * fractionToDeallocate / 100; uint256 effective = _effectiveDeallocateAmount(requested); // Only enforce near-zero postcondition when the strategy hook allows near-full // deallocation in a single step. if (effective * 100 < realAssetsBefore * 99) { vm.stopPrank(); return; } assertLe(realAssetsAfter, realAssetsBefore / 10, "Invariant violation: Real assets should be near zero after large deallocation"); } vm.stopPrank(); } /// @notice Fuzz test: Cannot allocate more than vault's available balance function test_fuzz_cannot_allocate_more_than_available(uint256 amountToAllocate) public { vm.startPrank(admin); uint256 vaultTotalAssets = IVaultV2(vault).totalAssets(); uint256 minAlloc = _getMinAllocateAmount(); // Bound from minAlloc to allow testing both within and exceeding available balance uint256 minBound = minAlloc < vaultTotalAssets * 100 ? minAlloc : vaultTotalAssets * 100; amountToAllocate = bound(amountToAllocate, minBound, vaultTotalAssets * 100); uint256 realAssetsBefore = IMYTStrategy(strategy).realAssets(); // Give vault its current total assets (plus some buffer for testing) deal(testConfig.vaultAsset, vault, vaultTotalAssets * 2); // If amount exceeds vault's available balance, expect revert // Available balance = vaultTotalAssets * 2 (what we just dealt) - current allocation bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 currentAllocation = IVaultV2(vault).allocation(allocationId); uint256 availableBalance = vaultTotalAssets * 2 - currentAllocation; // Calculate effective cap using helper (uses vault.totalAssets() directly) uint256 effectiveCap = _getEffectiveCapHeadroom(allocationId); if (amountToAllocate > availableBalance || amountToAllocate > effectiveCap) { vm.expectRevert(); IAllocator(allocator).allocate(strategy, amountToAllocate); } else { if (amountToAllocate > 0) { IAllocator(allocator).allocate(strategy, amountToAllocate); } uint256 realAssetsAfter = IMYTStrategy(strategy).realAssets(); assertLe( realAssetsAfter, availableBalance + realAssetsBefore, "Invariant violation: Allocated more than vault assets available" ); assertGe(realAssetsAfter, 0, "Invariant violation: Real assets negative"); } vm.stopPrank(); } /// @notice Fuzz test: Repeated small operations maintain invariants function test_fuzz_repeated_operations_stability(uint256 baseAmount, uint8 numOperations) public { vm.startPrank(admin); (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); if (maxAlloc == 0 || maxAlloc < minAlloc) return; baseAmount = bound(baseAmount, minAlloc, maxAlloc); numOperations = uint8(bound(numOperations, 5, 50)); uint256 realAssetsHistoryMin = type(uint256).max; uint256 realAssetsHistoryMax = 0; uint256 currentRealAssets = 0; for (uint8 i = 0; i < numOperations; i++) { bool isAllocate = i % 2 == 0; uint256 amount = baseAmount * (1 + (i % 5)) / 5; if (isAllocate) { (, uint256 currentMax) = _getAllocationBounds(); uint256 minAllocateAmount = _getMinAllocateAmount(); if (currentMax >= minAllocateAmount) { amount = bound(amount, minAllocateAmount, currentMax); _prepareVaultAssets(amount); _allocateOrSkipWhitelisted(amount, RevertContext.FuzzAllocate); } } else { currentRealAssets = IMYTStrategy(strategy).realAssets(); if (currentRealAssets > 0) { uint256 deallocationAmount = currentRealAssets > amount ? amount : currentRealAssets; uint256 target = _effectiveDeallocateAmount(deallocationAmount); if (target == 0) continue; uint256 preview = IMYTStrategy(strategy).previewAdjustedWithdraw(target); if (preview > 0) { _deallocateOrSkipWhitelisted(preview, RevertContext.FuzzDeallocate); } } } currentRealAssets = IMYTStrategy(strategy).realAssets(); if (currentRealAssets < realAssetsHistoryMin) { realAssetsHistoryMin = currentRealAssets; } if (currentRealAssets > realAssetsHistoryMax) { realAssetsHistoryMax = currentRealAssets; } } assertLe(realAssetsHistoryMax, testConfig.absoluteCap, "Invariant violation: Real assets exceeded cap"); vm.stopPrank(); } /// @notice Fuzz test: Time warps don't negatively affect real assets (unless strategy has negative yield) function test_fuzz_time_warp_stability(uint256 initialAlloc, uint256 warpAmount, uint8 numWarps) public { vm.startPrank(admin); (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); if (maxAlloc == 0 || maxAlloc < minAlloc) return; initialAlloc = bound(initialAlloc, minAlloc, maxAlloc); numWarps = uint8(bound(numWarps, 1, 10)); _prepareVaultAssets(initialAlloc); bool allocated = _allocateOrSkipWhitelisted(initialAlloc, RevertContext.FuzzAllocate); if (!allocated) { vm.stopPrank(); return; } uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); uint256 minRealAssets = initialRealAssets; // Perform multiple time warps for (uint8 i = 0; i < numWarps; i++) { warpAmount = bound(warpAmount, 1 hours, 365 days); _warpWithHook(warpAmount); uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); if (currentRealAssets < minRealAssets) { minRealAssets = currentRealAssets; } } uint256 tolerance = initialRealAssets * 5 / 100; assertGe( minRealAssets + tolerance, initialRealAssets, "Invariant violation: Real assets decreased significantly without operations" ); vm.stopPrank(); } /// @notice End-to-end test with multiple time warps belongs in iterative/fuzz module. function test_end_to_end_multiple_allocations_with_time_warp() public { vm.startPrank(admin); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); // First allocation (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); if (maxAlloc == 0) return; uint256 alloc1 = 100 * 10 ** testConfig.decimals; alloc1 = alloc1 > maxAlloc ? maxAlloc : alloc1; if (alloc1 < minAlloc) return; _prepareVaultAssets(alloc1); bool alloc1Ok = _allocateOrSkipWhitelisted(alloc1, RevertContext.FuzzAllocate); if (!alloc1Ok) { vm.stopPrank(); return; } uint256 realAssetsAfterAlloc1 = IMYTStrategy(strategy).realAssets(); assertGt(realAssetsAfterAlloc1, 0, "Real assets should be positive after first allocation"); assertApproxEqAbs(IVaultV2(vault).allocation(allocationId), alloc1, 1 * 10 ** testConfig.decimals); _warpWithHook(1 days); // Second allocation console.log("---------- second allocation ----------"); (minAlloc, maxAlloc) = _getAllocationBounds(); if (maxAlloc == 0) return; uint256 alloc2 = 50 * 10 ** testConfig.decimals; alloc2 = alloc2 > maxAlloc ? maxAlloc : alloc2; if (alloc2 < minAlloc) return; _prepareVaultAssets(alloc2); bool alloc2Ok = _allocateOrSkipWhitelisted(alloc2, RevertContext.FuzzAllocate); if (!alloc2Ok) { vm.stopPrank(); return; } uint256 realAssetsAfterAlloc2 = IMYTStrategy(strategy).realAssets(); assertGe(realAssetsAfterAlloc2, realAssetsAfterAlloc1, "Real assets should not decrease after second allocation"); assertApproxEqAbs(IVaultV2(vault).allocation(allocationId), alloc1 + alloc2, 1 * 10 ** testConfig.decimals); _warpWithHook(7 days); // Partial deallocation uint256 dealloc1 = 30 * 10 ** testConfig.decimals; _beforePreviewWithdraw(dealloc1); uint256 dealloc1Preview = IMYTStrategy(strategy).previewAdjustedWithdraw(dealloc1); uint256 allocationBeforeDealloc = IVaultV2(vault).allocation(allocationId); bool partialDeallocOk = _deallocateOrSkipWhitelisted(dealloc1Preview, RevertContext.FuzzDeallocate); if (!partialDeallocOk) { vm.stopPrank(); return; } uint256 realAssetsAfterDealloc1 = IMYTStrategy(strategy).realAssets(); assertLe(realAssetsAfterDealloc1, realAssetsAfterAlloc2, "Real assets should decrease after deallocation"); uint256 actualAllocationAfterDealloc = IVaultV2(vault).allocation(allocationId); assertLt(actualAllocationAfterDealloc, allocationBeforeDealloc, "Tracked allocation should decrease after deallocation"); uint256 realAssetsDecrease = realAssetsAfterAlloc2 - realAssetsAfterDealloc1; uint256 trackedDecrease = allocationBeforeDealloc - actualAllocationAfterDealloc; // Allow larger tolerance (10%) since share/asset conversions fluctuate with time in Tokemak assertApproxEqRel(realAssetsDecrease, trackedDecrease, 1e17); // 10% tolerance _warpWithHook(30 days); // Full deallocation for (uint256 i = 0; i < 256; i++) { uint256 before = IMYTStrategy(strategy).realAssets(); if (before == 0) break; bool ok = _deallocateFromRealAssetsEstimate(RevertContext.FuzzDeallocate); if (!ok) { vm.stopPrank(); return; } uint256 after_ = IMYTStrategy(strategy).realAssets(); if (after_ >= before) break; } uint256 realAssetsAfterFinal = IMYTStrategy(strategy).realAssets(); assertLt(realAssetsAfterFinal, realAssetsAfterDealloc1, "Real assets should be near zero after final deallocation"); assertApproxEqAbs(IVaultV2(vault).allocation(allocationId), 0, 2 * 10 ** testConfig.decimals); (uint256 finalVaultTotalAssets,,) = IVaultV2(vault).accrueInterestView(); assertGe(finalVaultTotalAssets, 0, "Vault total assets should be non-negative"); vm.stopPrank(); } /// @notice Iterative accumulation test with repeated warp/deallocate loops. function test_strategy_accumulation_over_time() public { vm.startPrank(admin); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); // Allocate initial amount - bounded by effective cap uint256 effectiveCap = _getEffectiveCapHeadroom(allocationId); uint256 vaultTotalAssets = IVaultV2(vault).totalAssets(); uint256 allocAmount = vaultTotalAssets / 20; allocAmount = allocAmount > effectiveCap ? effectiveCap : allocAmount; if (allocAmount == 0) return; { uint256 currentBalance = TokenUtils.safeBalanceOf(testConfig.vaultAsset, vault); deal(IVaultV2(vault).asset(), address(vault), currentBalance + allocAmount); } bool initialAllocOk = _allocateOrSkipWhitelisted(allocAmount, RevertContext.FuzzAllocate); if (!initialAllocOk) { vm.stopPrank(); return; } uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); uint256 minExpected = initialRealAssets * 95 / 100; // Start with 95% of initial as minimum // Warp forward and check accumulation for (uint256 i = 1; i <= 4; i++) { _warpWithHook(30 days); // Simulate yield by transferring small amount to strategy (0.5% per period) uint256 currentVaultAssets = IVaultV2(vault).totalAssets(); deal(testConfig.vaultAsset, strategy, currentVaultAssets * 5 / 1000); uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); assertGe(currentRealAssets, minExpected, "Real assets decreased significantly over time"); minExpected = currentRealAssets; // Small deallocation to test withdrawal capability - use rebounded effective cap uint256 currentEffectiveCap = _getEffectiveCapHeadroom(allocationId); if (i == 2 && currentEffectiveCap > 0) { uint256 targetDealloc = IMYTStrategy(strategy).realAssets() / 10; _beforePreviewWithdraw(targetDealloc); uint256 smallDealloc = IMYTStrategy(strategy).previewAdjustedWithdraw(targetDealloc); if (smallDealloc > 0) { bool smallOk = _deallocateOrSkipWhitelisted(smallDealloc, RevertContext.FuzzDeallocate); if (smallOk) { minExpected = IMYTStrategy(strategy).realAssets(); } } } } // Final full deallocation _deallocateFromRealAssetsEstimate(RevertContext.FuzzDeallocate); vm.stopPrank(); } /// @notice Fuzz test: Zero amount operations should have no effect (idempotency) function test_fuzz_zero_operations_no_effect(uint256 amount) public { vm.startPrank(admin); (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); if (maxAlloc < minAlloc) return; amount = bound(amount, minAlloc, maxAlloc); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); // maxAlloc helper is 0 when we reached an effective cap if(amount > 0) { _prepareVaultAssets(amount); IAllocator(allocator).allocate(strategy, amount); } uint256 realAssetsBefore = IMYTStrategy(strategy).realAssets(); uint256 allocationBefore = IVaultV2(vault).allocation(allocationId); vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.InvalidAmount.selector, 1, 0)); IAllocator(allocator).allocate(strategy, 0); uint256 realAssetsAfterZeroAlloc = IMYTStrategy(strategy).realAssets(); uint256 allocationAfterZeroAlloc = IVaultV2(vault).allocation(allocationId); assertEq(realAssetsAfterZeroAlloc, realAssetsBefore, "Invariant violation: Zero allocation changed state"); assertEq( allocationAfterZeroAlloc, allocationBefore, "Invariant violation: Zero allocation changed allocation tracking" ); // Try to deallocate zero try IMYTStrategy(strategy).deallocate(getVaultParams(), 0, "", address(vault)) {} catch {} uint256 realAssetsAfterZeroDealloc = IMYTStrategy(strategy).realAssets(); uint256 allocationAfterZeroDealloc = IVaultV2(vault).allocation(allocationId); assertEq(realAssetsAfterZeroDealloc, realAssetsBefore, "Invariant violation: Zero deallocation changed state"); assertEq( allocationAfterZeroDealloc, allocationBefore, "Invariant violation: Zero deallocation changed allocation tracking" ); vm.stopPrank(); } /// @notice Fuzz test: Allocations respect absolute and relative caps function test_fuzz_allocation_respects_caps(uint256 amountToAllocate) public { vm.startPrank(admin); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 absoluteCap = IVaultV2(vault).absoluteCap(allocationId); uint256 relativeCap = IVaultV2(vault).relativeCap(allocationId); (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); uint256 minBound = minAlloc < maxAlloc * 2 ? minAlloc : maxAlloc * 2; amountToAllocate = bound(amountToAllocate, minBound, maxAlloc * 2); _prepareVaultAssets(amountToAllocate); // Try to allocate through AlchemistAllocator - handle both success and failure cases try IAllocator(allocator).allocate(strategy, amountToAllocate) {} catch {} uint256 finalAllocation = IVaultV2(vault).allocation(allocationId); uint256 newVaultTotalAssets = IVaultV2(vault).totalAssets(); assertLe(finalAllocation, absoluteCap, "Invariant violation: Allocation exceeded absolute cap"); uint256 maxAllowedByRelative = (newVaultTotalAssets * relativeCap) / 1e18; assertLe( finalAllocation, maxAllowedByRelative + (10 ** testConfig.decimals), "Invariant violation: Allocation exceeded relative cap" ); vm.stopPrank(); } } ================================================ FILE: src/test/base/BaseStrategySimple.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {IAllocator} from "../../interfaces/IAllocator.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {TokenUtils} from "../../libraries/TokenUtils.sol"; import {RevertContext} from "./StrategyTypes.sol"; import {StrategyOps} from "./StrategyOps.sol"; import "forge-std/console.sol"; /// @notice Simple base tests shared by strategy suites. /// @dev Add deterministic or straightforward tests here; keep assertions readable and strategy-agnostic. abstract contract BaseStrategySimple is StrategyOps { function _assertDeallocateChange(int256 change, uint256 amountToDeallocate) internal view virtual { // Default expectation: deallocate change tracks requested amount. assertApproxEqRel(change, -int256(amountToDeallocate), 1e16); // 1% slippage tolerance } function _getForceDeallocateSwapParams() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.swap; params.swapParams = IMYTStrategy.SwapParams({txData: hex"1234", minIntermediateOut: 0}); return abi.encode(params); } function _getForceDeallocateUnwrapAndSwapParams() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.unwrapAndSwap; params.swapParams = IMYTStrategy.SwapParams({txData: hex"1234", minIntermediateOut: 1}); return abi.encode(params); } function test_strategy_allocate_reverts_due_to_zero_amount() public { uint256 amountToAllocate = 0; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.InvalidAmount.selector, 1, 0)); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); vm.stopPrank(); } function test_strategy_allocate_reverts_due_to_paused_allocation() public { bytes memory params = getVaultParams(); vm.startPrank(admin); IMYTStrategy(strategy).setKillSwitch(true); vm.stopPrank(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, 100 * 10 ** testConfig.decimals); vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.StrategyAllocationPaused.selector, strategy)); IMYTStrategy(strategy).allocate(params, 100 * 10 ** testConfig.decimals, "", address(vault)); vm.stopPrank(); } function test_strategy_deallocate_reverts_due_to_zero_amount() public { uint256 amountToAllocate = 100 * 10 ** testConfig.decimals; uint256 amountToDeallocate = 0; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); bytes memory deallocParams = getDeallocateVaultParams(amountToDeallocate); vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.InvalidAmount.selector, 1, 0)); IMYTStrategy(strategy).deallocate(deallocParams, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_strategy_forceDeallocate_swap_reverts() public { vm.startPrank(vault); vm.expectRevert(IMYTStrategy.ForceDeallocateSwapNotAllowed.selector); IMYTStrategy(strategy).deallocate( _getForceDeallocateSwapParams(), 1, IVaultV2.forceDeallocate.selector, address(vault) ); vm.stopPrank(); } function test_strategy_forceDeallocate_unwrapAndSwap_reverts() public { vm.startPrank(vault); vm.expectRevert(IMYTStrategy.ForceDeallocateSwapNotAllowed.selector); IMYTStrategy(strategy).deallocate( _getForceDeallocateUnwrapAndSwapParams(), 1, IVaultV2.forceDeallocate.selector, address(vault) ); vm.stopPrank(); } function test_strategy_deallocate(uint256 amountToAllocate) public { (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); amountToAllocate = bound(amountToAllocate, minAlloc, maxAlloc); console.log(minAlloc, maxAlloc); bytes memory allocParams = getVaultParams(); vm.startPrank(vault); // only allocate if we are whithin caps if(amountToAllocate > 0) { deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(allocParams, amountToAllocate, "", address(vault)); } else { console.log("Allocation was skipped due to caps!"); } uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); uint256 targetDeallocate = _effectiveDeallocateAmount(amountToAllocate); if (targetDeallocate == 0) { vm.stopPrank(); return; } uint256 amountToDeallocate = IMYTStrategy(strategy).previewAdjustedWithdraw(targetDeallocate); amountToDeallocate = bound(amountToDeallocate, 0, IMYTStrategy(strategy).realAssets()); if (amountToDeallocate == 0) return; // we are not interested in deallocating from empty vaults bytes32 adapterId = IMYTStrategy(strategy).adapterId(); vm.mockCall(vault, abi.encodeWithSelector(IVaultV2.allocation.selector, adapterId), abi.encode(initialRealAssets)); (bytes32[] memory strategyIds, int256 change) = IMYTStrategy(strategy).deallocate( getDeallocateVaultParams(amountToDeallocate), amountToDeallocate, "", address(vault) ); vm.clearMockedCalls(); _assertDeallocateChange(change, amountToDeallocate); assertGt(strategyIds.length, 0, "strategyIds is empty"); assertEq(strategyIds[0], IMYTStrategy(strategy).adapterId(), "adapter id not in strategyIds"); uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); uint256 idleAssets = TokenUtils.safeBalanceOf(testConfig.vaultAsset, address(strategy)); assertGe(idleAssets, amountToDeallocate, "Strategy idle assets should cover deallocated amount"); assertGe(finalRealAssets, idleAssets, "Real assets should include idle assets"); vm.stopPrank(); } function test_strategy_withdrawToVault(uint256 amount) public { amount = bound(amount, 1 * 10 ** testConfig.decimals, testConfig.vaultInitialDeposit); vm.startPrank(admin); deal(testConfig.vaultAsset, strategy, amount); uint256 initialAmountLeftOver = TokenUtils.safeBalanceOf(testConfig.vaultAsset, address(strategy)); uint256 initialAmountInVault = TokenUtils.safeBalanceOf(testConfig.vaultAsset, address(vault)); require(initialAmountLeftOver == amount, "Initial amount left over is not equal to amount"); IMYTStrategy(strategy).withdrawToVault(); vm.assertEq(TokenUtils.safeBalanceOf(testConfig.vaultAsset, address(strategy)), initialAmountLeftOver - amount); vm.assertEq(TokenUtils.safeBalanceOf(testConfig.vaultAsset, address(vault)), initialAmountInVault + amount); vm.stopPrank(); } function test_allocator_allocate_direct(uint256 amountToAllocate) public { (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); if (maxAlloc == 0) return; amountToAllocate = bound(amountToAllocate, minAlloc, maxAlloc); vm.startPrank(admin); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); _prepareVaultAssets(amountToAllocate); bool allocated = _allocateOrSkipWhitelisted(amountToAllocate, RevertContext.FuzzAllocate); if (!allocated) { vm.stopPrank(); return; } (uint256 newTotalAssets, uint256 performanceFeeShares, uint256 managementFeeShares) = IVaultV2(vault).accrueInterestView(); uint256 directRealAssets = IMYTStrategy(strategy).realAssets(); assertGt(directRealAssets, 0, "Direct allocate should produce non-zero real assets"); assertEq(performanceFeeShares, 0); assertEq(managementFeeShares, 0); assertGe(newTotalAssets, 0, "Vault total assets must remain non-negative"); assertLe(IVaultV2(vault).allocation(allocationId), amountToAllocate, "Allocation should not exceed requested amount"); vm.stopPrank(); } function test_allocator_deallocate_direct(uint256 amountToAllocate, uint256 amountToDeallocate) public { (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); if (maxAlloc == 0) return; amountToAllocate = bound(amountToAllocate, minAlloc, maxAlloc); vm.startPrank(admin); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); _prepareVaultAssets(amountToAllocate); bool allocated = _allocateOrSkipWhitelisted(amountToAllocate, RevertContext.FuzzAllocate); if (!allocated) { vm.stopPrank(); return; } uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); uint256 targetDeallocate = _effectiveDeallocateAmount(amountToAllocate); if (targetDeallocate == 0) { vm.stopPrank(); return; } amountToDeallocate = IMYTStrategy(strategy).previewAdjustedWithdraw(targetDeallocate); bool deallocated = _deallocateOrSkipWhitelisted(amountToDeallocate, RevertContext.FuzzDeallocate); if (!deallocated) { vm.stopPrank(); return; } (uint256 newTotalAssets, uint256 performanceFeeShares, uint256 managementFeeShares) = IVaultV2(vault).accrueInterestView(); assertLe(IMYTStrategy(strategy).realAssets(), currentRealAssets, "Direct deallocate should not increase real assets"); assertEq(performanceFeeShares, 0); assertEq(managementFeeShares, 0); assertGe(newTotalAssets, 0, "Vault total assets must remain non-negative"); assertLe(IVaultV2(vault).allocation(allocationId), amountToAllocate, "Allocation should not increase on deallocation"); vm.stopPrank(); } } ================================================ FILE: src/test/base/StrategyHandler.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "forge-std/Test.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {IAllocator} from "../../interfaces/IAllocator.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {IStrategyClassifier} from "../../interfaces/IStrategyClassifier.sol"; import {RevertContext, IRevertAllowlistProvider} from "./StrategyTypes.sol"; import {StrategyRevertUtils} from "./StrategyRevertUtils.sol"; /// @notice Invariant handler module for base strategy testing. /// @dev Instantiate from setup and target its selectors for invariant/fuzz-driven state transitions. /// @notice Handler contract for invariant testing according to Foundry best practices. /// It wraps the vault and strategy, constrains inputs, and tracks ghost variables. contract StrategyHandler is Test, StrategyRevertUtils { IVaultV2 public vault; IMYTStrategy public strategy; address public allocator; address public asset; address public admin; address public classifier; uint256 public minAllocateAmount; // Ghost variables to track cumulative state changes uint256 public ghost_totalAllocated; uint256 public ghost_totalDeallocated; uint256 public ghost_initialVaultAssets; // Call counters for coverage analysis mapping(bytes4 => uint256) public calls; address public limitProvider; constructor( address _vault, address _strategy, address _allocator, address _admin, address _limitProvider, address _classifier, uint256 _minAllocateAmount ) { vault = IVaultV2(_vault); strategy = IMYTStrategy(_strategy); allocator = _allocator; admin = _admin; limitProvider = _limitProvider; classifier = _classifier; minAllocateAmount = _minAllocateAmount; asset = vault.asset(); ghost_initialVaultAssets = vault.totalAssets(); } modifier countCall(bytes4 selector) { calls[selector]++; _; } function _isWhitelistedRevert(bytes4 sel, RevertContext context) internal view returns (bool) { return IRevertAllowlistProvider(limitProvider).isProtocolRevertAllowed(sel, context) || IRevertAllowlistProvider(limitProvider).isMytRevertAllowed(sel, context); } function allocate(uint256 amount) external countCall(this.allocate.selector) { uint256 vaultAssets = vault.totalAssets(); // If vault has no assets, we cannot allocate if (vaultAssets == 0) return; // Get the strategy's allocation limits bytes32 allocationId = strategy.adapterId(); uint256 currentAllocation = vault.allocation(allocationId); uint256 absoluteCap = vault.absoluteCap(allocationId); uint256 relativeCap = vault.relativeCap(allocationId); // Calculate remaining headroom in absolute cap uint256 absoluteRemaining = absoluteCap > currentAllocation ? absoluteCap - currentAllocation : 0; // Calculate remaining headroom in relative cap (convert from WAD to WEI) uint256 maxAllowedByRelative = (vaultAssets * relativeCap) / 1e18; uint256 relativeRemaining = maxAllowedByRelative > currentAllocation ? maxAllowedByRelative - currentAllocation : 0; // The effective limit is the minimum of the two caps uint256 effectiveLimit = absoluteRemaining < relativeRemaining ? absoluteRemaining : relativeRemaining; // Factor in classifier global risk cap (WAD percentage of totalAssets) if (classifier != address(0)) { uint8 riskLevel = IStrategyClassifier(classifier).getStrategyRiskLevel(uint256(allocationId)); uint256 globalRiskCap = (vaultAssets * IStrategyClassifier(classifier).getGlobalCap(riskLevel)) / 1e18; uint256 currentRiskAllocation = 0; uint256 len = vault.adaptersLength(); for (uint256 i = 0; i < len; i++) { address stratAdapter = vault.adapters(i); bytes32 stratId = IMYTStrategy(stratAdapter).adapterId(); if (IStrategyClassifier(classifier).getStrategyRiskLevel(uint256(stratId)) == riskLevel) { currentRiskAllocation += vault.allocation(stratId); } } uint256 globalRiskRemaining = globalRiskCap > currentRiskAllocation ? globalRiskCap - currentRiskAllocation : 0; effectiveLimit = effectiveLimit < globalRiskRemaining ? effectiveLimit : globalRiskRemaining; } if (effectiveLimit < minAllocateAmount) return; amount = bound(amount, minAllocateAmount, effectiveLimit); { uint256 currentIdle = IERC20(vault.asset()).balanceOf(address(vault)); deal(vault.asset(), address(vault), currentIdle + amount); } vm.startPrank(admin); try IAllocator(allocator).allocate(address(strategy), amount) { vm.stopPrank(); } catch (bytes memory errData) { vm.stopPrank(); _revertUnlessWhitelisted(errData, _isWhitelistedRevert(_revertSelector(errData), RevertContext.HandlerAllocate)); return; } ghost_totalAllocated += amount; } function deallocate(uint256 amount) external countCall(this.deallocate.selector) { bytes32 allocationId = strategy.adapterId(); uint256 currentAllocation = vault.allocation(allocationId); // If nothing is allocated, we cannot deallocate if (currentAllocation == 0) return; // Bound deallocation to current allocation amount = bound(amount, 1, currentAllocation); vm.startPrank(admin); if (IRevertAllowlistProvider(limitProvider).useAllocatorDeallocateUnwrapAndSwap()) { IAllocator(allocator).deallocateWithUnwrapAndSwap( address(strategy), amount, IRevertAllowlistProvider(limitProvider).allocatorDeallocateSwapData(amount), IRevertAllowlistProvider(limitProvider).allocatorDeallocateMinIntermediateOut(amount) ); } else if (IRevertAllowlistProvider(limitProvider).useAllocatorDeallocateSwap()) { IAllocator(allocator).deallocateWithSwap( address(strategy), amount, IRevertAllowlistProvider(limitProvider).allocatorDeallocateSwapData(amount) ); } else { IAllocator(allocator).deallocate(address(strategy), amount); } vm.stopPrank(); ghost_totalDeallocated += amount; } function warpTime(uint256 timeDelta) external countCall(this.warpTime.selector) { vm.warp(block.timestamp + bound(timeDelta, 1, 365 days)); } function callSummary() external view { console.log("Handler Call Summary:"); console.log("allocate calls:", calls[this.allocate.selector]); console.log("deallocate calls:", calls[this.deallocate.selector]); console.log("warpTime calls:", calls[this.warpTime.selector]); } } ================================================ FILE: src/test/base/StrategyOps.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IAllocator} from "../../interfaces/IAllocator.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {RevertContext} from "./StrategyTypes.sol"; import {StrategySetup} from "./StrategySetup.sol"; import {StrategyRevertUtils} from "./StrategyRevertUtils.sol"; /// @notice Shared allocate/deallocate execution primitives for base tests. /// @dev Reuse these helpers in scenario/fuzz modules to apply allowlist and preview logic uniformly. abstract contract StrategyOps is StrategySetup, StrategyRevertUtils { function _isWhitelistedRevert(bytes4 sel, RevertContext context) internal view returns (bool) { return this.isProtocolRevertAllowed(sel, context) || this.isMytRevertAllowed(sel, context); } function _allocateOrSkipWhitelisted(uint256 amount, RevertContext context) internal returns (bool) { try IAllocator(allocator).allocate(strategy, amount) { return true; } catch (bytes memory errData) { _revertUnlessWhitelisted(errData, _isWhitelistedRevert(_revertSelector(errData), context)); return false; } } function _deallocateOrSkipWhitelisted(uint256 amount, RevertContext context) internal returns (bool) { if (_useAllocatorDeallocateUnwrapAndSwap()) { try IAllocator(allocator).deallocateWithUnwrapAndSwap( strategy, amount, _allocatorDeallocateSwapData(amount), _allocatorDeallocateMinIntermediateOut(amount) ) { return true; } catch (bytes memory errData) { _revertUnlessWhitelisted(errData, _isWhitelistedRevert(_revertSelector(errData), context)); return false; } } if (_useAllocatorDeallocateSwap()) { try IAllocator(allocator).deallocateWithSwap(strategy, amount, _allocatorDeallocateSwapData(amount)) { return true; } catch (bytes memory errData) { _revertUnlessWhitelisted(errData, _isWhitelistedRevert(_revertSelector(errData), context)); return false; } } try IAllocator(allocator).deallocate(strategy, amount) { return true; } catch (bytes memory errData) { _revertUnlessWhitelisted(errData, _isWhitelistedRevert(_revertSelector(errData), context)); return false; } } function _deallocateEstimate(uint256 targetAssets) internal returns (bool) { return _deallocateEstimate(targetAssets, RevertContext.DirectDeallocate); } function _deallocateEstimate(uint256 targetAssets, RevertContext context) internal returns (bool) { uint256 target = _effectiveDeallocateAmount(targetAssets); if (target == 0) return false; _beforePreviewWithdraw(target); uint256 preview = IMYTStrategy(strategy).previewAdjustedWithdraw(target); if (preview == 0) return false; return _deallocateOrSkipWhitelisted(preview, context); } function _deallocateFromRealAssetsEstimate() internal returns (bool) { return _deallocateFromRealAssetsEstimate(RevertContext.DirectDeallocate); } function _deallocateFromRealAssetsEstimate(RevertContext context) internal returns (bool) { uint256 current = IMYTStrategy(strategy).realAssets(); if (current == 0) return true; return _deallocateEstimate(current, context); } } ================================================ FILE: src/test/base/StrategyRevertUtils.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; /// @notice Shared revert decoding and forwarding helpers for strategy tests. /// @dev Use this mixin from base modules/handlers to keep revert handling consistent. abstract contract StrategyRevertUtils { error UnexpectedRevert(bytes4 selector, bytes data); function _revertSelector(bytes memory errData) internal pure returns (bytes4 sel) { if (errData.length < 4) return bytes4(0); assembly { sel := mload(add(errData, 32)) } } function _revertUnlessWhitelisted(bytes memory errData, bool isWhitelisted) internal pure { if (isWhitelisted) return; bytes4 sel = _revertSelector(errData); revert UnexpectedRevert(sel, errData); } } ================================================ FILE: src/test/base/StrategySetup.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "forge-std/Test.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {AlchemistAllocator} from "../../AlchemistAllocator.sol"; import {AlchemistStrategyClassifier} from "../../AlchemistStrategyClassifier.sol"; import {MYTTestHelper} from "../libraries/MYTTestHelper.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {TokenUtils} from "../../libraries/TokenUtils.sol"; import {RevertContext, IRevertAllowlistProvider} from "./StrategyTypes.sol"; import {StrategyHandler} from "./StrategyHandler.sol"; /// @notice Environment/bootstrap layer for all strategy base tests. /// @dev Owns fork selection, vault/allocator wiring, shared state, and strategy extension hooks. abstract contract StrategySetup is Test, IRevertAllowlistProvider { IMYTStrategy.StrategyParams public strategyConfig; TestConfig public testConfig; // Common state variables address public strategy; address public vault; address public allocator; address public classifier; StrategyHandler public handler; uint256 private _forkId; // Common addresses - can be overridden by child contracts address public admin = address(1); address public curator = address(2); address public operator = address(3); address public vaultDepositor = address(4); struct TestConfig { address vaultAsset; uint256 vaultInitialDeposit; uint256 absoluteCap; uint256 relativeCap; uint256 decimals; } // Minimum allocation amount to satisfy underlying protocol requirements (e.g., Aave V3 min supply) // Represents 0.001 tokens - computed dynamically based on asset decimals uint256 public constant MIN_ALLOCATE_AMOUNT_SCALAR = 3; // 10^(decimals - 3) = 0.001 tokens // Abstract functions that must be implemented by child contracts function getTestConfig() internal virtual returns (TestConfig memory); function getStrategyConfig() internal virtual returns (IMYTStrategy.StrategyParams memory); function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal virtual returns (address); function getForkBlockNumber() internal virtual returns (uint256); function getRpcUrl() internal virtual returns (string memory); function isProtocolRevertAllowed(bytes4, RevertContext) external view virtual returns (bool) { return false; } function getAllocateVaultParams(uint256) internal view virtual returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; return abi.encode(params); } /// @dev Optional strategy-specific deallocation params hook. /// Default behavior uses direct action, matching `getVaultParams()`. function getDeallocateVaultParams(uint256) internal view virtual returns (bytes memory) { return getVaultParams(); } /// @dev Optional strategy-specific allocator deallocate mode. /// Default behavior uses direct allocator deallocate. function _useAllocatorDeallocateSwap() internal pure virtual returns (bool) { return false; } /// @dev Optional strategy-specific allocator unwrap-and-swap deallocate mode. /// Default behavior does not use unwrap + swap. function _useAllocatorDeallocateUnwrapAndSwap() internal pure virtual returns (bool) { return false; } /// @dev Optional strategy-specific calldata provider for allocator swap deallocate. function _allocatorDeallocateSwapData(uint256) internal view virtual returns (bytes memory) { return bytes(""); } /// @dev Optional strategy-specific minimum intermediate out provider for unwrap-and-swap deallocation. function _allocatorDeallocateMinIntermediateOut(uint256) internal view virtual returns (uint256) { return 0; } function isMytRevertAllowed(bytes4, RevertContext) external view virtual returns (bool) { return false; } function useAllocatorDeallocateSwap() external view virtual returns (bool) { return _useAllocatorDeallocateSwap(); } function useAllocatorDeallocateUnwrapAndSwap() external view virtual returns (bool) { return _useAllocatorDeallocateUnwrapAndSwap(); } function allocatorDeallocateSwapData(uint256 amount) external view virtual returns (bytes memory) { return _allocatorDeallocateSwapData(amount); } function allocatorDeallocateMinIntermediateOut(uint256 amount) external view virtual returns (uint256) { return _allocatorDeallocateMinIntermediateOut(amount); } function setUp() public virtual { testConfig = getTestConfig(); strategyConfig = getStrategyConfig(); // Fork setup string memory rpc = getRpcUrl(); if (getForkBlockNumber() > 0) { _forkId = vm.createFork(rpc, getForkBlockNumber()); } else { _forkId = vm.createFork(rpc); } vm.selectFork(_forkId); // Core setup vm.startPrank(admin); vault = _getVault(testConfig.vaultAsset); strategy = createStrategy(vault, strategyConfig); vm.stopPrank(); _setUpMYT(vault, strategy, testConfig.absoluteCap, testConfig.relativeCap); _magicDepositToVault(vault, vaultDepositor, testConfig.vaultInitialDeposit); require(IVaultV2(vault).totalAssets() == testConfig.vaultInitialDeposit, "vault total assets mismatch"); vm.makePersistent(strategy); // Setup Invariant Handler handler = new StrategyHandler(vault, strategy, allocator, admin, address(this), classifier, _getMinAllocateAmount()); targetContract(address(handler)); // Target specific functions in the handler for invariant fuzzing bytes4[] memory selectors = new bytes4[](3); selectors[0] = handler.allocate.selector; selectors[1] = handler.deallocate.selector; selectors[2] = handler.warpTime.selector; targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); } function _getVault(address asset) internal returns (address) { return address(MYTTestHelper._setupVault(asset, admin, curator)); } uint256 public constant PERFORMANCE_FEE = 15e16; function _setUpMYT(address _vault, address _mytStrategy, uint256 absoluteCap, uint256 relativeCap) internal { vm.startPrank(admin); classifier = address(new AlchemistStrategyClassifier(admin)); // Set up risk classes matching constructor defaults (WAD: 1e18 = 100%) AlchemistStrategyClassifier(classifier).setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% AlchemistStrategyClassifier(classifier).setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% AlchemistStrategyClassifier(classifier).setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% // Assign risk level to the strategy bytes32 strategyId = IMYTStrategy(_mytStrategy).adapterId(); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel(uint256(strategyId), uint8(strategyConfig.riskClass)); allocator = address(new AlchemistAllocator(_vault, admin, operator, classifier)); vm.stopPrank(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (allocator, true))); IVaultV2(_vault).setIsAllocator(allocator, true); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, _mytStrategy)); IVaultV2(_vault).addAdapter(_mytStrategy); bytes memory idData = IMYTStrategy(_mytStrategy).getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, absoluteCap))); IVaultV2(_vault).increaseAbsoluteCap(idData, absoluteCap); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, relativeCap))); IVaultV2(_vault).increaseRelativeCap(idData, relativeCap); // Validation require(IVaultV2(_vault).adaptersLength() == 1, "adaptersLength must be 1"); require(IVaultV2(_vault).isAllocator(allocator), "allocator is not set"); require(IVaultV2(_vault).isAdapter(_mytStrategy), "strategy is not set"); require(IVaultV2(_vault).absoluteCap(strategyId) == absoluteCap, "absoluteCap is not set"); require(IVaultV2(_vault).relativeCap(strategyId) == relativeCap, "relativeCap is not set"); vm.stopPrank(); } function _magicDepositToVault(address _vault, address depositor, uint256 amount) internal returns (uint256) { deal(address(IVaultV2(_vault).asset()), depositor, amount); vm.startPrank(depositor); TokenUtils.safeApprove(address(IVaultV2(_vault).asset()), _vault, amount); uint256 shares = IVaultV2(_vault).deposit(amount, depositor); vm.stopPrank(); return shares; } function _vaultSubmitAndFastForward(bytes memory data) internal { IVaultV2(vault).submit(data); bytes4 selector = bytes4(data); vm.warp(block.timestamp + IVaultV2(vault).timelock(selector)); } function getVaultParams() internal pure returns (bytes memory) { IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; return abi.encode(params); } /// @dev Optional strategy-specific hook invoked before time-shifts in base tests. function _beforeTimeShift(uint256 targetTimestamp) internal virtual {} /// @dev Optional strategy-specific hook invoked before previewAdjustedWithdraw calls. function _beforePreviewWithdraw(uint256 requestedAssets) internal virtual {} /// @dev Optional strategy-specific clamp for deallocation requests. /// Default behavior uses the requested amount directly. function _effectiveDeallocateAmount(uint256 requestedAssets) internal view virtual returns (uint256) { return requestedAssets; } /// @dev Base helper to keep time-shift behavior extensible for strategy-specific tests. function _warpWithHook(uint256 timeDelta) internal { uint256 targetTimestamp = block.timestamp + timeDelta; _beforeTimeShift(targetTimestamp); vm.warp(targetTimestamp); } /// @dev Helper to get decimals-aware minimum allocation amount (0.001 tokens). function _getMinAllocateAmount() internal view virtual returns (uint256) { return 10 ** (testConfig.decimals - MIN_ALLOCATE_AMOUNT_SCALAR); } /// @dev Helper to get valid allocation bounds based on effective cap and vault assets. function _getAllocationBounds() internal view returns (uint256 min, uint256 max) { bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 effectiveCap = _getEffectiveCapHeadroom(allocationId); uint256 vaultAssets = IVaultV2(vault).totalAssets(); console.log("vaultAssets", vaultAssets); uint256 maxAlloc = effectiveCap < vaultAssets ? effectiveCap : vaultAssets; console.log("final maxAlloc", maxAlloc); uint256 minAlloc = _getMinAllocateAmount(); // If available headroom is below minimum, return (0, 0) to signal no valid allocation possible if (maxAlloc < minAlloc) { return (0, 0); } return (minAlloc, maxAlloc); } /// @dev Helper to deal specific amount of assets to the vault. function _prepareVaultAssets(uint256 amount) internal { uint256 currentBalance = TokenUtils.safeBalanceOf(testConfig.vaultAsset, vault); deal(testConfig.vaultAsset, vault, currentBalance + amount); } /// @dev Helper to calculate effective cap headroom (matches AlchemistAllocator._validateCaps logic) function _getEffectiveCapHeadroom(bytes32 allocationId) internal view returns (uint256) { uint256 currentAllocation = IVaultV2(vault).allocation(allocationId); console.log("currentAllocation", currentAllocation); uint256 absoluteCap = IVaultV2(vault).absoluteCap(allocationId); uint256 relativeCap = IVaultV2(vault).relativeCap(allocationId); uint256 totalAssets = IVaultV2(vault).totalAssets(); uint256 absoluteRemaining = absoluteCap > currentAllocation ? absoluteCap - currentAllocation : 0; console.log("absoluteRemaining", absoluteRemaining); uint256 absoluteValueOfRelativeCap = (totalAssets * relativeCap) / 1e18; console.log("absoluteValueOfRelativeCap", absoluteValueOfRelativeCap); uint256 relativeRemaining = absoluteValueOfRelativeCap > currentAllocation ? absoluteValueOfRelativeCap - currentAllocation : 0; console.log("relativeRemaining", relativeRemaining); uint256 limit = absoluteRemaining < relativeRemaining ? absoluteRemaining : relativeRemaining; uint256 strategyId = uint256(allocationId); uint8 riskLevel = AlchemistStrategyClassifier(classifier).getStrategyRiskLevel(strategyId); uint256 globalRiskCapPct = AlchemistStrategyClassifier(classifier).getGlobalCap(riskLevel); uint256 globalRiskCap = (totalAssets * globalRiskCapPct) / 1e18; uint256 globalRiskRemaining = globalRiskCap > currentAllocation ? globalRiskCap - currentAllocation : 0; limit = limit < globalRiskRemaining ? limit : globalRiskRemaining; console.log("limit", limit); return limit; } } ================================================ FILE: src/test/base/StrategyTypes.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; /// @notice Shared type definitions for the base strategy testing stack. /// @dev Keep enums/interfaces used across setup, ops, handler, and strategy-specific tests here. enum RevertContext { HandlerAllocate, HandlerDeallocate, FuzzAllocate, FuzzDeallocate, DirectAllocate, DirectDeallocate } interface IRevertAllowlistProvider { function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external view returns (bool); function isMytRevertAllowed(bytes4 selector, RevertContext context) external view returns (bool); function useAllocatorDeallocateSwap() external view returns (bool); function useAllocatorDeallocateUnwrapAndSwap() external view returns (bool); function allocatorDeallocateSwapData(uint256 amount) external view returns (bytes memory); function allocatorDeallocateMinIntermediateOut(uint256 amount) external view returns (uint256); } ================================================ FILE: src/test/libraries/AlchemistNFTHelper.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IAlchemistV3Position} from "../../interfaces/IAlchemistV3Position.sol"; import {CustomBase64} from "./CustomBase64.sol"; library AlchemistNFTHelper { /** * @notice Returns all token IDs owned by `owner` for the given NFT contract address. * @param owner The address whose tokens we want to retrieve. * @param nft The address of the AlchemistV3Position NFT contract. * @return tokenIds An array with all token IDs owned by `owner`. */ function getAllTokenIdsForOwner(address owner, address nft) public view returns (uint256[] memory tokenIds) { // Get the number of tokens owned by `owner` uint256 tokenCount = IAlchemistV3Position(nft).balanceOf(owner); tokenIds = new uint256[](tokenCount); if (tokenCount == 0) { return tokenIds; } // Loop through each token and retrieve its token ID via the enumerable interface. for (uint256 i = 0; i < tokenCount; i++) { tokenIds[i] = IAlchemistV3Position(nft).tokenOfOwnerByIndex(owner, i); } } /** * @notice Returns first token id found for owner * @param owner The address whose tokens we want to retrieve. * @param nft The address of the AlchemistV3Position NFT contract. * @return tokenId token id owned by `owner`. */ function getFirstTokenId(address owner, address nft) public view returns (uint256 tokenId) { uint256[] memory tokenIds = getAllTokenIdsForOwner(owner, nft); if (tokenIds.length == 0) { return 0; } tokenId = tokenIds[0]; } /** * @notice Slices a string * @param text The string to slice * @param start The start index * @param length The length of the slice * @return result The sliced string */ function slice(string memory text, uint256 start, uint256 length) internal pure returns (string memory) { bytes memory textBytes = bytes(text); bytes memory result = new bytes(length); for (uint256 i = 0; i < length; i++) { result[i] = textBytes[start + i]; } return string(result); } function contains(string memory source, string memory keyword) internal pure returns (bool) { bytes memory sourceBytes = bytes(source); bytes memory keywordBytes = bytes(keyword); if (keywordBytes.length > sourceBytes.length) { return false; } for (uint256 i = 0; i <= sourceBytes.length - keywordBytes.length; i++) { bool found = true; for (uint256 j = 0; j < keywordBytes.length; j++) { if (sourceBytes[i + j] != keywordBytes[j]) { found = false; break; } } if (found) { return true; } } return false; } function base64Content(string memory uri) internal pure returns (string memory) { return slice(uri, 29, bytes(uri).length - 29); } function jsonContent(string memory uri) internal pure returns (string memory) { return string(CustomBase64.decode(base64Content(uri))); } } ================================================ FILE: src/test/libraries/CustomBase64.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; library CustomBase64 { string internal constant TABLE_ENCODE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; function encode(bytes memory data) internal pure returns (string memory) { if (data.length == 0) return ""; // Output length: 4 bytes for each 3 bytes (rounded up) uint256 outputLength = 4 * ((data.length + 2) / 3); bytes memory output = new bytes(outputLength); uint256 j = 0; for (uint256 i = 0; i < data.length; i += 3) { // Load 3 bytes or whatever is left uint24 input = 0; if (i < data.length) input |= uint24(uint8(data[i])) << 16; if (i + 1 < data.length) input |= uint24(uint8(data[i + 1])) << 8; if (i + 2 < data.length) input |= uint24(uint8(data[i + 2])); // Convert to 4 base64 characters for (uint256 k = 0; k < 4; k++) { uint8 index = uint8((input >> (18 - 6 * k)) & 0x3F); output[j++] = bytes(TABLE_ENCODE)[index]; } } // Replace characters with '=' for padding if (data.length % 3 == 1) { output[outputLength - 2] = bytes1("="); output[outputLength - 1] = bytes1("="); } else if (data.length % 3 == 2) { output[outputLength - 1] = bytes1("="); } return string(output); } function decode(string memory input) internal pure returns (bytes memory) { bytes memory data = bytes(input); require(data.length % 4 == 0, "Invalid base64 string length"); uint256 paddingLength = 0; if (data.length > 0) { if (data[data.length - 1] == "=") paddingLength++; if (data.length > 1 && data[data.length - 2] == "=") paddingLength++; } uint256 outputLength = (data.length / 4) * 3 - paddingLength; bytes memory output = new bytes(outputLength); uint256 j = 0; uint24 temp = 0; for (uint256 i = 0; i < data.length; i += 4) { temp = 0; for (uint256 k = 0; k < 4; k++) { uint8 value; bytes1 char = data[i + k]; if (char >= 0x41 && char <= 0x5A) { // A-Z value = uint8(char) - 0x41; } else if (char >= 0x61 && char <= 0x7A) { // a-z value = uint8(char) - 0x61 + 26; } else if (char >= 0x30 && char <= 0x39) { // 0-9 value = uint8(char) - 0x30 + 52; } else if (char == 0x2B) { // + value = 62; } else if (char == 0x2F) { // / value = 63; } else if (char == 0x3D) { // = value = 0; // Padding character } else { revert("Invalid base64 character"); } temp = (temp << 6) | value; } // Convert 24 bits to 3 bytes if (j < outputLength) output[j++] = bytes1(uint8(temp >> 16)); if (j < outputLength) output[j++] = bytes1(uint8(temp >> 8)); if (j < outputLength) output[j++] = bytes1(uint8(temp)); } return output; } } ================================================ FILE: src/test/libraries/MYTTestHelper.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {TokenUtils} from "../../libraries/TokenUtils.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {MockMYTStrategy} from "../mocks/MockMYTStrategy.sol"; import {MockMYTVault} from "../mocks/MockMYTVault.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; library MYTTestHelper { Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); uint256 public constant PERFORMANCE_FEE = 15e16; function _setupVault(address collateral, address admin, address curator) internal returns (MockMYTVault) { MockMYTVault vault = new MockMYTVault(admin, collateral); vault.setCurator(curator); vm.stopPrank(); vm.startPrank(curator); vault.submit(abi.encodeCall(IVaultV2.setPerformanceFeeRecipient, (admin))); vault.setPerformanceFeeRecipient(admin); vault.submit(abi.encodeCall(IVaultV2.setPerformanceFee, (PERFORMANCE_FEE))); vault.setPerformanceFee(PERFORMANCE_FEE); vm.stopPrank(); vm.startPrank(admin); return vault; } function _setupStrategy(address myt, address yieldToken, address owner, string memory name, string memory protocol, IMYTStrategy.RiskClass riskClass) external returns (MockMYTStrategy) { IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: owner, name: name, protocol: protocol, riskClass: riskClass, cap: 100 ether, globalCap: 100 ether, estimatedYield: 100 ether, additionalIncentives: false, slippageBPS: 1 }); return new MockMYTStrategy(myt, yieldToken, params); } } ================================================ FILE: src/test/mocks/AlchemicTokenV3.sol ================================================ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.28; import {AccessControl} from "../../../lib/openzeppelin-contracts/contracts/access/AccessControl.sol"; import {ERC20} from "../../../lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; import {Ownable} from "../../../lib/openzeppelin-contracts/contracts/access/Ownable.sol"; import {ReentrancyGuard} from "../../../lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; import {IllegalArgument, IllegalState, Unauthorized} from "../../base/Errors.sol"; import {IERC3156FlashLender} from "../../../lib/openzeppelin-contracts/contracts/interfaces/IERC3156FlashLender.sol"; import {IERC3156FlashBorrower} from "../../../lib/openzeppelin-contracts/contracts/interfaces/IERC3156FlashBorrower.sol"; /// @title AlchemicTokenV3 /// @author Alchemix Finance /// /// @notice This is the contract for version two alchemic tokens. contract AlchemicTokenV3 is AccessControl, ReentrancyGuard, ERC20, IERC3156FlashLender { /// @notice The identifier of the role which maintains other roles. bytes32 public constant ADMIN_ROLE = keccak256("ADMIN"); /// @notice The identifier of the role which allows accounts to mint tokens. bytes32 public constant SENTINEL_ROLE = keccak256("SENTINEL"); /// @notice The expected return value from a flash mint receiver bytes32 public constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan"); /// @notice The maximum number of basis points needed to represent 100%. uint256 public constant BPS = 10_000; /// @notice A set of addresses which are whitelisted for minting new tokens. mapping(address => bool) public whitelisted; /// @notice A set of addresses which are paused from minting new tokens. mapping(address => bool) public paused; /// @notice Fee for flash minting uint256 public flashMintFee; /// @notice Max flash mint amount uint256 public maxFlashLoanAmount; /// @notice An event which is emitted when a minter is paused from minting. /// /// @param minter The address of the minter which was paused. /// @param state A flag indicating if the alchemist is paused or unpaused. event Paused(address minter, bool state); /// @notice An event which is emitted when the flash mint fee is updated. /// /// @param fee The new flash mint fee. event SetFlashMintFee(uint256 fee); /// @notice An event which is emitted when the max flash loan is updated. /// /// @param maxFlashLoan The new max flash loan. event SetMaxFlashLoan(uint256 maxFlashLoan); constructor(string memory _name, string memory _symbol, uint256 _flashFee) ERC20(_name, _symbol) { _grantRole(ADMIN_ROLE, msg.sender); _grantRole(SENTINEL_ROLE, msg.sender); _setRoleAdmin(SENTINEL_ROLE, ADMIN_ROLE); _setRoleAdmin(ADMIN_ROLE, ADMIN_ROLE); flashMintFee = _flashFee; emit SetFlashMintFee(_flashFee); } /// @dev A modifier which checks that the caller has the admin role. modifier onlyAdmin() { if (!hasRole(ADMIN_ROLE, msg.sender)) { revert Unauthorized(); } _; } /// @dev A modifier which checks that the caller has the sentinel role. modifier onlySentinel() { if (!hasRole(SENTINEL_ROLE, msg.sender)) { revert Unauthorized(); } _; } /// @dev A modifier which checks if whitelisted for minting. modifier onlyWhitelisted() { if (!whitelisted[msg.sender]) { revert Unauthorized(); } _; } /// @notice Sets the flash minting fee. /// /// @notice This function reverts if `msg.sender` is not an admin. /// /// @param newFee The new flash mint fee. function setFlashFee(uint256 newFee) external onlyAdmin { if (newFee >= BPS) { revert IllegalArgument(); } flashMintFee = newFee; emit SetFlashMintFee(flashMintFee); } /// @notice Mints tokens to `a recipient.` /// /// @notice This function reverts if `msg.sender` is not whitelisted. /// @notice This function reverts if `msg.sender` is paused. /// /// @param recipient The address to mint the tokens to. /// @param amount The amount of tokens to mint. function mint(address recipient, uint256 amount) external onlyWhitelisted { if (paused[msg.sender]) { revert IllegalState(); } _mint(recipient, amount); } /// @notice Sets `minter` as whitelisted to mint. /// /// @notice This function reverts if `msg.sender` is not an admin. /// /// @param minter The account to permit to mint. /// @param state A flag indicating if the minter should be able to mint. function setWhitelist(address minter, bool state) external onlyAdmin { whitelisted[minter] = state; } /// @notice Sets `sentinel` as a sentinel. /// /// @notice This function reverts if `msg.sender` is not an admin. /// /// @param sentinel The address to set as a sentinel. function setSentinel(address sentinel) external onlyAdmin { _grantRole(SENTINEL_ROLE, sentinel); } /// @notice Pauses `minter` from minting tokens. /// /// @notice This function reverts if `msg.sender` is not a sentinel. /// /// @param minter The address to set as paused or unpaused. /// @param state A flag indicating if the minter should be paused or unpaused. function pauseMinter(address minter, bool state) external onlySentinel { paused[minter] = state; emit Paused(minter, state); } /// @notice Burns `amount` tokens from `msg.sender`. /// /// @param amount The amount of tokens to be burned. function burn(uint256 amount) external { _burn(msg.sender, amount); } /// @dev Destroys `amount` tokens from `account`, deducting from the caller's allowance. /// /// @param account The address the burn tokens from. /// @param amount The amount of tokens to burn. function burnFrom(address account, uint256 amount) external { uint256 newAllowance = allowance(account, msg.sender) - amount; _approve(account, msg.sender, newAllowance); _burn(account, amount); } /// @notice Adjusts the maximum flashloan amount. /// /// @param _maxFlashLoanAmount The maximum flashloan amount. function setMaxFlashLoan(uint256 _maxFlashLoanAmount) external onlyAdmin { maxFlashLoanAmount = _maxFlashLoanAmount; emit SetMaxFlashLoan(_maxFlashLoanAmount); } /// @notice Gets the maximum amount to be flash loaned of a token. /// /// @param token The address of the token. /// /// @return The maximum amount of `token` that can be flashed loaned. function maxFlashLoan(address token) public view override returns (uint256) { if (token != address(this)) { return 0; } return maxFlashLoanAmount; } /// @notice Gets the flash loan fee of `amount` of `token`. /// /// @param token The address of the token.` /// @param amount The amount of `token` to flash mint. /// /// @return The flash loan fee. function flashFee(address token, uint256 amount) public view override returns (uint256) { if (token != address(this)) { revert IllegalArgument(); } return amount * flashMintFee / BPS; } /// @notice Performs a flash mint (called flash loan to confirm with ERC3156 standard). /// /// @param receiver The address which will receive the flash minted tokens. /// @param token The address of the token to flash mint. /// @param amount How much to flash mint. /// @param data ABI encoded data to pass to the receiver. /// /// @return If the flash loan was successful. function flashLoan(IERC3156FlashBorrower receiver, address token, uint256 amount, bytes calldata data) external override nonReentrant returns (bool) { if (token != address(this)) { revert IllegalArgument(); } if (amount > maxFlashLoan(token)) { revert IllegalArgument(); } uint256 fee = flashFee(token, amount); _mint(address(receiver), amount); if (receiver.onFlashLoan(msg.sender, token, amount, fee, data) != CALLBACK_SUCCESS) { revert IllegalState(); } _burn(address(receiver), amount + fee); // Will throw error if not enough to burn return true; } } ================================================ FILE: src/test/mocks/MockAlchemistAllocator.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {AlchemistAllocator} from "../../AlchemistAllocator.sol"; contract MockAlchemistAllocator is AlchemistAllocator { constructor(address _myt, address _admin, address _operator, address _classifier) AlchemistAllocator(_myt, _admin, _operator, _classifier) {} } ================================================ FILE: src/test/mocks/MockAlchemistCurator.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {AlchemistCurator} from "../../AlchemistCurator.sol"; contract MockAlchemistCurator is AlchemistCurator { constructor(address _admin, address _operator) AlchemistCurator(_admin, _operator) {} } ================================================ FILE: src/test/mocks/MockMYTStrategy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {TokenUtils} from "../../libraries/TokenUtils.sol"; import {IMockYieldToken} from "./MockYieldToken.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; contract MockMYTStrategy is MYTStrategy { IMockYieldToken public immutable token; IERC20 public immutable underlying; constructor(address _myt, address _token, IMYTStrategy.StrategyParams memory _params) MYTStrategy(_myt, _params) { token = IMockYieldToken(_token); underlying = IERC20(token.underlyingToken()); } function _allocate(uint256 amount) internal override returns (uint256 depositReturn) { // if native eth used, most strats will have their own function to wrap eth to weth // so will assume that all token deposits are done with weth TokenUtils.safeApprove(address(underlying), address(token), amount); depositReturn = token.deposit(amount); require(depositReturn == amount); } function _deallocate(uint256 assets) internal override returns (uint256 amountReturned) { // `assets` is in underlying units requested by the vault. uint256 price = token.price(); // WAD, underlying value of 10**dec shares uint8 dec = token.decimals(); // sharesToBurn = assets * 10**dec / price, rounded up so the mock // can satisfy the vault's requested underlying withdrawal amount. uint256 sharesToBurn = (assets * (10 ** uint256(dec))) / price; if ((sharesToBurn * price) / (10 ** uint256(dec)) < assets) { sharesToBurn += 1; } uint256 shareBal = token.balanceOf(address(this)); require(sharesToBurn <= shareBal, "insufficient shares"); // Burn shares and receive underlying back to this strategy. amountReturned = token.requestWithdraw(address(this), sharesToBurn); // Approve the actual amount of underlying returned for the vault to pull. TokenUtils.safeApprove(address(underlying), msg.sender, amountReturned); require(amountReturned >= assets, "insufficient withdraw"); } function realAssets() external view override returns (uint256) { return _totalValue(); } function _totalValue() internal view override returns (uint256) { uint256 invested = (token.balanceOf(address(this)) * token.price()) / 10 ** token.decimals(); uint256 idle = underlying.balanceOf(address(this)); return invested + idle; } function mockUpdateWhitelistedAllocators(address allocator, bool value) public {} } ================================================ FILE: src/test/mocks/MockMYTVault.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {VaultV2} from "lib/vault-v2/src/VaultV2.sol"; contract MockMYTVault is VaultV2 { constructor(address admin, address collateral) VaultV2(admin, collateral) {} } ================================================ FILE: src/test/mocks/MockWETH.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MockWETH is ERC20 { constructor() ERC20("Wrapped Ether", "WETH") {} function deposit() external payable { _mint(msg.sender, msg.value); } function withdraw(uint256 amount) external { _burn(msg.sender, amount); (bool success,) = msg.sender.call{value: amount}(""); require(success, "ETH transfer failed"); } receive() external payable { _mint(msg.sender, msg.value); } } ================================================ FILE: src/test/mocks/MockYieldToken.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {TestYieldToken} from "./TestYieldToken.sol"; import {TokenUtils} from "../../libraries/TokenUtils.sol"; interface IMockYieldToken { function deposit(uint256 amount) external returns (uint256); function requestWithdraw(address recipient, uint256 amount) external returns (uint256); function mockedSupply() external view returns (uint256); function underlyingToken() external view returns (address); function mint(uint256 amount, address recipient) external returns (uint256); function balanceOf(address account) external view returns (uint256); function price() external view returns (uint256); function decimals() external view returns (uint8); function updateMockTokenSupply(uint256 value) external; function siphon(uint256 amount) external; } contract MockYieldToken is TestYieldToken { uint256 private constant BPS = 10_000; constructor(address _underlyingToken) TestYieldToken(_underlyingToken) {} // for non eth based depositdeposits function deposit(uint256 amount) external returns (uint256) { require(amount > 0); uint256 shares = _issueSharesForAmount(msg.sender, amount); TokenUtils.safeTransferFrom(underlyingToken, msg.sender, address(this), amount); return shares; } function requestWithdraw(address recipient, uint256 amount) external returns (uint256) { assert(amount > 0); uint256 value = _shareValue(amount); value = (value * (BPS - slippage)) / BPS; _burn(msg.sender, amount); TokenUtils.safeTransfer(underlyingToken, recipient, value); return value; } } ================================================ FILE: src/test/mocks/TestERC20.sol ================================================ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.23; import "../../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import "../../interfaces/IERC20Burnable.sol"; import "../../interfaces/IERC20Mintable.sol"; contract TestERC20 is IERC20, IERC20Mintable, IERC20Burnable { uint256 public override totalSupply; uint8 public decimals; mapping(address => uint256) public override balanceOf; mapping(address => mapping(address => uint256)) public override allowance; constructor(uint256 amountToMint, uint8 _decimals) { decimals = _decimals; mint(msg.sender, amountToMint); } function mint(address to, uint256 amount) public override { uint256 balanceNext = balanceOf[to] + amount; require(balanceNext >= amount, "overflow balance"); balanceOf[to] = balanceNext; totalSupply += amount; } function transfer(address recipient, uint256 amount) public override returns (bool) { uint256 balanceBefore = balanceOf[msg.sender]; require(balanceBefore >= amount, "insufficient balance"); balanceOf[msg.sender] = balanceBefore - amount; uint256 balanceRecipient = balanceOf[recipient]; require(balanceRecipient + amount >= balanceRecipient, "recipient balance overflow"); balanceOf[recipient] = balanceRecipient + amount; emit Transfer(msg.sender, recipient, amount); return true; } function approve(address spender, uint256 amount) public override returns (bool) { allowance[msg.sender][spender] = amount; emit Approval(msg.sender, spender, amount); return true; } function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) { uint256 allowanceBefore = allowance[sender][msg.sender]; require(allowanceBefore >= amount, "allowance insufficient"); allowance[sender][msg.sender] = allowanceBefore - amount; uint256 balanceRecipient = balanceOf[recipient]; require(balanceRecipient + amount >= balanceRecipient, "overflow balance recipient"); balanceOf[recipient] = balanceRecipient + amount; uint256 balanceSender = balanceOf[sender]; require(balanceSender >= amount, "underflow balance sender"); balanceOf[sender] = balanceSender - amount; emit Transfer(sender, recipient, amount); return true; } function burnFrom(address owner, uint256 amount) public override returns (bool) { uint256 allowanceBefore = allowance[owner][msg.sender]; require(allowanceBefore >= amount, "allowance insufficient"); allowance[owner][msg.sender] = allowanceBefore - amount; uint256 balanceOwner = balanceOf[owner]; require(balanceOwner >= amount, "overflow balance recipient"); balanceOf[owner] = balanceOwner - amount; totalSupply -= amount; emit Transfer(msg.sender, address(0), amount); return true; } function burn(uint256 amount) public override returns (bool) { uint256 balanceOwner = balanceOf[msg.sender]; require(balanceOwner >= amount, "overflow balance recipient"); balanceOf[msg.sender] = balanceOwner - amount; totalSupply -= amount; emit Transfer(msg.sender, address(0), amount); return true; } } ================================================ FILE: src/test/mocks/TestYieldToken.sol ================================================ pragma solidity ^0.8.23; import {ERC20} from "../../../lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; import "../../libraries/TokenUtils.sol"; import "../../interfaces/test/ITestYieldToken.sol"; import "./TestERC20.sol"; /// @title TestYieldToken /// @author Alchemix Finance contract TestYieldToken is ITestYieldToken, ERC20 { address private constant BLACKHOLE = address(0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB); uint256 private constant BPS = 10_000; address public override underlyingToken; uint8 private _decimals; uint256 public slippage; uint256 public mockedSupply; event TestYieldTokenLogEvent(string message, uint256 amount, address recipient); constructor(address _underlyingToken) ERC20("Yield Token", "Yield Token") { underlyingToken = _underlyingToken; _decimals = TokenUtils.expectDecimals(_underlyingToken); slippage = 0; } function decimals() public view override returns (uint8) { return _decimals; } function price() external view override returns (uint256) { return _shareValue(10 ** _decimals); } function setSlippage(uint256 _slippage) external { slippage = _slippage; } function updateMockTokenSupply(uint256 value) external { mockedSupply = value; } function mint(uint256 amount, address recipient) external override returns (uint256) { assert(amount > 0); uint256 shares = _issueSharesForAmount(recipient, amount); TokenUtils.safeTransferFrom(underlyingToken, msg.sender, address(this), amount); return shares; } function redeem(uint256 shares, address recipient) external override returns (uint256) { assert(shares > 0); uint256 value = _shareValue(shares); value = (value * (BPS - slippage)) / BPS; _burn(msg.sender, shares); TokenUtils.safeTransfer(underlyingToken, recipient, value); return value; } function slurp(uint256 amount) external override { TokenUtils.safeTransferFrom(underlyingToken, msg.sender, address(this), amount); } function siphon(uint256 amount) external override { TokenUtils.safeTransfer(underlyingToken, BLACKHOLE, amount); } function _issueSharesForAmount(address to, uint256 amount) internal returns (uint256) { uint256 shares = 0; if (mockTokenSupply() > 0) { shares = (amount * mockTokenSupply()) / TokenUtils.safeBalanceOf(underlyingToken, address(this)); } else { shares = amount; } shares = (shares * (BPS - slippage)) / BPS; _mint(to, shares); return shares; } function _shareValue(uint256 shares) internal view returns (uint256) { if (mockTokenSupply() == 0) { return shares; } return (shares * TokenUtils.safeBalanceOf(underlyingToken, address(this))) / mockTokenSupply(); } function mockTokenSupply() public view returns (uint256) { return mockedSupply > 0 ? mockedSupply : totalSupply(); } } ================================================ FILE: src/test/mocks/TokenAdapterMock.sol ================================================ pragma solidity ^0.8.23; import {IYieldToken} from "../../interfaces/IYieldToken.sol"; contract TokenAdapterMock { address public token; constructor(address _token) { token = _token; } function price() external view returns (uint256) { return IYieldToken(token).price(); } } ================================================ FILE: src/test/router/AlchemistRouter.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "forge-std/Test.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {TransparentUpgradeableProxy} from "lib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {AlchemistRouter} from "../../router/AlchemistRouter.sol"; import {AlchemistV3} from "../../AlchemistV3.sol"; import {AlchemistV3Position} from "../../AlchemistV3Position.sol"; import {AlchemistV3PositionRenderer} from "../../AlchemistV3PositionRenderer.sol"; import {AlchemistTokenVault} from "../../AlchemistTokenVault.sol"; import {AlchemistStrategyClassifier} from "../../AlchemistStrategyClassifier.sol"; import {Transmuter} from "../../Transmuter.sol"; import {Whitelist} from "../../utils/Whitelist.sol"; import {TestERC20} from "../mocks/TestERC20.sol"; import {IAlchemistV3, AlchemistInitializationParams} from "../../interfaces/IAlchemistV3.sol"; import {IAlchemistV3Position} from "../../interfaces/IAlchemistV3Position.sol"; import {ITransmuter} from "../../interfaces/ITransmuter.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {AlchemicTokenV3} from "../mocks/AlchemicTokenV3.sol"; import {MockYieldToken} from "../mocks/MockYieldToken.sol"; import {MockMYTStrategy} from "../mocks/MockMYTStrategy.sol"; import {MockAlchemistAllocator} from "../mocks/MockAlchemistAllocator.sol"; import {MockWETH} from "../mocks/MockWETH.sol"; import {MYTTestHelper} from "../libraries/MYTTestHelper.sol"; import {TokenUtils} from "../../libraries/TokenUtils.sol"; import {SafeERC20} from "../../libraries/SafeERC20.sol"; contract AlchemistRouterTest is Test { // ----- [SETUP] Variables for setting up a minimal CDP ----- // Callable contract variables AlchemistV3 alchemist; Transmuter transmuter; AlchemistV3Position alchemistNFT; AlchemistTokenVault alchemistFeeVault; // // Proxy variables TransparentUpgradeableProxy proxyAlchemist; TransparentUpgradeableProxy proxyTransmuter; // // Contract variables // CheatCodes cheats = CheatCodes(HEVM_ADDRESS); AlchemistV3 alchemistLogic; Transmuter transmuterLogic; AlchemicTokenV3 alToken; Whitelist whitelist; // Parameters for AlchemicTokenV2 string public _name; string public _symbol; uint256 public _flashFee; address public alOwner; uint256 internal constant ONE_Q128 = uint256(1) << 128; mapping(address => bool) users; uint256 public constant FIXED_POINT_SCALAR = 1e18; uint256 public constant BPS = 10_000; uint256 public protocolFee = 100; uint256 public liquidatorFeeBPS = 300; // in BPS, 3% uint256 public repaymentFeeBPS = 100; uint256 public minimumCollateralization = uint256(FIXED_POINT_SCALAR * FIXED_POINT_SCALAR) / 9e17; uint256 public liquidationTargetCollateralization = uint256(1e36) / 88e16; // ~113.63% (88% LTV) // ----- Variables for deposits & withdrawals ----- // account funds to make deposits/test with uint256 accountFunds; // large amount to test with uint256 whaleSupply; // amount of yield/underlying token to deposit uint256 depositAmount; // minimum amount of yield/underlying token to deposit uint256 minimumDeposit = 1000e18; // minimum amount of yield/underlying token to deposit uint256 minimumDepositOrWithdrawalLoss = FIXED_POINT_SCALAR; // random EOA for testing address externalUser = address(0x69E8cE9bFc01AA33cD2d02Ed91c72224481Fa420); // another random EOA for testing address anotherExternalUser = address(0x420Ab24368E5bA8b727E9B8aB967073Ff9316969); // another random EOA for testing address yetAnotherExternalUser = address(0x520aB24368e5Ba8B727E9b8aB967073Ff9316961); // another random EOA for testing address someWhale = address(0x521aB24368E5Ba8b727e9b8AB967073fF9316961); // WETH address address public weth = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); address public protocolFeeReceiver = address(10); // MYT variables IVaultV2 vault; MockAlchemistAllocator allocator; MockMYTStrategy mytStrategy; address public operator = address(0x2222222222222222222222222222222222222222); // default operator address public admin = address(0x4444444444444444444444444444444444444444); // DAO OSX address public curator = address(0x8888888888888888888888888888888888888888); address public mockVaultCollateral = address(new TestERC20(100e18, uint8(18))); address public mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); uint256 public defaultStrategyAbsoluteCap = 2_000_000_000e18; uint256 public defaultStrategyRelativeCap = 1e18; // 100% // Router test variables AlchemistRouter router; address user; address underlying; address debtToken; address mytVault; AlchemistV3Position nft; uint256 constant AMOUNT = 1000e18; uint256 constant BORROW_AMOUNT = 100e18; struct CalculateLiquidationResult { uint256 liquidationAmountInYield; uint256 debtToBurn; uint256 outSourcedFee; uint256 baseFeeInYield; } struct AccountPosition { address user; uint256 collateral; uint256 debt; uint256 tokenId; } function setUp() external { adJustTestFunds(18); setUpMYT(); deployCoreContracts(18); router = new AlchemistRouter(address(alchemist)); user = makeAddr("user"); underlying = address(vault.asset()); debtToken = address(alToken); mytVault = address(vault); nft = alchemistNFT; transmuter = transmuterLogic; } function adJustTestFunds(uint256 alchemistUnderlyingTokenDecimals) public { accountFunds = 200_000 * 10 ** alchemistUnderlyingTokenDecimals; whaleSupply = 20_000_000_000 * 10 ** alchemistUnderlyingTokenDecimals; depositAmount = 200_000 * 10 ** alchemistUnderlyingTokenDecimals; } function setUpMYT() public { vm.startPrank(admin); mockVaultCollateral = address(new MockWETH()); mockStrategyYieldToken = address(new MockYieldToken(mockVaultCollateral)); vault = MYTTestHelper._setupVault(mockVaultCollateral, admin, curator); mytStrategy = MYTTestHelper._setupStrategy( address(vault), mockStrategyYieldToken, admin, "MockToken", "MockTokenProtocol", IMYTStrategy.RiskClass.LOW ); allocator = new MockAlchemistAllocator( address(vault), admin, operator, address(new AlchemistStrategyClassifier(admin)) ); vm.stopPrank(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (address(allocator), true))); vault.setIsAllocator(address(allocator), true); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, address(mytStrategy))); vault.addAdapter(address(mytStrategy)); bytes memory idData = mytStrategy.getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, defaultStrategyAbsoluteCap))); vault.increaseAbsoluteCap(idData, defaultStrategyAbsoluteCap); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, defaultStrategyRelativeCap))); vault.increaseRelativeCap(idData, defaultStrategyRelativeCap); vm.stopPrank(); } function _magicDepositToVault(address _vault, address depositor, uint256 amount) internal returns (uint256) { deal(address(mockVaultCollateral), address(depositor), amount); vm.startPrank(depositor); TokenUtils.safeApprove(address(mockVaultCollateral), _vault, amount); uint256 shares = IVaultV2(_vault).deposit(amount, depositor); vm.stopPrank(); return shares; } function _vaultSubmitAndFastForward(bytes memory data) internal { vault.submit(data); bytes4 selector = bytes4(data); vm.warp(block.timestamp + vault.timelock(selector)); } function deployCoreContracts(uint256 alchemistUnderlyingTokenDecimals) public { // test maniplulation for convenience address caller = address(0xdead); address proxyOwner = address(this); vm.assume(caller != address(0)); vm.assume(proxyOwner != address(0)); vm.assume(caller != proxyOwner); vm.startPrank(caller); // Fake tokens alToken = new AlchemicTokenV3(_name, _symbol, _flashFee); ITransmuter.TransmuterInitializationParams memory transParams = ITransmuter.TransmuterInitializationParams({ syntheticToken: address(alToken), feeReceiver: address(this), timeToTransmute: 5_256_000, transmutationFee: 10, exitFee: 20, graphSize: 52_560_000 }); // Contracts and logic contracts alOwner = caller; transmuterLogic = new Transmuter(transParams); alchemistLogic = new AlchemistV3(); whitelist = new Whitelist(); // AlchemistV3 proxy AlchemistInitializationParams memory params = AlchemistInitializationParams({ admin: alOwner, debtToken: address(alToken), underlyingToken: address(vault.asset()), depositCap: type(uint256).max, minimumCollateralization: minimumCollateralization, collateralizationLowerBound: 1_052_631_578_950_000_000, // 1.05 collateralization globalMinimumCollateralization: 1_111_111_111_111_111_111, // 1.1 liquidationTargetCollateralization: liquidationTargetCollateralization, transmuter: address(transmuterLogic), protocolFee: 0, protocolFeeReceiver: protocolFeeReceiver, liquidatorFee: liquidatorFeeBPS, repaymentFee: repaymentFeeBPS, myt: address(vault) }); bytes memory alchemParams = abi.encodeWithSelector(AlchemistV3.initialize.selector, params); proxyAlchemist = new TransparentUpgradeableProxy(address(alchemistLogic), proxyOwner, alchemParams); alchemist = AlchemistV3(address(proxyAlchemist)); // Whitelist alchemist proxy for minting tokens alToken.setWhitelist(address(proxyAlchemist), true); whitelist.add(address(0xbeef)); whitelist.add(externalUser); whitelist.add(anotherExternalUser); transmuterLogic.setAlchemist(address(alchemist)); transmuterLogic.setDepositCap(uint256(type(int256).max)); alchemistNFT = new AlchemistV3Position(address(alchemist), alOwner); alchemistNFT.setMetadataRenderer(address(new AlchemistV3PositionRenderer())); alchemist.setAlchemistPositionNFT(address(alchemistNFT)); alchemistFeeVault = new AlchemistTokenVault(address(vault.asset()), address(alchemist), alOwner); alchemistFeeVault.setAuthorization(address(alchemist), true); alchemist.setAlchemistFeeVault(address(alchemistFeeVault)); _magicDepositToVault(address(vault), address(0xbeef), accountFunds); _magicDepositToVault(address(vault), address(0xdad), accountFunds); _magicDepositToVault(address(vault), externalUser, accountFunds); _magicDepositToVault(address(vault), yetAnotherExternalUser, accountFunds); _magicDepositToVault(address(vault), anotherExternalUser, accountFunds); vm.stopPrank(); vm.startPrank(address(admin)); allocator.allocate(address(mytStrategy), vault.convertToAssets(vault.totalSupply())); vm.stopPrank(); deal(address(alToken), address(0xdad), accountFunds); deal(address(alToken), address(anotherExternalUser), accountFunds); deal(address(vault.asset()), address(0xbeef), accountFunds); deal(address(vault.asset()), externalUser, accountFunds); deal(address(vault.asset()), yetAnotherExternalUser, accountFunds); deal(address(vault.asset()), anotherExternalUser, accountFunds); deal(address(vault.asset()), alchemist.alchemistFeeVault(), 10_000 * (10 ** alchemistUnderlyingTokenDecimals)); vm.startPrank(anotherExternalUser); SafeERC20.safeApprove(address(vault.asset()), address(vault), accountFunds); vm.stopPrank(); vm.startPrank(yetAnotherExternalUser); SafeERC20.safeApprove(address(vault.asset()), address(vault), accountFunds); vm.stopPrank(); vm.startPrank(someWhale); deal(address(vault), someWhale, whaleSupply); deal(address(vault.asset()), someWhale, whaleSupply); SafeERC20.safeApprove(address(vault.asset()), address(mockStrategyYieldToken), whaleSupply); vm.stopPrank(); } function _deadline() internal view returns (uint256) { return block.timestamp + 1 hours; } // ═══════════════════════════════════════════════════════════════════════ // depositUnderlying — new position // ═══════════════════════════════════════════════════════════════════════ function test_depositUnderlying_newPosition() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); } function test_depositUnderlying_withBorrow() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, BORROW_AMOUNT, 0, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); assertGe(IERC20(debtToken).balanceOf(user), BORROW_AMOUNT, "Debt tokens not received"); } // ═══════════════════════════════════════════════════════════════════════ // depositETH — new position // ═══════════════════════════════════════════════════════════════════════ function test_depositETH_newPosition() public { vm.deal(user, AMOUNT); vm.prank(user); uint256 tokenId = router.depositETH{value: AMOUNT}(0, 0, 0, _deadline()); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); } function test_depositETH_withBorrow() public { vm.deal(user, AMOUNT); vm.prank(user); uint256 tokenId = router.depositETH{value: AMOUNT}(0, BORROW_AMOUNT, 0, _deadline()); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); assertGe(IERC20(debtToken).balanceOf(user), BORROW_AMOUNT, "Debt tokens not received"); } // ═══════════════════════════════════════════════════════════════════════ // depositUnderlyingToExisting // ═══════════════════════════════════════════════════════════════════════ function test_depositUnderlyingToExisting() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT * 2); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); router.depositUnderlying(tokenId, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); } function test_depositUnderlyingToExisting_withBorrow() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT * 2); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); alchemist.approveMint(tokenId, address(router), BORROW_AMOUNT); router.depositUnderlying(tokenId, AMOUNT, BORROW_AMOUNT, 0, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); assertGe(IERC20(debtToken).balanceOf(user), BORROW_AMOUNT, "Debt tokens not received"); } // ═══════════════════════════════════════════════════════════════════════ // depositETHToExisting // ═══════════════════════════════════════════════════════════════════════ function test_depositETHToExisting() public { vm.deal(user, AMOUNT * 2); vm.startPrank(user); uint256 tokenId = router.depositETH{value: AMOUNT}(0, 0, 0, _deadline()); router.depositETH{value: AMOUNT}(tokenId, 0, 0, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); } function test_depositETHToExisting_withBorrow() public { vm.deal(user, AMOUNT * 2); vm.startPrank(user); uint256 tokenId = router.depositETH{value: AMOUNT}(0, 0, 0, _deadline()); alchemist.approveMint(tokenId, address(router), BORROW_AMOUNT); router.depositETH{value: AMOUNT}(tokenId, BORROW_AMOUNT, 0, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); assertGe(IERC20(debtToken).balanceOf(user), BORROW_AMOUNT, "Debt tokens not received"); } // ═══════════════════════════════════════════════════════════════════════ // depositMYT — new position // ═══════════════════════════════════════════════════════════════════════ function test_depositMYT_newPosition() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(mytVault, AMOUNT); uint256 shares = IVaultV2(mytVault).deposit(AMOUNT, user); IERC20(mytVault).approve(address(router), shares); uint256 tokenId = router.depositMYT(0, shares, 0, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); } function test_depositMYT_withBorrow() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(mytVault, AMOUNT); uint256 shares = IVaultV2(mytVault).deposit(AMOUNT, user); IERC20(mytVault).approve(address(router), shares); uint256 tokenId = router.depositMYT(0, shares, BORROW_AMOUNT, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); assertGe(IERC20(debtToken).balanceOf(user), BORROW_AMOUNT, "Debt tokens not received"); } // ═══════════════════════════════════════════════════════════════════════ // depositMYTToExisting // ═══════════════════════════════════════════════════════════════════════ function test_depositMYTToExisting() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); IERC20(underlying).approve(mytVault, AMOUNT); uint256 shares = IVaultV2(mytVault).deposit(AMOUNT, user); IERC20(mytVault).approve(address(router), shares); router.depositMYT(tokenId, shares, 0, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); } function test_depositMYTToExisting_withBorrow() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); IERC20(underlying).approve(mytVault, AMOUNT); uint256 shares = IVaultV2(mytVault).deposit(AMOUNT, user); IERC20(mytVault).approve(address(router), shares); alchemist.approveMint(tokenId, address(router), BORROW_AMOUNT); router.depositMYT(tokenId, shares, BORROW_AMOUNT, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT not owned by user"); assertGe(IERC20(debtToken).balanceOf(user), BORROW_AMOUNT, "Debt tokens not received"); } function test_revert_depositMYTToExisting_notOwner() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); address attacker = makeAddr("attacker"); deal(underlying, attacker, AMOUNT); vm.startPrank(attacker); IERC20(underlying).approve(mytVault, AMOUNT); uint256 shares = IVaultV2(mytVault).deposit(AMOUNT, attacker); IERC20(mytVault).approve(address(router), shares); vm.expectRevert("Not position owner"); router.depositMYT(tokenId, shares, 0, _deadline()); vm.stopPrank(); } // ═══════════════════════════════════════════════════════════════════════ // depositETHToVaultOnly // ═══════════════════════════════════════════════════════════════════════ function test_depositETHToVaultOnly() public { vm.deal(user, AMOUNT); vm.prank(user); uint256 shares = router.depositETHToVaultOnly{value: AMOUNT}(0, _deadline()); assertGt(shares, 0, "No shares returned"); assertGt(IERC20(mytVault).balanceOf(user), 0, "User has no MYT shares"); } // ═══════════════════════════════════════════════════════════════════════ // Reverts // ═══════════════════════════════════════════════════════════════════════ function test_revert_expired() public { vm.prank(user); vm.expectRevert("Expired"); router.depositUnderlying(0, AMOUNT, 0, 0, block.timestamp - 1); } function test_revert_depositETH_noValue() public { vm.prank(user); vm.expectRevert("No ETH sent"); router.depositETH{value: 0}(0, 0, 0, _deadline()); } function test_revert_depositETHToExisting_noValue() public { vm.prank(user); vm.expectRevert("No ETH sent"); router.depositETH{value: 0}(1, 0, 0, _deadline()); } function test_revert_depositETHToVaultOnly_noValue() public { vm.prank(user); vm.expectRevert("No ETH sent"); router.depositETHToVaultOnly{value: 0}(0, _deadline()); } function test_revert_slippage() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); vm.expectRevert("Slippage"); router.depositUnderlying(0, AMOUNT, 0, type(uint256).max, _deadline()); vm.stopPrank(); } function test_revert_directETH() public { vm.deal(user, 1 ether); vm.prank(user); vm.expectRevert("Use depositETH"); (bool s, ) = address(router).call{value: 1 ether}(""); s; } function test_revert_depositToExisting_notOwner() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); address attacker = makeAddr("attacker"); deal(underlying, attacker, AMOUNT); vm.startPrank(attacker); IERC20(underlying).approve(address(router), AMOUNT); vm.expectRevert("Not position owner"); router.depositUnderlying(tokenId, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); } // ═══════════════════════════════════════════════════════════════════════ // Statelessness invariants // ═══════════════════════════════════════════════════════════════════════ function test_routerIsEmptyAfterDeposit() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "Underlying stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(nft.balanceOf(address(router)), 0, "NFT stuck"); assertEq(address(router).balance, 0, "ETH stuck"); } function test_routerIsEmptyAfterETHDeposit() public { vm.deal(user, AMOUNT); vm.prank(user); router.depositETH{value: AMOUNT}(0, 0, 0, _deadline()); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "WETH stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(nft.balanceOf(address(router)), 0, "NFT stuck"); assertEq(address(router).balance, 0, "ETH stuck"); } function test_routerIsEmptyAfterExistingDeposit() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT * 2); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); router.depositUnderlying(tokenId, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "Underlying stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(address(router).balance, 0, "ETH stuck"); } // ═══════════════════════════════════════════════════════════════════════ // No residual approvals // ═══════════════════════════════════════════════════════════════════════ function test_noResidualApprovals() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).allowance(address(router), mytVault), 0, "Underlying to MYT approval not cleared"); assertEq( IERC20(mytVault).allowance(address(router), address(alchemist)), 0, "MYT to Alchemist approval not cleared" ); } function test_noResidualApprovals_existing() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT * 2); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); router.depositUnderlying(tokenId, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).allowance(address(router), mytVault), 0, "Underlying to MYT approval not cleared"); assertEq( IERC20(mytVault).allowance(address(router), address(alchemist)), 0, "MYT to Alchemist approval not cleared" ); } // ═══════════════════════════════════════════════════════════════════════ // repayUnderlying // ═══════════════════════════════════════════════════════════════════════ function test_repayUnderlying() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT * 2); uint256 tokenId = router.depositUnderlying(0, AMOUNT, BORROW_AMOUNT, 0, _deadline()); uint256 debtBefore = IERC20(debtToken).balanceOf(user); assertGe(debtBefore, BORROW_AMOUNT, "No debt tokens minted"); vm.roll(block.number + 1); router.repayUnderlying(tokenId, AMOUNT, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "Underlying stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(IERC20(underlying).allowance(address(router), mytVault), 0, "Approval not cleared"); assertEq(IERC20(mytVault).allowance(address(router), address(alchemist)), 0, "MYT approval not cleared"); } function test_repayUnderlying_overpayReturnsShares() public { uint256 smallBorrow = 10 ether; deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT * 2); uint256 tokenId = router.depositUnderlying(0, AMOUNT, smallBorrow, 0, _deadline()); uint256 mytBefore = IERC20(mytVault).balanceOf(user); vm.roll(block.number + 1); router.repayUnderlying(tokenId, AMOUNT, 0, _deadline()); vm.stopPrank(); uint256 mytAfter = IERC20(mytVault).balanceOf(user); assertGt(mytAfter, mytBefore, "No MYT shares returned from overpay"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck in router"); } // ═══════════════════════════════════════════════════════════════════════ // repayETH // ═══════════════════════════════════════════════════════════════════════ function test_repayETH() public { vm.deal(user, AMOUNT * 2); vm.startPrank(user); uint256 tokenId = router.depositETH{value: AMOUNT}(0, BORROW_AMOUNT, 0, _deadline()); vm.roll(block.number + 1); router.repayETH{value: AMOUNT}(tokenId, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "WETH stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(address(router).balance, 0, "ETH stuck"); } function test_repayETH_overpayReturnsShares() public { uint256 smallBorrow = 10 ether; vm.deal(user, AMOUNT * 2); vm.startPrank(user); uint256 tokenId = router.depositETH{value: AMOUNT}(0, smallBorrow, 0, _deadline()); uint256 mytBefore = IERC20(mytVault).balanceOf(user); vm.roll(block.number + 1); router.repayETH{value: AMOUNT}(tokenId, 0, _deadline()); vm.stopPrank(); uint256 mytAfter = IERC20(mytVault).balanceOf(user); assertGt(mytAfter, mytBefore, "No MYT shares returned from overpay"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck in router"); } // ═══════════════════════════════════════════════════════════════════════ // claimRedemptionUnderlying // ═══════════════════════════════════════════════════════════════════════ function _createTransmuterPosition(uint256 debtAmount) internal returns (uint256 positionId) { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); router.depositUnderlying(0, AMOUNT, debtAmount, 0, _deadline()); IERC20(debtToken).approve(address(transmuter), debtAmount); transmuter.createRedemption(debtAmount, user); uint256 bal = IERC721(address(transmuter)).balanceOf(user); positionId = IAlchemistV3Position(address(transmuter)).tokenOfOwnerByIndex(user, bal - 1); vm.stopPrank(); } function test_claimRedemptionUnderlying() public { uint256 positionId = _createTransmuterPosition(BORROW_AMOUNT); vm.roll(block.number + transmuter.timeToTransmute() + 1); uint256 underlyingBefore = IERC20(underlying).balanceOf(user); vm.startPrank(user); IERC721(address(transmuter)).approve(address(router), positionId); router.claimRedemption(positionId, 0, _deadline(), false); vm.stopPrank(); assertGt(IERC20(underlying).balanceOf(user), underlyingBefore, "No underlying received"); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "Underlying stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(IERC20(debtToken).balanceOf(address(router)), 0, "Synth stuck"); } function test_claimRedemptionUnderlying_partialMaturation() public { uint256 positionId = _createTransmuterPosition(BORROW_AMOUNT); vm.roll(block.number + transmuter.timeToTransmute() / 2); uint256 underlyingBefore = IERC20(underlying).balanceOf(user); uint256 synthBefore = IERC20(debtToken).balanceOf(user); vm.startPrank(user); IERC721(address(transmuter)).approve(address(router), positionId); router.claimRedemption(positionId, 0, _deadline(), false); vm.stopPrank(); assertGt(IERC20(underlying).balanceOf(user), underlyingBefore, "No underlying received"); assertGt(IERC20(debtToken).balanceOf(user), synthBefore, "No synth returned"); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "Underlying stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(IERC20(debtToken).balanceOf(address(router)), 0, "Synth stuck"); } // ═══════════════════════════════════════════════════════════════════════ // claimRedemptionETH // ═══════════════════════════════════════════════════════════════════════ function test_claimRedemptionETH() public { address ethUser = address(0xBEEF); vm.deal(ethUser, AMOUNT); vm.startPrank(ethUser); router.depositETH{value: AMOUNT}(0, BORROW_AMOUNT, 0, _deadline()); IERC20(debtToken).approve(address(transmuter), BORROW_AMOUNT); transmuter.createRedemption(BORROW_AMOUNT, ethUser); uint256 bal = IERC721(address(transmuter)).balanceOf(ethUser); uint256 positionId = IAlchemistV3Position(address(transmuter)).tokenOfOwnerByIndex(ethUser, bal - 1); vm.stopPrank(); vm.roll(block.number + transmuter.timeToTransmute() + 1); uint256 ethBefore = ethUser.balance; vm.startPrank(ethUser); IERC721(address(transmuter)).approve(address(router), positionId); router.claimRedemption(positionId, 0, _deadline(), true); vm.stopPrank(); assertGt(ethUser.balance, ethBefore, "No ETH received"); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "WETH stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(IERC20(debtToken).balanceOf(address(router)), 0, "Synth stuck"); assertEq(address(router).balance, 0, "ETH stuck in router"); } // ═══════════════════════════════════════════════════════════════════════ // Security: mintFrom allowance theft prevention // ═══════════════════════════════════════════════════════════════════════ function test_revert_mintFromTheft_underlying() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 victimTokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); alchemist.approveMint(victimTokenId, address(router), BORROW_AMOUNT); vm.stopPrank(); address attacker = makeAddr("attacker"); deal(underlying, attacker, 1 ether); vm.startPrank(attacker); IERC20(underlying).approve(address(router), 1 ether); vm.expectRevert("Not position owner"); router.depositUnderlying(victimTokenId, 1 ether, BORROW_AMOUNT, 0, _deadline()); vm.stopPrank(); } function test_revert_mintFromTheft_ETH() public { vm.deal(user, AMOUNT); vm.startPrank(user); uint256 victimTokenId = router.depositETH{value: AMOUNT}(0, 0, 0, _deadline()); alchemist.approveMint(victimTokenId, address(router), BORROW_AMOUNT); vm.stopPrank(); address attacker = makeAddr("attacker"); vm.deal(attacker, 1 ether); vm.prank(attacker); vm.expectRevert("Not position owner"); router.depositETH{value: 1 ether}(victimTokenId, BORROW_AMOUNT, 0, _deadline()); } // ═══════════════════════════════════════════════════════════════════════ // Security: zero amount checks // ═══════════════════════════════════════════════════════════════════════ function test_revert_depositUnderlying_zeroAmount() public { vm.prank(user); vm.expectRevert("Zero amount"); router.depositUnderlying(0, 0, 0, 0, _deadline()); } function test_revert_depositUnderlyingToExisting_zeroAmount() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); vm.prank(user); vm.expectRevert("Zero amount"); router.depositUnderlying(tokenId, 0, 0, 0, _deadline()); } function test_revert_repayUnderlying_zeroAmount() public { vm.prank(user); vm.expectRevert("Zero amount"); router.repayUnderlying(1, 0, 0, _deadline()); } function test_revert_withdrawUnderlying_zeroShares() public { vm.prank(user); vm.expectRevert("Zero shares"); router.withdrawUnderlying(1, 0, 0, _deadline()); } function test_revert_withdrawETH_zeroShares() public { vm.prank(user); vm.expectRevert("Zero shares"); router.withdrawETH(1, 0, 0, _deadline()); } // ═══════════════════════════════════════════════════════════════════════ // withdrawUnderlying // ═══════════════════════════════════════════════════════════════════════ function test_withdrawUnderlying() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); (uint256 collateral, , ) = alchemist.getCDP(tokenId); uint256 underlyingBefore = IERC20(underlying).balanceOf(user); nft.approve(address(router), tokenId); router.withdrawUnderlying(tokenId, collateral, 0, _deadline()); vm.stopPrank(); uint256 underlyingAfter = IERC20(underlying).balanceOf(user); assertGt(underlyingAfter, underlyingBefore, "No underlying received"); assertEq(nft.ownerOf(tokenId), user, "NFT not returned"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); } // ═══════════════════════════════════════════════════════════════════════ // withdrawETH // ═══════════════════════════════════════════════════════════════════════ function test_withdrawETH() public { address ethUser = address(0xBEEF); vm.deal(ethUser, AMOUNT); vm.startPrank(ethUser); uint256 tokenId = router.depositETH{value: AMOUNT}(0, 0, 0, _deadline()); (uint256 collateral, , ) = alchemist.getCDP(tokenId); nft.approve(address(router), tokenId); vm.stopPrank(); uint256 ethBefore = ethUser.balance; vm.prank(ethUser); router.withdrawETH(tokenId, collateral, 0, _deadline()); assertGt(ethUser.balance, ethBefore, "No ETH received"); assertEq(nft.ownerOf(tokenId), ethUser, "NFT not returned"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "WETH stuck"); assertEq(address(router).balance, 0, "ETH stuck in router"); } function test_routerIsEmptyAfterWithdraw() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); (uint256 collateral, , ) = alchemist.getCDP(tokenId); nft.approve(address(router), tokenId); router.withdrawUnderlying(tokenId, collateral, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "Underlying stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(nft.balanceOf(address(router)), 0, "NFT stuck"); assertEq(address(router).balance, 0, "ETH stuck"); } // ═══════════════════════════════════════════════════════════════════════ // Security: withdraw by non-owner reverts // ═══════════════════════════════════════════════════════════════════════ function test_revert_withdrawUnderlying_notOwner() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); address attacker = makeAddr("attacker"); vm.prank(attacker); vm.expectRevert(); router.withdrawUnderlying(tokenId, 1, 0, _deadline()); } function test_noResidualApprovals_withdraw() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); (uint256 collateral, , ) = alchemist.getCDP(tokenId); nft.approve(address(router), tokenId); router.withdrawUnderlying(tokenId, collateral, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).allowance(address(router), mytVault), 0, "Underlying->MYT approval"); assertEq(IERC20(mytVault).allowance(address(router), address(alchemist)), 0, "MYT->Alchemist approval"); } // ═══════════════════════════════════════════════════════════════════════ // Security: claimRedemption by non-owner reverts // ═══════════════════════════════════════════════════════════════════════ function test_revert_claimRedemptionUnderlying_notOwner() public { uint256 positionId = _createTransmuterPosition(BORROW_AMOUNT); vm.roll(block.number + transmuter.timeToTransmute() + 1); address attacker = makeAddr("attacker"); vm.prank(attacker); vm.expectRevert(); router.claimRedemption(positionId, 0, _deadline(), false); } function test_revert_claimRedemptionETH_notOwner() public { uint256 positionId = _createTransmuterPosition(BORROW_AMOUNT); vm.roll(block.number + transmuter.timeToTransmute() + 1); address attacker = makeAddr("attacker"); vm.prank(attacker); vm.expectRevert(); router.claimRedemption(positionId, 0, _deadline(), true); } // ═══════════════════════════════════════════════════════════════════════ // Security: no residual approvals after repay // ═══════════════════════════════════════════════════════════════════════ function test_noResidualApprovals_repay() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT * 2); uint256 tokenId = router.depositUnderlying(0, AMOUNT, BORROW_AMOUNT, 0, _deadline()); vm.roll(block.number + 1); router.repayUnderlying(tokenId, AMOUNT, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).allowance(address(router), mytVault), 0, "Underlying->MYT approval"); assertEq(IERC20(mytVault).allowance(address(router), address(alchemist)), 0, "MYT->Alchemist approval"); } // ═══════════════════════════════════════════════════════════════════════ // Security: router statelessness after every flow // ═══════════════════════════════════════════════════════════════════════ function test_routerIsEmptyAfterRepay() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT * 2); uint256 tokenId = router.depositUnderlying(0, AMOUNT, BORROW_AMOUNT, 0, _deadline()); vm.roll(block.number + 1); router.repayUnderlying(tokenId, AMOUNT, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "Underlying stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(nft.balanceOf(address(router)), 0, "NFT stuck"); assertEq(address(router).balance, 0, "ETH stuck"); } function test_routerIsEmptyAfterRepayETH() public { vm.deal(user, AMOUNT * 2); vm.startPrank(user); uint256 tokenId = router.depositETH{value: AMOUNT}(0, BORROW_AMOUNT, 0, _deadline()); vm.roll(block.number + 1); router.repayETH{value: AMOUNT}(tokenId, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "WETH stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(nft.balanceOf(address(router)), 0, "NFT stuck"); assertEq(address(router).balance, 0, "ETH stuck"); } function test_routerIsEmptyAfterClaimRedemption() public { uint256 positionId = _createTransmuterPosition(BORROW_AMOUNT); vm.roll(block.number + transmuter.timeToTransmute() + 1); vm.startPrank(user); IERC721(address(transmuter)).approve(address(router), positionId); router.claimRedemption(positionId, 0, _deadline(), false); vm.stopPrank(); assertEq(IERC20(underlying).balanceOf(address(router)), 0, "Underlying stuck"); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(IERC20(debtToken).balanceOf(address(router)), 0, "Synth stuck"); assertEq(address(router).balance, 0, "ETH stuck"); } // ═══════════════════════════════════════════════════════════════════════ // Security: deadline enforcement on all functions // ═══════════════════════════════════════════════════════════════════════ function test_revert_repayUnderlying_expired() public { vm.prank(user); vm.expectRevert("Expired"); router.repayUnderlying(1, AMOUNT, 0, block.timestamp - 1); } function test_revert_repayETH_expired() public { vm.deal(user, AMOUNT); vm.prank(user); vm.expectRevert("Expired"); router.repayETH{value: AMOUNT}(1, 0, block.timestamp - 1); } function test_revert_claimRedemptionUnderlying_expired() public { vm.prank(user); vm.expectRevert("Expired"); router.claimRedemption(1, 0, block.timestamp - 1, false); } function test_revert_claimRedemptionETH_expired() public { vm.prank(user); vm.expectRevert("Expired"); router.claimRedemption(1, 0, block.timestamp - 1, true); } function test_revert_depositETHToVaultOnly_expired() public { vm.deal(user, AMOUNT); vm.prank(user); vm.expectRevert("Expired"); router.depositETHToVaultOnly{value: AMOUNT}(0, block.timestamp - 1); } // ═══════════════════════════════════════════════════════════════════════ // Security: slippage on claimRedemption // ═══════════════════════════════════════════════════════════════════════ function test_revert_claimRedemptionUnderlying_slippage() public { uint256 positionId = _createTransmuterPosition(BORROW_AMOUNT); vm.roll(block.number + transmuter.timeToTransmute() + 1); vm.startPrank(user); IERC721(address(transmuter)).approve(address(router), positionId); vm.expectRevert("Slippage"); router.claimRedemption(positionId, type(uint256).max, _deadline(), false); vm.stopPrank(); } function test_revert_claimRedemptionETH_slippage() public { vm.deal(user, AMOUNT); vm.startPrank(user); router.depositETH{value: AMOUNT}(0, BORROW_AMOUNT, 0, _deadline()); IERC20(debtToken).approve(address(transmuter), BORROW_AMOUNT); transmuter.createRedemption(BORROW_AMOUNT, user); uint256 bal = IERC721(address(transmuter)).balanceOf(user); uint256 positionId = IAlchemistV3Position(address(transmuter)).tokenOfOwnerByIndex(user, bal - 1); vm.stopPrank(); vm.roll(block.number + transmuter.timeToTransmute() + 1); vm.startPrank(user); IERC721(address(transmuter)).approve(address(router), positionId); vm.expectRevert("Slippage"); router.claimRedemption(positionId, type(uint256).max, _deadline(), true); vm.stopPrank(); } // ═══════════════════════════════════════════════════════════════════════ // Security: slippage on repay vault deposit // ═══════════════════════════════════════════════════════════════════════ function test_revert_repayUnderlying_slippage() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT * 2); uint256 tokenId = router.depositUnderlying(0, AMOUNT, BORROW_AMOUNT, 0, _deadline()); vm.roll(block.number + 1); vm.expectRevert("Slippage"); router.repayUnderlying(tokenId, AMOUNT, type(uint256).max, _deadline()); vm.stopPrank(); } function test_revert_repayETH_slippage() public { vm.deal(user, AMOUNT * 2); vm.startPrank(user); uint256 tokenId = router.depositETH{value: AMOUNT}(0, BORROW_AMOUNT, 0, _deadline()); vm.roll(block.number + 1); vm.expectRevert("Slippage"); router.repayETH{value: AMOUNT}(tokenId, type(uint256).max, _deadline()); vm.stopPrank(); } // ═══════════════════════════════════════════════════════════════════════ // Statelessness: MYT deposit routes // ═══════════════════════════════════════════════════════════════════════ function test_routerIsEmptyAfterDepositMYT() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(mytVault, AMOUNT); uint256 shares = IVaultV2(mytVault).deposit(AMOUNT, user); IERC20(mytVault).approve(address(router), shares); router.depositMYT(0, shares, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(nft.balanceOf(address(router)), 0, "NFT stuck"); } function test_routerIsEmptyAfterDepositMYTToExisting() public { deal(underlying, user, AMOUNT * 2); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); IERC20(underlying).approve(mytVault, AMOUNT); uint256 shares = IVaultV2(mytVault).deposit(AMOUNT, user); IERC20(mytVault).approve(address(router), shares); router.depositMYT(tokenId, shares, 0, _deadline()); vm.stopPrank(); assertEq(IERC20(mytVault).balanceOf(address(router)), 0, "MYT stuck"); assertEq(IERC20(mytVault).allowance(address(router), address(alchemist)), 0, "MYT->Alchemist approval"); } // ═══════════════════════════════════════════════════════════════════════ // Attack scenarios // ═══════════════════════════════════════════════════════════════════════ function test_attack_borrowFromVictimPosition_MYT() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 victimTokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); alchemist.approveMint(victimTokenId, address(router), BORROW_AMOUNT); vm.stopPrank(); address attacker = makeAddr("attacker"); deal(underlying, attacker, 1 ether); vm.startPrank(attacker); IERC20(underlying).approve(mytVault, 1 ether); uint256 shares = IVaultV2(mytVault).deposit(1 ether, attacker); IERC20(mytVault).approve(address(router), shares); vm.expectRevert("Not position owner"); router.depositMYT(victimTokenId, shares, BORROW_AMOUNT, _deadline()); vm.stopPrank(); } function test_attack_claimRedemption_victimPosition() public { uint256 positionId = _createTransmuterPosition(BORROW_AMOUNT); vm.roll(block.number + transmuter.timeToTransmute() + 1); address attacker = makeAddr("attacker"); vm.prank(attacker); vm.expectRevert(); router.claimRedemption(positionId, 0, _deadline(), false); } function test_attack_frontRunDeposit_nftGoesToCaller() public { deal(underlying, user, AMOUNT); address attacker = makeAddr("attacker"); deal(underlying, attacker, AMOUNT); vm.startPrank(attacker); IERC20(underlying).approve(address(router), AMOUNT); uint256 attackerTokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 userTokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); vm.stopPrank(); assertEq(nft.ownerOf(attackerTokenId), attacker, "Attacker doesn't own their NFT"); assertEq(nft.ownerOf(userTokenId), user, "User doesn't own their NFT"); assertTrue(attackerTokenId != userTokenId, "Same token ID"); } function test_attack_directETHSend() public { vm.deal(user, 1 ether); vm.prank(user); (bool success, ) = address(router).call{value: 1 ether}(""); assertFalse(success, "Direct ETH should be rejected"); } function test_attack_sendNFTToRouter() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 tokenId = router.depositUnderlying(0, AMOUNT, 0, 0, _deadline()); vm.expectRevert(); nft.safeTransferFrom(user, address(router), tokenId); vm.stopPrank(); assertEq(nft.ownerOf(tokenId), user, "NFT ownership changed"); } function test_attack_repayOtherPosition_usesAttackerFunds() public { deal(underlying, user, AMOUNT); vm.startPrank(user); IERC20(underlying).approve(address(router), AMOUNT); uint256 userTokenId = router.depositUnderlying(0, AMOUNT, BORROW_AMOUNT, 0, _deadline()); vm.stopPrank(); vm.roll(block.number + 1); address attacker = makeAddr("attacker"); deal(underlying, attacker, AMOUNT); uint256 attackerBalBefore = IERC20(underlying).balanceOf(attacker); vm.startPrank(attacker); IERC20(underlying).approve(address(router), AMOUNT); router.repayUnderlying(userTokenId, AMOUNT, 0, _deadline()); vm.stopPrank(); uint256 attackerBalAfter = IERC20(underlying).balanceOf(attacker); assertLt(attackerBalAfter, attackerBalBefore, "Attacker didn't spend funds"); assertEq(nft.ownerOf(userTokenId), user, "User lost NFT"); } } ================================================ FILE: src/test/strategies/AaveV3ARBUSDCStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {AaveStrategy} from "../../strategies/AaveStrategy.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; /// @notice Replaces the real Aave RewardsController via vm.etch so that /// claimAllRewardsToSelf actually transfers reward tokens to the caller. contract MockRewardsController { IERC20 public immutable rewardToken; uint256 public immutable rewardAmount; constructor(address _rewardToken, uint256 _rewardAmount) { rewardToken = IERC20(_rewardToken); rewardAmount = _rewardAmount; } function claimAllRewardsToSelf(address[] calldata) external returns (address[] memory rewardsList, uint256[] memory claimedAmounts) { rewardToken.transfer(msg.sender, rewardAmount); rewardsList = new address[](1); rewardsList[0] = address(rewardToken); claimedAmounts = new uint256[](1); claimedAmounts[0] = rewardAmount; } } /// @notice When used as allowanceHolder, transfers a fixed amount of token to msg.sender on any call (simulates swap output). contract MockSwapExecutor { IERC20 public immutable token; uint256 public amountToTransfer; constructor(address _token, uint256 _amountToTransfer) { token = IERC20(_token); amountToTransfer = _amountToTransfer; } receive() external payable {} fallback() external { token.transfer(msg.sender, amountToTransfer); } } contract AaveV3ARBUSDCStrategyTest is BaseStrategyTest { address public constant AAVE_V3_USDC_ATOKEN = 0x724dc807b04555b71ed48a6896b6F41593b8C637; address public constant AAVE_V3_USDC_POOL = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb; address public constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; address public constant ARB = 0x912CE59144191C1204E64559FE8253a0e49E6548; address public constant REWARDS_CONTROLLER = 0x929EC64c34a17401F460460D4B9390518E5B473e; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "AaveV3ARBUSDC", protocol: "AaveV3ARBUSDC", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e6, globalCap: 1e18, estimatedYield: 100e6, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: USDC, vaultInitialDeposit: 1000e6, absoluteCap: 10_000e6, relativeCap: 1e18, decimals: 6}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new AaveStrategy(vault, params, USDC, AAVE_V3_USDC_ATOKEN, AAVE_V3_USDC_POOL, REWARDS_CONTROLLER, ARB)); } function getForkBlockNumber() internal pure override returns (uint256) { return 387_030_683; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("ARBITRUM_RPC_URL"); } function _effectiveDeallocateAmount(uint256 requestedAssets) internal view override returns (uint256) { uint256 maxWithdrawable = IMYTStrategy(strategy).realAssets(); uint256 minMeaningfulDeallocate = 1 * 10 ** testConfig.decimals; if (maxWithdrawable < minMeaningfulDeallocate || requestedAssets < minMeaningfulDeallocate) { return 0; } return requestedAssets < maxWithdrawable ? requestedAssets : maxWithdrawable; } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { bool isFuzzOrHandler = context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; return false; } // Add any strategy-specific tests here function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1 * 10 ** testConfig.decimals, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_claimRewards_emits_event_and_vault_receives_asset() public { // Allocate assets to create an Aave position bytes memory params = getVaultParams(); uint256 amountToAllocate = 1000e6; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); // Configure mock reward claim uint256 arbRewardAmount = 10e18; // 10 ARB tokens claimed uint256 mockSwapReturn = 15e6; // Simulated swap output // Deploy a MockRewardsController and etch its bytecode over the real // rewards controller address so claimAllRewardsToSelf actually transfers ARB. MockRewardsController mockRC = new MockRewardsController(ARB, arbRewardAmount); vm.etch(REWARDS_CONTROLLER, address(mockRC).code); deal(ARB, REWARDS_CONTROLLER, arbRewardAmount); // Setup MockSwapExecutor as allowanceHolder to simulate DEX swap. // Swap output should be measured in vault asset terms (USDC). // NOTE: quote must be non-empty so the call hits fallback() not receive(). MockSwapExecutor mockSwap = new MockSwapExecutor(USDC, mockSwapReturn); deal(USDC, address(mockSwap), mockSwapReturn); // Point the strategy's allowanceHolder to our mock vm.prank(address(1)); // strategy owner MYTStrategy(strategy).setAllowanceHolder(address(mockSwap)); // Record vault USDC balance before claiming uint256 vaultBalanceBefore = IERC20(USDC).balanceOf(vault); // Expect the RewardsClaimed event with correct token and amount vm.expectEmit(true, true, false, true, strategy); emit IMYTStrategy.RewardsClaimed(ARB, arbRewardAmount); // Execute claimRewards as strategy owner // Pass non-empty quote bytes so allowanceHolder.call(quote) routes to // MockSwapExecutor.fallback() (which transfers USDC) instead of receive(). bytes memory quote = hex"01"; vm.prank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(AAVE_V3_USDC_ATOKEN, quote, 4.99e6); // Verify rewards were received and vault got the asset uint256 vaultBalanceAfter = IERC20(USDC).balanceOf(vault); uint256 vaultAssetReceived = vaultBalanceAfter - vaultBalanceBefore; assertGt(received, 0, "No rewards received from claim"); assertEq(vaultAssetReceived, mockSwapReturn, "Vault did not receive expected USDC amount"); assertEq(received, vaultAssetReceived, "Returned amount is not in vault asset terms"); } // Test: Aave v3 ARB USDC vault yield accumulation over time function test_aave_v3_arbusdc_yield_accumulation() public { vm.startPrank(allocator); // Allocate initial amount uint256 allocAmount = 500e6; // 500 ARB USDC IVaultV2(vault).allocate(strategy, getVaultParams(), allocAmount); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); // Track real assets over time with warps uint256[] memory realAssetsSnapshots = new uint256[](4); uint256 minExpected = initialRealAssets * 95 / 100; // Start with 95% of initial as minimum for (uint256 i = 0; i < 4; i++) { vm.warp(block.timestamp + 30 days); // Simulate yield by transferring small amount to strategy (0.5% per period) deal(testConfig.vaultAsset, strategy, initialRealAssets * 5 / 1000); realAssetsSnapshots[i] = IMYTStrategy(strategy).realAssets(); // Real assets should not significantly decrease (may increase with yield) assertGe(realAssetsSnapshots[i], minExpected, "Real assets decreased significantly"); // Update minExpected to the new baseline minExpected = realAssetsSnapshots[i]; // Small deallocation on second snapshot if (i == 1) { uint256 smallDealloc = 50e6; // 50 ARB USDC uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(smallDealloc); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); // Update minExpected after deallocation to account for the reduction minExpected = IMYTStrategy(strategy).realAssets(); } } // Final deallocation uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e6) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } // Allow small tolerance for slippage/rounding (up to 1% of initial) assertApproxEqAbs(IMYTStrategy(strategy).realAssets(), 0, initialRealAssets / 100, "All real assets should be deallocated"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/AaveV3ARBWETHStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {AaveStrategy} from "../../strategies/AaveStrategy.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; /// @notice Replaces the real Aave RewardsController via vm.etch so that /// claimAllRewardsToSelf actually transfers reward tokens to the caller. contract MockRewardsController { IERC20 public immutable rewardToken; uint256 public immutable rewardAmount; constructor(address _rewardToken, uint256 _rewardAmount) { rewardToken = IERC20(_rewardToken); rewardAmount = _rewardAmount; } function claimAllRewardsToSelf(address[] calldata) external returns (address[] memory rewardsList, uint256[] memory claimedAmounts) { rewardToken.transfer(msg.sender, rewardAmount); rewardsList = new address[](1); rewardsList[0] = address(rewardToken); claimedAmounts = new uint256[](1); claimedAmounts[0] = rewardAmount; } } /// @notice When used as allowanceHolder, transfers a fixed amount of token to msg.sender on any call (simulates swap output). contract MockSwapExecutor { IERC20 public immutable token; uint256 public amountToTransfer; constructor(address _token, uint256 _amountToTransfer) { token = IERC20(_token); amountToTransfer = _amountToTransfer; } receive() external payable {} fallback() external { token.transfer(msg.sender, amountToTransfer); } } contract AaveV3ARBWETHStrategyTest is BaseStrategyTest { address public constant AAVE_V3_ARB_WETH_ATOKEN = 0xe50fA9b3c56FfB159cB0FCA61F5c9D750e8128c8; address public constant AAVE_V3_ARB_WETH_POOL = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb; address public constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1; address public constant ARB = 0x912CE59144191C1204E64559FE8253a0e49E6548; address public constant REWARDS_CONTROLLER = 0x929EC64c34a17401F460460D4B9390518E5B473e; // Error(string) selector (0x08c379a0), observed as "PD". // In this suite it is observed on deallocate paths (withdraw mock), not allocate. bytes4 internal constant ERROR_STRING_SELECTOR = 0x08c379a0; // Aave custom error selector (0x2c5211c6): `InvalidAmount()`. // In this suite it is observed on deallocate paths (withdraw mock), not allocate. bytes4 internal constant ALLOWED_AAVE_REVERT_SELECTOR = 0x2c5211c6; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "AaveV3ARBWETH", protocol: "AaveV3ARBWETH", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e18, globalCap: 1e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: WETH, vaultInitialDeposit: 1000e18, absoluteCap: 10_000e18, relativeCap: 1e18, decimals: 18}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address( new AaveStrategy(vault, params, WETH, AAVE_V3_ARB_WETH_ATOKEN, AAVE_V3_ARB_WETH_POOL, REWARDS_CONTROLLER, ARB) ); } function getForkBlockNumber() internal pure override returns (uint256) { return 0; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("ARBITRUM_RPC_URL"); } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { bool isFuzzOrHandler = context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; if (!isFuzzOrHandler) return false; return selector == ALLOWED_AAVE_REVERT_SELECTOR || selector == ERROR_STRING_SELECTOR; } // Add any strategy-specific tests here function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1 * 10 ** testConfig.decimals, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_allowlisted_revert_custom_selector_is_deterministic() public { uint256 amountToAllocate = 1e18; uint256 amountToDeallocate = 5e17; address mockPool = address(0xBEEF); bytes4 getPoolSelector = bytes4(keccak256("getPool()")); bytes4 withdrawSelector = bytes4(keccak256("withdraw(address,uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCall(AAVE_V3_ARB_WETH_POOL, abi.encodeWithSelector(getPoolSelector), abi.encode(mockPool)); vm.mockCallRevert( mockPool, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ALLOWED_AAVE_REVERT_SELECTOR) ); vm.expectRevert(ALLOWED_AAVE_REVERT_SELECTOR); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } function test_allowlisted_revert_error_string_is_deterministic() public { uint256 amountToAllocate = 1e18; uint256 amountToDeallocate = 5e17; address mockPool = address(0xBEEF); bytes4 getPoolSelector = bytes4(keccak256("getPool()")); bytes4 withdrawSelector = bytes4(keccak256("withdraw(address,uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCall(AAVE_V3_ARB_WETH_POOL, abi.encodeWithSelector(getPoolSelector), abi.encode(mockPool)); vm.mockCallRevert( mockPool, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ERROR_STRING_SELECTOR, "PD") ); vm.expectRevert(bytes("PD")); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } function test_claimRewards_succeeds() public { bytes memory params = getVaultParams(); // Allocate some assets first so there are positions to claim rewards for uint256 amountToAllocate = 1000e6; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); // Claim rewards should not revert vm.prank(address(1)); IMYTStrategy(strategy).claimRewards(AAVE_V3_ARB_WETH_ATOKEN, "", 0); vm.stopPrank(); } function test_claimRewards_emits_event_and_vault_receives_asset() public { // Allocate assets to create an Aave position bytes memory params = getVaultParams(); uint256 amountToAllocate = 10e18; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); // Configure mock reward claim uint256 arbRewardAmount = 10e18; // 10 ARB tokens claimed uint256 mockSwapReturn = 5e15; // Simulated WETH swap output // Deploy a MockRewardsController and etch its bytecode over the real // rewards controller address so claimAllRewardsToSelf actually transfers ARB. MockRewardsController mockRC = new MockRewardsController(ARB, arbRewardAmount); vm.etch(REWARDS_CONTROLLER, address(mockRC).code); deal(ARB, REWARDS_CONTROLLER, arbRewardAmount); // Setup MockSwapExecutor as allowanceHolder to simulate DEX swap. // Swap output should be measured in vault asset terms (WETH). // NOTE: quote must be non-empty so the call hits fallback() not receive(). MockSwapExecutor mockSwap = new MockSwapExecutor(WETH, mockSwapReturn); deal(WETH, address(mockSwap), mockSwapReturn); // Point the strategy's allowanceHolder to our mock vm.prank(address(1)); // strategy owner MYTStrategy(strategy).setAllowanceHolder(address(mockSwap)); // Record vault WETH balance before claiming uint256 vaultBalanceBefore = IERC20(WETH).balanceOf(vault); // Expect the RewardsClaimed event with correct token and amount vm.expectEmit(true, true, false, true, strategy); emit IMYTStrategy.RewardsClaimed(ARB, arbRewardAmount); // Execute claimRewards as strategy owner // Pass non-empty quote bytes so allowanceHolder.call(quote) routes to // MockSwapExecutor.fallback() (which transfers WETH) instead of receive(). bytes memory quote = hex"01"; vm.prank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(AAVE_V3_ARB_WETH_ATOKEN, quote, 4.99e15); // Verify rewards were received and vault got the asset uint256 vaultBalanceAfter = IERC20(WETH).balanceOf(vault); uint256 vaultAssetReceived = vaultBalanceAfter - vaultBalanceBefore; assertGt(received, 0, "No rewards received from claim"); assertEq(vaultAssetReceived, mockSwapReturn, "Vault did not receive expected WETH amount"); assertEq(received, vaultAssetReceived, "Returned amount is not in vault asset terms"); } // Test: Aave v3 ARB WETH vault yield accumulation over time function test_aave_v3_arbweth_yield_accumulation() public { vm.startPrank(allocator); // Allocate initial amount uint256 allocAmount = 300e18; // 300 ARB WETH IVaultV2(vault).allocate(strategy, getVaultParams(), allocAmount); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); // Track real assets over time with warps uint256[] memory realAssetsSnapshots = new uint256[](4); uint256 minExpected = initialRealAssets * 95 / 100; // Start with 95% of initial as minimum for (uint256 i = 0; i < 4; i++) { vm.warp(block.timestamp + 30 days); // Simulate yield by transferring small amount to strategy (0.5% per period) deal(testConfig.vaultAsset, strategy, initialRealAssets * 5 / 1000); realAssetsSnapshots[i] = IMYTStrategy(strategy).realAssets(); // Real assets should not significantly decrease (may increase with yield) assertGe(realAssetsSnapshots[i], minExpected, "Real assets decreased significantly"); // Update minExpected to the new baseline minExpected = realAssetsSnapshots[i]; // Small deallocation on second snapshot if (i == 1) { uint256 smallDealloc = 30e18; // 30 ARB WETH uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(smallDealloc); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); // Update minExpected after deallocation to account for the reduction minExpected = IMYTStrategy(strategy).realAssets(); } } // Final deallocation uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e15) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } // Allow small tolerance for slippage/rounding (up to 1% of initial) assertApproxEqAbs(IMYTStrategy(strategy).realAssets(), 0, initialRealAssets / 100, "All real assets should be deallocated"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/AaveV3ETHWETHStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {AaveStrategy} from "../../strategies/AaveStrategy.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; contract MockRewardsControllerETH { IERC20 public immutable rewardToken; uint256 public immutable rewardAmount; constructor(address _rewardToken, uint256 _rewardAmount) { rewardToken = IERC20(_rewardToken); rewardAmount = _rewardAmount; } function claimAllRewardsToSelf(address[] calldata) external returns (address[] memory rewardsList, uint256[] memory claimedAmounts) { rewardToken.transfer(msg.sender, rewardAmount); rewardsList = new address[](1); rewardsList[0] = address(rewardToken); claimedAmounts = new uint256[](1); claimedAmounts[0] = rewardAmount; } } contract MockSwapExecutorETH { IERC20 public immutable token; uint256 public amountToTransfer; constructor(address _token, uint256 _amountToTransfer) { token = IERC20(_token); amountToTransfer = _amountToTransfer; } receive() external payable {} fallback() external { token.transfer(msg.sender, amountToTransfer); } } contract AaveV3ETHWETHStrategyTest is BaseStrategyTest { address public constant AAVE_V3_ETH_WETH_ATOKEN = 0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8; address public constant AAVE_V3_ETH_POOL_ADDRESS_PROVIDER = 0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e; address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address public constant REWARD_TOKEN = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; address public constant REWARDS_CONTROLLER = 0x8164Cc65827dcFe994AB23944CBC90e0aa80bFcb; bytes4 internal constant ERROR_STRING_SELECTOR = 0x08c379a0; bytes4 internal constant ALLOWED_AAVE_REVERT_SELECTOR = 0x2c5211c6; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "AaveV3ETHWETH", protocol: "AaveV3ETHWETH", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e18, globalCap: 1e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: WETH, vaultInitialDeposit: 1000e18, absoluteCap: 10_000e18, relativeCap: 1e18, decimals: 18}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address( new AaveStrategy(vault, params, WETH, AAVE_V3_ETH_WETH_ATOKEN, AAVE_V3_ETH_POOL_ADDRESS_PROVIDER, REWARDS_CONTROLLER, REWARD_TOKEN) ); } function getForkBlockNumber() internal pure override returns (uint256) { return 0; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("MAINNET_RPC_URL"); } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { bool isFuzzOrHandler = context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; if (!isFuzzOrHandler) return false; return selector == ALLOWED_AAVE_REVERT_SELECTOR || selector == ERROR_STRING_SELECTOR; } function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1 * 10 ** testConfig.decimals, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_allowlisted_revert_custom_selector_is_deterministic() public { uint256 amountToAllocate = 1e18; uint256 amountToDeallocate = 5e17; address mockPool = address(0xBEEF); bytes4 getPoolSelector = bytes4(keccak256("getPool()")); bytes4 withdrawSelector = bytes4(keccak256("withdraw(address,uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCall(AAVE_V3_ETH_POOL_ADDRESS_PROVIDER, abi.encodeWithSelector(getPoolSelector), abi.encode(mockPool)); vm.mockCallRevert( mockPool, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ALLOWED_AAVE_REVERT_SELECTOR) ); vm.expectRevert(ALLOWED_AAVE_REVERT_SELECTOR); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } function test_allowlisted_revert_error_string_is_deterministic() public { uint256 amountToAllocate = 1e18; uint256 amountToDeallocate = 5e17; address mockPool = address(0xBEEF); bytes4 getPoolSelector = bytes4(keccak256("getPool()")); bytes4 withdrawSelector = bytes4(keccak256("withdraw(address,uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCall(AAVE_V3_ETH_POOL_ADDRESS_PROVIDER, abi.encodeWithSelector(getPoolSelector), abi.encode(mockPool)); vm.mockCallRevert( mockPool, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ERROR_STRING_SELECTOR, "PD") ); vm.expectRevert(bytes("PD")); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } function test_claimRewards_succeeds() public { bytes memory params = getVaultParams(); uint256 amountToAllocate = 1000e18; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); vm.prank(address(1)); IMYTStrategy(strategy).claimRewards(AAVE_V3_ETH_WETH_ATOKEN, "", 0); vm.stopPrank(); } function test_claimRewards_emits_event_and_vault_receives_asset() public { bytes memory params = getVaultParams(); uint256 amountToAllocate = 10e18; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 rewardAmount = 10e18; uint256 mockSwapReturn = 5e15; MockRewardsControllerETH mockRC = new MockRewardsControllerETH(REWARD_TOKEN, rewardAmount); vm.etch(REWARDS_CONTROLLER, address(mockRC).code); deal(REWARD_TOKEN, REWARDS_CONTROLLER, rewardAmount); MockSwapExecutorETH mockSwap = new MockSwapExecutorETH(WETH, mockSwapReturn); deal(WETH, address(mockSwap), mockSwapReturn); vm.prank(address(1)); MYTStrategy(strategy).setAllowanceHolder(address(mockSwap)); uint256 vaultBalanceBefore = IERC20(WETH).balanceOf(vault); vm.expectEmit(true, true, false, true, strategy); emit IMYTStrategy.RewardsClaimed(REWARD_TOKEN, rewardAmount); bytes memory quote = hex"01"; vm.prank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(AAVE_V3_ETH_WETH_ATOKEN, quote, 4.99e15); uint256 vaultBalanceAfter = IERC20(WETH).balanceOf(vault); uint256 vaultAssetReceived = vaultBalanceAfter - vaultBalanceBefore; assertGt(received, 0, "No rewards received from claim"); assertEq(vaultAssetReceived, mockSwapReturn, "Vault did not receive expected WETH amount"); assertEq(received, vaultAssetReceived, "Returned amount is not in vault asset terms"); } function test_aave_v3_ethweth_yield_accumulation() public { vm.startPrank(allocator); uint256 allocAmount = 300e18; IVaultV2(vault).allocate(strategy, getVaultParams(), allocAmount); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); uint256[] memory realAssetsSnapshots = new uint256[](4); uint256 minExpected = initialRealAssets * 95 / 100; for (uint256 i = 0; i < 4; i++) { vm.warp(block.timestamp + 30 days); deal(testConfig.vaultAsset, strategy, initialRealAssets * 5 / 1000); realAssetsSnapshots[i] = IMYTStrategy(strategy).realAssets(); assertGe(realAssetsSnapshots[i], minExpected, "Real assets decreased significantly"); minExpected = realAssetsSnapshots[i]; if (i == 1) { uint256 smallDealloc = 30e18; uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(smallDealloc); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); minExpected = IMYTStrategy(strategy).realAssets(); } } uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e15) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } assertApproxEqAbs(IMYTStrategy(strategy).realAssets(), 0, initialRealAssets / 100, "All real assets should be deallocated"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/AaveV3OPUSDCStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {AaveStrategy} from "../../strategies/AaveStrategy.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; interface IRewardsController { function claimAllRewardsToSelf(address[] calldata assets) external returns (address[] memory rewardsList, uint256[] memory claimedAmounts); } /// @notice Replaces the real Aave RewardsController via vm.etch so that /// claimAllRewardsToSelf actually transfers reward tokens to the caller. contract MockRewardsController { IERC20 public immutable rewardToken; uint256 public immutable rewardAmount; constructor(address _rewardToken, uint256 _rewardAmount) { rewardToken = IERC20(_rewardToken); rewardAmount = _rewardAmount; } function claimAllRewardsToSelf(address[] calldata) external returns (address[] memory rewardsList, uint256[] memory claimedAmounts) { rewardToken.transfer(msg.sender, rewardAmount); rewardsList = new address[](1); rewardsList[0] = address(rewardToken); claimedAmounts = new uint256[](1); claimedAmounts[0] = rewardAmount; } } /// @notice When used as allowanceHolder, transfers a fixed amount of vault asset to msg.sender on any call (simulates swap output). contract MockSwapExecutor { IERC20 public immutable token; uint256 public amountToTransfer; constructor(address _token, uint256 _amountToTransfer) { token = IERC20(_token); amountToTransfer = _amountToTransfer; } receive() external payable {} fallback() external { token.transfer(msg.sender, amountToTransfer); } } contract AaveV3OPUSDCStrategyTest is BaseStrategyTest { address public constant AAVE_V3_USDC_ATOKEN = 0x38d693cE1dF5AaDF7bC62595A37D667aD57922e5; address public constant AAVE_V3_USDC_POOL = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb; // pool provider to query address public constant USDC = 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85; address public constant OP = 0x4200000000000000000000000000000000000042; address public constant REWARDS_CONTROLLER = 0x929EC64c34a17401F460460D4B9390518E5B473e; // Aave custom error selector (0x2c5211c6): `InvalidAmount()`. // In this suite it is observed on deallocate paths (withdraw mock), not allocate. bytes4 internal constant ALLOWED_AAVE_REVERT_SELECTOR = 0x2c5211c6; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "AaveV3OPUSDC", protocol: "AaveV3OPUSDC", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e6, globalCap: 1e18, estimatedYield: 100e6, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: USDC, vaultInitialDeposit: 1000e6, absoluteCap: 10_000e6, relativeCap: 1e18, decimals: 6}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new AaveStrategy(vault, params, USDC, AAVE_V3_USDC_ATOKEN, AAVE_V3_USDC_POOL, REWARDS_CONTROLLER, OP)); } function getForkBlockNumber() internal pure override returns (uint256) { return 141_751_698; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("OPTIMISM_RPC_URL"); } function _effectiveDeallocateAmount(uint256 requestedAssets) internal view override returns (uint256) { uint256 maxWithdrawable = IMYTStrategy(strategy).realAssets(); uint256 minMeaningfulDeallocate = 1 * 10 ** testConfig.decimals; if (maxWithdrawable < minMeaningfulDeallocate || requestedAssets < minMeaningfulDeallocate) { return 0; } return requestedAssets < maxWithdrawable ? requestedAssets : maxWithdrawable; } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { bool isFuzzOrHandler = context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; return isFuzzOrHandler && selector == ALLOWED_AAVE_REVERT_SELECTOR; } function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1 * 10 ** testConfig.decimals, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_claimRewards_succeeds() public { bytes memory params = getVaultParams(); // Allocate some assets first so there are positions to claim rewards for uint256 amountToAllocate = 1000e6; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); // Claim rewards should not revert vm.prank(address(1)); IMYTStrategy(strategy).claimRewards(AAVE_V3_USDC_ATOKEN, "", 0); vm.stopPrank(); } function test_allowlisted_revert_custom_selector_is_deterministic() public { uint256 amountToAllocate = 100e6; uint256 amountToDeallocate = 50e6; address mockPool = address(0xBEEF); bytes4 getPoolSelector = bytes4(keccak256("getPool()")); bytes4 withdrawSelector = bytes4(keccak256("withdraw(address,uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCall(AAVE_V3_USDC_POOL, abi.encodeWithSelector(getPoolSelector), abi.encode(mockPool)); vm.mockCallRevert( mockPool, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ALLOWED_AAVE_REVERT_SELECTOR) ); vm.expectRevert(ALLOWED_AAVE_REVERT_SELECTOR); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } function test_claimRewards_emits_event_and_vault_receives_asset() public { // Allocate assets to create an Aave position bytes memory params = getVaultParams(); uint256 amountToAllocate = 1000e6; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); // Configure mock reward claim uint256 opRewardAmount = 10e18; // 10 OP tokens claimed uint256 mockSwapReturn = 15e6; // Simulated swap output // Deploy a MockRewardsController and etch its bytecode over the real // rewards controller address so claimAllRewardsToSelf actually transfers OP. MockRewardsController mockRC = new MockRewardsController(OP, opRewardAmount); vm.etch(REWARDS_CONTROLLER, address(mockRC).code); deal(OP, REWARDS_CONTROLLER, opRewardAmount); // Setup MockSwapExecutor as allowanceHolder to simulate DEX swap. // Swap output should be measured in vault asset terms (USDC). MockSwapExecutor mockSwap = new MockSwapExecutor(USDC, mockSwapReturn); deal(USDC, address(mockSwap), mockSwapReturn); // Point the strategy's allowanceHolder to our mock vm.prank(address(1)); // strategy owner MYTStrategy(strategy).setAllowanceHolder(address(mockSwap)); // Record vault USDC balance before claiming uint256 vaultBalanceBefore = IERC20(USDC).balanceOf(vault); // Expect the RewardsClaimed event with correct token and amount vm.expectEmit(true, true, false, true, strategy); emit IMYTStrategy.RewardsClaimed(OP, opRewardAmount); // Execute claimRewards as strategy owner // MockSwapExecutor.fallback() transfers USDC to simulate swap output. bytes memory quote = hex"01"; vm.prank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(AAVE_V3_USDC_ATOKEN, quote, 4.99e6); // Verify rewards were received and vault got the asset uint256 vaultBalanceAfter = IERC20(USDC).balanceOf(vault); uint256 vaultAssetReceived = vaultBalanceAfter - vaultBalanceBefore; assertGt(received, 0, "No rewards received from claim"); assertEq(vaultAssetReceived, mockSwapReturn, "Vault did not receive expected USDC amount"); assertEq(received, vaultAssetReceived, "Returned amount is not in vault asset terms"); } } ================================================ FILE: src/test/strategies/AaveV3OPWETHStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {AaveStrategy} from "../../strategies/AaveStrategy.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; contract MockRewardsControllerOPWETH { IERC20 public immutable rewardToken; uint256 public immutable rewardAmount; constructor(address _rewardToken, uint256 _rewardAmount) { rewardToken = IERC20(_rewardToken); rewardAmount = _rewardAmount; } function claimAllRewardsToSelf(address[] calldata) external returns (address[] memory rewardsList, uint256[] memory claimedAmounts) { rewardToken.transfer(msg.sender, rewardAmount); rewardsList = new address[](1); rewardsList[0] = address(rewardToken); claimedAmounts = new uint256[](1); claimedAmounts[0] = rewardAmount; } } contract MockSwapExecutorOPWETH { IERC20 public immutable token; uint256 public amountToTransfer; constructor(address _token, uint256 _amountToTransfer) { token = IERC20(_token); amountToTransfer = _amountToTransfer; } receive() external payable {} fallback() external { token.transfer(msg.sender, amountToTransfer); } } contract AaveV3OPWETHStrategyTest is BaseStrategyTest { address public constant AAVE_V3_OP_WETH_ATOKEN = 0xe50fA9b3c56FfB159cB0FCA61F5c9D750e8128c8; address public constant AAVE_V3_OP_POOL_ADDRESS_PROVIDER = 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb; address public constant WETH = 0x4200000000000000000000000000000000000006; address public constant REWARD_TOKEN = 0x4200000000000000000000000000000000000042; address public constant REWARDS_CONTROLLER = 0x929EC64c34a17401F460460D4B9390518E5B473e; bytes4 internal constant ALLOWED_AAVE_REVERT_SELECTOR = 0x2c5211c6; bytes4 internal constant ERROR_STRING_SELECTOR = 0x08c379a0; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "AaveV3OPWETH", protocol: "AaveV3OPWETH", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e18, globalCap: 1e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: WETH, vaultInitialDeposit: 1000e18, absoluteCap: 10_000e18, relativeCap: 1e18, decimals: 18}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address( new AaveStrategy(vault, params, WETH, AAVE_V3_OP_WETH_ATOKEN, AAVE_V3_OP_POOL_ADDRESS_PROVIDER, REWARDS_CONTROLLER, REWARD_TOKEN) ); } function getForkBlockNumber() internal pure override returns (uint256) { return 0; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("OPTIMISM_RPC_URL"); } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { bool isFuzzOrHandler = context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; if (!isFuzzOrHandler) return false; return selector == ALLOWED_AAVE_REVERT_SELECTOR || selector == ERROR_STRING_SELECTOR; } function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1 * 10 ** testConfig.decimals, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_allowlisted_revert_custom_selector_is_deterministic() public { uint256 amountToAllocate = 1e18; uint256 amountToDeallocate = 5e17; address mockPool = address(0xBEEF); bytes4 getPoolSelector = bytes4(keccak256("getPool()")); bytes4 withdrawSelector = bytes4(keccak256("withdraw(address,uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCall(AAVE_V3_OP_POOL_ADDRESS_PROVIDER, abi.encodeWithSelector(getPoolSelector), abi.encode(mockPool)); vm.mockCallRevert( mockPool, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ALLOWED_AAVE_REVERT_SELECTOR) ); vm.expectRevert(ALLOWED_AAVE_REVERT_SELECTOR); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } function test_allowlisted_revert_error_string_is_deterministic() public { uint256 amountToAllocate = 1e18; uint256 amountToDeallocate = 5e17; address mockPool = address(0xBEEF); bytes4 getPoolSelector = bytes4(keccak256("getPool()")); bytes4 withdrawSelector = bytes4(keccak256("withdraw(address,uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCall(AAVE_V3_OP_POOL_ADDRESS_PROVIDER, abi.encodeWithSelector(getPoolSelector), abi.encode(mockPool)); vm.mockCallRevert( mockPool, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ERROR_STRING_SELECTOR, "PD") ); vm.expectRevert(bytes("PD")); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } function test_claimRewards_succeeds() public { bytes memory params = getVaultParams(); uint256 amountToAllocate = 1000e18; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); vm.prank(address(1)); IMYTStrategy(strategy).claimRewards(AAVE_V3_OP_WETH_ATOKEN, "", 0); vm.stopPrank(); } function test_claimRewards_emits_event_and_vault_receives_asset() public { bytes memory params = getVaultParams(); uint256 amountToAllocate = 10e18; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 rewardAmount = 10e18; uint256 mockSwapReturn = 5e15; MockRewardsControllerOPWETH mockRC = new MockRewardsControllerOPWETH(REWARD_TOKEN, rewardAmount); vm.etch(REWARDS_CONTROLLER, address(mockRC).code); deal(REWARD_TOKEN, REWARDS_CONTROLLER, rewardAmount); MockSwapExecutorOPWETH mockSwap = new MockSwapExecutorOPWETH(WETH, mockSwapReturn); deal(WETH, address(mockSwap), mockSwapReturn); vm.prank(address(1)); MYTStrategy(strategy).setAllowanceHolder(address(mockSwap)); uint256 vaultBalanceBefore = IERC20(WETH).balanceOf(vault); vm.expectEmit(true, true, false, true, strategy); emit IMYTStrategy.RewardsClaimed(REWARD_TOKEN, rewardAmount); bytes memory quote = hex"01"; vm.prank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(AAVE_V3_OP_WETH_ATOKEN, quote, 4.99e15); uint256 vaultBalanceAfter = IERC20(WETH).balanceOf(vault); uint256 vaultAssetReceived = vaultBalanceAfter - vaultBalanceBefore; assertGt(received, 0, "No rewards received from claim"); assertEq(vaultAssetReceived, mockSwapReturn, "Vault did not receive expected WETH amount"); assertEq(received, vaultAssetReceived, "Returned amount is not in vault asset terms"); } function test_aave_v3_opweth_yield_accumulation() public { vm.startPrank(allocator); uint256 allocAmount = 300e18; IVaultV2(vault).allocate(strategy, getVaultParams(), allocAmount); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); uint256[] memory realAssetsSnapshots = new uint256[](4); uint256 minExpected = initialRealAssets * 95 / 100; for (uint256 i = 0; i < 4; i++) { vm.warp(block.timestamp + 30 days); deal(testConfig.vaultAsset, strategy, initialRealAssets * 5 / 1000); realAssetsSnapshots[i] = IMYTStrategy(strategy).realAssets(); assertGe(realAssetsSnapshots[i], minExpected, "Real assets decreased significantly"); minExpected = realAssetsSnapshots[i]; if (i == 1) { uint256 smallDealloc = 30e18; uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(smallDealloc); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); minExpected = IMYTStrategy(strategy).realAssets(); } } uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e15) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } assertApproxEqAbs(IMYTStrategy(strategy).realAssets(), 0, initialRealAssets / 100, "All real assets should be deallocated"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/EtherfiEETHStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseStrategyTest} from "../BaseStrategyTest.sol"; import {EtherfiEETHMYTStrategy, IWeETH} from "../../strategies/EtherfiEETHStrategy.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {AggregatorV3Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IAllocator} from "../../interfaces/IAllocator.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; struct RedemptionLimitView { uint64 capacity; uint64 remaining; uint64 lastRefill; uint64 refillRate; } interface IRedemptionManagerView { function canRedeem(uint256 amount, address token) external view returns (bool); function tokenToRedemptionInfo(address token) external view returns ( RedemptionLimitView memory limit, uint16 exitFeeSplitToTreasuryInBps, uint16 exitFeeInBps, uint16 lowWatermarkInBpsOfTvl ); } contract MockSwapper { function swap(address from, address to, uint256 amountIn, uint256 amountOut) external { (bool pullOk,) = from.call(abi.encodeWithSelector(IERC20.transferFrom.selector, msg.sender, address(this), amountIn)); require(pullOk, "pull failed"); (bool pushOk,) = to.call(abi.encodeWithSelector(IERC20.transfer.selector, msg.sender, amountOut)); require(pushOk, "push failed"); } } contract MockFeeRedemptionManager { uint256 public constant BPS = 10_000; address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; address public immutable weETH; address public immutable eETH; uint256 public immutable feeBps; constructor(address _weETH, address _eETH, uint256 _feeBps) payable { weETH = _weETH; eETH = _eETH; feeBps = _feeBps; } function canRedeem(uint256, address token) external view returns (bool) { return token == ETH; } function liquidityPool() external view returns (address) { return address(this); } function amountForShare(uint256 shares) external pure returns (uint256) { return shares; } function sharesForAmount(uint256 amount) external pure returns (uint256) { return amount; } function sharesForWithdrawalAmount(uint256 amount) external pure returns (uint256) { return amount; } function previewRedeem(uint256 shares, address token) external view returns (uint256) { if (token != ETH) return 0; uint256 fee = (shares * feeBps + BPS - 1) / BPS; return shares - fee; } function tokenToRedemptionInfo(address token) external view returns (RedemptionLimitView memory limit, uint16 exitFeeSplitToTreasuryInBps, uint16 exitFeeInBps, uint16 lowWatermarkInBpsOfTvl) { if (token != ETH) { return (limit, 0, 0, 0); } return ( RedemptionLimitView({capacity: type(uint64).max, remaining: type(uint64).max, lastRefill: 0, refillRate: 0}), 0, uint16(feeBps), 0 ); } function redeemWeEth(uint256 amount, address receiver, address outputToken) external returns (uint256) { require(outputToken == ETH, "invalid output token"); require(IERC20(weETH).transferFrom(msg.sender, address(this), amount), "transfer failed"); uint256 grossEth = IWeETH(weETH).getEETHByWeETH(amount); uint256 fee = (grossEth * feeBps + BPS - 1) / BPS; uint256 netEth = grossEth - fee; (bool ok,) = receiver.call{value: netEth}(""); require(ok, "eth transfer failed"); return netEth; } receive() external payable {} } contract MockEtherfiEETHStrategy is EtherfiEETHMYTStrategy { constructor( address _myt, StrategyParams memory _params, address _eETH, address _weETH, address _depositAdapter, address _redemptionManager, address _weEthEthOracle, uint256 _maxOracleStaleness ) EtherfiEETHMYTStrategy( _myt, _params, _eETH, _weETH, _depositAdapter, _redemptionManager, _weEthEthOracle, _maxOracleStaleness ) {} } contract EtherfiEETHStrategyTest is BaseStrategyTest { address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address public constant WEETH = 0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee; address public constant EETH = 0x35fA164735182de50811E8e2E824cFb9B6118ac2; address public constant DEPOSIT_ADAPTER = 0xcfC6d9Bd7411962Bfe7145451A7EF71A24b6A7A2; address public constant REDEMPTION_MANAGER = 0xDadEf1fFBFeaAB4f68A9fD181395F68b4e4E7Ae0; address public constant WEETH_ETH_ORACLE = 0x5c9C449BbC9a6075A2c061dF312a35fd1E05fF22; uint256 public constant MAX_ORACLE_STALENESS = 24 hours; uint256 public constant TEST_RESIDUAL_TOLERANCE_BPS = 100; MockSwapper public swapper; function setUp() public override { swapper = new MockSwapper(); super.setUp(); vm.startPrank(admin); MYTStrategy(strategy).setAllowanceHolder(address(swapper)); vm.stopPrank(); // Only swap execution is mocked; protocol contracts are live mainnet. deal(WETH, address(swapper), 1_000_000e18); deal(WEETH, address(swapper), 1_000_000e18); } function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "EETH", protocol: "EtherFi", riskClass: IMYTStrategy.RiskClass.MEDIUM, cap: 2_000_000e18, globalCap: 2_000_000e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 10 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({ vaultAsset: WETH, vaultInitialDeposit: 1000e18, absoluteCap: 2_000_000e18, relativeCap: 1e18, decimals: 18 }); } function createStrategy(address vault_, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address( new MockEtherfiEETHStrategy( vault_, params, EETH, WEETH, DEPOSIT_ADAPTER, REDEMPTION_MANAGER, WEETH_ETH_ORACLE, MAX_ORACLE_STALENESS ) ); } function test_constructor_sets_max_oracle_staleness() public view { assertEq( EtherfiEETHMYTStrategy(payable(strategy)).MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS, "unexpected initial max oracle staleness" ); } function getForkBlockNumber() internal pure override returns (uint256) { return 24595012;//24592846; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("MAINNET_RPC_URL"); } function _mockFreshWeEthEthOracle(uint256 targetTimestamp) internal { (uint80 roundId, int256 answer, uint256 startedAt,, uint80 answeredInRound) = AggregatorV3Interface(WEETH_ETH_ORACLE).latestRoundData(); vm.mockCall( WEETH_ETH_ORACLE, abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector), abi.encode(roundId, answer, startedAt, targetTimestamp, answeredInRound) ); } function _beforeTimeShift(uint256 targetTimestamp) internal override { _mockFreshWeEthEthOracle(targetTimestamp); } function _beforePreviewWithdraw(uint256) internal override { _mockFreshWeEthEthOracle(block.timestamp); } function _minWeEthOut(uint256 wethAmount) internal view returns (uint256) { (, int256 answer,, uint256 updatedAt,) = AggregatorV3Interface(WEETH_ETH_ORACLE).latestRoundData(); require(answer > 0 && updatedAt != 0, "invalid oracle answer"); uint256 minWethValue = (wethAmount * (10_000 - strategyConfig.slippageBPS)) / 10_000; uint256 minWeEthOut = minWethValue * (10 ** AggregatorV3Interface(WEETH_ETH_ORACLE).decimals()) / uint256(answer); return minWeEthOut == 0 ? 1 : minWeEthOut; } function _maxWeEthIn(uint256 wethAmount) internal view returns (uint256) { (, int256 answer,, uint256 updatedAt,) = AggregatorV3Interface(WEETH_ETH_ORACLE).latestRoundData(); require(answer > 0 && updatedAt != 0, "invalid oracle answer"); uint256 maxWethIn = (wethAmount * 10_000 + (10_000 - strategyConfig.slippageBPS) - 1) / (10_000 - strategyConfig.slippageBPS); uint256 scale = 10 ** AggregatorV3Interface(WEETH_ETH_ORACLE).decimals(); uint256 maxWeEthIn = (maxWethIn * scale + uint256(answer) - 1) / uint256(answer); return maxWeEthIn == 0 ? 1 : maxWeEthIn; } function _grossRedeemAmount(address manager, uint256 netAmount) internal view returns (uint256) { (, uint16 exitFeeInBps,) = _redemptionInfo(manager); return (netAmount * 10_000 + (10_000 - exitFeeInBps) - 1) / (10_000 - exitFeeInBps); } function _maxNetDirectDeallocate(address manager) internal view returns (uint256) { uint256 maxGrossDeallocate = _effectiveDeallocateAmount(type(uint256).max); (, uint16 exitFeeInBps,) = _redemptionInfo(manager); if (exitFeeInBps >= 10_000) return 0; return (maxGrossDeallocate * (10_000 - exitFeeInBps)) / 10_000; } function _redemptionInfo(address manager) internal view returns (uint16 exitFeeSplitToTreasuryInBps, uint16 exitFeeInBps, uint16 lowWatermarkInBpsOfTvl) { (, exitFeeSplitToTreasuryInBps, exitFeeInBps, lowWatermarkInBpsOfTvl) = IRedemptionManagerView(manager).tokenToRedemptionInfo(ETH); } function _swapCallDataForWethOut(uint256 wethOut) internal view returns (bytes memory) { uint256 idleBalance = IERC20(WETH).balanceOf(strategy); uint256 shortfall = wethOut > idleBalance ? wethOut - idleBalance : 0; if (shortfall == 0) { return abi.encodeCall(MockSwapper.swap, (WEETH, WETH, 0, 0)); } uint256 weETHBalance = IWeETH(WEETH).balanceOf(strategy); uint256 weETHToSwap = _maxWeEthIn(shortfall); if (weETHToSwap > weETHBalance) weETHToSwap = weETHBalance; if (weETHToSwap == 0 && weETHBalance > 0) weETHToSwap = 1; return abi.encodeCall(MockSwapper.swap, (WEETH, WETH, weETHToSwap, shortfall)); } function test_allocate_swap_mock_success() public { uint256 amount = 10e18; _mockFreshWeEthEthOracle(block.timestamp); uint256 minWeEthOut = _minWeEthOut(amount); bytes memory callData = abi.encodeCall(MockSwapper.swap, (WETH, WEETH, amount, minWeEthOut)); IMYTStrategy.SwapParams memory sp = IMYTStrategy.SwapParams({txData: callData, minIntermediateOut: 0}); IMYTStrategy.VaultAdapterParams memory vp = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: sp}); vm.startPrank(vault); deal(WETH, strategy, amount); IMYTStrategy(strategy).allocate(abi.encode(vp), amount, "", address(vault)); vm.stopPrank(); assertGe(IWeETH(WEETH).balanceOf(strategy), minWeEthOut, "weETH balance should satisfy oracle min out"); } function test_allocate_swap_reverts_when_allowanceHolder_returns_less_than_minAmountOut() public { uint256 amount = 10e18; _mockFreshWeEthEthOracle(block.timestamp); uint256 minWeEthOut = _minWeEthOut(amount); require(minWeEthOut > 1, "min output too small"); uint256 insufficientOut = minWeEthOut - 1; bytes memory callData = abi.encodeCall(MockSwapper.swap, (WETH, WEETH, amount, insufficientOut)); IMYTStrategy.SwapParams memory sp = IMYTStrategy.SwapParams({txData: callData, minIntermediateOut: 0}); IMYTStrategy.VaultAdapterParams memory vp = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: sp}); vm.startPrank(vault); deal(WETH, strategy, amount); assertEq(MYTStrategy(strategy).allowanceHolder(), address(swapper), "test should execute through allowance holder"); // The mock allowance holder call succeeds but under-delivers weETH, so dexSwap must // revert on the post-swap balance delta check against the oracle-derived minimum. vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.InvalidAmount.selector, minWeEthOut, insufficientOut)); IMYTStrategy(strategy).allocate(abi.encode(vp), amount, "", address(vault)); vm.stopPrank(); } function getDeallocateVaultParams(uint256 assets) internal view override returns (bytes memory) { IMYTStrategy.SwapParams memory sp = IMYTStrategy.SwapParams({txData: _swapCallDataForWethOut(assets), minIntermediateOut: 0}); IMYTStrategy.VaultAdapterParams memory vp = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: sp}); return abi.encode(vp); } function _useAllocatorDeallocateSwap() internal pure override returns (bool) { return true; } function _allocatorDeallocateSwapData(uint256 amount) internal view override returns (bytes memory) { return _swapCallDataForWethOut(amount); } function _assertDeallocateChange(int256 change, uint256 amountToDeallocate) internal view override { assertApproxEqRel(change, -int256(amountToDeallocate), 1e16); } function test_deallocate_swap_mock_success() public { uint256 amount = 10e18; bytes memory directParams = getVaultParams(); vm.startPrank(vault); deal(WETH, strategy, amount); IMYTStrategy(strategy).allocate(directParams, amount, "", address(vault)); vm.stopPrank(); uint256 maxEETH = IWeETH(WEETH).getEETHByWeETH(IWeETH(WEETH).balanceOf(strategy)); uint256 deallocCap = maxEETH / 2; uint256 deallocAmount = amount < deallocCap ? amount : deallocCap; require(deallocAmount > 0, "dealloc amount is zero"); deal(WETH, address(swapper), deallocAmount); bytes memory callData = _swapCallDataForWethOut(deallocAmount); IMYTStrategy.SwapParams memory sp = IMYTStrategy.SwapParams({txData: callData, minIntermediateOut: 0}); IMYTStrategy.VaultAdapterParams memory vp = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: sp}); bytes memory deallocParams = abi.encode(vp); vm.startPrank(vault); IMYTStrategy(strategy).deallocate(deallocParams, deallocAmount, "", address(vault)); vm.stopPrank(); } function test_deallocate_swap_mock_reverts_on_insufficient_swap_output() public { uint256 amount = 10e18; bytes memory directParams = getVaultParams(); vm.startPrank(vault); deal(WETH, strategy, amount); IMYTStrategy(strategy).allocate(directParams, amount, "", address(vault)); vm.stopPrank(); uint256 maxEETH = IWeETH(WEETH).getEETHByWeETH(IWeETH(WEETH).balanceOf(strategy)); uint256 deallocCap = maxEETH / 2; uint256 deallocAmount = amount < deallocCap ? amount : deallocCap; require(deallocAmount > 1, "dealloc amount too small"); uint256 insufficientOut = deallocAmount - 1; uint256 sellAmount = IWeETH(WEETH).getWeETHByeETH(deallocAmount); uint256 weETHBalance = IWeETH(WEETH).balanceOf(strategy); if (sellAmount > weETHBalance) sellAmount = weETHBalance; deal(WETH, address(swapper), insufficientOut); bytes memory callData = abi.encodeCall(MockSwapper.swap, (WEETH, WETH, sellAmount, insufficientOut)); IMYTStrategy.SwapParams memory sp = IMYTStrategy.SwapParams({txData: callData, minIntermediateOut: 0}); IMYTStrategy.VaultAdapterParams memory vp = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: sp}); bytes memory deallocParams = abi.encode(vp); vm.startPrank(vault); vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.InvalidAmount.selector, deallocAmount, insufficientOut)); IMYTStrategy(strategy).deallocate(deallocParams, deallocAmount, "", address(vault)); vm.stopPrank(); } function test_allocator_deallocate_max_preview_from_total_value(uint256 amountToAllocate) public { amountToAllocate = bound(amountToAllocate, 1e18, 100e18); _mockFreshWeEthEthOracle(block.timestamp); vm.startPrank(admin); IAllocator(allocator).allocate(strategy, amountToAllocate); uint256 realAssetsBefore = IMYTStrategy(strategy).realAssets(); assertGt(realAssetsBefore, 0, "real assets should be positive after allocation"); uint256 targetDeallocate = _effectiveDeallocateAmount(realAssetsBefore); require(targetDeallocate > 0, "target deallocate is zero"); uint256 previewedDeallocate = IMYTStrategy(strategy).previewAdjustedWithdraw(targetDeallocate); assertGt(previewedDeallocate, 0, "previewed deallocation should be positive"); assertLe(previewedDeallocate, targetDeallocate, "previewed amount should not exceed target"); uint256 weETHBalanceBefore = IWeETH(WEETH).balanceOf(strategy); uint256 weETHToSwap = _maxWeEthIn(previewedDeallocate); assertLe(weETHToSwap, weETHBalanceBefore, "previewed deallocation should be fundable by position"); deal(WETH, address(swapper), previewedDeallocate); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 allocationBefore = IVaultV2(vault).allocation(allocationId); IAllocator(allocator).deallocateWithSwap(strategy, previewedDeallocate, _swapCallDataForWethOut(previewedDeallocate)); vm.stopPrank(); uint256 allocationAfter = IVaultV2(vault).allocation(allocationId); uint256 realAssetsAfter = IMYTStrategy(strategy).realAssets(); uint256 leftoverWeth = IERC20(WETH).balanceOf(strategy); uint256 maxResidual = (realAssetsBefore * TEST_RESIDUAL_TOLERANCE_BPS) / 10_000 + 1e18; uint256 expectedRemaining = realAssetsBefore > previewedDeallocate ? realAssetsBefore - previewedDeallocate : 0; assertLt(allocationAfter, allocationBefore, "allocator deallocation should reduce vault allocation"); assertLt(IWeETH(WEETH).balanceOf(strategy), weETHBalanceBefore, "weETH balance should decrease after deallocation"); assertLe(realAssetsAfter, expectedRemaining + maxResidual, "remaining strategy balance should stay near expected residual"); assertLe(leftoverWeth, maxResidual, "leftover idle WETH should stay within slippage tolerance"); } function test_deallocate_direct_uses_instant_redeem_path_cant_redeem() public { uint256 allocateAmount = 1e18; uint256 deallocateAmount = 1e16; bytes memory allocParams = getAllocateVaultParams(allocateAmount); vm.startPrank(vault); deal(WETH, strategy, allocateAmount); IMYTStrategy(strategy).allocate(allocParams, allocateAmount, "", address(vault)); vm.stopPrank(); // If canRedeem is unavailable/reverting at this fork state, skip deterministically. uint256 grossRedeemAmount = _grossRedeemAmount(REDEMPTION_MANAGER, deallocateAmount); bool redeemable = IRedemptionManagerView(REDEMPTION_MANAGER).canRedeem(grossRedeemAmount, ETH); if (redeemable) return; // if liquidity is unavailable, direct deallocate should revert IMYTStrategy.VaultAdapterParams memory directDealloc; directDealloc.action = IMYTStrategy.ActionType.direct; bytes memory deallocParams = abi.encode(directDealloc); vm.startPrank(vault); vm.expectRevert(bytes("Cannot redeem. Instant redemption path is not available.")); IMYTStrategy(strategy).deallocate(deallocParams, deallocateAmount, "", address(vault)); vm.stopPrank(); } function test_deallocate_direct_accounts_for_instant_redemption_fee() public { uint256 allocateAmount = 10e18; uint256 deallocateAmount = 1e18; bytes memory allocParams = getAllocateVaultParams(allocateAmount); MockFeeRedemptionManager feeRedemptionManager = new MockFeeRedemptionManager(WEETH, EETH, 30); address localStrategy = address( new MockEtherfiEETHStrategy( vault, strategyConfig, EETH, WEETH, DEPOSIT_ADAPTER, address(feeRedemptionManager), WEETH_ETH_ORACLE, MAX_ORACLE_STALENESS ) ); vm.deal(address(feeRedemptionManager), allocateAmount); vm.startPrank(vault); deal(WETH, localStrategy, allocateAmount); IMYTStrategy(localStrategy).allocate(allocParams, allocateAmount, "", address(vault)); IMYTStrategy.VaultAdapterParams memory directDealloc; directDealloc.action = IMYTStrategy.ActionType.direct; bytes memory deallocParams = abi.encode(directDealloc); IMYTStrategy(localStrategy).deallocate(deallocParams, deallocateAmount, "", address(vault)); assertGe(IERC20(WETH).balanceOf(localStrategy), deallocateAmount, "idle WETH should cover requested deallocation"); vm.stopPrank(); } function test_deallocate_direct_uses_live_fee_from_redemption_manager() public { uint256 allocateAmount = 10e18; uint256 deallocateAmount = 1e18; bytes memory allocParams = getAllocateVaultParams(allocateAmount); MockFeeRedemptionManager feeRedemptionManager = new MockFeeRedemptionManager(WEETH, EETH, 100); address localStrategy = address( new MockEtherfiEETHStrategy( vault, strategyConfig, EETH, WEETH, DEPOSIT_ADAPTER, address(feeRedemptionManager), WEETH_ETH_ORACLE, MAX_ORACLE_STALENESS ) ); vm.deal(address(feeRedemptionManager), allocateAmount); vm.startPrank(vault); deal(WETH, localStrategy, allocateAmount); IMYTStrategy(localStrategy).allocate(allocParams, allocateAmount, "", address(vault)); IMYTStrategy.VaultAdapterParams memory directDealloc; directDealloc.action = IMYTStrategy.ActionType.direct; bytes memory deallocParams = abi.encode(directDealloc); IMYTStrategy(localStrategy).deallocate(deallocParams, deallocateAmount, "", address(vault)); assertGe(IERC20(WETH).balanceOf(localStrategy), deallocateAmount, "strategy should adapt to updated fee"); vm.stopPrank(); } function _effectiveDeallocateAmount(uint256 requestedAssets) internal view override returns (uint256) { uint256 maxEETH = IWeETH(WEETH).getEETHByWeETH(IWeETH(WEETH).balanceOf(strategy)); if (maxEETH == 0) return 0; // `MYTStrategy.deallocate()` requires totalValueAfter >= assets, so cap requests // to at most half of current position to stay inside that invariant. uint256 maxSafe = maxEETH / 2; if (maxSafe == 0) return 0; uint256 capped = requestedAssets < maxSafe ? requestedAssets : maxSafe; uint256 minAssetForOneWeETH = IWeETH(WEETH).getEETHByWeETH(1); if (capped < minAssetForOneWeETH && minAssetForOneWeETH <= maxSafe) { return minAssetForOneWeETH; } return capped; } function _allocateMultipleTimes(uint256[] memory rawAmounts) internal { uint256 iterations = bound(rawAmounts.length, 2, 10); bytes memory allocParams = getAllocateVaultParams(0); uint256 lastRealAssets = IMYTStrategy(strategy).realAssets(); uint256 successfulAllocs = 0; uint256 minAllocUnit = 1e16; vm.startPrank(vault); for (uint256 i = 0; i < iterations; i++) { (uint256 minAlloc, uint256 maxAlloc) = _getAllocationBounds(); if (maxAlloc < minAllocUnit) break; uint256 remainingIterations = iterations - i; uint256 maxPerIteration = maxAlloc / remainingIterations; if (maxPerIteration < minAllocUnit) break; uint256 seed = rawAmounts.length == 0 ? uint256(keccak256(abi.encode(i))) : rawAmounts[i % rawAmounts.length]; uint256 amount = bound(seed, minAlloc, maxPerIteration); deal(WETH, strategy, amount); IMYTStrategy(strategy).allocate(allocParams, amount, "", address(vault)); uint256 currentRealAssets = IMYTStrategy(strategy).realAssets(); assertGe(currentRealAssets, lastRealAssets, "Real assets should not decrease after allocation"); lastRealAssets = currentRealAssets; successfulAllocs++; } vm.stopPrank(); assertGt(successfulAllocs, 1, "Expected multiple successful allocations"); assertGt(IMYTStrategy(strategy).realAssets(), 0, "Final real assets should be positive"); } function test_fuzz_allocate_multiple_times(uint256[] memory rawAmounts) public { _allocateMultipleTimes(rawAmounts); } function test_fuzz_deallocate_direct_uses_instant_redeem_path_can_redeem( uint256[] memory rawAllocateAmounts, uint256 rawDeallocateAmount ) public { _allocateMultipleTimes(rawAllocateAmounts); uint256 maxDeallocate = _maxNetDirectDeallocate(REDEMPTION_MANAGER); require(maxDeallocate > 0, "max dealloc is zero"); uint256 deallocateAmount = bound(rawDeallocateAmount, 1, maxDeallocate); uint256 grossRedeemAmount = _grossRedeemAmount(REDEMPTION_MANAGER, deallocateAmount); if (!IRedemptionManagerView(REDEMPTION_MANAGER).canRedeem(grossRedeemAmount, ETH)) return; IMYTStrategy.VaultAdapterParams memory directDealloc; directDealloc.action = IMYTStrategy.ActionType.direct; bytes memory deallocParams = abi.encode(directDealloc); vm.startPrank(vault); try IMYTStrategy(strategy).deallocate(deallocParams, deallocateAmount, "", address(vault)) {} catch { vm.stopPrank(); return; } vm.stopPrank(); } } ================================================ FILE: src/test/strategies/EulerARBUSDCStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {ERC4626Strategy} from "../../strategies/ERC4626Strategy.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; interface IERC4626MaxWithdraw { function maxWithdraw(address owner) external view returns (uint256); } contract MockEulerARBUSDCStrategy is ERC4626Strategy { constructor(address _myt, StrategyParams memory _params, address _vault) ERC4626Strategy(_myt, _params, _vault) {} } contract EulerARBUSDCStrategyTest is BaseStrategyTest { address public constant EULER_USDC_VAULT = 0x0a1eCC5Fe8C9be3C809844fcBe615B46A869b899; address public constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; // Error(string) selector (0x08c379a0), observed as "PD". // In this suite it is observed on allocate paths (deposit mock), not deallocate. bytes4 internal constant ERROR_STRING_SELECTOR = 0x08c379a0; // Euler custom error selector (0xca0985cf): `E_ZeroShares()`. // In this suite it is observed on deallocate paths (withdraw mock), not allocate. bytes4 internal constant ALLOWED_EULER_REVERT_SELECTOR = 0xca0985cf; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "EulerARBUSDC", protocol: "EulerARBUSDC", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e6, globalCap: 1e18, estimatedYield: 100e6, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: USDC, vaultInitialDeposit: 1000e6, absoluteCap: 10_000e6, relativeCap: 1e18, decimals: 6}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new MockEulerARBUSDCStrategy(vault, params, EULER_USDC_VAULT)); } function getForkBlockNumber() internal pure override returns (uint256) { return 0; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("ARBITRUM_RPC_URL"); } function _effectiveDeallocateAmount(uint256 requestedAssets) internal view override returns (uint256) { uint256 maxWithdrawable = IERC4626MaxWithdraw(EULER_USDC_VAULT).maxWithdraw(strategy); return requestedAssets < maxWithdrawable ? requestedAssets : maxWithdrawable; } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { bool isFuzzOrHandler = context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; if (!isFuzzOrHandler) return false; return selector == ERROR_STRING_SELECTOR || selector == ALLOWED_EULER_REVERT_SELECTOR; } // Add any strategy-specific tests here function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1 * 10 ** testConfig.decimals, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_allowlisted_revert_error_string_is_deterministic() public { uint256 amountToAllocate = 1e6; bytes4 depositSelector = bytes4(keccak256("deposit(uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); vm.mockCallRevert( EULER_USDC_VAULT, abi.encodePacked(depositSelector), abi.encodeWithSelector(ERROR_STRING_SELECTOR, "PD") ); vm.expectRevert(bytes("PD")); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); vm.stopPrank(); } function test_allowlisted_revert_custom_selector_is_deterministic() public { uint256 amountToAllocate = 2e6; uint256 amountToDeallocate = 1e6; bytes4 withdrawSelector = bytes4(keccak256("withdraw(uint256,address,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCallRevert( EULER_USDC_VAULT, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ALLOWED_EULER_REVERT_SELECTOR) ); vm.expectRevert(ALLOWED_EULER_REVERT_SELECTOR); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/EulerARBWETHStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {ERC4626Strategy} from "../../strategies/ERC4626Strategy.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; interface IERC4626MaxWithdraw { function maxWithdraw(address owner) external view returns (uint256); } contract MockEulerARBWETHStrategy is ERC4626Strategy { constructor(address _myt, StrategyParams memory _params, address _eulerVault) ERC4626Strategy(_myt, _params, _eulerVault) {} } contract EulerARBWETHStrategyTest is BaseStrategyTest { address public constant EULER_WETH_VAULT = 0x78E3E051D32157AACD550fBB78458762d8f7edFF; address public constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1; // Error(string) selector (0x08c379a0), observed as "PD". // In this suite it is observed on allocate paths (deposit mock), not deallocate. bytes4 internal constant ERROR_STRING_SELECTOR = 0x08c379a0; // Euler custom error selector (0xca0985cf): `E_ZeroShares()`. // In this suite it is observed on deallocate paths (withdraw mock), not allocate. bytes4 internal constant ALLOWED_EULER_REVERT_SELECTOR = 0xca0985cf; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "EulerARBWETH", protocol: "EulerARBWETH", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e18, globalCap: 1e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: WETH, vaultInitialDeposit: 1000e18, absoluteCap: 10_000e18, relativeCap: 1e18, decimals: 18}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new MockEulerARBWETHStrategy(vault, params, EULER_WETH_VAULT)); } function getForkBlockNumber() internal pure override returns (uint256) { return 0; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("ARBITRUM_RPC_URL"); } function _effectiveDeallocateAmount(uint256 requestedAssets) internal view override returns (uint256) { uint256 maxWithdrawable = IERC4626MaxWithdraw(EULER_WETH_VAULT).maxWithdraw(strategy); return requestedAssets < maxWithdrawable ? requestedAssets : maxWithdrawable; } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { bool isFuzzOrHandler = context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; if (!isFuzzOrHandler) return false; return selector == ERROR_STRING_SELECTOR || selector == ALLOWED_EULER_REVERT_SELECTOR; } // Add any strategy-specific tests here function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1e6, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_allowlisted_revert_error_string_is_deterministic() public { uint256 amountToAllocate = 1e18; bytes4 depositSelector = bytes4(keccak256("deposit(uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); vm.mockCallRevert( EULER_WETH_VAULT, abi.encodePacked(depositSelector), abi.encodeWithSelector(ERROR_STRING_SELECTOR, "PD") ); vm.expectRevert(bytes("PD")); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); vm.stopPrank(); } function test_allowlisted_revert_custom_selector_is_deterministic() public { uint256 amountToAllocate = 2e18; uint256 amountToDeallocate = 1e18; bytes4 withdrawSelector = bytes4(keccak256("withdraw(uint256,address,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCallRevert( EULER_WETH_VAULT, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ALLOWED_EULER_REVERT_SELECTOR) ); vm.expectRevert(ALLOWED_EULER_REVERT_SELECTOR); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } // End-to-end test: Full lifecycle with time accumulation for EulerARBWETH function test_euler_arbweth_full_lifecycle_with_time() public { vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); // Initial allocation uint256 alloc1 = 400e18; // 400 ARB WETH IVaultV2(vault).allocate(strategy, getVaultParams(), alloc1); uint256 realAssets1 = IMYTStrategy(strategy).realAssets(); assertGt(realAssets1, 0, "Real assets should be positive after allocation"); assertApproxEqAbs(IVaultV2(vault).allocation(allocationId), alloc1, 1e15); // Warp forward 7 days vm.warp(block.timestamp + 7 days); // Additional allocation uint256 alloc2 = 250e18; // 250 ARB WETH IVaultV2(vault).allocate(strategy, getVaultParams(), alloc2); uint256 realAssets2 = IMYTStrategy(strategy).realAssets(); assertGe(realAssets2, realAssets1, "Real assets should not decrease"); // Warp forward 14 days vm.warp(block.timestamp + 14 days); // Partial deallocation (withdraw 150 WETH) uint256 deallocAmount1 = 150e18; uint256 deallocPreview1 = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount1); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview1); uint256 realAssets3 = IMYTStrategy(strategy).realAssets(); assertLt(realAssets3, realAssets2, "Real assets should decrease after deallocation"); // Warp forward 30 days vm.warp(block.timestamp + 30 days); // Check vault WETH balance uint256 vaultWETHBalance = IERC20(WETH).balanceOf(vault); assertGt(vaultWETHBalance, 0, "Vault should have WETH"); // Full deallocation of remaining uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e15) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } uint256 finalVaultWETHBalance = IERC20(WETH).balanceOf(vault); assertGt(finalVaultWETHBalance, vaultWETHBalance, "Vault WETH should increase after deallocation"); vm.stopPrank(); } // Fuzz test: Multiple random allocations and deallocations with time warps function test_fuzz_euler_arbweth_operations(uint256[] calldata amounts, uint256[] calldata timeDelays) public { // Use bound for array length instead of assume uint256 numOps = bound(amounts.length, 1, 8); // Ensure we don't access beyond array bounds uint256 maxIterations = numOps < amounts.length ? numOps : amounts.length; vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); for (uint256 i = 0; i < maxIterations; i++) { // Alternate between allocation and deallocation bool isAllocate = i % 2 == 0; uint256 amount = bound(amounts[i], 0.1e18, 50e18); // 0.1-50 ARB WETH if (isAllocate) { IVaultV2(vault).allocate(strategy, getVaultParams(), amount); } else { uint256 currentAllocation = IVaultV2(vault).allocation(allocationId); if (currentAllocation > 0) { uint256 maxDealloc = currentAllocation < 0.1e18 ? currentAllocation : 0.1e18; uint256 deallocAmount = bound(amount, 0, maxDealloc); if (deallocAmount > 0) { uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount); if (deallocPreview > 0) { IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); } } } } // Warp forward (with bounds check for timeDelays array) uint256 timeDelay = i < timeDelays.length ? bound(timeDelays[i], 1 hours, 30 days) : 1 hours; vm.warp(block.timestamp + timeDelay); } // Final sanity checks uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); uint256 finalAllocation = IVaultV2(vault).allocation(allocationId); uint256 vaultWETHBalance = IERC20(WETH).balanceOf(vault); assertGe(finalRealAssets, 0, "Real assets should be non-negative"); assertGe(finalAllocation, 0, "Allocation should be non-negative"); assertGt(vaultWETHBalance, 0, "Vault should have WETH"); vm.stopPrank(); } // Test: Euler ARB vault yield accumulation over time function test_euler_arbweth_yield_accumulation() public { vm.startPrank(allocator); // Allocate initial amount uint256 allocAmount = 300e18; // 300 ARB WETH IVaultV2(vault).allocate(strategy, getVaultParams(), allocAmount); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); // Track real assets over time with warps uint256[] memory realAssetsSnapshots = new uint256[](4); uint256 minExpected = initialRealAssets * 95 / 100; // Start with 95% of initial as minimum for (uint256 i = 0; i < 4; i++) { _warpWithHook(30 days); // Simulate yield by transferring small amount to strategy (0.5% per period) deal(testConfig.vaultAsset, strategy, initialRealAssets * 5 / 1000); realAssetsSnapshots[i] = IMYTStrategy(strategy).realAssets(); // Real assets should not significantly decrease (may increase with yield) assertGe(realAssetsSnapshots[i], minExpected, "Real assets decreased significantly"); // Update minExpected to the new baseline minExpected = realAssetsSnapshots[i]; // Small deallocation on second snapshot if (i == 1) { uint256 smallDealloc = 30e18; // 30 ARB WETH bool smallOk = _deallocateEstimate(smallDealloc, RevertContext.FuzzDeallocate); if (smallOk) { // Update minExpected after deallocation to account for the reduction minExpected = IMYTStrategy(strategy).realAssets(); } } } // Final deallocation (best effort under allowlist-aware multi-step flows). bool finalOk = _deallocateFromRealAssetsEstimate(RevertContext.FuzzDeallocate); if (!finalOk) { vm.stopPrank(); return; } // Allow small tolerance for slippage/rounding and protocol-side withdraw limits. assertApproxEqAbs(IMYTStrategy(strategy).realAssets(), 0, initialRealAssets / 50, "All real assets should be deallocated"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/EulerUSDCStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {ERC4626Strategy} from "../../strategies/ERC4626Strategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; interface IERC4626MaxWithdraw { function maxWithdraw(address owner) external view returns (uint256); } contract MockEulerUSDCStrategy is ERC4626Strategy { constructor(address _myt, StrategyParams memory _params, address _vault) ERC4626Strategy(_myt, _params, _vault) {} } contract EulerUSDCStrategyTest is BaseStrategyTest { address public constant EULER_USDC_VAULT = 0xe0a80d35bB6618CBA260120b279d357978c42BCE; address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // Error(string) selector (0x08c379a0), observed as "PD". // In this suite it is observed on allocate paths (deposit mock), not deallocate. bytes4 internal constant ERROR_STRING_SELECTOR = 0x08c379a0; // Euler custom error selector (0xca0985cf): `E_ZeroShares()`. // In this suite it is observed on deallocate paths (withdraw mock), not allocate. bytes4 internal constant ALLOWED_EULER_REVERT_SELECTOR = 0xca0985cf; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "EulerUSDC", protocol: "EulerUSDC", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e6, globalCap: 1e18, estimatedYield: 100e6, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: USDC, vaultInitialDeposit: 1000e6, absoluteCap: 10_000e6, relativeCap: 1e18, decimals: 6}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new MockEulerUSDCStrategy(vault, params, EULER_USDC_VAULT)); } function getForkBlockNumber() internal pure override returns (uint256) { return 22_089_302; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("MAINNET_RPC_URL"); } function _effectiveDeallocateAmount(uint256 requestedAssets) internal view override returns (uint256) { uint256 maxWithdrawable = IERC4626MaxWithdraw(EULER_USDC_VAULT).maxWithdraw(strategy); return requestedAssets < maxWithdrawable ? requestedAssets : maxWithdrawable; } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { bool isFuzzOrHandler = context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; if (!isFuzzOrHandler) return false; return selector == ERROR_STRING_SELECTOR || selector == ALLOWED_EULER_REVERT_SELECTOR; } // Add any strategy-specific tests here function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1e6, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_allowlisted_revert_error_string_is_deterministic() public { uint256 amountToAllocate = 1e6; bytes4 depositSelector = bytes4(keccak256("deposit(uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); vm.mockCallRevert( EULER_USDC_VAULT, abi.encodePacked(depositSelector), abi.encodeWithSelector(ERROR_STRING_SELECTOR, "PD") ); vm.expectRevert(bytes("PD")); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); vm.stopPrank(); } function test_allowlisted_revert_custom_selector_is_deterministic() public { uint256 amountToAllocate = 2e6; uint256 amountToDeallocate = 1e6; bytes4 withdrawSelector = bytes4(keccak256("withdraw(uint256,address,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCallRevert( EULER_USDC_VAULT, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ALLOWED_EULER_REVERT_SELECTOR) ); vm.expectRevert(ALLOWED_EULER_REVERT_SELECTOR); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } // End-to-end test: Full lifecycle with time accumulation for EulerUSDC function test_euler_usdc_full_lifecycle_with_time() public { vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 initialVaultTotalAssets = IVaultV2(vault).totalAssets(); // Initial allocation uint256 alloc1 = 500e6; // 500 USDC IVaultV2(vault).allocate(strategy, getVaultParams(), alloc1); uint256 realAssets1 = IMYTStrategy(strategy).realAssets(); assertGt(realAssets1, 0, "Real assets should be positive after allocation"); assertApproxEqAbs(IVaultV2(vault).allocation(allocationId), alloc1, 1e5); // Warp forward 7 days vm.warp(block.timestamp + 7 days); // Additional allocation uint256 alloc2 = 300e6; // 300 USDC IVaultV2(vault).allocate(strategy, getVaultParams(), alloc2); uint256 realAssets2 = IMYTStrategy(strategy).realAssets(); assertGe(realAssets2, realAssets1, "Real assets should not decrease"); assertGe(IVaultV2(vault).allocation(allocationId), alloc1 + alloc2, "Allocation is less than 2 previous deposits"); // Warp forward 14 days vm.warp(block.timestamp + 14 days); // Partial deallocation (withdraw 200 USDC) uint256 deallocAmount1 = 200e6; uint256 deallocPreview1 = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount1); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview1); uint256 realAssets3 = IMYTStrategy(strategy).realAssets(); assertLt(realAssets3, realAssets2, "Real assets should decrease after deallocation"); // Warp forward 30 days vm.warp(block.timestamp + 30 days); // Check vault Euler balance reflects accumulated yield uint256 vaultUSDCBalance = IERC20(USDC).balanceOf(vault); assertGt(vaultUSDCBalance, 0, "Vault should have USDC"); // Full deallocation of remaining uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e6) { // Only if > 1 USDC uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } uint256 finalVaultUSDCBalance = IERC20(USDC).balanceOf(vault); assertGt(finalVaultUSDCBalance, vaultUSDCBalance, "Vault USDC should increase after deallocation"); vm.stopPrank(); } // Fuzz test: Multiple random allocations and deallocations with time warps function test_fuzz_euler_usdc_operations(uint256[] calldata amounts, uint256[] calldata timeDelays) public { // Use bound for array length instead of assume uint256 numOps = bound(amounts.length, 1, 8); // Ensure we don't access beyond array bounds uint256 maxIterations = numOps < amounts.length ? numOps : amounts.length; vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); for (uint256 i = 0; i < maxIterations; i++) { // Alternate between allocation and deallocation bool isAllocate = i % 2 == 0; uint256 amount = bound(amounts[i], 10e6, 100e6); // 10-100 USDC if (isAllocate) { IVaultV2(vault).allocate(strategy, getVaultParams(), amount); } else { uint256 currentAllocation = IVaultV2(vault).allocation(allocationId); if (currentAllocation > 0) { uint256 maxDealloc = currentAllocation < 10e6 ? currentAllocation : 10e6; uint256 deallocAmount = bound(amount, 0, maxDealloc); if (deallocAmount > 0) { uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount); if (deallocPreview > 0) { IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); } } } } // Warp forward (with bounds check for timeDelays array) uint256 timeDelay = i < timeDelays.length ? bound(timeDelays[i], 1 hours, 30 days) : 1 hours; vm.warp(block.timestamp + timeDelay); } // Final sanity checks uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); uint256 finalAllocation = IVaultV2(vault).allocation(allocationId); uint256 vaultUSDCBalance = IERC20(USDC).balanceOf(vault); assertGe(finalRealAssets, 0, "Real assets should be non-negative"); assertGe(finalAllocation, 0, "Allocation should be non-negative"); assertGt(vaultUSDCBalance, 0, "Vault should have USDC"); vm.stopPrank(); } // Test: Euler vault yield accumulation over time function test_euler_usdc_yield_accumulation() public { vm.startPrank(allocator); // Allocate initial amount uint256 allocAmount = 400e6; // 400 USDC IVaultV2(vault).allocate(strategy, getVaultParams(), allocAmount); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); // Track real assets over time with warps uint256[] memory realAssetsSnapshots = new uint256[](5); uint256 minExpected = initialRealAssets * 95 / 100; // Start with 95% of initial as minimum for (uint256 i = 0; i < 5; i++) { vm.warp(block.timestamp + 30 days); // Simulate yield by transferring small amount to strategy (0.5% per period) deal(testConfig.vaultAsset, strategy, initialRealAssets * 5 / 1000); realAssetsSnapshots[i] = IMYTStrategy(strategy).realAssets(); // Real assets should not significantly decrease (may increase with yield) assertGe(realAssetsSnapshots[i], minExpected, "Real assets decreased significantly"); // Update minExpected to the new baseline minExpected = realAssetsSnapshots[i]; // Small deallocation on second snapshot if (i == 1) { uint256 smallDealloc = 50e6; // 50 USDC uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(smallDealloc); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); // Update minExpected after deallocation to account for the reduction minExpected = IMYTStrategy(strategy).realAssets(); } } // Final deallocation uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e6) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } // Allow small tolerance for slippage/rounding (up to 1% of initial) assertApproxEqAbs(IMYTStrategy(strategy).realAssets(), 0, initialRealAssets / 100, "All real assets should be deallocated"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/EulerWETHStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {ERC4626Strategy} from "../../strategies/ERC4626Strategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; interface IERC4626MaxWithdraw { function maxWithdraw(address owner) external view returns (uint256); } contract MockEulerWETHStrategy is ERC4626Strategy { constructor(address _myt, StrategyParams memory _params, address _eulerVault) ERC4626Strategy(_myt, _params, _eulerVault) {} } contract EulerWETHStrategyTest is BaseStrategyTest { address public constant EULER_WETH_VAULT = 0xD8b27CF359b7D15710a5BE299AF6e7Bf904984C2; address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // Error(string) selector (0x08c379a0), observed as "PD". // In this suite it is observed on allocate paths (deposit mock), not deallocate. bytes4 internal constant ERROR_STRING_SELECTOR = 0x08c379a0; // Euler custom error selector (0xca0985cf): `E_ZeroShares()`. // In this suite it is observed on deallocate paths (withdraw mock), not allocate. bytes4 internal constant ALLOWED_EULER_REVERT_SELECTOR = 0xca0985cf; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "EulerWETH", protocol: "EulerWETH", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e18, globalCap: 1e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: WETH, vaultInitialDeposit: 1000e18, absoluteCap: 10_000e18, relativeCap: 1e18, decimals: 18}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new MockEulerWETHStrategy(vault, params, EULER_WETH_VAULT)); } function getForkBlockNumber() internal pure override returns (uint256) { return 22_089_302; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("MAINNET_RPC_URL"); } function _effectiveDeallocateAmount(uint256 requestedAssets) internal view override returns (uint256) { uint256 maxWithdrawable = IERC4626MaxWithdraw(EULER_WETH_VAULT).maxWithdraw(strategy); return requestedAssets < maxWithdrawable ? requestedAssets : maxWithdrawable; } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { bool isFuzzOrHandler = context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; if (!isFuzzOrHandler) return false; return selector == ERROR_STRING_SELECTOR || selector == ALLOWED_EULER_REVERT_SELECTOR; } // Add any strategy-specific tests here function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1e6, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_allowlisted_revert_error_string_is_deterministic() public { uint256 amountToAllocate = 1e18; bytes4 depositSelector = bytes4(keccak256("deposit(uint256,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); vm.mockCallRevert( EULER_WETH_VAULT, abi.encodePacked(depositSelector), abi.encodeWithSelector(ERROR_STRING_SELECTOR, "PD") ); vm.expectRevert(bytes("PD")); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); vm.stopPrank(); } function test_allowlisted_revert_custom_selector_is_deterministic() public { uint256 amountToAllocate = 2e18; uint256 amountToDeallocate = 1e18; bytes4 withdrawSelector = bytes4(keccak256("withdraw(uint256,address,address)")); vm.startPrank(allocator); _prepareVaultAssets(amountToAllocate); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(amountToDeallocate); require(deallocPreview > 0, "preview is zero"); vm.mockCallRevert( EULER_WETH_VAULT, abi.encodePacked(withdrawSelector), abi.encodeWithSelector(ALLOWED_EULER_REVERT_SELECTOR) ); vm.expectRevert(ALLOWED_EULER_REVERT_SELECTOR); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); vm.stopPrank(); } // End-to-end test: Full lifecycle with time accumulation for EulerWETH function test_euler_weth_full_lifecycle_with_time() public { vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 initialVaultTotalAssets = IVaultV2(vault).totalAssets(); // Initial allocation uint256 alloc1 = 2e18; // 2 WETH IVaultV2(vault).allocate(strategy, getVaultParams(), alloc1); uint256 realAssets1 = IMYTStrategy(strategy).realAssets(); assertGt(realAssets1, 0, "Real assets should be positive after allocation"); assertApproxEqAbs(IVaultV2(vault).allocation(allocationId), alloc1, 1e15); // Warp forward 7 days vm.warp(block.timestamp + 7 days); // Additional allocation uint256 alloc2 = 1e18; // 1 WETH IVaultV2(vault).allocate(strategy, getVaultParams(), alloc2); uint256 realAssets2 = IMYTStrategy(strategy).realAssets(); assertGe(realAssets2, realAssets1, "Real assets should not decrease"); // Warp forward 14 days vm.warp(block.timestamp + 14 days); // Partial deallocation (withdraw 0.5 WETH) uint256 deallocAmount1 = 0.5e18; uint256 deallocPreview1 = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount1); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview1); uint256 realAssets3 = IMYTStrategy(strategy).realAssets(); assertLt(realAssets3, realAssets2, "Real assets should decrease after deallocation"); // Warp forward 30 days vm.warp(block.timestamp + 30 days); // Check vault WETH balance uint256 vaultWETHBalance = IERC20(WETH).balanceOf(vault); assertGt(vaultWETHBalance, 0, "Vault should have WETH"); // Full deallocation of remaining uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e15) { // Only if > 0.001 WETH uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } uint256 finalVaultWETHBalance = IERC20(WETH).balanceOf(vault); assertGt(finalVaultWETHBalance, vaultWETHBalance, "Vault WETH should increase after deallocation"); vm.stopPrank(); } // Fuzz test: Multiple random allocations and deallocations with time warps function test_fuzz_euler_weth_operations(uint256[] calldata amounts, uint256[] calldata timeDelays) public { // Use bound for array length instead of assume uint256 numOps = bound(amounts.length, 1, 8); // Ensure we don't access beyond array bounds uint256 maxIterations = numOps < amounts.length ? numOps : amounts.length; vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); for (uint256 i = 0; i < maxIterations; i++) { // Alternate between allocation and deallocation bool isAllocate = i % 2 == 0; uint256 amount = bound(amounts[i], 0.1e18, 10e18); // 0.1-10 WETH if (isAllocate) { IVaultV2(vault).allocate(strategy, getVaultParams(), amount); } else { uint256 currentAllocation = IVaultV2(vault).allocation(allocationId); if (currentAllocation > 0) { uint256 maxDealloc = currentAllocation < 0.1e18 ? currentAllocation : 0.1e18; uint256 deallocAmount = bound(amount, 0, maxDealloc); if (deallocAmount > 0) { uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount); if (deallocPreview > 0) { IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); } } } } // Warp forward (with bounds check for timeDelays array) uint256 timeDelay = i < timeDelays.length ? bound(timeDelays[i], 1 hours, 30 days) : 1 hours; vm.warp(block.timestamp + timeDelay); } // Final sanity checks uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); uint256 finalAllocation = IVaultV2(vault).allocation(allocationId); uint256 vaultWETHBalance = IERC20(WETH).balanceOf(vault); assertGe(finalRealAssets, 0, "Real assets should be non-negative"); assertGe(finalAllocation, 0, "Allocation should be non-negative"); assertGt(vaultWETHBalance, 0, "Vault should have WETH"); vm.stopPrank(); } // Test: Euler vault yield accumulation over time function test_euler_weth_yield_accumulation() public { vm.startPrank(allocator); // Allocate initial amount uint256 allocAmount = 3e18; // 3 WETH IVaultV2(vault).allocate(strategy, getVaultParams(), allocAmount); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); // Track real assets over time with warps uint256[] memory realAssetsSnapshots = new uint256[](5); uint256 minExpected = initialRealAssets * 95 / 100; // Start with 95% of initial as minimum for (uint256 i = 0; i < 5; i++) { vm.warp(block.timestamp + 30 days); // Simulate yield by transferring small amount to strategy (0.5% per period) deal(testConfig.vaultAsset, strategy, initialRealAssets * 5 / 1000); realAssetsSnapshots[i] = IMYTStrategy(strategy).realAssets(); // Real assets should not significantly decrease (may increase with yield) assertGe(realAssetsSnapshots[i], minExpected, "Real assets decreased significantly"); // Update minExpected to the new baseline minExpected = realAssetsSnapshots[i]; // Small deallocation on second snapshot if (i == 1) { uint256 smallDealloc = 0.5e18; // 0.5 WETH uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(smallDealloc); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); // Update minExpected after deallocation to account for the reduction minExpected = IMYTStrategy(strategy).realAssets(); } } // Final deallocation uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e15) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } // Allow small tolerance for slippage/rounding (up to 1% of initial) assertApproxEqAbs(IMYTStrategy(strategy).realAssets(), 0, initialRealAssets / 100, "All real assets should be deallocated"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/FluidARBUSDCStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {ERC4626Strategy} from "../../strategies/ERC4626Strategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; contract MockFluidARBUSDCStrategy is ERC4626Strategy { constructor(address _myt, StrategyParams memory _params, address _vault) ERC4626Strategy(_myt, _params, _vault) {} } contract FluidARBUSDCStrategyTest is BaseStrategyTest { address public constant FLUID_USDC_VAULT = 0x1A996cb54bb95462040408C06122D45D6Cdb6096; address public constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; // Fluid custom error selector (0xdcab82e2): `FluidLiquidityError(uint256)`. // Observed in fork traces on allocator allocate path when Fluid `deposit` is called with dust-sized amounts (e.g. 1 unit). // In this suite it is observed on allocate paths; deallocate-path occurrences were not observed. // Allowlisted only for fuzz/handler contexts to avoid flakiness from protocol-side dust guards. bytes4 internal constant ALLOWED_FLUID_REVERT_SELECTOR = 0xdcab82e2; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "FluidARBUSDC", protocol: "FluidARBUSDC", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e6, globalCap: 1e18, estimatedYield: 100e6, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: USDC, vaultInitialDeposit: 1000e6, absoluteCap: 10_000e6, relativeCap: 1e18, decimals: 6}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new MockFluidARBUSDCStrategy(vault, params, FLUID_USDC_VAULT)); } function getForkBlockNumber() internal pure override returns (uint256) { return 0; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("ARBITRUM_RPC_URL"); } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { bool isFuzzOrHandler = context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; return isFuzzOrHandler && selector == ALLOWED_FLUID_REVERT_SELECTOR; } // Add any strategy-specific tests here function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1 * 10 ** testConfig.decimals, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } // End-to-end test: Full lifecycle with time accumulation for FluidARBUSDC function test_fluid_arbusdc_full_lifecycle_with_time() public { vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); // Initial allocation uint256 alloc1 = 300e6; // 300 ARB USDC IVaultV2(vault).allocate(strategy, getVaultParams(), alloc1); uint256 realAssets1 = IMYTStrategy(strategy).realAssets(); assertGt(realAssets1, 0, "Real assets should be positive after allocation"); assertApproxEqAbs(IVaultV2(vault).allocation(allocationId), alloc1, 1e5); // Warp forward 7 days vm.warp(block.timestamp + 7 days); // Additional allocation uint256 alloc2 = 200e6; // 200 ARB USDC IVaultV2(vault).allocate(strategy, getVaultParams(), alloc2); uint256 realAssets2 = IMYTStrategy(strategy).realAssets(); assertGe(realAssets2, realAssets1, "Real assets should not decrease"); // Warp forward 14 days vm.warp(block.timestamp + 14 days); // Partial deallocation (withdraw 100 USDC) uint256 deallocAmount1 = 100e6; uint256 deallocPreview1 = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount1); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview1); uint256 realAssets3 = IMYTStrategy(strategy).realAssets(); assertLt(realAssets3, realAssets2, "Real assets should decrease after deallocation"); // Warp forward 30 days vm.warp(block.timestamp + 30 days); // Check vault USDC balance uint256 vaultUSDCBalance = IERC20(USDC).balanceOf(vault); assertGt(vaultUSDCBalance, 0, "Vault should have USDC"); // Full deallocation of remaining uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e6) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } uint256 finalVaultUSDCBalance = IERC20(USDC).balanceOf(vault); assertGt(finalVaultUSDCBalance, vaultUSDCBalance, "Vault USDC should increase after deallocation"); vm.stopPrank(); } // Fuzz test: Multiple random allocations and deallocations with time warps function test_fuzz_fluid_arbusdc_operations(uint256[] calldata amounts, uint256[] calldata timeDelays) public { // Use bound for array length instead of assume uint256 numOps = bound(amounts.length, 1, 8); // Ensure we don't access beyond array bounds uint256 maxIterations = numOps < amounts.length ? numOps : amounts.length; vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); for (uint256 i = 0; i < maxIterations; i++) { // Alternate between allocation and deallocation bool isAllocate = i % 2 == 0; uint256 amount = bound(amounts[i], 10e6, 100e6); // 10-100 ARB USDC if (isAllocate) { IVaultV2(vault).allocate(strategy, getVaultParams(), amount); } else { uint256 currentAllocation = IVaultV2(vault).allocation(allocationId); if (currentAllocation > 0) { uint256 maxDealloc = currentAllocation < 10e6 ? currentAllocation : 10e6; uint256 deallocAmount = bound(amount, 0, maxDealloc); if (deallocAmount > 0) { uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount); if (deallocPreview > 0) { IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); } } } } // Warp forward (with bounds check for timeDelays array) uint256 timeDelay = i < timeDelays.length ? bound(timeDelays[i], 1 hours, 30 days) : 1 hours; vm.warp(block.timestamp + timeDelay); } // Final sanity checks uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); uint256 finalAllocation = IVaultV2(vault).allocation(allocationId); uint256 vaultUSDCBalance = IERC20(USDC).balanceOf(vault); assertGe(finalRealAssets, 0, "Real assets should be non-negative"); assertGe(finalAllocation, 0, "Allocation should be non-negative"); assertGt(vaultUSDCBalance, 0, "Vault should have USDC"); vm.stopPrank(); } // Test: Fluid ARB USDC vault yield accumulation over time function test_fluid_arbusdc_yield_accumulation() public { vm.startPrank(allocator); // Allocate initial amount uint256 allocAmount = 250e6; // 250 ARB USDC IVaultV2(vault).allocate(strategy, getVaultParams(), allocAmount); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); // Track real assets over time with warps uint256[] memory realAssetsSnapshots = new uint256[](4); uint256 minExpected = initialRealAssets * 95 / 100; // Start with 95% of initial as minimum for (uint256 i = 0; i < 4; i++) { vm.warp(block.timestamp + 30 days); // Simulate yield by transferring small amount to strategy (0.5% per period) deal(testConfig.vaultAsset, strategy, initialRealAssets * 5 / 1000); realAssetsSnapshots[i] = IMYTStrategy(strategy).realAssets(); // Real assets should not significantly decrease (may increase with yield) assertGe(realAssetsSnapshots[i], minExpected, "Real assets decreased significantly"); // Update minExpected to the new baseline minExpected = realAssetsSnapshots[i]; // Small deallocation on second snapshot if (i == 1) { uint256 smallDealloc = 25e6; // 25 ARB USDC uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(smallDealloc); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); // Update minExpected after deallocation to account for the reduction minExpected = IMYTStrategy(strategy).realAssets(); } } // Final deallocation uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e6) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } // Allow small tolerance for slippage/rounding (up to 1% of initial) assertApproxEqAbs(IMYTStrategy(strategy).realAssets(), 0, initialRealAssets / 100, "All real assets should be deallocated"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/SFraxETHStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {Test} from "forge-std/Test.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {AggregatorV3Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {IAllocator} from "../../interfaces/IAllocator.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {AlchemistAllocator} from "../../AlchemistAllocator.sol"; import {AlchemistStrategyClassifier} from "../../AlchemistStrategyClassifier.sol"; import {FrxEthEthDualOracleAggregatorAdapter} from "../../FrxEthEthDualOracleAggregatorAdapter.sol"; import {SFraxETHStrategy} from "../../strategies/SFraxETHStrategy.sol"; import {TokenUtils} from "../../libraries/TokenUtils.sol"; import {MockMYTVault} from "../mocks/MockMYTVault.sol"; interface ISfrxETHView { function balanceOf(address account) external view returns (uint256); function convertToAssets(uint256 shares) external view returns (uint256 assets); } contract MockSwapperForSFraxETH { function swap(address from, address to, uint256 amountIn, uint256 amountOut) external { require(IERC20(from).transferFrom(msg.sender, address(this), amountIn), "pull failed"); require(IERC20(to).transfer(msg.sender, amountOut), "push failed"); } } contract SFraxETHStrategyTest is Test { uint256 internal constant ABSOLUTE_CAP = 1_000_000e18; uint256 internal constant RELATIVE_CAP = 1e18; uint256 internal constant MIN_FRXETH_OUT_BPS = 9000; uint256 internal constant MAX_ORACLE_STALENESS = 24 hours; address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address public constant FRXETH = 0x5E8422345238F34275888049021821E8E08CAa1f; address public constant SFRXETH = 0xac3E018457B222d93114458476f3E3416Abbe38F; address public constant FRAX_MINTER_V2 = 0x7Bc6bad540453360F744666D625fec0ee1320cA3; address public constant FRXETH_ETH_DUAL_ORACLE = 0x350a9841956D8B0212EAdF5E14a449CA85FAE1C0; MockMYTVault internal vault; AlchemistAllocator internal allocator; AlchemistStrategyClassifier internal classifier; SFraxETHStrategy internal strategy; FrxEthEthDualOracleAggregatorAdapter internal oracleAdapter; address internal admin = address(0xA11CE); address internal operator = address(0xB0B); uint256 private _forkId; function setUp() public { _forkId = vm.createFork(vm.envString("MAINNET_RPC_URL"), getForkBlockNumber()); vm.selectFork(_forkId); vault = new MockMYTVault(admin, WETH); classifier = new AlchemistStrategyClassifier(admin); oracleAdapter = new FrxEthEthDualOracleAggregatorAdapter(FRXETH_ETH_DUAL_ORACLE); vm.startPrank(admin); vault.setCurator(operator); classifier.setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% classifier.setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% classifier.setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% allocator = new AlchemistAllocator(address(vault), admin, operator, address(classifier)); vm.stopPrank(); vm.startPrank(operator); vault.submit(abi.encodeCall(IVaultV2.setPerformanceFeeRecipient, (admin))); vault.setPerformanceFeeRecipient(admin); vault.submit(abi.encodeCall(IVaultV2.setPerformanceFee, (15e16))); vault.setPerformanceFee(15e16); vm.stopPrank(); vm.startPrank(admin); IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "sfrxETH", protocol: "Frax", riskClass: IMYTStrategy.RiskClass.LOW, cap: ABSOLUTE_CAP, globalCap: ABSOLUTE_CAP, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 100 }); strategy = new SFraxETHStrategy( address(vault), params, FRAX_MINTER_V2, FRXETH, SFRXETH, address(oracleAdapter), 0, MAX_ORACLE_STALENESS ); classifier.assignStrategyRiskLevel(uint256(IMYTStrategy(address(strategy)).adapterId()), uint8(params.riskClass)); vm.stopPrank(); _setUpMYT(); _mockFreshFrxEthEthOracle(); _depositToVault(ABSOLUTE_CAP); } function test_allocator_allocate_direct_mintsSfrxEth(uint256 amount) public { _mockFreshFrxEthEthOracle(); amount = bound(amount, 1e18, ABSOLUTE_CAP); vm.prank(admin); IAllocator(address(allocator)).allocate(address(strategy), amount); assertGt(ISfrxETHView(SFRXETH).balanceOf(address(strategy)), 0, "strategy should receive sfrxETH shares"); assertEq(IERC20(WETH).balanceOf(address(strategy)), 0, "strategy should not keep idle WETH after direct allocate"); assertApproxEqRel( IMYTStrategy(address(strategy)).realAssets(), ISfrxETHView(SFRXETH).convertToAssets(ISfrxETHView(SFRXETH).balanceOf(address(strategy))), 1e15 ); } function test_allocator_allocateWithSwap_wrapsReceivedFrxEthIntoSfrxEth() public { _mockFreshFrxEthEthOracle(); MockSwapperForSFraxETH swapper = new MockSwapperForSFraxETH(); deal(FRXETH, address(swapper), 10e18); vm.prank(admin); MYTStrategy(address(strategy)).setAllowanceHolder(address(swapper)); bytes memory txData = abi.encodeCall(MockSwapperForSFraxETH.swap, (WETH, FRXETH, 10e18, 10e18)); vm.prank(admin); IAllocator(address(allocator)).allocateWithSwap(address(strategy), 10e18, txData); uint256 sharesBalance = ISfrxETHView(SFRXETH).balanceOf(address(strategy)); assertGt(sharesBalance, 0, "strategy should receive sfrxETH shares after swap allocation"); assertEq(IERC20(FRXETH).balanceOf(address(strategy)), 0, "strategy should not retain frxETH after deposit"); assertEq(IERC20(WETH).balanceOf(address(strategy)), 0, "strategy should not retain idle WETH after swap allocation"); assertApproxEqRel(IMYTStrategy(address(strategy)).realAssets(), ISfrxETHView(SFRXETH).convertToAssets(sharesBalance), 1e15); } function test_realAssets_includesRawFrxEthBalance() public { _mockFreshFrxEthEthOracle(); deal(FRXETH, address(strategy), 3e18); assertEq(IMYTStrategy(address(strategy)).realAssets(), 3e18, "raw frxETH should be counted in real assets"); } function test_allocator_allocateWithSwap_reverts_below_minFrxEthOut_floor_when_oracle_min_is_weakened() public { uint256 amountIn = 10e18; uint256 minFrxEthOut = (amountIn * MIN_FRXETH_OUT_BPS) / 10_000; uint256 mockedOut = minFrxEthOut - 1; vm.mockCall( FRXETH_ETH_DUAL_ORACLE, abi.encodeWithSignature("getPrices()"), abi.encode(false, uint256(100e18), uint256(100e18)) ); MockSwapperForSFraxETH swapper = new MockSwapperForSFraxETH(); deal(FRXETH, address(swapper), mockedOut); vm.prank(admin); strategy.setMinFrxEthOutBps(MIN_FRXETH_OUT_BPS); vm.prank(admin); MYTStrategy(address(strategy)).setAllowanceHolder(address(swapper)); bytes memory txData = abi.encodeCall(MockSwapperForSFraxETH.swap, (WETH, FRXETH, amountIn, mockedOut)); vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.InvalidAmount.selector, minFrxEthOut, mockedOut)); vm.prank(admin); IAllocator(address(allocator)).allocateWithSwap(address(strategy), amountIn, txData); } function test_setMinFrxEthOutBps_onlyOwner_updatesValue() public { assertEq(strategy.minFrxEthOutBps(), 0, "unexpected initial minFrxEthOutBps"); vm.prank(admin); strategy.setMinFrxEthOutBps(MIN_FRXETH_OUT_BPS); assertEq(strategy.minFrxEthOutBps(), MIN_FRXETH_OUT_BPS, "minFrxEthOutBps should update"); } function test_setMinFrxEthOutBps_reverts_for_non_owner() public { vm.expectRevert(); vm.prank(operator); strategy.setMinFrxEthOutBps(MIN_FRXETH_OUT_BPS); } function test_setMinFrxEthOutBps_reverts_above_bps_limit() public { vm.expectRevert(bytes("Invalid min frxETH out bps")); vm.prank(admin); strategy.setMinFrxEthOutBps(10_001); } function test_constructor_sets_max_oracle_staleness() public view { assertEq(strategy.MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS, "unexpected initial max oracle staleness"); } function test_allocator_deallocate_with_unwrapAndSwap_usesFrxEthIntermediate() public { _mockFreshFrxEthEthOracle(); vm.prank(admin); IAllocator(address(allocator)).allocate(address(strategy), 10e18); MockSwapperForSFraxETH swapper = new MockSwapperForSFraxETH(); deal(WETH, address(swapper), 4e18); vm.prank(admin); MYTStrategy(address(strategy)).setAllowanceHolder(address(swapper)); uint256 vaultBalanceBefore = IERC20(WETH).balanceOf(address(vault)); uint256 strategySharesBefore = ISfrxETHView(SFRXETH).balanceOf(address(strategy)); uint256 allocationBefore = IVaultV2(address(vault)).allocation(IMYTStrategy(address(strategy)).adapterId()); bytes memory txData = abi.encodeCall(MockSwapperForSFraxETH.swap, (FRXETH, WETH, 4e18, 4e18)); _mockFreshFrxEthEthOracle(); vm.prank(admin); IAllocator(address(allocator)).deallocateWithUnwrapAndSwap(address(strategy), 4e18, txData, 4e18); assertEq(IERC20(WETH).balanceOf(address(vault)), vaultBalanceBefore + 4e18, "vault should receive deallocated WETH"); assertEq(IERC20(FRXETH).balanceOf(address(strategy)), 0, "strategy should not retain frxETH after swap"); assertLt(ISfrxETHView(SFRXETH).balanceOf(address(strategy)), strategySharesBefore, "strategy should burn sfrxETH"); assertLt( IVaultV2(address(vault)).allocation(IMYTStrategy(address(strategy)).adapterId()), allocationBefore, "allocation should decrease after deallocation" ); } function test_allocator_deallocateWithSwap_reverts_useUnwrapPath() public { _mockFreshFrxEthEthOracle(); vm.prank(admin); IAllocator(address(allocator)).allocate(address(strategy), 10e18); vm.expectRevert(IMYTStrategy.ActionNotSupported.selector); vm.prank(admin); IAllocator(address(allocator)).deallocateWithSwap(address(strategy), 1e18, hex"01"); } function _mockFreshFrxEthEthOracle() internal { vm.mockCall( FRXETH_ETH_DUAL_ORACLE, abi.encodeWithSignature("getPrices()"), abi.encode(false, uint256(1e18), uint256(1e18)) ); } function _setUpMYT() internal { vm.startPrank(operator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (address(allocator), true))); IVaultV2(address(vault)).setIsAllocator(address(allocator), true); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, address(strategy))); IVaultV2(address(vault)).addAdapter(address(strategy)); bytes memory idData = IMYTStrategy(address(strategy)).getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, ABSOLUTE_CAP))); IVaultV2(address(vault)).increaseAbsoluteCap(idData, ABSOLUTE_CAP); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, RELATIVE_CAP))); IVaultV2(address(vault)).increaseRelativeCap(idData, RELATIVE_CAP); vm.stopPrank(); } function _depositToVault(uint256 amount) internal { deal(WETH, admin, amount); vm.startPrank(admin); TokenUtils.safeApprove(WETH, address(vault), amount); IVaultV2(address(vault)).deposit(amount, admin); vm.stopPrank(); } function _vaultSubmitAndFastForward(bytes memory data) internal { IVaultV2(address(vault)).submit(data); bytes4 selector = bytes4(data); vm.warp(block.timestamp + IVaultV2(address(vault)).timelock(selector)); } function getForkBlockNumber() internal pure returns (uint256) { return 24595012; } } ================================================ FILE: src/test/strategies/SiUSDStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseStrategyTest} from "../BaseStrategyTest.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {IAllocator} from "../../interfaces/IAllocator.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {SiUSDStrategy} from "../../strategies/SiUSDStrategy.sol"; interface ISIUSDView { function balanceOf(address account) external view returns (uint256); function convertToAssets(uint256 shares) external view returns (uint256 assets); } interface IRedeemControllerView { function receiptToAsset(uint256 receiptAmount) external view returns (uint256); } contract MockSwapperForSiUSD { function swap(address from, address to, uint256 amountIn, uint256 amountOut) external { require(IERC20(from).transferFrom(msg.sender, address(this), amountIn), "pull failed"); require(IERC20(to).transfer(msg.sender, amountOut), "push failed"); } } contract MockIUsdOracle { uint8 internal immutable _decimals; int256 internal _answer; uint256 internal _updatedAt; constructor(uint8 decimals_, int256 answer_, uint256 updatedAt_) { _decimals = decimals_; _answer = answer_; _updatedAt = updatedAt_; } function decimals() external view returns (uint8) { return _decimals; } function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) { return (0, _answer, 0, _updatedAt, 0); } function setUpdatedAt(uint256 updatedAt_) external { _updatedAt = updatedAt_; } } contract SiUSDStrategyTest is BaseStrategyTest { uint256 internal constant INITIAL_VAULT_DEPOSIT = 1_000_000e6; uint256 internal constant ABSOLUTE_CAP = 10_000_000e6; uint256 internal constant RELATIVE_CAP = 1e18; uint256 internal constant MAX_ORACLE_STALENESS = 365 days; uint8 internal constant IUSD_ORACLE_DECIMALS = 18; int256 internal constant IUSD_USDC_ORACLE_ANSWER = 1e6; address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address public constant IUSD = 0x48f9e38f3070AD8945DFEae3FA70987722E3D89c; address public constant SIUSD = 0xDBDC1Ef57537E34680B898E1FEBD3D68c7389bCB; address public constant GATEWAY = 0x3f04b65Ddbd87f9CE0A2e7Eb24d80e7fb87625b5; address public constant MINT_CONTROLLER = 0x49877d937B9a00d50557bdC3D87287b5c3a4C256; address public constant REDEEM_CONTROLLER = 0xCb1747E89a43DEdcF4A2b831a0D94859EFeC7601; MockSwapperForSiUSD internal swapper; MockIUsdOracle internal oracle; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "siUSD", protocol: "InfiniFi", riskClass: IMYTStrategy.RiskClass.LOW, cap: ABSOLUTE_CAP, globalCap: ABSOLUTE_CAP, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 300 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({ vaultAsset: USDC, vaultInitialDeposit: INITIAL_VAULT_DEPOSIT, absoluteCap: ABSOLUTE_CAP, relativeCap: RELATIVE_CAP, decimals: 6 }); } function createStrategy(address vault_, IMYTStrategy.StrategyParams memory params) internal override returns (address) { oracle = new MockIUsdOracle(IUSD_ORACLE_DECIMALS, IUSD_USDC_ORACLE_ANSWER, block.timestamp); return address( new SiUSDStrategy( vault_, params, USDC, IUSD, SIUSD, GATEWAY, MINT_CONTROLLER, REDEEM_CONTROLLER, address(oracle), MAX_ORACLE_STALENESS ) ); } function getForkBlockNumber() internal pure override returns (uint256) { return 24595012; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("MAINNET_RPC_URL"); } function _getMinAllocateAmount() internal pure override returns (uint256) { return 1e6; } function setUp() public override { super.setUp(); oracle.setUpdatedAt(block.timestamp); swapper = new MockSwapperForSiUSD(); deal(USDC, address(swapper), 100_000_000e6); vm.prank(admin); MYTStrategy(strategy).setAllowanceHolder(address(swapper)); } function test_allocator_allocate_direct_mintsSiUsd(uint256 amount) public { amount = bound(amount, 1e6, INITIAL_VAULT_DEPOSIT); vm.prank(admin); IAllocator(allocator).allocate(strategy, amount); uint256 siUsdShares = ISIUSDView(SIUSD).balanceOf(strategy); assertGt(siUsdShares, 0, "strategy should receive siUSD shares"); assertLe(IERC20(USDC).balanceOf(strategy), 1, "strategy should only retain minimal USDC dust after allocate"); assertApproxEqAbs( IMYTStrategy(strategy).realAssets(), _expectedTotalValue(), 5, "realAssets should track redeemable USDC value" ); } function test_allocator_deallocate_direct_redeemsBackToUsdc() public { uint256 allocateAmount = 10_000e6; uint256 deallocateAmount = 4_000e6; vm.prank(admin); IAllocator(allocator).allocate(strategy, allocateAmount); uint256 vaultBalanceBefore = IERC20(USDC).balanceOf(vault); uint256 strategySharesBefore = ISIUSDView(SIUSD).balanceOf(strategy); uint256 allocationBefore = IVaultV2(vault).allocation(IMYTStrategy(strategy).adapterId()); vm.prank(admin); IAllocator(allocator).deallocate(strategy, deallocateAmount); assertEq(IERC20(USDC).balanceOf(vault), vaultBalanceBefore + deallocateAmount, "vault should receive USDC"); assertLt(ISIUSDView(SIUSD).balanceOf(strategy), strategySharesBefore, "strategy should burn siUSD shares"); assertLt( IVaultV2(vault).allocation(IMYTStrategy(strategy).adapterId()), allocationBefore, "allocation should decrease after deallocation" ); } function test_allocator_deallocate_with_unwrapAndSwap_unstakesToIusdAndSwapsToUsdc() public { uint256 allocateAmount = 10_000e6; uint256 deallocateAmount = 4_000e6; vm.prank(admin); IAllocator(allocator).allocate(strategy, allocateAmount); uint256 vaultBalanceBefore = IERC20(USDC).balanceOf(vault); uint256 strategySharesBefore = ISIUSDView(SIUSD).balanceOf(strategy); uint256 allocationBefore = IVaultV2(vault).allocation(IMYTStrategy(strategy).adapterId()); bytes memory txData = _allocatorDeallocateSwapData(deallocateAmount); uint256 minIntermediateOut = _allocatorDeallocateMinIntermediateOut(deallocateAmount); vm.prank(admin); IAllocator(allocator).deallocateWithUnwrapAndSwap(strategy, deallocateAmount, txData, minIntermediateOut); assertEq(IERC20(USDC).balanceOf(vault), vaultBalanceBefore + deallocateAmount, "vault should receive USDC"); assertEq(IERC20(IUSD).balanceOf(strategy), 0, "strategy should not retain iUSD after swap"); assertLt(ISIUSDView(SIUSD).balanceOf(strategy), strategySharesBefore, "strategy should burn siUSD shares"); assertLt( IVaultV2(vault).allocation(IMYTStrategy(strategy).adapterId()), allocationBefore, "allocation should decrease after deallocation" ); } function test_previewAdjustedWithdraw_isPositiveAfterAllocation() public { vm.prank(admin); IAllocator(allocator).allocate(strategy, 10_000e6); uint256 preview = IMYTStrategy(strategy).previewAdjustedWithdraw(4_000e6); assertGt(preview, 0, "preview should be positive"); assertLe(preview, 4_000e6, "preview should not exceed requested amount"); } function test_allocator_allocateWithSwap_reverts_noDexSwaps() public { vm.expectRevert(IMYTStrategy.ActionNotSupported.selector); vm.prank(admin); IAllocator(allocator).allocateWithSwap(strategy, 1e6, hex"01"); } function test_allocator_deallocateWithSwap_reverts_useUnwrapPath() public { vm.prank(admin); IAllocator(allocator).allocate(strategy, 10_000e6); vm.expectRevert(IMYTStrategy.ActionNotSupported.selector); vm.prank(admin); IAllocator(allocator).deallocateWithSwap(strategy, 1e6, hex"01"); } function test_constructor_sets_max_oracle_staleness() public view { assertEq(SiUSDStrategy(strategy).MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS, "unexpected initial max oracle staleness"); } function _useAllocatorDeallocateUnwrapAndSwap() internal pure override returns (bool) { return false; } function _allocatorDeallocateSwapData(uint256 amount) internal view override returns (bytes memory) { uint256 iUsdAmount = _allocatorDeallocateMinIntermediateOut(amount); return abi.encodeCall(MockSwapperForSiUSD.swap, (IUSD, USDC, iUsdAmount, amount)); } function _allocatorDeallocateMinIntermediateOut(uint256 amount) internal view override returns (uint256) { uint256 idleUsdc = IERC20(USDC).balanceOf(strategy); if (idleUsdc >= amount) return 0; uint256 shortfall = amount - idleUsdc; uint256 desiredIUsd = _assetToOracleToken(shortfall); uint256 availableIUsd = IERC20(IUSD).balanceOf(strategy) + ISIUSDView(SIUSD).convertToAssets(ISIUSDView(SIUSD).balanceOf(strategy)); return desiredIUsd > availableIUsd ? availableIUsd : desiredIUsd; } function _beforeTimeShift(uint256 targetTimestamp) internal override { oracle.setUpdatedAt(targetTimestamp); } function _beforePreviewWithdraw(uint256) internal override { oracle.setUpdatedAt(block.timestamp); } function _expectedTotalValue() internal view returns (uint256) { uint256 siUsdShares = ISIUSDView(SIUSD).balanceOf(strategy); uint256 iUsdFromShares = ISIUSDView(SIUSD).convertToAssets(siUsdShares); uint256 idleIUsd = IERC20(IUSD).balanceOf(strategy); uint256 idleUsdc = IERC20(USDC).balanceOf(strategy); return idleUsdc + IRedeemControllerView(REDEEM_CONTROLLER).receiptToAsset(iUsdFromShares + idleIUsd); } function _oracleTokenToAsset(uint256 oracleTokenAmount) internal pure returns (uint256) { return oracleTokenAmount * uint256(IUSD_USDC_ORACLE_ANSWER) / (10 ** IUSD_ORACLE_DECIMALS); } function _assetToOracleToken(uint256 assetAmount) internal pure returns (uint256) { return assetAmount * (10 ** IUSD_ORACLE_DECIMALS) / uint256(IUSD_USDC_ORACLE_ANSWER); } } ================================================ FILE: src/test/strategies/TokeAutoETHStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; // Adjust these imports to your layout import {TokeAutoStrategy} from "../../strategies/TokeAutoStrategy.sol"; import {BaseStrategyTest, RevertContext} from "../BaseStrategyTest.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; interface IRootOracle { function getPriceInEth(address token) external returns (uint256); function getCeilingPrice(address token, address pool, address quoteToken) external returns (uint256); function getFloorPrice(address token, address pool, address quoteToken) external returns (uint256); } interface IAutoEthMath { enum Rounding { Down, Up, Zero } enum TotalAssetPurpose { Global, Deposit, Withdraw } function totalAssets(TotalAssetPurpose purpose) external view returns (uint256); function totalSupply() external view returns (uint256); function convertToShares( uint256 assets, uint256 totalAssetsForPurpose, uint256 supply, Rounding rounding ) external view returns (uint256); } /// @notice Replaces the Tokemak MainRewarder via vm.etch so that /// getReward actually transfers TOKE tokens to the recipient. contract MockTokeRewarder { IERC20 public immutable tokeToken; uint256 public immutable rewardAmount; address public immutable rewardTokenAddr; uint256 public immutable lockDuration; constructor(address _tokeToken, uint256 _rewardAmount, address _rewardTokenAddr, uint256 _lockDuration) { tokeToken = IERC20(_tokeToken); rewardAmount = _rewardAmount; rewardTokenAddr = _rewardTokenAddr; lockDuration = _lockDuration; } function allowExtraRewards() external pure returns (bool) { return false; } function getReward(address, address recipient, bool) external { tokeToken.transfer(recipient, rewardAmount); } function rewardToken() external view returns (address) { return rewardTokenAddr; } function tokeLockDuration() external view returns (uint256) { return lockDuration; } } /// @notice When used as allowanceHolder, transfers a fixed amount of token /// to msg.sender on any call (simulates swap output). contract MockSwapExecutor { IERC20 public immutable token; uint256 public amountToTransfer; constructor(address _token, uint256 _amountToTransfer) { token = IERC20(_token); amountToTransfer = _amountToTransfer; } receive() external payable {} fallback() external { token.transfer(msg.sender, amountToTransfer); } } contract MockTokeAutoEthStrategy is TokeAutoStrategy { constructor( address _myt, StrategyParams memory _params, address _autoEth, address _rewarder, address _weth, address _tokeRewardsToken ) TokeAutoStrategy(_myt, _params, _weth, _autoEth, _rewarder, _tokeRewardsToken) {} } contract TokeAutoETHStrategyTest is BaseStrategyTest { address public constant TOKE_AUTO_ETH_VAULT = 0x0A2b94F6871c1D7A32Fe58E1ab5e6deA2f114E56; address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address public constant REWARDER = 0x60882D6f70857606Cdd37729ccCe882015d1755E; address public constant ORACLE = 0x61F8BE7FD721e80C0249829eaE6f0DAf21bc2CaC; address public constant TOKE = 0x2e9d63788249371f1DFC918a52f8d799F4a38C94; // Error(string) selector (0x08c379a0), observed in Tokemak traces. // In this suite it is observed on both allocate and deallocate paths. bytes4 internal constant ERROR_STRING_SELECTOR = 0x08c379a0; // Tokemak custom error selector (0x8d54ba1f / InvalidDataReturned in tests). // In this suite it is observed on allocate paths (stake mock), not deallocate. bytes4 internal constant ALLOWED_TOKEMAK_REVERT_SELECTOR = 0x8d54ba1f; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "TokeAutoEth", protocol: "TokeAutoEth", riskClass: IMYTStrategy.RiskClass.MEDIUM, cap: 10_000e18, globalCap: 1e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 600 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: WETH, vaultInitialDeposit: 1000e18, absoluteCap: 10_000e18, relativeCap: 1e18, decimals: 18}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new MockTokeAutoEthStrategy(vault, params, TOKE_AUTO_ETH_VAULT, REWARDER, WETH, TOKE)); } function getForkBlockNumber() internal pure override returns (uint256) { return 24667747; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("MAINNET_RPC_URL"); } function _beforeTimeShift(uint256) internal override { // Keep Tokemak oracle reads fresh across synthetic time warps. // Use live-like mainnet values captured via Tenderly RPC. uint256 mockedEthPrice = 1_108_368_970_000_000_000; uint256 mockedCeilingPrice = 1_006_112_990_447_894_840; uint256 mockedFloorPrice = 1_001_260_889_888_317_396; vm.mockCall( ORACLE, abi.encodeWithSelector(IRootOracle.getPriceInEth.selector), abi.encode(mockedEthPrice) ); vm.mockCall( ORACLE, abi.encodeWithSelector(IRootOracle.getCeilingPrice.selector), abi.encode(mockedCeilingPrice) ); vm.mockCall( ORACLE, abi.encodeWithSelector(IRootOracle.getFloorPrice.selector), abi.encode(mockedFloorPrice) ); } function _beforePreviewWithdraw(uint256 requestedAssets) internal override { if (requestedAssets == 0) return; // Force convertToShares(assets, ...) -> assets for this requested amount. // Use calldata-prefix matching on selector + first arg so changing totals/supply // do not bypass the mock. vm.mockCall( TOKE_AUTO_ETH_VAULT, abi.encodePacked(IAutoEthMath.convertToShares.selector, bytes32(requestedAssets)), abi.encode(requestedAssets) ); // Deallocate is called with previewAdjustedWithdraw(amount), so pre-mock that // amount too as identity to keep strategy-side convertToShares deterministic. uint256 previewAmount = IMYTStrategy(strategy).previewAdjustedWithdraw(requestedAssets); if (previewAmount == 0) return; vm.mockCall( TOKE_AUTO_ETH_VAULT, abi.encodePacked(IAutoEthMath.convertToShares.selector, bytes32(previewAmount)), abi.encode(previewAmount) ); } function isProtocolRevertAllowed(bytes4 selector, RevertContext context) external pure override returns (bool) { if ( selector != ERROR_STRING_SELECTOR && selector != ALLOWED_TOKEMAK_REVERT_SELECTOR ) return false; return context == RevertContext.HandlerAllocate || context == RevertContext.HandlerDeallocate || context == RevertContext.FuzzAllocate || context == RevertContext.FuzzDeallocate; } function isMytRevertAllowed(bytes4, RevertContext) external pure override returns (bool) { return false; } // Add any strategy-specific tests here function test_strategy_deallocate_reverts_due_to_slippage(uint256 amountToAllocate, uint256 amountToDeallocate) public { amountToAllocate = bound(amountToAllocate, 1e18, testConfig.vaultInitialDeposit); amountToDeallocate = amountToAllocate; bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } function test_deallocate_full_real_assets() public { bytes memory params = getVaultParams(); vm.startPrank(vault); uint256 amountToAllocate = 12345 * 10 ** 18; deal(testConfig.vaultAsset, strategy, amountToAllocate); uint256 initialIdle = IERC20(WETH).balanceOf(strategy); /// staking to the REWARDER contract through the TokeAutoEthStrategy's allocate method IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(0)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); // Direct adapter deallocate leaves withdrawn WETH idle on the strategy until the vault pulls it. IMYTStrategy(strategy).deallocate(params, initialRealAssets, "", address(vault)); uint256 idleWeth = IERC20(WETH).balanceOf(strategy); assertGt(idleWeth, 0, "Idle WETH should remain on strategy after direct deallocate"); assertApproxEqRel(IMYTStrategy(strategy).realAssets(), idleWeth, 1e16); vm.stopPrank(); } function test_claimRewards_emits_event_and_vault_receives_asset() public { // Allocate assets to create a Tokemak position bytes memory params = getVaultParams(); uint256 amountToAllocate = 10e18; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); // Configure mock reward claim (stakingDisabled = true via tokeLockDuration == 0) uint256 tokeRewardAmount = 10e18; // 10 TOKE tokens claimed uint256 mockSwapReturn = 5e15; // Simulated WETH swap output // Deploy a MockTokeRewarder and etch over the real REWARDER address. // rewardToken = TOKE, tokeLockDuration = 0 → stakingDisabled = true MockTokeRewarder mockRew = new MockTokeRewarder(TOKE, tokeRewardAmount, TOKE, 0); vm.etch(REWARDER, address(mockRew).code); deal(TOKE, REWARDER, tokeRewardAmount); // Setup MockSwapExecutor as allowanceHolder to simulate DEX swap. // dexSwap(MYT.asset(), token, ...) measures WETH balance change, // so the mock executor transfers WETH to the strategy. // NOTE: quote must be non-empty so the call hits fallback() not receive(). MockSwapExecutor mockSwap = new MockSwapExecutor(WETH, mockSwapReturn); deal(WETH, address(mockSwap), mockSwapReturn); // Point the strategy's allowanceHolder to our mock vm.prank(address(1)); // strategy owner MYTStrategy(strategy).setAllowanceHolder(address(mockSwap)); // Record vault WETH balance before claiming uint256 vaultBalanceBefore = IERC20(WETH).balanceOf(vault); // Expect the RewardsClaimed event with correct token and amount vm.expectEmit(true, true, false, true, strategy); emit IMYTStrategy.RewardsClaimed(TOKE, tokeRewardAmount); // Execute claimRewards as strategy owner bytes memory quote = hex"01"; vm.prank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(TOKE, quote, 4.99e15); // Verify rewards were received and vault got the asset uint256 vaultBalanceAfter = IERC20(WETH).balanceOf(vault); assertGt(received, 0, "No rewards received from claim"); assertEq(received, mockSwapReturn, "Received amount does not match expected swap output"); assertEq(vaultBalanceAfter - vaultBalanceBefore, received, "Vault did not receive expected WETH amount"); } function test_claimRewards_returns_zero_when_staking_enabled() public { // Allocate assets to create a Tokemak position bytes memory params = getVaultParams(); uint256 amountToAllocate = 10e18; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 tokeRewardAmount = 10e18; // Deploy a MockTokeRewarder with staking ENABLED: // rewardToken == TOKE AND tokeLockDuration > 0 → stakingDisabled = false MockTokeRewarder mockRew = new MockTokeRewarder(TOKE, tokeRewardAmount, TOKE, 1); vm.etch(REWARDER, address(mockRew).code); deal(TOKE, REWARDER, tokeRewardAmount); // Record vault WETH balance before claiming uint256 vaultBalanceBefore = IERC20(WETH).balanceOf(vault); // Execute claimRewards as strategy owner — should return 0 bytes memory quote = hex"01"; vm.prank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(TOKE, quote, 9.99e18); // Verify nothing was returned and vault balance is unchanged uint256 vaultBalanceAfter = IERC20(WETH).balanceOf(vault); assertEq(received, 0, "Should return 0 when staking is enabled"); assertEq(vaultBalanceAfter, vaultBalanceBefore, "Vault balance should not change when staking is enabled"); } function test_allowlisted_revert_deposit_value_below_minimum_is_deterministic() public { bytes memory params = getVaultParams(); uint256 amountToAllocate = 1e18; bytes4 convertToAssetsSelector = bytes4(keccak256("convertToAssets(uint256,uint256,uint256,uint8)")); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.mockCall(TOKE_AUTO_ETH_VAULT, abi.encodePacked(convertToAssetsSelector), abi.encode(0)); vm.expectRevert(bytes("Deposit value below minimum")); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); vm.stopPrank(); } function test_allowlisted_revert_withdraw_amount_insufficient_is_deterministic() public { bytes memory params = getVaultParams(); uint256 amountToAllocate = 2e18; uint256 amountToDeallocate = 1e18; vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); vm.mockCall( REWARDER, abi.encodeWithSelector(bytes4(keccak256("balanceOf(address)")), strategy), abi.encode(0) ); vm.expectRevert(); IMYTStrategy(strategy).deallocate(params, amountToDeallocate, "", address(vault)); vm.stopPrank(); } // Ensures the allowlisted custom selector (0x8d54ba1f / InvalidDataReturned) is explicitly asserted. // Mocks rewarder.stake() to emit that revert so fuzz-skip coverage has a deterministic counterpart. function test_allowlisted_revert_custom_selector_is_deterministic() public { uint256 amountToAllocate = 1e18; bytes4 convertToAssetsSelector = bytes4(keccak256("convertToAssets(uint256,uint256,uint256,uint8)")); bytes4 stakeSelector = bytes4(keccak256("stake(address,uint256)")); vm.startPrank(allocator); vm.mockCall(TOKE_AUTO_ETH_VAULT, abi.encodePacked(convertToAssetsSelector), abi.encode(amountToAllocate)); vm.mockCallRevert(REWARDER, abi.encodePacked(stakeSelector), abi.encodeWithSelector(ALLOWED_TOKEMAK_REVERT_SELECTOR)); vm.expectRevert(ALLOWED_TOKEMAK_REVERT_SELECTOR); IVaultV2(vault).allocate(strategy, getVaultParams(), amountToAllocate); vm.stopPrank(); } // End-to-end test: Full lifecycle with time accumulation for TokeAutoETH function test_toke_auto_eth_full_lifecycle_with_time() public { vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); // Initial allocation uint256 alloc1 = 2e18; // 2 WETH IVaultV2(vault).allocate(strategy, getVaultParams(), alloc1); uint256 realAssets1 = IMYTStrategy(strategy).realAssets(); assertGt(realAssets1, 0, "Real assets should be positive after allocation"); assertApproxEqAbs(IVaultV2(vault).allocation(allocationId), alloc1, 1e15); // Warp forward 14 days _warpWithHook(14 days); // Additional allocation uint256 alloc2 = 1e18; // 1 WETH IVaultV2(vault).allocate(strategy, getVaultParams(), alloc2); uint256 realAssets2 = IMYTStrategy(strategy).realAssets(); assertGe(realAssets2, realAssets1, "Real assets should not decrease"); // Warp forward 30 days _warpWithHook(30 days); // Partial deallocation (withdraw 0.5 WETH) uint256 deallocAmount1 = 0.05e18; uint256 deallocPreview1 = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount1); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview1); uint256 realAssets3 = IMYTStrategy(strategy).realAssets(); assertLt(realAssets3, realAssets2, "Real assets should decrease after deallocation"); // Warp forward 60 days _warpWithHook(60 days); // Check vault WETH balance uint256 vaultWETHBalance = IERC20(WETH).balanceOf(vault); assertGt(vaultWETHBalance, 0, "Vault should have WETH"); // Full deallocation of remaining uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e15) { uint256 finalTarget = IVaultV2(vault).allocation(allocationId) / 20; if (finalTarget > 1e15) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalTarget); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } } uint256 finalVaultWETHBalance = IERC20(WETH).balanceOf(vault); assertGt(finalVaultWETHBalance, vaultWETHBalance, "Vault WETH should increase after deallocation"); vm.stopPrank(); } // Fuzz test: Multiple random allocations and deallocations with time warps function test_fuzz_toke_auto_eth_operations(uint256[] calldata amounts, uint256[] calldata timeDelays) public { // Use bound for array length instead of assume uint256 numOps = bound(amounts.length, 1, 8); // Ensure we don't access beyond array bounds uint256 maxIterations = numOps < amounts.length ? numOps : amounts.length; vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); for (uint256 i = 0; i < maxIterations; i++) { // Alternate between allocation and deallocation bool isAllocate = i % 2 == 0; uint256 amount = bound(amounts[i], 0.1e18, 5e18); // 0.1-5 WETH if (isAllocate) { IVaultV2(vault).allocate(strategy, getVaultParams(), amount); } else { uint256 currentAllocation = IVaultV2(vault).allocation(allocationId); uint256 deallocAmount = 0; if (currentAllocation > 0) { uint256 conservativeMax = currentAllocation / 10_000; deallocAmount = conservativeMax == 0 ? 0 : bound(amount, 0, conservativeMax); } if (deallocAmount > 0) { uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount); if (deallocPreview > 0) { IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); } } } // Warp forward (only access if timeDelays has this index) uint256 timeDelay = i < timeDelays.length ? bound(timeDelays[i], 1 hours, 60 days) : 1 hours; _warpWithHook(timeDelay); } // Final sanity checks uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); uint256 finalAllocation = IVaultV2(vault).allocation(allocationId); uint256 vaultWETHBalance = IERC20(WETH).balanceOf(vault); assertGe(finalRealAssets, 0, "Real assets should be non-negative"); assertGe(finalAllocation, 0, "Allocation should be non-negative"); assertGt(vaultWETHBalance, 0, "Vault should have WETH"); vm.stopPrank(); } // Test: TokeAutoETH with reward claiming over time function test_toke_auto_eth_rewards_over_time() public { vm.startPrank(allocator); // Allocate initial amount uint256 allocAmount = 3e18; // 3 WETH IVaultV2(vault).allocate(strategy, getVaultParams(), allocAmount); // Warp 30 days _warpWithHook(30 days); // Setup reward claiming mock (staking disabled) uint256 tokeRewardAmount = 10e18; uint256 mockSwapReturn = 5e15; MockTokeRewarder mockRew = new MockTokeRewarder(TOKE, tokeRewardAmount, TOKE, 0); bytes memory rewarderCodeBeforeMock = REWARDER.code; vm.etch(REWARDER, address(mockRew).code); deal(TOKE, REWARDER, tokeRewardAmount); MockSwapExecutor mockSwap = new MockSwapExecutor(WETH, mockSwapReturn); deal(WETH, address(mockSwap), mockSwapReturn); vm.stopPrank(); vm.startPrank(address(1)); MYTStrategy(strategy).setAllowanceHolder(address(mockSwap)); // Claim rewards bytes memory quote = hex"01"; vm.stopPrank(); vm.startPrank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(TOKE, quote, 4.99e15); assertGt(received, 0, "Should receive rewards"); vm.etch(REWARDER, rewarderCodeBeforeMock); // Continue with allocations/deallocations vm.stopPrank(); vm.startPrank(allocator); uint256 realAssets1 = IMYTStrategy(strategy).realAssets(); _warpWithHook(30 days); // Small deallocation uint256 smallDealloc = 0.05e18; uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(smallDealloc); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); _warpWithHook(30 days); // Final deallocation uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e15) { bytes32 allocationId = IMYTStrategy(strategy).adapterId(); uint256 finalTarget = IVaultV2(vault).allocation(allocationId) / 20; if (finalTarget > 1e15) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalTarget); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } } assertLe(IMYTStrategy(strategy).realAssets(), realAssets1, "Final real assets should not exceed prior balance"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/TokeAutoUSDStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; // Adjust these imports to your layout import {TokeAutoStrategy} from "../../strategies/TokeAutoStrategy.sol"; import {BaseStrategyTest} from "../BaseStrategyTest.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; /// @notice Replaces the Tokemak MainRewarder via vm.etch so that /// getReward actually transfers TOKE tokens to the recipient. contract MockTokeRewarder { IERC20 public immutable tokeToken; uint256 public immutable rewardAmount; address public immutable rewardTokenAddr; uint256 public immutable lockDuration; constructor(address _tokeToken, uint256 _rewardAmount, address _rewardTokenAddr, uint256 _lockDuration) { tokeToken = IERC20(_tokeToken); rewardAmount = _rewardAmount; rewardTokenAddr = _rewardTokenAddr; lockDuration = _lockDuration; } function allowExtraRewards() external pure returns (bool) { return false; } function getReward(address, address recipient, bool) external { tokeToken.transfer(recipient, rewardAmount); } function rewardToken() external view returns (address) { return rewardTokenAddr; } function tokeLockDuration() external view returns (uint256) { return lockDuration; } } /// @notice When used as allowanceHolder, transfers a fixed amount of token /// to msg.sender on any call (simulates swap output). contract MockSwapExecutor { IERC20 public immutable token; uint256 public amountToTransfer; constructor(address _token, uint256 _amountToTransfer) { token = IERC20(_token); amountToTransfer = _amountToTransfer; } receive() external payable {} fallback() external { token.transfer(msg.sender, amountToTransfer); } } contract MockTokeAutoUSDStrategy is TokeAutoStrategy { constructor( address _myt, StrategyParams memory _params, address _usdc, address _autoUSD, address _rewarder, address _tokeRewardsToken ) TokeAutoStrategy(_myt, _params, _usdc, _autoUSD, _rewarder, _tokeRewardsToken) {} } contract TokeAutoUSDStrategyTest is BaseStrategyTest { address public constant TOKE_AUTO_USD_VAULT = 0xa7569A44f348d3D70d8ad5889e50F78E33d80D35; address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address public constant REWARDER = 0x726104CfBd7ece2d1f5b3654a19109A9e2b6c27B; address public constant TOKE = 0x2e9d63788249371f1DFC918a52f8d799F4a38C94; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "TokeAutoUSD", protocol: "TokeAutoUSD", riskClass: IMYTStrategy.RiskClass.MEDIUM, cap: 10_000e6, globalCap: 1e18, estimatedYield: 100e6, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: USDC, vaultInitialDeposit: 1000e6, absoluteCap: 10_000e6, relativeCap: 1e18, decimals: 6}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new MockTokeAutoUSDStrategy(vault, params, USDC, TOKE_AUTO_USD_VAULT, REWARDER, TOKE)); } function getForkBlockNumber() internal pure override returns (uint256) { return 22_089_302; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("MAINNET_RPC_URL"); } // Test that full deallocation completes without reverting function test_strategy_full_deallocate(uint256 amountToAllocate) public { amountToAllocate = bound(amountToAllocate, 1 * 10 ** testConfig.decimals, testConfig.vaultInitialDeposit); bytes memory params = getVaultParams(); vm.startPrank(vault); deal(testConfig.vaultAsset, strategy, amountToAllocate); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 initialRealAssets = IMYTStrategy(strategy).realAssets(); require(initialRealAssets > 0, "Initial real assets is 0"); IMYTStrategy(strategy).deallocate(params, amountToAllocate, "", address(vault)); uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); uint256 idleUsdc = IERC20(USDC).balanceOf(strategy); assertGt(idleUsdc, 0, "Idle USDC should remain on strategy after direct deallocate"); assertApproxEqRel(finalRealAssets, idleUsdc, 1e16); vm.stopPrank(); } function test_claimRewards_emits_event_and_vault_receives_asset() public { // Allocate assets to create a Tokemak position bytes memory params = getVaultParams(); uint256 amountToAllocate = 100e6; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); // Configure mock reward claim (stakingDisabled = true via tokeLockDuration == 0) uint256 tokeRewardAmount = 10e18; // 10 TOKE tokens claimed uint256 mockSwapReturn = 5e6; // Simulated USDC swap output // Deploy a MockTokeRewarder and etch over the real REWARDER address. // rewardToken = TOKE, tokeLockDuration = 0 → stakingDisabled = true MockTokeRewarder mockRew = new MockTokeRewarder(TOKE, tokeRewardAmount, TOKE, 0); vm.etch(REWARDER, address(mockRew).code); deal(TOKE, REWARDER, tokeRewardAmount); // Setup MockSwapExecutor as allowanceHolder to simulate DEX swap. // dexSwap(MYT.asset(), token, ...) measures USDC balance change, MockSwapExecutor mockSwap = new MockSwapExecutor(USDC, mockSwapReturn); deal(USDC, address(mockSwap), mockSwapReturn); // Point the strategy's allowanceHolder to our mock vm.prank(address(1)); // strategy owner MYTStrategy(strategy).setAllowanceHolder(address(mockSwap)); // Record vault USDC balance before claiming uint256 vaultBalanceBefore = IERC20(USDC).balanceOf(vault); // Expect the RewardsClaimed event with correct token and amount vm.expectEmit(true, true, false, true, strategy); emit IMYTStrategy.RewardsClaimed(TOKE, tokeRewardAmount); // Execute claimRewards as strategy owner bytes memory quote = hex"01"; vm.prank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(TOKE, quote, 4.99e6); // Verify rewards were received and vault got the asset uint256 vaultBalanceAfter = IERC20(USDC).balanceOf(vault); assertGt(received, 0, "No rewards received from claim"); assertEq(received, mockSwapReturn, "Received amount does not match expected swap output"); assertEq(vaultBalanceAfter - vaultBalanceBefore, received, "Vault did not receive expected USDC amount"); } function test_claimRewards_returns_zero_when_staking_enabled() public { // Allocate assets to create a Tokemak position bytes memory params = getVaultParams(); uint256 amountToAllocate = 100e6; deal(testConfig.vaultAsset, strategy, amountToAllocate); vm.prank(vault); IMYTStrategy(strategy).allocate(params, amountToAllocate, "", address(vault)); uint256 tokeRewardAmount = 10e18; // Deploy a MockTokeRewarder with staking ENABLED: // rewardToken == TOKE AND tokeLockDuration > 0 → stakingDisabled = false MockTokeRewarder mockRew = new MockTokeRewarder(TOKE, tokeRewardAmount, TOKE, 1); vm.etch(REWARDER, address(mockRew).code); deal(TOKE, REWARDER, tokeRewardAmount); // Record vault USDC balance before claiming uint256 vaultBalanceBefore = IERC20(USDC).balanceOf(vault); // Execute claimRewards as strategy owner — should return 0 bytes memory quote = hex"01"; vm.prank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(TOKE, quote, 9.99e18); // Verify nothing was returned and vault balance is unchanged uint256 vaultBalanceAfter = IERC20(USDC).balanceOf(vault); assertEq(received, 0, "Should return 0 when staking is enabled"); assertEq(vaultBalanceAfter, vaultBalanceBefore, "Vault balance should not change when staking is enabled"); } // End-to-end test: Full lifecycle with time accumulation for TokeAutoUSD function test_toke_auto_usd_full_lifecycle_with_time() public { vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); // Initial allocation uint256 alloc1 = 300e6; // 300 USDC IVaultV2(vault).allocate(strategy, getVaultParams(), alloc1); uint256 realAssets1 = IMYTStrategy(strategy).realAssets(); assertGt(realAssets1, 0, "Real assets should be positive after allocation"); assertApproxEqAbs(IVaultV2(vault).allocation(allocationId), alloc1, 1e5); // Warp forward 14 days vm.warp(block.timestamp + 14 days); // Additional allocation uint256 alloc2 = 200e6; // 200 USDC IVaultV2(vault).allocate(strategy, getVaultParams(), alloc2); uint256 realAssets2 = IMYTStrategy(strategy).realAssets(); assertGe(realAssets2, realAssets1, "Real assets should not decrease"); // Warp forward 30 days vm.warp(block.timestamp + 30 days); // Partial deallocation (withdraw 100 USDC) uint256 deallocAmount1 = 100e6; uint256 deallocPreview1 = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount1); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview1); uint256 realAssets3 = IMYTStrategy(strategy).realAssets(); assertLt(realAssets3, realAssets2, "Real assets should decrease after deallocation"); // Warp forward 60 days vm.warp(block.timestamp + 60 days); // Check vault USDC balance uint256 vaultUSDCBalance = IERC20(USDC).balanceOf(vault); assertGt(vaultUSDCBalance, 0, "Vault should have USDC"); // Full deallocation of remaining uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e6) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } uint256 finalVaultUSDCBalance = IERC20(USDC).balanceOf(vault); assertGt(finalVaultUSDCBalance, vaultUSDCBalance, "Vault USDC should increase after deallocation"); vm.stopPrank(); } // Fuzz test: Multiple random allocations and deallocations with time warps function test_fuzz_toke_auto_usd_operations(uint256[] calldata amounts, uint256[] calldata timeDelays) public { // Use bound for array length instead of assume uint256 numOps = bound(amounts.length, 1, 8); // Ensure we don't access beyond array bounds uint256 maxIterations = numOps < amounts.length ? numOps : amounts.length; vm.startPrank(allocator); bytes32 allocationId = IMYTStrategy(strategy).adapterId(); for (uint256 i = 0; i < maxIterations; i++) { // Alternate between allocation and deallocation bool isAllocate = i % 2 == 0; uint256 amount = bound(amounts[i], 10e6, 50e6); // 10-50 USDC if (isAllocate) { IVaultV2(vault).allocate(strategy, getVaultParams(), amount); } else { uint256 currentAllocation = IVaultV2(vault).allocation(allocationId); uint256 deallocAmount = 0; if (currentAllocation > 0) { deallocAmount = bound(amount, 0, currentAllocation); } if (deallocAmount > 0) { uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(deallocAmount); if (deallocPreview > 0) { IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); } } } // Warp forward (only access if timeDelays has this index) uint256 timeDelay = i < timeDelays.length ? bound(timeDelays[i], 1 hours, 60 days) : 1 hours; vm.warp(block.timestamp + timeDelay); } // Final sanity checks uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); uint256 finalAllocation = IVaultV2(vault).allocation(allocationId); uint256 vaultUSDCBalance = IERC20(USDC).balanceOf(vault); assertGe(finalRealAssets, 0, "Real assets should be non-negative"); assertGe(finalAllocation, 0, "Allocation should be non-negative"); assertGt(vaultUSDCBalance, 0, "Vault should have USDC"); vm.stopPrank(); } // Test: TokeAutoUSD with reward claiming over time function test_toke_auto_usd_rewards_over_time() public { vm.startPrank(allocator); // Allocate initial amount uint256 allocAmount = 250e6; // 250 USDC IVaultV2(vault).allocate(strategy, getVaultParams(), allocAmount); // Warp 30 days _warpWithHook(30 days); // Setup reward claiming mock (staking disabled) uint256 tokeRewardAmount = 10e18; uint256 mockSwapReturn = 5e6; MockTokeRewarder mockRew = new MockTokeRewarder(TOKE, tokeRewardAmount, TOKE, 0); bytes memory rewarderCodeBeforeMock = REWARDER.code; vm.etch(REWARDER, address(mockRew).code); deal(TOKE, REWARDER, tokeRewardAmount); MockSwapExecutor mockSwap = new MockSwapExecutor(USDC, mockSwapReturn); deal(USDC, address(mockSwap), mockSwapReturn); vm.stopPrank(); vm.startPrank(address(1)); MYTStrategy(strategy).setAllowanceHolder(address(mockSwap)); // Claim rewards bytes memory quote = hex"01"; vm.stopPrank(); vm.startPrank(address(1)); uint256 received = IMYTStrategy(strategy).claimRewards(TOKE, quote, 4.99e6); assertGt(received, 0, "Should receive rewards"); vm.etch(REWARDER, rewarderCodeBeforeMock); // Continue with allocations/deallocations vm.stopPrank(); vm.startPrank(allocator); uint256 realAssets1 = IMYTStrategy(strategy).realAssets(); _warpWithHook(30 days); // Small deallocation uint256 smallDealloc = 30e6; uint256 deallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(smallDealloc); IVaultV2(vault).deallocate(strategy, getVaultParams(), deallocPreview); _warpWithHook(30 days); // Final deallocation uint256 finalRealAssets = IMYTStrategy(strategy).realAssets(); if (finalRealAssets > 1e6) { uint256 finalDeallocPreview = IMYTStrategy(strategy).previewAdjustedWithdraw(finalRealAssets); IVaultV2(vault).deallocate(strategy, getVaultParams(), finalDeallocPreview); } // Allow small residual dust from share/asset rounding on Tokemak redeem path. assertApproxEqAbs(IMYTStrategy(strategy).realAssets(), 0, 1e5, "All real assets should be deallocated"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/WstethMainnetStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import "forge-std/Test.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {TokenUtils} from "../../libraries/TokenUtils.sol"; import {WstETHEthereumStrategy} from "../../strategies/WstETHEthereumStrategy.sol"; import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {AggregatorV3Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {AlchemistAllocator} from "../../AlchemistAllocator.sol"; import {IAllocator} from "../../interfaces/IAllocator.sol"; import {AlchemistStrategyClassifier} from "../../AlchemistStrategyClassifier.sol"; import {MockMYTVault} from "../mocks/MockMYTVault.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; interface IWstETH { function balanceOf(address account) external view returns (uint256); function getStETHByWstETH(uint256 _wstETHAmount) external view returns (uint256); function getWstETHByStETH(uint256 _stETHAmount) external view returns (uint256); } /// @notice Simple allowanceHolder mock that simulates swap output by /// transferring a fixed token amount to caller on any call. contract MockSwapExecutor { IERC20 public immutable token; uint256 public amountToTransfer; constructor(address _token, uint256 _amountToTransfer) { token = IERC20(_token); amountToTransfer = _amountToTransfer; } fallback() external { token.transfer(msg.sender, amountToTransfer); } } /// @notice Smart allowanceHolder mock that transfers buy token to the caller and /// pulls the full approved sell token amount, matching dexSwap's balance-delta semantics. contract MockSwapExecutorDynamic { IERC20 public immutable buyToken; IERC20 public immutable sellToken; constructor(address _sellToken, address _buyToken) { sellToken = IERC20(_sellToken); buyToken = IERC20(_buyToken); } fallback() external { uint256 sellAllowance = sellToken.allowance(msg.sender, address(this)); if (sellAllowance > 0) { sellToken.transferFrom(msg.sender, address(this), sellAllowance); } uint256 buyBalance = buyToken.balanceOf(address(this)); if (buyBalance > 0) { buyToken.transfer(msg.sender, buyBalance); } } } contract MockWstethStrategy is WstETHEthereumStrategy { constructor( address _myt, StrategyParams memory _params, address _wstETH, address _stEthEthOracle, uint256 _maxOracleStaleness ) WstETHEthereumStrategy(_myt, _params, _wstETH, _stEthEthOracle, _maxOracleStaleness) {} } contract WstethStrategyTest is Test { uint256 public constant STRATEGY_SLIPPAGE_BPS = 200; uint256 public constant TEST_RESIDUAL_TOLERANCE_BPS = 100; uint256 public constant MAX_ORACLE_STALENESS = 24 hours; uint256 public constant ASSUMED_STETH_ETH_ORACLE_ANSWER = 1.23e18; uint256 public constant QUOTED_WSTETH_SELL_AMOUNT = 1_000_000_000_000; uint256 public constant QUOTED_WETH_BUY_AMOUNT = 1_222_732_076_605; address public mytStrategy; address public vault; address public allocator; address public classifier; address public weth = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); address public wstETH = address(0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0); address public stEthEthOracle = address(0x86392dC19c0b719886221c78AB11eb8Cf5c52812); address public admin = address(0x1111111111111111111111111111111111111111); address public curator = address(0x2222222222222222222222222222222222222222); address public constant MAINNET_PERMIT2 = 0x000000000022d473030f1dF7Fa9381e04776c7c5; uint256 private _forkId; event WstethStrategyTestLog(string message, uint256 value); event WstethStrategyTestLogAddress(string message, address value); function setUp() public { // Fork setup string memory rpc = getRpcUrl(); if (getForkBlockNumber() > 0) { _forkId = vm.createFork(rpc, getForkBlockNumber()); } else { _forkId = vm.createFork(rpc); } vm.selectFork(_forkId); vm.startPrank(admin); vault = _getVault(weth); classifier = address(new AlchemistStrategyClassifier(admin)); // Set up risk classes matching constructor defaults (WAD: 1e18 = 100%) AlchemistStrategyClassifier(classifier).setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% AlchemistStrategyClassifier(classifier).setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% AlchemistStrategyClassifier(classifier).setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% allocator = address(new AlchemistAllocator{salt: bytes32("allocator")}(address(vault), admin, curator, classifier)); IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "WstethStrategy", protocol: "WstethStrategy", riskClass: IMYTStrategy.RiskClass.LOW, cap: 2_000_000e18, globalCap: 2_000_000e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: STRATEGY_SLIPPAGE_BPS }); mytStrategy = _createStrategy(vault, params); // Assign risk level to the strategy bytes32 strategyId = IMYTStrategy(mytStrategy).adapterId(); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel(uint256(strategyId), uint8(params.riskClass)); emit WstethStrategyTestLogAddress("mytStrategy", mytStrategy); _setUpMYT(vault, mytStrategy, 2_000_000e18, 1e18); _magicDepositToVault(vault, admin, 1_000_000e18); require(IVaultV2(vault).totalAssets() == 1_000_000e18, "vault total assets mismatch"); _mockFreshStEthEthOracle(); vm.stopPrank(); } function _getVault(address asset) internal returns (address) { MockMYTVault v = new MockMYTVault{salt: bytes32("vault")}(admin, asset); v.setCurator(curator); vm.stopPrank(); vm.startPrank(curator); v.submit(abi.encodeCall(IVaultV2.setPerformanceFeeRecipient, (admin))); v.setPerformanceFeeRecipient(admin); v.submit(abi.encodeCall(IVaultV2.setPerformanceFee, (15e16))); v.setPerformanceFee(15e16); vm.stopPrank(); vm.startPrank(admin); return address(v); } function _createStrategy(address _vault, IMYTStrategy.StrategyParams memory params) internal returns (address) { return address( new MockWstethStrategy{salt: bytes32("wsteth_strategy")}( _vault, params, wstETH, stEthEthOracle, MAX_ORACLE_STALENESS ) ); } function test_constructor_sets_max_oracle_staleness() public view { assertEq( WstETHEthereumStrategy(payable(mytStrategy)).MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS, "unexpected initial max oracle staleness" ); } function _stEthOracleAnswer() internal view returns (uint256) { (, int256 answer,, uint256 updatedAt,) = AggregatorV3Interface(stEthEthOracle).latestRoundData(); require(answer > 0 && updatedAt != 0, "invalid oracle answer"); return uint256(answer); } function _maxWstEthIn(uint256 wethAmount) internal view returns (uint256) { uint256 maxWethIn = (wethAmount * 10_000 + (10_000 - STRATEGY_SLIPPAGE_BPS) - 1) / (10_000 - STRATEGY_SLIPPAGE_BPS); uint256 scale = 10 ** AggregatorV3Interface(stEthEthOracle).decimals(); uint256 answer = _stEthOracleAnswer(); uint256 stEthAmount = (maxWethIn * scale + answer - 1) / answer; return IWstETH(wstETH).getWstETHByStETH(stEthAmount); } function _quoteScaledWethOut(uint256 wstEthAmountIn) internal pure returns (uint256) { return (wstEthAmountIn * QUOTED_WETH_BUY_AMOUNT) / QUOTED_WSTETH_SELL_AMOUNT; } function test_strategy_allocate_direct() public { vm.startPrank(vault); uint256 amount = 100e18; deal(weth, mytStrategy, amount); IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; bytes memory data = abi.encode(params); (bytes32[] memory strategyIds, int256 change) = IMYTStrategy(mytStrategy).allocate(data, amount, "", vault); // change is the _totalValue() delta; verify allocation occurred assertGt(change, 0, "change should be positive after allocation"); assertGt(strategyIds.length, 0, "strategyIds is empty"); assertEq(strategyIds[0], IMYTStrategy(mytStrategy).adapterId(), "adapter id not in strategyIds"); // Verify wstETH was received. uint256 wstETHBalance = IWstETH(wstETH).balanceOf(mytStrategy); assertGt(wstETHBalance, 0, "wstETH balance should be positive"); // realAssets() values the position in WETH terms using the configured oracle. uint256 realAssets = IMYTStrategy(mytStrategy).realAssets(); assertGt(realAssets, 0, "real assets should be positive"); vm.stopPrank(); } function test_strategy_deallocate_with_mocked_dex_swap(uint256 requestedOut) public { vm.startPrank(vault); uint256 allocateAmount = 100e18; deal(weth, mytStrategy, allocateAmount); IMYTStrategy.VaultAdapterParams memory allocParams; allocParams.action = IMYTStrategy.ActionType.direct; IMYTStrategy(mytStrategy).allocate(abi.encode(allocParams), allocateAmount, "", vault); vm.stopPrank(); requestedOut = bound(requestedOut, 1e18, 100e18); uint256 wstETHBalanceBefore = IWstETH(wstETH).balanceOf(mytStrategy); uint256 wstEthToSwap = _maxWstEthIn(requestedOut); if (wstEthToSwap > wstETHBalanceBefore) { wstEthToSwap = wstETHBalanceBefore; } assertLe(wstEthToSwap, wstETHBalanceBefore, "requested deallocation should be fundable by position"); MockSwapExecutorDynamic mockSwap = new MockSwapExecutorDynamic(wstETH, weth); deal(weth, address(mockSwap), requestedOut); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(mockSwap)); vm.startPrank(vault); IMYTStrategy.SwapParams memory swapParams = IMYTStrategy.SwapParams({ txData: hex"01", minIntermediateOut: 0 }); IMYTStrategy.VaultAdapterParams memory deallocParams = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: swapParams}); (bytes32[] memory strategyIds,) = IMYTStrategy(mytStrategy).deallocate( abi.encode(deallocParams), requestedOut, "", vault ); vm.stopPrank(); assertGt(strategyIds.length, 0, "strategyIds is empty"); assertEq(strategyIds[0], IMYTStrategy(mytStrategy).adapterId(), "adapter id not in strategyIds"); assertEq(IERC20(weth).allowance(mytStrategy, vault), requestedOut, "vault allowance should equal WETH deallocated"); assertEq(IERC20(weth).balanceOf(mytStrategy), requestedOut, "strategy should receive requested WETH output"); } function test_allocator_deallocate_max_preview_from_total_value(uint256 allocateAmount) public { allocateAmount = bound(allocateAmount, 1e18, 100e18); vm.prank(admin); IAllocator(allocator).allocate(mytStrategy, allocateAmount); uint256 realAssetsBefore = IMYTStrategy(mytStrategy).realAssets(); assertGt(realAssetsBefore, 0, "real assets should be positive after allocation"); uint256 maxDeallocate = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(realAssetsBefore); assertGt(maxDeallocate, 0, "previewed deallocation amount should be positive"); assertLe(maxDeallocate, realAssetsBefore, "previewed amount should not exceed total value"); uint256 wstETHBalanceBefore = IWstETH(wstETH).balanceOf(mytStrategy); uint256 wstEthToSwap = _maxWstEthIn(maxDeallocate); assertLe(wstEthToSwap, wstETHBalanceBefore, "previewed amount should be fundable by position"); MockSwapExecutorDynamic mockSwap = new MockSwapExecutorDynamic(wstETH, weth); deal(weth, address(mockSwap), maxDeallocate); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(mockSwap)); bytes32 strategyId = IMYTStrategy(mytStrategy).adapterId(); uint256 allocationBefore = IVaultV2(vault).allocation(strategyId); vm.prank(admin); IAllocator(allocator).deallocateWithSwap(mytStrategy, maxDeallocate, hex"01"); uint256 allocationAfter = IVaultV2(vault).allocation(strategyId); assertLt(allocationAfter, allocationBefore, "allocator deallocation should reduce vault allocation"); assertLt(IWstETH(wstETH).balanceOf(mytStrategy), wstETHBalanceBefore, "wstETH balance should decrease after deallocation"); assertLt(IMYTStrategy(mytStrategy).realAssets(), realAssetsBefore, "real assets should decrease after deallocation"); } function test_strategy_deallocate_with_mocked_dex_swap_reverts_when_under_min_out() public { vm.startPrank(vault); uint256 allocateAmount = 100e18; deal(weth, mytStrategy, allocateAmount); IMYTStrategy.VaultAdapterParams memory allocParams; allocParams.action = IMYTStrategy.ActionType.direct; IMYTStrategy(mytStrategy).allocate(abi.encode(allocParams), allocateAmount, "", vault); vm.stopPrank(); uint256 requiredOut = 5e18; uint256 mockedOut = requiredOut - 1; MockSwapExecutor mockSwap = new MockSwapExecutor(weth, mockedOut); deal(weth, address(mockSwap), mockedOut); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(mockSwap)); vm.startPrank(vault); IMYTStrategy.SwapParams memory swapParams = IMYTStrategy.SwapParams({ txData: hex"01", minIntermediateOut: 0 }); IMYTStrategy.VaultAdapterParams memory deallocParams = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: swapParams}); vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.InvalidAmount.selector, requiredOut, mockedOut)); IMYTStrategy(mytStrategy).deallocate(abi.encode(deallocParams), requiredOut, "", vault); vm.stopPrank(); } function test_strategy_allocate_with_mocked_dex_swap() public { uint256 expectedWstethOut = 7e18; MockSwapExecutor mockSwap = new MockSwapExecutor(wstETH, expectedWstethOut); deal(wstETH, address(mockSwap), expectedWstethOut); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(mockSwap)); vm.startPrank(vault); uint256 amountIn = 5e18; deal(weth, mytStrategy, amountIn); IMYTStrategy.SwapParams memory swapParams = IMYTStrategy.SwapParams({txData: hex"01", minIntermediateOut: 0}); IMYTStrategy.VaultAdapterParams memory allocParams = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: swapParams}); (bytes32[] memory strategyIds, int256 change) = IMYTStrategy(mytStrategy).allocate( abi.encode(allocParams), amountIn, "", vault ); vm.stopPrank(); assertGt(strategyIds.length, 0, "strategyIds is empty"); assertEq(strategyIds[0], IMYTStrategy(mytStrategy).adapterId(), "adapter id not in strategyIds"); assertEq(IWstETH(wstETH).balanceOf(mytStrategy), expectedWstethOut, "strategy should receive expected wstETH from swap"); assertGt(change, 0, "allocation change should be positive"); } function test_realAssets_prices_wsteth_in_steth_units() public { vm.startPrank(vault); uint256 allocateAmount = 10e18; deal(weth, mytStrategy, allocateAmount); IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; IMYTStrategy(mytStrategy).allocate(abi.encode(params), allocateAmount, "", vault); vm.stopPrank(); uint256 wstETHBalance = IWstETH(wstETH).balanceOf(mytStrategy); uint256 stETHEquivalent = IWstETH(wstETH).getStETHByWstETH(wstETHBalance); uint256 scale = 10 ** AggregatorV3Interface(stEthEthOracle).decimals(); uint256 expectedRealAssets = stETHEquivalent * _stEthOracleAnswer() / scale; assertEq(IMYTStrategy(mytStrategy).realAssets(), expectedRealAssets, "realAssets should value wstETH via stETH equivalent"); } function test_realAssets_includes_idle_weth_leftover() public { assertEq(IWstETH(wstETH).balanceOf(mytStrategy), 0, "strategy should start without wstETH"); vm.startPrank(vault); uint256 allocateAmount = 10e18; deal(weth, mytStrategy, allocateAmount); IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; IMYTStrategy(mytStrategy).allocate(abi.encode(params), allocateAmount, "", vault); vm.stopPrank(); uint256 allocatedValue = IMYTStrategy(mytStrategy).realAssets(); assertGt(allocatedValue, 0, "allocated value should be positive"); uint256 leftover = 3e18; deal(weth, mytStrategy, leftover); uint256 totalRealAssets = IMYTStrategy(mytStrategy).realAssets(); assertEq(totalRealAssets, allocatedValue + leftover, "realAssets should include allocation plus idle WETH leftover"); } /// @notice Get swap calldata from 0x API for WETH -> wstETH function getWethToWstethCalldata(address taker, uint256 sellAmount) internal returns (bytes memory) { return _get0xCalldata(weth, wstETH, taker, sellAmount); } /// @notice Get swap calldata from 0x API for wstETH -> WETH function getWstethToWethCalldata(address taker, uint256 sellAmount) internal returns (bytes memory) { return _get0xCalldata(wstETH, weth, taker, sellAmount); } /// @notice Get 0x API key from environment variable function _get0xApiKey() internal view returns (string memory) { return vm.envString("ZEROX_API_KEY"); } /// @notice Get expected buy amount from 0x API quote function get0xBuyAmount(address sellToken, address buyToken, address taker, uint256 sellAmount) internal returns (uint256) { string[] memory inputs = new string[](3); inputs[0] = "bash"; inputs[1] = "-c"; inputs[2] = string.concat( "sleep 2 && ", // to avoid rate limits of 0x "curl -s --location --request GET 'https://api.0x.org/swap/allowance-holder/quote?chainId=1&sellToken=", vm.toString(sellToken), "&buyToken=", vm.toString(buyToken), "&sellAmount=", vm.toString(sellAmount), "&taker=", vm.toString(taker), "' -H '0x-api-key: ", _get0xApiKey(), "' -H '0x-version: v2' | jq -r '.buyAmount' | xargs cast to-hex" ); bytes memory b = vm.ffi(inputs); return vm.parseUint(string(b)); } /// @notice Generic helper to get swap calldata from 0x API function _get0xCalldata(address sellToken, address buyToken, address taker, uint256 sellAmount) internal returns (bytes memory) { string[] memory inputs = new string[](3); inputs[0] = "bash"; inputs[1] = "-c"; inputs[2] = string.concat( "sleep 2 && ", "curl -s --location --request GET 'https://api.0x.org/swap/allowance-holder/quote?chainId=1&sellToken=", vm.toString(sellToken), "&buyToken=", vm.toString(buyToken), "&sellAmount=", vm.toString(sellAmount), "&taker=", vm.toString(taker), "' -H '0x-api-key: ", _get0xApiKey(), "' -H '0x-version: v2' | jq -r .transaction.data" ); return vm.ffi(inputs); } /// @notice Fork at latest block since we use live API quotes function getForkBlockNumber() internal pure returns (uint256) { return 0; } function _mockFreshStEthEthOracle() internal { _mockStEthEthOracleAnswer(ASSUMED_STETH_ETH_ORACLE_ANSWER); } function _mockStEthEthOracleAnswer(uint256 answerToMock) internal { (uint80 roundId, int256 answer, uint256 startedAt,, uint80 answeredInRound) = AggregatorV3Interface(stEthEthOracle).latestRoundData(); vm.mockCall( stEthEthOracle, abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector), abi.encode(roundId, int256(answerToMock), startedAt, block.timestamp, answeredInRound) ); } function _setUpMYT(address _vault, address _mytStrategy, uint256 absoluteCap, uint256 relativeCap) internal { vm.startPrank(admin); vm.stopPrank(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (allocator, true))); IVaultV2(_vault).setIsAllocator(allocator, true); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, _mytStrategy)); IVaultV2(_vault).addAdapter(_mytStrategy); bytes memory idData = IMYTStrategy(_mytStrategy).getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, absoluteCap))); IVaultV2(_vault).increaseAbsoluteCap(idData, absoluteCap); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, relativeCap))); IVaultV2(_vault).increaseRelativeCap(idData, relativeCap); // Validation require(IVaultV2(_vault).adaptersLength() == 1, "adaptersLength must be 1"); require(IVaultV2(_vault).isAllocator(allocator), "allocator is not set"); require(IVaultV2(_vault).isAdapter(_mytStrategy), "strategy is not set"); bytes32 strategyId = IMYTStrategy(_mytStrategy).adapterId(); require(IVaultV2(_vault).absoluteCap(strategyId) == absoluteCap, "absoluteCap is not set"); require(IVaultV2(_vault).relativeCap(strategyId) == relativeCap, "relativeCap is not set"); vm.stopPrank(); } function _magicDepositToVault(address _vault, address depositor, uint256 amount) internal returns (uint256) { deal(address(IVaultV2(_vault).asset()), depositor, amount); vm.startPrank(depositor); TokenUtils.safeApprove(address(IVaultV2(_vault).asset()), _vault, amount); uint256 shares = IVaultV2(_vault).deposit(amount, depositor); vm.stopPrank(); return shares; } function _vaultSubmitAndFastForward(bytes memory data) internal { IVaultV2(vault).submit(data); bytes4 selector = bytes4(data); vm.warp(block.timestamp + IVaultV2(vault).timelock(selector)); } function getRpcUrl() internal view returns (string memory) { return vm.envString("MAINNET_RPC_URL"); } function test_allocator_allocate(uint256 amountToAllocate) public { // Lido has stake limits - cap at 1000 ETH to avoid STAKE_LIMIT error amountToAllocate = bound(amountToAllocate, 1e18, 1_000e18); vm.startPrank(admin); IAllocator(allocator).allocate(mytStrategy, amountToAllocate); // Verify wstETH was received. uint256 wstETHBalance = IWstETH(wstETH).balanceOf(mytStrategy); assertGt(wstETHBalance, 0, "wstETH balance should be positive"); // realAssets() values the position in WETH terms using the configured oracle. uint256 realAssets = IMYTStrategy(mytStrategy).realAssets(); assertGt(realAssets, 0, "real assets should be positive"); vm.stopPrank(); } function test_allocator_deallocate_with_swap() public { uint256 amountToAllocate = 100e18; vm.prank(admin); IAllocator(allocator).allocate(mytStrategy, amountToAllocate); uint256 wstETHBalanceBefore = IWstETH(wstETH).balanceOf(mytStrategy); assertGt(wstETHBalanceBefore, 0, "wstETH balance should be positive"); uint256 realAssetsBefore = IMYTStrategy(mytStrategy).realAssets(); uint256 previewedDeallocate = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(realAssetsBefore); assertGt(previewedDeallocate, 0, "previewed deallocation should be positive"); uint256 wstEthToSwap = _maxWstEthIn(previewedDeallocate); assertLe(wstEthToSwap, wstETHBalanceBefore, "previewed deallocation should be fundable by position"); MockSwapExecutorDynamic mockSwap = new MockSwapExecutorDynamic(wstETH, weth); deal(weth, address(mockSwap), previewedDeallocate); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(mockSwap)); vm.prank(admin); IAllocator(allocator).deallocateWithSwap(mytStrategy, previewedDeallocate, hex"01"); uint256 maxResidual = (realAssetsBefore * TEST_RESIDUAL_TOLERANCE_BPS) / 10_000 + 1e18; uint256 leftoverWeth = IERC20(weth).balanceOf(mytStrategy); uint256 realAssetsAfter = IMYTStrategy(mytStrategy).realAssets(); assertLe(realAssetsAfter, maxResidual, "remaining strategy balance should stay within slippage tolerance"); assertLe(leftoverWeth, maxResidual, "leftover idle WETH should stay within slippage tolerance"); assertLt(IWstETH(wstETH).balanceOf(mytStrategy), wstETHBalanceBefore, "wstETH balance should decrease after deallocation"); } function test_previewAdjustedWithdraw() public { // Should return 0 when strategy has no wstETH uint256 previewEmpty = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(100e18); assertEq(previewEmpty, 0, "should return 0 when no wstETH balance"); // Allocate some funds and verify preview vm.startPrank(vault); uint256 allocateAmount = 100e18; deal(weth, mytStrategy, allocateAmount); IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; bytes memory data = abi.encode(params); IMYTStrategy(mytStrategy).allocate(data, allocateAmount, "", vault); vm.stopPrank(); // Get strategy's fundamental capacity in WETH terms (oracle-adjusted). uint256 maxCapacity = IMYTStrategy(mytStrategy).realAssets(); // Preview for amount within capacity uint256 requestedAmount = 50e18; uint256 preview = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(requestedAmount); // Should be less than requested due to slippage haircut assertLt(preview, requestedAmount, "preview should be less than requested due to haircut"); assertGt(preview, 0, "preview should be positive"); // Verify haircut is applied correctly (slippageBPS = 200 from setUp) uint256 expectedPreview = (requestedAmount * (10_000 - STRATEGY_SLIPPAGE_BPS)) / 10_000; assertEq(preview, expectedPreview, "preview should match expected after haircut"); // Preview for amount exceeding capacity should cap at capacity uint256 excessAmount = maxCapacity + 100e18; uint256 previewExcess = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(excessAmount); uint256 expectedCapped = (maxCapacity * (10_000 - STRATEGY_SLIPPAGE_BPS)) / 10_000; assertEq(previewExcess, expectedCapped, "preview should be capped at max capacity minus haircut"); } function test_realAssets_reverts_when_oracle_is_stale() public { vm.startPrank(vault); uint256 allocateAmount = 10e18; deal(weth, mytStrategy, allocateAmount); IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; IMYTStrategy(mytStrategy).allocate(abi.encode(params), allocateAmount, "", vault); vm.stopPrank(); (uint80 roundId, int256 answer, uint256 startedAt,, uint80 answeredInRound) = AggregatorV3Interface(stEthEthOracle).latestRoundData(); vm.mockCall( stEthEthOracle, abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector), abi.encode(roundId, answer, startedAt, block.timestamp - MAX_ORACLE_STALENESS - 1, answeredInRound) ); vm.expectRevert(bytes("Stale oracle answer")); IMYTStrategy(mytStrategy).realAssets(); } // Test: WstETH Mainnet yield accumulation over time // Verifies that realAssets() remains stable/increases after time passes // (wstETH accrues yield via staking rewards reflected in exchange rate) function test_wsteth_mainnet_yield_accumulation() public { // Set up mocked swap executor for deallocations (avoids 0x signature deadline issues with vm.warp) MockSwapExecutorDynamic mockSwap = new MockSwapExecutorDynamic(wstETH, weth); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(mockSwap)); vm.startPrank(allocator); // Allocate initial amount uint256 allocAmount = 100e18; // 100 WETH // Use direct allocation (WETH -> wstETH wrap) IMYTStrategy.VaultAdapterParams memory params = IMYTStrategy.VaultAdapterParams({ action: IMYTStrategy.ActionType.direct, swapParams: IMYTStrategy.SwapParams({txData: "", minIntermediateOut: 0}) }); bytes memory data = abi.encode(params); IVaultV2(vault).allocate(mytStrategy, data, allocAmount); _mockFreshStEthEthOracle(); uint256 initialRealAssets = IMYTStrategy(mytStrategy).realAssets(); assertGt(initialRealAssets, 0, "Should have real assets after allocation"); // Track real assets over multiple time periods uint256 previousAssets = initialRealAssets; for (uint256 i = 0; i < 3; i++) { // Warp forward 30 days vm.warp(block.timestamp + 30 days); _mockFreshStEthEthOracle(); // Real assets should not decrease significantly over time // (wstETH exchange rate typically increases with staking rewards) uint256 currentAssets = IMYTStrategy(mytStrategy).realAssets(); assertGe(currentAssets, previousAssets * 95 / 100, "Real assets should not decrease significantly"); previousAssets = currentAssets; } // Final deallocation using mocked swap uint256 wstETHBalance = IWstETH(wstETH).balanceOf(mytStrategy); uint256 realAssetsValue = IMYTStrategy(mytStrategy).realAssets(); // Fund mock with exactly the oracle-adjusted WETH value // The mock returns its full balance, so fund it with what we expect to receive deal(weth, address(mockSwap), realAssetsValue); IMYTStrategy.SwapParams memory deallocSwapParams = IMYTStrategy.SwapParams({ txData: hex"01", // Mock calldata minIntermediateOut: 0 }); IMYTStrategy.VaultAdapterParams memory deallocParams = IMYTStrategy.VaultAdapterParams({ action: IMYTStrategy.ActionType.swap, swapParams: deallocSwapParams }); bytes memory deallocateData = abi.encode(deallocParams); // Deallocate the oracle-adjusted WETH value IVaultV2(vault).deallocate(mytStrategy, deallocateData, realAssetsValue); assertEq(IWstETH(wstETH).balanceOf(mytStrategy), 0, "wstETH should be fully swapped"); // Verify deallocation succeeded - realAssets should be significantly reduced uint256 remainingRealAssets = IMYTStrategy(mytStrategy).realAssets(); uint256 leftoverWeth = IERC20(weth).balanceOf(mytStrategy); uint256 maxResidual = (initialRealAssets * TEST_RESIDUAL_TOLERANCE_BPS) / 10_000 + 1e18; assertLe(leftoverWeth, maxResidual, "leftover idle WETH should stay within slippage tolerance"); assertLe(remainingRealAssets, maxResidual, "remaining real assets should stay within slippage tolerance"); vm.stopPrank(); } } ================================================ FILE: src/test/strategies/WstethOptimismStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import "forge-std/Test.sol"; import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol"; import {TokenUtils} from "../../libraries/TokenUtils.sol"; import {WstETHL2Strategy} from "../../strategies/WstETHL2Strategy.sol"; import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import {AggregatorV3Interface} from "lib/chainlink-brownie-contracts/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; import {AlchemistAllocator} from "../../AlchemistAllocator.sol"; import {IAllocator} from "../../interfaces/IAllocator.sol"; import {AlchemistStrategyClassifier} from "../../AlchemistStrategyClassifier.sol"; import {MockMYTVault} from "../mocks/MockMYTVault.sol"; import {MYTStrategy} from "../../MYTStrategy.sol"; interface IWstETH { function balanceOf(address account) external view returns (uint256); } contract MockSwapExecutor { IERC20 public immutable sellToken; IERC20 public immutable buyToken; uint256 public amountToTransfer; constructor(address _sellToken, address _buyToken, uint256 _amountToTransfer) { sellToken = IERC20(_sellToken); buyToken = IERC20(_buyToken); amountToTransfer = _amountToTransfer; } fallback() external { uint256 sellAllowance = sellToken.allowance(msg.sender, address(this)); if (sellAllowance > 0) { sellToken.transferFrom(msg.sender, address(this), sellAllowance); } buyToken.transfer(msg.sender, amountToTransfer); } } contract MockSwapExecutorDynamic { IERC20 public immutable buyToken; IERC20 public immutable sellToken; constructor(address _sellToken, address _buyToken) { sellToken = IERC20(_sellToken); buyToken = IERC20(_buyToken); } fallback() external { uint256 sellAllowance = sellToken.allowance(msg.sender, address(this)); if (sellAllowance > 0) { sellToken.transferFrom(msg.sender, address(this), sellAllowance); } uint256 buyBalance = buyToken.balanceOf(address(this)); if (buyBalance > 0) { buyToken.transfer(msg.sender, buyBalance); } } } contract MockWstethOptimismStrategy is WstETHL2Strategy { constructor( address _myt, StrategyParams memory _params, address _wstETH, address _wstEthEthOracle, uint256 _maxOracleStaleness ) WstETHL2Strategy(_myt, _params, _wstETH, _wstEthEthOracle, _maxOracleStaleness) {} } contract WstethOptimismStrategyTest is Test { uint256 public constant STRATEGY_SLIPPAGE_BPS = 200; uint256 public constant TEST_RESIDUAL_TOLERANCE_BPS = 100; uint256 public constant MAX_ORACLE_STALENESS = 1 hours; address public mytStrategy; address public vault; address public allocator; address public classifier; address public constant WETH = 0x4200000000000000000000000000000000000006; address public constant WSTETH = 0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb; address public constant WSTETH_ETH_ORACLE = 0x524299Ab0987a7c4B3c8022a35669DdcdC715a10; address public admin = address(0x1111111111111111111111111111111111111111); address public curator = address(0x2222222222222222222222222222222222222222); uint256 private _forkId; function setUp() public { string memory rpc = getRpcUrl(); if (getForkBlockNumber() > 0) { _forkId = vm.createFork(rpc, getForkBlockNumber()); } else { _forkId = vm.createFork(rpc); } vm.selectFork(_forkId); vm.startPrank(admin); vault = _getVault(WETH); classifier = address(new AlchemistStrategyClassifier(admin)); AlchemistStrategyClassifier(classifier).setRiskClass(0, 1e18, 1e18); // LOW: 100%/100% AlchemistStrategyClassifier(classifier).setRiskClass(1, 0.4e18, 0.25e18); // MEDIUM: 40%/25% AlchemistStrategyClassifier(classifier).setRiskClass(2, 0.1e18, 0.1e18); // HIGH: 10%/10% allocator = address(new AlchemistAllocator{salt: bytes32("allocator")}(address(vault), admin, curator, classifier)); IMYTStrategy.StrategyParams memory params = IMYTStrategy.StrategyParams({ owner: admin, name: "WstethOptimismStrategy", protocol: "WstethOptimismStrategy", riskClass: IMYTStrategy.RiskClass.LOW, cap: 2_000_000e18, globalCap: 2_000_000e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: STRATEGY_SLIPPAGE_BPS }); mytStrategy = _createStrategy(vault, params); bytes32 strategyId = IMYTStrategy(mytStrategy).adapterId(); AlchemistStrategyClassifier(classifier).assignStrategyRiskLevel(uint256(strategyId), uint8(params.riskClass)); _setUpMYT(vault, mytStrategy, 2_000_000e18, 1e18); _magicDepositToVault(vault, admin, 1_000_000e18); require(IVaultV2(vault).totalAssets() == 1_000_000e18, "vault total assets mismatch"); vm.stopPrank(); } function _getVault(address asset) internal returns (address) { MockMYTVault v = new MockMYTVault{salt: bytes32("vault")}(admin, asset); v.setCurator(curator); vm.stopPrank(); vm.startPrank(curator); v.submit(abi.encodeCall(IVaultV2.setPerformanceFeeRecipient, (admin))); v.setPerformanceFeeRecipient(admin); v.submit(abi.encodeCall(IVaultV2.setPerformanceFee, (15e16))); v.setPerformanceFee(15e16); vm.stopPrank(); vm.startPrank(admin); return address(v); } function _createStrategy(address _vault, IMYTStrategy.StrategyParams memory params) internal returns (address) { return address( new MockWstethOptimismStrategy{salt: bytes32("wsteth_strategy")}( _vault, params, WSTETH, WSTETH_ETH_ORACLE, MAX_ORACLE_STALENESS ) ); } function test_constructor_sets_max_oracle_staleness() public view { assertEq( WstETHL2Strategy(mytStrategy).MAX_ORACLE_STALENESS(), MAX_ORACLE_STALENESS, "unexpected initial max oracle staleness" ); } function _wstEthOracleAnswer() internal view returns (uint256) { (, int256 answer,, uint256 updatedAt,) = AggregatorV3Interface(WSTETH_ETH_ORACLE).latestRoundData(); require(answer > 0 && updatedAt != 0, "invalid oracle answer"); return uint256(answer); } function _maxWstEthIn(uint256 wethAmount) internal view returns (uint256) { uint256 maxWethIn = (wethAmount * 10_000 + (10_000 - STRATEGY_SLIPPAGE_BPS) - 1) / (10_000 - STRATEGY_SLIPPAGE_BPS); uint256 scale = 10 ** AggregatorV3Interface(WSTETH_ETH_ORACLE).decimals(); uint256 answer = _wstEthOracleAnswer(); uint256 wstEthAmount = (maxWethIn * scale + answer - 1) / answer; return wstEthAmount == 0 ? 1 : wstEthAmount; } function _allocateWithMockedSwap(uint256 amountIn, uint256 expectedWstethOut) internal { MockSwapExecutor mockSwap = new MockSwapExecutor(WETH, WSTETH, expectedWstethOut); deal(WSTETH, address(mockSwap), expectedWstethOut); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(mockSwap)); vm.startPrank(vault); deal(WETH, mytStrategy, amountIn); IMYTStrategy.SwapParams memory swapParams = IMYTStrategy.SwapParams({txData: hex"01", minIntermediateOut: 0}); IMYTStrategy.VaultAdapterParams memory allocParams = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: swapParams}); IMYTStrategy(mytStrategy).allocate(abi.encode(allocParams), amountIn, "", vault); vm.stopPrank(); } function test_strategy_allocate_direct_reverts() public { vm.startPrank(vault); deal(WETH, mytStrategy, 1e18); IMYTStrategy.VaultAdapterParams memory params; params.action = IMYTStrategy.ActionType.direct; vm.expectRevert(IMYTStrategy.ActionNotSupported.selector); IMYTStrategy(mytStrategy).allocate(abi.encode(params), 1e18, "", vault); vm.stopPrank(); } function test_strategy_allocate_with_mocked_dex_swap() public { uint256 amountIn = 5e18; uint256 expectedWstethOut = 7e18; _allocateWithMockedSwap(amountIn, expectedWstethOut); assertEq(IWstETH(WSTETH).balanceOf(mytStrategy), expectedWstethOut, "strategy should receive expected wstETH from swap"); assertGt(IMYTStrategy(mytStrategy).realAssets(), 0, "allocation should create real assets"); } function test_strategy_deallocate_with_mocked_dex_swap() public { _allocateWithMockedSwap(100e18, 100e18); uint256 expectedOut = 5e18; MockSwapExecutor mockSwap = new MockSwapExecutor(WSTETH, WETH, expectedOut); deal(WETH, address(mockSwap), expectedOut); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(mockSwap)); vm.startPrank(vault); IMYTStrategy.SwapParams memory swapParams = IMYTStrategy.SwapParams({txData: hex"01", minIntermediateOut: 0}); IMYTStrategy.VaultAdapterParams memory deallocParams = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: swapParams}); (bytes32[] memory strategyIds,) = IMYTStrategy(mytStrategy).deallocate( abi.encode(deallocParams), expectedOut, "", vault ); vm.stopPrank(); assertGt(strategyIds.length, 0, "strategyIds is empty"); assertEq(strategyIds[0], IMYTStrategy(mytStrategy).adapterId(), "adapter id not in strategyIds"); assertEq(IERC20(WETH).allowance(mytStrategy, vault), expectedOut, "vault allowance should equal WETH deallocated"); assertEq(IERC20(WETH).balanceOf(mytStrategy), expectedOut, "strategy should receive mocked WETH output"); } function test_strategy_deallocate_with_mocked_dex_swap_reverts_when_under_min_out() public { _allocateWithMockedSwap(100e18, 100e18); uint256 requiredOut = 5e18; uint256 mockedOut = requiredOut - 1; MockSwapExecutor mockSwap = new MockSwapExecutor(WSTETH, WETH, mockedOut); deal(WETH, address(mockSwap), mockedOut); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(mockSwap)); vm.startPrank(vault); IMYTStrategy.SwapParams memory swapParams = IMYTStrategy.SwapParams({txData: hex"01", minIntermediateOut: 0}); IMYTStrategy.VaultAdapterParams memory deallocParams = IMYTStrategy.VaultAdapterParams({action: IMYTStrategy.ActionType.swap, swapParams: swapParams}); vm.expectRevert(abi.encodeWithSelector(IMYTStrategy.InvalidAmount.selector, requiredOut, mockedOut)); IMYTStrategy(mytStrategy).deallocate(abi.encode(deallocParams), requiredOut, "", vault); vm.stopPrank(); } function test_realAssets_prices_wsteth_in_oracle_units() public { uint256 mockedWstethOut = 10e18; _allocateWithMockedSwap(10e18, mockedWstethOut); uint256 wstETHBalance = IWstETH(WSTETH).balanceOf(mytStrategy); uint256 scale = 10 ** AggregatorV3Interface(WSTETH_ETH_ORACLE).decimals(); uint256 expectedRealAssets = wstETHBalance * _wstEthOracleAnswer() / scale; assertEq( IMYTStrategy(mytStrategy).realAssets(), expectedRealAssets, "realAssets should value raw wstETH balance via the wstETH/ETH oracle" ); } function test_realAssets_includes_idle_weth_leftover() public { assertEq(IWstETH(WSTETH).balanceOf(mytStrategy), 0, "strategy should start without wstETH"); _allocateWithMockedSwap(10e18, 10e18); uint256 allocatedValue = IMYTStrategy(mytStrategy).realAssets(); assertGt(allocatedValue, 0, "allocated value should be positive"); uint256 leftover = 3e18; deal(WETH, mytStrategy, leftover); uint256 totalRealAssets = IMYTStrategy(mytStrategy).realAssets(); assertEq(totalRealAssets, allocatedValue + leftover, "realAssets should include allocation plus idle WETH leftover"); } function test_previewAdjustedWithdraw() public { uint256 previewEmpty = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(100e18); assertEq(previewEmpty, 0, "should return 0 when no wstETH balance"); _allocateWithMockedSwap(100e18, 100e18); uint256 maxCapacity = IMYTStrategy(mytStrategy).realAssets(); uint256 requestedAmount = 50e18; uint256 preview = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(requestedAmount); assertLt(preview, requestedAmount, "preview should be less than requested due to haircut"); assertGt(preview, 0, "preview should be positive"); uint256 expectedPreview = (requestedAmount * (10_000 - STRATEGY_SLIPPAGE_BPS)) / 10_000; assertEq(preview, expectedPreview, "preview should match expected after haircut"); uint256 excessAmount = maxCapacity + 100e18; uint256 previewExcess = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(excessAmount); uint256 expectedCapped = (maxCapacity * (10_000 - STRATEGY_SLIPPAGE_BPS)) / 10_000; assertEq(previewExcess, expectedCapped, "preview should be capped at max capacity minus haircut"); uint256 idleLeftover = 10e18; deal(WETH, mytStrategy, idleLeftover); uint256 previewIdleOnly = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(8e18); assertEq(previewIdleOnly, 8e18, "idle assets should be previewed at par"); uint256 mixedRequest = idleLeftover + 20e18; uint256 mixedPreview = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(mixedRequest); uint256 expectedMixedPreview = idleLeftover + (20e18 * (10_000 - STRATEGY_SLIPPAGE_BPS)) / 10_000; assertEq(mixedPreview, expectedMixedPreview, "preview should use idle assets before haircutting invested assets"); } function test_realAssets_reverts_when_oracle_is_stale() public { _allocateWithMockedSwap(10e18, 10e18); (uint80 roundId, int256 answer, uint256 startedAt,, uint80 answeredInRound) = AggregatorV3Interface(WSTETH_ETH_ORACLE).latestRoundData(); vm.mockCall( WSTETH_ETH_ORACLE, abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector), abi.encode(roundId, answer, startedAt, block.timestamp - MAX_ORACLE_STALENESS - 1, answeredInRound) ); vm.expectRevert(bytes("Stale oracle answer")); IMYTStrategy(mytStrategy).realAssets(); } function test_owner_can_set_priced_token_oracle() public { address newOracle = address(0x1234567890123456789012345678901234567890); uint8 newDecimals = 18; int256 newAnswer = 2e18; vm.mockCall( newOracle, abi.encodeWithSelector(AggregatorV3Interface.decimals.selector), abi.encode(newDecimals) ); vm.mockCall( newOracle, abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector), abi.encode(uint80(1), newAnswer, block.timestamp, block.timestamp, uint80(1)) ); vm.prank(admin); WstETHL2Strategy(mytStrategy).setPricedTokenOracle(newOracle); assertEq(address(WstETHL2Strategy(mytStrategy).pricedTokenOracle()), newOracle, "oracle should update"); assertEq(WstETHL2Strategy(mytStrategy).pricedTokenOracleDecimals(), newDecimals, "decimals should update"); uint256 wstETHBalance = 3e18; deal(WSTETH, mytStrategy, wstETHBalance); assertEq( IMYTStrategy(mytStrategy).realAssets(), wstETHBalance * uint256(newAnswer) / (10 ** newDecimals), "realAssets should use the replacement oracle" ); } function test_owner_can_set_max_oracle_staleness() public { uint256 newMaxOracleStaleness = 8 days; vm.prank(admin); WstETHL2Strategy(mytStrategy).setMaxOracleStaleness(newMaxOracleStaleness); assertEq( WstETHL2Strategy(mytStrategy).MAX_ORACLE_STALENESS(), newMaxOracleStaleness, "max oracle staleness should update" ); _allocateWithMockedSwap(10e18, 10e18); (uint80 roundId, int256 answer, uint256 startedAt,, uint80 answeredInRound) = AggregatorV3Interface(WSTETH_ETH_ORACLE).latestRoundData(); vm.mockCall( WSTETH_ETH_ORACLE, abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector), abi.encode(roundId, answer, startedAt, block.timestamp - newMaxOracleStaleness, answeredInRound) ); assertGt(IMYTStrategy(mytStrategy).realAssets(), 0, "updated staleness window should be honored"); } function test_allocator_allocate_with_mocked_swap() public { uint256 amountToAllocate = 100e18; MockSwapExecutor mockSwap = new MockSwapExecutor(WETH, WSTETH, amountToAllocate); deal(WSTETH, address(mockSwap), amountToAllocate); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(mockSwap)); vm.startPrank(admin); IAllocator(allocator).allocateWithSwap( mytStrategy, amountToAllocate, hex"01" ); uint256 wstETHBalance = IWstETH(WSTETH).balanceOf(mytStrategy); assertGt(wstETHBalance, 0, "wstETH balance should be positive"); uint256 realAssets = IMYTStrategy(mytStrategy).realAssets(); assertGt(realAssets, 0, "real assets should be positive"); vm.stopPrank(); } function test_allocator_deallocate_with_mocked_swap() public { uint256 amountToAllocate = 100e18; MockSwapExecutor allocSwap = new MockSwapExecutor(WETH, WSTETH, amountToAllocate); deal(WSTETH, address(allocSwap), amountToAllocate); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(allocSwap)); vm.prank(admin); IAllocator(allocator).allocateWithSwap(mytStrategy, amountToAllocate, hex"01"); uint256 wstETHBalanceBefore = IWstETH(WSTETH).balanceOf(mytStrategy); assertGt(wstETHBalanceBefore, 0, "wstETH balance should be positive"); uint256 realAssetsBefore = IMYTStrategy(mytStrategy).realAssets(); uint256 previewedDeallocate = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(realAssetsBefore); assertGt(previewedDeallocate, 0, "previewed deallocation should be positive"); uint256 wstEthToSwap = _maxWstEthIn(previewedDeallocate); assertLe(wstEthToSwap, wstETHBalanceBefore, "previewed deallocation should be fundable by position"); MockSwapExecutorDynamic deallocSwap = new MockSwapExecutorDynamic(WSTETH, WETH); deal(WETH, address(deallocSwap), previewedDeallocate); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(deallocSwap)); vm.prank(admin); IAllocator(allocator).deallocateWithSwap(mytStrategy, previewedDeallocate, hex"01"); uint256 maxResidual = (realAssetsBefore * TEST_RESIDUAL_TOLERANCE_BPS) / 10_000 + 1e18; uint256 leftoverWeth = IERC20(WETH).balanceOf(mytStrategy); uint256 realAssetsAfter = IMYTStrategy(mytStrategy).realAssets(); assertLe(realAssetsAfter, maxResidual, "remaining strategy balance should stay within slippage tolerance"); assertLe(leftoverWeth, maxResidual, "leftover idle WETH should stay within slippage tolerance"); assertLt(IWstETH(WSTETH).balanceOf(mytStrategy), wstETHBalanceBefore, "wstETH balance should decrease after deallocation"); } function test_allocator_deallocate_max_preview_from_total_value(uint256 allocateAmount) public { allocateAmount = bound(allocateAmount, 1e18, 100e18); MockSwapExecutor allocSwap = new MockSwapExecutor(WETH, WSTETH, allocateAmount); deal(WSTETH, address(allocSwap), allocateAmount); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(allocSwap)); vm.prank(admin); IAllocator(allocator).allocateWithSwap(mytStrategy, allocateAmount, hex"01"); uint256 realAssetsBefore = IMYTStrategy(mytStrategy).realAssets(); assertGt(realAssetsBefore, 0, "real assets should be positive after allocation"); uint256 maxDeallocate = IMYTStrategy(mytStrategy).previewAdjustedWithdraw(realAssetsBefore); assertGt(maxDeallocate, 0, "previewed deallocation amount should be positive"); assertLe(maxDeallocate, realAssetsBefore, "previewed amount should not exceed total value"); uint256 wstETHBalanceBefore = IWstETH(WSTETH).balanceOf(mytStrategy); uint256 wstEthToSwap = _maxWstEthIn(maxDeallocate); assertLe(wstEthToSwap, wstETHBalanceBefore, "previewed amount should be fundable by position"); MockSwapExecutorDynamic deallocSwap = new MockSwapExecutorDynamic(WSTETH, WETH); deal(WETH, address(deallocSwap), maxDeallocate); vm.prank(admin); MYTStrategy(mytStrategy).setAllowanceHolder(address(deallocSwap)); bytes32 strategyId = IMYTStrategy(mytStrategy).adapterId(); uint256 allocationBefore = IVaultV2(vault).allocation(strategyId); vm.prank(admin); IAllocator(allocator).deallocateWithSwap(mytStrategy, maxDeallocate, hex"01"); uint256 allocationAfter = IVaultV2(vault).allocation(strategyId); assertLt(allocationAfter, allocationBefore, "allocator deallocation should reduce vault allocation"); assertLt(IWstETH(WSTETH).balanceOf(mytStrategy), wstETHBalanceBefore, "wstETH balance should decrease after deallocation"); assertLt(IMYTStrategy(mytStrategy).realAssets(), realAssetsBefore, "real assets should decrease after deallocation"); } function getForkBlockNumber() internal pure returns (uint256) { return 141_751_698; } function getRpcUrl() internal view returns (string memory) { return vm.envString("OPTIMISM_RPC_URL"); } function _setUpMYT(address _vault, address _mytStrategy, uint256 absoluteCap, uint256 relativeCap) internal { vm.startPrank(admin); vm.stopPrank(); vm.startPrank(curator); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.setIsAllocator, (allocator, true))); IVaultV2(_vault).setIsAllocator(allocator, true); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.addAdapter, _mytStrategy)); IVaultV2(_vault).addAdapter(_mytStrategy); bytes memory idData = IMYTStrategy(_mytStrategy).getIdData(); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, absoluteCap))); IVaultV2(_vault).increaseAbsoluteCap(idData, absoluteCap); _vaultSubmitAndFastForward(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, relativeCap))); IVaultV2(_vault).increaseRelativeCap(idData, relativeCap); require(IVaultV2(_vault).adaptersLength() == 1, "adaptersLength must be 1"); require(IVaultV2(_vault).isAllocator(allocator), "allocator is not set"); require(IVaultV2(_vault).isAdapter(_mytStrategy), "strategy is not set"); bytes32 strategyId = IMYTStrategy(_mytStrategy).adapterId(); require(IVaultV2(_vault).absoluteCap(strategyId) == absoluteCap, "absoluteCap is not set"); require(IVaultV2(_vault).relativeCap(strategyId) == relativeCap, "relativeCap is not set"); vm.stopPrank(); } function _magicDepositToVault(address _vault, address depositor, uint256 amount) internal returns (uint256) { deal(address(IVaultV2(_vault).asset()), depositor, amount); vm.startPrank(depositor); TokenUtils.safeApprove(address(IVaultV2(_vault).asset()), _vault, amount); uint256 shares = IVaultV2(_vault).deposit(amount, depositor); vm.stopPrank(); return shares; } function _vaultSubmitAndFastForward(bytes memory data) internal { IVaultV2(vault).submit(data); bytes4 selector = bytes4(data); vm.warp(block.timestamp + IVaultV2(vault).timelock(selector)); } } ================================================ FILE: src/test/strategies/YvUSDCStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {ERC4626Strategy} from "../../strategies/ERC4626Strategy.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IVaultV2} from "lib/vault-v2/src/interfaces/IVaultV2.sol"; interface IERC4626MaxWithdraw { function maxWithdraw(address owner) external view returns (uint256); } contract MockYvUSDCStrategy is ERC4626Strategy { constructor(address _myt, StrategyParams memory _params, address _vault) ERC4626Strategy(_myt, _params, _vault) {} } contract YvUSDCStrategyTest is BaseStrategyTest { address public constant YV_USDC_VAULT = 0x696d02Db93291651ED510704c9b286841d506987; address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "YvUSDC", protocol: "YearnUSDC", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e6, globalCap: 1e18, estimatedYield: 100e6, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: USDC, vaultInitialDeposit: 1000e6, absoluteCap: 10_000e6, relativeCap: 1e18, decimals: 6}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new MockYvUSDCStrategy(vault, params, YV_USDC_VAULT)); } function getForkBlockNumber() internal pure override returns (uint256) { return 24_850_461; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("MAINNET_RPC_URL"); } function _effectiveDeallocateAmount(uint256 requestedAssets) internal view override returns (uint256) { uint256 maxWithdrawable = IERC4626MaxWithdraw(YV_USDC_VAULT).maxWithdraw(strategy); return requestedAssets < maxWithdrawable ? requestedAssets : maxWithdrawable; } } ================================================ FILE: src/test/strategies/YvWETHStrategy.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; import "../BaseStrategyTest.sol"; import {ERC4626Strategy} from "../../strategies/ERC4626Strategy.sol"; interface IERC4626MaxWithdraw { function maxWithdraw(address owner) external view returns (uint256); } contract MockYvWETHStrategy is ERC4626Strategy { constructor(address _myt, StrategyParams memory _params, address _vault) ERC4626Strategy(_myt, _params, _vault) {} } contract YvWETHStrategyTest is BaseStrategyTest { address public constant YV_WETH_VAULT = 0xc56413869c6CDf96496f2b1eF801fEDBdFA7dDB0; address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; function getStrategyConfig() internal pure override returns (IMYTStrategy.StrategyParams memory) { return IMYTStrategy.StrategyParams({ owner: address(1), name: "YvWETH", protocol: "YearnWETH", riskClass: IMYTStrategy.RiskClass.LOW, cap: 10_000e18, globalCap: 1e18, estimatedYield: 100e18, additionalIncentives: false, slippageBPS: 1 }); } function getTestConfig() internal pure override returns (TestConfig memory) { return TestConfig({vaultAsset: WETH, vaultInitialDeposit: 1000e18, absoluteCap: 10_000e18, relativeCap: 1e18, decimals: 18}); } function createStrategy(address vault, IMYTStrategy.StrategyParams memory params) internal override returns (address) { return address(new MockYvWETHStrategy(vault, params, YV_WETH_VAULT)); } function getForkBlockNumber() internal pure override returns (uint256) { return 24_850_461; } function getRpcUrl() internal view override returns (string memory) { return vm.envString("MAINNET_RPC_URL"); } function _effectiveDeallocateAmount(uint256 requestedAssets) internal view override returns (uint256) { uint256 maxWithdrawable = IERC4626MaxWithdraw(YV_WETH_VAULT).maxWithdraw(strategy); return requestedAssets < maxWithdrawable ? requestedAssets : maxWithdrawable; } } ================================================ FILE: src/test/strategies/utils/offchain/quotes/stethToWeth.json ================================================ { "allowanceTarget": "0x0000000000001ff3684f28c67538d4d072c22734", "blockNumber": "24284935", "buyAmount": "99689643709467877385", "buyToken": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "fees": { "integratorFee": null, "integratorFees": null, "zeroExFee": { "amount": "149759097085961367", "token": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "type": "volume" }, "gasFee": null }, "issues": { "allowance": { "actual": "0", "spender": "0x0000000000001ff3684f28c67538d4d072c22734" }, "balance": { "token": "0xae7ab96520de3a18e5e111b5eaab095312d7fe84", "actual": "0", "expected": "99850000000000000000" }, "simulationIncomplete": false, "invalidSourcesPassed": [] }, "liquidityAvailable": true, "minBuyAmount": "99679659769186508544", "route": { "fills": [ { "from": "0xae7ab96520de3a18e5e111b5eaab095312d7fe84", "to": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "source": "Origin", "proportionBps": "10000" } ], "tokens": [ { "address": "0xae7ab96520de3a18e5e111b5eaab095312d7fe84", "symbol": "stETH" }, { "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "symbol": "WETH" } ] }, "sellAmount": "99850000000000000000", "sellToken": "0xae7ab96520de3a18e5e111b5eaab095312d7fe84", "tokenMetadata": { "buyToken": { "buyTaxBps": "0", "sellTaxBps": "0", "transferTaxBps": "0" }, "sellToken": { "buyTaxBps": "0", "sellTaxBps": "0", "transferTaxBps": "0" } }, "totalNetworkFee": "91587338032008", "transaction": { "to": "0x0000000000001ff3684f28c67538d4d072c22734", "data": "0x2213bc0b0000000000000000000000001f560aee3d0c615f51466dcd9a312cfa858271a5000000000000000000000000ae7ab96520de3a18e5e111b5eaab095312d7fe8400000000000000000000000000000000000000000000000569b275f8d6c100000000000000000000000000001f560aee3d0c615f51466dcd9a312cfa858271a500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000006a41fff991f000000000000000000000000d1ef758608be415c5c50aa657d9c87f15b60a5a9000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000567554a6f42285f0000000000000000000000000000000000000000000000000000000000000000a04bf899f16b42a44fb43ffe0e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000048000000000000000000000000000000000000000000000000000000000000000e4c1fb425e0000000000000000000000001f560aee3d0c615f51466dcd9a312cfa858271a5000000000000000000000000ae7ab96520de3a18e5e111b5eaab095312d7fe8400000000000000000000000000000000000000000000000569b275f8d6c1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000069711ae600000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e438c9c147000000000000000000000000ae7ab96520de3a18e5e111b5eaab095312d7fe84000000000000000000000000000000000000000000000000000000000000271000000000000000000000000085b78aca6deae198fbf201c82daf6ca21942acc6000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000010438ed173900000000000000000000000000000000000000000000000569b275f8d6c10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000001f560aee3d0c615f51466dcd9a312cfa858271a50000000000000000000000000000000000000000000000000000000069711ae60000000000000000000000000000000000000000000000000000000000000002000000000000000000000000ae7ab96520de3a18e5e111b5eaab095312d7fe84000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008434ee90ca000000000000000000000000f5c4f3dc02c3fb9279495a8fef7b0741da956157000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000005698cd32930acfda4000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c147000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000f000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "gas": "237146", "gasPrice": "386206548", "value": "0" }, "zid": "0x4bf899f16b42a44fb43ffe0e" } ================================================ FILE: src/test/strategies/utils/offchain/quotes/wethToWsteth.json ================================================ { "allowanceTarget": "0x0000000000001ff3684f28c67538d4d072c22734", "blockNumber": "24284934", "buyAmount": "81514985000132187500", "buyToken": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "fees": { "integratorFee": null, "integratorFees": null, "zeroExFee": { "amount": "122456161742814000", "token": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "type": "volume" }, "gasFee": null }, "issues": { "allowance": { "actual": "0", "spender": "0x0000000000001ff3684f28c67538d4d072c22734" }, "balance": { "token": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "actual": "0", "expected": "100000000000000000000" }, "simulationIncomplete": false, "invalidSourcesPassed": [] }, "liquidityAvailable": true, "minBuyAmount": "81506821256016000000", "route": { "fills": [ { "from": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "to": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "source": "Fluid", "proportionBps": "10000" } ], "tokens": [ { "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "symbol": "WETH" }, { "address": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "symbol": "wstETH" } ] }, "sellAmount": "100000000000000000000", "sellToken": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "tokenMetadata": { "buyToken": { "buyTaxBps": "0", "sellTaxBps": "0", "transferTaxBps": "0" }, "sellToken": { "buyTaxBps": "0", "sellTaxBps": "0", "transferTaxBps": "0" } }, "totalNetworkFee": "156699058578972", "transaction": { "to": "0x0000000000001ff3684f28c67538d4d072c22734", "data": "0x2213bc0b0000000000000000000000001f560aee3d0c615f51466dcd9a312cfa858271a5000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000056bc75e2d631000000000000000000000000000001f560aee3d0c615f51466dcd9a312cfa858271a500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000007841fff991f000000000000000000000000d1ef758608be415c5c50aa657d9c87f15b60a5a90000000000000000000000007f39c581f595b53c5cb19bd0b3f8da6c935e2ca00000000000000000000000000000000000000000000000046b2266173817740000000000000000000000000000000000000000000000000000000000000000a0c0646ec364750fab0bb3b8b50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000004a0000000000000000000000000000000000000000000000000000000000000056000000000000000000000000000000000000000000000000000000000000000e4c1fb425e0000000000000000000000001f560aee3d0c615f51466dcd9a312cfa858271a5000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000069711adf00000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000002710000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000242e1a7d4d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee00000000000000000000000000000000000000000000000000000000000027100000000000000000000000000b1a513ee24972daef112bc777a5610d4325c9e7000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000842668dfaa0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f560aee3d0c615f51466dcd9a312cfa858271a50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008434ee90ca000000000000000000000000f5c4f3dc02c3fb9279495a8fef7b0741da9561570000000000000000000000007f39c581f595b53c5cb19bd0b3f8da6c935e2ca00000000000000000000000000000000000000000000000046cf27bcf0784fa4f000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c1470000000000000000000000007f39c581f595b53c5cb19bd0b3f8da6c935e2ca0000000000000000000000000000000000000000000000000000000000000000f0000000000000000000000007f39c581f595b53c5cb19bd0b3f8da6c935e2ca0000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "gas": "405739", "gasPrice": "386206548", "value": "0" }, "zid": "0xc0646ec364750fab0bb3b8b5" } ================================================ FILE: src/test/strategies/utils/offchain/quotes/wstethToWeth.json ================================================ { "allowanceTarget": "0x000000000022d473030f116ddee9f6b43ac78ba3", "blockNumber": "24737769", "buyAmount": "1222732076605", "buyToken": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "fees": { "integratorFee": null, "integratorFees": null, "zeroExFee": { "amount": "1836836232", "token": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "type": "volume" }, "gasFee": null }, "issues": { "allowance": { "actual": "0", "spender": "0x000000000022d473030f116ddee9f6b43ac78ba3" }, "balance": { "token": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "actual": "0", "expected": "1000000000000" }, "simulationIncomplete": false, "invalidSourcesPassed": [] }, "liquidityAvailable": true, "minBuyAmount": "1222609619712", "permit2": { "type": "Permit2", "hash": "0x7170022e989b28e76e1cdd5f25b4b4ed875be8fd13e5d2995e339e15e7250d83", "eip712": { "types": { "EIP712Domain": [ { "name": "name", "type": "string" }, { "name": "chainId", "type": "uint256" }, { "name": "verifyingContract", "type": "address" } ], "PermitTransferFrom": [ { "name": "permitted", "type": "TokenPermissions" }, { "name": "spender", "type": "address" }, { "name": "nonce", "type": "uint256" }, { "name": "deadline", "type": "uint256" } ], "TokenPermissions": [ { "name": "token", "type": "address" }, { "name": "amount", "type": "uint256" } ] }, "domain": { "name": "Permit2", "chainId": 1, "verifyingContract": "0x000000000022d473030f116ddee9f6b43ac78ba3" }, "message": { "permitted": { "token": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "amount": "1000000000000" }, "spender": "0xf48a3f7c0575c85cf4529aa220caf3c055773f1c", "nonce": "2241959297937691820908574931991579", "deadline": "1774480626" }, "primaryType": "PermitTransferFrom" } }, "route": { "fills": [ { "from": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "to": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "source": "Uniswap_V2", "proportionBps": "10000" } ], "tokens": [ { "address": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "symbol": "wstETH" }, { "address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "symbol": "WETH" } ] }, "sellAmount": "1000000000000", "sellToken": "0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0", "tokenMetadata": { "buyToken": { "buyTaxBps": "0", "sellTaxBps": "0", "transferTaxBps": "0" }, "sellToken": { "buyTaxBps": "0", "sellTaxBps": "0", "transferTaxBps": "0" } }, "totalNetworkFee": "31150146924644", "transaction": { "to": "0xf48a3f7c0575c85cf4529aa220caf3c055773f1c", "data": "0x1fff991f00000000000000000000000015452ec016c4dc8c549e7fe6ff4b26324ea8b7a4000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000011ca9364b0000000000000000000000000000000000000000000000000000000000000000a0d5ec3010ec480af950bb7aaf0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000003a000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000c4103b48be000000000000000000000000f48a3f7c0575c85cf4529aa220caf3c055773f1c0000000000000000000000007f39c581f595b53c5cb19bd0b3f8da6c935e2ca000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003f3ee751ab00246cb0beec2e904ef51e18ac4d770000000000000000000000000000000000000000000000000000000000001e01000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008434ee90ca000000000000000000000000f5c4f3dc02c3fb9279495a8fef7b0741da956157000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000022307eff528000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012438c9c147000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000f000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000002400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ad01c20d5886137e056775af56915de824c8fce500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ffffc1fb425e0000000000000000000000003f3ee751ab00246cb0beec2e904ef51e18ac4d770000000000000000000000007f39c581f595b53c5cb19bd0b3f8da6c935e2ca0000000000000000000000000000000000000000000000000000000e8d4a510000000000000000000000000000000000000006e898131631616b1779bad70bc1b0000000000000000000000000000000000000000000000000000000069c46cf200000000000000000000000000000000000000000000000000000000000000c0", "gas": "258082", "gasPrice": "120698642", "value": "0" }, "zid": "0xd5ec3010ec480af950bb7aaf" } ================================================ FILE: src/utils/PermissionedProxy.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; contract PermissionedProxy { address admin; mapping (address => bool) public operators; mapping (bytes4 => bool) public permissionedCalls; address public pendingAdmin; constructor(address _admin, address _operator) { require(_admin != address(0), "zero"); require(_operator != address(0), "zero"); admin = _admin; operators[_operator] = true; } modifier onlyAdmin() { _onlyAdmin(); _; } function _onlyAdmin() internal view { require(msg.sender == admin, "PD"); } modifier onlyOperator() { _onlyOperator(); _; } function _onlyOperator() internal view { require(operators[msg.sender], "PD"); } event AdminUpdated(address indexed admin); event OperatorUpdated(address indexed operator); event AddedPermissionedCall(bytes4 indexed sig); function transferAdminOwnerShip(address _newAdmin) external onlyAdmin { pendingAdmin = _newAdmin; } function acceptAdminOwnership() external { require(msg.sender == pendingAdmin, "PD"); admin = pendingAdmin; pendingAdmin = address(0); emit AdminUpdated(admin); } function setOperator(address _operator, bool value) external onlyAdmin { require(_operator != address(0), "zero"); operators[_operator] = value; emit OperatorUpdated(_operator); } function setPermissionedCall(bytes4 sig, bool value) external onlyAdmin { permissionedCalls[sig] = value; emit AddedPermissionedCall(sig); } function proxy(address vault, bytes memory data) external payable onlyOperator { bytes4 selector; require(data.length >= 4, "SEL"); assembly { selector := mload(add(data, 32)) } require(permissionedCalls[selector], "PD"); (bool success, ) = vault.call{value: msg.value}(data); require(success, "failed"); } } ================================================ FILE: src/utils/Whitelist.sol ================================================ pragma solidity ^0.8.13; import "../base/Errors.sol"; import "../interfaces/IWhitelist.sol"; import "../../lib/openzeppelin-contracts/contracts/access/Ownable.sol"; import "../libraries/Sets.sol"; /// @title Whitelist /// @author Alchemix Finance contract Whitelist is IWhitelist, Ownable { using Sets for Sets.AddressSet; Sets.AddressSet addresses; /// @inheritdoc IWhitelist bool public override disabled; constructor() Ownable(msg.sender) {} /// @inheritdoc IWhitelist function getAddresses() external view returns (address[] memory) { return addresses.values; } /// @inheritdoc IWhitelist function add(address caller) external override { _onlyAdmin(); if (disabled) { revert IllegalState(); } addresses.add(caller); emit AccountAdded(caller); } /// @inheritdoc IWhitelist function remove(address caller) external override { _onlyAdmin(); if (disabled) { revert IllegalState(); } addresses.remove(caller); emit AccountRemoved(caller); } /// @inheritdoc IWhitelist function disable() external override { require(!disabled); _onlyAdmin(); disabled = true; emit WhitelistDisabled(); } /// @inheritdoc IWhitelist function isWhitelisted(address account) external view override returns (bool) { return disabled || addresses.contains(account); } /// @dev Reverts if the caller is not the contract owner. function _onlyAdmin() internal view { if (msg.sender != owner()) { revert Unauthorized(); } } } ================================================ FILE: src/utils/ZeroXSwapVerifier.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /** * @title ZeroXSwapVerifier * @dev Verifies 0x permit swap calldata and validates token whitelist and amount bounds * * This contract decodes 0x Settler calldata to extract swap actions and verifies: * 1. Input tokens are whitelisted * 2. Swap amounts are within configured bounds * 3. Action types are permitted * 4. Target token and amount match expected values */ library ZeroXSwapVerifier { // Constants for 0x Settler function selectors bytes4 private constant EXECUTE_SELECTOR = 0xcf71ff4f; // execute(SlippageAndActions,bytes[]) bytes4 private constant EXECUTE_META_TXN_SELECTOR = 0x0476baab; // executeMetaTxn(SlippageAndActions,bytes[],address,bytes) // Action selectors for different swap types bytes4 private constant BASIC_SELL_TO_POOL = 0x5228831d; bytes4 private constant UNISWAPV3_VIP = 0x9ebf8e8d; bytes4 private constant RFQ_VIP = 0x0dfeb419; bytes4 private constant METATXN_VIP = 0xc1fb425e; bytes4 private constant CURVE_TRICRYPTO_VIP = 0x103b48be; bytes4 private constant UNISWAPV4_VIP = 0x38c9c147; bytes4 private constant TRANSFER_FROM = 0x8d68a156; bytes4 private constant NATIVE_DEPOSIT = 0xc876d21d; bytes4 private constant SELL_TO_LIQUIDITY_PROVIDER = 0xf1e0a1c3; bytes4 private constant DODOV1_VIP = 0x40a07c6c; bytes4 private constant VELODROME_V2_VIP = 0xb8df6d4d; bytes4 private constant DODOV2_VIP = 0xd92aadfb; struct SlippageAndActions { address recipient; address buyToken; uint256 minAmountOut; bytes[] actions; } /** * @dev Returns a slice of a bytes memory array. * @param data The original data * @param start The starting index * @param length The length of the slice * @return The sliced bytes */ function _slice(bytes memory data, uint256 start, uint256 length) internal pure returns (bytes memory) { bytes memory result = new bytes(length); for (uint256 i = 0; i < length; i++) { result[i] = data[start + i]; } return result; } /** * @dev Returns a slice from start to end of a bytes memory array. * @param data The original data * @param start The starting index * @return The sliced bytes */ function _slice(bytes memory data, uint256 start) internal pure returns (bytes memory) { return _slice(data, start, data.length - start); } /** * @dev Decode calldata and verify all actions (external for try/catch) * @param calldata_ The calldata to decode * @param owner the address we whitelist as spender * @param targetToken The expected token address that should be matched * @param maxSlippageBps Maximum allowed slippage in basis points */ function decodeAndVerifyActions(bytes calldata calldata_, address owner, address targetToken, uint256 maxSlippageBps) internal view { bytes4 selector = bytes4(calldata_[0:4]); if (selector == EXECUTE_SELECTOR) { _verifyExecuteCalldata(calldata_[4:], owner, targetToken, maxSlippageBps); } else if (selector == EXECUTE_META_TXN_SELECTOR) { _verifyExecuteMetaTxnCalldata(calldata_[4:], owner, targetToken, maxSlippageBps); } else { revert("Unsupported function selector"); } } /** * @dev Main verification function for 0x swap calldata * @param calldata_ The complete calldata from 0x API * @param owner the address we whitelist as spender * @param targetToken The expected token address that should be matched * @param maxSlippageBps Maximum allowed slippage in basis points (1000 = 10%) * @return verified Whether the swap passes all checks */ function verifySwapCalldata(bytes calldata calldata_, address owner, address targetToken, uint256 maxSlippageBps) external view returns (bool verified) { if (calldata_.length < 4) { return false; } bytes4 selector = bytes4(calldata_[0:4]); // Check if it's a valid 0x Settler function require(selector == EXECUTE_SELECTOR || selector == EXECUTE_META_TXN_SELECTOR, "IS"); decodeAndVerifyActions(calldata_, owner, targetToken, maxSlippageBps); return true; } /** * @dev Verify execute() function calldata * @param data The function parameters (without selector) * @param targetToken The expected token address that should be matched * @param maxSlippageBps Maximum allowed slippage in basis points */ function _verifyExecuteCalldata(bytes calldata data, address owner, address targetToken, uint256 maxSlippageBps) internal view { // Decode SlippageAndActions struct and actions array (SlippageAndActions memory saa, ) = abi.decode(data, (SlippageAndActions, bytes)); // TODO shall we also verify saa.buyToken ? _verifyActions(saa.actions, owner, targetToken, maxSlippageBps); } /** * @dev Verify executeMetaTxn() function calldata * @param data The function parameters (without selector) * @param targetToken The expected token address that should be matched * @param maxSlippageBps Maximum allowed slippage in basis points */ function _verifyExecuteMetaTxnCalldata(bytes calldata data, address owner, address targetToken, uint256 maxSlippageBps) internal view { // Decode parameters: (SlippageAndActions, bytes[], address, bytes) (SlippageAndActions memory saa, , , ) = abi.decode(data, (SlippageAndActions, bytes[], address, bytes)); // TODO shall we also verify saa.buyToken ? _verifyActions(saa.actions, owner, targetToken, maxSlippageBps); } /** * @dev Verify all actions in the actions array * @param actions Array of encoded action calls * @param targetToken The expected token address that should be matched * @param maxSlippageBps Maximum allowed slippage in basis points */ function _verifyActions(bytes[] memory actions, address owner, address targetToken, uint256 maxSlippageBps) internal view { for (uint256 i = 0; i < actions.length; i++) { _verifyAction(actions[i], owner, targetToken, maxSlippageBps); } } /** * @dev Verify a single action * @param action The encoded action call * @param targetToken The expected token address that should be matched * @param maxSlippageBps Maximum allowed slippage in basis points */ function _verifyAction(bytes memory action, address owner, address targetToken, uint256 maxSlippageBps) internal view { if (action.length < 4) { revert("Invalid action length"); } bytes4 actionSelector = bytes4(action); // Verify based on action type if (actionSelector == BASIC_SELL_TO_POOL) { _verifyBasicSellToPool(action, owner, targetToken, maxSlippageBps); } else if (actionSelector == UNISWAPV3_VIP) { _verifyUniswapV3VIP(action, owner, targetToken, maxSlippageBps); } else if (actionSelector == RFQ_VIP) { _verifyRFQVIP(action, owner, targetToken, maxSlippageBps); } else if (actionSelector == TRANSFER_FROM) { _verifyTransferFrom(action, owner, targetToken, maxSlippageBps); } else if (actionSelector == SELL_TO_LIQUIDITY_PROVIDER) { _verifySellToLiquidityProvider(action, owner, targetToken, maxSlippageBps); } else if (actionSelector == NATIVE_DEPOSIT) { revert("not supported"); } else if (actionSelector == VELODROME_V2_VIP) { _verifyVelodromeV2VIP(action, owner, targetToken, maxSlippageBps); } else { revert("IAC"); } // Add more action types as needed } /** * @dev Verify BASIC_SELL_TO_POOL action * Format: basicSellToPool(IERC20 sellToken, uint256 bps, address pool, uint256 offset, bytes data) */ function _verifyBasicSellToPool(bytes memory action, address owner, address targetToken, uint256 maxSlippageBps) internal view { (address sellToken, uint256 bps, , , ) = abi.decode( _slice(action, 4), (address, uint256, address, uint256, bytes) ); require(sellToken == targetToken, "IT"); require(bps <= maxSlippageBps, "Slippage too high"); } /** * @dev Verify UNISWAP_V3_VIP action * Format: uniswapV3VIP(address recipient, uint256 bps, uint256 feeOrTickSpacing, bool feeOnTransfer, bytes fills) */ function _verifyUniswapV3VIP(bytes memory action, address owner, address targetToken, uint256 maxSlippageBps) internal view { (, uint256 bps, , , bytes memory fills) = abi.decode( _slice(action, 4), (address, uint256, uint256, bool, bytes) ); // Extract token from fills data - this requires parsing the UniswapV3 fill structure address sellToken = _extractTokenFromUniswapFills(fills); require(sellToken == targetToken, "IT"); require(bps <= maxSlippageBps, "Slippage too high"); } /** * @dev Verify RFQ_VIP action * Format: rfqVIP(uint256 info, bytes fillData) */ function _verifyRFQVIP(bytes memory action, address owner, address targetToken, uint256 targetAmount) internal view { (, bytes memory fillData) = abi.decode(_slice(action, 4), (uint256, bytes)); // Extract token and amount from RFQ fill data (address sellToken, uint256 amount) = _extractTokenAndAmountFromRFQ(fillData); require(sellToken == targetToken, "IT"); // Removed balance check as the 0x quote already has slippage protection } /** * @dev Verify TRANSFER_FROM action * Format: transferFrom(IERC20 token, address from, address to, uint256 amount) */ function _verifyTransferFrom(bytes memory action, address owner, address targetToken, uint256 targetAmount) internal view { (address token, , , uint256 amount) = abi.decode( _slice(action, 4), (address, address, address, uint256) ); require(token == targetToken, "IT"); // Removed balance check as the 0x quote already has slippage protection } /** * @dev Verify SELL_TO_LIQUIDITY_PROVIDER action */ function _verifySellToLiquidityProvider(bytes memory action, address owner, address targetToken, uint256 targetAmount) internal view { (address sellToken, , uint256 sellAmount, , ) = abi.decode( _slice(action, 4), (address, address, uint256, uint256, bytes) ); require(sellToken == targetToken, "IT"); // Removed balance check as the 0x quote already has slippage protection } /** * @dev Verify VELODROME_V2_VIP action */ function _verifyVelodromeV2VIP(bytes memory action, address owner, address targetToken, uint256 maxSlippageBps) internal view { (address sellToken, uint256 bps, , , , ) = abi.decode( _slice(action, 4), (address, uint256, bool, uint256, uint256, bytes) ); require(sellToken == targetToken, "IT"); require(bps <= maxSlippageBps, "Slippage too high"); } /** * @dev Extract token from UniswapV3 fills data * TODO */ function _extractTokenFromUniswapFills(bytes memory fills) internal pure returns (address) { // Simplified - in reality this would parse the complex fills structure if (fills.length >= 32) { return abi.decode(_slice(fills, 0, 32), (address)); } revert("unimplemented"); } /** * @dev Extract token and amount from RFQ fill data * TODO */ function _extractTokenAndAmountFromRFQ(bytes memory fillData) internal pure returns (address token, uint256 amount) { // Simplified - in reality this would parse the RFQ fill structure if (fillData.length >= 64) { return abi.decode(_slice(fillData, 0, 64), (address, uint256)); } revert("unimplemented"); } }