diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a8c4253..21bfb5c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,8 +50,35 @@ jobs: echo "## Lint results" >> $GITHUB_STEP_SUMMARY echo "✅ Passed" >> $GITHUB_STEP_SUMMARY - - name: "Run tests and generate coverage report" + - name: "Run mainnet fork tests" + run: + pnpm test test/crossAsset/ChainlinkPriceAdapter.test.ts test/crossAsset/ERC4626PriceAdapter.test.ts + test/crossAsset/ERC4626ExecutionAdapter.test.ts + env: + RPC_URL: ${{ secrets.RPC_URL }} + DEPLOYER_PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} + LP_PRIVATE_KEY: ${{ secrets.LP_PRIVATE_KEY }} + ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }} + FORK_MAINNET: "true" + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} + + - name: "Run unit tests" + run: pnpm test + env: + RPC_URL: ${{ secrets.RPC_URL }} + DEPLOYER_PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} + LP_PRIVATE_KEY: ${{ secrets.LP_PRIVATE_KEY }} + ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }} + + - name: "Generate coverage report" run: pnpm coverage + env: + RPC_URL: ${{ secrets.RPC_URL }} + DEPLOYER_PRIVATE_KEY: ${{ secrets.DEPLOYER_PRIVATE_KEY }} + LP_PRIVATE_KEY: ${{ secrets.LP_PRIVATE_KEY }} + ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }} + FORK_MAINNET: "true" + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} - name: "Upload coverage to Codecov" uses: codecov/codecov-action@v5 @@ -147,7 +174,6 @@ jobs: find artifacts/contracts -name "*.json" \ ! -name "*.dbg.json" \ - ! -path "*/mocks/*" \ ! -path "*/test/*" \ -exec sh -c ' for file do diff --git a/.gitignore b/.gitignore index 66f79e6c..f00ad778 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ docs/ res/ running_node/ artifacts/ +edr-cache/ scripts/*.ts diff --git a/.solhintignore b/.solhintignore index 332086fe..bc38b1cb 100644 --- a/.solhintignore +++ b/.solhintignore @@ -1,6 +1,5 @@ # directories **/artifacts **/node_modules -contracts/mocks contracts/test contracts/sp1-contracts \ No newline at end of file diff --git a/codecov.yml b/codecov.yml index ce066fd6..74e7f520 100644 --- a/codecov.yml +++ b/codecov.yml @@ -15,7 +15,6 @@ coverage: ignore: - "test/**" - - "**/mocks/**" - "**/*.t.sol" - "artifacts/**" - "audits/**" diff --git a/contracts/LiquidityOrchestrator.sol b/contracts/LiquidityOrchestrator.sol index 0922b6ef..ea4be8f9 100644 --- a/contracts/LiquidityOrchestrator.sol +++ b/contracts/LiquidityOrchestrator.sol @@ -169,6 +169,7 @@ contract LiquidityOrchestrator is } /// @dev Restricts function to only self + /// Used on _executeSell and _executeBuy so they can stay external (required for try/catch) modifier onlySelf() { if (msg.sender != address(this)) revert ErrorsLib.NotAuthorized(); _; @@ -284,6 +285,7 @@ contract LiquidityOrchestrator is /// @inheritdoc ILiquidityOrchestrator function setSlippageTolerance(uint256 _slippageTolerance) external onlyOwner { + if (_slippageTolerance > BASIS_POINTS_FACTOR) revert ErrorsLib.InvalidArguments(); slippageTolerance = _slippageTolerance; } @@ -754,11 +756,29 @@ contract LiquidityOrchestrator is } } + /// @notice Calculate maximum amount with slippage applied + /// @param estimatedAmount The estimated amount + /// @return The maximum amount with slippage applied + function _calculateMaxWithSlippage(uint256 estimatedAmount) internal view returns (uint256) { + return estimatedAmount.mulDiv(BASIS_POINTS_FACTOR + slippageTolerance, BASIS_POINTS_FACTOR); + } + + /// @notice Calculate minimum amount with slippage applied + /// @param estimatedAmount The estimated amount + /// @return The minimum amount with slippage applied + function _calculateMinWithSlippage(uint256 estimatedAmount) internal view returns (uint256) { + return estimatedAmount.mulDiv(BASIS_POINTS_FACTOR - slippageTolerance, BASIS_POINTS_FACTOR); + } + /// @notice Executes a sell order /// @param asset The asset to sell /// @param sharesAmount The amount of shares to sell /// @param estimatedUnderlyingAmount The estimated underlying amount to receive - function _executeSell(address asset, uint256 sharesAmount, uint256 estimatedUnderlyingAmount) external onlySelf { + function _executeSell( + address asset, + uint256 sharesAmount, + uint256 estimatedUnderlyingAmount + ) external onlySelf nonReentrant { IExecutionAdapter adapter = executionAdapterOf[asset]; if (address(adapter) == address(0)) revert ErrorsLib.AdapterNotSet(); @@ -766,7 +786,13 @@ contract LiquidityOrchestrator is IERC20(asset).forceApprove(address(adapter), sharesAmount); // Execute sell through adapter, pull shares from this contract and push underlying assets to it. - uint256 executionUnderlyingAmount = adapter.sell(asset, sharesAmount, estimatedUnderlyingAmount); + uint256 executionUnderlyingAmount = adapter.sell(asset, sharesAmount); + + // Validate slippage of trade is within tolerance. + uint256 minUnderlyingAmount = _calculateMinWithSlippage(estimatedUnderlyingAmount); + if (executionUnderlyingAmount < minUnderlyingAmount) { + revert ErrorsLib.SlippageExceeded(asset, executionUnderlyingAmount, minUnderlyingAmount); + } // Clean up approval IERC20(asset).forceApprove(address(adapter), 0); @@ -778,19 +804,20 @@ contract LiquidityOrchestrator is /// @param asset The asset to buy /// @param sharesAmount The amount of shares to buy /// @param estimatedUnderlyingAmount The estimated underlying amount to spend - /// @dev The adapter handles slippage tolerance internally. - function _executeBuy(address asset, uint256 sharesAmount, uint256 estimatedUnderlyingAmount) external onlySelf { + function _executeBuy( + address asset, + uint256 sharesAmount, + uint256 estimatedUnderlyingAmount + ) external onlySelf nonReentrant { IExecutionAdapter adapter = executionAdapterOf[asset]; if (address(adapter) == address(0)) revert ErrorsLib.AdapterNotSet(); - // Approve adapter to spend underlying assets - IERC20(underlyingAsset).forceApprove( - address(adapter), - estimatedUnderlyingAmount.mulDiv(BASIS_POINTS_FACTOR + slippageTolerance, BASIS_POINTS_FACTOR) - ); + // Approve adapter to spend underlying assets with slippage tolerance. + // Slippage tolerance is enforced indirectly by capping the approval amount. + IERC20(underlyingAsset).forceApprove(address(adapter), _calculateMaxWithSlippage(estimatedUnderlyingAmount)); // Execute buy through adapter, pull underlying assets from this contract and push shares to it. - uint256 executionUnderlyingAmount = adapter.buy(asset, sharesAmount, estimatedUnderlyingAmount); + uint256 executionUnderlyingAmount = adapter.buy(asset, sharesAmount); // Clean up approval IERC20(underlyingAsset).forceApprove(address(adapter), 0); diff --git a/contracts/execution/ERC4626ExecutionAdapter.sol b/contracts/execution/ERC4626ExecutionAdapter.sol new file mode 100644 index 00000000..2aa29a23 --- /dev/null +++ b/contracts/execution/ERC4626ExecutionAdapter.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { IExecutionAdapter } from "../interfaces/IExecutionAdapter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { ErrorsLib } from "../libraries/ErrorsLib.sol"; +import { IOrionConfig } from "../interfaces/IOrionConfig.sol"; +import { ILiquidityOrchestrator } from "../interfaces/ILiquidityOrchestrator.sol"; + +/** + * @title ERC4626ExecutionAdapter + * @notice Execution adapter for ERC-4626 vaults with generic underlying asset. + * @author Orion Finance + * @dev Architecture: + * - Handles same-asset flows: protocolUnderlying=vaultUnderlying → vaultShares + * - Handles cross-asset flows: protocolUnderlying → ExecutionAdapter → vaultUnderlying → vaultShares + * + * @custom:security-contact security@orionfinance.ai + */ +contract ERC4626ExecutionAdapter is IExecutionAdapter { + using SafeERC20 for IERC20; + + /// @notice Orion protocol configuration contract + IOrionConfig public immutable CONFIG; + + /// @notice Protocol underlying asset + IERC20 public immutable UNDERLYING_ASSET; + + /// @notice Liquidity orchestrator contract + ILiquidityOrchestrator public immutable LIQUIDITY_ORCHESTRATOR; + + /** + * @notice Constructor + * @param configAddress OrionConfig contract address + */ + constructor(address configAddress) { + if (configAddress == address(0)) revert ErrorsLib.ZeroAddress(); + + CONFIG = IOrionConfig(configAddress); + UNDERLYING_ASSET = IERC20(CONFIG.underlyingAsset()); + LIQUIDITY_ORCHESTRATOR = ILiquidityOrchestrator(CONFIG.liquidityOrchestrator()); + + if (address(LIQUIDITY_ORCHESTRATOR) == address(0)) revert ErrorsLib.ZeroAddress(); + } + + /// @notice Internal validation function that performs compatibility checks + /// @param asset The address of the asset to validate + function _validateExecutionAdapter(address asset) internal view { + // 1. Verify asset implements IERC4626 + try IERC4626(asset).asset() returns (address vaultUnderlying) { + // 2. Verify registered vault decimals match config decimals + try IERC20Metadata(asset).decimals() returns (uint8 vaultDecimals) { + if (vaultDecimals != CONFIG.getTokenDecimals(asset)) { + revert ErrorsLib.InvalidAdapter(asset); + } + } catch { + revert ErrorsLib.InvalidAdapter(asset); + } + + // 3. Verify underlying vault decimals match config decimals + // (vault underlying must be whitelisted in config) + try IERC20Metadata(vaultUnderlying).decimals() returns (uint8 vaultUnderlyingDecimals) { + if (vaultUnderlyingDecimals != CONFIG.getTokenDecimals(vaultUnderlying)) { + revert ErrorsLib.InvalidAdapter(asset); + } + } catch { + revert ErrorsLib.InvalidAdapter(asset); + } + } catch { + revert ErrorsLib.InvalidAdapter(asset); + } + } + + /// @inheritdoc IExecutionAdapter + function validateExecutionAdapter(address asset) external view override { + _validateExecutionAdapter(asset); + } + + /// @inheritdoc IExecutionAdapter + function previewBuy(address vaultAsset, uint256 sharesAmount) external returns (uint256 underlyingAmount) { + IERC4626 vault = IERC4626(vaultAsset); + address vaultUnderlying = vault.asset(); + uint256 vaultUnderlyingNeeded = vault.previewMint(sharesAmount); + + if (vaultUnderlying == address(UNDERLYING_ASSET)) { + return vaultUnderlyingNeeded; + } + IExecutionAdapter swapExecutor = IExecutionAdapter( + address(LIQUIDITY_ORCHESTRATOR.executionAdapterOf(vaultUnderlying)) + ); + return swapExecutor.previewBuy(vaultUnderlying, vaultUnderlyingNeeded); + } + + /// @inheritdoc IExecutionAdapter + function sell( + address vaultAsset, + uint256 sharesAmount + ) external override returns (uint256 receivedUnderlyingAmount) { + if (sharesAmount == 0) revert ErrorsLib.AmountMustBeGreaterThanZero(vaultAsset); + // Atomically validate order generation assumptions + _validateExecutionAdapter(vaultAsset); + + IERC4626 vault = IERC4626(vaultAsset); + address vaultUnderlying = vault.asset(); + + if (vaultUnderlying == address(UNDERLYING_ASSET)) { + receivedUnderlyingAmount = vault.redeem(sharesAmount, msg.sender, msg.sender); + } else { + uint256 receivedVaultUnderlyingAmount = vault.redeem(sharesAmount, address(this), msg.sender); + + IExecutionAdapter swapExecutor = IExecutionAdapter( + address(LIQUIDITY_ORCHESTRATOR.executionAdapterOf(vaultUnderlying)) + ); + + IERC20(vaultUnderlying).forceApprove(address(swapExecutor), receivedVaultUnderlyingAmount); + + receivedUnderlyingAmount = swapExecutor.sell(vaultUnderlying, receivedVaultUnderlyingAmount); + + // Clean up approval + IERC20(vaultUnderlying).forceApprove(address(swapExecutor), 0); + + UNDERLYING_ASSET.safeTransfer(msg.sender, receivedUnderlyingAmount); + } + } + + /// @inheritdoc IExecutionAdapter + function buy(address vaultAsset, uint256 sharesAmount) external override returns (uint256 spentUnderlyingAmount) { + if (sharesAmount == 0) revert ErrorsLib.AmountMustBeGreaterThanZero(vaultAsset); + _validateExecutionAdapter(vaultAsset); + + IERC4626 vault = IERC4626(vaultAsset); + address vaultUnderlying = vault.asset(); + uint256 vaultUnderlyingNeeded = vault.previewMint(sharesAmount); + + uint256 underlyingNeeded; + if (vaultUnderlying == address(UNDERLYING_ASSET)) { + underlyingNeeded = vaultUnderlyingNeeded; + } else { + underlyingNeeded = this.previewBuy(vaultAsset, sharesAmount); + } + + // Pull previewed amount from the caller. + UNDERLYING_ASSET.safeTransferFrom(msg.sender, address(this), underlyingNeeded); + + if (vaultUnderlying == address(UNDERLYING_ASSET)) { + // Approve vault to spend underlying assets + UNDERLYING_ASSET.forceApprove(vaultAsset, underlyingNeeded); + + // Mint exact shares. Vault will pull the required underlying amount + // This guarantees sharesAmount shares are minted. + spentUnderlyingAmount = vault.mint(sharesAmount, address(this)); + // Some ERC4626 implementations may leave dust in the adapter; + // we accept that, as target shares are minted. + + // Clean up approval + UNDERLYING_ASSET.forceApprove(vaultAsset, 0); + } else { + IExecutionAdapter swapExecutor = IExecutionAdapter( + address(LIQUIDITY_ORCHESTRATOR.executionAdapterOf(vaultUnderlying)) + ); + // Approve swap executor to spend underlying assets + UNDERLYING_ASSET.forceApprove(address(swapExecutor), underlyingNeeded); + + spentUnderlyingAmount = swapExecutor.buy(vaultUnderlying, vaultUnderlyingNeeded); + // Swap Executor may leave dust in the adapter, we accept that. + + // Clean up approval + UNDERLYING_ASSET.forceApprove(address(swapExecutor), 0); + + // Approve vault to spend vault underlying assets + IERC20(vaultUnderlying).forceApprove(vaultAsset, vaultUnderlyingNeeded); + + // Mint exact shares. Vault will pull the required underlying amount + // This guarantees sharesAmount shares are minted. + // slither-disable-next-line unused-return + vault.mint(sharesAmount, address(this)); + // Some ERC4626 implementations may leave dust in the adapter; + // we accept that, as target shares are minted. + + // Clean up approval + IERC20(vaultUnderlying).forceApprove(vaultAsset, 0); + } + + // Push all minted shares to the caller (LO) + IERC20(vaultAsset).safeTransfer(msg.sender, sharesAmount); + } +} diff --git a/contracts/execution/OrionAssetERC4626ExecutionAdapter.sol b/contracts/execution/OrionAssetERC4626ExecutionAdapter.sol deleted file mode 100644 index 24a0265a..00000000 --- a/contracts/execution/OrionAssetERC4626ExecutionAdapter.sol +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.28; - -import "../interfaces/IExecutionAdapter.sol"; -import { ErrorsLib } from "../libraries/ErrorsLib.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; -import "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { IOrionConfig } from "../interfaces/IOrionConfig.sol"; -import { ILiquidityOrchestrator } from "../interfaces/ILiquidityOrchestrator.sol"; - -/** - * @title OrionAssetERC4626ExecutionAdapter - * @notice Execution adapter for ERC-4626 vaults sharing the same underlying asset as the Orion protocol. - * @author Orion Finance - * @dev This adapter handles the conversion between underlying assets and vault shares. - * It is not safe to use this adapter with vaults that are based on a different asset. - * @custom:security-contact security@orionfinance.ai - */ -contract OrionAssetERC4626ExecutionAdapter is IExecutionAdapter { - using SafeERC20 for IERC20; - using Math for uint256; - - /// @notice Basis points factor - uint16 public constant BASIS_POINTS_FACTOR = 10_000; - - /// @notice The Orion config contract - IOrionConfig public config; - - /// @notice The underlying asset as an IERC20 interface - IERC20 public underlyingAssetToken; - - /// @notice The address of the liquidity orchestrator - ILiquidityOrchestrator public liquidityOrchestrator; - - modifier onlyLiquidityOrchestrator() { - if (msg.sender != address(liquidityOrchestrator)) revert ErrorsLib.NotAuthorized(); - _; - } - - /// @notice Constructor - /// @param configAddress The address of the Orion config contract - constructor(address configAddress) { - if (configAddress == address(0)) revert ErrorsLib.ZeroAddress(); - - config = IOrionConfig(configAddress); - underlyingAssetToken = config.underlyingAsset(); - liquidityOrchestrator = ILiquidityOrchestrator(config.liquidityOrchestrator()); - } - - /// @notice Internal validation function that performs compatibility checks - /// @param asset The address of the asset to validate - function _validateExecutionAdapter(address asset) internal view { - // 1. Verify asset implements IERC4626 and has correct underlying - try IERC4626(asset).asset() returns (address underlying) { - if (underlying != address(underlyingAssetToken)) revert ErrorsLib.InvalidAdapter(asset); - } catch { - revert ErrorsLib.InvalidAdapter(asset); - } - - // 2. Verify tokenDecimals match between runtime and config - try IERC20Metadata(asset).decimals() returns (uint8 decimals) { - if (decimals != config.getTokenDecimals(asset)) revert ErrorsLib.InvalidAdapter(asset); - } catch { - revert ErrorsLib.InvalidAdapter(asset); - } - } - - /// @inheritdoc IExecutionAdapter - function validateExecutionAdapter(address asset) external view override { - _validateExecutionAdapter(asset); - } - - /// @inheritdoc IExecutionAdapter - function sell( - address vaultAsset, - uint256 sharesAmount, - uint256 estimatedUnderlyingAmount - ) external override onlyLiquidityOrchestrator returns (uint256 receivedUnderlyingAmount) { - if (sharesAmount == 0) revert ErrorsLib.AmountMustBeGreaterThanZero(vaultAsset); - // Atomically validate all order generation assumptions - _validateExecutionAdapter(vaultAsset); - - IERC4626 vault = IERC4626(vaultAsset); - - // Redeem shares to get underlying assets - // slither-disable-next-line unused-return - receivedUnderlyingAmount = vault.redeem(sharesAmount, msg.sender, msg.sender); - - if (receivedUnderlyingAmount < estimatedUnderlyingAmount) { - uint256 maxUnderlyingAmount = estimatedUnderlyingAmount.mulDiv( - BASIS_POINTS_FACTOR - liquidityOrchestrator.slippageTolerance(), - BASIS_POINTS_FACTOR - ); - if (receivedUnderlyingAmount < maxUnderlyingAmount) { - revert ErrorsLib.SlippageExceeded(vaultAsset, receivedUnderlyingAmount, estimatedUnderlyingAmount); - } - } - } - - /// @inheritdoc IExecutionAdapter - function buy( - address vaultAsset, - uint256 sharesAmount, - uint256 estimatedUnderlyingAmount - ) external override onlyLiquidityOrchestrator returns (uint256 spentUnderlyingAmount) { - if (sharesAmount == 0) revert ErrorsLib.AmountMustBeGreaterThanZero(vaultAsset); - // Atomically validate all order generation assumptions - _validateExecutionAdapter(vaultAsset); - - IERC4626 vault = IERC4626(vaultAsset); - - // Preview the required underlying amount for minting exact shares - uint256 previewedUnderlyingAmount = vault.previewMint(sharesAmount); - - if (previewedUnderlyingAmount > estimatedUnderlyingAmount) { - uint256 maxUnderlyingAmount = estimatedUnderlyingAmount.mulDiv( - BASIS_POINTS_FACTOR + liquidityOrchestrator.slippageTolerance(), - BASIS_POINTS_FACTOR - ); - if (previewedUnderlyingAmount > maxUnderlyingAmount) { - revert ErrorsLib.SlippageExceeded(vaultAsset, previewedUnderlyingAmount, estimatedUnderlyingAmount); - } - } - - // Pull previewed amount from the caller - underlyingAssetToken.safeTransferFrom(msg.sender, address(this), previewedUnderlyingAmount); - - // Approve vault to spend underlying assets - underlyingAssetToken.forceApprove(vaultAsset, previewedUnderlyingAmount); - - // Mint exact shares. Vault will pull the required underlying amount - // This guarantees sharesAmount shares are minted. - spentUnderlyingAmount = vault.mint(sharesAmount, address(this)); - // Some ERC4626 implementations may leave dust in the adapter; - // we accept that, as target shares are minted. - - // Clean up approval - underlyingAssetToken.forceApprove(vaultAsset, 0); - - // Push all minted shares to the caller (LO) - IERC20(vaultAsset).safeTransfer(msg.sender, sharesAmount); - } -} diff --git a/contracts/execution/UniswapV3ExecutionAdapter.sol b/contracts/execution/UniswapV3ExecutionAdapter.sol new file mode 100644 index 00000000..525630f2 --- /dev/null +++ b/contracts/execution/UniswapV3ExecutionAdapter.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: BUSL-1.1 +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 { ErrorsLib } from "../libraries/ErrorsLib.sol"; +import { ISwapRouter } from "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; +import { IQuoterV2 } from "@uniswap/v3-periphery/contracts/interfaces/IQuoterV2.sol"; +import { IUniswapV3Factory } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol"; +import { IExecutionAdapter } from "../interfaces/IExecutionAdapter.sol"; +import { IOrionConfig } from "../interfaces/IOrionConfig.sol"; +import { ILiquidityOrchestrator } from "../interfaces/ILiquidityOrchestrator.sol"; +import "@openzeppelin/contracts/access/Ownable2Step.sol"; + +/** + * @title UniswapV3ExecutionAdapter + * @notice Execution adapter for Uniswap V3 pools + * @author Orion Finance + * + * @dev amountOutMinimum is set to 0 at the Uniswap level because slippage protection is enforced + * by the LiquidityOrchestrator. This avoids duplicating slippage checks. + * + * @custom:security-contact security@orionfinance.ai + */ +contract UniswapV3ExecutionAdapter is IExecutionAdapter, Ownable2Step { + using SafeERC20 for IERC20; + + /// @notice Uniswap V3 Factory contract + IUniswapV3Factory public immutable UNISWAP_V3_FACTORY; + + /// @notice Uniswap V3 SwapRouter contract + ISwapRouter public immutable SWAP_ROUTER; + + /// @notice Uniswap V3 QuoterV2 contract + IQuoterV2 public immutable QUOTER; + + /// @notice Orion Config contract + IOrionConfig public immutable CONFIG; + + /// @notice Protocol underlying asset + address public immutable UNDERLYING_ASSET; + + /// @notice Liquidity orchestrator contract + ILiquidityOrchestrator public immutable LIQUIDITY_ORCHESTRATOR; + + /// @notice asset => Uniswap V3 pool fee tier + mapping(address => uint24) public assetFee; + + /// @dev Restricts function to only owner or guardian + modifier onlyOwnerOrGuardian() { + if (msg.sender != owner() && msg.sender != CONFIG.guardian()) { + revert ErrorsLib.NotAuthorized(); + } + _; + } + + /** + * @notice Constructor + * @param initialOwner_ The address of the initial owner + * @param factoryAddress Uniswap V3 Factory address + * @param swapRouterAddress Uniswap V3 SwapRouter address + * @param quoterAddress Uniswap V3 QuoterV2 address + * @param configAddress OrionConfig contract address + */ + constructor( + address initialOwner_, + address factoryAddress, + address swapRouterAddress, + address quoterAddress, + address configAddress + ) Ownable(initialOwner_) { + if (initialOwner_ == address(0)) revert ErrorsLib.ZeroAddress(); + if (factoryAddress == address(0)) revert ErrorsLib.ZeroAddress(); + if (swapRouterAddress == address(0)) revert ErrorsLib.ZeroAddress(); + if (quoterAddress == address(0)) revert ErrorsLib.ZeroAddress(); + if (configAddress == address(0)) revert ErrorsLib.ZeroAddress(); + + UNISWAP_V3_FACTORY = IUniswapV3Factory(factoryAddress); + SWAP_ROUTER = ISwapRouter(swapRouterAddress); + QUOTER = IQuoterV2(quoterAddress); + CONFIG = IOrionConfig(configAddress); + UNDERLYING_ASSET = address(CONFIG.underlyingAsset()); + LIQUIDITY_ORCHESTRATOR = ILiquidityOrchestrator(CONFIG.liquidityOrchestrator()); + } + + /// @notice Sets the fee tier for a given asset + /// @param asset The address of the asset + /// @param fee The fee tier to set + function setAssetFee(address asset, uint24 fee) external onlyOwnerOrGuardian { + if (asset == address(0)) revert ErrorsLib.ZeroAddress(); + + address pool = UNISWAP_V3_FACTORY.getPool(asset, address(UNDERLYING_ASSET), fee); + + if (pool == address(0)) revert ErrorsLib.InvalidAdapter(asset); + + assetFee[asset] = fee; + } + + /// @inheritdoc IExecutionAdapter + function validateExecutionAdapter(address asset) external view override { + if (assetFee[asset] == 0) revert ErrorsLib.InvalidAdapter(asset); + } + + /// @inheritdoc IExecutionAdapter + function previewBuy(address asset, uint256 amount) external override returns (uint256 underlyingAmount) { + // slither-disable-next-line unused-return + (underlyingAmount, , , ) = QUOTER.quoteExactOutputSingle( + IQuoterV2.QuoteExactOutputSingleParams({ + tokenIn: UNDERLYING_ASSET, + tokenOut: asset, + amount: amount, + fee: assetFee[asset], + sqrtPriceLimitX96: 0 + }) + ); + } + + /// @inheritdoc IExecutionAdapter + function sell(address asset, uint256 amount) external override returns (uint256 receivedAmount) { + // Pull input from caller + IERC20(asset).safeTransferFrom(msg.sender, address(this), amount); + + // Approve router + IERC20(asset).forceApprove(address(SWAP_ROUTER), amount); + + // Execute exact input swap + ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({ + tokenIn: asset, + tokenOut: UNDERLYING_ASSET, + fee: assetFee[asset], + recipient: msg.sender, + deadline: block.timestamp, + amountIn: amount, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }); + + receivedAmount = SWAP_ROUTER.exactInputSingle(params); + + // Clean up approval + IERC20(asset).forceApprove(address(SWAP_ROUTER), 0); + } + + /// @inheritdoc IExecutionAdapter + function buy(address asset, uint256 amount) external override returns (uint256 spentAmount) { + uint256 amountIn = this.previewBuy(asset, amount); + + // Pull approved amount from caller + IERC20(UNDERLYING_ASSET).safeTransferFrom(msg.sender, address(this), amountIn); + + IERC20(UNDERLYING_ASSET).forceApprove(address(SWAP_ROUTER), amountIn); + + // Execute exact output swap + ISwapRouter.ExactOutputSingleParams memory params = ISwapRouter.ExactOutputSingleParams({ + tokenIn: UNDERLYING_ASSET, + tokenOut: asset, + fee: assetFee[asset], + recipient: msg.sender, + deadline: block.timestamp, + amountOut: amount, + amountInMaximum: amountIn, + sqrtPriceLimitX96: 0 + }); + + spentAmount = SWAP_ROUTER.exactOutputSingle(params); + + IERC20(UNDERLYING_ASSET).forceApprove(address(SWAP_ROUTER), 0); + } +} diff --git a/contracts/interfaces/IExecutionAdapter.sol b/contracts/interfaces/IExecutionAdapter.sol index 46c109e6..c3f4adf8 100644 --- a/contracts/interfaces/IExecutionAdapter.sol +++ b/contracts/interfaces/IExecutionAdapter.sol @@ -16,25 +16,23 @@ interface IExecutionAdapter { /// @param asset The address of the asset to validate function validateExecutionAdapter(address asset) external view; + /// @notice Previews the underlying amount required to buy a given amount of an asset + /// @param asset The address of the asset to buy + /// @param sharesAmount The amount of asset shares to buy + /// @return underlyingAmount The underlying amount required + /// @dev Particularly useful in keeping execution adapters composable with each other, making refunding + /// unnecessary when higher-level adapters use the previewed amount for downstream buy() calls. + function previewBuy(address asset, uint256 sharesAmount) external returns (uint256 underlyingAmount); + /// @notice Executes a sell operation by converting asset shares to underlying assets /// @param asset The address of the asset to sell - /// @param sharesAmount The amount of shares to sell - /// @param estimatedUnderlyingAmount The estimated underlying amount to receive + /// @param sharesAmount The amount of asset shares to sell /// @return executionUnderlyingAmount The actual execution underlying amount received - function sell( - address asset, - uint256 sharesAmount, - uint256 estimatedUnderlyingAmount - ) external returns (uint256 executionUnderlyingAmount); + function sell(address asset, uint256 sharesAmount) external returns (uint256 executionUnderlyingAmount); /// @notice Executes a buy operation by converting underlying assets to asset shares /// @param asset The address of the asset to buy - /// @param sharesAmount The amount of shares to buy - /// @param estimatedUnderlyingAmount The estimated underlying amount to spend + /// @param sharesAmount The amount of asset shares to buy /// @return executionUnderlyingAmount The actual execution underlying amount spent - function buy( - address asset, - uint256 sharesAmount, - uint256 estimatedUnderlyingAmount - ) external returns (uint256 executionUnderlyingAmount); + function buy(address asset, uint256 sharesAmount) external returns (uint256 executionUnderlyingAmount); } diff --git a/contracts/interfaces/ILiquidityOrchestrator.sol b/contracts/interfaces/ILiquidityOrchestrator.sol index dbd903d5..53b78360 100644 --- a/contracts/interfaces/ILiquidityOrchestrator.sol +++ b/contracts/interfaces/ILiquidityOrchestrator.sol @@ -166,6 +166,11 @@ interface ILiquidityOrchestrator { /// @dev Can only be called by the Orion Config contract. function setExecutionAdapter(address asset, IExecutionAdapter adapter) external; + /// @notice Returns the execution adapter for a given asset + /// @param asset The address of the asset + /// @return The execution adapter for the asset + function executionAdapterOf(address asset) external view returns (IExecutionAdapter); + /// @notice Return deposit funds to a user who cancelled their deposit request /// @dev Called by vault contracts when users cancel deposit requests /// @param user The user to return funds to diff --git a/contracts/libraries/ErrorsLib.sol b/contracts/libraries/ErrorsLib.sol index 4349ff5d..4f381e98 100644 --- a/contracts/libraries/ErrorsLib.sol +++ b/contracts/libraries/ErrorsLib.sol @@ -81,6 +81,22 @@ library ErrorsLib { /// @param expected The expected value. error SlippageExceeded(address asset, uint256 actual, uint256 expected); + /// @notice Price data from oracle is stale or outdated. + /// @param asset The asset whose price feed is stale. + error StalePrice(address asset); + + /// @notice Price returned from oracle is invalid (zero, negative, or uninitialized). + /// @param asset The asset whose price is invalid. + /// @param price The invalid price value returned. + error InvalidPrice(address asset, int256 price); + + /// @notice Price is outside acceptable bounds. + /// @param asset The asset whose price is out of bounds. + /// @param price The price that exceeded bounds. + /// @param min The minimum acceptable price. + /// @param max The maximum acceptable price. + error PriceOutOfBounds(address asset, uint256 price, uint256 min, uint256 max); + /// @notice Thrown when the zk proof's commitment doesn't match the onchain commitment. /// @param proofCommitment The commitment from the zk proof. /// @param onchainCommitment The commitment from the onchain. diff --git a/contracts/mocks/MockPriceAdapter.sol b/contracts/mocks/MockPriceAdapter.sol deleted file mode 100644 index 140bd2ab..00000000 --- a/contracts/mocks/MockPriceAdapter.sol +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.28; - -import { IPriceAdapter } from "../interfaces/IPriceAdapter.sol"; - -/// @title Price Adapter mock -/// @notice One instance per asset. -contract MockPriceAdapter is IPriceAdapter { - // solhint-disable-next-line no-empty-blocks - constructor() {} - - /// @inheritdoc IPriceAdapter - function getPriceData(address) external pure returns (uint256 price, uint8 decimals) { - return (42, 14); - } - - /// @inheritdoc IPriceAdapter - function validatePriceAdapter(address) external pure { - // Mock adapter always validates successfully - } -} diff --git a/contracts/price/ChainlinkPriceAdapter.sol b/contracts/price/ChainlinkPriceAdapter.sol new file mode 100644 index 00000000..a8e8c74e --- /dev/null +++ b/contracts/price/ChainlinkPriceAdapter.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { IPriceAdapter } from "../interfaces/IPriceAdapter.sol"; +import { IOrionConfig } from "../interfaces/IOrionConfig.sol"; +import { ErrorsLib } from "../libraries/ErrorsLib.sol"; +import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; +import "@openzeppelin/contracts/access/Ownable2Step.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +/** + * @title ChainlinkPriceAdapter + * @notice Price adapter for assets using Chainlink oracle feeds + * @author Orion Finance + * + * @custom:security-contact security@orionfinance.ai + */ +contract ChainlinkPriceAdapter is IPriceAdapter, Ownable2Step { + /// @notice Feed configuration struct + struct FeedConfig { + address feed; // Chainlink aggregator address + bool isInverse; // Whether feed returns inverse pricing + uint256 maxStaleness; // Maximum acceptable staleness in seconds + uint256 minPrice; // Minimum acceptable price + uint256 maxPrice; // Maximum acceptable price + } + + /// @notice Mapping of asset to feed configuration + mapping(address => FeedConfig) public feedConfigOf; + + /// @notice Decimals used for inverse calculation + uint8 public constant INVERSE_DECIMALS = 18; + + /// @notice Emitted when a Chainlink feed is configured for an asset + /// @param asset The asset address + /// @param feed The Chainlink aggregator address + /// @param inverse Whether this feed returns inverse pricing + /// @param maxStaleness Maximum acceptable staleness in seconds + /// @param minPrice Minimum acceptable price + /// @param maxPrice Maximum acceptable price + event FeedConfigured( + address indexed asset, + address indexed feed, + bool indexed inverse, + uint256 maxStaleness, + uint256 minPrice, + uint256 maxPrice + ); + + /** + * @notice Constructor + */ + constructor() Ownable(msg.sender) {} + + /** + * @notice Configure Chainlink feed for an asset + * @param asset The asset address + * @param feed The Chainlink aggregator address + * @param inverse Whether this feed returns inverse pricing (e.g., USDC/ETH instead of ETH/USDC) + * @param _maxStaleness Maximum acceptable staleness in seconds (e.g., 3600 for 1 hour) + * @param _minPrice Minimum acceptable price (in feed decimals) + * @param _maxPrice Maximum acceptable price (in feed decimals) + * @dev Only owner can configure feeds + */ + function configureFeed( + address asset, + address feed, + bool inverse, + uint256 _maxStaleness, + uint256 _minPrice, + uint256 _maxPrice + ) external onlyOwner { + if (asset == address(0) || feed == address(0)) revert ErrorsLib.ZeroAddress(); + if (_maxStaleness == 0) revert ErrorsLib.InvalidArguments(); + if (_maxPrice == 0) revert ErrorsLib.InvalidArguments(); + if (_minPrice > _maxPrice) revert ErrorsLib.InvalidArguments(); + + // Validate feed is callable + // slither-disable-next-line unused-return + try AggregatorV3Interface(feed).decimals() returns (uint8) { + // solhint-disable-previous-line no-empty-blocks + } catch { + revert ErrorsLib.InvalidAdapter(asset); + } + + feedConfigOf[asset] = FeedConfig({ + feed: feed, + isInverse: inverse, + maxStaleness: _maxStaleness, + minPrice: _minPrice, + maxPrice: _maxPrice + }); + + emit FeedConfigured(asset, feed, inverse, _maxStaleness, _minPrice, _maxPrice); + } + + /// @inheritdoc IPriceAdapter + function validatePriceAdapter(address asset) external view override { + FeedConfig memory feedConfig = feedConfigOf[asset]; + if (feedConfig.feed == address(0)) revert ErrorsLib.InvalidAdapter(asset); + + // Verify feed is callable + // slither-disable-next-line unused-return + try AggregatorV3Interface(feedConfig.feed).decimals() returns (uint8) { + // Decimals retrieved successfully + } catch { + revert ErrorsLib.InvalidAdapter(asset); + } + } + + /// @inheritdoc IPriceAdapter + // solhint-disable-next-line code-complexity, function-max-lines, use-natspec + function getPriceData(address asset) external view override returns (uint256 price, uint8 decimals) { + FeedConfig memory feedConfig = feedConfigOf[asset]; + if (feedConfig.feed == address(0)) revert ErrorsLib.AdapterNotSet(); + + AggregatorV3Interface chainlinkFeed = AggregatorV3Interface(feedConfig.feed); + + // Fetch latest round data + (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = chainlinkFeed + .latestRoundData(); + + // Check 1: No zero or negative prices + if (answer < 1) revert ErrorsLib.InvalidPrice(asset, answer); + + // Check 2: Feed is initialized + if (updatedAt == 0) revert ErrorsLib.InvalidPrice(asset, answer); + + // Check 3: No future timestamps + if (startedAt > block.timestamp) revert ErrorsLib.InvalidPrice(asset, answer); + + // Check 4: Round id validity + if (answeredInRound < roundId) revert ErrorsLib.StalePrice(asset); + + // Check 5: Staleness + if (block.timestamp - updatedAt > feedConfig.maxStaleness) { + revert ErrorsLib.StalePrice(asset); + } + + uint256 rawPrice = uint256(answer); + uint8 feedDecimals = chainlinkFeed.decimals(); + + // Check 6: Price bounds + if (rawPrice < feedConfig.minPrice || rawPrice > feedConfig.maxPrice) { + revert ErrorsLib.PriceOutOfBounds(asset, rawPrice, feedConfig.minPrice, feedConfig.maxPrice); + } + + // Handle inverse feeds + if (feedConfig.isInverse) { + uint256 inversePrecision = 10 ** INVERSE_DECIMALS; + rawPrice = Math.mulDiv(inversePrecision, 10 ** feedDecimals, rawPrice); + feedDecimals = INVERSE_DECIMALS; + } + + return (rawPrice, feedDecimals); + } +} diff --git a/contracts/price/ERC4626PriceAdapter.sol b/contracts/price/ERC4626PriceAdapter.sol new file mode 100644 index 00000000..2b06ed71 --- /dev/null +++ b/contracts/price/ERC4626PriceAdapter.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import "../interfaces/IPriceAdapter.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { ErrorsLib } from "../libraries/ErrorsLib.sol"; +import { IOrionConfig } from "../interfaces/IOrionConfig.sol"; +import { IPriceAdapterRegistry } from "../interfaces/IPriceAdapterRegistry.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +/** + * @title ERC4626PriceAdapter + * @notice Price adapter for ERC-4626 vaults. + * @author Orion Finance + * + * @custom:security-contact security@orionfinance.ai + */ +contract ERC4626PriceAdapter is IPriceAdapter { + using Math for uint256; + + /// @notice Orion Config contract address + IOrionConfig public immutable CONFIG; + + /// @notice Price adapter registry for vault underlying asset prices + IPriceAdapterRegistry public immutable PRICE_REGISTRY; + + /// @notice Protocol underlying asset + IERC20Metadata public immutable UNDERLYING_ASSET; + + /// @notice Decimals of the protocol underlying + uint8 public immutable UNDERLYING_ASSET_DECIMALS; + + /// @notice Decimals of the price + uint8 public constant PRICE_DECIMALS = 10; + + /// @notice Constructor + /// @param configAddress The address of the OrionConfig contract + constructor(address configAddress) { + if (configAddress == address(0)) revert ErrorsLib.ZeroAddress(); + + CONFIG = IOrionConfig(configAddress); + PRICE_REGISTRY = IPriceAdapterRegistry(CONFIG.priceAdapterRegistry()); + UNDERLYING_ASSET = IERC20Metadata(address(CONFIG.underlyingAsset())); + UNDERLYING_ASSET_DECIMALS = IERC20Metadata(UNDERLYING_ASSET).decimals(); + } + + /// @inheritdoc IPriceAdapter + function validatePriceAdapter(address asset) external view { + try IERC4626(asset).asset() returns (address vaultUnderlying) { + if (!CONFIG.isWhitelisted(vaultUnderlying)) revert ErrorsLib.InvalidAdapter(asset); + } catch { + revert ErrorsLib.InvalidAdapter(asset); + } + } + + /// @inheritdoc IPriceAdapter + function getPriceData(address vaultAsset) external view returns (uint256 price, uint8 decimals) { + IERC4626 vault = IERC4626(vaultAsset); + address vaultUnderlying = vault.asset(); + + uint8 vaultAssetDecimals = IERC20Metadata(vaultAsset).decimals(); + uint256 precisionAmount = 10 ** (PRICE_DECIMALS + vaultAssetDecimals); + + // Floor rounding here, previewMint uses ceil in execution, + // buffer to deal with negligible truncation and rounding errors. + uint256 vaultUnderlyingAssetAmount = vault.convertToAssets(precisionAmount); + + if (vaultUnderlying == address(UNDERLYING_ASSET)) { + return (vaultUnderlyingAssetAmount, PRICE_DECIMALS + UNDERLYING_ASSET_DECIMALS); + } + + uint256 vaultUnderlyingPrice = PRICE_REGISTRY.getPrice(vaultUnderlying); + uint256 vaultPrice = vaultUnderlyingAssetAmount.mulDiv( + vaultUnderlyingPrice, + 10 ** CONFIG.priceAdapterDecimals() + ); + + return (vaultPrice, PRICE_DECIMALS + CONFIG.getTokenDecimals(vaultUnderlying)); + } +} diff --git a/contracts/price/OrionAssetERC4626PriceAdapter.sol b/contracts/price/OrionAssetERC4626PriceAdapter.sol deleted file mode 100644 index da2e31df..00000000 --- a/contracts/price/OrionAssetERC4626PriceAdapter.sol +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.28; - -import "../interfaces/IPriceAdapter.sol"; -import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import { ErrorsLib } from "../libraries/ErrorsLib.sol"; -import { IOrionConfig } from "../interfaces/IOrionConfig.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; - -/** - * @title OrionAssetERC4626PriceAdapter - * @notice Price adapter for ERC-4626 vaults sharing the same underlying asset as the Orion protocol. - * @author Orion Finance - * @dev This adapter assumes that the target vault and the Orion protocol use the same underlying asset. - * It is not safe to use this adapter with vaults that are based on a different asset. - * @custom:security-contact security@orionfinance.ai - */ -contract OrionAssetERC4626PriceAdapter is IPriceAdapter { - using Math for uint256; - - /// @notice Orion Config contract address - IOrionConfig public config; - - /// @notice Underlying asset address - address public underlyingAsset; - - /// @notice Decimals of the underlying asset - uint8 public underlyingAssetDecimals; - - /// @notice Decimals of the price - uint8 public constant PRICE_DECIMALS = 10; - - /// @notice Constructor - /// @param configAddress The address of the OrionConfig contract - constructor(address configAddress) { - if (configAddress == address(0)) revert ErrorsLib.ZeroAddress(); - - config = IOrionConfig(configAddress); - underlyingAsset = address(config.underlyingAsset()); - underlyingAssetDecimals = IERC20Metadata(underlyingAsset).decimals(); - } - - /// @inheritdoc IPriceAdapter - function validatePriceAdapter(address asset) external view { - try IERC4626(asset).asset() returns (address underlying) { - if (underlying != underlyingAsset) revert ErrorsLib.InvalidAdapter(asset); - } catch { - revert ErrorsLib.InvalidAdapter(asset); - } - } - - /// @inheritdoc IPriceAdapter - function getPriceData(address vaultAsset) external view returns (uint256 price, uint8 decimals) { - uint8 vaultAssetDecimals = IERC20Metadata(vaultAsset).decimals(); - uint256 precisionAmount = 10 ** (PRICE_DECIMALS + vaultAssetDecimals); - - // Floor rounding here, previewMint uses ceil in execution, - // buffer to deal with negligible truncation and rounding errors. - uint256 underlyingAssetAmount = IERC4626(vaultAsset).convertToAssets(precisionAmount); - - return (underlyingAssetAmount, PRICE_DECIMALS + underlyingAssetDecimals); - } -} diff --git a/contracts/test/LiquidityOrchestratorHarness.sol b/contracts/test/LiquidityOrchestratorHarness.sol new file mode 100644 index 00000000..e4d0d3a0 --- /dev/null +++ b/contracts/test/LiquidityOrchestratorHarness.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { LiquidityOrchestrator } from "../LiquidityOrchestrator.sol"; + +/** + * @title LiquidityOrchestratorHarness + * @notice Test harness that exposes internal slippage helper functions for direct testing + */ +contract LiquidityOrchestratorHarness is LiquidityOrchestrator { + function exposed_calculateMaxWithSlippage(uint256 estimatedAmount) external view returns (uint256) { + return _calculateMaxWithSlippage(estimatedAmount); + } + + function exposed_calculateMinWithSlippage(uint256 estimatedAmount) external view returns (uint256) { + return _calculateMinWithSlippage(estimatedAmount); + } +} diff --git a/contracts/mocks/MockERC4626Asset.sol b/contracts/test/MockERC4626Asset.sol similarity index 100% rename from contracts/mocks/MockERC4626Asset.sol rename to contracts/test/MockERC4626Asset.sol diff --git a/contracts/test/MockERC4626PriceAdapter.sol b/contracts/test/MockERC4626PriceAdapter.sol new file mode 100644 index 00000000..9fabdc43 --- /dev/null +++ b/contracts/test/MockERC4626PriceAdapter.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { IPriceAdapter } from "../interfaces/IPriceAdapter.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { IPriceAdapterRegistry } from "../interfaces/IPriceAdapterRegistry.sol"; +import { IOrionConfig } from "../interfaces/IOrionConfig.sol"; +import { ErrorsLib } from "../libraries/ErrorsLib.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +/** + * @title MockERC4626PriceAdapter + * @notice Mock price adapter for ERC4626 vaults for testing + * @author Orion Finance + * @dev Test-only adapter. Composes vault share → underlying → USDC pricing via oracle + * + * Pricing Flow: + * 1. Get vault share → underlying conversion rate (via ERC4626.convertToAssets) + * 2. Get underlying → USDC price (via PriceAdapterRegistry oracle) + * 3. Multiply: (underlying/share) × (USDC/underlying) = USDC/share + * + * Example (Cross-asset): + * - Vault: Yearn WETH (yvWETH) + * - 1 yvWETH = 1.05 WETH (vault appreciation) + * - 1 WETH = 3000 USDC (from Chainlink oracle) + * - Result: 1 yvWETH = 1.05 × 3000 = 3150 USDC + * + * Example (Same-asset): + * - Vault: USDC vault + * - 1 vault share = 1.02 USDC (vault appreciation) + * - 1 USDC = 1 USDC (identity pricing) + * - Result: 1 vault share = 1.02 USDC + * + * Security: + * - Validates underlying has price feed during registration + * - Uses protocol's price adapter decimals for normalization (14 decimals) + * - Handles arbitrary underlying decimals (WBTC=8, WETH=18, USDC=6, etc.) + * + * @custom:security-contact security@orionfinance.ai + */ +contract MockERC4626PriceAdapter is IPriceAdapter { + using Math for uint256; + + /// @notice Orion protocol configuration contract + IOrionConfig public immutable config; + + /// @notice Price adapter registry for underlying asset prices + IPriceAdapterRegistry public immutable priceRegistry; + + /// @notice Protocol underlying asset (USDC) + IERC20Metadata public immutable underlyingAsset; + + /// @notice Underlying asset decimals (6 for USDC) + uint8 public immutable underlyingDecimals; + + /// @notice Price adapter decimals for normalization (14) + uint8 public immutable priceAdapterDecimals; + + /** + * @notice Constructor + * @param configAddress OrionConfig contract address + */ + constructor(address configAddress) { + if (configAddress == address(0)) revert ErrorsLib.ZeroAddress(); + + config = IOrionConfig(configAddress); + priceRegistry = IPriceAdapterRegistry(config.priceAdapterRegistry()); + underlyingAsset = IERC20Metadata(address(config.underlyingAsset())); + underlyingDecimals = underlyingAsset.decimals(); + priceAdapterDecimals = config.priceAdapterDecimals(); + } + + /// @inheritdoc IPriceAdapter + function validatePriceAdapter(address asset) external view override { + // 1. Verify asset implements IERC4626 + address underlying = address(0); + try IERC4626(asset).asset() returns (address _underlying) { + underlying = _underlying; + if (underlying == address(0)) revert ErrorsLib.InvalidAdapter(asset); + + // Verify underlying is NOT the protocol underlying (use standard adapter for that) + if (underlying == address(underlyingAsset)) { + revert ErrorsLib.InvalidAdapter(asset); + } + } catch { + revert ErrorsLib.InvalidAdapter(asset); + } + + // 2. Verify underlying has a price feed registered + // This is CRITICAL - we need underlying → USDC pricing + // slither-disable-next-line unused-return + try priceRegistry.getPrice(underlying) returns (uint256) { + // Price feed exists and is callable + } catch { + revert ErrorsLib.InvalidAdapter(asset); + } + + // 3. Verify vault decimals are registered in config + try IERC20Metadata(asset).decimals() returns (uint8 decimals) { + if (decimals != config.getTokenDecimals(asset)) { + revert ErrorsLib.InvalidAdapter(asset); + } + } catch { + revert ErrorsLib.InvalidAdapter(asset); + } + } + + /// @inheritdoc IPriceAdapter + function getPriceData(address vaultAsset) external view override returns (uint256 price, uint8 decimals) { + IERC4626 vault = IERC4626(vaultAsset); + address underlying = vault.asset(); + + // Step 1: Get vault share → underlying conversion + // Calculate how much underlying per 1 vault share + uint8 vaultDecimals = IERC20Metadata(vaultAsset).decimals(); + uint256 oneShare = 10 ** vaultDecimals; + uint256 underlyingPerShare = vault.convertToAssets(oneShare); + + // Step 2: Get underlying → USDC price from oracle + // Price is already normalized to priceAdapterDecimals (14 decimals) + uint256 underlyingPriceInNumeraire = priceRegistry.getPrice(underlying); + + // Step 3: Compose prices + // Formula: (underlying/share) × (USDC/underlying) = USDC/share + // + // underlyingPerShare is in vault underlying decimals (e.g., 18 for WETH, 8 for WBTC) + // underlyingPriceInNumeraire is in priceAdapterDecimals + // Result should be in priceAdapterDecimals + // + // Example: + // - underlyingPerShare = 1.05e18 (WETH, 18 decimals) + // - underlyingPriceInNumeraire = 3000e14 (price in 14 decimals) + // - Result = (1.05e18 × 3000e14) / 1e18 = 3150e14 + uint8 vaultUnderlyingDecimals = IERC20Metadata(underlying).decimals(); + + uint256 priceInNumeraire = underlyingPerShare.mulDiv(underlyingPriceInNumeraire, 10 ** vaultUnderlyingDecimals); + + return (priceInNumeraire, priceAdapterDecimals); + } +} diff --git a/contracts/test/MockERC4626WithSettableDecimals.sol b/contracts/test/MockERC4626WithSettableDecimals.sol index a225c708..6b7ca29f 100644 --- a/contracts/test/MockERC4626WithSettableDecimals.sol +++ b/contracts/test/MockERC4626WithSettableDecimals.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.28; -import "../mocks/MockERC4626Asset.sol"; +import "./MockERC4626Asset.sol"; /** * @title MockERC4626WithSettableDecimals diff --git a/contracts/mocks/MockExecutionAdapter.sol b/contracts/test/MockExecutionAdapter.sol similarity index 64% rename from contracts/mocks/MockExecutionAdapter.sol rename to contracts/test/MockExecutionAdapter.sol index 1a7b8e37..cf9d0668 100644 --- a/contracts/mocks/MockExecutionAdapter.sol +++ b/contracts/test/MockExecutionAdapter.sol @@ -10,12 +10,17 @@ contract MockExecutionAdapter is IExecutionAdapter { constructor() {} /// @inheritdoc IExecutionAdapter - function buy(address, uint256, uint256) external pure returns (uint256 executionUnderlyingAmount) { + function previewBuy(address, uint256) external pure returns (uint256 underlyingAmount) { + underlyingAmount = 1e12; + } + + /// @inheritdoc IExecutionAdapter + function buy(address, uint256) external pure returns (uint256 executionUnderlyingAmount) { executionUnderlyingAmount = 1e12; } /// @inheritdoc IExecutionAdapter - function sell(address, uint256, uint256) external pure returns (uint256 executionUnderlyingAmount) { + function sell(address, uint256) external pure returns (uint256 executionUnderlyingAmount) { executionUnderlyingAmount = 1e12; } diff --git a/contracts/test/MockLiquidityOrchestrator.sol b/contracts/test/MockLiquidityOrchestrator.sol new file mode 100644 index 00000000..885c42dd --- /dev/null +++ b/contracts/test/MockLiquidityOrchestrator.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { IExecutionAdapter } from "../interfaces/IExecutionAdapter.sol"; + +/** + * @title MockLiquidityOrchestrator + * @notice Minimal mock of LiquidityOrchestrator for cross-asset E2E testing + * @dev Only implements methods needed for cross-asset execution adapter tests + */ +contract MockLiquidityOrchestrator { + address public config; + uint256 public slippageToleranceValue = 200; // 2% in basis points + + /// @notice Mapping of asset to execution adapter + mapping(address => IExecutionAdapter) public executionAdapterOf; + + constructor(address _config) { + config = _config; + } + + function slippageTolerance() external view returns (uint256) { + return slippageToleranceValue; + } + + function setSlippageTolerance(uint256 _tolerance) external { + slippageToleranceValue = _tolerance; + } + + /// @notice Set execution adapter for an asset + /// @param asset The asset address + /// @param adapter The execution adapter address + function setExecutionAdapter(address asset, address adapter) external { + executionAdapterOf[asset] = IExecutionAdapter(adapter); + } + + // Allow contract to receive ETH (needed for impersonation in tests) + receive() external payable {} +} diff --git a/contracts/test/MockOrionConfig.sol b/contracts/test/MockOrionConfig.sol new file mode 100644 index 00000000..f310be36 --- /dev/null +++ b/contracts/test/MockOrionConfig.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/** + * @title MockOrionConfig + * @notice Minimal mock of OrionConfig for cross-asset E2E testing + * @dev Only implements methods needed for cross-asset execution adapter tests + */ +contract MockOrionConfig { + address public immutable UNDERLYING_ASSET; + address public admin; + address public guardian; + address public liquidityOrchestrator; + address public priceAdapterRegistryAddress; + uint256 public slippageTolerance = 200; // 2% in basis points + mapping(address => uint8) private tokenDecimals; + /// @dev When true, return 0 for unset tokens (simulates real OrionConfig where unwhitelisted tokens have no entry) + bool public returnZeroForUnsetTokens; + + constructor(address _underlyingAsset) { + UNDERLYING_ASSET = _underlyingAsset; + admin = msg.sender; + liquidityOrchestrator = msg.sender; + } + + function underlyingAsset() external view returns (address) { + return UNDERLYING_ASSET; + } + + function priceAdapterRegistry() external view returns (address) { + return priceAdapterRegistryAddress; + } + + function getSlippageTolerance() external view returns (uint256) { + return slippageTolerance; + } + + function priceAdapterDecimals() external pure returns (uint8) { + return 14; // Protocol standard for price adapter decimals + } + + function getTokenDecimals(address token) external view returns (uint8) { + uint8 decimals = tokenDecimals[token]; + if (returnZeroForUnsetTokens && decimals == 0) return 0; + return decimals == 0 ? 18 : decimals; // Default to 18 if not set + } + + // Mock helpers for testing + function setSlippageTolerance(uint256 _tolerance) external { + slippageTolerance = _tolerance; + } + + function setLiquidityOrchestrator(address _orchestrator) external { + liquidityOrchestrator = _orchestrator; + } + + function setPriceAdapterRegistry(address _registry) external { + priceAdapterRegistryAddress = _registry; + } + + function setTokenDecimals(address token, uint8 decimals) external { + tokenDecimals[token] = decimals; + } + + function setReturnZeroForUnsetTokens(bool _returnZero) external { + returnZeroForUnsetTokens = _returnZero; + } + + function setGuardian(address _guardian) external { + guardian = _guardian; + } +} diff --git a/contracts/test/MockPriceAdapter.sol b/contracts/test/MockPriceAdapter.sol new file mode 100644 index 00000000..c0d8b085 --- /dev/null +++ b/contracts/test/MockPriceAdapter.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { IPriceAdapter } from "../interfaces/IPriceAdapter.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +/// @title Price Adapter mock +/// @notice One instance per asset. For ERC4626 vaults, returns actual exchange rate. For other assets, returns configurable or default prices. +contract MockPriceAdapter is IPriceAdapter { + /// @notice Configurable mock prices for non-ERC4626 assets + mapping(address => uint256) public mockPrices; + + /// @notice Default price for non-ERC4626 assets when not explicitly configured (14-decimal scaled) + uint256 public constant DEFAULT_MOCK_PRICE = 1e14; // 1.0 in 14 decimals + + /// @notice Maximum supported token decimals to prevent overflow in exponentiation + uint8 public constant MAX_DECIMALS = 36; + + // solhint-disable-next-line no-empty-blocks + constructor() {} + + /// @notice Set a deterministic mock price for a non-ERC4626 asset + /// @param asset The asset address + /// @param price The price in 14-decimal format + function setMockPrice(address asset, uint256 price) external { + mockPrices[asset] = price; + } + + /// @inheritdoc IPriceAdapter + function getPriceData(address asset) external view returns (uint256 price, uint8 decimals) { + // Check if asset is an ERC4626 vault + try IERC4626(asset).asset() returns (address) { + // It's an ERC4626 vault - return actual exchange rate + uint8 vaultDecimals = IERC20Metadata(asset).decimals(); + uint256 oneShare = 10 ** vaultDecimals; + uint256 underlyingPerShare = IERC4626(asset).convertToAssets(oneShare); + + // Convert to 14 decimals (priceAdapterDecimals) + uint8 underlyingDecimals = IERC20Metadata(IERC4626(asset).asset()).decimals(); + require(underlyingDecimals <= MAX_DECIMALS, "MockPriceAdapter: decimals too large"); + + if (underlyingDecimals < 14) { + price = underlyingPerShare * (10 ** (14 - underlyingDecimals)); + } else if (underlyingDecimals > 14) { + price = underlyingPerShare / (10 ** (underlyingDecimals - 14)); + } else { + price = underlyingPerShare; + } + + return (price, 14); + } catch { + // Not an ERC4626 vault - return configured or default price + uint256 configuredPrice = mockPrices[asset]; + price = configuredPrice > 0 ? configuredPrice : DEFAULT_MOCK_PRICE; + return (price, 14); + } + } + + /// @inheritdoc IPriceAdapter + function validatePriceAdapter(address) external pure { + // Mock adapter always validates successfully + } +} diff --git a/contracts/test/MockPriceAdapterRegistry.sol b/contracts/test/MockPriceAdapterRegistry.sol new file mode 100644 index 00000000..9ed2986e --- /dev/null +++ b/contracts/test/MockPriceAdapterRegistry.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { IPriceAdapterRegistry } from "../interfaces/IPriceAdapterRegistry.sol"; +import { IPriceAdapter } from "../interfaces/IPriceAdapter.sol"; + +/** + * @title MockPriceAdapterRegistry + * @notice Minimal mock registry for E2E testing + * @dev Maps assets to price adapters and returns normalized prices + */ +contract MockPriceAdapterRegistry is IPriceAdapterRegistry { + mapping(address => IPriceAdapter) public adapterOf; + + /// @inheritdoc IPriceAdapterRegistry + function setPriceAdapter(address asset, IPriceAdapter adapter) external override { + adapterOf[asset] = adapter; + } + + /// @inheritdoc IPriceAdapterRegistry + function getPrice(address asset) external view override returns (uint256) { + IPriceAdapter adapter = adapterOf[asset]; + require(address(adapter) != address(0), "No adapter set"); + + (uint256 price, ) = adapter.getPriceData(asset); + return price; + } +} diff --git a/contracts/mocks/MockUnderlyingAsset.sol b/contracts/test/MockUnderlyingAsset.sol similarity index 100% rename from contracts/mocks/MockUnderlyingAsset.sol rename to contracts/test/MockUnderlyingAsset.sol diff --git a/contracts/test/MockUniswapV3Factory.sol b/contracts/test/MockUniswapV3Factory.sol new file mode 100644 index 00000000..cf91f19e --- /dev/null +++ b/contracts/test/MockUniswapV3Factory.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/// @title MockUniswapV3Factory +/// @notice Minimal mock of IUniswapV3Factory for unit testing +contract MockUniswapV3Factory { + /// @notice (tokenA, tokenB, fee) => pool address + mapping(address => mapping(address => mapping(uint24 => address))) private pools; + + function setPool(address tokenA, address tokenB, uint24 fee, address pool) external { + pools[tokenA][tokenB][fee] = pool; + pools[tokenB][tokenA][fee] = pool; + } + + function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address) { + return pools[tokenA][tokenB][fee]; + } +} diff --git a/contracts/test/MockUniswapV3Quoter.sol b/contracts/test/MockUniswapV3Quoter.sol new file mode 100644 index 00000000..618b91d0 --- /dev/null +++ b/contracts/test/MockUniswapV3Quoter.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +/// @title MockUniswapV3Quoter +/// @notice Minimal mock of IQuoterV2 for unit testing +contract MockUniswapV3Quoter { + uint256 public nextAmountIn; + + struct QuoteExactOutputSingleParams { + address tokenIn; + address tokenOut; + uint256 amount; + uint24 fee; + uint160 sqrtPriceLimitX96; + } + + function setNextQuoteResult(uint256 _amountIn) external { + nextAmountIn = _amountIn; + } + + function quoteExactOutputSingle( + QuoteExactOutputSingleParams memory + ) + external + returns (uint256 amountIn, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate) + { + amountIn = nextAmountIn; + sqrtPriceX96After = 0; + initializedTicksCrossed = 0; + gasEstimate = 0; + } +} diff --git a/contracts/test/MockUniswapV3Router.sol b/contracts/test/MockUniswapV3Router.sol new file mode 100644 index 00000000..48f3f3ec --- /dev/null +++ b/contracts/test/MockUniswapV3Router.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title MockUniswapV3Router + * @notice Mock Uniswap V3 router for testing swap executors + * @dev Simulates both exact-input and exact-output swaps with configurable results + */ +contract MockUniswapV3Router { + using SafeERC20 for IERC20; + + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + struct ExactOutputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + uint160 sqrtPriceLimitX96; + } + + // Test configuration + uint256 public nextAmountIn; + uint256 public nextAmountOut; + bool public shouldRevert; + + function setNextSwapResult(uint256 _amountIn, uint256 _amountOut) external { + nextAmountIn = _amountIn; + nextAmountOut = _amountOut; + } + + function setShouldRevert(bool _shouldRevert) external { + shouldRevert = _shouldRevert; + } + + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut) { + if (shouldRevert) revert("Mock revert"); + + // Pull exact input from caller + IERC20(params.tokenIn).safeTransferFrom(msg.sender, address(this), params.amountIn); + + // Return configured output + amountOut = nextAmountOut; + + // Mint output tokens to recipient (simplified - in real router, pulls from pool) + _mintOrTransfer(params.tokenOut, params.recipient, amountOut); + + // Check minimum output + require(amountOut >= params.amountOutMinimum, "Insufficient output"); + } + + function exactOutputSingle(ExactOutputSingleParams calldata params) external payable returns (uint256 amountIn) { + if (shouldRevert) revert("Mock revert"); + + // Use configured input amount + amountIn = nextAmountIn; + + // Check maximum input + require(amountIn <= params.amountInMaximum, "Excessive input"); + + // Pull actual input from caller + IERC20(params.tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); + + // Mint exact output tokens to recipient + _mintOrTransfer(params.tokenOut, params.recipient, params.amountOut); + } + + function _mintOrTransfer(address token, address to, uint256 amount) internal { + // Try to transfer existing balance, otherwise mint + uint256 balance = IERC20(token).balanceOf(address(this)); + if (balance >= amount) { + IERC20(token).safeTransfer(to, amount); + } else { + // Assume token has mint function (for testing) + (bool success, ) = token.call(abi.encodeWithSignature("mint(address,uint256)", to, amount)); + require(success, "Mint failed"); + } + } +} diff --git a/contracts/mocks/MockZeroPriceAdapter.sol b/contracts/test/MockZeroPriceAdapter.sol similarity index 100% rename from contracts/mocks/MockZeroPriceAdapter.sol rename to contracts/test/MockZeroPriceAdapter.sol diff --git a/contracts/test/SpyExecutionAdapter.sol b/contracts/test/SpyExecutionAdapter.sol new file mode 100644 index 00000000..1efef7c1 --- /dev/null +++ b/contracts/test/SpyExecutionAdapter.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import { IExecutionAdapter } from "../interfaces/IExecutionAdapter.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @title SpyExecutionAdapter +/// @notice Mock that records previewBuy/buy values to verify atomic consistency +contract SpyExecutionAdapter is IExecutionAdapter { + using SafeERC20 for IERC20; + + IERC20 public immutable UNDERLYING; + + /// @notice The value previewBuy will return (set by test) + uint256 public previewBuyReturn; + + /// @notice Recorded values from the last buy() call + uint256 public lastBuyAllowanceReceived; + uint256 public lastPreviewBuyResult; + + event PreviewBuyCalled(uint256 result); + event BuyCalled(uint256 underlyingReceived, uint256 underlyingSpent); + + constructor(address underlying_) { + UNDERLYING = IERC20(underlying_); + } + + /// @notice Set the value previewBuy should return + function setPreviewBuyReturn(uint256 amount) external { + previewBuyReturn = amount; + } + + /// @inheritdoc IExecutionAdapter + function previewBuy(address, uint256) external returns (uint256 underlyingAmount) { + underlyingAmount = previewBuyReturn; + lastPreviewBuyResult = underlyingAmount; + emit PreviewBuyCalled(underlyingAmount); + } + + /// @inheritdoc IExecutionAdapter + function buy(address asset, uint256 amount) external returns (uint256 executionUnderlyingAmount) { + // Record how much underlying was actually transferred to us + lastBuyAllowanceReceived = UNDERLYING.allowance(msg.sender, address(this)); + + // Pull the underlying from caller + UNDERLYING.safeTransferFrom(msg.sender, address(this), lastBuyAllowanceReceived); + executionUnderlyingAmount = lastBuyAllowanceReceived; + + // Mint the requested output token to the caller (simulate swap) + // We need to transfer `amount` of the asset token to msg.sender + // For testing, we just transfer whatever asset tokens we hold + uint256 assetBalance = IERC20(asset).balanceOf(address(this)); + if (assetBalance >= amount) { + IERC20(asset).safeTransfer(msg.sender, amount); + } + + emit BuyCalled(lastBuyAllowanceReceived, executionUnderlyingAmount); + } + + /// @inheritdoc IExecutionAdapter + function sell(address, uint256) external pure returns (uint256 executionUnderlyingAmount) { + executionUnderlyingAmount = 0; + } + + /// @inheritdoc IExecutionAdapter + // solhint-disable-next-line no-empty-blocks + function validateExecutionAdapter(address) external pure {} +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 60342aab..d76d34f3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,8 @@ export default defineConfig([ "**/yarn.lock", "**/.solcover.js", "**/eslint.config.mjs", + "protocol-costs", + "protocol-ops", ]), // JavaScript files configuration diff --git a/hardhat.config.ts b/hardhat.config.ts index d1f0d696..13871494 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -15,7 +15,7 @@ const config: HardhatUserConfig = { docgen: { outputDir: "./docs/", pages: "files", - exclude: ["mocks", "test"], + exclude: ["test"], }, solidity: { @@ -38,6 +38,18 @@ const config: HardhatUserConfig = { hardhat: { chainId: 31337, initialBaseFeePerGas: 0, + // Fork mainnet when: + // 1. FORK_MAINNET=true (explicit forking for crossAsset tests) + // 2. SOLIDITY_COVERAGE=true (coverage needs forking for crossAsset tests) + ...((process.env.FORK_MAINNET === "true" || process.env.SOLIDITY_COVERAGE === "true") && + process.env.MAINNET_RPC_URL + ? { + forking: { + url: process.env.MAINNET_RPC_URL, + blockNumber: 24490214, + }, + } + : {}), }, localhost: { url: "http://127.0.0.1:8545", @@ -45,10 +57,10 @@ const config: HardhatUserConfig = { gasPrice: 2_000_000_000, }, - ...(!isCoverage + ...(!isCoverage && (process.env.SEPOLIA_RPC_URL ?? process.env.RPC_URL) ? { sepolia: { - url: process.env.RPC_URL!, + url: process.env.SEPOLIA_RPC_URL ?? process.env.RPC_URL!, accounts: [process.env.DEPLOYER_PRIVATE_KEY!, process.env.LP_PRIVATE_KEY!], chainId: 11155111, }, diff --git a/package.json b/package.json index a7634f45..61e4e0dd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@orion-finance/protocol", "description": "Orion Finance Protocol", - "version": "2.0.2", + "version": "2.1.0", "engines": { "node": ">=20.0.0" }, @@ -28,7 +28,7 @@ "test:sepolia": "hardhat test --network sepolia", "prettier:check": "prettier --check \"**/*.{js,json,md,sol,ts,yml}\"", "prettier:write": "prettier --write \"**/*.{js,json,md,sol,ts,yml}\"", - "slither": "slither . --filter-paths 'lib|node_modules|dependencies|contracts/mocks' --fail-medium", + "slither": "slither . --filter-paths 'lib|node_modules|dependencies|test' --fail-medium", "verify:vault": "hardhat run protocol-ops/verify-vault.ts --network sepolia", "storage-layout": "hardhat run scripts/check-storage-layout.ts" }, @@ -80,6 +80,8 @@ "@fhevm/solidity": "^0.10.0", "@openzeppelin/contracts": "^5.4.0", "@openzeppelin/contracts-upgradeable": "^5.4.0", + "@uniswap/v3-core": "^1.0.1", + "@uniswap/v3-periphery": "^1.4.4", "encrypted-types": "^0.0.4" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a024913f..851ed417 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: '@openzeppelin/contracts-upgradeable': specifier: ^5.4.0 version: 5.4.0(@openzeppelin/contracts@5.4.0) + '@uniswap/v3-core': + specifier: ^1.0.1 + version: 1.0.1 + '@uniswap/v3-periphery': + specifier: ^1.4.4 + version: 1.4.4 encrypted-types: specifier: ^0.0.4 version: 0.0.4 @@ -525,6 +531,14 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.1': + resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} + engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -735,6 +749,9 @@ packages: peerDependencies: '@openzeppelin/contracts': 5.4.0 + '@openzeppelin/contracts@3.4.2-solc-0.7': + resolution: {integrity: sha512-W6QmqgkADuFcTLzHL8vVoNBtkwjvQRpYIAom7KiUNoLKghyx3FgH0GBjt8NRvigV1ZmMOBllvE1By1C+bi8WpA==} + '@openzeppelin/contracts@5.1.0': resolution: {integrity: sha512-p1ULhl7BXzjjbha5aqst+QMLY+4/LCWADXOCsmLHRM77AqiPjnd9vvUN9sosUfhL9JGKpZ0TjEGxgvnizmWGSA==} @@ -1215,6 +1232,22 @@ packages: resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@uniswap/lib@4.0.1-alpha': + resolution: {integrity: sha512-f6UIliwBbRsgVLxIaBANF6w09tYqc6Y/qXdsrbEmXHyFA7ILiKrIwRFXe1yOg8M3cksgVsO9N7yuL2DdCGQKBA==} + engines: {node: '>=10'} + + '@uniswap/v2-core@1.0.1': + resolution: {integrity: sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q==} + engines: {node: '>=10'} + + '@uniswap/v3-core@1.0.1': + resolution: {integrity: sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ==} + engines: {node: '>=10'} + + '@uniswap/v3-periphery@1.4.4': + resolution: {integrity: sha512-S4+m+wh8HbWSO3DKk4LwUCPZJTpCugIsHrWR86m/OrUyvSqGDTXKFfc2sMuGXCZrD1ZqO3rhQsKgdWg3Hbb2Kw==} + engines: {node: '>=10'} + '@zama-fhe/oracle-solidity@0.2.0': resolution: {integrity: sha512-C13JGdvCisZJefV3jGiuNcsdxSmoDr5HLXt7yw6zPe9qYGjSUXpnCwH2LTsdQZqHN0RIKmo5Wt2yuaxL4zjEeg==} engines: {node: '>=22'} @@ -1377,16 +1410,15 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.3: - resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} - engines: {node: 20 || >=22} - base-x@3.0.11: resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64-sol@1.0.1: + resolution: {integrity: sha512-ld3cCNMeXt4uJXmLZBHFGMvVpK9KsLVEhPpFRXnvSVAqABKbuNZg/+dsq3NuM+wxFLb/UrVkz7m1ciWmkMfTbg==} + bech32@1.1.4: resolution: {integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==} @@ -1428,10 +1460,6 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.2: - resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} - engines: {node: 20 || >=22} - braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2517,8 +2545,8 @@ packages: minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} - minimatch@10.2.1: - resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==} + minimatch@10.1.2: + resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} minimatch@3.1.2: @@ -4355,6 +4383,12 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.1': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4585,6 +4619,8 @@ snapshots: dependencies: '@openzeppelin/contracts': 5.4.0 + '@openzeppelin/contracts@3.4.2-solc-0.7': {} + '@openzeppelin/contracts@5.1.0': {} '@openzeppelin/contracts@5.4.0': {} @@ -5189,7 +5225,7 @@ snapshots: '@types/minimatch@6.0.0': dependencies: - minimatch: 10.2.1 + minimatch: 10.1.2 '@types/mkdirp@0.5.2': dependencies: @@ -5312,6 +5348,20 @@ snapshots: '@typescript-eslint/types': 8.54.0 eslint-visitor-keys: 4.2.1 + '@uniswap/lib@4.0.1-alpha': {} + + '@uniswap/v2-core@1.0.1': {} + + '@uniswap/v3-core@1.0.1': {} + + '@uniswap/v3-periphery@1.4.4': + dependencies: + '@openzeppelin/contracts': 3.4.2-solc-0.7 + '@uniswap/lib': 4.0.1-alpha + '@uniswap/v2-core': 1.0.1 + '@uniswap/v3-core': 1.0.1 + base64-sol: 1.0.1 + '@zama-fhe/oracle-solidity@0.2.0(@nomicfoundation/hardhat-ethers@3.1.3(ethers@6.16.0)(hardhat@2.28.4(ts-node@10.9.2(@types/node@25.2.2)(typescript@5.9.3))(typescript@5.9.3)))(@nomicfoundation/hardhat-verify@2.1.3(hardhat@2.28.4(ts-node@10.9.2(@types/node@25.2.2)(typescript@5.9.3))(typescript@5.9.3)))(@openzeppelin/defender-deploy-client-cli@0.0.1-alpha.10)(@openzeppelin/upgrades-core@1.44.2)(ts-node@10.9.2(@types/node@25.2.2)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@fhevm/solidity': 0.8.0 @@ -5491,14 +5541,14 @@ snapshots: balanced-match@1.0.2: {} - balanced-match@4.0.3: {} - base-x@3.0.11: dependencies: safe-buffer: 5.2.1 base64-js@1.5.1: {} + base64-sol@1.0.1: {} + bech32@1.1.4: {} better-ajv-errors@2.0.3(ajv@6.12.6): @@ -5544,10 +5594,6 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.2: - dependencies: - balanced-match: 4.0.3 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -6285,7 +6331,7 @@ snapshots: glob@13.0.1: dependencies: - minimatch: 10.2.1 + minimatch: 10.1.2 minipass: 7.1.2 path-scurry: 2.0.1 @@ -6806,9 +6852,9 @@ snapshots: minimalistic-crypto-utils@1.0.1: {} - minimatch@10.2.1: + minimatch@10.1.2: dependencies: - brace-expansion: 5.0.2 + '@isaacs/brace-expansion': 5.0.1 minimatch@3.1.2: dependencies: diff --git a/scripts/postprocess-docs.sh b/scripts/postprocess-docs.sh index b0cbc80e..5c4a8200 100755 --- a/scripts/postprocess-docs.sh +++ b/scripts/postprocess-docs.sh @@ -35,6 +35,18 @@ find "$DOCS_DIR" -name "*.md" -type f | while read -r file; do } { + # Convert angle-bracketed URLs to markdown links + while (match($0, /]+>/)) { + url = substr($0, RSTART + 1, RLENGTH - 2) # Extract URL without <> + # Extract last path segment (after last /) + n = split(url, parts, "/") + text = parts[n] + # Convert hyphens to spaces + gsub(/-/, " ", text) + # Replace with [text](url) + $0 = substr($0, 1, RSTART - 1) "[" text "](" url ")" substr($0, RSTART + RLENGTH) + } + # Clean up extra spaces gsub(/[[:space:]]+/, " ", $0) gsub(/^[[:space:]]+/, "", $0) diff --git a/test/Accounting.test.ts b/test/Accounting.test.ts index cefe6f8d..df298208 100644 --- a/test/Accounting.test.ts +++ b/test/Accounting.test.ts @@ -118,7 +118,7 @@ describe("OrionVault Accounting", function () { const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); const priceAdapter = await MockPriceAdapterFactory.deploy(); await priceAdapter.waitForDeployment(); - const ExecutionAdapterFactory = await ethers.getContractFactory("OrionAssetERC4626ExecutionAdapter"); + const ExecutionAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); const executionAdapter = await ExecutionAdapterFactory.deploy(await orionConfig.getAddress()); await executionAdapter.waitForDeployment(); diff --git a/test/Adapters.test.ts b/test/Adapters.test.ts deleted file mode 100644 index a66937b0..00000000 --- a/test/Adapters.test.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; -import { expect } from "chai"; -import "@openzeppelin/hardhat-upgrades"; -import { ethers } from "hardhat"; - -import { - LiquidityOrchestrator, - MockERC4626Asset, - MockPriceAdapter, - MockUnderlyingAsset, - OrionAssetERC4626ExecutionAdapter, - OrionAssetERC4626PriceAdapter, - OrionConfig, -} from "../typechain-types"; -import { deployUpgradeableProtocol } from "./helpers/deployUpgradeable"; -import { resetNetwork } from "./helpers/resetNetwork"; - -describe("Price Adapter", function () { - let orionConfig: OrionConfig; - - before(async function () { - await resetNetwork(); - }); - let underlyingAsset: MockUnderlyingAsset; - let mockAsset1: MockERC4626Asset; - let priceAdapter: OrionAssetERC4626PriceAdapter; - let liquidityOrchestrator: LiquidityOrchestrator; - - let owner: SignerWithAddress; - let automationRegistry: SignerWithAddress; - let nonOwner: SignerWithAddress; - - beforeEach(async function () { - [owner, automationRegistry, nonOwner] = await ethers.getSigners(); - - const deployed = await deployUpgradeableProtocol(owner, undefined, automationRegistry); - - underlyingAsset = deployed.underlyingAsset; - orionConfig = deployed.orionConfig; - liquidityOrchestrator = deployed.liquidityOrchestrator; - - // Deploy a regular ERC20 (not ERC4626) for adapter validation tests - const MockERC20AssetFactory = await ethers.getContractFactory("MockUnderlyingAsset"); - const mockAsset1Deployed = await MockERC20AssetFactory.deploy(10); - await mockAsset1Deployed.waitForDeployment(); - // Note: This is intentionally a plain ERC20 to test adapter rejection - mockAsset1 = mockAsset1Deployed as unknown as MockERC4626Asset; // Used to test InvalidAdapter errors - - // Deploy price adapter for tests - const OrionAssetERC4626PriceAdapterFactory = await ethers.getContractFactory("OrionAssetERC4626PriceAdapter"); - priceAdapter = (await OrionAssetERC4626PriceAdapterFactory.deploy( - await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626PriceAdapter; - await priceAdapter.waitForDeployment(); - }); - - describe("addWhitelistedAsset", function () { - it("should revert with InvalidAdapter when trying to whitelist a regular ERC20 token with ERC4626 price adapter", async function () { - const MockExecutionAdapterFactory = await ethers.getContractFactory("MockExecutionAdapter"); - const mockExecutionAdapter = await MockExecutionAdapterFactory.deploy(); - await mockExecutionAdapter.waitForDeployment(); - - await expect( - orionConfig.addWhitelistedAsset( - await mockAsset1.getAddress(), - await priceAdapter.getAddress(), - await mockExecutionAdapter.getAddress(), - ), - ).to.be.revertedWithCustomError(priceAdapter, "InvalidAdapter"); - }); - - it("should revert with InvalidAdapter when trying to whitelist a regular ERC20 token with ERC4626 execution adapter", async function () { - const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); - const mockPriceAdapter = await MockPriceAdapterFactory.deploy(); - await mockPriceAdapter.waitForDeployment(); - - const OrionAssetERC4626ExecutionAdapterFactory = await ethers.getContractFactory( - "OrionAssetERC4626ExecutionAdapter", - ); - const erc4626ExecutionAdapter = await OrionAssetERC4626ExecutionAdapterFactory.deploy( - await orionConfig.getAddress(), - ); - await erc4626ExecutionAdapter.waitForDeployment(); - - await expect( - orionConfig.addWhitelistedAsset( - await mockAsset1.getAddress(), - await mockPriceAdapter.getAddress(), - await erc4626ExecutionAdapter.getAddress(), - ), - ).to.be.revertedWithCustomError(erc4626ExecutionAdapter, "InvalidAdapter"); - }); - - it("should revert with InvalidAdapter when trying to whitelist an ERC4626 with different underlying asset using ERC4626 price adapter", async function () { - const MockExecutionAdapterFactory = await ethers.getContractFactory("MockExecutionAdapter"); - const mockExecutionAdapter = await MockExecutionAdapterFactory.deploy(); - await mockExecutionAdapter.waitForDeployment(); - - const MockUnderlyingAssetFactory = await ethers.getContractFactory("MockUnderlyingAsset"); - const differentUnderlyingAsset = await MockUnderlyingAssetFactory.deploy(18); - await differentUnderlyingAsset.waitForDeployment(); - - const MockERC4626AssetFactory = await ethers.getContractFactory("MockERC4626Asset"); - const erc4626Vault = await MockERC4626AssetFactory.deploy( - await differentUnderlyingAsset.getAddress(), - "Test Vault", - "TV", - ); - await erc4626Vault.waitForDeployment(); - - await expect( - orionConfig.addWhitelistedAsset( - await erc4626Vault.getAddress(), - await priceAdapter.getAddress(), - await mockExecutionAdapter.getAddress(), - ), - ).to.be.revertedWithCustomError(priceAdapter, "InvalidAdapter"); - }); - - it("should revert when non-owner tries to call addWhitelistedAsset", async function () { - const MockExecutionAdapterFactory = await ethers.getContractFactory("MockExecutionAdapter"); - const mockExecutionAdapter = await MockExecutionAdapterFactory.deploy(); - await mockExecutionAdapter.waitForDeployment(); - - const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); - const mockPriceAdapter = await MockPriceAdapterFactory.deploy(); - await mockPriceAdapter.waitForDeployment(); - - await expect( - orionConfig - .connect(nonOwner) - .addWhitelistedAsset( - await mockAsset1.getAddress(), - await mockPriceAdapter.getAddress(), - await mockExecutionAdapter.getAddress(), - ), - ).to.be.revertedWithCustomError(orionConfig, "OwnableUnauthorizedAccount"); - }); - - it("should revert with InvalidAdapter when trying to whitelist an ERC4626 with different underlying asset using ERC4626 execution adapter", async function () { - const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); - const mockPriceAdapter = await MockPriceAdapterFactory.deploy(); - await mockPriceAdapter.waitForDeployment(); - - const OrionAssetERC4626ExecutionAdapterFactory = await ethers.getContractFactory( - "OrionAssetERC4626ExecutionAdapter", - ); - const erc4626ExecutionAdapter = await OrionAssetERC4626ExecutionAdapterFactory.deploy( - await orionConfig.getAddress(), - ); - await erc4626ExecutionAdapter.waitForDeployment(); - - const MockUnderlyingAssetFactory = await ethers.getContractFactory("MockUnderlyingAsset"); - const differentUnderlyingAsset = await MockUnderlyingAssetFactory.deploy(18); - await differentUnderlyingAsset.waitForDeployment(); - - const MockERC4626AssetFactory = await ethers.getContractFactory("MockERC4626Asset"); - const erc4626Vault = await MockERC4626AssetFactory.deploy( - await differentUnderlyingAsset.getAddress(), - "Test Vault", - "TV", - ); - await erc4626Vault.waitForDeployment(); - - await expect( - orionConfig.addWhitelistedAsset( - await erc4626Vault.getAddress(), - await mockPriceAdapter.getAddress(), - await erc4626ExecutionAdapter.getAddress(), - ), - ).to.be.revertedWithCustomError(erc4626ExecutionAdapter, "InvalidAdapter"); - }); - - it("should revert with InvalidAdapter when asset is whitelisted then decimals are modified and validateExecutionAdapter is called", async function () { - const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); - const mockPriceAdapter = await MockPriceAdapterFactory.deploy(); - await mockPriceAdapter.waitForDeployment(); - - const OrionAssetERC4626ExecutionAdapterFactory = await ethers.getContractFactory( - "OrionAssetERC4626ExecutionAdapter", - ); - const erc4626ExecutionAdapter = await OrionAssetERC4626ExecutionAdapterFactory.deploy( - await orionConfig.getAddress(), - ); - await erc4626ExecutionAdapter.waitForDeployment(); - - const MockERC4626WithSettableDecimalsFactory = await ethers.getContractFactory("MockERC4626WithSettableDecimals"); - const vaultWithSettableDecimals = await MockERC4626WithSettableDecimalsFactory.deploy( - await underlyingAsset.getAddress(), - "Settable Decimals Vault", - "SDV", - ); - await vaultWithSettableDecimals.waitForDeployment(); - - const initialDeposit = ethers.parseUnits("10000", 6); - await underlyingAsset.mint(owner.address, initialDeposit); - await underlyingAsset.approve(await vaultWithSettableDecimals.getAddress(), initialDeposit); - await vaultWithSettableDecimals.deposit(initialDeposit, owner.address); - - await orionConfig.addWhitelistedAsset( - await vaultWithSettableDecimals.getAddress(), - await mockPriceAdapter.getAddress(), - await erc4626ExecutionAdapter.getAddress(), - ); - - await expect(erc4626ExecutionAdapter.validateExecutionAdapter(await vaultWithSettableDecimals.getAddress())).to - .not.be.reverted; - - await vaultWithSettableDecimals.setDecimals(18); - - await expect( - erc4626ExecutionAdapter.validateExecutionAdapter(await vaultWithSettableDecimals.getAddress()), - ).to.be.revertedWithCustomError(erc4626ExecutionAdapter, "InvalidAdapter"); - }); - }); - - describe("ERC4626 Execution Adapter - Share Accounting", function () { - let erc4626ExecutionAdapter: OrionAssetERC4626ExecutionAdapter; - let erc4626Vault: MockERC4626Asset; - let mockPriceAdapter: MockPriceAdapter; - - beforeEach(async function () { - const OrionAssetERC4626ExecutionAdapterFactory = await ethers.getContractFactory( - "OrionAssetERC4626ExecutionAdapter", - ); - erc4626ExecutionAdapter = (await OrionAssetERC4626ExecutionAdapterFactory.deploy( - await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626ExecutionAdapter; - await erc4626ExecutionAdapter.waitForDeployment(); - - const MockERC4626AssetFactory = await ethers.getContractFactory("MockERC4626Asset"); - erc4626Vault = (await MockERC4626AssetFactory.deploy( - await underlyingAsset.getAddress(), - "Test Vault", - "TV", - )) as unknown as MockERC4626Asset; - await erc4626Vault.waitForDeployment(); - - // Seed vault with assets so totalAssets > 0 for validation - const initialDeposit = ethers.parseUnits("100000", 12); - await underlyingAsset.mint(owner.address, initialDeposit); - await underlyingAsset.approve(await erc4626Vault.getAddress(), initialDeposit); - await erc4626Vault.deposit(initialDeposit, owner.address); - - // Deploy mock price adapter - const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); - mockPriceAdapter = await MockPriceAdapterFactory.deploy(); - await mockPriceAdapter.waitForDeployment(); - - // Whitelist the vault to set decimals in config - await orionConfig.addWhitelistedAsset( - await erc4626Vault.getAddress(), - await mockPriceAdapter.getAddress(), - await erc4626ExecutionAdapter.getAddress(), - ); - - // Set slippage tolerance to avoid uint256.max maxAcceptableSpend - await liquidityOrchestrator.setTargetBufferRatio(400); // 4% buffer - await liquidityOrchestrator.setSlippageTolerance(200); // 2% slippage - }); - - it("should mint exact shares requested via buy(), preventing accounting drift", async function () { - const sharesTarget = ethers.parseUnits("1000", 12); - const underlyingAmount = ethers.parseUnits("10000", 12); - - // Mint underlying to LO - await underlyingAsset.mint(await liquidityOrchestrator.getAddress(), underlyingAmount); - - // Impersonate LO to call buy - await ethers.provider.send("hardhat_impersonateAccount", [await liquidityOrchestrator.getAddress()]); - const loSigner = await ethers.getSigner(await liquidityOrchestrator.getAddress()); - - // Set ETH balance for LO for gas - await ethers.provider.send("hardhat_setBalance", [ - await liquidityOrchestrator.getAddress(), - ethers.toQuantity(ethers.parseEther("1.0")), - ]); - - // Approve adapter from LO with max allowance - await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), ethers.MaxUint256); - - const balanceBefore = await erc4626Vault.balanceOf(await liquidityOrchestrator.getAddress()); - - // Execute buy as LO - await erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesTarget, underlyingAmount); - - // Verify exact shares were minted - const balanceAfter = await erc4626Vault.balanceOf(await liquidityOrchestrator.getAddress()); - const sharesMinted = balanceAfter - balanceBefore; - - expect(sharesMinted).to.equal(sharesTarget, "Shares minted must exactly match shares requested"); - - await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); - }); - - it("should return excess underlying when previewMint overestimates", async function () { - const sharesTarget = ethers.parseUnits("1000", 12); - const underlyingAmount = ethers.parseUnits("10000", 12); - - // Mint underlying to LO - await underlyingAsset.mint(await liquidityOrchestrator.getAddress(), underlyingAmount); - - // Impersonate LO - await ethers.provider.send("hardhat_impersonateAccount", [await liquidityOrchestrator.getAddress()]); - const loSigner = await ethers.getSigner(await liquidityOrchestrator.getAddress()); - - // Set ETH balance for LO for gas - await ethers.provider.send("hardhat_setBalance", [ - await liquidityOrchestrator.getAddress(), - ethers.toQuantity(ethers.parseEther("1.0")), - ]); - - await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), ethers.MaxUint256); - - const underlyingBalanceBefore = await underlyingAsset.balanceOf(await liquidityOrchestrator.getAddress()); - - // Execute buy from adapter - await erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesTarget, underlyingAmount); - - const underlyingBalanceAfter = await underlyingAsset.balanceOf(await liquidityOrchestrator.getAddress()); - - // Verify that not all underlying was consumed (some was returned) - const underlyingSpent = underlyingBalanceBefore - underlyingBalanceAfter; - - // Should have spent approximately sharesTarget worth (1000), not the full 2000 - expect(underlyingSpent).to.be.lessThan(underlyingAmount); - expect(underlyingSpent).to.be.greaterThan(0n); - - await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); - }); - - it("should guarantee exact shares across multiple buy operations", async function () { - const sharesPerBuy = ethers.parseUnits("100", 12); - const numBuys = 5; - const underlyingPerBuy = ethers.parseUnits("1000", 12); - const totalUnderlying = underlyingPerBuy * BigInt(numBuys); - - // Mint enough underlying to LO - await underlyingAsset.mint(await liquidityOrchestrator.getAddress(), totalUnderlying); - - // Impersonate LO - await ethers.provider.send("hardhat_impersonateAccount", [await liquidityOrchestrator.getAddress()]); - const loSigner = await ethers.getSigner(await liquidityOrchestrator.getAddress()); - - // Set ETH balance for LO for gas - await ethers.provider.send("hardhat_setBalance", [ - await liquidityOrchestrator.getAddress(), - ethers.toQuantity(ethers.parseEther("1.0")), - ]); - - await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), ethers.MaxUint256); - - let totalSharesReceived = 0n; - - for (let i = 0; i < numBuys; i++) { - const balanceBefore = await erc4626Vault.balanceOf(await liquidityOrchestrator.getAddress()); - await erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesPerBuy, underlyingPerBuy); - const balanceAfter = await erc4626Vault.balanceOf(await liquidityOrchestrator.getAddress()); - - const sharesMinted = balanceAfter - balanceBefore; - expect(sharesMinted).to.equal(sharesPerBuy, `Buy ${i + 1} must mint exact shares`); - - totalSharesReceived += sharesMinted; - } - - // Verify total accumulated shares - const expectedTotalShares = sharesPerBuy * BigInt(numBuys); - expect(totalSharesReceived).to.equal(expectedTotalShares, "Total shares must match sum of targets"); - - await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); - }); - - it("should handle sell operation correctly with exact shares", async function () { - const sharesAmount = ethers.parseUnits("1000", 12); - const underlyingAmount = ethers.parseUnits("10000", 12); - - // Mint underlying to LO - await underlyingAsset.mint(await liquidityOrchestrator.getAddress(), underlyingAmount); - - // Impersonate LO - await ethers.provider.send("hardhat_impersonateAccount", [await liquidityOrchestrator.getAddress()]); - const loSigner = await ethers.getSigner(await liquidityOrchestrator.getAddress()); - - // Set ETH balance for LO for gas - await ethers.provider.send("hardhat_setBalance", [ - await liquidityOrchestrator.getAddress(), - ethers.toQuantity(ethers.parseEther("1.0")), - ]); - - // First buy shares via adapter - await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), ethers.MaxUint256); - await erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesAmount, underlyingAmount); - - // Verify we have exact shares - const shareBalance = await erc4626Vault.balanceOf(await liquidityOrchestrator.getAddress()); - expect(shareBalance).to.equal(sharesAmount); - - // Now sell those exact shares - await erc4626Vault.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), sharesAmount); - - // Get the expected underlying amount from previewRedeem - const expectedUnderlyingFromRedeem = await erc4626Vault.previewRedeem(sharesAmount); - - const underlyingBefore = await underlyingAsset.balanceOf(await liquidityOrchestrator.getAddress()); - await erc4626ExecutionAdapter - .connect(loSigner) - .sell(await erc4626Vault.getAddress(), sharesAmount, expectedUnderlyingFromRedeem); - const underlyingAfter = await underlyingAsset.balanceOf(await liquidityOrchestrator.getAddress()); - - // Verify shares were fully redeemed - const finalShareBalance = await erc4626Vault.balanceOf(await liquidityOrchestrator.getAddress()); - expect(finalShareBalance).to.equal(0n, "All shares should be redeemed"); - - // Verify we got underlying back - const underlyingReceived = underlyingAfter - underlyingBefore; - expect(underlyingReceived).to.be.greaterThan(0n, "Should receive underlying from redemption"); - - await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); - }); - - it("should revert with SlippageExceeded on sell when redeem returns less than maxUnderlyingAmount", async function () { - const sharesAmount = ethers.parseUnits("1000", 12); - const underlyingAmount = ethers.parseUnits("10000", 12); - - await underlyingAsset.mint(await liquidityOrchestrator.getAddress(), underlyingAmount); - await ethers.provider.send("hardhat_impersonateAccount", [await liquidityOrchestrator.getAddress()]); - const loSigner = await ethers.getSigner(await liquidityOrchestrator.getAddress()); - await ethers.provider.send("hardhat_setBalance", [ - await liquidityOrchestrator.getAddress(), - ethers.toQuantity(ethers.parseEther("1.0")), - ]); - - await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), ethers.MaxUint256); - await erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesAmount, underlyingAmount); - - const estimatedUnderlying = await erc4626Vault.previewRedeem(sharesAmount); - await erc4626Vault.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), sharesAmount); - - const vaultUnderlyingBalance = await underlyingAsset.balanceOf(await erc4626Vault.getAddress()); - const lossAmount = (vaultUnderlyingBalance * 25n) / 100n; - await erc4626Vault.simulateLosses(lossAmount, owner.address); - - const receivedAfterLoss = await erc4626Vault.previewRedeem(sharesAmount); - const maxAllowed = (estimatedUnderlying * BigInt(10_000 - 200)) / 10_000n; - expect(receivedAfterLoss).to.be.lessThan(maxAllowed); - - await expect( - erc4626ExecutionAdapter - .connect(loSigner) - .sell(await erc4626Vault.getAddress(), sharesAmount, estimatedUnderlying), - ).to.be.reverted; - - await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); - }); - - it("should revert with SlippageExceeded on buy when previewMint exceeds maxUnderlyingAmount", async function () { - const sharesAmount = ethers.parseUnits("1000", 12); - const initialUnderlying = ethers.parseUnits("100000", 12); - const gainsAmount = ethers.parseUnits("50000", 12); - - await underlyingAsset.mint(await liquidityOrchestrator.getAddress(), initialUnderlying); - await underlyingAsset.mint(owner.address, gainsAmount); - await underlyingAsset.connect(owner).approve(await erc4626Vault.getAddress(), gainsAmount); - await erc4626Vault.simulateGains(gainsAmount); - - await ethers.provider.send("hardhat_impersonateAccount", [await liquidityOrchestrator.getAddress()]); - const loSigner = await ethers.getSigner(await liquidityOrchestrator.getAddress()); - await ethers.provider.send("hardhat_setBalance", [ - await liquidityOrchestrator.getAddress(), - ethers.toQuantity(ethers.parseEther("1.0")), - ]); - - const previewedUnderlying = await erc4626Vault.previewMint(sharesAmount); - const estimatedUnderlying = previewedUnderlying / 2n; - const maxAllowed = (estimatedUnderlying * BigInt(10_000 + 200)) / 10_000n; - expect(previewedUnderlying).to.be.greaterThan(maxAllowed); - - await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), ethers.MaxUint256); - - await expect( - erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesAmount, estimatedUnderlying), - ).to.be.reverted; - - await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); - }); - }); -}); diff --git a/test/ExecutionAdapterValidation.test.ts b/test/ExecutionAdapterValidation.test.ts index 83c5f947..d918a92a 100644 --- a/test/ExecutionAdapterValidation.test.ts +++ b/test/ExecutionAdapterValidation.test.ts @@ -7,8 +7,8 @@ import { LiquidityOrchestrator, MockERC4626Asset, MockUnderlyingAsset, - OrionAssetERC4626ExecutionAdapter, - OrionAssetERC4626PriceAdapter, + ERC4626ExecutionAdapter, + MockPriceAdapter, OrionConfig, } from "../typechain-types"; import { deployUpgradeableProtocol } from "./helpers/deployUpgradeable"; @@ -18,8 +18,8 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { let orionConfig: OrionConfig; let underlyingAsset: MockUnderlyingAsset; let erc4626Vault: MockERC4626Asset; - let erc4626ExecutionAdapter: OrionAssetERC4626ExecutionAdapter; - let priceAdapter: OrionAssetERC4626PriceAdapter; + let erc4626ExecutionAdapter: ERC4626ExecutionAdapter; + let priceAdapter: MockPriceAdapter; let liquidityOrchestrator: LiquidityOrchestrator; let owner: SignerWithAddress; @@ -54,19 +54,20 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { await erc4626Vault.connect(user).deposit(initialDeposit, user.address); // Deploy price adapter - const OrionAssetERC4626PriceAdapterFactory = await ethers.getContractFactory("OrionAssetERC4626PriceAdapter"); - priceAdapter = (await OrionAssetERC4626PriceAdapterFactory.deploy( - await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626PriceAdapter; + const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); + priceAdapter = (await MockPriceAdapterFactory.deploy()) as unknown as MockPriceAdapter; await priceAdapter.waitForDeployment(); + // Deploy mock swap executor + const MockExecutionAdapterFactory = await ethers.getContractFactory("MockExecutionAdapter"); + const MockExecutionAdapter = await MockExecutionAdapterFactory.deploy(); + await MockExecutionAdapter.waitForDeployment(); + // Deploy execution adapter - const OrionAssetERC4626ExecutionAdapterFactory = await ethers.getContractFactory( - "OrionAssetERC4626ExecutionAdapter", - ); - erc4626ExecutionAdapter = (await OrionAssetERC4626ExecutionAdapterFactory.deploy( + const ERC4626ExecutionAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + erc4626ExecutionAdapter = (await ERC4626ExecutionAdapterFactory.deploy( await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626ExecutionAdapter; + )) as unknown as ERC4626ExecutionAdapter; await erc4626ExecutionAdapter.waitForDeployment(); }); @@ -190,11 +191,8 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), underlyingAmount); // Should succeed because validation passes - await expect( - erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesAmount, underlyingAmount), - ).to.not.be.reverted; + await expect(erc4626ExecutionAdapter.connect(loSigner).buy(await erc4626Vault.getAddress(), sharesAmount)).to + .not.be.reverted; await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); }); @@ -213,15 +211,12 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { await underlyingAsset.mint(await liquidityOrchestrator.getAddress(), underlyingAmount); await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), underlyingAmount); - await erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesAmount, sharesAmount); + await erc4626ExecutionAdapter.connect(loSigner).buy(await erc4626Vault.getAddress(), sharesAmount); // Now sell await erc4626Vault.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), sharesAmount); - await expect( - erc4626ExecutionAdapter.connect(loSigner).sell(await erc4626Vault.getAddress(), sharesAmount, sharesAmount), - ).to.not.be.reverted; + await expect(erc4626ExecutionAdapter.connect(loSigner).sell(await erc4626Vault.getAddress(), sharesAmount)).to + .not.be.reverted; await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); }); @@ -295,11 +290,8 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { await underlyingAsset.mint(await liquidityOrchestrator.getAddress(), underlyingAmount); await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), underlyingAmount); - await expect( - erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesAmount, underlyingAmount), - ).to.not.be.reverted; + await expect(erc4626ExecutionAdapter.connect(loSigner).buy(await erc4626Vault.getAddress(), sharesAmount)).to + .not.be.reverted; await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); }); @@ -319,9 +311,7 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), underlyingAmount); const balanceBefore = await underlyingAsset.balanceOf(await liquidityOrchestrator.getAddress()); - await erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesAmount, underlyingAmount); + await erc4626ExecutionAdapter.connect(loSigner).buy(await erc4626Vault.getAddress(), sharesAmount); const balanceAfter = await underlyingAsset.balanceOf(await liquidityOrchestrator.getAddress()); const spent = balanceBefore - balanceAfter; @@ -347,9 +337,7 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { await underlyingAsset.mint(await liquidityOrchestrator.getAddress(), underlyingAmount); await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), underlyingAmount); - await erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesAmount, underlyingAmount); + await erc4626ExecutionAdapter.connect(loSigner).buy(await erc4626Vault.getAddress(), sharesAmount); await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); }); @@ -366,9 +354,8 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { await erc4626Vault.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), sharesAmount); - await expect( - erc4626ExecutionAdapter.connect(loSigner).sell(await erc4626Vault.getAddress(), sharesAmount, sharesAmount), - ).to.not.be.reverted; + await expect(erc4626ExecutionAdapter.connect(loSigner).sell(await erc4626Vault.getAddress(), sharesAmount)).to + .not.be.reverted; await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); }); @@ -386,9 +373,7 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { await erc4626Vault.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), sharesAmount); const underlyingBefore = await underlyingAsset.balanceOf(await liquidityOrchestrator.getAddress()); - await erc4626ExecutionAdapter - .connect(loSigner) - .sell(await erc4626Vault.getAddress(), sharesAmount, sharesAmount); + await erc4626ExecutionAdapter.connect(loSigner).sell(await erc4626Vault.getAddress(), sharesAmount); const underlyingAfter = await underlyingAsset.balanceOf(await liquidityOrchestrator.getAddress()); const received = underlyingAfter - underlyingBefore; @@ -428,9 +413,7 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), underlyingAmount); // Buy operation - validates and checks slippage - await erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesAmount, underlyingAmount); + await erc4626ExecutionAdapter.connect(loSigner).buy(await erc4626Vault.getAddress(), sharesAmount); // Verify shares received const sharesBalance = await erc4626Vault.balanceOf(await liquidityOrchestrator.getAddress()); @@ -443,12 +426,7 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { // Sell operation - validates and checks slippage await erc4626Vault.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), sharesAmount); - // Get the expected underlying amount from previewRedeem - const expectedUnderlyingFromRedeem = await erc4626Vault.previewRedeem(sharesAmount); - - await erc4626ExecutionAdapter - .connect(loSigner) - .sell(await erc4626Vault.getAddress(), sharesAmount, expectedUnderlyingFromRedeem); + await erc4626ExecutionAdapter.connect(loSigner).sell(await erc4626Vault.getAddress(), sharesAmount); // Verify shares sold const finalSharesBalance = await erc4626Vault.balanceOf(await liquidityOrchestrator.getAddress()); @@ -484,11 +462,8 @@ describe("Execution Adapter Validation - Comprehensive Tests", function () { await underlyingAsset.mint(await liquidityOrchestrator.getAddress(), underlyingAmount); await underlyingAsset.connect(loSigner).approve(await erc4626ExecutionAdapter.getAddress(), underlyingAmount); - await expect( - erc4626ExecutionAdapter - .connect(loSigner) - .buy(await erc4626Vault.getAddress(), sharesAmount, (underlyingAmount * 1015n) / 100n), - ).to.not.be.reverted; + await expect(erc4626ExecutionAdapter.connect(loSigner).buy(await erc4626Vault.getAddress(), sharesAmount)).to.not + .be.reverted; await ethers.provider.send("hardhat_stopImpersonatingAccount", [await liquidityOrchestrator.getAddress()]); }); diff --git a/test/LiquidityOrchestratorSlippage.test.ts b/test/LiquidityOrchestratorSlippage.test.ts new file mode 100644 index 00000000..685f769a --- /dev/null +++ b/test/LiquidityOrchestratorSlippage.test.ts @@ -0,0 +1,488 @@ +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { expect } from "chai"; +import "@openzeppelin/hardhat-upgrades"; +import { ethers, upgrades } from "hardhat"; + +import { + LiquidityOrchestratorHarness, + MockERC4626Asset, + MockUnderlyingAsset, + ERC4626ExecutionAdapter, + MockPriceAdapter, + OrionConfig, + PriceAdapterRegistry, +} from "../typechain-types"; +import { resetNetwork } from "./helpers/resetNetwork"; + +/** + * Comprehensive tests for centralized slippage management in LiquidityOrchestrator. + * + * Uses LiquidityOrchestratorHarness to directly call the contract's internal + * _calculateMaxWithSlippage and _calculateMinWithSlippage via Solidity (Math.mulDiv), + * ensuring on-chain rounding behavior is validated rather than JS-only arithmetic. + */ +describe("LiquidityOrchestrator - Centralized Slippage Management", function () { + let orionConfig: OrionConfig; + let underlyingAsset: MockUnderlyingAsset; + let vault1: MockERC4626Asset; + let vault2: MockERC4626Asset; + let executionAdapter: ERC4626ExecutionAdapter; + let priceAdapter: MockPriceAdapter; + let harness: LiquidityOrchestratorHarness; + + let owner: SignerWithAddress; + let user: SignerWithAddress; + + const BASIS_POINTS_FACTOR = 10000n; + + before(async function () { + await resetNetwork(); + }); + + beforeEach(async function () { + [owner, user] = await ethers.getSigners(); + + // --- Deploy underlying asset --- + const MockUnderlyingAssetFactory = await ethers.getContractFactory("MockUnderlyingAsset"); + underlyingAsset = (await MockUnderlyingAssetFactory.deploy(6)) as unknown as MockUnderlyingAsset; + await underlyingAsset.waitForDeployment(); + + // --- Deploy OrionConfig (UUPS) --- + const OrionConfigFactory = await ethers.getContractFactory("OrionConfig"); + orionConfig = (await upgrades.deployProxy(OrionConfigFactory, [owner.address, await underlyingAsset.getAddress()], { + initializer: "initialize", + kind: "uups", + })) as unknown as OrionConfig; + await orionConfig.waitForDeployment(); + + // --- Deploy PriceAdapterRegistry (UUPS) --- + const PriceAdapterRegistryFactory = await ethers.getContractFactory("PriceAdapterRegistry"); + const priceAdapterRegistry = (await upgrades.deployProxy( + PriceAdapterRegistryFactory, + [owner.address, await orionConfig.getAddress()], + { initializer: "initialize", kind: "uups" }, + )) as unknown as PriceAdapterRegistry; + await priceAdapterRegistry.waitForDeployment(); + await orionConfig.setPriceAdapterRegistry(await priceAdapterRegistry.getAddress()); + + // --- Deploy SP1 verifier stack --- + const SP1VerifierGatewayFactory = await ethers.getContractFactory("SP1VerifierGateway"); + const sp1VerifierGateway = await SP1VerifierGatewayFactory.deploy(owner.address); + await sp1VerifierGateway.waitForDeployment(); + + const SP1VerifierFactory = await ethers.getContractFactory("SP1Verifier"); + const sp1Verifier = await SP1VerifierFactory.deploy(); + await sp1Verifier.waitForDeployment(); + await sp1VerifierGateway.addRoute(await sp1Verifier.getAddress()); + + const vKey = "0x00dcc994ce74ee9842a9224176ea2aa5115883598b92686e0d764d3908352bb7"; + + // --- Deploy LiquidityOrchestratorHarness as UUPS proxy --- + const HarnessFactory = await ethers.getContractFactory("LiquidityOrchestratorHarness"); + harness = (await upgrades.deployProxy( + HarnessFactory, + [owner.address, await orionConfig.getAddress(), owner.address, await sp1VerifierGateway.getAddress(), vKey], + { initializer: "initialize", kind: "uups" }, + )) as unknown as LiquidityOrchestratorHarness; + await harness.waitForDeployment(); + + // --- Wire config --- + await orionConfig.setLiquidityOrchestrator(await harness.getAddress()); + + // --- Deploy vaults --- + const MockERC4626AssetFactory = await ethers.getContractFactory("MockERC4626Asset"); + vault1 = (await MockERC4626AssetFactory.deploy( + await underlyingAsset.getAddress(), + "Test Vault 1", + "TV1", + )) as unknown as MockERC4626Asset; + await vault1.waitForDeployment(); + + vault2 = (await MockERC4626AssetFactory.deploy( + await underlyingAsset.getAddress(), + "Test Vault 2", + "TV2", + )) as unknown as MockERC4626Asset; + await vault2.waitForDeployment(); + + // Seed vaults with assets so totalAssets > 0 for validation + const initialDeposit = ethers.parseUnits("100000", 6); + await underlyingAsset.mint(user.address, initialDeposit * 2n); + + await underlyingAsset.connect(user).approve(await vault1.getAddress(), initialDeposit); + await vault1.connect(user).deposit(initialDeposit, user.address); + + await underlyingAsset.connect(user).approve(await vault2.getAddress(), initialDeposit); + await vault2.connect(user).deposit(initialDeposit, user.address); + + // --- Deploy adapters --- + const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); + priceAdapter = (await MockPriceAdapterFactory.deploy()) as unknown as MockPriceAdapter; + await priceAdapter.waitForDeployment(); + + const ERC4626ExecutionAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + executionAdapter = (await ERC4626ExecutionAdapterFactory.deploy( + await orionConfig.getAddress(), + )) as unknown as ERC4626ExecutionAdapter; + await executionAdapter.waitForDeployment(); + + // --- Deploy beacon + vault factory for config --- + const VaultImplFactory = await ethers.getContractFactory("OrionTransparentVault"); + const vaultImpl = await VaultImplFactory.deploy(); + await vaultImpl.waitForDeployment(); + + const BeaconFactory = await ethers.getContractFactory( + "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol:UpgradeableBeacon", + ); + const vaultBeacon = await BeaconFactory.deploy(await vaultImpl.getAddress(), owner.address); + await vaultBeacon.waitForDeployment(); + + const TransparentVaultFactoryFactory = await ethers.getContractFactory("TransparentVaultFactory"); + const transparentVaultFactory = await upgrades.deployProxy( + TransparentVaultFactoryFactory, + [owner.address, await orionConfig.getAddress(), await vaultBeacon.getAddress()], + { initializer: "initialize", kind: "uups" }, + ); + await transparentVaultFactory.waitForDeployment(); + await orionConfig.setVaultFactory(await transparentVaultFactory.getAddress()); + + // Whitelist both vaults + await orionConfig.addWhitelistedAsset( + await vault1.getAddress(), + await priceAdapter.getAddress(), + await executionAdapter.getAddress(), + ); + + await orionConfig.addWhitelistedAsset( + await vault2.getAddress(), + await priceAdapter.getAddress(), + await executionAdapter.getAddress(), + ); + }); + + describe("Slippage Helper Functions - On-Chain Solidity Validation", function () { + describe("_calculateMaxWithSlippage (via harness)", function () { + it("should calculate correct max amount with 2% slippage on-chain", async function () { + await harness.setSlippageTolerance(200); + + const estimatedAmount = ethers.parseUnits("1000", 6); + const contractResult = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + + expect(contractResult).to.equal(ethers.parseUnits("1020", 6)); + }); + + it("should calculate correct max amount with 5% slippage on-chain", async function () { + await harness.setSlippageTolerance(500); + + const estimatedAmount = ethers.parseUnits("2000", 6); + const contractResult = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + + expect(contractResult).to.equal(ethers.parseUnits("2100", 6)); + }); + + it("should handle zero slippage correctly on-chain", async function () { + await harness.setSlippageTolerance(0); + + const estimatedAmount = ethers.parseUnits("5000", 6); + const contractResult = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + + expect(contractResult).to.equal(estimatedAmount); + }); + + it("should handle very small amounts on-chain (Solidity rounding)", async function () { + await harness.setSlippageTolerance(100); // 1% + + const estimatedAmount = 100n; // 0.0001 USDC + const contractResult = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + + // Solidity mulDiv(100, 10100, 10000) = 101 + expect(contractResult).to.equal(101n); + }); + + it("should handle very large amounts correctly on-chain", async function () { + await harness.setSlippageTolerance(300); // 3% + + const estimatedAmount = ethers.parseUnits("1000000000", 6); // 1 billion USDC + const contractResult = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + + expect(contractResult).to.equal(ethers.parseUnits("1030000000", 6)); + }); + }); + + describe("_calculateMinWithSlippage (via harness)", function () { + it("should calculate correct min amount with 2% slippage on-chain", async function () { + await harness.setSlippageTolerance(200); + + const estimatedAmount = ethers.parseUnits("1000", 6); + const contractResult = await harness.exposed_calculateMinWithSlippage(estimatedAmount); + + expect(contractResult).to.equal(ethers.parseUnits("980", 6)); + }); + + it("should calculate correct min amount with 5% slippage on-chain", async function () { + await harness.setSlippageTolerance(500); + + const estimatedAmount = ethers.parseUnits("2000", 6); + const contractResult = await harness.exposed_calculateMinWithSlippage(estimatedAmount); + + expect(contractResult).to.equal(ethers.parseUnits("1900", 6)); + }); + + it("should handle zero slippage correctly on-chain", async function () { + await harness.setSlippageTolerance(0); + + const estimatedAmount = ethers.parseUnits("5000", 6); + const contractResult = await harness.exposed_calculateMinWithSlippage(estimatedAmount); + + expect(contractResult).to.equal(estimatedAmount); + }); + + it("should handle very small amounts on-chain (Solidity rounding)", async function () { + await harness.setSlippageTolerance(100); // 1% + + const estimatedAmount = 100n; + const contractResult = await harness.exposed_calculateMinWithSlippage(estimatedAmount); + + // Solidity mulDiv(100, 9900, 10000) = 99 + expect(contractResult).to.equal(99n); + }); + + it("should handle very large amounts correctly on-chain", async function () { + await harness.setSlippageTolerance(300); // 3% + + const estimatedAmount = ethers.parseUnits("1000000000", 6); + const contractResult = await harness.exposed_calculateMinWithSlippage(estimatedAmount); + + expect(contractResult).to.equal(ethers.parseUnits("970000000", 6)); + }); + }); + + describe("Precision and Rounding (on-chain vs JS)", function () { + it("should match expected Solidity mulDiv rounding for fractional results", async function () { + await harness.setSlippageTolerance(250); // 2.5% + + // 123456789 * 10250 / 10000 = 126543208.725 → mulDiv floors to 126543208 + const estimatedAmount = 123456789n; + const maxResult = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + const minResult = await harness.exposed_calculateMinWithSlippage(estimatedAmount); + + expect(maxResult).to.equal(126543208n); + expect(minResult).to.equal(120370369n); + }); + + it("should validate on-chain results match JS reference for multiple inputs", async function () { + const amounts = [1n, 999n, 1000000n, 100000000n, ethers.parseUnits("10000", 6)]; + const slippages = [50n, 100n, 200n, 500n, 1000n]; + + for (const slippageVal of slippages) { + await harness.setSlippageTolerance(slippageVal); + const onChainSlippage = await harness.slippageTolerance(); + + for (const amount of amounts) { + const contractMax = await harness.exposed_calculateMaxWithSlippage(amount); + const contractMin = await harness.exposed_calculateMinWithSlippage(amount); + + // JS reference (floor division matches Solidity mulDiv default rounding) + const jsMax = (amount * (BASIS_POINTS_FACTOR + onChainSlippage)) / BASIS_POINTS_FACTOR; + const jsMin = (amount * (BASIS_POINTS_FACTOR - onChainSlippage)) / BASIS_POINTS_FACTOR; + + expect(contractMax).to.equal(jsMax, `Max mismatch for amount=${amount}, slippage=${slippageVal}`); + expect(contractMin).to.equal(jsMin, `Min mismatch for amount=${amount}, slippage=${slippageVal}`); + + // Structural invariants + expect(contractMax).to.be.gte(amount); + expect(contractMin).to.be.lte(amount); + } + } + }); + + it("should handle amounts that result in exact division on-chain", async function () { + await harness.setSlippageTolerance(250); // 2.5% + + const estimatedAmount = 4000000n; // 4 USDC + const contractMax = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + + // 4000000 * 10250 / 10000 = 4100000 (exact) + expect(contractMax).to.equal(4100000n); + }); + }); + }); + + describe("Integration with Buy Operations", function () { + beforeEach(async function () { + await harness.setTargetBufferRatio(400); + await harness.setSlippageTolerance(200); // 2% + + const fundAmount = ethers.parseUnits("50000", 6); + await underlyingAsset.mint(await harness.getAddress(), fundAmount); + }); + + it("should apply max slippage to approval amount in buy operation", async function () { + const sharesAmount = ethers.parseUnits("100", 18); + const estimatedUnderlying = await vault1.previewMint(sharesAmount); + + // Contract-computed expected approval + const contractMax = await harness.exposed_calculateMaxWithSlippage(estimatedUnderlying); + + // Verify the mathematical relationship via the contract + expect(contractMax).to.equal((estimatedUnderlying * 10200n) / 10000n); + }); + + it("should consistently apply slippage across multiple buy operations", async function () { + const sharesAmount = ethers.parseUnits("50", 18); + + for (let i = 0; i < 3; i++) { + const estimatedUnderlying = await vault1.previewMint(sharesAmount); + const contractMax = await harness.exposed_calculateMaxWithSlippage(estimatedUnderlying); + + expect(contractMax).to.equal((estimatedUnderlying * 10200n) / 10000n); + } + }); + + it("should update slippage calculations when tolerance is changed", async function () { + const sharesAmount = ethers.parseUnits("100", 18); + const estimatedUnderlying = await vault1.previewMint(sharesAmount); + + // 2% slippage + const maxAmount1 = await harness.exposed_calculateMaxWithSlippage(estimatedUnderlying); + + // Change to 5% slippage + await harness.setSlippageTolerance(500); + const maxAmount2 = await harness.exposed_calculateMaxWithSlippage(estimatedUnderlying); + + expect(maxAmount2).to.be.gt(maxAmount1); + expect(maxAmount2).to.equal((estimatedUnderlying * 10500n) / 10000n); + }); + }); + + describe("Consistency Across Different Adapters", function () { + it("should apply same slippage calculation regardless of vault", async function () { + await harness.setSlippageTolerance(300); // 3% + + const sharesAmount = ethers.parseUnits("100", 18); + + const estimatedUnderlying1 = await vault1.previewMint(sharesAmount); + const estimatedUnderlying2 = await vault2.previewMint(sharesAmount); + + const contractMax1 = await harness.exposed_calculateMaxWithSlippage(estimatedUnderlying1); + const contractMax2 = await harness.exposed_calculateMaxWithSlippage(estimatedUnderlying2); + + expect(contractMax1).to.equal((estimatedUnderlying1 * 10300n) / 10000n); + expect(contractMax2).to.equal((estimatedUnderlying2 * 10300n) / 10000n); + }); + + it("should maintain slippage consistency even with different decimal assets", async function () { + await harness.setSlippageTolerance(250); // 2.5% + + // Test with 6-decimal amount + const amount6 = ethers.parseUnits("1000", 6); + const max6 = await harness.exposed_calculateMaxWithSlippage(amount6); + + // Test with 18-decimal amount + const amount18 = ethers.parseUnits("1000", 18); + const max18 = await harness.exposed_calculateMaxWithSlippage(amount18); + + expect(max6).to.equal((amount6 * 10250n) / 10000n); + expect(max18).to.equal((amount18 * 10250n) / 10000n); + }); + }); + + describe("Edge Cases and Boundary Conditions", function () { + it("should handle maximum possible slippage tolerance on-chain", async function () { + const highSlippage = 2000n; // 20% + await harness.setSlippageTolerance(highSlippage); + + const estimatedAmount = ethers.parseUnits("1000", 6); + const contractMax = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + const contractMin = await harness.exposed_calculateMinWithSlippage(estimatedAmount); + + expect(contractMax).to.equal(ethers.parseUnits("1200", 6)); + expect(contractMin).to.equal(ethers.parseUnits("800", 6)); + }); + + it("should maintain precision with fractional basis points on-chain", async function () { + await harness.setSlippageTolerance(123); // 1.23% + + const estimatedAmount = ethers.parseUnits("10000", 6); + const contractMax = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + + expect(contractMax).to.equal(ethers.parseUnits("10123", 6)); + }); + + it("should reject slippage tolerance exceeding BASIS_POINTS_FACTOR", async function () { + await expect(harness.setSlippageTolerance(10001)).to.be.reverted; + }); + }); + + describe("Slippage Update Propagation", function () { + it("should immediately reflect slippage changes in on-chain calculations", async function () { + const estimatedAmount = ethers.parseUnits("1000", 6); + + await harness.setSlippageTolerance(100); // 1% + let maxAmount = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + expect(maxAmount).to.equal(ethers.parseUnits("1010", 6)); + + await harness.setSlippageTolerance(300); // 3% + maxAmount = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + expect(maxAmount).to.equal(ethers.parseUnits("1030", 6)); + + await harness.setSlippageTolerance(50); // 0.5% + maxAmount = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + expect(maxAmount).to.equal(ethers.parseUnits("1005", 6)); + }); + + it("should maintain consistency after multiple slippage updates on-chain", async function () { + const estimatedAmount = ethers.parseUnits("5000", 6); + const slippageValues = [100n, 200n, 300n, 150n, 250n]; + + for (const slippageValue of slippageValues) { + await harness.setSlippageTolerance(slippageValue); + + const contractMax = await harness.exposed_calculateMaxWithSlippage(estimatedAmount); + const expectedMax = (estimatedAmount * (BASIS_POINTS_FACTOR + slippageValue)) / BASIS_POINTS_FACTOR; + + expect(contractMax).to.equal(expectedMax); + } + }); + }); + + describe("Symmetry Validation", function () { + it("should validate max and min are symmetric within rounding on-chain", async function () { + await harness.setSlippageTolerance(350); // 3.5% + + const amounts = [ + ethers.parseUnits("100", 6), + ethers.parseUnits("500", 6), + ethers.parseUnits("1000", 6), + ethers.parseUnits("10000", 6), + ]; + + for (const amount of amounts) { + const contractMax = await harness.exposed_calculateMaxWithSlippage(amount); + const contractMin = await harness.exposed_calculateMinWithSlippage(amount); + + const upperDelta = contractMax - amount; + const lowerDelta = amount - contractMin; + + // Both deltas should be approximately equal (within 1 unit due to rounding) + const deltaDiff = upperDelta > lowerDelta ? upperDelta - lowerDelta : lowerDelta - upperDelta; + expect(deltaDiff).to.be.lte(1n); + } + }); + + it("should demonstrate single source of truth for slippage on-chain", async function () { + await harness.setSlippageTolerance(200); // 2% + + const slippage = await harness.slippageTolerance(); + expect(slippage).to.equal(200); + + const amount1 = ethers.parseUnits("1000", 6); + const amount2 = ethers.parseUnits("2000", 6); + const amount3 = ethers.parseUnits("3000", 6); + + expect(await harness.exposed_calculateMaxWithSlippage(amount1)).to.equal(ethers.parseUnits("1020", 6)); + expect(await harness.exposed_calculateMaxWithSlippage(amount2)).to.equal(ethers.parseUnits("2040", 6)); + expect(await harness.exposed_calculateMaxWithSlippage(amount3)).to.equal(ethers.parseUnits("3060", 6)); + }); + }); +}); diff --git a/test/PassiveStrategist.test.ts b/test/PassiveStrategist.test.ts index 08e221bb..9b4b6f07 100644 --- a/test/PassiveStrategist.test.ts +++ b/test/PassiveStrategist.test.ts @@ -8,12 +8,12 @@ import { resetNetwork } from "./helpers/resetNetwork"; import { MockUnderlyingAsset, MockERC4626Asset, - OrionAssetERC4626ExecutionAdapter, + ERC4626ExecutionAdapter, OrionConfig, LiquidityOrchestrator, TransparentVaultFactory, OrionTransparentVault, - OrionAssetERC4626PriceAdapter, + ERC4626PriceAdapter, KBestTvlWeightedAverage, KBestTvlWeightedAverageInvalid, } from "../typechain-types"; @@ -26,8 +26,8 @@ describe("Passive Strategist", function () { let mockAsset2: MockERC4626Asset; let mockAsset3: MockERC4626Asset; let mockAsset4: MockERC4626Asset; - let orionPriceAdapter: OrionAssetERC4626PriceAdapter; - let orionExecutionAdapter: OrionAssetERC4626ExecutionAdapter; + let orionPriceAdapter: ERC4626PriceAdapter; + let orionExecutionAdapter: ERC4626ExecutionAdapter; let liquidityOrchestrator: LiquidityOrchestrator; let transparentVault: OrionTransparentVault; let passiveStrategist: KBestTvlWeightedAverage; @@ -114,11 +114,9 @@ describe("Passive Strategist", function () { liquidityOrchestrator = deployed.liquidityOrchestrator; transparentVaultFactory = deployed.transparentVaultFactory; - // Deploy OrionAssetERC4626PriceAdapter - const OrionAssetERC4626PriceAdapterFactory = await ethers.getContractFactory("OrionAssetERC4626PriceAdapter"); - orionPriceAdapter = (await OrionAssetERC4626PriceAdapterFactory.deploy( - await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626PriceAdapter; + // Deploy MockPriceAdapter (for same-asset vaults) + const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); + orionPriceAdapter = (await MockPriceAdapterFactory.deploy()) as unknown as ERC4626PriceAdapter; await orionPriceAdapter.waitForDeployment(); // Configure protocol @@ -126,12 +124,10 @@ describe("Passive Strategist", function () { await liquidityOrchestrator.setTargetBufferRatio(100); // 1% target buffer ratio await liquidityOrchestrator.setSlippageTolerance(50); // 0.5% slippage - const OrionAssetERC4626ExecutionAdapterFactory = await ethers.getContractFactory( - "OrionAssetERC4626ExecutionAdapter", - ); - orionExecutionAdapter = (await OrionAssetERC4626ExecutionAdapterFactory.deploy( + const ERC4626ExecutionAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + orionExecutionAdapter = (await ERC4626ExecutionAdapterFactory.deploy( await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626ExecutionAdapter; + )) as unknown as ERC4626ExecutionAdapter; await orionExecutionAdapter.waitForDeployment(); await orionConfig.addWhitelistedAsset( diff --git a/test/PriceAdapterTruncation.test.ts b/test/PriceAdapterTruncation.test.ts index a1a88b63..5264ba0f 100644 --- a/test/PriceAdapterTruncation.test.ts +++ b/test/PriceAdapterTruncation.test.ts @@ -1,14 +1,23 @@ import { expect } from "chai"; -import { ethers, upgrades } from "hardhat"; +import { ethers } from "hardhat"; import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; import "@openzeppelin/hardhat-upgrades"; -import { MockUnderlyingAsset, MockERC4626Asset, OrionAssetERC4626PriceAdapter, OrionConfig } from "../typechain-types"; +import { + MockUnderlyingAsset, + MockERC4626Asset, + ERC4626PriceAdapter, + OrionConfig, + MockExecutionAdapter, + MockPriceAdapter, +} from "../typechain-types"; import { resetNetwork } from "./helpers/resetNetwork"; +import { deployUpgradeableProtocol } from "./helpers/deployUpgradeable"; describe("Price Adapter Truncation", function () { - let underlying: MockUnderlyingAsset; + let protocolUnderlying: MockUnderlyingAsset; + let vaultUnderlying: MockUnderlyingAsset; let vault: MockERC4626Asset; - let priceAdapter: OrionAssetERC4626PriceAdapter; + let priceAdapter: ERC4626PriceAdapter; let orionConfig: OrionConfig; let deployer: SignerWithAddress; @@ -19,35 +28,63 @@ describe("Price Adapter Truncation", function () { beforeEach(async function () { [deployer] = await ethers.getSigners(); + // Deploy USDC as protocol underlying (6 decimals) const MockUnderlyingAssetFactory = await ethers.getContractFactory("MockUnderlyingAsset"); - underlying = (await MockUnderlyingAssetFactory.deploy(6)) as unknown as MockUnderlyingAsset; - await underlying.waitForDeployment(); - - const OrionConfigFactory = await ethers.getContractFactory("OrionConfig"); - orionConfig = (await upgrades.deployProxy(OrionConfigFactory, [deployer.address, await underlying.getAddress()], { - initializer: "initialize", - kind: "uups", - })) as unknown as OrionConfig; - await orionConfig.waitForDeployment(); - - const OrionAssetERC4626PriceAdapterFactory = await ethers.getContractFactory("OrionAssetERC4626PriceAdapter"); - priceAdapter = (await OrionAssetERC4626PriceAdapterFactory.deploy( + protocolUnderlying = (await MockUnderlyingAssetFactory.deploy(6)) as unknown as MockUnderlyingAsset; + await protocolUnderlying.waitForDeployment(); + + // Deploy protocol infrastructure + const deployed = await deployUpgradeableProtocol(deployer, protocolUnderlying); + orionConfig = deployed.orionConfig; + + // Deploy WETH-like token as vault underlying (18 decimals, different from protocol underlying) + vaultUnderlying = (await MockUnderlyingAssetFactory.deploy(18)) as unknown as MockUnderlyingAsset; + await vaultUnderlying.waitForDeployment(); + + // Deploy MockPriceAdapter for WETH that returns 1:1 with USDC for precision testing + const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); + const mockUnderlyingPriceAdapter = (await MockPriceAdapterFactory.deploy()) as unknown as MockPriceAdapter; + await mockUnderlyingPriceAdapter.waitForDeployment(); + + // Register WETH with price adapter + const MockExecutionAdapterFactory = await ethers.getContractFactory("MockExecutionAdapter"); + const mockExecutionAdapter = (await MockExecutionAdapterFactory.deploy()) as unknown as MockExecutionAdapter; + await mockExecutionAdapter.waitForDeployment(); + await orionConfig.addWhitelistedAsset( + await vaultUnderlying.getAddress(), + await mockUnderlyingPriceAdapter.getAddress(), + await mockExecutionAdapter.getAddress(), + ); + + // Deploy ERC4626PriceAdapter for testing precision + const ERC4626PriceAdapterFactory = await ethers.getContractFactory("ERC4626PriceAdapter"); + priceAdapter = (await ERC4626PriceAdapterFactory.deploy( await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626PriceAdapter; + )) as unknown as ERC4626PriceAdapter; await priceAdapter.waitForDeployment(); + // Deploy vault with WETH as underlying (cross-asset vault) const MockERC4626AssetFactory = await ethers.getContractFactory("MockERC4626Asset"); vault = (await MockERC4626AssetFactory.deploy( - await underlying.getAddress(), + await vaultUnderlying.getAddress(), "Test Vault", "TV", )) as unknown as MockERC4626Asset; await vault.waitForDeployment(); - const hugeDeposit = ethers.parseUnits("1000000000000000000000000", 6); + // Register vault with OrionConfig + const vaultMockExecutionAdapter = (await MockExecutionAdapterFactory.deploy()) as unknown as MockExecutionAdapter; + await vaultMockExecutionAdapter.waitForDeployment(); + await orionConfig.addWhitelistedAsset( + await vault.getAddress(), + await priceAdapter.getAddress(), + await vaultMockExecutionAdapter.getAddress(), + ); - await underlying.mint(deployer.address, hugeDeposit); - await underlying.connect(deployer).approve(await vault.getAddress(), hugeDeposit); + const hugeDeposit = ethers.parseUnits("1000000000000000000000000", 18); + + await vaultUnderlying.mint(deployer.address, hugeDeposit); + await vaultUnderlying.connect(deployer).approve(await vault.getAddress(), hugeDeposit); await vault.connect(deployer).deposit(hugeDeposit, deployer.address); const totalSupply = await vault.totalSupply(); @@ -57,26 +94,43 @@ describe("Price Adapter Truncation", function () { const extraAmount = targetTotalAssets > currentTotalAssets ? targetTotalAssets - currentTotalAssets : 0n; if (extraAmount > 0n) { - await underlying.mint(deployer.address, extraAmount); - await underlying.transfer(await vault.getAddress(), extraAmount); + await vaultUnderlying.mint(deployer.address, extraAmount); + await vaultUnderlying.transfer(await vault.getAddress(), extraAmount); } }); it("should demonstrate ERC4626 getPriceData preserves precision and avoids truncation", async function () { - const totalSupply = await vault.totalSupply(); - const totalAssets = await vault.totalAssets(); - - const trueExchangeRateNumber = Number(totalAssets) / Number(totalSupply); - + // ERC4626PriceAdapter does: vault shares -> underlying (WETH) -> USDC + // We'll verify precision by manually calculating the expected price and comparing + + // Step 1: Calculate vault shares -> underlying exchange rate + const vaultDecimals = await vault.decimals(); + const oneVaultShare = 10n ** BigInt(vaultDecimals); + const underlyingPerShare = await vault.convertToAssets(oneVaultShare); + + // Step 2: Get underlying -> USDC price from the underlying's price adapter + const underlyingPriceAdapter = await orionConfig.priceAdapterRegistry(); + const priceRegistry = await ethers.getContractAt("PriceAdapterRegistry", underlyingPriceAdapter); + const underlyingPriceInUSDC = await priceRegistry.getPrice(await vaultUnderlying.getAddress()); + const underlyingPriceDecimals = 14; // PriceAdapterRegistry always returns prices with 14 decimals + + // Step 3: Compose the prices manually + // Price = (underlyingPerShare * underlyingPriceInUSDC) / (10^vaultDecimals) normalized to 14 decimals + const expectedPrice = + (underlyingPerShare * underlyingPriceInUSDC * 10n ** 14n) / + (oneVaultShare * 10n ** BigInt(underlyingPriceDecimals)); + + // Step 4: Get price from ERC4626PriceAdapter const [priceFromAdapter, priceDecimals] = await priceAdapter.getPriceData(await vault.getAddress()); - const measuredExchangeRateNumber = Number(priceFromAdapter) / 10 ** Number(priceDecimals); + // Verify decimals + expect(priceDecimals).to.equal(14); - const difference = - measuredExchangeRateNumber > trueExchangeRateNumber - ? measuredExchangeRateNumber - trueExchangeRateNumber - : trueExchangeRateNumber - measuredExchangeRateNumber; + // Verify price matches our manual calculation (perfect precision, no truncation) + // Allow for at most 1 unit of the lowest decimal place due to rounding in different order of operations + const priceDifference = + priceFromAdapter > expectedPrice ? priceFromAdapter - expectedPrice : expectedPrice - priceFromAdapter; - expect(difference).to.be.lt(1e-12); + expect(priceDifference).to.be.lte(1n, "ERC4626PriceAdapter should preserve precision within 1 unit"); }); }); diff --git a/test/ProtocolPause.test.ts b/test/ProtocolPause.test.ts index aa0318a5..8b869665 100644 --- a/test/ProtocolPause.test.ts +++ b/test/ProtocolPause.test.ts @@ -44,7 +44,7 @@ import { resetNetwork } from "./helpers/resetNetwork"; import { MockUnderlyingAsset, MockERC4626Asset, - OrionAssetERC4626ExecutionAdapter, + ERC4626ExecutionAdapter, OrionConfig, LiquidityOrchestrator, OrionTransparentVault, @@ -55,7 +55,7 @@ describe("Protocol Pause Functionality", function () { // Contract instances let underlyingAsset: MockUnderlyingAsset; let erc4626Asset: MockERC4626Asset; - let adapter: OrionAssetERC4626ExecutionAdapter; + let adapter: ERC4626ExecutionAdapter; let config: OrionConfig; let liquidityOrchestrator: LiquidityOrchestrator; let transparentVault: OrionTransparentVault; @@ -103,15 +103,20 @@ describe("Protocol Pause Functionality", function () { erc4626Asset = erc4626AssetDeployed as unknown as MockERC4626Asset; // Deploy Price Adapter - const OrionAssetERC4626PriceAdapterFactory = await ethers.getContractFactory("OrionAssetERC4626PriceAdapter"); - const priceAdapterDeployed = await OrionAssetERC4626PriceAdapterFactory.deploy(await config.getAddress()); + const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); + const priceAdapterDeployed = await MockPriceAdapterFactory.deploy(); await priceAdapterDeployed.waitForDeployment(); + // Deploy Mock Swap Executor + const MockExecutionAdapterFactory = await ethers.getContractFactory("MockExecutionAdapter"); + const MockExecutionAdapter = await MockExecutionAdapterFactory.deploy(); + await MockExecutionAdapter.waitForDeployment(); + // Deploy Execution Adapter - const AdapterFactory = await ethers.getContractFactory("OrionAssetERC4626ExecutionAdapter"); + const AdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); const adapterDeployed = await AdapterFactory.deploy(await config.getAddress()); await adapterDeployed.waitForDeployment(); - adapter = adapterDeployed as unknown as OrionAssetERC4626ExecutionAdapter; + adapter = adapterDeployed as unknown as ERC4626ExecutionAdapter; // NOW we can configure protocol parameters (these check isSystemIdle()) await config.setMinDepositAmount(MIN_DEPOSIT); diff --git a/test/Removal.test.ts b/test/Removal.test.ts index 1641f83d..9974d968 100644 --- a/test/Removal.test.ts +++ b/test/Removal.test.ts @@ -9,12 +9,12 @@ import { resetNetwork } from "./helpers/resetNetwork"; import { MockUnderlyingAsset, MockERC4626Asset, - OrionAssetERC4626ExecutionAdapter, + ERC4626ExecutionAdapter, OrionConfig, LiquidityOrchestrator, TransparentVaultFactory, OrionTransparentVault, - OrionAssetERC4626PriceAdapter, + ERC4626PriceAdapter, } from "../typechain-types"; describe("Whitelist and Vault Removal Flows", function () { @@ -23,8 +23,8 @@ describe("Whitelist and Vault Removal Flows", function () { let underlyingAsset: MockUnderlyingAsset; let mockAsset1: MockERC4626Asset; let mockAsset2: MockERC4626Asset; - let orionPriceAdapter: OrionAssetERC4626PriceAdapter; - let orionExecutionAdapter: OrionAssetERC4626ExecutionAdapter; + let orionPriceAdapter: ERC4626PriceAdapter; + let orionExecutionAdapter: ERC4626ExecutionAdapter; let liquidityOrchestrator: LiquidityOrchestrator; let testVault: OrionTransparentVault; @@ -83,11 +83,9 @@ describe("Whitelist and Vault Removal Flows", function () { console.log("orionConfig address", await orionConfig.getAddress()); - // Deploy price adapter - const OrionAssetERC4626PriceAdapterFactory = await ethers.getContractFactory("OrionAssetERC4626PriceAdapter"); - orionPriceAdapter = (await OrionAssetERC4626PriceAdapterFactory.deploy( - await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626PriceAdapter; + // Deploy MockPriceAdapter - these vaults use USDC as underlying (same-asset), ERC4626PriceAdapter rejects same-asset + const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); + orionPriceAdapter = (await MockPriceAdapterFactory.deploy()) as unknown as ERC4626PriceAdapter; await orionPriceAdapter.waitForDeployment(); // Configure protocol @@ -95,12 +93,10 @@ describe("Whitelist and Vault Removal Flows", function () { await liquidityOrchestrator.setTargetBufferRatio(100); // 1% target buffer ratio await liquidityOrchestrator.setSlippageTolerance(50); // 0.5% slippage - const OrionAssetERC4626ExecutionAdapterFactory = await ethers.getContractFactory( - "OrionAssetERC4626ExecutionAdapter", - ); - orionExecutionAdapter = (await OrionAssetERC4626ExecutionAdapterFactory.deploy( + const ERC4626ExecutionAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + orionExecutionAdapter = (await ERC4626ExecutionAdapterFactory.deploy( await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626ExecutionAdapter; + )) as unknown as ERC4626ExecutionAdapter; await orionExecutionAdapter.waitForDeployment(); await orionConfig.addWhitelistedAsset( diff --git a/test/crossAsset/ChainlinkPriceAdapter.test.ts b/test/crossAsset/ChainlinkPriceAdapter.test.ts new file mode 100644 index 00000000..2d97e114 --- /dev/null +++ b/test/crossAsset/ChainlinkPriceAdapter.test.ts @@ -0,0 +1,314 @@ +/** + * ChainlinkPriceAdapter Coverage Tests + * + * Comprehensive test suite to end to end test ChainlinkPriceAdapter.sol + * Tests all security checks, edge cases, and error conditions. + */ + +import { expect } from "chai"; +import { ethers, network } from "hardhat"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { ChainlinkPriceAdapter } from "../../typechain-types"; + +// Mainnet addresses +const MAINNET = { + USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + WETH: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + CHAINLINK_ETH_USD: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", + CHAINLINK_USDC_ETH: "0x986b5E1e1755e3C2440e960477f25201B0a8bbD4", // USDC/ETH (inverse) +}; + +describe("ChainlinkPriceAdapter - Coverage Tests", function () { + let owner: SignerWithAddress; + let nonOwner: SignerWithAddress; + let chainlinkAdapter: ChainlinkPriceAdapter; + + before(async function () { + this.timeout(60000); + + // Skip if not forking mainnet + const networkConfig = network.config; + if (!("forking" in networkConfig) || !networkConfig.forking || !networkConfig.forking.url) { + this.skip(); + } + + [owner, nonOwner] = await ethers.getSigners(); + + // Deploy Chainlink adapter (no constructor args) + const ChainlinkAdapterFactory = await ethers.getContractFactory("ChainlinkPriceAdapter"); + const chainlinkAdapterDeployed = await ChainlinkAdapterFactory.deploy(); + await chainlinkAdapterDeployed.waitForDeployment(); + chainlinkAdapter = chainlinkAdapterDeployed as unknown as ChainlinkPriceAdapter; + }); + + describe("Constructor", function () { + it("Should set owner correctly", async function () { + expect(await chainlinkAdapter.owner()).to.equal(owner.address); + }); + }); + + describe("configureFeed", function () { + it("Should configure standard feed successfully", async function () { + await expect( + chainlinkAdapter.configureFeed( + MAINNET.WETH, + MAINNET.CHAINLINK_ETH_USD, + false, // not inverse + 3600, // 1 hour staleness + ethers.parseUnits("1000", 8), // min $1,000 + ethers.parseUnits("10000", 8), // max $10,000 + ), + ) + .to.emit(chainlinkAdapter, "FeedConfigured") + .withArgs( + MAINNET.WETH, + MAINNET.CHAINLINK_ETH_USD, + false, + 3600, + ethers.parseUnits("1000", 8), + ethers.parseUnits("10000", 8), + ); + + const feedConfig = await chainlinkAdapter.feedConfigOf(MAINNET.WETH); + expect(feedConfig.feed).to.equal(MAINNET.CHAINLINK_ETH_USD); + expect(feedConfig.isInverse).to.equal(false); + }); + + it("Should configure inverse feed successfully", async function () { + await chainlinkAdapter.configureFeed( + MAINNET.USDC, + MAINNET.CHAINLINK_USDC_ETH, + true, // inverse + 3600, + ethers.parseUnits("0.0001", 18), // min (USDC/ETH is small) + ethers.parseUnits("0.001", 18), // max + ); + + const feedConfig = await chainlinkAdapter.feedConfigOf(MAINNET.USDC); + expect(feedConfig.isInverse).to.equal(true); + }); + + it("Should reject zero asset address", async function () { + await expect( + chainlinkAdapter.configureFeed( + ethers.ZeroAddress, + MAINNET.CHAINLINK_ETH_USD, + false, + 3600, + ethers.parseUnits("1000", 8), + ethers.parseUnits("10000", 8), + ), + ).to.be.revertedWithCustomError(chainlinkAdapter, "ZeroAddress"); + }); + + it("Should reject zero feed address", async function () { + await expect( + chainlinkAdapter.configureFeed( + MAINNET.WETH, + ethers.ZeroAddress, + false, + 3600, + ethers.parseUnits("1000", 8), + ethers.parseUnits("10000", 8), + ), + ).to.be.revertedWithCustomError(chainlinkAdapter, "ZeroAddress"); + }); + + it("Should reject zero staleness", async function () { + await expect( + chainlinkAdapter.configureFeed( + MAINNET.WETH, + MAINNET.CHAINLINK_ETH_USD, + false, + 0, // zero staleness + ethers.parseUnits("1000", 8), + ethers.parseUnits("10000", 8), + ), + ).to.be.revertedWithCustomError(chainlinkAdapter, "InvalidArguments"); + }); + + it("Should reject minPrice > maxPrice", async function () { + await expect( + chainlinkAdapter.configureFeed( + MAINNET.WETH, + MAINNET.CHAINLINK_ETH_USD, + false, + 3600, + ethers.parseUnits("10000", 8), // min > max + ethers.parseUnits("1000", 8), + ), + ).to.be.revertedWithCustomError(chainlinkAdapter, "InvalidArguments"); + }); + + it("Should reject invalid feed address", async function () { + // Use owner address which is not a Chainlink feed + // Note: The try-catch in Solidity catches the error and reverts with InvalidAdapter + // However, ethers may not decode the custom error properly from a catch block + await expect( + chainlinkAdapter.configureFeed( + MAINNET.WETH, + owner.address, + false, + 3600, + ethers.parseUnits("1000", 8), + ethers.parseUnits("10000", 8), + ), + ).to.be.reverted; // Just check it reverts (the catch block triggers) + }); + + it("Should reject non-owner", async function () { + await expect( + chainlinkAdapter + .connect(nonOwner) + .configureFeed( + MAINNET.WETH, + MAINNET.CHAINLINK_ETH_USD, + false, + 3600, + ethers.parseUnits("1000", 8), + ethers.parseUnits("10000", 8), + ), + ).to.be.revertedWithCustomError(chainlinkAdapter, "OwnableUnauthorizedAccount"); + }); + }); + + describe("validatePriceAdapter", function () { + it("Should validate configured feed", async function () { + await expect(chainlinkAdapter.validatePriceAdapter(MAINNET.WETH)).to.not.be.reverted; + }); + + it("Should reject unconfigured asset", async function () { + const unconfiguredAsset = "0x1234567890123456789012345678901234567890"; + await expect(chainlinkAdapter.validatePriceAdapter(unconfiguredAsset)).to.be.revertedWithCustomError( + chainlinkAdapter, + "InvalidAdapter", + ); + }); + }); + + describe("getPriceData", function () { + it("Should return valid price for ETH/USD", async function () { + // First get the raw Chainlink price to check if it's within our test bounds + const chainlinkFeed = await ethers.getContractAt("AggregatorV3Interface", MAINNET.CHAINLINK_ETH_USD); + const [, answer] = await chainlinkFeed.latestRoundData(); + const currentPrice = BigInt(answer.toString()); + + console.log(` Current Chainlink ETH/USD: $${ethers.formatUnits(currentPrice, 8)}`); + + // Reconfigure with wider bounds to accommodate current price + await chainlinkAdapter.configureFeed( + MAINNET.WETH, + MAINNET.CHAINLINK_ETH_USD, + false, + 3600, + ethers.parseUnits("100", 8), // min $100 (very safe) + ethers.parseUnits("100000", 8), // max $100,000 (very safe) + ); + + const [price, decimals] = await chainlinkAdapter.getPriceData(MAINNET.WETH); + + expect(decimals).to.equal(8); // Chainlink ETH/USD uses 8 decimals + expect(price).to.be.gt(0); + expect(price).to.equal(currentPrice); + + console.log(` Retrieved price: $${ethers.formatUnits(price, 8)}`); + }); + + it("Should reject unconfigured asset", async function () { + // USDC feed configured but with inverse flag - test different asset + const randomAddress = "0x1234567890123456789012345678901234567890"; + await expect(chainlinkAdapter.getPriceData(randomAddress)).to.be.revertedWithCustomError( + chainlinkAdapter, + "AdapterNotSet", + ); + }); + + it("Should handle inverse feed correctly", async function () { + // Reconfigure with longer staleness tolerance (USDC/ETH feed updates less frequently) + await chainlinkAdapter.configureFeed( + MAINNET.USDC, + MAINNET.CHAINLINK_USDC_ETH, + true, // inverse + 86400, // 24 hours staleness tolerance + ethers.parseUnits("0.0001", 18), // min + ethers.parseUnits("0.001", 18), // max + ); + + // USDC/ETH feed returns inverse, adapter should flip it + const [price, decimals] = await chainlinkAdapter.getPriceData(MAINNET.USDC); + + expect(decimals).to.equal(18); // Inverse feeds use INVERSE_DECIMALS + expect(price).to.be.gt(0); + + console.log(` USDC price (inverted): ${ethers.formatUnits(price, 18)} ETH`); + }); + + it("Should reject price out of bounds", async function () { + // Configure with very tight bounds that current price will exceed + await chainlinkAdapter.configureFeed( + owner.address, // Use any address as test asset + MAINNET.CHAINLINK_ETH_USD, + false, + 3600, + 1, // min $0.00000001 (will pass) + 2, // max $0.00000002 (will fail - current price is much higher) + ); + + await expect(chainlinkAdapter.getPriceData(owner.address)).to.be.revertedWithCustomError( + chainlinkAdapter, + "PriceOutOfBounds", + ); + }); + }); + + describe("transferOwnership (two-step)", function () { + it("Should transfer ownership via propose + accept", async function () { + const newOwner = nonOwner.address; + await chainlinkAdapter.transferOwnership(newOwner); + + // Owner hasn't changed yet — pending owner must accept + expect(await chainlinkAdapter.owner()).to.equal(owner.address); + expect(await chainlinkAdapter.pendingOwner()).to.equal(newOwner); + + // Accept ownership + await chainlinkAdapter.connect(nonOwner).acceptOwnership(); + expect(await chainlinkAdapter.owner()).to.equal(newOwner); + expect(await chainlinkAdapter.pendingOwner()).to.equal(ethers.ZeroAddress); + + // Transfer back for other tests + await chainlinkAdapter.connect(nonOwner).transferOwnership(owner.address); + await chainlinkAdapter.acceptOwnership(); + }); + + it("Should reject accept from non-pending owner", async function () { + await chainlinkAdapter.transferOwnership(nonOwner.address); + await expect(chainlinkAdapter.acceptOwnership()).to.be.revertedWithCustomError( + chainlinkAdapter, + "OwnableUnauthorizedAccount", + ); + // Clean up: accept with correct account + await chainlinkAdapter.connect(nonOwner).acceptOwnership(); + // Transfer back + await chainlinkAdapter.connect(nonOwner).transferOwnership(owner.address); + await chainlinkAdapter.acceptOwnership(); + }); + + it("Should reject zero address", async function () { + // OZ Ownable2Step may or may not revert on zero. If it reverts, expect OwnableInvalidOwner. + try { + await expect(chainlinkAdapter.transferOwnership(ethers.ZeroAddress)) + .to.be.revertedWithCustomError(chainlinkAdapter, "OwnableInvalidOwner") + .withArgs(ethers.ZeroAddress); + } catch { + // Tx did not revert; ensure owner did not become zero + expect(await chainlinkAdapter.owner()).to.equal(owner.address); + } + }); + + it("Should reject non-owner", async function () { + await expect( + chainlinkAdapter.connect(nonOwner).transferOwnership(nonOwner.address), + ).to.be.revertedWithCustomError(chainlinkAdapter, "OwnableUnauthorizedAccount"); + }); + }); +}); diff --git a/test/crossAsset/ERC4626ExecutionAdapter.atomic.test.ts b/test/crossAsset/ERC4626ExecutionAdapter.atomic.test.ts new file mode 100644 index 00000000..8f9d29fd --- /dev/null +++ b/test/crossAsset/ERC4626ExecutionAdapter.atomic.test.ts @@ -0,0 +1,432 @@ +/** + * ERC4626ExecutionAdapter - Atomic Guarantee Unit Tests + * + * Verifies that within a single buy() transaction: + * - previewBuy determines the exact amount pulled from the caller + * - The swap executor receives exactly the previewBuy amount + * - No tokens are stuck in the adapter + * + * Uses mocks (no mainnet fork required) to isolate and verify + * the atomic previewBuy→pull→swap→mint flow. + */ + +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { + ERC4626ExecutionAdapter, + MockUnderlyingAsset, + MockERC4626Asset, + MockOrionConfig, + MockLiquidityOrchestrator, + SpyExecutionAdapter, +} from "../../typechain-types"; + +describe("ERC4626ExecutionAdapter - Atomic Guarantees (Unit)", function () { + let owner: SignerWithAddress; + let loSigner: SignerWithAddress; + + let usdc: MockUnderlyingAsset; + let weth: MockUnderlyingAsset; + let vault: MockERC4626Asset; + let config: MockOrionConfig; + let liquidityOrchestrator: MockLiquidityOrchestrator; + let spySwapExecutor: SpyExecutionAdapter; + + // Contract under test + let vaultAdapter: ERC4626ExecutionAdapter; + + const USDC_DECIMALS = 6; + const WETH_DECIMALS = 18; + + /** Adapter token balances must stay strictly below this (base units); 0 is valid, guarantees dust bound. */ + const MAX_DUST = 10; + + before(async function () { + [owner] = await ethers.getSigners(); + + // Deploy mock tokens + const MockERC20 = await ethers.getContractFactory("MockUnderlyingAsset"); + usdc = (await MockERC20.deploy(USDC_DECIMALS)) as unknown as MockUnderlyingAsset; + weth = (await MockERC20.deploy(WETH_DECIMALS)) as unknown as MockUnderlyingAsset; + + // Deploy mock ERC4626 vault (WETH underlying) + const MockVaultFactory = await ethers.getContractFactory("MockERC4626Asset"); + vault = (await MockVaultFactory.deploy( + await weth.getAddress(), + "Mock WETH Vault", + "mVWETH", + )) as unknown as MockERC4626Asset; + + // Deploy config + const MockConfigFactory = await ethers.getContractFactory("MockOrionConfig"); + config = (await MockConfigFactory.deploy(await usdc.getAddress())) as unknown as MockOrionConfig; + + // Deploy LO + const MockLOFactory = await ethers.getContractFactory("MockLiquidityOrchestrator"); + liquidityOrchestrator = (await MockLOFactory.deploy( + await config.getAddress(), + )) as unknown as MockLiquidityOrchestrator; + + // Wire config → LO + await config.setLiquidityOrchestrator(await liquidityOrchestrator.getAddress()); + + // Register token decimals + await config.setTokenDecimals(await weth.getAddress(), WETH_DECIMALS); + await config.setTokenDecimals(await vault.getAddress(), WETH_DECIMALS); // vault shares = 18 decimals + + // Deploy spy swap executor + const SpyFactory = await ethers.getContractFactory("SpyExecutionAdapter"); + spySwapExecutor = (await SpyFactory.deploy(await usdc.getAddress())) as unknown as SpyExecutionAdapter; + + // Register WETH → spy swap executor in LO + await liquidityOrchestrator.setExecutionAdapter(await weth.getAddress(), await spySwapExecutor.getAddress()); + + // Deploy the real vault adapter (contract under test) + const VaultAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + vaultAdapter = (await VaultAdapterFactory.deploy(await config.getAddress())) as unknown as ERC4626ExecutionAdapter; + + // Register vault → vault adapter in LO + await liquidityOrchestrator.setExecutionAdapter(await vault.getAddress(), await vaultAdapter.getAddress()); + + // Setup impersonated LO signer + const loAddress = await liquidityOrchestrator.getAddress(); + await ethers.provider.send("hardhat_impersonateAccount", [loAddress]); + loSigner = await ethers.getSigner(loAddress); + await owner.sendTransaction({ to: loAddress, value: ethers.parseEther("10") }); + }); + + describe("Cross-Asset Buy - Atomic previewBuy→pull consistency", function () { + const PREVIEW_BUY_AMOUNT = ethers.parseUnits("2500", 6); // 2500 USDC + const SHARES_TO_BUY = ethers.parseUnits("1", 18); // 1 vault share + + beforeEach(async function () { + // Configure spy: previewBuy returns exactly 2500 USDC + await spySwapExecutor.setPreviewBuyReturn(PREVIEW_BUY_AMOUNT); + + // Fund LO with generous USDC (more than needed) + await usdc.mint(loSigner.address, ethers.parseUnits("100000", USDC_DECIMALS)); + + // Fund spy executor with WETH so it can "output" WETH to the vault adapter + const wethNeeded = await vault.previewMint(SHARES_TO_BUY); + await weth.mint(await spySwapExecutor.getAddress(), wethNeeded * 2n); + }); + + it("Should transfer exactly previewBuy amount to swap executor", async function () { + // Approve generous amount — 10x what previewBuy says + const generousApproval = PREVIEW_BUY_AMOUNT * 10n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), generousApproval); + + // Execute buy + await vaultAdapter.connect(loSigner).buy(await vault.getAddress(), SHARES_TO_BUY); + + // The spy recorded how much allowance it received from the vault adapter + const allowanceReceived = await spySwapExecutor.lastBuyAllowanceReceived(); + const previewResult = await spySwapExecutor.lastPreviewBuyResult(); + + // THE ATOMIC GUARANTEE: swap executor received exactly what previewBuy returned + expect(allowanceReceived).to.equal(previewResult); + expect(allowanceReceived).to.equal(PREVIEW_BUY_AMOUNT); + + console.log(` previewBuy returned: ${ethers.formatUnits(previewResult, USDC_DECIMALS)} USDC`); + console.log(` swap executor received: ${ethers.formatUnits(allowanceReceived, USDC_DECIMALS)} USDC`); + }); + + it("Should pull exactly previewBuy amount from caller (not full allowance)", async function () { + const generousApproval = PREVIEW_BUY_AMOUNT * 10n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), generousApproval); + + const balanceBefore = await usdc.balanceOf(loSigner.address); + await vaultAdapter.connect(loSigner).buy(await vault.getAddress(), SHARES_TO_BUY); + const balanceAfter = await usdc.balanceOf(loSigner.address); + + const actualPulled = balanceBefore - balanceAfter; + + // Pulled exactly what previewBuy said, NOT the full generous approval + expect(actualPulled).to.equal(PREVIEW_BUY_AMOUNT); + expect(actualPulled).to.be.lt(generousApproval); + + console.log(` Approved: ${ethers.formatUnits(generousApproval, USDC_DECIMALS)} USDC`); + console.log(` Pulled: ${ethers.formatUnits(actualPulled, USDC_DECIMALS)} USDC`); + }); + + it("Should emit matching previewBuy and buy events in same tx", async function () { + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), PREVIEW_BUY_AMOUNT * 10n); + + const tx = await vaultAdapter.connect(loSigner).buy(await vault.getAddress(), SHARES_TO_BUY); + const receipt = await tx.wait(); + + // Find PreviewBuyCalled and BuyCalled events from the spy + const spyInterface = spySwapExecutor.interface; + const previewEvent = receipt!.logs + .map((log) => { + try { + return spyInterface.parseLog({ topics: [...log.topics], data: log.data }); + } catch { + return null; + } + }) + .find((e) => e?.name === "PreviewBuyCalled"); + + const buyEvent = receipt!.logs + .map((log) => { + try { + return spyInterface.parseLog({ topics: [...log.topics], data: log.data }); + } catch { + return null; + } + }) + .find((e) => e?.name === "BuyCalled"); + + void expect(previewEvent).to.not.be.null; + void expect(buyEvent).to.not.be.null; + + const previewAmount = previewEvent!.args[0]; + const buyReceivedAmount = buyEvent!.args[0]; + + // Both events in the same transaction — values must match exactly + expect(buyReceivedAmount).to.equal(previewAmount); + + console.log(` PreviewBuyCalled: ${ethers.formatUnits(previewAmount, USDC_DECIMALS)} USDC`); + console.log(` BuyCalled received: ${ethers.formatUnits(buyReceivedAmount, USDC_DECIMALS)} USDC`); + }); + + it("Should leave adapter dust below epsilon after buy", async function () { + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), PREVIEW_BUY_AMOUNT * 10n); + + await vaultAdapter.connect(loSigner).buy(await vault.getAddress(), SHARES_TO_BUY); + + const adapterAddr = await vaultAdapter.getAddress(); + const dustUsdc = await usdc.balanceOf(adapterAddr); + const dustWeth = await weth.balanceOf(adapterAddr); + const dustShares = await vault.balanceOf(adapterAddr); + expect(dustUsdc, "USDC dust in adapter").to.be.lt(MAX_DUST); + expect(dustWeth, "WETH dust in adapter").to.be.lt(MAX_DUST); + expect(dustShares, "share dust in adapter").to.be.lt(MAX_DUST); + }); + + it("Should deliver exact shares to caller", async function () { + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), PREVIEW_BUY_AMOUNT * 10n); + + const sharesBefore = await vault.balanceOf(loSigner.address); + await vaultAdapter.connect(loSigner).buy(await vault.getAddress(), SHARES_TO_BUY); + const sharesAfter = await vault.balanceOf(loSigner.address); + + expect(sharesAfter - sharesBefore).to.equal(SHARES_TO_BUY); + }); + + it("Should work with different previewBuy amounts", async function () { + // Test with a different previewBuy value + const differentAmount = ethers.parseUnits("5000", USDC_DECIMALS); + await spySwapExecutor.setPreviewBuyReturn(differentAmount); + + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), differentAmount * 10n); + + const balanceBefore = await usdc.balanceOf(loSigner.address); + await vaultAdapter.connect(loSigner).buy(await vault.getAddress(), SHARES_TO_BUY); + const balanceAfter = await usdc.balanceOf(loSigner.address); + + const actualPulled = balanceBefore - balanceAfter; + expect(actualPulled).to.equal(differentAmount); + + const recorded = await spySwapExecutor.lastBuyAllowanceReceived(); + expect(recorded).to.equal(differentAmount); + + console.log(` previewBuy set to: ${ethers.formatUnits(differentAmount, USDC_DECIMALS)} USDC`); + console.log(` Actually pulled: ${ethers.formatUnits(actualPulled, USDC_DECIMALS)} USDC`); + }); + }); + + describe("Same-Asset Buy - Atomic previewMint→pull consistency", function () { + let usdcVault: MockERC4626Asset; + let usdcVaultAdapter: ERC4626ExecutionAdapter; + + before(async function () { + // Deploy same-asset vault (USDC → USDC vault) + const MockVaultFactory = await ethers.getContractFactory("MockERC4626Asset"); + usdcVault = (await MockVaultFactory.deploy( + await usdc.getAddress(), + "USDC Vault", + "vUSDC", + )) as unknown as MockERC4626Asset; + + // Register decimals + await config.setTokenDecimals(await usdc.getAddress(), USDC_DECIMALS); + await config.setTokenDecimals(await usdcVault.getAddress(), USDC_DECIMALS); + + // Deploy separate adapter + const VaultAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + usdcVaultAdapter = (await VaultAdapterFactory.deploy( + await config.getAddress(), + )) as unknown as ERC4626ExecutionAdapter; + + // Register in LO + await liquidityOrchestrator.setExecutionAdapter( + await usdcVault.getAddress(), + await usdcVaultAdapter.getAddress(), + ); + + // Seed vault with initial deposit to establish exchange rate + await usdc.mint(owner.address, ethers.parseUnits("10000", USDC_DECIMALS)); + await usdc.approve(await usdcVault.getAddress(), ethers.parseUnits("10000", USDC_DECIMALS)); + await usdcVault.deposit(ethers.parseUnits("10000", USDC_DECIMALS), owner.address); + }); + + it("Should pull exactly previewMint amount (same-asset, no swap)", async function () { + const sharesAmount = ethers.parseUnits("100", USDC_DECIMALS); + + // What the vault says it needs + const previewMintAmount = await usdcVault.previewMint(sharesAmount); + + // Fund LO and approve generously + await usdc.mint(loSigner.address, previewMintAmount * 10n); + const generousApproval = previewMintAmount * 10n; + await usdc.connect(loSigner).approve(await usdcVaultAdapter.getAddress(), generousApproval); + + const balanceBefore = await usdc.balanceOf(loSigner.address); + await usdcVaultAdapter.connect(loSigner).buy(await usdcVault.getAddress(), sharesAmount); + const balanceAfter = await usdc.balanceOf(loSigner.address); + + const actualPulled = balanceBefore - balanceAfter; + + // Same-asset: pulled exactly previewMint, not the full approval + expect(actualPulled).to.equal(previewMintAmount); + expect(actualPulled).to.be.lt(generousApproval); + + // Exact shares delivered + const shares = await usdcVault.balanceOf(loSigner.address); + expect(shares).to.equal(sharesAmount); + + console.log(` previewMint: ${ethers.formatUnits(previewMintAmount, USDC_DECIMALS)} USDC`); + console.log(` Pulled: ${ethers.formatUnits(actualPulled, USDC_DECIMALS)} USDC`); + console.log(` Approved: ${ethers.formatUnits(generousApproval, USDC_DECIMALS)} USDC`); + }); + }); + + describe("Buy slippage enforcement", function () { + /** + * (a) USDC-underlying vault: oracle implies low share price but actual vault has high share price. + * Caller approves based on low estimate; adapter pulls previewMint (high) → insufficient allowance, order fails. + */ + it("(a) USDC-underlying vault: very low share price from oracle, failing order", async function () { + const MockVaultFactory = await ethers.getContractFactory("MockERC4626Asset"); + const highPriceUsdcVault = (await MockVaultFactory.deploy( + await usdc.getAddress(), + "High Price USDC Vault", + "hpUSDC", + )) as unknown as MockERC4626Asset; + + await config.setTokenDecimals(await usdc.getAddress(), USDC_DECIMALS); + await config.setTokenDecimals(await highPriceUsdcVault.getAddress(), USDC_DECIMALS); + const HighPriceAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + const highPriceAdapter = (await HighPriceAdapterFactory.deploy( + await config.getAddress(), + )) as unknown as ERC4626ExecutionAdapter; + await liquidityOrchestrator.setExecutionAdapter( + await highPriceUsdcVault.getAddress(), + await highPriceAdapter.getAddress(), + ); + + // Seed vault: 100 USDC → 100 shares (1:1), then simulate gains so 1 share = 10_000 USDC + const seedUsdc = ethers.parseUnits("100", USDC_DECIMALS); + await usdc.mint(owner.address, seedUsdc + ethers.parseUnits("999900", USDC_DECIMALS)); + await usdc.approve(await highPriceUsdcVault.getAddress(), seedUsdc); + await highPriceUsdcVault.deposit(seedUsdc, owner.address); + await usdc.transfer(await highPriceUsdcVault.getAddress(), ethers.parseUnits("999900", USDC_DECIMALS)); + // Now totalAssets = 1e6 USDC, totalSupply = 100e6 (100 shares in 6 decimals). 1 share = 10_000 USDC. + + const oneShare = ethers.parseUnits("1", USDC_DECIMALS); + const actualCost = await highPriceUsdcVault.previewMint(oneShare); + + // Caller (LO) approves as if oracle said share was cheap (e.g. 100 USDC per share) + const lowApproval = ethers.parseUnits("100", USDC_DECIMALS); + expect(actualCost).to.be.gt(lowApproval); // actual cost far exceeds low approval → order must fail + await usdc.mint(loSigner.address, actualCost); + await usdc.connect(loSigner).approve(await highPriceAdapter.getAddress(), lowApproval); + + await expect(highPriceAdapter.connect(loSigner).buy(await highPriceUsdcVault.getAddress(), oneShare)).to.be + .reverted; // ERC20: insufficient allowance when adapter pulls actualCost + }); + + /** + * (b) ERC20-underlying vault: very bad USDC/ERC20 exchange rate. + * previewBuy returns huge USDC needed; caller approved only a small amount → pull fails. + */ + it("(b) ERC20-underlying vault: very bad USDC/ERC20 rate, failing order", async function () { + const sharesAmount = ethers.parseUnits("1", 18); + const badRateUsdcNeeded = ethers.parseUnits("1000000", USDC_DECIMALS); // 1M USDC for 1 share's WETH + await spySwapExecutor.setPreviewBuyReturn(badRateUsdcNeeded); + + const smallApproval = ethers.parseUnits("2000", USDC_DECIMALS); // thought rate was good + await usdc.mint(loSigner.address, badRateUsdcNeeded); + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), smallApproval); + + await expect(vaultAdapter.connect(loSigner).buy(await vault.getAddress(), sharesAmount)).to.be.reverted; // insufficient allowance + }); + + /** + * (c) ERC20-underlying vault: bad share/ERC20 price but very good ERC20/USDC rate → net positive slippage, order passes. + */ + it("(c) ERC20-underlying vault: bad share/ERC20 but good ERC20/USDC, net positive slippage, order passes", async function () { + // Use a fresh vault so share price is deterministic: 1 share = 0.1 WETH + const MockVaultFactory = await ethers.getContractFactory("MockERC4626Asset"); + const lowPriceVault = (await MockVaultFactory.deploy( + await weth.getAddress(), + "Low WETH Price Vault", + "lwVault", + )) as unknown as MockERC4626Asset; + await config.setTokenDecimals(await lowPriceVault.getAddress(), WETH_DECIMALS); + await liquidityOrchestrator.setExecutionAdapter( + await lowPriceVault.getAddress(), + await vaultAdapter.getAddress(), + ); + + const oneWeth = ethers.parseUnits("1", WETH_DECIMALS); + const oneShare = ethers.parseUnits("1", 18); + await weth.mint(owner.address, oneWeth); + await weth.approve(await lowPriceVault.getAddress(), oneWeth); + await lowPriceVault.deposit(oneWeth, owner.address); + await lowPriceVault.simulateLosses(ethers.parseUnits("0.9", WETH_DECIMALS), owner.address); + const wethNeeded = await lowPriceVault.previewMint(oneShare); + const expectedWeth = ethers.parseUnits("0.1", WETH_DECIMALS); + expect(wethNeeded).to.be.gte(expectedWeth); + expect(wethNeeded).to.be.lte(expectedWeth + 1n); // round-up + + // Spy: good USDC/WETH rate — 0.1 WETH costs only 100 USDC + const goodRateUsdc = ethers.parseUnits("100", USDC_DECIMALS); + await spySwapExecutor.setPreviewBuyReturn(goodRateUsdc); + + const approvalWithSlippage = ethers.parseUnits("110", USDC_DECIMALS); // 10% buffer + await usdc.mint(loSigner.address, approvalWithSlippage); + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), approvalWithSlippage); + await weth.mint(await spySwapExecutor.getAddress(), wethNeeded * 2n); + + await expect(vaultAdapter.connect(loSigner).buy(await lowPriceVault.getAddress(), oneShare)).to.not.be.reverted; + + const sharesReceived = await lowPriceVault.balanceOf(loSigner.address); + expect(sharesReceived).to.be.gte(oneShare); + expect(sharesReceived).to.be.lte(oneShare + 1n); // allow 1 wei rounding + }); + }); + + describe("Validation - vault whitelisted before underlying", function () { + it("Should revert when vault underlying is not registered in config (simulates whitelist vault before underlying)", async function () { + // Config that returns 0 for unset tokens (like OrionConfig) + const strictConfigFactory = await ethers.getContractFactory("MockOrionConfig"); + const strictConfig = (await strictConfigFactory.deploy(await usdc.getAddress())) as unknown as MockOrionConfig; + await strictConfig.setLiquidityOrchestrator(await liquidityOrchestrator.getAddress()); + await strictConfig.setReturnZeroForUnsetTokens(true); + await strictConfig.setTokenDecimals(await vault.getAddress(), WETH_DECIMALS); // vault shares only; WETH not set => 0 + + const adapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + const adapter = (await adapterFactory.deploy( + await strictConfig.getAddress(), + )) as unknown as ERC4626ExecutionAdapter; + + await expect(adapter.validateExecutionAdapter(await vault.getAddress())).to.be.revertedWithCustomError( + adapter, + "InvalidAdapter", + ); + }); + }); +}); diff --git a/test/crossAsset/ERC4626ExecutionAdapter.test.ts b/test/crossAsset/ERC4626ExecutionAdapter.test.ts new file mode 100644 index 00000000..7d0b6303 --- /dev/null +++ b/test/crossAsset/ERC4626ExecutionAdapter.test.ts @@ -0,0 +1,1222 @@ +/** + * ERC4626ExecutionAdapter E2E Tests + * + * Tests the new architecture where: + * 1. Token swap executors are registered for tokens (WETH → UniswapV3ExecutionAdapter) + * 2. Vault adapters are registered for vaults (Morpho WETH vault → ERC4626ExecutionAdapter) + * 3. Vault adapters delegate to swap executors via LO's executionAdapterOf mapping + * + * Test Coverage: + * 1. Buy flow: USDC → vault adapter → swap executor → WETH → Morpho vault + * 2. Sell flow: Morpho vault → WETH → swap executor → USDC + * 3. Same-asset flow: USDC → USDC vault (no swap) + * 4. Error cases: Missing swap executor, invalid configuration + * 5. Slippage management (centralized in LO) + * 6. Gas benchmarking + */ + +import { expect } from "chai"; +import { ethers, network } from "hardhat"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { + OrionConfig, + ERC4626ExecutionAdapter, + UniswapV3ExecutionAdapter, + ChainlinkPriceAdapter, + MockERC4626PriceAdapter, + IERC4626, + IERC20, + MockLiquidityOrchestrator, + MockERC4626Asset, +} from "../../typechain-types"; + +/** Adapter USDC balance must stay strictly below this (in base units); 0 is valid, guarantees dust bound. + * To find the failure threshold: set to 0 and run; the failure will show actual dust (e.g. "expected 5 to be below 0" → need > 5). */ +const MAX_USDC_DUST = 10; + +// Mainnet addresses +const MAINNET = { + USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + WETH: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + WBTC: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + + // Morpho Vaults + MORPHO_WETH: "0x31A5684983EeE865d943A696AAC155363bA024f9", // Vault Bridge WETH (vbgtWETH) + + // Uniswap V3 + UNISWAP_V3_FACTORY: "0x1F98431c8aD98523631AE4a59f267346ea31F984", + UNISWAP_ROUTER: "0xE592427A0AEce92De3Edee1F18E0157C05861564", + UNISWAP_QUOTER_V2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e", + USDC_WETH_POOL: "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640", // 0.05% fee + WETH_FEE: 500, // 0.05% fee tier for USDC-WETH pool + + // Chainlink Oracles + CHAINLINK_ETH_USD: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", + CHAINLINK_BTC_USD: "0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c", + + // Whale addresses for token acquisition + USDC_WHALE: "0x37305b1cd40574e4c5ce33f8e8306be057fd7341", // SKY: PSM + WETH_WHALE: "0x4d5f47fa6a74757f35c14fd3a6ef8e3c9bc514e8", // Aave +}; + +describe("ERC4626ExecutionAdapter", function () { + let owner: SignerWithAddress; + + let orionConfig: OrionConfig; + let liquidityOrchestrator: MockLiquidityOrchestrator; + let loSigner: SignerWithAddress; // Impersonated signer for LO contract + + // Adapters + let vaultAdapter: ERC4626ExecutionAdapter; + let tokenSwapExecutor: UniswapV3ExecutionAdapter; + + // Price adapters + let chainlinkAdapter: ChainlinkPriceAdapter; + let vaultPriceAdapter: MockERC4626PriceAdapter; + + // Tokens + let usdc: IERC20; + let weth: IERC20; + let morphoWETH: IERC4626; + + // Test parameters + const USDC_DECIMALS = 6; + const WETH_DECIMALS = 18; + const SLIPPAGE_TOLERANCE = 200; // 2% + const INITIAL_USDC_BALANCE = ethers.parseUnits("100000", USDC_DECIMALS); // 100k USDC + + before(async function () { + this.timeout(120000); // 2 minutes for mainnet forking + + // Skip all tests if not forking mainnet + const networkConfig = network.config; + if (!("forking" in networkConfig) || !networkConfig.forking || !networkConfig.forking.url) { + this.skip(); + } + + [owner] = await ethers.getSigners(); + + // Get contract instances + usdc = await ethers.getContractAt("IERC20", MAINNET.USDC); + weth = await ethers.getContractAt("IERC20", MAINNET.WETH); + morphoWETH = await ethers.getContractAt("IERC4626", MAINNET.MORPHO_WETH); + }); + + describe("Setup and Deployment", function () { + it("Should deploy all contracts", async function () { + this.timeout(60000); + + // Deploy minimal OrionConfig mock + const MockOrionConfigFactory = await ethers.getContractFactory("MockOrionConfig"); + orionConfig = (await MockOrionConfigFactory.deploy(MAINNET.USDC)) as OrionConfig; + + // Deploy MockLiquidityOrchestrator with slippage tolerance + const MockLiquidityOrchestratorFactory = await ethers.getContractFactory("MockLiquidityOrchestrator"); + liquidityOrchestrator = await MockLiquidityOrchestratorFactory.deploy(await orionConfig.getAddress()); + + // Set slippage tolerance in LO + await liquidityOrchestrator.setSlippageTolerance(SLIPPAGE_TOLERANCE); + + // Deploy Chainlink price adapter (no constructor args) + const ChainlinkAdapterFactory = await ethers.getContractFactory("ChainlinkPriceAdapter"); + chainlinkAdapter = await ChainlinkAdapterFactory.deploy(); + + // Configure Chainlink feeds + await chainlinkAdapter.configureFeed( + MAINNET.WETH, + MAINNET.CHAINLINK_ETH_USD, + false, // not inverse + 3600, // 1 hour staleness + ethers.parseUnits("1000", 8), // min $1,000 + ethers.parseUnits("10000", 8), // max $10,000 + ); + + // Deploy MockPriceAdapterRegistry and configure it + const MockPriceAdapterRegistryFactory = await ethers.getContractFactory("MockPriceAdapterRegistry"); + const priceAdapterRegistry = await MockPriceAdapterRegistryFactory.deploy(); + await priceAdapterRegistry.setPriceAdapter(MAINNET.WETH, await chainlinkAdapter.getAddress()); + + // Configure mock OrionConfig + const mockConfig = await ethers.getContractAt("MockOrionConfig", await orionConfig.getAddress()); + await mockConfig.setPriceAdapterRegistry(await priceAdapterRegistry.getAddress()); + await mockConfig.setLiquidityOrchestrator(await liquidityOrchestrator.getAddress()); + + // Deploy vault price adapter + const VaultPriceAdapterFactory = await ethers.getContractFactory("MockERC4626PriceAdapter"); + vaultPriceAdapter = await VaultPriceAdapterFactory.deploy(await orionConfig.getAddress()); + + // Deploy token swap executor (for WETH token swaps) + const TokenSwapExecutorFactory = await ethers.getContractFactory("UniswapV3ExecutionAdapter"); + tokenSwapExecutor = await TokenSwapExecutorFactory.deploy( + owner.address, + MAINNET.UNISWAP_V3_FACTORY, + MAINNET.UNISWAP_ROUTER, + MAINNET.UNISWAP_QUOTER_V2, + await orionConfig.getAddress(), + ); + + // Register WETH fee tier so the adapter can swap USDC ↔ WETH + await tokenSwapExecutor.setAssetFee(MAINNET.WETH, MAINNET.WETH_FEE); + + // Deploy vault adapter (for ERC4626 vaults) + const VaultAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + vaultAdapter = await VaultAdapterFactory.deploy(await orionConfig.getAddress()); + + void expect(await tokenSwapExecutor.getAddress()).to.be.properAddress; + void expect(await vaultAdapter.getAddress()).to.be.properAddress; + void expect(await chainlinkAdapter.getAddress()).to.be.properAddress; + void expect(await vaultPriceAdapter.getAddress()).to.be.properAddress; + }); + + it("Should register WETH token with swap executor in LO", async function () { + // Register WETH token → tokenSwapExecutor + await liquidityOrchestrator.setExecutionAdapter(MAINNET.WETH, await tokenSwapExecutor.getAddress()); + + const registeredAdapter = await liquidityOrchestrator.executionAdapterOf(MAINNET.WETH); + expect(registeredAdapter).to.equal(await tokenSwapExecutor.getAddress()); + }); + + it("Should register Morpho WETH vault with vault adapter in LO", async function () { + // Register Morpho vault → vaultAdapter + await liquidityOrchestrator.setExecutionAdapter(MAINNET.MORPHO_WETH, await vaultAdapter.getAddress()); + + const registeredAdapter = await liquidityOrchestrator.executionAdapterOf(MAINNET.MORPHO_WETH); + expect(registeredAdapter).to.equal(await vaultAdapter.getAddress()); + }); + + it("Should fund test accounts with USDC", async function () { + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + + // Fund whale with ETH for gas + await owner.sendTransaction({ + to: MAINNET.USDC_WHALE, + value: ethers.parseEther("10"), + }); + + const loAddress = await liquidityOrchestrator.getAddress(); + await usdc.connect(usdcWhale).transfer(loAddress, INITIAL_USDC_BALANCE); + + const balance = await usdc.balanceOf(loAddress); + expect(balance).to.equal(INITIAL_USDC_BALANCE); + }); + + it("Should setup impersonated LO signer", async function () { + const loAddress = await liquidityOrchestrator.getAddress(); + await network.provider.request({ + method: "hardhat_impersonateAccount", + params: [loAddress], + }); + + loSigner = await ethers.getSigner(loAddress); + + // Fund it with ETH for gas + await owner.sendTransaction({ + to: loAddress, + value: ethers.parseEther("10"), + }); + + const ethBalance = await ethers.provider.getBalance(loAddress); + expect(ethBalance).to.be.gt(0); + }); + + it("Should validate Morpho WETH vault", async function () { + const underlying = await morphoWETH.asset(); + expect(underlying).to.equal(MAINNET.WETH); + + const decimals = await morphoWETH.decimals(); + expect(decimals).to.equal(18); + }); + }); + + describe("Buy Flow: USDC → Vault Adapter → Swap Executor → WETH → Morpho Vault", function () { + let initialUSDCBalance: bigint; + let sharesAmount: bigint; + let estimatedUSDCCost: bigint; + + before(async function () { + initialUSDCBalance = await usdc.balanceOf(loSigner.address); + sharesAmount = ethers.parseUnits("1", 18); // 1 vbgtWETH share + }); + + it("Should calculate accurate price estimate", async function () { + // Get vault share → WETH conversion + const wethPerShare = await morphoWETH.convertToAssets(sharesAmount); + console.log(` 1 vbgtWETH = ${ethers.formatUnits(wethPerShare, 18)} WETH`); + + // Get WETH → USD price from Chainlink + const [wethPriceRaw, priceDecimals] = await chainlinkAdapter.getPriceData(MAINNET.WETH); + const wethPriceUSD = wethPriceRaw / 10n ** (BigInt(priceDecimals) - 2n); + console.log(` 1 WETH = $${wethPriceUSD / 100n}`); + + // Estimate USDC cost + estimatedUSDCCost = (wethPerShare * wethPriceUSD) / 10n ** BigInt(18 + 2 - USDC_DECIMALS); + console.log(` Estimated cost: ${ethers.formatUnits(estimatedUSDCCost, USDC_DECIMALS)} USDC`); + }); + + it("Should execute buy - vault adapter delegates to swap executor", async function () { + // Calculate max USDC with slippage (LO manages slippage) + const maxUSDC = (estimatedUSDCCost * (10000n + BigInt(SLIPPAGE_TOLERANCE))) / 10000n; + + // Approve vault adapter to spend USDC (with slippage buffer) + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + // Execute buy + const tx = await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + + const receipt = await tx.wait(); + console.log(` Gas used: ${receipt!.gasUsed.toLocaleString()}`); + + // Verify shares received + const sharesBalance = await morphoWETH.balanceOf(loSigner.address); + expect(sharesBalance).to.equal(sharesAmount); + }); + + it("Should refund excess USDC", async function () { + const finalUSDCBalance = await usdc.balanceOf(loSigner.address); + const usdcSpent = initialUSDCBalance - finalUSDCBalance; + + console.log(` USDC spent: ${ethers.formatUnits(usdcSpent, USDC_DECIMALS)}`); + console.log(` USDC estimated: ${ethers.formatUnits(estimatedUSDCCost, USDC_DECIMALS)}`); + + // Should be within slippage tolerance + const maxSpend = (estimatedUSDCCost * (10000n + BigInt(SLIPPAGE_TOLERANCE))) / 10000n; + expect(usdcSpent).to.be.lte(maxSpend); + }); + + it("Should enforce slippage protection", async function () { + // Try to buy with unrealistically low allowance + const tooLowAllowance = estimatedUSDCCost / 10n; // 10x too low + + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), tooLowAllowance); + + await expect(vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount)).to.be.reverted; // Should revert due to insufficient allowance + }); + }); + + describe("Sell Flow: Morpho Vault → WETH → Swap Executor → USDC", function () { + let initialUSDCBalance: bigint; + let sharesToSell: bigint; + let estimatedUSDCReceived: bigint; + + before(async function () { + initialUSDCBalance = await usdc.balanceOf(loSigner.address); + sharesToSell = await morphoWETH.balanceOf(loSigner.address); + }); + + it("Should calculate accurate sell estimate", async function () { + // Get vault share → WETH conversion + const wethToReceive = await morphoWETH.convertToAssets(sharesToSell); + console.log(` ${ethers.formatUnits(sharesToSell, 18)} vbgtWETH = ${ethers.formatUnits(wethToReceive, 18)} WETH`); + + // Get WETH → USD price + const [wethPriceRaw, priceDecimals] = await chainlinkAdapter.getPriceData(MAINNET.WETH); + const wethPriceUSD = wethPriceRaw / 10n ** (BigInt(priceDecimals) - 2n); + + // Estimate USDC received (all BigInt arithmetic, no Number truncation) + estimatedUSDCReceived = (wethToReceive * wethPriceUSD) / 10n ** BigInt(18 + 2 - USDC_DECIMALS); + console.log(` Estimated receive: ${ethers.formatUnits(estimatedUSDCReceived, USDC_DECIMALS)} USDC`); + }); + + it("Should execute sell - vault adapter delegates to swap executor", async function () { + // Approve vault adapter to spend shares + await morphoWETH.connect(loSigner).approve(await vaultAdapter.getAddress(), sharesToSell); + + // Execute sell (LO validates final amount, adapter passes 0 as minAmount) + const tx = await vaultAdapter.connect(loSigner).sell(MAINNET.MORPHO_WETH, sharesToSell); + + const receipt = await tx.wait(); + console.log(` Gas used: ${receipt!.gasUsed.toLocaleString()}`); + + // Verify shares burned + const sharesBalance = await morphoWETH.balanceOf(loSigner.address); + expect(sharesBalance).to.equal(0); + }); + + it("Should receive expected USDC amount", async function () { + const finalUSDCBalance = await usdc.balanceOf(loSigner.address); + const usdcReceived = finalUSDCBalance - initialUSDCBalance; + + console.log(` USDC received: ${ethers.formatUnits(usdcReceived, USDC_DECIMALS)}`); + console.log(` USDC estimated: ${ethers.formatUnits(estimatedUSDCReceived, USDC_DECIMALS)}`); + + // Should be within slippage tolerance + const minReceive = (estimatedUSDCReceived * BigInt(10000 - SLIPPAGE_TOLERANCE)) / 10000n; + expect(usdcReceived).to.be.gte(minReceive); + }); + }); + + describe("Architecture Validation", function () { + it("Should confirm vault adapter uses swap executor from LO", async function () { + // Verify WETH is registered with token swap executor + const wethAdapter = await liquidityOrchestrator.executionAdapterOf(MAINNET.WETH); + expect(wethAdapter).to.equal(await tokenSwapExecutor.getAddress()); + + // Verify Morpho vault is registered with vault adapter + const vaultAdapterAddr = await liquidityOrchestrator.executionAdapterOf(MAINNET.MORPHO_WETH); + expect(vaultAdapterAddr).to.equal(await vaultAdapter.getAddress()); + + console.log(` ✓ WETH token → ${await tokenSwapExecutor.getAddress()}`); + console.log(` ✓ Morpho vault → ${await vaultAdapter.getAddress()}`); + console.log(` ✓ Vault adapter delegates to swap executor via LO.executionAdapterOf[WETH]`); + }); + + it("Should revert if swap executor not set for underlying token", async function () { + // Create a mock vault with an underlying that has no swap executor + // This would happen if we try to buy a vault whose underlying isn't whitelisted + // For this test, we'd need to deploy a mock vault - skip for mainnet fork test + // The architecture ensures this is validated at whitelist time + }); + + it("Should maintain approval hygiene", async function () { + const vaultAdapterAddress = await vaultAdapter.getAddress(); + + const usdcAllowance = await usdc.allowance(vaultAdapterAddress, await tokenSwapExecutor.getAddress()); + const wethAllowance = await weth.allowance(vaultAdapterAddress, MAINNET.MORPHO_WETH); + + expect(usdcAllowance).to.equal(0); + expect(wethAllowance).to.equal(0); + }); + }); + + describe("Validation Tests", function () { + it("Should reject non-ERC4626 assets", async function () { + // Try to validate a regular ERC20 (USDC) which is not ERC4626 + await expect(vaultAdapter.validateExecutionAdapter(MAINNET.USDC)).to.be.revertedWithCustomError( + vaultAdapter, + "InvalidAdapter", + ); + }); + + it("Should reject vault with no swap executor for underlying", async function () { + // Deploy a mock vault with WBTC underlying (no swap executor registered) + const MockERC4626Factory = await ethers.getContractFactory("MockERC4626Asset"); + const mockVault = await MockERC4626Factory.deploy(MAINNET.WBTC, "Mock WBTC Vault", "mWBTC"); + await mockVault.waitForDeployment(); + + // MockOrionConfig returns hardcoded 18 decimals for all tokens + // Should fail validation because WBTC has no swap executor registered in LO + await expect(vaultAdapter.validateExecutionAdapter(await mockVault.getAddress())).to.be.revertedWithCustomError( + vaultAdapter, + "InvalidAdapter", + ); + }); + }); + + describe("Share Accounting Precision", function () { + before(async function () { + // Fund for precision tests + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + await owner.sendTransaction({ + to: MAINNET.USDC_WHALE, + value: ethers.parseEther("10"), + }); + + const fundAmount = ethers.parseUnits("50000", USDC_DECIMALS); + await usdc.connect(usdcWhale).transfer(loSigner.address, fundAmount); + }); + + it("Should mint exact shares requested via buy()", async function () { + const exactShares = ethers.parseUnits("2.5", 18); // Request exactly 2.5 shares + + // Calculate cost + const wethNeeded = await morphoWETH.convertToAssets(exactShares); + const [wethPrice, priceDecimals] = await chainlinkAdapter.getPriceData(MAINNET.WETH); + const estimatedCost = + (wethNeeded * wethPrice) / 10n ** (BigInt(WETH_DECIMALS) + BigInt(priceDecimals) - BigInt(USDC_DECIMALS)); + + const maxUSDC = (estimatedCost * (10000n + BigInt(SLIPPAGE_TOLERANCE))) / 10000n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, exactShares); + + // Verify EXACTLY 2.5 shares received (no drift) + const sharesBalance = await morphoWETH.balanceOf(loSigner.address); + expect(sharesBalance).to.equal(exactShares); + console.log(` Requested: ${ethers.formatUnits(exactShares, 18)} shares`); + console.log(` Received: ${ethers.formatUnits(sharesBalance, 18)} shares`); + }); + + it("Should handle multiple sequential buy operations with no drift", async function () { + const buyAmount = ethers.parseUnits("0.5", 18); // Buy 0.5 shares each time + const iterations = 3; + + let totalSharesExpected = await morphoWETH.balanceOf(loSigner.address); + + for (let i = 0; i < iterations; i++) { + const wethNeeded = await morphoWETH.convertToAssets(buyAmount); + const [wethPrice, priceDecimals] = await chainlinkAdapter.getPriceData(MAINNET.WETH); + const estimatedCost = + (wethNeeded * wethPrice) / 10n ** (BigInt(WETH_DECIMALS) + BigInt(priceDecimals) - BigInt(USDC_DECIMALS)); + + const maxUSDC = (estimatedCost * (10000n + BigInt(SLIPPAGE_TOLERANCE))) / 10000n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, buyAmount); + + totalSharesExpected += buyAmount; + + const currentBalance = await morphoWETH.balanceOf(loSigner.address); + expect(currentBalance).to.equal(totalSharesExpected); + console.log(` Iteration ${i + 1}: ${ethers.formatUnits(currentBalance, 18)} shares (no drift)`); + } + }); + + it("Should refund excess underlying when swap uses less than max", async function () { + const sharesAmount = ethers.parseUnits("0.2", 18); + const balanceBefore = await usdc.balanceOf(loSigner.address); + + const wethNeeded = await morphoWETH.convertToAssets(sharesAmount); + const [wethPrice, priceDecimals] = await chainlinkAdapter.getPriceData(MAINNET.WETH); + const estimatedCost = + (wethNeeded * wethPrice) / 10n ** (BigInt(WETH_DECIMALS) + BigInt(priceDecimals) - BigInt(USDC_DECIMALS)); + + const maxUSDC = (estimatedCost * (10000n + BigInt(SLIPPAGE_TOLERANCE))) / 10000n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + + const balanceAfter = await usdc.balanceOf(loSigner.address); + const actualSpent = balanceBefore - balanceAfter; + + // Actual spent should be less than max (refund occurred) + expect(actualSpent).to.be.lt(maxUSDC); + console.log(` Max approved: ${ethers.formatUnits(maxUSDC, USDC_DECIMALS)} USDC`); + console.log(` Actual spent: ${ethers.formatUnits(actualSpent, USDC_DECIMALS)} USDC`); + console.log(` Refunded: ${ethers.formatUnits(maxUSDC - actualSpent, USDC_DECIMALS)} USDC`); + }); + }); + + describe("Buy - previewBuy Accuracy", function () { + before(async function () { + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + await owner.sendTransaction({ to: MAINNET.USDC_WHALE, value: ethers.parseEther("10") }); + const fundAmount = ethers.parseUnits("50000", USDC_DECIMALS); + await usdc.connect(usdcWhale).transfer(loSigner.address, fundAmount); + }); + + it("Should pull exact previewBuy amount (atomic within buy tx)", async function () { + const sharesAmount = ethers.parseUnits("0.5", 18); + + // previewBuy tells us roughly how much the buy will cost + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + expect(previewedCost).to.be.gt(0); + + // Approve generous amount — the contract only pulls what previewBuy returns internally + const generousApproval = previewedCost * 2n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), generousApproval); + + const balanceBefore = await usdc.balanceOf(loSigner.address); + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + const balanceAfter = await usdc.balanceOf(loSigner.address); + + const actualSpent = balanceBefore - balanceAfter; + + // The key invariant: buy() pulls exactly what its internal previewBuy returns, + // NOT the full approved amount. Verify it didn't drain the generous approval. + expect(actualSpent).to.be.lt(generousApproval); + // And it's in the right ballpark of our external preview (same order of magnitude) + expect(actualSpent).to.be.gt(previewedCost / 2n); + expect(actualSpent).to.be.lt(previewedCost * 2n); + + console.log(` Approved: ${ethers.formatUnits(generousApproval, USDC_DECIMALS)} USDC`); + console.log(` Actually pulled: ${ethers.formatUnits(actualSpent, USDC_DECIMALS)} USDC`); + console.log(` External preview: ${ethers.formatUnits(previewedCost, USDC_DECIMALS)} USDC`); + }); + + it("Should have previewBuy scale linearly with share amount", async function () { + const smallShares = ethers.parseUnits("0.1", 18); + const largeShares = ethers.parseUnits("1", 18); + + const smallCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, smallShares); + const largeCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, largeShares); + + // Large should be roughly 10x small (within 1% for AMM price impact) + const ratio = (largeCost * 1000n) / smallCost; + expect(ratio).to.be.gte(9900n); // At least 9.9x + expect(ratio).to.be.lte(10100n); // At most 10.1x + console.log(` 0.1 shares cost: ${ethers.formatUnits(smallCost, USDC_DECIMALS)} USDC`); + console.log(` 1.0 shares cost: ${ethers.formatUnits(largeCost, USDC_DECIMALS)} USDC`); + console.log(` Ratio: ${Number(ratio) / 1000}`); + }); + }); + + describe("Buy - Return Value & Accounting", function () { + before(async function () { + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + await owner.sendTransaction({ to: MAINNET.USDC_WHALE, value: ethers.parseEther("10") }); + const fundAmount = ethers.parseUnits("50000", USDC_DECIMALS); + await usdc.connect(usdcWhale).transfer(loSigner.address, fundAmount); + }); + + it("Should return non-zero spentUnderlyingAmount from buy (cross-asset)", async function () { + const sharesAmount = ethers.parseUnits("0.3", 18); + + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + const maxUSDC = (previewedCost * 10200n) / 10000n; // 2% buffer + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + const balanceBefore = await usdc.balanceOf(loSigner.address); + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + const balanceAfter = await usdc.balanceOf(loSigner.address); + const actualSpent = balanceBefore - balanceAfter; + + // Verify non-zero and sensible spend + expect(actualSpent).to.be.gt(0); + // Spend should be less than max approved (previewBuy-based pull, not allowance-based) + expect(actualSpent).to.be.lte(maxUSDC); + + console.log(` Actual spent: ${ethers.formatUnits(actualSpent, USDC_DECIMALS)} USDC`); + console.log(` Max approved: ${ethers.formatUnits(maxUSDC, USDC_DECIMALS)} USDC`); + }); + + it("Should revert buy with zero shares amount", async function () { + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), ethers.parseUnits("1000", USDC_DECIMALS)); + + // Zero shares should revert (previewMint(0) returns 0, but vault.mint(0) may behave differently) + await expect(vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, 0)).to.be.reverted; + }); + + it("Should leave zero token approvals after buy (approval hygiene)", async function () { + const sharesAmount = ethers.parseUnits("0.1", 18); + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + const maxUSDC = (previewedCost * 10200n) / 10000n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + + const vaultAdapterAddr = await vaultAdapter.getAddress(); + const swapExecutorAddr = await tokenSwapExecutor.getAddress(); + + // No leftover USDC approval from adapter → swap executor + const usdcApproval = await usdc.allowance(vaultAdapterAddr, swapExecutorAddr); + expect(usdcApproval).to.equal(0); + + // No leftover WETH approval from adapter → vault + const wethApproval = await weth.allowance(vaultAdapterAddr, MAINNET.MORPHO_WETH); + expect(wethApproval).to.equal(0); + + console.log(` USDC allowance (adapter→swapExecutor): ${usdcApproval}`); + console.log(` WETH allowance (adapter→vault): ${wethApproval}`); + }); + + it("Should not leave adapter holding any tokens after buy", async function () { + const sharesAmount = ethers.parseUnits("0.2", 18); + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + const maxUSDC = (previewedCost * 10200n) / 10000n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + + const vaultAdapterAddr = await vaultAdapter.getAddress(); + + // Adapter dust must stay below epsilon (no refunding) + const adapterUSDC = await usdc.balanceOf(vaultAdapterAddr); + const adapterWETH = await weth.balanceOf(vaultAdapterAddr); + const adapterShares = await morphoWETH.balanceOf(vaultAdapterAddr); + + expect(adapterUSDC, "USDC dust in adapter").to.be.lt(MAX_USDC_DUST); + expect(adapterWETH, "WETH dust in adapter").to.be.lt(MAX_USDC_DUST); + expect(adapterShares, "share dust in adapter").to.be.lt(MAX_USDC_DUST); + + console.log(` Adapter USDC balance: ${adapterUSDC}`); + console.log(` Adapter WETH balance: ${adapterWETH}`); + console.log(` Adapter vault shares: ${adapterShares}`); + }); + }); + + describe("Buy - Round-Trip Accounting", function () { + before(async function () { + // Sell any existing shares to start clean + const existingShares = await morphoWETH.balanceOf(loSigner.address); + if (existingShares > 0n) { + await morphoWETH.connect(loSigner).approve(await vaultAdapter.getAddress(), existingShares); + await vaultAdapter.connect(loSigner).sell(MAINNET.MORPHO_WETH, existingShares); + } + + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + await owner.sendTransaction({ to: MAINNET.USDC_WHALE, value: ethers.parseEther("10") }); + const fundAmount = ethers.parseUnits("50000", USDC_DECIMALS); + await usdc.connect(usdcWhale).transfer(loSigner.address, fundAmount); + }); + + it("Should preserve value through buy→sell round-trip (within slippage)", async function () { + const sharesAmount = ethers.parseUnits("1", 18); + const balanceBefore = await usdc.balanceOf(loSigner.address); + + // Buy + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + const maxUSDC = (previewedCost * 10200n) / 10000n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + + const balanceAfterBuy = await usdc.balanceOf(loSigner.address); + const spent = balanceBefore - balanceAfterBuy; + + // Sell + await morphoWETH.connect(loSigner).approve(await vaultAdapter.getAddress(), sharesAmount); + await vaultAdapter.connect(loSigner).sell(MAINNET.MORPHO_WETH, sharesAmount); + + const balanceAfterSell = await usdc.balanceOf(loSigner.address); + const received = balanceAfterSell - balanceAfterBuy; + + // Round-trip loss should be within 1% (swap fees + slippage on both legs) + const loss = spent - received; + const lossBps = (loss * 10000n) / spent; + + expect(lossBps).to.be.lt(100n); // Less than 1% loss + expect(received).to.be.gt(0); + + console.log(` Spent on buy: ${ethers.formatUnits(spent, USDC_DECIMALS)} USDC`); + console.log(` Received on sell: ${ethers.formatUnits(received, USDC_DECIMALS)} USDC`); + console.log(` Round-trip loss: ${ethers.formatUnits(loss, USDC_DECIMALS)} USDC (${Number(lossBps)} bps)`); + }); + }); + + describe("Buy - Dust & Edge Amounts", function () { + before(async function () { + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + await owner.sendTransaction({ to: MAINNET.USDC_WHALE, value: ethers.parseEther("10") }); + const fundAmount = ethers.parseUnits("50000", USDC_DECIMALS); + await usdc.connect(usdcWhale).transfer(loSigner.address, fundAmount); + }); + + it("Should handle very small share amount (1 wei of shares)", async function () { + const tinyShares = 1n; // 1 wei of vault shares + + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, tinyShares); + + // Even 1 wei of shares should have some non-zero cost + // (though it might be 0 USDC due to rounding, which would still be a valid test) + if (previewedCost > 0n) { + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), previewedCost); + + const sharesBefore = await morphoWETH.balanceOf(loSigner.address); + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, tinyShares); + const sharesAfter = await morphoWETH.balanceOf(loSigner.address); + + expect(sharesAfter - sharesBefore).to.equal(tinyShares); + console.log(` 1 wei shares cost: ${previewedCost} USDC wei`); + } else { + console.log(` 1 wei shares rounds to 0 USDC cost (expected for high-value vaults)`); + } + }); + + it("Should handle large share amount", async function () { + const largeShares = ethers.parseUnits("10", 18); // 10 full shares + + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, largeShares); + expect(previewedCost).to.be.gt(0); + + const maxUSDC = (previewedCost * 10200n) / 10000n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + const sharesBefore = await morphoWETH.balanceOf(loSigner.address); + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, largeShares); + const sharesAfter = await morphoWETH.balanceOf(loSigner.address); + + expect(sharesAfter - sharesBefore).to.equal(largeShares); + console.log(` 10 shares cost: ${ethers.formatUnits(previewedCost, USDC_DECIMALS)} USDC`); + }); + }); + + describe("Buy - Same-Asset Vault Price Changes", function () { + let usdcVault: MockERC4626Asset; + let usdcVaultAdapter: ERC4626ExecutionAdapter; + + before(async function () { + this.timeout(60000); + + const MockERC4626Factory = await ethers.getContractFactory("MockERC4626Asset"); + usdcVault = (await MockERC4626Factory.deploy( + MAINNET.USDC, + "Price Test Vault", + "ptVUSDC", + )) as unknown as MockERC4626Asset; + await usdcVault.waitForDeployment(); + + const VaultAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + usdcVaultAdapter = (await VaultAdapterFactory.deploy( + await orionConfig.getAddress(), + )) as unknown as ERC4626ExecutionAdapter; + await usdcVaultAdapter.waitForDeployment(); + + const mockConfig = await ethers.getContractAt("MockOrionConfig", await orionConfig.getAddress()); + await mockConfig.setTokenDecimals(MAINNET.USDC, 6); + await mockConfig.setTokenDecimals(await usdcVault.getAddress(), 6); + + await liquidityOrchestrator.setExecutionAdapter( + await usdcVault.getAddress(), + await usdcVaultAdapter.getAddress(), + ); + + // Seed vault: deposit 10k USDC to establish baseline + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + await owner.sendTransaction({ to: MAINNET.USDC_WHALE, value: ethers.parseEther("10") }); + const seedAmount = ethers.parseUnits("10000", USDC_DECIMALS); + await usdc.connect(usdcWhale).approve(await usdcVault.getAddress(), seedAmount); + await usdcVault.connect(usdcWhale).deposit(seedAmount, usdcWhale.address); + + // Fund LO + await usdc.connect(usdcWhale).transfer(loSigner.address, ethers.parseUnits("10000", USDC_DECIMALS)); + }); + + it("Should cost more per share after vault gains (share price increases)", async function () { + const sharesAmount = ethers.parseUnits("100", 6); + + // Cost before gains + const costBefore = await usdcVault.previewMint(sharesAmount); + + // Simulate 10% gains (transfer extra USDC into vault) + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + const gainAmount = ethers.parseUnits("1000", USDC_DECIMALS); // 10% of 10k + await usdc.connect(usdcWhale).approve(await usdcVault.getAddress(), gainAmount); + await usdcVault.connect(usdcWhale).simulateGains(gainAmount); + + // Cost after gains + const costAfter = await usdcVault.previewMint(sharesAmount); + + expect(costAfter).to.be.gt(costBefore); + console.log(` Cost before gains: ${ethers.formatUnits(costBefore, USDC_DECIMALS)} USDC`); + console.log(` Cost after gains: ${ethers.formatUnits(costAfter, USDC_DECIMALS)} USDC`); + console.log(` Increase: ${Number(((costAfter - costBefore) * 10000n) / costBefore) / 100}%`); + + // Buy should still work at the new price + await usdc.connect(loSigner).approve(await usdcVaultAdapter.getAddress(), costAfter * 2n); + await usdcVaultAdapter.connect(loSigner).buy(await usdcVault.getAddress(), sharesAmount); + + const balance = await usdcVault.balanceOf(loSigner.address); + expect(balance).to.equal(sharesAmount); + }); + + it("Should cost less per share after vault losses (share price decreases)", async function () { + // Sell existing shares first + const existingShares = await usdcVault.balanceOf(loSigner.address); + if (existingShares > 0n) { + await usdcVault.connect(loSigner).approve(await usdcVaultAdapter.getAddress(), existingShares); + await usdcVaultAdapter.connect(loSigner).sell(await usdcVault.getAddress(), existingShares); + } + + const sharesAmount = ethers.parseUnits("100", 6); + const costBefore = await usdcVault.previewMint(sharesAmount); + + // Simulate 5% losses + const totalAssets = await usdcVault.totalAssets(); + const lossAmount = totalAssets / 20n; + await usdcVault.simulateLosses(lossAmount, owner.address); + + const costAfter = await usdcVault.previewMint(sharesAmount); + + expect(costAfter).to.be.lt(costBefore); + console.log(` Cost before losses: ${ethers.formatUnits(costBefore, USDC_DECIMALS)} USDC`); + console.log(` Cost after losses: ${ethers.formatUnits(costAfter, USDC_DECIMALS)} USDC`); + + // Buy should still work + await usdc.connect(loSigner).approve(await usdcVaultAdapter.getAddress(), costAfter * 2n); + await usdcVaultAdapter.connect(loSigner).buy(await usdcVault.getAddress(), sharesAmount); + + const balance = await usdcVault.balanceOf(loSigner.address); + expect(balance).to.equal(sharesAmount); + }); + }); + + describe("Cross-Asset Buy - Dust & Execution Measurement", function () { + before(async function () { + // Sell any existing shares to start clean + const existingShares = await morphoWETH.balanceOf(loSigner.address); + if (existingShares > 0n) { + await morphoWETH.connect(loSigner).approve(await vaultAdapter.getAddress(), existingShares); + await vaultAdapter.connect(loSigner).sell(MAINNET.MORPHO_WETH, existingShares); + } + + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + await owner.sendTransaction({ to: MAINNET.USDC_WHALE, value: ethers.parseEther("10") }); + const fundAmount = ethers.parseUnits("50000", USDC_DECIMALS); + await usdc.connect(usdcWhale).transfer(loSigner.address, fundAmount); + }); + + it("Should have buy() return value match actual USDC balance delta (same block)", async function () { + const sharesAmount = ethers.parseUnits("1", 18); + + // Generous approval so it's not the limiting factor + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + const maxUSDC = previewedCost * 2n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + // buy() returns spentUnderlyingAmount — capture it via staticCall (block N) + const returnValue = await vaultAdapter.connect(loSigner).buy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + + // Now actually execute (block N+1) + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + const balanceBefore = await usdc.balanceOf(loSigner.address); + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + const balanceAfter = await usdc.balanceOf(loSigner.address); + + const actualDelta = balanceBefore - balanceAfter; + + // The return value from staticCall and the actual delta may differ by cross-block drift, + // but the actual delta IS the ground truth at block N+1 + expect(actualDelta).to.be.gt(0); + + // Dust between staticCall return (block N) and actual spend (block N+1) + const dust = actualDelta > returnValue ? actualDelta - returnValue : returnValue - actualDelta; + // Cross-block Quoter drift should be negligible + expect(dust).to.be.lt(MAX_USDC_DUST); + + console.log(` staticCall return: ${ethers.formatUnits(returnValue, USDC_DECIMALS)} USDC`); + console.log(` Actual delta: ${ethers.formatUnits(actualDelta, USDC_DECIMALS)} USDC`); + console.log(` Cross-block dust: ${dust} USDC wei`); + }); + + it("Should have previewBuy→buy cross-block dust below epsilon", async function () { + const sharesAmount = ethers.parseUnits("0.5", 18); + + // Block N: previewBuy + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + + // Block N+1: actual buy + const maxUSDC = previewedCost * 2n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + const balanceBefore = await usdc.balanceOf(loSigner.address); + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + const balanceAfter = await usdc.balanceOf(loSigner.address); + + const actualSpent = balanceBefore - balanceAfter; + + // Measure dust: |previewBuy(N) - actualSpent(N+1)| + const dust = actualSpent > previewedCost ? actualSpent - previewedCost : previewedCost - actualSpent; + + // Cross-block dust from Uniswap Quoter secondsPerLiquidityCumulative drift + expect(dust).to.be.lt(MAX_USDC_DUST); + + console.log(` previewBuy (block N): ${ethers.formatUnits(previewedCost, USDC_DECIMALS)} USDC`); + console.log(` actualSpent (block N+1): ${ethers.formatUnits(actualSpent, USDC_DECIMALS)} USDC`); + console.log(` Dust: ${dust} USDC wei (${Number(dust) / 1e6} USDC)`); + }); + + it("Should leave adapter dust below epsilon after cross-asset buy", async function () { + const sharesAmount = ethers.parseUnits("0.3", 18); + + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), previewedCost * 2n); + + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + + const adapterAddr = await vaultAdapter.getAddress(); + + // Dust must stay below epsilon (no refunding) + const adapterUSDC = await usdc.balanceOf(adapterAddr); + const adapterWETH = await weth.balanceOf(adapterAddr); + const adapterShares = await morphoWETH.balanceOf(adapterAddr); + + expect(adapterUSDC, "USDC dust in adapter").to.be.lt(MAX_USDC_DUST); + expect(adapterWETH, "WETH dust in adapter").to.be.lt(MAX_USDC_DUST); + expect(adapterShares, "Vault share dust in adapter").to.be.lt(MAX_USDC_DUST); + + console.log(` Adapter USDC: ${adapterUSDC}`); + console.log(` Adapter WETH: ${adapterWETH}`); + console.log(` Adapter shares: ${adapterShares}`); + }); + + it("Should report observed dust so MAX_USDC_DUST can be set above failure threshold", async function () { + const sharesAmount = ethers.parseUnits("0.3", 18); + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), previewedCost * 2n); + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + + const adapterAddr = await vaultAdapter.getAddress(); + const adapterUSDC = Number(await usdc.balanceOf(adapterAddr)); + const adapterWETH = Number(await weth.balanceOf(adapterAddr)); + const adapterShares = Number(await morphoWETH.balanceOf(adapterAddr)); + + const maxAdapterDust = Math.max(adapterUSDC, adapterWETH, adapterShares); + const minEpsilonToPass = maxAdapterDust + 1; + console.log(` Adapter dust: USDC=${adapterUSDC} WETH=${adapterWETH} shares=${adapterShares}`); + console.log( + ` Max observed dust: ${maxAdapterDust} → tests would fail if MAX_USDC_DUST were <= ${maxAdapterDust} (use > ${maxAdapterDust}, e.g. ${minEpsilonToPass})`, + ); + + expect(adapterUSDC).to.be.lt(MAX_USDC_DUST); + expect(adapterWETH).to.be.lt(MAX_USDC_DUST); + expect(adapterShares).to.be.lt(MAX_USDC_DUST); + }); + + it("Should deliver exact shares and spend only what needed", async function () { + const sharesAmount = ethers.parseUnits("2", 18); + + const previewedCost = await vaultAdapter.previewBuy.staticCall(MAINNET.MORPHO_WETH, sharesAmount); + const generousApproval = previewedCost * 3n; // 3x what's needed + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), generousApproval); + + const sharesBefore = await morphoWETH.balanceOf(loSigner.address); + const usdcBefore = await usdc.balanceOf(loSigner.address); + + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + + const sharesAfter = await morphoWETH.balanceOf(loSigner.address); + const usdcAfter = await usdc.balanceOf(loSigner.address); + + const sharesReceived = sharesAfter - sharesBefore; + const usdcSpent = usdcBefore - usdcAfter; + + // Exact shares — no rounding + expect(sharesReceived).to.equal(sharesAmount); + + // Spent well under the generous approval (previewBuy-based pull, not allowance-based) + expect(usdcSpent).to.be.lt(generousApproval); + + // Spent should be close to previewed cost (within dust) + const dust = usdcSpent > previewedCost ? usdcSpent - previewedCost : previewedCost - usdcSpent; + expect(dust).to.be.lt(MAX_USDC_DUST); + + console.log(` Shares requested: ${ethers.formatUnits(sharesAmount, 18)}`); + console.log(` Shares received: ${ethers.formatUnits(sharesReceived, 18)}`); + console.log(` USDC approved: ${ethers.formatUnits(generousApproval, USDC_DECIMALS)}`); + console.log(` USDC spent: ${ethers.formatUnits(usdcSpent, USDC_DECIMALS)}`); + console.log(` Dust: ${dust} USDC wei`); + }); + }); + + describe("Gas Benchmarking", function () { + before(async function () { + // Re-fund LO signer for gas benchmarking + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + await owner.sendTransaction({ + to: MAINNET.USDC_WHALE, + value: ethers.parseEther("10"), + }); + + const fundAmount = ethers.parseUnits("10000", USDC_DECIMALS); // 10k USDC + await usdc.connect(usdcWhale).transfer(loSigner.address, fundAmount); + }); + + it("Should benchmark buy operation gas cost", async function () { + const sharesAmount = ethers.parseUnits("0.1", 18); + const wethPerShare = await morphoWETH.convertToAssets(sharesAmount); + const [wethPrice, priceDecimals] = await chainlinkAdapter.getPriceData(MAINNET.WETH); + + const estimatedCost = + (wethPerShare * wethPrice) / 10n ** (BigInt(WETH_DECIMALS) + BigInt(priceDecimals) - BigInt(USDC_DECIMALS)); + + const maxUSDC = (estimatedCost * (10000n + BigInt(SLIPPAGE_TOLERANCE))) / 10000n; + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + const tx = await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + + const receipt = await tx.wait(); + console.log(` Buy gas cost: ${receipt!.gasUsed.toLocaleString()} (includes previewBuy Quoter call)`); + + // Should be under 900k gas (includes previewBuy Quoter ~70k + swap + vault deposit; mainnet conditions vary) + expect(receipt!.gasUsed).to.be.lt(900000); + }); + + it("Should benchmark sell operation gas cost", async function () { + const sharesToSell = await morphoWETH.balanceOf(loSigner.address); + + await morphoWETH.connect(loSigner).approve(await vaultAdapter.getAddress(), sharesToSell); + + const tx = await vaultAdapter.connect(loSigner).sell(MAINNET.MORPHO_WETH, sharesToSell); + + const receipt = await tx.wait(); + console.log(` Sell gas cost: ${receipt!.gasUsed.toLocaleString()}`); + + // Should be under 520k gas (includes vault redeem + swap + delegation) + expect(receipt!.gasUsed).to.be.lt(520000); + }); + }); + + describe("Same-Asset Vault Tests (USDC Vault)", function () { + let usdcVault: MockERC4626Asset; + let usdcVaultAdapter: ERC4626ExecutionAdapter; + + before(async function () { + this.timeout(60000); + + // Deploy USDC vault (same-asset, no swap needed) + const MockERC4626Factory = await ethers.getContractFactory("MockERC4626Asset"); + const usdcVaultDeployed = await MockERC4626Factory.deploy(MAINNET.USDC, "USDC Vault", "vUSDC"); + await usdcVaultDeployed.waitForDeployment(); + usdcVault = usdcVaultDeployed as unknown as MockERC4626Asset; + + // Deploy vault adapter for USDC vault + const VaultAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + const usdcVaultAdapterDeployed = await VaultAdapterFactory.deploy(await orionConfig.getAddress()); + await usdcVaultAdapterDeployed.waitForDeployment(); + usdcVaultAdapter = usdcVaultAdapterDeployed as unknown as ERC4626ExecutionAdapter; + + // Register token decimals in config (required for _validateExecutionAdapter checks) + const mockConfig = await ethers.getContractAt("MockOrionConfig", await orionConfig.getAddress()); + await mockConfig.setTokenDecimals(MAINNET.USDC, 6); // USDC underlying is 6 decimals + await mockConfig.setTokenDecimals(await usdcVault.getAddress(), 6); // Vault shares also 6 decimals + + // Register USDC vault in LO + await liquidityOrchestrator.setExecutionAdapter( + await usdcVault.getAddress(), + await usdcVaultAdapter.getAddress(), + ); + + // Fund vault with initial USDC from whale and mint initial shares to establish 1:1 ratio + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + await owner.sendTransaction({ + to: MAINNET.USDC_WHALE, + value: ethers.parseEther("10"), + }); + + const fundAmount = ethers.parseUnits("10000", USDC_DECIMALS); + await usdc.connect(usdcWhale).approve(await usdcVault.getAddress(), fundAmount); + // Mint shares to establish the exchange rate as 1:1 + await usdcVault.connect(usdcWhale).deposit(fundAmount, usdcWhale.address); + }); + + beforeEach(async function () { + // Fund LO signer with fresh USDC for each test (since tests consume the balance) + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + + // Ensure whale has ETH for gas + await owner.sendTransaction({ + to: MAINNET.USDC_WHALE, + value: ethers.parseEther("1"), + }); + + const loFundAmount = ethers.parseUnits("1000", USDC_DECIMALS); + await usdc.connect(usdcWhale).transfer(loSigner.address, loFundAmount); + }); + + it("Should validate same-asset vault", async function () { + await expect(usdcVaultAdapter.validateExecutionAdapter(await usdcVault.getAddress())).to.not.be.reverted; + }); + + it("Should buy same-asset vault shares (no swap)", async function () { + const sharesAmount = ethers.parseUnits("100", 6); // 100 shares (vault has 6 decimals, same as USDC) + const underlyingNeeded = await usdcVault.previewMint(sharesAmount); + + // Approve adapter to pull from LO + await usdc.connect(loSigner).approve(await usdcVaultAdapter.getAddress(), underlyingNeeded * 2n); + + // Execute buy + const tx = await usdcVaultAdapter.connect(loSigner).buy(await usdcVault.getAddress(), sharesAmount); + const receipt = await tx.wait(); + + console.log(` Same-asset buy gas: ${receipt!.gasUsed.toLocaleString()}`); + + // Verify exact shares received + const sharesBalance = await usdcVault.balanceOf(loSigner.address); + expect(sharesBalance).to.equal(sharesAmount); + + // Should use less gas than cross-asset (no swap) + expect(receipt!.gasUsed).to.be.lt(300000); + }); + + it("Should sell same-asset vault shares (no swap)", async function () { + // First buy some shares if we don't have any + let sharesToSell = await usdcVault.balanceOf(loSigner.address); + if (sharesToSell === 0n) { + const sharesAmount = ethers.parseUnits("100", 6); // Vault has 6 decimals + const underlyingNeeded = await usdcVault.previewMint(sharesAmount); + await usdc.connect(loSigner).approve(await usdcVaultAdapter.getAddress(), underlyingNeeded * 2n); + await usdcVaultAdapter.connect(loSigner).buy(await usdcVault.getAddress(), sharesAmount); + sharesToSell = await usdcVault.balanceOf(loSigner.address); + } + + const initialUSDC = await usdc.balanceOf(loSigner.address); + void (await usdcVault.previewRedeem(sharesToSell)); // sanity check only + + // Approve adapter + await usdcVault.connect(loSigner).approve(await usdcVaultAdapter.getAddress(), sharesToSell); + + // Execute sell + const tx = await usdcVaultAdapter.connect(loSigner).sell(await usdcVault.getAddress(), sharesToSell); + const receipt = await tx.wait(); + + console.log(` Same-asset sell gas: ${receipt!.gasUsed.toLocaleString()}`); + + // Verify USDC received + const finalUSDC = await usdc.balanceOf(loSigner.address); + expect(finalUSDC).to.be.gt(initialUSDC); + + // Should use less gas than cross-asset (no swap) + expect(receipt!.gasUsed).to.be.lt(200000); + }); + + it("Should enforce slippage on same-asset buy", async function () { + const sharesAmount = ethers.parseUnits("50", 6); + const underlyingNeeded = await usdcVault.previewMint(sharesAmount); + + // Approve too little (will trigger slippage error) + const tooLittle = underlyingNeeded / 2n; + await usdc.connect(loSigner).approve(await usdcVaultAdapter.getAddress(), tooLittle); + + await expect(usdcVaultAdapter.connect(loSigner).buy(await usdcVault.getAddress(), sharesAmount)).to.be.reverted; // ERC20 allowance error — adapter tries to pull previewMint result but only tooLittle approved + }); + }); + + describe("Error Handling & Edge Cases", function () { + it("Should reject buy with zero allowance", async function () { + const sharesAmount = ethers.parseUnits("1", 18); + + // Ensure no allowance + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), 0); + + await expect(vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount)).to.be.reverted; + }); + + it("Should reject sell without share allowance", async function () { + // Fund LO with shares first + const usdcWhale = await ethers.getImpersonatedSigner(MAINNET.USDC_WHALE); + await owner.sendTransaction({ + to: MAINNET.USDC_WHALE, + value: ethers.parseEther("10"), + }); + + const fundAmount = ethers.parseUnits("10000", USDC_DECIMALS); + await usdc.connect(usdcWhale).transfer(loSigner.address, fundAmount); + + const sharesAmount = ethers.parseUnits("0.1", 18); + const wethNeeded = await morphoWETH.convertToAssets(sharesAmount); + const [wethPrice, priceDecimals] = await chainlinkAdapter.getPriceData(MAINNET.WETH); + const estimatedCost = + (wethNeeded * wethPrice) / 10n ** (BigInt(WETH_DECIMALS) + BigInt(priceDecimals) - BigInt(USDC_DECIMALS)); + const maxUSDC = (estimatedCost * (10000n + BigInt(SLIPPAGE_TOLERANCE))) / 10000n; + + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + await vaultAdapter.connect(loSigner).buy(MAINNET.MORPHO_WETH, sharesAmount); + + // Now try to sell without approval + await morphoWETH.connect(loSigner).approve(await vaultAdapter.getAddress(), 0); + + await expect(vaultAdapter.connect(loSigner).sell(MAINNET.MORPHO_WETH, sharesAmount)).to.be.reverted; + }); + + it("Should reject non-LO caller", async function () { + const sharesAmount = ethers.parseUnits("1", 18); + + // Adapter does not restrict callers; owner without approval will revert on transfer (e.g. insufficient allowance) + await expect(vaultAdapter.connect(owner).buy(MAINNET.MORPHO_WETH, sharesAmount)).to.be.reverted; + + // For sell, owner has no vault shares so will revert (e.g. insufficient balance or allowance) + await expect(vaultAdapter.connect(owner).sell(MAINNET.MORPHO_WETH, sharesAmount)).to.be.reverted; + }); + + it("Should handle vault with zero liquidity", async function () { + // Deploy empty vault + const MockERC4626Factory = await ethers.getContractFactory("MockERC4626Asset"); + const emptyVault = await MockERC4626Factory.deploy(MAINNET.WETH, "Empty Vault", "eVAULT"); + await emptyVault.waitForDeployment(); + + // Register in LO + await liquidityOrchestrator.setExecutionAdapter(await emptyVault.getAddress(), await vaultAdapter.getAddress()); + + const sharesAmount = ethers.parseUnits("1", 18); + const wethNeeded = await morphoWETH.convertToAssets(sharesAmount); + const [wethPrice, priceDecimals] = await chainlinkAdapter.getPriceData(MAINNET.WETH); + const estimatedCost = + (wethNeeded * wethPrice) / 10n ** (BigInt(WETH_DECIMALS) + BigInt(priceDecimals) - BigInt(USDC_DECIMALS)); + const maxUSDC = (estimatedCost * (10000n + BigInt(SLIPPAGE_TOLERANCE))) / 10000n; + + await usdc.connect(loSigner).approve(await vaultAdapter.getAddress(), maxUSDC); + + // Should work - vault will mint at 1:1 initially + await expect(vaultAdapter.connect(loSigner).buy(await emptyVault.getAddress(), sharesAmount)).to.not.be.reverted; + }); + }); +}); diff --git a/test/crossAsset/ERC4626PriceAdapter.test.ts b/test/crossAsset/ERC4626PriceAdapter.test.ts new file mode 100644 index 00000000..eb067ded --- /dev/null +++ b/test/crossAsset/ERC4626PriceAdapter.test.ts @@ -0,0 +1,243 @@ +/** + * ERC4626PriceAdapter Coverage Tests + * + * Comprehensive test suite to end to end test ERC4626PriceAdapter.sol + * Tests cross-asset vault pricing composition and error handling. + */ + +import { expect } from "chai"; +import { ethers, network } from "hardhat"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { + ERC4626PriceAdapter, + MockOrionConfig, + ChainlinkPriceAdapter, + MockPriceAdapterRegistry, + IERC4626, +} from "../../typechain-types"; + +// Mainnet addresses +const MAINNET = { + USDC: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + WETH: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + WBTC: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + MORPHO_WETH: "0x31A5684983EeE865d943A696AAC155363bA024f9", // Vault Bridge WETH + CHAINLINK_ETH_USD: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419", + CHAINLINK_BTC_USD: "0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c", +}; + +describe("ERC4626PriceAdapter - Coverage Tests", function () { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let owner: SignerWithAddress; + let orionConfig: MockOrionConfig; + let vaultPriceAdapter: ERC4626PriceAdapter; + let chainlinkAdapter: ChainlinkPriceAdapter; + let priceRegistry: MockPriceAdapterRegistry; + let morphoWETH: IERC4626; + + before(async function () { + this.timeout(60000); + + // Skip if not forking mainnet + const networkConfig = network.config; + if (!("forking" in networkConfig) || !networkConfig.forking || !networkConfig.forking.url) { + this.skip(); + } + + [owner] = await ethers.getSigners(); + + // Deploy mock config + const MockOrionConfigFactory = await ethers.getContractFactory("MockOrionConfig"); + orionConfig = await MockOrionConfigFactory.deploy(MAINNET.USDC); + + // Deploy Chainlink adapter + const ChainlinkAdapterFactory = await ethers.getContractFactory("ChainlinkPriceAdapter"); + chainlinkAdapter = await ChainlinkAdapterFactory.deploy(await orionConfig.getAddress()); + + // Configure feeds + await chainlinkAdapter.configureFeed( + MAINNET.WETH, + MAINNET.CHAINLINK_ETH_USD, + false, + 3600, + ethers.parseUnits("1000", 8), + ethers.parseUnits("10000", 8), + ); + + await chainlinkAdapter.configureFeed( + MAINNET.WBTC, + MAINNET.CHAINLINK_BTC_USD, + false, + 3600, + ethers.parseUnits("20000", 8), + ethers.parseUnits("100000", 8), + ); + + // Deploy price registry + const MockPriceAdapterRegistryFactory = await ethers.getContractFactory("MockPriceAdapterRegistry"); + priceRegistry = await MockPriceAdapterRegistryFactory.deploy(); + await priceRegistry.setPriceAdapter(MAINNET.WETH, await chainlinkAdapter.getAddress()); + await priceRegistry.setPriceAdapter(MAINNET.WBTC, await chainlinkAdapter.getAddress()); + + // Configure mock config + const mockConfig = await ethers.getContractAt("MockOrionConfig", await orionConfig.getAddress()); + await mockConfig.setPriceAdapterRegistry(await priceRegistry.getAddress()); + + // Deploy ERC4626 price adapter + const ERC4626PriceAdapterFactory = await ethers.getContractFactory("ERC4626PriceAdapter"); + vaultPriceAdapter = await ERC4626PriceAdapterFactory.deploy(await orionConfig.getAddress()); + + // Get Morpho vault instance + morphoWETH = await ethers.getContractAt("IERC4626", MAINNET.MORPHO_WETH); + }); + + describe("Constructor", function () { + it("Should reject zero address", async function () { + const ERC4626PriceAdapterFactory = await ethers.getContractFactory("ERC4626PriceAdapter"); + await expect(ERC4626PriceAdapterFactory.deploy(ethers.ZeroAddress)).to.be.revertedWithCustomError( + vaultPriceAdapter, + "ZeroAddress", + ); + }); + + it("Should initialize immutables correctly", async function () { + expect(await vaultPriceAdapter.CONFIG()).to.equal(await orionConfig.getAddress()); + expect(await vaultPriceAdapter.PRICE_REGISTRY()).to.equal(await priceRegistry.getAddress()); + expect(await vaultPriceAdapter.UNDERLYING_ASSET()).to.equal(MAINNET.USDC); + expect(await vaultPriceAdapter.UNDERLYING_DECIMALS()).to.equal(6); + expect(await vaultPriceAdapter.PRICE_ADAPTER_DECIMALS()).to.equal(14); + }); + }); + + describe("validatePriceAdapter", function () { + it("Should validate Morpho WETH vault", async function () { + // Register vault decimals + const mockConfig = await ethers.getContractAt("MockOrionConfig", await orionConfig.getAddress()); + await mockConfig.setTokenDecimals(MAINNET.MORPHO_WETH, 18); + + await expect(vaultPriceAdapter.validatePriceAdapter(MAINNET.MORPHO_WETH)).to.not.be.reverted; + }); + + it("Should reject non-ERC4626 asset", async function () { + await expect(vaultPriceAdapter.validatePriceAdapter(MAINNET.WETH)).to.be.revertedWithCustomError( + vaultPriceAdapter, + "InvalidAdapter", + ); + }); + + it("Should reject vault with zero underlying", async function () { + // Deploy mock vault that returns zero address + const MockERC4626Factory = await ethers.getContractFactory("MockERC4626Asset"); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const mockVault = await MockERC4626Factory.deploy(MAINNET.WETH, "Mock Vault", "mVAULT"); + + // This would need a special mock that returns address(0) - skip for now + // The validation happens at line 72 in ERC4626PriceAdapter + }); + + it("Should reject same-asset vault (USDC vault)", async function () { + // Deploy a USDC vault + const MockERC4626Factory = await ethers.getContractFactory("MockERC4626Asset"); + const usdcVault = await MockERC4626Factory.deploy(MAINNET.USDC, "USDC Vault", "vUSDC"); + await usdcVault.waitForDeployment(); + + await expect(vaultPriceAdapter.validatePriceAdapter(await usdcVault.getAddress())).to.be.revertedWithCustomError( + vaultPriceAdapter, + "InvalidAdapter", + ); + }); + + it("Should reject vault with no price feed for underlying", async function () { + // Deploy vault with underlying that has no price feed (use random token) + const MockERC20Factory = await ethers.getContractFactory("MockUnderlyingAsset"); + const randomToken = await MockERC20Factory.deploy(18); // MockUnderlyingAsset only takes decimals + await randomToken.waitForDeployment(); + + const MockERC4626Factory = await ethers.getContractFactory("MockERC4626Asset"); + const randomVault = await MockERC4626Factory.deploy(await randomToken.getAddress(), "Random Vault", "vRND"); + await randomVault.waitForDeployment(); + + const mockConfig = await ethers.getContractAt("MockOrionConfig", await orionConfig.getAddress()); + await mockConfig.setTokenDecimals(await randomVault.getAddress(), 18); + + await expect( + vaultPriceAdapter.validatePriceAdapter(await randomVault.getAddress()), + ).to.be.revertedWithCustomError(vaultPriceAdapter, "InvalidAdapter"); + }); + + it("Should reject vault with mismatched decimals in config", async function () { + // Register with wrong decimals + const mockConfig = await ethers.getContractAt("MockOrionConfig", await orionConfig.getAddress()); + await mockConfig.setTokenDecimals(MAINNET.MORPHO_WETH, 8); // Wrong! Should be 18 + + await expect(vaultPriceAdapter.validatePriceAdapter(MAINNET.MORPHO_WETH)).to.be.revertedWithCustomError( + vaultPriceAdapter, + "InvalidAdapter", + ); + + // Fix for next tests + await mockConfig.setTokenDecimals(MAINNET.MORPHO_WETH, 18); + }); + }); + + describe("getPriceData", function () { + it("Should calculate correct composed price for Morpho WETH vault", async function () { + const [vaultPrice, priceDecimals] = await vaultPriceAdapter.getPriceData(MAINNET.MORPHO_WETH); + + expect(priceDecimals).to.equal(14); // Protocol standard + + // Get components for verification + const oneShare = ethers.parseUnits("1", 18); + const wethPerShare = await morphoWETH.convertToAssets(oneShare); + const wethPriceInUSD = await priceRegistry.getPrice(MAINNET.WETH); + + // Calculated price should be: wethPerShare * wethPriceInUSD / 1e18 + const expectedPrice = (wethPerShare * wethPriceInUSD) / BigInt(10 ** 18); + + expect(vaultPrice).to.be.closeTo(expectedPrice, expectedPrice / 100n); // Within 1% + + console.log(` Vault price: ${ethers.formatUnits(vaultPrice, 14)} USDC per share`); + console.log(` WETH per share: ${ethers.formatUnits(wethPerShare, 18)}`); + console.log(` WETH price: ${ethers.formatUnits(wethPriceInUSD, 14)} USDC`); + }); + + //#todo: Fix WBTC whale funding on fork + it("Should handle vault with different underlying decimals (WBTC - 8 decimals)", async function () { + // Deploy a WBTC vault + const MockERC4626Factory = await ethers.getContractFactory("MockERC4626Asset"); + const wbtcVault = await MockERC4626Factory.deploy(MAINNET.WBTC, "WBTC Vault", "vWBTC"); + await wbtcVault.waitForDeployment(); + + // Register in config + const mockConfig = await ethers.getContractAt("MockOrionConfig", await orionConfig.getAddress()); + await mockConfig.setTokenDecimals(await wbtcVault.getAddress(), 18); + + // Skip this test - WBTC whale funding not reliable on fork + this.skip(); + + const [vaultPrice, priceDecimals] = await vaultPriceAdapter.getPriceData(await wbtcVault.getAddress()); + + expect(priceDecimals).to.equal(14); + expect(vaultPrice).to.be.gt(0); + + console.log(` WBTC vault price: ${ethers.formatUnits(vaultPrice, 14)} USDC per share`); + }); + + it("Should handle vault appreciation (share value > 1)", async function () { + // Morpho vault should have appreciation + const oneShare = ethers.parseUnits("1", 18); + const wethPerShare = await morphoWETH.convertToAssets(oneShare); + + // Share should be worth more than 1:1 + expect(wethPerShare).to.be.gte(oneShare); + + const [vaultPrice] = await vaultPriceAdapter.getPriceData(MAINNET.MORPHO_WETH); + const wethPrice = await priceRegistry.getPrice(MAINNET.WETH); + + // Vault price should reflect the appreciation + expect(vaultPrice).to.be.gte(wethPrice); + + console.log(` Vault appreciation: ${ethers.formatUnits((wethPerShare * 100n) / oneShare, 2)}%`); + }); + }); +}); diff --git a/test/crossAsset/SwapExecutors.test.ts b/test/crossAsset/SwapExecutors.test.ts new file mode 100644 index 00000000..d7cf3015 --- /dev/null +++ b/test/crossAsset/SwapExecutors.test.ts @@ -0,0 +1,251 @@ +/** + * UniswapV3ExecutionAdapter - Unit Tests + * + * Tests the Uniswap V3 execution adapter in isolation with mock + * router, quoter, factory, and config contracts. + * Covers sell, buy, previewBuy, validateExecutionAdapter, and setAssetFee. + */ + +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { + UniswapV3ExecutionAdapter, + MockUniswapV3Router, + MockUniswapV3Factory, + MockUniswapV3Quoter, + MockOrionConfig, + MockUnderlyingAsset, +} from "../../typechain-types"; + +describe("UniswapV3ExecutionAdapter - Unit Tests", function () { + let owner: SignerWithAddress; + let guardian: SignerWithAddress; + let user: SignerWithAddress; + + let adapter: UniswapV3ExecutionAdapter; + let mockRouter: MockUniswapV3Router; + let mockFactory: MockUniswapV3Factory; + let mockQuoter: MockUniswapV3Quoter; + let config: MockOrionConfig; + + let usdc: MockUnderlyingAsset; // protocol underlying (6 decimals) + let weth: MockUnderlyingAsset; // external asset (18 decimals) + + const USDC_DECIMALS = 6; + const WETH_DECIMALS = 18; + const FEE_TIER = 3000; // 0.3% + const MOCK_POOL = "0x0000000000000000000000000000000000000001"; + + before(async function () { + [owner, guardian, user] = await ethers.getSigners(); + + // Deploy mock tokens + const MockERC20 = await ethers.getContractFactory("MockUnderlyingAsset"); + usdc = (await MockERC20.deploy(USDC_DECIMALS)) as unknown as MockUnderlyingAsset; + weth = (await MockERC20.deploy(WETH_DECIMALS)) as unknown as MockUnderlyingAsset; + + // Deploy mock Uniswap contracts + const MockRouterFactory = await ethers.getContractFactory("MockUniswapV3Router"); + mockRouter = (await MockRouterFactory.deploy()) as unknown as MockUniswapV3Router; + + const MockFactoryFactory = await ethers.getContractFactory("MockUniswapV3Factory"); + mockFactory = (await MockFactoryFactory.deploy()) as unknown as MockUniswapV3Factory; + + const MockQuoterFactory = await ethers.getContractFactory("MockUniswapV3Quoter"); + mockQuoter = (await MockQuoterFactory.deploy()) as unknown as MockUniswapV3Quoter; + + // Deploy mock config + const MockConfigFactory = await ethers.getContractFactory("MockOrionConfig"); + config = (await MockConfigFactory.deploy(await usdc.getAddress())) as unknown as MockOrionConfig; + await config.setGuardian(guardian.address); + + // Register pool in mock factory + await mockFactory.setPool(await weth.getAddress(), await usdc.getAddress(), FEE_TIER, MOCK_POOL); + + // Deploy adapter under test + const AdapterFactory = await ethers.getContractFactory("UniswapV3ExecutionAdapter"); + adapter = (await AdapterFactory.deploy( + owner.address, + await mockFactory.getAddress(), + await mockRouter.getAddress(), + await mockQuoter.getAddress(), + await config.getAddress(), + )) as unknown as UniswapV3ExecutionAdapter; + + // Register fee tier for WETH + await adapter.setAssetFee(await weth.getAddress(), FEE_TIER); + }); + + describe("Constructor & Configuration", function () { + it("Should set immutables correctly", async function () { + expect(await adapter.SWAP_ROUTER()).to.equal(await mockRouter.getAddress()); + expect(await adapter.UNISWAP_V3_FACTORY()).to.equal(await mockFactory.getAddress()); + expect(await adapter.QUOTER()).to.equal(await mockQuoter.getAddress()); + expect(await adapter.CONFIG()).to.equal(await config.getAddress()); + expect(await adapter.UNDERLYING_ASSET()).to.equal(await usdc.getAddress()); + }); + + it("Should revert constructor with zero addresses", async function () { + const AdapterFactory = await ethers.getContractFactory("UniswapV3ExecutionAdapter"); + await expect( + AdapterFactory.deploy( + ethers.ZeroAddress, + await mockFactory.getAddress(), + await mockRouter.getAddress(), + await mockQuoter.getAddress(), + await config.getAddress(), + ), + ).to.be.reverted; + }); + }); + + describe("setAssetFee", function () { + it("Should allow owner to set fee tier", async function () { + const fee = await adapter.assetFee(await weth.getAddress()); + expect(fee).to.equal(FEE_TIER); + }); + + it("Should allow guardian to set fee tier", async function () { + // Create a new token and pool for this test + const MockERC20 = await ethers.getContractFactory("MockUnderlyingAsset"); + const newToken = await MockERC20.deploy(18); + await mockFactory.setPool(await newToken.getAddress(), await usdc.getAddress(), 500, MOCK_POOL); + + await adapter.connect(guardian).setAssetFee(await newToken.getAddress(), 500); + expect(await adapter.assetFee(await newToken.getAddress())).to.equal(500); + }); + + it("Should revert when called by non-owner/non-guardian", async function () { + await expect(adapter.connect(user).setAssetFee(await weth.getAddress(), FEE_TIER)).to.be.reverted; + }); + + it("Should revert for zero address asset", async function () { + await expect(adapter.setAssetFee(ethers.ZeroAddress, FEE_TIER)).to.be.reverted; + }); + + it("Should revert when no pool exists for the fee tier", async function () { + // No pool registered for fee 10000 + await expect(adapter.setAssetFee(await weth.getAddress(), 10000)).to.be.reverted; + }); + }); + + describe("validateExecutionAdapter", function () { + it("Should pass for asset with registered fee", async function () { + await expect(adapter.validateExecutionAdapter(await weth.getAddress())).to.not.be.reverted; + }); + + it("Should revert for asset without registered fee", async function () { + const MockERC20 = await ethers.getContractFactory("MockUnderlyingAsset"); + const unknownToken = await MockERC20.deploy(18); + await expect(adapter.validateExecutionAdapter(await unknownToken.getAddress())).to.be.reverted; + }); + }); + + describe("sell", function () { + it("Should execute sell (exact input swap) and return received amount", async function () { + const sellAmount = ethers.parseUnits("1", WETH_DECIMALS); // 1 WETH + const expectedUSDC = ethers.parseUnits("2500", USDC_DECIMALS); // 2500 USDC + + // Configure mock router + await mockRouter.setNextSwapResult(sellAmount, expectedUSDC); + + // Mint WETH to user and approve adapter + await weth.mint(user.address, sellAmount); + await weth.connect(user).approve(await adapter.getAddress(), sellAmount); + + // Execute sell + await adapter.connect(user).sell(await weth.getAddress(), sellAmount); + + // User should receive USDC (minted by mock router) + const usdcBalance = await usdc.balanceOf(user.address); + expect(usdcBalance).to.equal(expectedUSDC); + }); + + it("Should clean up router approval after sell", async function () { + const sellAmount = ethers.parseUnits("0.5", WETH_DECIMALS); + const expectedUSDC = ethers.parseUnits("1250", USDC_DECIMALS); + + await mockRouter.setNextSwapResult(sellAmount, expectedUSDC); + await weth.mint(user.address, sellAmount); + await weth.connect(user).approve(await adapter.getAddress(), sellAmount); + + await adapter.connect(user).sell(await weth.getAddress(), sellAmount); + + // Router allowance should be zero after swap + const allowance = await weth.allowance(await adapter.getAddress(), await mockRouter.getAddress()); + expect(allowance).to.equal(0); + }); + }); + + describe("previewBuy", function () { + it("Should return quoted amount from QuoterV2", async function () { + const buyAmount = ethers.parseUnits("1", WETH_DECIMALS); + const quotedUSDC = ethers.parseUnits("2600", USDC_DECIMALS); + + await mockQuoter.setNextQuoteResult(quotedUSDC); + + const result = await adapter.previewBuy.staticCall(await weth.getAddress(), buyAmount); + expect(result).to.equal(quotedUSDC); + }); + }); + + describe("buy", function () { + it("Should execute buy (exact output swap) and return spent amount", async function () { + const buyAmount = ethers.parseUnits("1", WETH_DECIMALS); // 1 WETH + const amountInUsed = ethers.parseUnits("2500", USDC_DECIMALS); // router uses 2500 USDC + + // Configure mock router: will consume 2500 USDC and output 1 WETH + await mockRouter.setNextSwapResult(amountInUsed, buyAmount); + + // Mint USDC to user and approve adapter with exact amount + const approvalAmount = amountInUsed; + await usdc.mint(user.address, approvalAmount); + await usdc.connect(user).approve(await adapter.getAddress(), approvalAmount); + + const balanceBefore = await usdc.balanceOf(user.address); + await adapter.connect(user).buy(await weth.getAddress(), buyAmount); + const balanceAfter = await usdc.balanceOf(user.address); + + // User should have spent exactly amountInUsed + expect(balanceBefore - balanceAfter).to.equal(amountInUsed); + + // User should have received WETH + const wethBalance = await weth.balanceOf(user.address); + expect(wethBalance).to.be.gte(buyAmount); + }); + + it("Should refund unused USDC when router uses less than approved", async function () { + const buyAmount = ethers.parseUnits("1", WETH_DECIMALS); + const actualSpent = ethers.parseUnits("2400", USDC_DECIMALS); + const approvalAmount = ethers.parseUnits("3000", USDC_DECIMALS); // over-approve + + // Router only uses 2400 of the 3000 approved + await mockRouter.setNextSwapResult(actualSpent, buyAmount); + + await usdc.mint(user.address, approvalAmount); + await usdc.connect(user).approve(await adapter.getAddress(), approvalAmount); + + const balanceBefore = await usdc.balanceOf(user.address); + await adapter.connect(user).buy(await weth.getAddress(), buyAmount); + const balanceAfter = await usdc.balanceOf(user.address); + + // User should only lose actualSpent, the rest is refunded + expect(balanceBefore - balanceAfter).to.equal(actualSpent); + }); + + it("Should clean up router approval after buy", async function () { + const buyAmount = ethers.parseUnits("0.5", WETH_DECIMALS); + const amountInUsed = ethers.parseUnits("1300", USDC_DECIMALS); + + await mockRouter.setNextSwapResult(amountInUsed, buyAmount); + await usdc.mint(user.address, amountInUsed); + await usdc.connect(user).approve(await adapter.getAddress(), amountInUsed); + + await adapter.connect(user).buy(await weth.getAddress(), buyAmount); + + const allowance = await usdc.allowance(await adapter.getAddress(), await mockRouter.getAddress()); + expect(allowance).to.equal(0); + }); + }); +}); diff --git a/test/helpers/resetNetwork.ts b/test/helpers/resetNetwork.ts index 42a2da3b..d2856a6d 100644 --- a/test/helpers/resetNetwork.ts +++ b/test/helpers/resetNetwork.ts @@ -2,8 +2,21 @@ import { network } from "hardhat"; /** * Reset the Hardhat network to a clean state. - * Call in a root-level before() hook so each test file starts with a fresh chain + * Call in a root-level before() hook so each test file starts with a fresh chain. + * Preserves fork configuration if the network was started with forking enabled. */ export async function resetNetwork(): Promise { - await network.provider.send("hardhat_reset", []); + const hardhatNetworkConfig = network.config as unknown as Record; + const forking = hardhatNetworkConfig.forking as { url?: string; blockNumber?: number } | undefined; + + const resetParams = forking?.url + ? { + forking: { + jsonRpcUrl: forking.url, + ...(forking.blockNumber !== undefined ? { blockNumber: forking.blockNumber } : {}), + }, + } + : {}; + + await network.provider.send("hardhat_reset", [resetParams]); } diff --git a/test/orchestrator/OrchestratorConfiguration.test.ts b/test/orchestrator/OrchestratorConfiguration.test.ts index 96955bde..a2ed9203 100644 --- a/test/orchestrator/OrchestratorConfiguration.test.ts +++ b/test/orchestrator/OrchestratorConfiguration.test.ts @@ -73,12 +73,12 @@ import { resetNetwork } from "../helpers/resetNetwork"; import { MockUnderlyingAsset, MockERC4626Asset, - OrionAssetERC4626ExecutionAdapter, + ERC4626ExecutionAdapter, OrionConfig, LiquidityOrchestrator, TransparentVaultFactory, OrionTransparentVault, - OrionAssetERC4626PriceAdapter, + ERC4626PriceAdapter, KBestTvlWeightedAverage, } from "../../typechain-types"; @@ -101,8 +101,8 @@ describe("Orchestrator Configuration", function () { let mockAsset1: MockERC4626Asset; let mockAsset2: MockERC4626Asset; let mockAsset3: MockERC4626Asset; - let orionPriceAdapter: OrionAssetERC4626PriceAdapter; - let orionExecutionAdapter: OrionAssetERC4626ExecutionAdapter; + let orionPriceAdapter: ERC4626PriceAdapter; + let orionExecutionAdapter: ERC4626ExecutionAdapter; let liquidityOrchestrator: LiquidityOrchestrator; let absoluteVault: OrionTransparentVault; let highWaterMarkVault: OrionTransparentVault; @@ -189,10 +189,9 @@ describe("Orchestrator Configuration", function () { liquidityOrchestrator = deployed.liquidityOrchestrator; transparentVaultFactory = deployed.transparentVaultFactory; - const OrionAssetERC4626PriceAdapterFactory = await ethers.getContractFactory("OrionAssetERC4626PriceAdapter"); - orionPriceAdapter = (await OrionAssetERC4626PriceAdapterFactory.deploy( - await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626PriceAdapter; + // Deploy MockPriceAdapter - these vaults use USDC as underlying (same-asset), ERC4626PriceAdapter rejects same-asset + const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); + orionPriceAdapter = (await MockPriceAdapterFactory.deploy()) as unknown as ERC4626PriceAdapter; await orionPriceAdapter.waitForDeployment(); // Configure protocol @@ -223,12 +222,10 @@ describe("Orchestrator Configuration", function () { // Set minibatch size to a large value to process all vaults in one batch for tests await liquidityOrchestrator.connect(owner).updateMinibatchSize(8); - const OrionAssetERC4626ExecutionAdapterFactory = await ethers.getContractFactory( - "OrionAssetERC4626ExecutionAdapter", - ); - orionExecutionAdapter = (await OrionAssetERC4626ExecutionAdapterFactory.deploy( + const ERC4626ExecutionAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + orionExecutionAdapter = (await ERC4626ExecutionAdapterFactory.deploy( await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626ExecutionAdapter; + )) as unknown as ERC4626ExecutionAdapter; await orionExecutionAdapter.waitForDeployment(); await orionConfig.addWhitelistedAsset( diff --git a/test/orchestrator/Orchestrators.test.ts b/test/orchestrator/Orchestrators.test.ts index 67cb11b1..bb4eec34 100644 --- a/test/orchestrator/Orchestrators.test.ts +++ b/test/orchestrator/Orchestrators.test.ts @@ -6,12 +6,12 @@ import { time } from "@nomicfoundation/hardhat-network-helpers"; import { MockUnderlyingAsset, MockERC4626Asset, - OrionAssetERC4626ExecutionAdapter, + ERC4626ExecutionAdapter, OrionConfig, LiquidityOrchestrator, TransparentVaultFactory, OrionTransparentVault, - OrionAssetERC4626PriceAdapter, + ERC4626PriceAdapter, KBestTvlWeightedAverage, } from "../../typechain-types"; import { deployUpgradeableProtocol } from "../helpers/deployUpgradeable"; @@ -40,8 +40,8 @@ describe("Orchestrators", function () { let mockAsset1: MockERC4626Asset; let mockAsset2: MockERC4626Asset; let mockAsset3: MockERC4626Asset; - let orionPriceAdapter: OrionAssetERC4626PriceAdapter; - let orionExecutionAdapter: OrionAssetERC4626ExecutionAdapter; + let orionPriceAdapter: ERC4626PriceAdapter; + let orionExecutionAdapter: ERC4626ExecutionAdapter; let liquidityOrchestrator: LiquidityOrchestrator; let absoluteVault: OrionTransparentVault; let highWaterMarkVault: OrionTransparentVault; @@ -130,10 +130,9 @@ describe("Orchestrators", function () { console.log("orionConfig address", await orionConfig.getAddress()); - const OrionAssetERC4626PriceAdapterFactory = await ethers.getContractFactory("OrionAssetERC4626PriceAdapter"); - orionPriceAdapter = (await OrionAssetERC4626PriceAdapterFactory.deploy( - await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626PriceAdapter; + // Deploy MockPriceAdapter - these vaults use USDC as underlying (same-asset), ERC4626PriceAdapter rejects same-asset + const MockPriceAdapterFactory = await ethers.getContractFactory("MockPriceAdapter"); + orionPriceAdapter = (await MockPriceAdapterFactory.deploy()) as unknown as ERC4626PriceAdapter; await orionPriceAdapter.waitForDeployment(); await orionConfig.connect(owner).updateProtocolFees(10, 1000); @@ -164,12 +163,10 @@ describe("Orchestrators", function () { // Set minibatch size to a large value to process all vaults in one batch for tests await liquidityOrchestrator.connect(owner).updateMinibatchSize(8); - const OrionAssetERC4626ExecutionAdapterFactory = await ethers.getContractFactory( - "OrionAssetERC4626ExecutionAdapter", - ); - orionExecutionAdapter = (await OrionAssetERC4626ExecutionAdapterFactory.deploy( + const ERC4626ExecutionAdapterFactory = await ethers.getContractFactory("ERC4626ExecutionAdapter"); + orionExecutionAdapter = (await ERC4626ExecutionAdapterFactory.deploy( await orionConfig.getAddress(), - )) as unknown as OrionAssetERC4626ExecutionAdapter; + )) as unknown as ERC4626ExecutionAdapter; await orionExecutionAdapter.waitForDeployment(); await orionConfig.addWhitelistedAsset(