diff --git a/test/unit/LidoARM/Base.t.sol b/test/unit/LidoARM/Base.t.sol new file mode 100644 index 00000000..2a309667 --- /dev/null +++ b/test/unit/LidoARM/Base.t.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {Test} from "forge-std/Test.sol"; + +// Contracts +import {LidoARM} from "contracts/LidoARM.sol"; +import {CapManager} from "contracts/CapManager.sol"; +import {StETHAssetAdapter} from "contracts/adapters/StETHAssetAdapter.sol"; +import {WstETHAssetAdapter} from "contracts/adapters/WstETHAssetAdapter.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Mocks +import {MockWstETH} from "./mocks/MockWstETH.sol"; +import {MockERC4626Market} from "./mocks/MockERC4626Market.sol"; +import {MockLidoWithdraw} from "./mocks/MockLidoWithdraw.sol"; + +abstract contract Base_Test_ is Test { + ////////////////////////////////////////////////////// + /// --- CONTRACTS + ////////////////////////////////////////////////////// + // Main contracts + LidoARM public lidoARM; + CapManager public capManager; + StETHAssetAdapter public stETHAssetAdapter; + WstETHAssetAdapter public wstETHAssetAdapter; + + // Interfaces + IERC20 public weth; + IERC20 public steth; + IERC20 public wsteth; + + // Mocks + MockWstETH public mockWstETH; + MockLidoWithdraw public lidoWithdrawalQueue; + MockERC4626Market public mockERC4626Market; + MockERC4626Market public mockERC4626Market2; + + ////////////////////////////////////////////////////// + /// --- Governance, multisigs and EOAs + ////////////////////////////////////////////////////// + // Users + address public alice = makeAddr("alice"); + address public bobby = makeAddr("bobby"); + + // Privileged roles + address public deployer = makeAddr("deployer"); + address public governor = makeAddr("governor"); + address public operator = makeAddr("operator"); + address public feeCollector = makeAddr("feeCollector"); + + ////////////////////////////////////////////////////// + /// --- DEFAULT VALUES + ////////////////////////////////////////////////////// + uint256 public constant FEE_SCALE = 10_000; + uint256 public constant PRICE_SCALE = 1e36; + uint256 public constant DEFAULT_FEE = 2_000; + uint256 public constant CLAIM_DELAY = 10 minutes; + uint256 public constant DELAY_REQUEST = 30 minutes; + uint256 public constant DEFAULT_AMOUNT = 1 ether; + uint256 public constant MIN_TOTAL_SUPPLY = 1e12; + uint256 public constant MIN_SHARES_TO_REDEEM = 1e7; + uint256 public constant MAX_CROSS_PRICE_DEVIATION = 20e32; +} diff --git a/test/unit/LidoARM/Shared.t.sol b/test/unit/LidoARM/Shared.t.sol new file mode 100644 index 00000000..72f6022a --- /dev/null +++ b/test/unit/LidoARM/Shared.t.sol @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Base_Test_} from "./Base.t.sol"; + +// Contracts +import {Proxy} from "contracts/Proxy.sol"; +import {LidoARM} from "contracts/LidoARM.sol"; +import {CapManager} from "contracts/CapManager.sol"; +import {StETHAssetAdapter} from "contracts/adapters/StETHAssetAdapter.sol"; +import {WstETHAssetAdapter} from "contracts/adapters/WstETHAssetAdapter.sol"; +import {AbstractLidoAssetAdapter} from "contracts/adapters/AbstractLidoAssetAdapter.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Mocks +import {WETH} from "@solmate/tokens/WETH.sol"; +import {MockERC20} from "@solmate/test/utils/mocks/MockERC20.sol"; +import {MockWstETH} from "./mocks/MockWstETH.sol"; +import {MockERC4626Market} from "./mocks/MockERC4626Market.sol"; +import {MockLidoWithdraw} from "./mocks/MockLidoWithdraw.sol"; + +abstract contract Unit_LidoARM_Shared_Test is Base_Test_ { + ////////////////////////////////////////////////////// + /// --- SETUP + ////////////////////////////////////////////////////// + function setUp() public virtual { + // Deploy Mock contracts + deployMockContracts(); + + // Deploy contracts + deployContracts(); + + // Label contracts + labelAll(); + + // Approve spending + approveSpending(); + } + + function deployMockContracts() internal virtual { + weth = IERC20(address(new WETH())); + steth = IERC20(address(new MockERC20("Staked Ether", "stETH", 18))); + mockERC4626Market = new MockERC4626Market(weth); + mockERC4626Market2 = new MockERC4626Market(weth); + mockWstETH = new MockWstETH(steth); + wsteth = IERC20(address(mockWstETH)); + lidoWithdrawalQueue = new MockLidoWithdraw(address(steth)); + } + + function deployContracts() internal virtual { + vm.startPrank(deployer); + + // --- Deploy Proxies + Proxy lidoARMProxy = new Proxy(); + Proxy capManagerProxy = new Proxy(); + Proxy stETHAssetAdapterProxy = new Proxy(); + Proxy wstETHAssetAdapterProxy = new Proxy(); + + // --- Deploy Logic contracts + LidoARM lidoARMLogic = new LidoARM({ + _weth: address(weth), + _claimDelay: CLAIM_DELAY, + _minSharesToRedeem: MIN_SHARES_TO_REDEEM, + _allocateThreshold: 1 ether + }); + CapManager capManagerLogic = new CapManager({_arm: address(lidoARMProxy)}); + StETHAssetAdapter stETHAssetAdapterLogic = new StETHAssetAdapter({ + _arm: address(lidoARMProxy), + _weth: address(weth), + _steth: address(steth), + _lidoWithdrawalQueue: address(lidoWithdrawalQueue) + }); + WstETHAssetAdapter wstETHAssetAdapterLogic = new WstETHAssetAdapter({ + _arm: address(lidoARMProxy), + _weth: address(weth), + _steth: address(steth), + _wsteth: address(wsteth), + _lidoWithdrawalQueue: address(lidoWithdrawalQueue) + }); + + // Initialization requires 1e12 liquid assets to mint to dead address. + // Mint 1e12 liquid assets to the deployer. + deal(address(weth), deployer, 1e12); + // Deployer approve the proxy to transfer 1e12 liquid assets. + weth.approve(address(lidoARMProxy), 1e12); + + // --- Initialize Proxies + // LidoARM Proxy + lidoARMProxy.initialize( + address(lidoARMLogic), + governor, + abi.encodeWithSelector( + LidoARM.initialize.selector, + "Lido ARM", + "LIDO-ARM", + operator, + DEFAULT_FEE, + feeCollector, + address(capManagerProxy) + ) + ); + + // CapManager Proxy + capManagerProxy.initialize( + address(capManagerLogic), + governor, + abi.encodeWithSelector(CapManager.initialize.selector, address(lidoARMProxy)) + ); + + // StETHAssetAdapter Proxy. Run `initialize()` through the proxy so the adapter + // approves the lido withdrawal queue from the proxy's storage, not the impl's. + stETHAssetAdapterProxy.initialize( + address(stETHAssetAdapterLogic), + governor, + abi.encodeWithSelector(AbstractLidoAssetAdapter.initialize.selector) + ); + + // WstETHAssetAdapter Proxy. Same rationale as above. + wstETHAssetAdapterProxy.initialize( + address(wstETHAssetAdapterLogic), + governor, + abi.encodeWithSelector(AbstractLidoAssetAdapter.initialize.selector) + ); + + vm.stopPrank(); + + // --- Set the proxy's implementation to the logic contract + lidoARM = LidoARM(payable(address(lidoARMProxy))); + capManager = CapManager(address(capManagerProxy)); + stETHAssetAdapter = StETHAssetAdapter(payable(address(stETHAssetAdapterProxy))); + wstETHAssetAdapter = WstETHAssetAdapter(payable(address(wstETHAssetAdapterProxy))); + } + + function labelAll() public virtual { + vm.label(address(weth), "WETH"); + vm.label(address(steth), "STETH"); + vm.label(address(wsteth), "WSTETH"); + vm.label(address(mockWstETH), "WSTETH"); + vm.label(address(lidoARM), "LIDO ARM PROXY"); + vm.label(address(capManager), "CAP MANAGER PROXY"); + vm.label(address(stETHAssetAdapter), "STETH ASSET ADAPTER PROXY"); + vm.label(address(wstETHAssetAdapter), "WSTETH ASSET ADAPTER PROXY"); + vm.label(address(lidoWithdrawalQueue), "LIDO WITHDRAWAL QUEUE (MOCK)"); + vm.label(address(mockERC4626Market), "ERC4626 MARKET (MOCK)"); + vm.label(address(mockERC4626Market2), "ERC4626 MARKET 2 (MOCK)"); + } + + function approveSpending() internal { + vm.startPrank(alice); + weth.approve(address(lidoARM), type(uint256).max); + steth.approve(address(lidoARM), type(uint256).max); + wsteth.approve(address(lidoARM), type(uint256).max); + vm.stopPrank(); + + vm.startPrank(bobby); + weth.approve(address(lidoARM), type(uint256).max); + steth.approve(address(lidoARM), type(uint256).max); + wsteth.approve(address(lidoARM), type(uint256).max); + vm.stopPrank(); + } + + function desactiveCapManager() internal { + vm.prank(governor); + lidoARM.setCapManager(address(0)); + } + + function addMarket(address market) internal { + address[] memory markets = new address[](1); + markets[0] = market; + vm.prank(governor); + lidoARM.addMarkets(markets); + } + + function setActiveMarket(address market) internal { + vm.prank(governor); + lidoARM.setActiveMarket(market); + } + + function setARMBuffer(uint256 buffer) internal { + vm.prank(governor); + lidoARM.setARMBuffer(buffer); + } + + function aliceFirstDeposit() internal { + aliceFirstDeposit(100 ether); + } + + function bobbyFirstDeposit() internal { + bobbyFirstDeposit(100 ether); + } + + function aliceFirstDeposit(uint256 amount) internal { + firstDeposit(alice, amount); + } + + function bobbyFirstDeposit(uint256 amount) internal { + firstDeposit(bobby, amount); + } + + function firstDeposit(address user, uint256 amount) internal { + vm.startPrank(user); + // Give the user some WETH + deal(address(weth), user, amount); + // The user approve LidoARM to spend his WETH + weth.approve(address(lidoARM), type(uint256).max); + // The user deposit the specified amount of WETH to LidoARM + lidoARM.deposit(amount); + vm.stopPrank(); + } + + function addBaseAsset(IERC20 token) internal { + vm.prank(governor); + if (token == steth) { + lidoARM.addBaseAsset( + address(steth), + address(stETHAssetAdapter), + 992 * 1e33, + 1001 * 1e33, + type(uint128).max, + type(uint128).max, + 1e36, + true + ); + } else if (token == wsteth) { + lidoARM.addBaseAsset( + address(wsteth), + address(wstETHAssetAdapter), + 992 * 1e33, + 1001 * 1e33, + type(uint128).max, + type(uint128).max, + 1e36, + false + ); + } else { + revert("Unsupported token"); + } + } + + function buyPrice(IERC20 token) internal view returns (uint256) { + (uint128 _buyPrice,,,,,,,) = lidoARM.baseAssetConfigs(address(token)); + return _buyPrice; + } + + function sellPrice(IERC20 token) internal view returns (uint256) { + (, uint128 _sellPrice,,,,,,) = lidoARM.baseAssetConfigs(address(token)); + return _sellPrice; + } + + function buyLiquidityRemaining(IERC20 token) internal view returns (uint256) { + (,, uint128 _buyLiquidityRemaining,,,,,) = lidoARM.baseAssetConfigs(address(token)); + return _buyLiquidityRemaining; + } + + function sellLiquidityRemaining(IERC20 token) internal view returns (uint256) { + (,,, uint128 _sellLiquidityRemaining,,,,) = lidoARM.baseAssetConfigs(address(token)); + return _sellLiquidityRemaining; + } + + function crossPrice(IERC20 token) internal view returns (uint256) { + (,,,, uint128 _crossPrice,,,) = lidoARM.baseAssetConfigs(address(token)); + return _crossPrice; + } + + function pendingRedeemAssets(IERC20 token) internal view returns (uint256) { + (,,,,, uint128 _pendingRedeemAssets,,) = lidoARM.baseAssetConfigs(address(token)); + return _pendingRedeemAssets; + } + + function expectedBuySideFee(IERC20 token, uint256 amountOut) internal view returns (uint256) { + uint256 assetBuyPrice = buyPrice(token); + uint256 assetCrossPrice = crossPrice(token); + uint256 feeMultiplier = assetBuyPrice == 0 + ? 0 + : (assetCrossPrice - assetBuyPrice) * uint256(lidoARM.fee()) * PRICE_SCALE / (assetBuyPrice * FEE_SCALE); + + return amountOut * feeMultiplier / PRICE_SCALE; + } + + function seedWstETHWithTargetExchangeRate() internal { + uint256 initialStETH = 100 ether; + uint256 accruedStETHRewards = 23.7 ether; + address deadHolder = address(0xdead); + + // Seed the wrapper with a real wstETH supply. We start by wrapping 100 stETH, which mints 100 wstETH + // shares because the ERC4626 exchange rate is still 1:1 when total supply is zero. + deal(address(steth), deadHolder, initialStETH); + + vm.startPrank(deadHolder); + // The mock wstETH uses ERC4626 deposit semantics under the hood, so the holder must approve stETH first. + steth.approve(address(wsteth), initialStETH); + // Mint wstETH shares to deadHolder and lock them there so tests start from a non-empty wrapper. + mockWstETH.wrap(initialStETH); + vm.stopPrank(); + + // Donate stETH directly to the wrapper, like rebasing stETH rewards accruing behind existing wstETH shares. + // After this, the vault has 123.7 stETH backing 100 wstETH. + // That gives: 1 wstETH = 1.237 stETH = 1.237 WETH. + MockERC20(address(steth)).mint(address(wsteth), accruedStETHRewards); + + assertEq( + mockWstETH.getStETHByWstETH(1 ether), + 1.237 ether, + "1 wstETH should be worth 1.237 stETH after seeding the exchange rate" + ); + } + + function dealWsteth(address to, uint256 amount) internal { + // Do not use Forge's deal(address(wsteth), to, amount) here. wstETH is an ERC4626-style vault token, so + // directly editing the share balance would bypass the underlying stETH transfer and break vault accounting. + // + // Instead, calculate how much stETH is needed to mint the requested amount of wstETH shares, then go + // through the normal ERC4626 mint path. This keeps totalSupply, the holder's wstETH shares, and the + // wrapper's stETH assets consistent with each other. + address from = address(0xfeed); + require(wsteth.balanceOf(from) == 0, "from address should start with 0 wstETH"); + + // amount is denominated in wstETH shares. If the wrapper has accrued rewards, 1 wstETH is worth more + // than 1 stETH, so minting amount shares requires more than amount stETH. + uint256 requiredStETH = mockWstETH.previewMint(amount); + deal(address(steth), from, requiredStETH); + + vm.startPrank(from); + // The mock wstETH pulls stETH during mint, so the temporary holder must approve the wrapper first. + steth.approve(address(wsteth), requiredStETH); + mockWstETH.mint(amount, from); + wsteth.transfer(to, amount); + vm.stopPrank(); + } + + function aliceRequest(uint256 sharesToRedeem) internal returns (uint256 requestId, uint256 assets) { + return requestRedeem(alice, sharesToRedeem); + } + + function bobbyRequest(uint256 sharesToRedeem) internal returns (uint256 requestId, uint256 assets) { + return requestRedeem(bobby, sharesToRedeem); + } + + function requestRedeem(address user, uint256 sharesToRedeem) internal returns (uint256 requestId, uint256 assets) { + if (sharesToRedeem == 0) { + sharesToRedeem = lidoARM.balanceOf(user); + } + vm.prank(user); + (requestId, assets) = lidoARM.requestRedeem(sharesToRedeem); + } + + function _assertStoredRequest( + uint256 requestId, + address expectedWithdrawer, + uint256 expectedClaimTimestamp, + uint256 expectedAssets, + uint256 expectedQueued, + uint256 expectedShares + ) internal view { + ( + address withdrawer, + bool claimed, + uint40 claimTimestamp, + uint128 storedAssets, + uint128 storedQueued, + uint128 storedShares + ) = lidoARM.withdrawalRequests(requestId); + assertEq(withdrawer, expectedWithdrawer, "req.withdrawer"); + assertEq(claimed, false, "req.claimed"); + assertEq(claimTimestamp, expectedClaimTimestamp, "req.claimTimestamp"); + assertEq(storedAssets, expectedAssets, "req.assets"); + assertEq(storedQueued, expectedQueued, "req.queued"); + assertEq(storedShares, expectedShares, "req.shares"); + } +} diff --git a/test/unit/LidoARM/concrete/Accounting.t.sol b/test/unit/LidoARM/concrete/Accounting.t.sol new file mode 100644 index 00000000..c71da0a4 --- /dev/null +++ b/test/unit/LidoARM/concrete/Accounting.t.sol @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Interfaces +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +/// @notice Coverage for the ARM's accounting view functions: `totalAssets`, +/// `convertToAssets` / `convertToShares`, `claimable`, `getReserves`, +/// and the `previewDeposit` / `previewRedeem` wrappers. Each test sets +/// up exactly one scenario along one of four axes (market, fees, LP +/// queue, yield/loss) and asserts the function's value with a +/// hand-computed expected. +contract Unit_LidoARM_Accounting_Test is Unit_LidoARM_Shared_Test { + uint256 internal constant INITIAL = 1e12; // MIN_TOTAL_SUPPLY minted to dead at init + + function setUp() public override { + super.setUp(); + desactiveCapManager(); + } + + ////////////////////////////////////////////////////// + /// --- totalAssets + ////////////////////////////////////////////////////// + function test_Accounting_TotalAssets_Initial() public view { + assertEq(lidoARM.totalAssets(), INITIAL, "totalAssets at init"); + } + + function test_Accounting_TotalAssets_NoFees_NoMarket_SingleAsset() public { + aliceFirstDeposit(); + assertEq(lidoARM.totalAssets(), 100 ether + INITIAL, "totalAssets after deposit"); + } + + function test_Accounting_TotalAssets_WithMarketAllocated() public { + aliceFirstDeposit(); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); // buffer=0 → all to market + assertEq(weth.balanceOf(address(lidoARM)), 0, "ARM WETH (moved to market)"); + + // Allocation moves liquidity; it should not change totalAssets. + assertEq(lidoARM.totalAssets(), 100 ether + INITIAL, "totalAssets unchanged by allocation"); + } + + function test_Accounting_TotalAssets_WithMarketLoss() public { + aliceFirstDeposit(); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + + // 50% loss in the market drops the share value 1:1 (MockERC4626 reads WETH balance). + uint256 halved = (100 ether + INITIAL) / 2; + deal(address(weth), address(mockERC4626Market), halved); + + assertEq(lidoARM.totalAssets(), halved, "totalAssets reflects market loss"); + } + + function test_Accounting_TotalAssets_WithMarketYield() public { + aliceFirstDeposit(); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + + // Donating WETH to the market raises share value pro-rata. The ARM owns 100% of supply, + // so the donation flows through as added totalAssets. + deal(address(weth), address(mockERC4626Market), 100 ether + INITIAL + 10 ether); + + assertEq(lidoARM.totalAssets(), 110 ether + INITIAL, "totalAssets reflects market yield"); + } + + function test_Accounting_TotalAssets_WithAccruedFees() public { + _generateFees(); + + uint256 wethBal = weth.balanceOf(address(lidoARM)); + uint256 stethBal = steth.balanceOf(address(lidoARM)); + uint256 fees = lidoARM.feesAccrued(); + + assertGt(fees, 0, "fees accrued"); + // stETH is pegged with crossPrice=1e36, so it contributes 1:1 to availableAssets. + assertEq(lidoARM.totalAssets(), wethBal + stethBal - fees, "totalAssets net of fees"); + } + + function test_Accounting_TotalAssets_AfterCollectFees() public { + _generateFees(); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + uint256 fees = lidoARM.feesAccrued(); + + lidoARM.collectFees(); + + assertEq(lidoARM.feesAccrued(), 0, "feesAccrued zeroed"); + assertEq(weth.balanceOf(feeCollector), fees, "feeCollector received the WETH"); + // The WETH transferred out was already excluded from totalAssets via feesAccrued, so the + // visible totalAssets is unchanged across the collection. + assertEq(lidoARM.totalAssets(), totalAssetsBefore, "totalAssets unchanged across collect"); + } + + function test_Accounting_TotalAssets_WithPendingLPRequest() public { + aliceFirstDeposit(); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + aliceRequest(50 ether); + + // Shares are escrowed (still counted in totalSupply) and no value left the ARM. + assertEq(lidoARM.totalAssets(), totalAssetsBefore, "totalAssets unchanged by pending request"); + assertEq(lidoARM.totalSupply(), 100 ether + INITIAL, "totalSupply unchanged by pending request"); + } + + function test_Accounting_TotalAssets_AfterLPClaim() public { + aliceFirstDeposit(); + (uint256 requestId,) = aliceRequest(50 ether); + skip(CLAIM_DELAY); + + vm.prank(alice); + lidoARM.claimRedeem(requestId); + + // Claim burns 50 ether of shares and sends 50 ether of WETH out. + assertEq(lidoARM.totalAssets(), 50 ether + INITIAL, "totalAssets after claim"); + assertEq(lidoARM.totalSupply(), 50 ether + INITIAL, "totalSupply after claim"); + } + + function test_Accounting_TotalAssets_WithBaseAssetBalance() public { + addBaseAsset(steth); + deal(address(steth), address(lidoARM), 10 ether); + + // stETH is pegged with crossPrice=1e36, so its on-hand balance contributes 1:1. + assertEq(lidoARM.totalAssets(), INITIAL + 10 ether, "totalAssets with stETH balance"); + } + + function test_Accounting_TotalAssets_WithPendingBaseRedeem() public { + addBaseAsset(steth); + deal(address(steth), address(lidoARM), 10 ether); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + vm.prank(operator); + lidoARM.requestBaseAssetRedeem(address(steth), 5 ether); + + // 5 stETH moved off the ARM into the protocol queue; pendingRedeemAssets picks up the + // same liquidity value at the (unchanged) crossPrice. Net effect on totalAssets: zero. + assertEq(lidoARM.totalAssets(), totalAssetsBefore, "totalAssets unchanged across request"); + } + + function test_Accounting_TotalAssets_AfterBaseRedeemClaimed() public { + addBaseAsset(steth); + deal(address(steth), address(lidoARM), 10 ether); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + vm.prank(operator); + lidoARM.requestBaseAssetRedeem(address(steth), 10 ether); + vm.prank(operator); + lidoARM.claimBaseAssetRedeem(address(steth), 10 ether); + + // pendingRedeemAssets clears and 10 ether of WETH lands in the ARM. + assertEq(lidoARM.totalAssets(), totalAssetsBefore, "totalAssets unchanged across full cycle"); + } + + function test_Accounting_TotalAssets_AfterYield() public { + aliceFirstDeposit(); + // Donate 10 WETH directly to the ARM. + deal(address(weth), address(lidoARM), 100 ether + INITIAL + 10 ether); + + assertEq(lidoARM.totalAssets(), 110 ether + INITIAL, "totalAssets after WETH yield"); + } + + function test_Accounting_TotalAssets_AfterLoss_ClampedAtMinSupply() public { + aliceFirstDeposit(); + aliceRequest(100 ether); // reserves the entire deposit + deal(address(weth), address(lidoARM), 0); // wipe out all liquidity + + // _availableAssets = 0; feesAccrued (0) + MIN_TOTAL_SUPPLY >= 0 → clamp. + assertEq(lidoARM.totalAssets(), INITIAL, "totalAssets clamped at MIN_TOTAL_SUPPLY"); + } + + ////////////////////////////////////////////////////// + /// --- convertToAssets / convertToShares + ////////////////////////////////////////////////////// + function test_Accounting_ConvertToAssets_Initial_OneToOne() public view { + // totalAssets = totalSupply = INITIAL → 1:1. + assertEq(lidoARM.convertToAssets(1 ether), 1 ether, "1 share -> 1 asset at init"); + } + + function test_Accounting_ConvertToShares_Initial_OneToOne() public view { + assertEq(lidoARM.convertToShares(1 ether), 1 ether, "1 asset -> 1 share at init"); + } + + function test_Accounting_ConvertToAssets_AfterYield_SharesAppreciate() public { + aliceFirstDeposit(); + // 50 ether yield: totalAssets rises, totalSupply unchanged. + deal(address(weth), address(lidoARM), 100 ether + INITIAL + 50 ether); + + uint256 expected = uint256(1 ether) * (150 ether + INITIAL) / (100 ether + INITIAL); + assertEq(lidoARM.convertToAssets(1 ether), expected, "convertToAssets after yield"); + assertGt(expected, 1 ether, "shares appreciated"); + } + + function test_Accounting_ConvertToAssets_AfterLoss_SharesDepreciate() public { + aliceFirstDeposit(); + // 50% loss: drop ARM WETH from 100 ether + INITIAL down to 50 ether. + deal(address(weth), address(lidoARM), 50 ether); + + uint256 expected = uint256(1 ether) * 50 ether / (100 ether + INITIAL); + assertEq(lidoARM.convertToAssets(1 ether), expected, "convertToAssets after loss"); + assertLt(expected, 1 ether, "shares depreciated"); + } + + function test_Accounting_ConvertToAssets_WithFees_NetOfAccrued() public { + _generateFees(); + + uint256 wethBal = weth.balanceOf(address(lidoARM)); + uint256 stethBal = steth.balanceOf(address(lidoARM)); + uint256 fees = lidoARM.feesAccrued(); + uint256 expectedTotalAssets = wethBal + stethBal - fees; + uint256 expectedShareValue = uint256(1 ether) * expectedTotalAssets / lidoARM.totalSupply(); + + assertEq(lidoARM.convertToAssets(1 ether), expectedShareValue, "convertToAssets net of fees"); + } + + ////////////////////////////////////////////////////// + /// --- claimable + ////////////////////////////////////////////////////// + function test_Accounting_Claimable_NoMarket() public { + aliceFirstDeposit(); + // No claims yet, no market: claimable = convertToShares(WETH balance). + uint256 expected = lidoARM.convertToShares(100 ether + INITIAL); + assertEq(lidoARM.claimable(), expected, "claimable with no market"); + } + + function test_Accounting_Claimable_WithMarketAllocated() public { + aliceFirstDeposit(); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); // all WETH moved into the market + + // WETH balance now 0, but maxWithdraw from market replaces it. + uint256 expected = lidoARM.convertToShares(100 ether + INITIAL); + assertEq(lidoARM.claimable(), expected, "claimable includes market maxWithdraw"); + } + + function test_Accounting_Claimable_AfterLPClaim_AccumulatesClaimedShares() public { + aliceFirstDeposit(); + (uint256 requestId,) = aliceRequest(50 ether); + skip(CLAIM_DELAY); + vm.prank(alice); + lidoARM.claimRedeem(requestId); + + // 50 ether of shares were burned; withdrawsClaimedShares is bumped by that amount. + // Remaining WETH (50 ether + INITIAL) is still 1:1 with the remaining supply. + uint256 expected = 50 ether + lidoARM.convertToShares(50 ether + INITIAL); + assertEq(lidoARM.claimable(), expected, "claimable accumulates burned shares"); + } + + function test_Accounting_Claimable_MarketLiquidityConstrained() public { + aliceFirstDeposit(); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + + // Force maxWithdraw to 0. The market still has economic value (convertToAssets unchanged), + // but claimable only counts what can actually be pulled out right now. + vm.mockCall(address(mockERC4626Market), abi.encodeWithSelector(IERC4626.maxWithdraw.selector), abi.encode(0)); + + assertEq(lidoARM.claimable(), 0, "claimable drops when liquidity is constrained"); + + vm.clearMockedCalls(); + } + + ////////////////////////////////////////////////////// + /// --- getReserves + ////////////////////////////////////////////////////// + function test_Accounting_GetReserves_AfterDeposit() public { + addBaseAsset(steth); + aliceFirstDeposit(); + + (uint256 liquidityAssets, uint256 baseAssetReserve) = lidoARM.getReserves(address(steth)); + assertEq(liquidityAssets, 100 ether + INITIAL, "liquidityAssets after deposit"); + assertEq(baseAssetReserve, 0, "baseAssetReserve"); + } + + function test_Accounting_GetReserves_WithActiveMarket() public { + addBaseAsset(steth); + aliceFirstDeposit(); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); // ARM WETH = 0, market maxWithdraw = deposited + + (uint256 liquidityAssets,) = lidoARM.getReserves(address(steth)); + assertEq(liquidityAssets, 100 ether + INITIAL, "liquidityAssets includes market maxWithdraw"); + } + + function test_Accounting_GetReserves_WithReservedWithdrawLiquidity() public { + addBaseAsset(steth); + aliceFirstDeposit(); + aliceRequest(50 ether); // reservedWithdrawLiquidity = 50 ether + + (uint256 liquidityAssets,) = lidoARM.getReserves(address(steth)); + assertEq(liquidityAssets, 50 ether + INITIAL, "liquidityAssets subtracts reserved"); + } + + function test_Accounting_GetReserves_ReservedExceedsBalance_ReturnsZero() public { + addBaseAsset(steth); + aliceFirstDeposit(); + aliceRequest(100 ether); // reserves the entire balance + deal(address(weth), address(lidoARM), 0); // wipe out WETH so reserved > balance + + (uint256 liquidityAssets,) = lidoARM.getReserves(address(steth)); + assertEq(liquidityAssets, 0, "liquidityAssets clamps to zero"); + } + + function test_Accounting_GetReserves_WithBaseAssetBalance() public { + addBaseAsset(steth); + deal(address(steth), address(lidoARM), 7 ether); + + (, uint256 baseAssetReserve) = lidoARM.getReserves(address(steth)); + assertEq(baseAssetReserve, 7 ether, "baseAssetReserve reflects ARM stETH balance"); + } + + function test_Accounting_GetReserves_RevertWhen_UnsupportedAsset() public { + // WETH is the liquidity asset, not a base asset. + vm.expectRevert("ARM: unsupported asset"); + lidoARM.getReserves(address(weth)); + } + + ////////////////////////////////////////////////////// + /// --- previewDeposit / previewRedeem + ////////////////////////////////////////////////////// + function test_Accounting_PreviewDeposit_MatchesConvertToShares() public { + aliceFirstDeposit(); + deal(address(weth), address(lidoARM), 100 ether + INITIAL + 25 ether); // create a non-trivial ratio + + assertEq(lidoARM.previewDeposit(7 ether), lidoARM.convertToShares(7 ether), "previewDeposit parity"); + } + + function test_Accounting_PreviewRedeem_MatchesConvertToAssets() public { + aliceFirstDeposit(); + deal(address(weth), address(lidoARM), 100 ether + INITIAL + 25 ether); + + assertEq(lidoARM.previewRedeem(7 ether), lidoARM.convertToAssets(7 ether), "previewRedeem parity"); + } + + ////////////////////////////////////////////////////// + /// --- Helpers + ////////////////////////////////////////////////////// + /// @dev Accrues fees via a stETH → WETH buy-side swap. After this, the ARM holds added stETH + /// and slightly less WETH; `feesAccrued` reflects the discount captured on the swap. + function _generateFees() internal { + aliceFirstDeposit(); // gives the ARM enough WETH to satisfy the swap output + addBaseAsset(steth); + + uint256 amountIn = 10 ether; + deal(address(steth), bobby, amountIn); + vm.prank(bobby); + lidoARM.swapExactTokensForTokens(steth, weth, amountIn, 0, bobby); + } +} diff --git a/test/unit/LidoARM/concrete/Admin.t.sol b/test/unit/LidoARM/concrete/Admin.t.sol new file mode 100644 index 00000000..9bf4a96f --- /dev/null +++ b/test/unit/LidoARM/concrete/Admin.t.sol @@ -0,0 +1,644 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; +import {Ownable} from "contracts/Ownable.sol"; +import {OwnableOperable} from "contracts/OwnableOperable.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Mocks +import {MockERC20} from "@solmate/test/utils/mocks/MockERC20.sol"; + +/// @notice Unit tests for the LidoARM admin surface — every owner / operator setter, +/// covering the happy path AND each documented revert branch. Wherever a +/// function does anything beyond writing a single field (event emission, +/// approvals, internal validation, side-effect calls into related state), +/// the happy-path test asserts that side effect explicitly so the test +/// catches removal of any of those behaviors. +contract Unit_LidoARM_Admin_Test is Unit_LidoARM_Shared_Test { + // Valid price defaults inside the [PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION, PRICE_SCALE] band. + uint256 internal constant CROSS_PRICE_DEFAULT = 1e36; + uint256 internal constant BUY_PRICE_DEFAULT = 992 * 1e33; // 0.992e36 + uint256 internal constant SELL_PRICE_DEFAULT = 1001 * 1e33; // 1.001e36 + uint256 internal constant LIQUIDITY_DEFAULT = type(uint128).max; + + function setUp() public override { + super.setUp(); + } + + /// @dev Drive `feesAccrued` to a non-zero value via the real swap path so the fee tests + /// exercise the same accrual logic that production callers hit. Setup: register stETH + /// with the default 0.992 buy price + 20% fee, seed alice with stETH, swap 10 stETH + /// worth for WETH out — the spread between cross and buy is the fee. Returns the + /// actual accrued amount so callers don't need to recompute it. + function _accrueFeesViaSwap() internal returns (uint256 accrued) { + desactiveCapManager(); // default total-assets cap is 0; disable so the deposit can land + addBaseAsset(steth); + aliceFirstDeposit(100 ether); // ARM now holds 100 ether of WETH available to swap out + + uint256 amountOut = 10 ether; + // amountIn rounding mirrors AbstractARM._swapTokensForExactTokens: amountOut * PRICE_SCALE / buyPrice + 3 wei. + uint256 expectedAmountIn = amountOut * PRICE_SCALE / BUY_PRICE_DEFAULT + 3; + deal(address(steth), alice, expectedAmountIn); + + vm.prank(alice); + lidoARM.swapTokensForExactTokens(steth, weth, amountOut, expectedAmountIn, alice); + + accrued = lidoARM.feesAccrued(); + require(accrued > 0, "test setup: swap did not accrue fees"); + } + + ////////////////////////////////////////////////////// + /// --- addBaseAsset + ////////////////////////////////////////////////////// + + function test_AddBaseAsset_Default() public { + // Pre: stETH not yet registered, no allowance from ARM to adapter. + (,,,,,,, address adapterBefore) = lidoARM.baseAssetConfigs(address(steth)); + assertEq(adapterBefore, address(0), "adapter unset pre"); + assertEq(steth.allowance(address(lidoARM), address(stETHAssetAdapter)), 0, "no allowance pre"); + + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.BaseAssetAdded( + address(steth), address(stETHAssetAdapter), BUY_PRICE_DEFAULT, SELL_PRICE_DEFAULT, CROSS_PRICE_DEFAULT, true + ); + + vm.prank(governor); + lidoARM.addBaseAsset( + address(steth), + address(stETHAssetAdapter), + BUY_PRICE_DEFAULT, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + CROSS_PRICE_DEFAULT, + true + ); + + // Storage written + ( + uint128 buyP, + uint128 sellP, + uint128 buyLiq, + uint128 sellLiq, + uint128 crossP, + uint128 pendingRedeem, + bool pegged, + address adapter + ) = lidoARM.baseAssetConfigs(address(steth)); + assertEq(buyP, BUY_PRICE_DEFAULT, "buyPrice"); + assertEq(sellP, SELL_PRICE_DEFAULT, "sellPrice"); + assertEq(buyLiq, LIQUIDITY_DEFAULT, "buyLiquidityRemaining"); + assertEq(sellLiq, LIQUIDITY_DEFAULT, "sellLiquidityRemaining"); + assertEq(crossP, CROSS_PRICE_DEFAULT, "crossPrice"); + assertEq(pendingRedeem, 0, "pendingRedeemAssets reset to 0"); + assertTrue(pegged, "peggedToLiquidityAsset"); + assertEq(adapter, address(stETHAssetAdapter), "adapter"); + + // Side effect: ARM approves the adapter for max stETH so requestRedeem can pull. + assertEq( + steth.allowance(address(lidoARM), address(stETHAssetAdapter)), + type(uint256).max, + "ARM stETH allowance to adapter" + ); + } + + function test_AddBaseAsset_RevertWhen_NotOwner() public { + vm.prank(alice); + vm.expectRevert(Ownable.OnlyOwner.selector); + lidoARM.addBaseAsset( + address(steth), + address(stETHAssetAdapter), + BUY_PRICE_DEFAULT, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + CROSS_PRICE_DEFAULT, + true + ); + } + + function test_AddBaseAsset_RevertWhen_AssetIsZero() public { + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidAsset.selector); + lidoARM.addBaseAsset( + address(0), + address(stETHAssetAdapter), + BUY_PRICE_DEFAULT, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + CROSS_PRICE_DEFAULT, + true + ); + } + + function test_AddBaseAsset_RevertWhen_AdapterIsZero() public { + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidAdapter.selector); + lidoARM.addBaseAsset( + address(steth), + address(0), + BUY_PRICE_DEFAULT, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + CROSS_PRICE_DEFAULT, + true + ); + } + + function test_AddBaseAsset_RevertWhen_AssetAlreadySupported() public { + addBaseAsset(steth); // first registration via shared helper + + vm.prank(governor); + vm.expectRevert(AbstractARM.AssetAlreadySupported.selector); + lidoARM.addBaseAsset( + address(steth), + address(stETHAssetAdapter), + BUY_PRICE_DEFAULT, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + CROSS_PRICE_DEFAULT, + true + ); + } + + function test_AddBaseAsset_RevertWhen_InvalidAssetDecimals() public { + // 6-decimal token: ARM forbids non-18-decimal base assets so accounting math stays consistent. + IERC20 sixDecimal = IERC20(address(new MockERC20("USDX", "USDX", 6))); + + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidAssetDecimals.selector); + lidoARM.addBaseAsset( + address(sixDecimal), + address(stETHAssetAdapter), + BUY_PRICE_DEFAULT, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + CROSS_PRICE_DEFAULT, + true + ); + } + + function test_AddBaseAsset_RevertWhen_AdapterAssetMismatch() public { + // mockWstETH (ERC4626 of stETH) has `asset() == steth`, which is NOT the ARM's + // liquidityAsset (weth). The cast through IAssetAdapter.asset() still succeeds because the + // signature matches, but the ARM rejects the mismatch. + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidAdapterAsset.selector); + lidoARM.addBaseAsset( + address(steth), + address(mockWstETH), + BUY_PRICE_DEFAULT, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + CROSS_PRICE_DEFAULT, + true + ); + } + + function test_AddBaseAsset_RevertWhen_CrossPriceTooLow() public { + // PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION = 1e36 - 20e32 = 0.998e36. One wei below reverts. + uint256 tooLow = PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION - 1; + vm.prank(governor); + vm.expectRevert(AbstractARM.CrossPriceTooLow.selector); + lidoARM.addBaseAsset( + address(steth), + address(stETHAssetAdapter), + BUY_PRICE_DEFAULT, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + tooLow, + true + ); + } + + function test_AddBaseAsset_RevertWhen_CrossPriceTooHigh() public { + // Cross price strictly above PRICE_SCALE (= 1e36) reverts. Equality is allowed. + vm.prank(governor); + vm.expectRevert(AbstractARM.CrossPriceTooHigh.selector); + lidoARM.addBaseAsset( + address(steth), + address(stETHAssetAdapter), + BUY_PRICE_DEFAULT, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + PRICE_SCALE + 1, + true + ); + } + + function test_AddBaseAsset_RevertWhen_SellPriceBelowCross() public { + // sellPrice < crossPrice is rejected by _validatePrices. + vm.prank(governor); + vm.expectRevert(AbstractARM.SellPriceTooLow.selector); + lidoARM.addBaseAsset( + address(steth), + address(stETHAssetAdapter), + BUY_PRICE_DEFAULT, + CROSS_PRICE_DEFAULT - 1, // sell < cross + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + CROSS_PRICE_DEFAULT, + true + ); + } + + function test_AddBaseAsset_RevertWhen_BuyPriceBelowMinimum() public { + // _validatePrices: buyPrice < MAX_CROSS_PRICE_DEVIATION (= 20e32) reverts. + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidBuyPrice.selector); + lidoARM.addBaseAsset( + address(steth), + address(stETHAssetAdapter), + MAX_CROSS_PRICE_DEVIATION - 1, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + CROSS_PRICE_DEFAULT, + true + ); + } + + function test_AddBaseAsset_RevertWhen_BuyPriceAtOrAboveCross() public { + // _validatePrices: buyPrice >= crossPrice reverts. Use equality (the strict-inequality edge). + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidBuyPrice.selector); + lidoARM.addBaseAsset( + address(steth), + address(stETHAssetAdapter), + CROSS_PRICE_DEFAULT, + SELL_PRICE_DEFAULT, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + CROSS_PRICE_DEFAULT, + true + ); + } + + ////////////////////////////////////////////////////// + /// --- setPrices + ////////////////////////////////////////////////////// + + function test_SetPrices_Owner() public { + addBaseAsset(steth); + + uint256 newBuy = 0.993e36; + uint256 newSell = 1.002e36; + uint256 newBuyLiq = 1_000 ether; + uint256 newSellLiq = 2_000 ether; + + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.TraderateChanged(address(steth), newBuy, newSell, newBuyLiq, newSellLiq); + + vm.prank(governor); + lidoARM.setPrices(address(steth), newBuy, newSell, newBuyLiq, newSellLiq); + + assertEq(buyPrice(steth), newBuy, "buyPrice"); + assertEq(sellPrice(steth), newSell, "sellPrice"); + assertEq(buyLiquidityRemaining(steth), newBuyLiq, "buyLiquidityRemaining"); + assertEq(sellLiquidityRemaining(steth), newSellLiq, "sellLiquidityRemaining"); + } + + function test_SetPrices_Operator() public { + // Operator is the dual-authority role; assert it can update prices just like the owner. + addBaseAsset(steth); + + vm.prank(operator); + lidoARM.setPrices(address(steth), 0.994e36, 1.003e36, 1 ether, 2 ether); + + assertEq(buyPrice(steth), 0.994e36, "buyPrice after operator update"); + } + + function test_SetPrices_RevertWhen_NotAuthorized() public { + addBaseAsset(steth); + + vm.prank(alice); + vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + lidoARM.setPrices(address(steth), BUY_PRICE_DEFAULT, SELL_PRICE_DEFAULT, 1 ether, 1 ether); + } + + function test_SetPrices_RevertWhen_UnsupportedAsset() public { + // No addBaseAsset → adapter is the zero address. + vm.prank(governor); + vm.expectRevert(AbstractARM.UnsupportedAsset.selector); + lidoARM.setPrices(address(steth), BUY_PRICE_DEFAULT, SELL_PRICE_DEFAULT, 1 ether, 1 ether); + } + + function test_SetPrices_RevertWhen_SellBelowCross() public { + addBaseAsset(steth); + vm.prank(governor); + vm.expectRevert(AbstractARM.SellPriceTooLow.selector); + lidoARM.setPrices(address(steth), BUY_PRICE_DEFAULT, CROSS_PRICE_DEFAULT - 1, 1 ether, 1 ether); + } + + function test_SetPrices_RevertWhen_BuyBelowMinimum() public { + addBaseAsset(steth); + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidBuyPrice.selector); + lidoARM.setPrices(address(steth), MAX_CROSS_PRICE_DEVIATION - 1, SELL_PRICE_DEFAULT, 1 ether, 1 ether); + } + + function test_SetPrices_RevertWhen_BuyAtOrAboveCross() public { + addBaseAsset(steth); + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidBuyPrice.selector); + lidoARM.setPrices(address(steth), CROSS_PRICE_DEFAULT, SELL_PRICE_DEFAULT, 1 ether, 1 ether); + } + + ////////////////////////////////////////////////////// + /// --- setCrossPrice + ////////////////////////////////////////////////////// + + function test_SetCrossPrice_Lower_WithoutExposure() public { + addBaseAsset(steth); + // No stETH balance, no pendingRedeem → the lowering-only exposure check is skipped. + + uint256 newCross = 0.999e36; + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.CrossPriceUpdated(address(steth), newCross); + + vm.prank(governor); + lidoARM.setCrossPrice(address(steth), newCross); + + assertEq(crossPrice(steth), newCross, "crossPrice lowered"); + } + + function test_SetCrossPrice_Raise() public { + addBaseAsset(steth); + + // Lower first so we can raise back up. (At default, cross == PRICE_SCALE which is the ceiling.) + vm.prank(governor); + lidoARM.setCrossPrice(address(steth), 0.999e36); + + vm.prank(governor); + lidoARM.setCrossPrice(address(steth), PRICE_SCALE); + + assertEq(crossPrice(steth), PRICE_SCALE, "crossPrice raised back to PRICE_SCALE"); + } + + function test_SetCrossPrice_RevertWhen_NotOwner() public { + addBaseAsset(steth); + vm.prank(alice); + vm.expectRevert(Ownable.OnlyOwner.selector); + lidoARM.setCrossPrice(address(steth), 0.999e36); + } + + function test_SetCrossPrice_RevertWhen_UnsupportedAsset() public { + vm.prank(governor); + vm.expectRevert(AbstractARM.UnsupportedAsset.selector); + lidoARM.setCrossPrice(address(steth), CROSS_PRICE_DEFAULT); + } + + function test_SetCrossPrice_RevertWhen_TooLow() public { + addBaseAsset(steth); + vm.prank(governor); + vm.expectRevert(AbstractARM.CrossPriceTooLow.selector); + lidoARM.setCrossPrice(address(steth), PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION - 1); + } + + function test_SetCrossPrice_RevertWhen_TooHigh() public { + addBaseAsset(steth); + vm.prank(governor); + vm.expectRevert(AbstractARM.CrossPriceTooHigh.selector); + lidoARM.setCrossPrice(address(steth), PRICE_SCALE + 1); + } + + function test_SetCrossPrice_RevertWhen_SellBelowNewCross() public { + addBaseAsset(steth); + + // Step 1: drop cross down to the floor so we can drop sell below 1e36. + vm.prank(governor); + lidoARM.setCrossPrice(address(steth), PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION); + // Step 2: bring sell to a value below PRICE_SCALE (within the new cross floor). + vm.prank(governor); + lidoARM.setPrices(address(steth), BUY_PRICE_DEFAULT, PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION, 1 ether, 1 ether); + // Step 3: raising cross above the new sell triggers the guard. + vm.prank(governor); + vm.expectRevert(AbstractARM.SellPriceTooLow.selector); + lidoARM.setCrossPrice(address(steth), 0.999e36); + } + + function test_SetCrossPrice_RevertWhen_BuyAtOrAboveNewCross() public { + addBaseAsset(steth); + + // Raise buy close to cross so a tiny lowering of cross collides with it. + vm.prank(governor); + lidoARM.setPrices(address(steth), 0.999e36, SELL_PRICE_DEFAULT, 1 ether, 1 ether); + + // newCross == buyPrice triggers buyPrice >= newCrossPrice. + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidBuyPrice.selector); + lidoARM.setCrossPrice(address(steth), 0.999e36); + } + + function test_SetCrossPrice_RevertWhen_TooManyBaseAssets() public { + addBaseAsset(steth); + + // Park enough stETH on the ARM that, valued at the cross price, exposure >= MIN_TOTAL_SUPPLY. + // MIN_TOTAL_SUPPLY == 1e12; stETH is valued 1:1 at cross == 1e36, so any balance >= 1e12 wei + // hits the guard. Deal a generous amount so the inequality is unambiguous. + deal(address(steth), address(lidoARM), 1 ether); + + vm.prank(governor); + vm.expectRevert(AbstractARM.TooManyBaseAssets.selector); + lidoARM.setCrossPrice(address(steth), 0.999e36); + } + + ////////////////////////////////////////////////////// + /// --- setFee + ////////////////////////////////////////////////////// + + function test_SetFee_Default() public { + uint256 newFee = 1_500; // 15% + + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.FeeUpdated(newFee); + + vm.prank(governor); + lidoARM.setFee(newFee); + + assertEq(lidoARM.fee(), newFee, "fee updated"); + } + + function test_SetFee_FlushesAccruedFees() public { + // _setFee calls collectFees() internally — accrued fees must flow to the collector before + // the rate changes. Trigger the accrual through a real swap (no storage poking). + uint256 accrued = _accrueFeesViaSwap(); + uint256 collectorBefore = weth.balanceOf(feeCollector); + + vm.prank(governor); + lidoARM.setFee(500); + + assertEq(lidoARM.feesAccrued(), 0, "feesAccrued zeroed"); + assertEq(weth.balanceOf(feeCollector) - collectorBefore, accrued, "collector received accrued"); + assertEq(lidoARM.fee(), 500, "fee updated after flush"); + } + + function test_SetFee_RevertWhen_NotOwner() public { + vm.prank(alice); + vm.expectRevert(Ownable.OnlyOwner.selector); + lidoARM.setFee(100); + } + + function test_SetFee_RevertWhen_FeeTooHigh() public { + // Maximum allowed fee is 50% (FEE_SCALE / 2 == 5_000). + vm.prank(governor); + vm.expectRevert(AbstractARM.FeeTooHigh.selector); + lidoARM.setFee(FEE_SCALE / 2 + 1); + } + + ////////////////////////////////////////////////////// + /// --- setFeeCollector + ////////////////////////////////////////////////////// + + function test_SetFeeCollector_Default() public { + address newCollector = makeAddr("newCollector"); + + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.FeeCollectorUpdated(newCollector); + + vm.prank(governor); + lidoARM.setFeeCollector(newCollector); + + assertEq(lidoARM.feeCollector(), newCollector, "feeCollector updated"); + } + + function test_SetFeeCollector_RevertWhen_NotOwner() public { + vm.prank(alice); + vm.expectRevert(Ownable.OnlyOwner.selector); + lidoARM.setFeeCollector(makeAddr("rejected")); + } + + function test_SetFeeCollector_RevertWhen_ZeroAddress() public { + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidFeeCollector.selector); + lidoARM.setFeeCollector(address(0)); + } + + ////////////////////////////////////////////////////// + /// --- collectFees + ////////////////////////////////////////////////////// + + function test_CollectFees_ZeroAccrued_ReturnsZeroNoTransfer() public { + assertEq(lidoARM.feesAccrued(), 0, "feesAccrued starts at 0"); + uint256 collectorBefore = weth.balanceOf(feeCollector); + + uint256 collected = lidoARM.collectFees(); + + assertEq(collected, 0, "returns 0 when nothing accrued"); + assertEq(weth.balanceOf(feeCollector), collectorBefore, "no transfer when 0 accrued"); + } + + function test_CollectFees_NonZero_TransfersToCollector() public { + uint256 accrued = _accrueFeesViaSwap(); + uint256 collectorBefore = weth.balanceOf(feeCollector); + + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.FeeCollected(feeCollector, accrued); + + uint256 collected = lidoARM.collectFees(); + + assertEq(collected, accrued, "returns accrued amount"); + assertEq(lidoARM.feesAccrued(), 0, "feesAccrued zeroed"); + assertEq(weth.balanceOf(feeCollector) - collectorBefore, accrued, "collector received"); + } + + function test_CollectFees_RevertWhen_InsufficientLiquidity() public { + // Natural setup for the guard: accrue some fees via a real swap, then reserve most of + // the ARM's WETH for an LP withdrawal so `reservedWithdrawLiquidity + fees` exceeds the + // on-hand WETH balance. + _accrueFeesViaSwap(); + + // After the swap the ARM holds ~90 ether of WETH; reserving 95 ether of shares pushes + // reservedWithdrawLiquidity past the balance even before the (small) fee is added. + vm.prank(alice); + lidoARM.requestRedeem(95 ether); + + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.collectFees(); + } + + ////////////////////////////////////////////////////// + /// --- setCapManager + ////////////////////////////////////////////////////// + + function test_SetCapManager_ToNonZero() public { + address newCapManager = makeAddr("newCapManager"); + + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.CapManagerUpdated(newCapManager); + + vm.prank(governor); + lidoARM.setCapManager(newCapManager); + + assertEq(lidoARM.capManager(), newCapManager, "capManager updated"); + } + + function test_SetCapManager_ToZero_DisablesCaps() public { + // Documented behavior: passing the zero address disables caps without further checks. + vm.prank(governor); + lidoARM.setCapManager(address(0)); + assertEq(lidoARM.capManager(), address(0), "capManager cleared"); + } + + function test_SetCapManager_RevertWhen_NotOwner() public { + vm.prank(alice); + vm.expectRevert(Ownable.OnlyOwner.selector); + lidoARM.setCapManager(makeAddr("rejected")); + } + + ////////////////////////////////////////////////////// + /// --- setARMBuffer + ////////////////////////////////////////////////////// + + function test_SetARMBuffer_Owner() public { + uint256 newBuffer = 0.25e18; // 25% + + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.ARMBufferUpdated(newBuffer); + + vm.prank(governor); + lidoARM.setARMBuffer(newBuffer); + + assertEq(lidoARM.armBuffer(), newBuffer, "armBuffer updated by owner"); + } + + function test_SetARMBuffer_Operator() public { + vm.prank(operator); + lidoARM.setARMBuffer(0.5e18); + assertEq(lidoARM.armBuffer(), 0.5e18, "armBuffer updated by operator"); + } + + function test_SetARMBuffer_BoundaryMax() public { + // 100% is the inclusive upper bound (1e18). + vm.prank(governor); + lidoARM.setARMBuffer(1e18); + assertEq(lidoARM.armBuffer(), 1e18, "armBuffer at boundary 1e18"); + } + + function test_SetARMBuffer_RevertWhen_NotAuthorized() public { + vm.prank(alice); + vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + lidoARM.setARMBuffer(0.1e18); + } + + function test_SetARMBuffer_RevertWhen_AboveMax() public { + // 100% + 1 wei reverts; the guard is strictly `> 1e18`. + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidARMBuffer.selector); + lidoARM.setARMBuffer(1e18 + 1); + } +} diff --git a/test/unit/LidoARM/concrete/Allocate.t.sol b/test/unit/LidoARM/concrete/Allocate.t.sol new file mode 100644 index 00000000..637f7dec --- /dev/null +++ b/test/unit/LidoARM/concrete/Allocate.t.sol @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +/// @notice Coverage for `AbstractARM.allocate()` / `_allocate()`. Exercises both the deposit +/// (positive delta) and withdraw (negative delta) branches, including the +/// maxRedeem-fallback path when the market cannot meet the desired withdraw amount. +contract Unit_LidoARM_Allocate_Test is Unit_LidoARM_Shared_Test { + function setUp() public override { + super.setUp(); + desactiveCapManager(); + } + + function test_Allocate_RevertWhen_NoActiveMarket() public { + vm.prank(alice); + vm.expectRevert("ARM: no active market"); + lidoARM.allocate(); + } + + function test_Allocate_NoOp_WhenAvailableAssetsZero() public { + // Drain the ARM so `_availableAssets()` returns zero before activating a market. + deal(address(weth), address(lidoARM), 0); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 0, "market shares pre"); + + vm.prank(alice); + lidoARM.allocate(); + + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 0, "market shares post"); + } + + function test_Allocate_PositiveDelta_NoOutstandingWithdraw() public { + // Stage everything in the ARM under a 100% buffer, then drop the buffer and allocate. + aliceFirstDeposit(); + addMarket(address(mockERC4626Market)); + setARMBuffer(1e18); + setActiveMarket(address(mockERC4626Market)); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 0, "market pre"); + + setARMBuffer(0); + vm.prank(alice); + lidoARM.allocate(); + + // Everything (100 ether + the init 1e12 dead-share WETH) lands in the market. + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 100 ether + 1e12, "market post"); + assertEq(weth.balanceOf(address(lidoARM)), 0, "ARM WETH post"); + assertEq(lidoARM.totalAssets(), 100 ether + 1e12, "totalAssets preserved"); + } + + function test_Allocate_PositiveDelta_WithOutstandingWithdraw() public { + // Deposit + outstanding redeem request. The reserved liquidity must stay in the ARM. + aliceFirstDeposit(); + aliceRequest(50 ether); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + + vm.prank(alice); + lidoARM.allocate(); + + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 50 ether + 1e12, "market post"); + assertEq(weth.balanceOf(address(lidoARM)), 50 ether, "reserved liquidity stays in ARM"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 50 ether, "reservedWithdrawLiquidity"); + } + + function test_Allocate_NegativeDelta_PartialWithdraw_EnoughLiquidityOnMarket() public { + // Push everything into the market, then raise the buffer to 30% and allocate. + aliceFirstDeposit(); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + // buffer is 0 by default, so the setActiveMarket call already moved everything into the market. + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 100 ether + 1e12, "market pre"); + assertEq(weth.balanceOf(address(lidoARM)), 0, "ARM WETH pre"); + + setARMBuffer(0.3 ether); + vm.prank(alice); + lidoARM.allocate(); + + uint256 expectedArm = (100 ether + 1e12) * 30 / 100; + uint256 expectedMarket = (100 ether + 1e12) - expectedArm; + assertEq(weth.balanceOf(address(lidoARM)), expectedArm, "ARM WETH post"); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), expectedMarket, "market post"); + } + + function test_Allocate_NegativeDelta_FullWithdraw_EnoughLiquidityOnMarket() public { + aliceFirstDeposit(); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 100 ether + 1e12, "market pre"); + + setARMBuffer(1e18); // 100% in ARM + vm.prank(alice); + lidoARM.allocate(); + + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 0, "market post"); + assertEq(weth.balanceOf(address(lidoARM)), 100 ether + 1e12, "ARM WETH post"); + } + + function test_Allocate_NegativeDelta_FullWithdraw_NotEnoughLiquidityOnMarket_AboveThreshold() public { + // Setup: everything goes into the market, then we simulate a 50% market loss and inflate + // `_availableAssets` with stETH so `desiredWithdrawAmount > maxWithdraw`, forcing the + // maxRedeem fallback in `_allocate`. + aliceFirstDeposit(); + addBaseAsset(steth); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 100 ether + 1e12, "market pre"); + + // Market loses 50% of its WETH. Shares stay; their convertToAssets value halves. + uint256 halved = (100 ether + 1e12) / 2; + deal(address(weth), address(mockERC4626Market), halved); + + // Phantom stETH in the ARM raises totalAssets above what the market can pay out. + deal(address(steth), address(lidoARM), 100 ether); + + setARMBuffer(1e18); // target = full availableAssets + vm.prank(alice); + lidoARM.allocate(); + + // The fallback redeems all market shares for whatever the market can give (the halved WETH). + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 0, "market shares post"); + assertEq(weth.balanceOf(address(lidoARM)), halved, "ARM WETH post"); + assertEq(steth.balanceOf(address(lidoARM)), 100 ether, "ARM stETH unchanged"); + } + + function test_Allocate_NegativeDelta_FullWithdraw_NotEnoughLiquidityOnMarket_BelowThreshold() public { + // Hits the `shares <= minSharesToRedeem` early-return in _allocate (AbstractARM line 1124). + // We seed the ARM with exactly `minSharesToRedeem` market shares (so the maxRedeem + // branch is reached but skipped) and inflate _availableAssets with phantom stETH so + // the withdraw path is triggered in the first place. + addBaseAsset(steth); + addMarket(address(mockERC4626Market)); + + // Mint exactly MIN_SHARES_TO_REDEEM market shares to the ARM by having a dummy + // depositor seed the market on the ARM's behalf. The market is empty, so deposit is 1:1. + address seeder = makeAddr("marketSeeder"); + deal(address(weth), seeder, MIN_SHARES_TO_REDEEM); + vm.startPrank(seeder); + weth.approve(address(mockERC4626Market), MIN_SHARES_TO_REDEEM); + mockERC4626Market.deposit(MIN_SHARES_TO_REDEEM, address(lidoARM)); + vm.stopPrank(); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), MIN_SHARES_TO_REDEEM, "seeded shares"); + + setActiveMarket(address(mockERC4626Market)); + + // Phantom stETH pushes the target far above what the market can pay out. + deal(address(steth), address(lidoARM), 100 ether); + setARMBuffer(1e18); + + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + + vm.prank(alice); + lidoARM.allocate(); + + // Early return: nothing moves, the ARM's WETH and market shares stay put. + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), MIN_SHARES_TO_REDEEM, "market shares unchanged"); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore, "ARM WETH unchanged"); + assertEq(steth.balanceOf(address(lidoARM)), 100 ether, "ARM stETH unchanged"); + } + + function test_Allocate_NullDelta() public { + // Pre-set a 20% buffer so the initial allocation matches the steady state and the + // subsequent allocate() call has nothing to move. + aliceFirstDeposit(); + setARMBuffer(0.2 ether); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + + uint256 expectedArm = (100 ether + 1e12) * 20 / 100; + uint256 expectedMarket = (100 ether + 1e12) - expectedArm; + assertEq(weth.balanceOf(address(lidoARM)), expectedArm, "ARM WETH pre"); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), expectedMarket, "market pre"); + + vm.prank(alice); + lidoARM.allocate(); + + assertEq(weth.balanceOf(address(lidoARM)), expectedArm, "ARM WETH post"); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), expectedMarket, "market post"); + } +} diff --git a/test/unit/LidoARM/concrete/BaseAssetRedeem.t.sol b/test/unit/LidoARM/concrete/BaseAssetRedeem.t.sol new file mode 100644 index 00000000..2c7254ae --- /dev/null +++ b/test/unit/LidoARM/concrete/BaseAssetRedeem.t.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; +import {OwnableOperable} from "contracts/OwnableOperable.sol"; + +/// @notice Tests the ARM-side flow of `requestBaseAssetRedeem` and `claimBaseAssetRedeem` for +/// both stETH (1:1 adapter) and wstETH (ERC4626-style adapter with a non-1:1 unwrap). +/// The adapter's internal queue logic is covered separately; here we only assert the +/// ARM's accounting (`pendingRedeemAssets`, `totalAssets`), access control, and that +/// funds move between ARM, adapter, and the Lido withdrawal queue as expected. +contract Unit_LidoARM_BaseAssetRedeem_Test is Unit_LidoARM_Shared_Test { + uint256 internal constant ARM_STETH_BALANCE = 100 ether; + uint256 internal constant ARM_WSTETH_BALANCE = 100 ether; + + function setUp() public override { + super.setUp(); + desactiveCapManager(); + + // stETH base asset (1:1 adapter). + addBaseAsset(steth); + deal(address(steth), address(lidoARM), ARM_STETH_BALANCE); + + // wstETH base asset. Apply the 1 wstETH = 1.237 stETH rate so the non-1:1 unwrap is exercised, + // then seed the ARM via the ERC4626 mint path — `deal` on wstETH would desync vault accounting. + addBaseAsset(wsteth); + seedWstETHWithTargetExchangeRate(); + dealWsteth(address(lidoARM), ARM_WSTETH_BALANCE); + } + + ////////////////////////////////////////////////////// + /// --- requestBaseAssetRedeem + ////////////////////////////////////////////////////// + function test_RequestBaseAssetRedeem_Default() public { + uint256 shares = 50 ether; + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Pre-conditions + assertEq(steth.balanceOf(address(lidoARM)), ARM_STETH_BALANCE, "ARM stETH pre"); + assertEq(steth.balanceOf(address(stETHAssetAdapter)), 0, "adapter stETH pre"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), 0, "queue stETH pre"); + assertEq(pendingRedeemAssets(steth), 0, "pendingRedeemAssets pre"); + assertEq(lidoWithdrawalQueue.counter(), 0, "queue counter pre"); + + // When + vm.prank(operator); + (uint256 sharesRequested, uint256 assetsExpected) = lidoARM.requestBaseAssetRedeem(address(steth), shares); + + // Then — return values (stETH adapter is 1:1) + assertEq(sharesRequested, shares, "sharesRequested"); + assertEq(assetsExpected, shares, "assetsExpected"); + + // Flow of funds: stETH leaves the ARM and lands in the withdrawal queue, not the adapter. + assertEq(steth.balanceOf(address(lidoARM)), ARM_STETH_BALANCE - shares, "ARM stETH post"); + assertEq(steth.balanceOf(address(stETHAssetAdapter)), 0, "adapter stETH post"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), shares, "queue stETH post"); + + // ARM accounting: the in-flight redeem replaces the on-hand stETH 1:1 in totalAssets(). + assertEq(pendingRedeemAssets(steth), shares, "pendingRedeemAssets post"); + assertEq(lidoARM.totalAssets(), totalAssetsBefore, "totalAssets preserved"); + + // The mock withdrawal queue recorded the request against the adapter. + assertEq(lidoWithdrawalQueue.counter(), 1, "queue counter post"); + (address requestOwner, uint256 requestAmount, bool claimed, bool finalized) = lidoWithdrawalQueue.requests(0); + assertEq(requestOwner, address(stETHAssetAdapter), "request.owner"); + assertEq(requestAmount, shares, "request.amount"); + assertEq(claimed, false, "request.claimed"); + assertEq(finalized, true, "request.finalized"); + } + + function test_RequestBaseAssetRedeem_Wsteth() public { + uint256 shares = 50 ether; + // wstETH is ERC4626-style: shares (wstETH) and assets (stETH-equivalent) diverge by the exchange rate. + uint256 expectedStETH = mockWstETH.getStETHByWstETH(shares); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Pre-conditions + assertEq(wsteth.balanceOf(address(lidoARM)), ARM_WSTETH_BALANCE, "ARM wstETH pre"); + assertEq(wsteth.balanceOf(address(wstETHAssetAdapter)), 0, "adapter wstETH pre"); + assertEq(steth.balanceOf(address(wstETHAssetAdapter)), 0, "adapter stETH pre"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), 0, "queue stETH pre"); + assertEq(pendingRedeemAssets(wsteth), 0, "pendingRedeemAssets pre"); + assertEq(lidoWithdrawalQueue.counter(), 0, "queue counter pre"); + + // When + vm.prank(operator); + (uint256 sharesRequested, uint256 assetsExpected) = lidoARM.requestBaseAssetRedeem(address(wsteth), shares); + + // Then — return values: shares are wstETH (1:1 with the request), assets expand by exchange rate. + assertEq(sharesRequested, shares, "sharesRequested"); + assertEq(assetsExpected, expectedStETH, "assetsExpected"); + + // Flow of funds: wstETH leaves the ARM, gets unwrapped to stETH, and stETH lands in the queue. + assertEq(wsteth.balanceOf(address(lidoARM)), ARM_WSTETH_BALANCE - shares, "ARM wstETH post"); + assertEq(wsteth.balanceOf(address(wstETHAssetAdapter)), 0, "adapter wstETH post"); + assertEq(steth.balanceOf(address(wstETHAssetAdapter)), 0, "adapter stETH post"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), expectedStETH, "queue stETH post"); + + // ARM accounting: pending is tracked in liquidity (stETH) terms; totalAssets preserved because + // the wstETH lost from the ARM balance is matched by the same stETH-equivalent in pending. + assertEq(pendingRedeemAssets(wsteth), expectedStETH, "pendingRedeemAssets post"); + assertEq(lidoARM.totalAssets(), totalAssetsBefore, "totalAssets preserved"); + + // The mock withdrawal queue recorded the request against the adapter, in stETH units. + assertEq(lidoWithdrawalQueue.counter(), 1, "queue counter post"); + (address requestOwner, uint256 requestAmount, bool claimed, bool finalized) = lidoWithdrawalQueue.requests(0); + assertEq(requestOwner, address(wstETHAssetAdapter), "request.owner"); + assertEq(requestAmount, expectedStETH, "request.amount"); + assertEq(claimed, false, "request.claimed"); + assertEq(finalized, true, "request.finalized"); + } + + function test_RequestBaseAssetRedeem_RevertWhen_UnsupportedAsset() public { + // weth is the liquidity asset; it has no adapter registered. + vm.prank(operator); + vm.expectRevert(AbstractARM.UnsupportedAsset.selector); + lidoARM.requestBaseAssetRedeem(address(weth), 1 ether); + } + + function test_RequestBaseAssetRedeem_RevertWhen_NotAuthorized() public { + vm.prank(alice); + vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + lidoARM.requestBaseAssetRedeem(address(steth), 1 ether); + } + + ////////////////////////////////////////////////////// + /// --- claimBaseAssetRedeem + ////////////////////////////////////////////////////// + function test_ClaimBaseAssetRedeem_Default() public { + uint256 shares = 50 ether; + + // Given: a redeem has already been requested. + vm.prank(operator); + lidoARM.requestBaseAssetRedeem(address(steth), shares); + + uint256 totalAssetsBefore = lidoARM.totalAssets(); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + + assertEq(pendingRedeemAssets(steth), shares, "pendingRedeemAssets pre claim"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), shares, "queue stETH pre claim"); + + // When + vm.prank(operator); + (uint256 sharesClaimed, uint256 assetsExpected, uint256 assetsReceived) = + lidoARM.claimBaseAssetRedeem(address(steth), shares); + + // Then — return values + assertEq(sharesClaimed, shares, "sharesClaimed"); + assertEq(assetsExpected, shares, "assetsExpected"); + assertEq(assetsReceived, shares, "assetsReceived"); + + // Flow of funds: WETH ends up in the ARM, adapter holds no residual ETH or WETH. + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + shares, "ARM weth post"); + assertEq(weth.balanceOf(address(stETHAssetAdapter)), 0, "adapter weth post"); + assertEq(address(stETHAssetAdapter).balance, 0, "adapter eth post"); + + // ARM accounting: pending cleared, totalAssets unchanged across the full cycle. + assertEq(pendingRedeemAssets(steth), 0, "pendingRedeemAssets post"); + assertEq(lidoARM.totalAssets(), totalAssetsBefore, "totalAssets preserved"); + + // The mock marked the request as claimed. + (,, bool claimed,) = lidoWithdrawalQueue.requests(0); + assertEq(claimed, true, "request.claimed"); + } + + function test_ClaimBaseAssetRedeem_Wsteth() public { + uint256 shares = 50 ether; + uint256 expectedStETH = mockWstETH.getStETHByWstETH(shares); + + // Given: a redeem has already been requested. + vm.prank(operator); + lidoARM.requestBaseAssetRedeem(address(wsteth), shares); + + uint256 totalAssetsBefore = lidoARM.totalAssets(); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + + assertEq(pendingRedeemAssets(wsteth), expectedStETH, "pendingRedeemAssets pre claim"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), expectedStETH, "queue stETH pre claim"); + + // When + vm.prank(operator); + (uint256 sharesClaimed, uint256 assetsExpected, uint256 assetsReceived) = + lidoARM.claimBaseAssetRedeem(address(wsteth), shares); + + // Then — return values + assertEq(sharesClaimed, shares, "sharesClaimed"); + assertEq(assetsExpected, expectedStETH, "assetsExpected"); + assertEq(assetsReceived, expectedStETH, "assetsReceived"); + + // Flow of funds: WETH ends up in the ARM, adapter holds no residual ETH or WETH. + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + expectedStETH, "ARM weth post"); + assertEq(weth.balanceOf(address(wstETHAssetAdapter)), 0, "adapter weth post"); + assertEq(address(wstETHAssetAdapter).balance, 0, "adapter eth post"); + + // ARM accounting: pending cleared, totalAssets unchanged across the full cycle. + assertEq(pendingRedeemAssets(wsteth), 0, "pendingRedeemAssets post"); + assertEq(lidoARM.totalAssets(), totalAssetsBefore, "totalAssets preserved"); + + // The mock marked the request as claimed. + (,, bool claimed,) = lidoWithdrawalQueue.requests(0); + assertEq(claimed, true, "request.claimed"); + } + + function test_ClaimBaseAssetRedeem_RevertWhen_UnsupportedAsset() public { + vm.prank(operator); + vm.expectRevert(AbstractARM.UnsupportedAsset.selector); + lidoARM.claimBaseAssetRedeem(address(weth), 1 ether); + } + + function test_ClaimBaseAssetRedeem_RevertWhen_NotAuthorized() public { + vm.prank(alice); + vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + lidoARM.claimBaseAssetRedeem(address(steth), 1 ether); + } +} diff --git a/test/unit/LidoARM/concrete/ClaimRedeem.t.sol b/test/unit/LidoARM/concrete/ClaimRedeem.t.sol new file mode 100644 index 00000000..b00f99b2 --- /dev/null +++ b/test/unit/LidoARM/concrete/ClaimRedeem.t.sol @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Libraries +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +contract Unit_LidoARM_ClaimRedeem_Test is Unit_LidoARM_Shared_Test { + using Math for uint256; + + function setUp() public override { + super.setUp(); + desactiveCapManager(); + aliceFirstDeposit(); + } + + ////////////////////////////////////////////////////// + /// --- Happy paths --- + ////////////////////////////////////////////////////// + function test_ClaimRedeem_Default() public { + aliceRequest(0); + skip(CLAIM_DELAY); + + // Given + uint256 expectedAssets = 100 ether; + uint256 expectedShares = 100 ether; + assertEq(lidoARM.balanceOf(address(lidoARM)), expectedShares); + assertEq(lidoARM.totalAssets(), expectedAssets + 1e12); + assertEq(lidoARM.totalSupply(), expectedShares + 1e12); + assertEq(lidoARM.reservedWithdrawLiquidity(), expectedAssets); + assertEq(lidoARM.withdrawsClaimedShares(), 0); + + // Expect + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(lidoARM), address(0), expectedShares); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAssets); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemClaimed(alice, 0, expectedAssets); + + // When + vm.prank(alice); + lidoARM.claimRedeem(0); + + // Then + assertEq(weth.balanceOf(alice), expectedAssets, "alice weth"); + assertEq(lidoARM.balanceOf(alice), 0, "alice shares"); + assertEq(lidoARM.balanceOf(address(lidoARM)), 0, "escrow"); + assertEq(lidoARM.totalAssets(), 1e12, "totalAssets"); + assertEq(lidoARM.totalSupply(), 1e12, "totalSupply"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved"); + assertEq(lidoARM.withdrawsClaimedShares(), expectedShares, "claimed"); + } + + function test_ClaimRedeem_LossAfterRequest() public { + aliceRequest(0); + skip(CLAIM_DELAY); + + // Simulate a loss on ARM + uint256 lossAmount = 20 ether + 2e11; // To ensure we loss 20% of the assets. + vm.prank(address(lidoARM)); + weth.transfer(address(0), lossAmount); // Burn lossAmount WETH from the ARM balance + + // Given + uint256 expectedTotalAssets = 100 ether + 1e12 - lossAmount; + uint256 expectedAliceAssets = 80 ether; // Alice should get 80% of her assets back, which is 80 WETH + uint256 expectedShares = 100 ether; // Shares are not affected by the loss + assertEq(lidoARM.balanceOf(address(lidoARM)), expectedShares); + assertEq(lidoARM.totalAssets(), expectedTotalAssets); + assertEq(lidoARM.totalSupply(), expectedShares + 1e12); + assertEq(lidoARM.reservedWithdrawLiquidity(), 100 ether); + assertEq(lidoARM.withdrawsClaimedShares(), 0); + + // Expect + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(lidoARM), address(0), expectedShares); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAliceAssets); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemClaimed(alice, 0, expectedAliceAssets); + + // When + vm.prank(alice); + lidoARM.claimRedeem(0); + + // Then + assertEq(weth.balanceOf(alice), expectedAliceAssets, "alice weth"); + assertEq(lidoARM.balanceOf(alice), 0, "alice shares"); + assertEq(lidoARM.balanceOf(address(lidoARM)), 0, "escrow"); + // We have the minimum returned by totalAssets, but in theory it should be 0.8e12. + assertEq(lidoARM.totalAssets(), 1e12, "totalAssets"); + assertEq(lidoARM.totalSupply(), 1e12, "totalSupply"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved"); + assertEq(lidoARM.withdrawsClaimedShares(), expectedShares, "claimed"); + } + + function test_ClaimRedeem_LossAfterBothQueued() public { + // Given: Alice and Bobby both deposited 100 WETH, then both requested redeem of 100 shares. + bobbyFirstDeposit(); + aliceRequest(0); + bobbyRequest(0); + skip(CLAIM_DELAY); + + // Simulate a loss on ARM + uint256 lossAmount = 40 ether + 2e11; // To ensure we loss 20% of the assets. + vm.prank(address(lidoARM)); + weth.transfer(address(0), lossAmount); // Burn lossAmount WETH from the ARM balance + + uint256 expectedTotalAssets = 200 ether + 1e12 - lossAmount; + uint256 expectedAliceAssets = 80 ether; // Alice should get 80% of her assets back, which is 80 WETH + uint256 expectedBobbyAssets = 80 ether; // Bobby should also get 80% of his assets back, which is 80 WETH + uint256 expectedShares = 100 ether; // Shares are not affected by the loss + assertEq(lidoARM.balanceOf(address(lidoARM)), expectedShares * 2); + assertEq(lidoARM.totalAssets(), expectedTotalAssets); + assertEq(lidoARM.totalSupply(), expectedShares * 2 + 1e12); + assertEq(lidoARM.reservedWithdrawLiquidity(), 200 ether); + assertEq(lidoARM.withdrawsClaimedShares(), 0); + + // When Alice claims + vm.prank(alice); + lidoARM.claimRedeem(0); + + // When Bobby claims + vm.prank(bobby); + lidoARM.claimRedeem(1); + + // Then + assertEq(weth.balanceOf(alice), expectedAliceAssets, "alice weth"); + assertEq(weth.balanceOf(bobby), expectedBobbyAssets, "bobby weth"); + assertEq(lidoARM.balanceOf(alice), 0, "alice shares"); + assertEq(lidoARM.balanceOf(bobby), 0, "bobby shares"); + assertEq(lidoARM.balanceOf(address(lidoARM)), 0, "escrow"); + // We have the minimum returned by totalAssets, but in theory it should be 0.8e12. + assertEq(lidoARM.totalAssets(), 1e12, "totalAssets"); + assertEq(lidoARM.totalSupply(), 1e12, "totalSupply"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved"); + assertEq(lidoARM.withdrawsClaimedShares(), expectedShares * 2, "claimed"); + } + + function test_ClaimRedeem_LossBeforeBothRequests() public { + // ARM loss 20% + // Alice Request + Claim 50% of her shares + // Then Bobby Request + Claim 25% of his sharesafter Alice claimed + // They should have both the same loss of 20% even if Bobby claim after Alice and + // the ARM is already 20% less valuable when Bobby claim, because the loss is shared + // equally at the time of the claim based on the shares that are being redeemed. + + // Given: Alice already deposited 100 WETH in setUp. Bobby also deposits 100 WETH. + bobbyFirstDeposit(); + + // Simulate a 20% loss on the ARM: burn 20% of the total assets. + uint256 lossAmount = 40 ether + 2e11; // 20% of (200 ether + 1e12) + vm.prank(address(lidoARM)); + weth.transfer(address(0), lossAmount); + + // Sanity: share price is now exactly 0.8. + assertEq(lidoARM.totalAssets(), 160 ether + 8e11, "totalAssets after loss"); + assertEq(lidoARM.totalSupply(), 200 ether + 1e12, "totalSupply after loss"); + assertEq(lidoARM.convertToAssets(1 ether), 0.8 ether, "share price after loss"); + + // When: Alice requests redeem of 50% of her shares (50 shares). + uint256 aliceShares = 50 ether; + uint256 expectedAliceAssets = 40 ether; // 50 * 0.8 + (uint256 aliceRequestId, uint256 aliceAssetsAtRequest) = aliceRequest(aliceShares); + assertEq(aliceAssetsAtRequest, expectedAliceAssets, "alice assets at request"); + + skip(CLAIM_DELAY); + + // Alice claims. + vm.prank(alice); + lidoARM.claimRedeem(aliceRequestId); + + // Then: Alice received 80% of her 50-share value = 40 WETH (20% loss). + assertEq(weth.balanceOf(alice), expectedAliceAssets, "alice weth after claim"); + assertEq(lidoARM.balanceOf(alice), 50 ether, "alice remaining shares"); + + // After Alice's claim, totalAssets / totalSupply should still give 0.8 per share. + assertEq(lidoARM.totalAssets(), 120 ether + 8e11, "totalAssets after alice claim"); + assertEq(lidoARM.totalSupply(), 150 ether + 1e12, "totalSupply after alice claim"); + assertEq(lidoARM.convertToAssets(1 ether), 0.8 ether, "share price after alice claim"); + + // When: Bobby requests redeem of 25% of his shares (25 shares), AFTER Alice claimed. + uint256 bobbyShares = 25 ether; + uint256 expectedBobbyAssets = 20 ether; // 25 * 0.8 + (uint256 bobbyRequestId, uint256 bobbyAssetsAtRequest) = bobbyRequest(bobbyShares); + assertEq(bobbyAssetsAtRequest, expectedBobbyAssets, "bobby assets at request"); + + skip(CLAIM_DELAY); + + // Bobby claims. + vm.prank(bobby); + lidoARM.claimRedeem(bobbyRequestId); + + // Then: Bobby received 80% of his 25-share value = 20 WETH (20% loss), the SAME loss as Alice + // even though he redeemed a different amount and after Alice claimed. + assertEq(weth.balanceOf(bobby), expectedBobbyAssets, "bobby weth after claim"); + assertEq(lidoARM.balanceOf(bobby), 75 ether, "bobby remaining shares"); + + // Loss equality check: both lost exactly 20% on the shares they redeemed. + uint256 aliceLossBps = (aliceShares - expectedAliceAssets) * 10_000 / aliceShares; + uint256 bobbyLossBps = (bobbyShares - expectedBobbyAssets) * 10_000 / bobbyShares; + assertEq(aliceLossBps, 2_000, "alice loss = 20%"); + assertEq(bobbyLossBps, 2_000, "bobby loss = 20%"); + assertEq(aliceLossBps, bobbyLossBps, "alice and bobby share the loss equally"); + } + + function test_ClaimRedeem_RecoveryBetweenClaims() public { + // Alice Request 50% of her shares at the pre-loss price (1.0) + // Then ARM loss 20% (between Alice request and Alice claim) + // Alice Claim -> the min(request.assets, assetsAtClaim) clause caps her payout at the loss-adjusted value + // Then ARM recover some funds, so it is now only a 10% loss + // Then Bobby Request + Claim 25% of his shares after Alice claimed + // They shouldn't have the same loss. + + // Given: Alice already deposited 100 WETH in setUp. Bobby also deposits 100 WETH. + bobbyFirstDeposit(); + + // Alice requests 50% of her shares (50) BEFORE the loss, at share price 1.0. + uint256 aliceShares = 50 ether; + uint256 expectedAliceAssets = 40 ether; // 50 * 0.8, alice still ends up with a 20% loss via the claim-time min + (uint256 aliceRequestId, uint256 aliceAssetsAtRequest) = aliceRequest(aliceShares); + assertEq(aliceAssetsAtRequest, 50 ether, "alice request.assets locked at pre-loss price"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 50 ether, "reserved at pre-loss price"); + + // Now simulate a 20% loss on the ARM, AFTER Alice's request but BEFORE her claim. + uint256 lossAmount = 40 ether + 2e11; // 20% of (200 ether + 1e12) + vm.prank(address(lidoARM)); + weth.transfer(address(0), lossAmount); + + // Sanity: share price is now 0.8 (20% loss). totalSupply is unchanged: Alice's shares were + // escrowed (not burnt) by requestRedeem, so they still share the loss pro-rata. + assertEq(lidoARM.totalAssets(), 160 ether + 8e11, "totalAssets after loss"); + assertEq(lidoARM.totalSupply(), 200 ether + 1e12, "totalSupply after loss"); + assertEq(lidoARM.convertToAssets(1 ether), 0.8 ether, "share price after loss"); + + // Alice claims. The min() clause caps her payout: request.assets (50) > assetsAtClaim (40) + // so she only receives 40 WETH, locking in the same 20% loss as if she'd requested post-loss. + skip(CLAIM_DELAY); + vm.prank(alice); + lidoARM.claimRedeem(aliceRequestId); + assertEq(weth.balanceOf(alice), expectedAliceAssets, "alice weth after claim"); + + // After Alice's claim: 120 ether + 8e11 / 150 ether + 1e12 -> share price still 0.8. + assertEq(lidoARM.totalAssets(), 120 ether + 8e11, "totalAssets after alice claim"); + assertEq(lidoARM.totalSupply(), 150 ether + 1e12, "totalSupply after alice claim"); + assertEq(lidoARM.convertToAssets(1 ether), 0.8 ether, "share price after alice claim"); + + // ARM recovers some funds: top up WETH so the share price becomes 0.9 (only a 10% loss). + // Target: assets = 0.9 * (150 ether + 1e12) = 135 ether + 9e11. Delta = 15 ether + 1e11. + uint256 recoveryAmount = 15 ether + 1e11; + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) + recoveryAmount); + assertEq(lidoARM.totalAssets(), 135 ether + 9e11, "totalAssets after recovery"); + assertEq(lidoARM.convertToAssets(1 ether), 0.9 ether, "share price after recovery"); + + // Bobby requests 25% of his shares at the recovered price (0.9). + uint256 bobbyShares = 25 ether; + uint256 expectedBobbyAssets = 22.5 ether; // 25 * 0.9, only a 10% loss + (uint256 bobbyRequestId, uint256 bobbyAssetsAtRequest) = bobbyRequest(bobbyShares); + assertEq(bobbyAssetsAtRequest, expectedBobbyAssets, "bobby assets at request"); + + skip(CLAIM_DELAY); + vm.prank(bobby); + lidoARM.claimRedeem(bobbyRequestId); + assertEq(weth.balanceOf(bobby), expectedBobbyAssets, "bobby weth after claim"); + assertEq(lidoARM.balanceOf(bobby), 75 ether, "bobby remaining shares"); + + // Loss comparison: Alice locked in 20% but Bobby only suffers 10%. + // The loss is "locked in" at the time of the claim, so the early exiter (Alice) + // does not benefit from the later recovery. + uint256 aliceLossBps = (aliceShares - expectedAliceAssets) * 10_000 / aliceShares; + uint256 bobbyLossBps = (bobbyShares - expectedBobbyAssets) * 10_000 / bobbyShares; + assertEq(aliceLossBps, 2_000, "alice loss = 20%"); + assertEq(bobbyLossBps, 1_000, "bobby loss = 10%"); + assertTrue(aliceLossBps != bobbyLossBps, "alice and bobby do NOT share the loss equally"); + } + + function test_ClaimRedeem_GainAfterRequest() public { + // Alice requests at price 1.0. Yield arrives. Alice should still only get request.assets + // (the cap), and the forfeited upside should accrue to the remaining LPs (Bob + dust). + + bobbyFirstDeposit(); + + uint256 aliceShares = 50 ether; + (uint256 aliceId, uint256 aliceAssetsAtRequest) = aliceRequest(aliceShares); + assertEq(aliceAssetsAtRequest, 50 ether, "request.assets at price 1.0"); + + // Yield: 20 WETH donated to the ARM. Share price rises from 1.0 to 1.1. + uint256 yield = 20 ether; + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) + yield); + assertEq(lidoARM.totalAssets(), 220 ether + 1e12, "totalAssets with yield"); + assertEq(lidoARM.totalSupply(), 200 ether + 1e12, "totalSupply unchanged"); + + skip(CLAIM_DELAY); + + // Without the cap, Alice would receive convertToAssets(50) ~ 55 ether. + // The cap clamps her to request.assets = 50 ether exactly. + vm.prank(alice); + uint256 aliceClaimed = lidoARM.claimRedeem(aliceId); + assertEq(aliceClaimed, 50 ether, "alice receives request.assets (capped)"); + assertEq(weth.balanceOf(alice), 50 ether, "alice weth balance"); + + // After Alice's claim, the ~5 ether of upside she gave up stays in the pool. Bob's 100 + // shares should now be worth strictly more than 100 ether. + uint256 bobShareValue = lidoARM.previewRedeem(100 ether); + uint256 expectedBobShareValue = Math.mulDiv(100 ether, 170 ether + 1e12, 150 ether + 1e12); + assertEq(bobShareValue, expectedBobShareValue, "bob's share value reflects forfeited upside"); + assertGt(bobShareValue, 100 ether, "bob gained from alice's forfeited upside"); + } + + function test_ClaimRedeem_TwoLossesSeparatedByClaim() public { + // First loss happens. Alice claims, locking in the first loss only. + // Then a second loss happens. Bob (who waited) eats both losses, compounding. + + bobbyFirstDeposit(); + + // Loss #1: -20% on (200 ether + 1e12) + uint256 loss1 = 40 ether + 2e11; + vm.prank(address(lidoARM)); + weth.transfer(address(0xdead), loss1); + assertEq(lidoARM.convertToAssets(1 ether), 0.8 ether, "price after loss #1"); + + // Alice requests 50 of her 100 shares and claims at price 0.8. + uint256 aliceShares = 50 ether; + (uint256 aliceId,) = aliceRequest(aliceShares); + skip(CLAIM_DELAY); + vm.prank(alice); + uint256 aliceClaimed = lidoARM.claimRedeem(aliceId); + assertEq(aliceClaimed, 40 ether, "alice locks in 20% loss"); + + // Share price is still 0.8 after Alice's burn (loss already absorbed pro-rata). + assertEq(lidoARM.convertToAssets(1 ether), 0.8 ether, "price still 0.8 after alice claim"); + assertEq(lidoARM.totalAssets(), 120 ether + 8e11, "totalAssets after alice claim"); + assertEq(lidoARM.totalSupply(), 150 ether + 1e12, "totalSupply after alice claim"); + + // Loss #2: -20% of CURRENT totalAssets (120 ether + 8e11) -> burn 24 ether + 1.6e11 + uint256 loss2 = 24 ether + 16e10; + vm.prank(address(lidoARM)); + weth.transfer(address(0xdead), loss2); + assertEq(lidoARM.totalAssets(), 96 ether + 64e10, "totalAssets after loss #2"); + // New price: 0.8 * 0.8 = 0.64 + assertEq(lidoARM.convertToAssets(1 ether), 0.64 ether, "price after loss #2"); + + // Bob requests 25 shares at the new price and claims. + uint256 bobShares = 25 ether; + (uint256 bobId, uint256 bobAssetsAtRequest) = bobbyRequest(bobShares); + assertEq(bobAssetsAtRequest, 16 ether, "bob's request.assets at price 0.64"); + skip(CLAIM_DELAY); + vm.prank(bobby); + uint256 bobClaimed = lidoARM.claimRedeem(bobId); + assertEq(bobClaimed, 16 ether, "bob claims at price 0.64"); + + // Loss comparison: Alice 20%, Bob 36% (= 1 - 0.8*0.8). + uint256 aliceLossBps = (aliceShares - aliceClaimed).mulDiv(10_000, aliceShares); + uint256 bobLossBps = (bobShares - bobClaimed).mulDiv(10_000, bobShares); + assertEq(aliceLossBps, 2_000, "alice loss = 20%"); + assertEq(bobLossBps, 3_600, "bob loss = 36% (compound)"); + } + + function test_ClaimRedeem_WithActiveMarket() public { + // Configure the active market, push Alice's WETH to it, then claim. The claim must + // pull the missing liquidity back from the market in the same transaction. + + address[] memory markets = new address[](1); + markets[0] = address(mockERC4626Market); + vm.prank(governor); + lidoARM.addMarkets(markets); + vm.prank(governor); + lidoARM.setActiveMarket(address(mockERC4626Market)); + + // Move Alice's already-deposited 100 WETH to the market (armBuffer = 0). + lidoARM.allocate(); + assertEq(weth.balanceOf(address(lidoARM)), 0, "ARM drained to market"); + assertGt(mockERC4626Market.balanceOf(address(lidoARM)), 0, "ARM holds market shares"); + + // Alice requests her 100 shares and waits. + (uint256 aliceId,) = aliceRequest(0); + skip(CLAIM_DELAY); + + // Claim must withdraw 100 ether from the market and forward to Alice. + vm.prank(alice); + uint256 aliceClaimed = lidoARM.claimRedeem(aliceId); + + assertEq(aliceClaimed, 100 ether, "claim payout"); + assertEq(weth.balanceOf(alice), 100 ether, "alice received WETH from market path"); + assertEq(weth.balanceOf(address(lidoARM)), 0, "ARM left with no WETH"); + } + + function test_ClaimRedeem_ByOperator() public { + // Alice requests; operator (not Alice) calls claimRedeem. WETH still goes to Alice. + (uint256 aliceId,) = aliceRequest(0); + skip(CLAIM_DELAY); + + vm.prank(operator); + lidoARM.claimRedeem(aliceId); + + assertEq(weth.balanceOf(alice), 100 ether, "alice received WETH"); + assertEq(weth.balanceOf(operator), 0, "operator did NOT pocket the WETH"); + } + + function test_ClaimRedeem_AtExactClaimTimestamp() public { + (uint256 aliceId,) = aliceRequest(0); + // Exactly at the claimTimestamp -> require uses `<=`, must succeed. + skip(CLAIM_DELAY); + vm.prank(alice); + lidoARM.claimRedeem(aliceId); + assertEq(weth.balanceOf(alice), 100 ether, "claim at the exact timestamp boundary"); + } + + function test_ClaimRedeem_FullReservationReleasedOnLoss() public { + // Alice locks request.assets = 100 ether at price 1.0. Then a 20% loss happens. + // Her payout is 80, but reservedWithdrawLiquidity must be reduced by 100 (the full + // request-time reservation), and the unreserved 20 ether becomes value for remaining LPs. + + bobbyFirstDeposit(); + (uint256 aliceId,) = aliceRequest(0); // 100 shares -> request.assets = 100 + assertEq(lidoARM.reservedWithdrawLiquidity(), 100 ether, "reservation at request time"); + + // 20% loss on (200 ether + 1e12) + uint256 lossAmount = 40 ether + 2e11; + vm.prank(address(lidoARM)); + weth.transfer(address(0xdead), lossAmount); + + skip(CLAIM_DELAY); + vm.prank(alice); + uint256 aliceClaimed = lidoARM.claimRedeem(aliceId); + + // Payout is loss-adjusted (80), but reservation released is the full 100. + assertEq(aliceClaimed, 80 ether, "alice payout = 80"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "full reservation released"); + + // The 20 ether delta (reservation - payout) stays in the pool, raising the value + // backing Bob's 100 remaining shares. Bob now backs (160 + 8e11) - 80 = 80 + 8e11 + // against 200 + 1e12 - 100 = 100 + 1e12 supply -> price still 0.8 per share. + assertEq(weth.balanceOf(address(lidoARM)), 80 ether + 8e11, "balance after claim"); + assertEq(lidoARM.totalSupply(), 100 ether + 1e12, "supply after burn"); + assertEq(lidoARM.convertToAssets(1 ether), 0.8 ether, "price still 0.8 for remaining LPs"); + } + + ////////////////////////////////////////////////////// + /// --- REVERTS --- + ////////////////////////////////////////////////////// + function test_ClaimRedeem_RevertWhen_InsufficientLiquidity() public { + // Goal: Alice and Bob both have a queued request, but the ARM holds only 50 WETH liquid + // (the rest replaced by stETH valued 1:1). The FIFO gate must block BOTH claims since + // claimable() in shares < either request.queued. + + bobbyFirstDeposit(); + addBaseAsset(steth); // Configure stETH so it counts in totalAssets at the cross price + + // Both request 100% of their shares at price 1.0 -> each request.assets = 100 ether + (uint256 aliceId,) = aliceRequest(0); + (uint256 bobId,) = bobbyRequest(0); + assertEq(lidoARM.reservedWithdrawLiquidity(), 200 ether, "reserved = 200"); + + // Swap 150 WETH out, 150 stETH in: balance(WETH) drops to 50, totalAssets stays at 200 + // (so the share price stays 1.0 and the gate cannot scale up via convertToShares). + vm.prank(address(lidoARM)); + weth.transfer(address(0xdead), 150 ether); + deal(address(steth), address(lidoARM), 150 ether); + + // Sanity: state is "balance-poor, totalAssets-rich" + assertEq(weth.balanceOf(address(lidoARM)), 50 ether + 1e12, "balance WETH = 50"); + assertEq(lidoARM.totalAssets(), 200 ether + 1e12, "totalAssets unchanged"); + assertEq(lidoARM.totalSupply(), 200 ether + 1e12, "totalSupply unchanged"); + + skip(CLAIM_DELAY); + + // claimable() in shares = convertToShares(50 ether + 1e12) ~= 50 ether + 1e12 + // Alice queued = 100 ether (in shares) -> cannot claim + // Bob queued = 200 ether -> cannot claim either + vm.prank(alice); + vm.expectRevert(AbstractARM.QueuePendingLiquidity.selector); + lidoARM.claimRedeem(aliceId); + + vm.prank(bobby); + vm.expectRevert(AbstractARM.QueuePendingLiquidity.selector); + lidoARM.claimRedeem(bobId); + } + + function test_ClaimRedeem_RevertWhen_NotRequesterOrOperator() public { + (uint256 aliceId,) = aliceRequest(0); + skip(CLAIM_DELAY); + + vm.prank(bobby); + vm.expectRevert(AbstractARM.NotRequesterOrOperator.selector); + lidoARM.claimRedeem(aliceId); + } + + function test_ClaimRedeem_RevertWhen_BeforeClaimDelay() public { + (uint256 aliceId,) = aliceRequest(0); + // One second short of the delay -> still locked. + skip(CLAIM_DELAY - 1); + vm.prank(alice); + vm.expectRevert(AbstractARM.ClaimDelayNotMet.selector); + lidoARM.claimRedeem(aliceId); + } + + function test_ClaimRedeem_RevertWhen_AlreadyClaimed() public { + (uint256 aliceId,) = aliceRequest(0); + skip(CLAIM_DELAY); + vm.prank(alice); + lidoARM.claimRedeem(aliceId); + + // Re-claim attempt + vm.prank(alice); + vm.expectRevert(AbstractARM.AlreadyClaimed.selector); + lidoARM.claimRedeem(aliceId); + } + + function test_ClaimRedeem_RevertWhen_Paused() public { + (uint256 aliceId,) = aliceRequest(0); + skip(CLAIM_DELAY); + + vm.prank(governor); + lidoARM.pause(); + + vm.prank(alice); + vm.expectRevert(AbstractARM.ContractPaused.selector); + lidoARM.claimRedeem(aliceId); + } +} diff --git a/test/unit/LidoARM/concrete/Deposit.t.sol b/test/unit/LidoARM/concrete/Deposit.t.sol new file mode 100644 index 00000000..d7754895 --- /dev/null +++ b/test/unit/LidoARM/concrete/Deposit.t.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Libraries +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +contract Unit_LidoARM_Deposit_Test is Unit_LidoARM_Shared_Test { + using Math for uint256; + + function setUp() public override { + super.setUp(); + desactiveCapManager(); + + // Give Alice some ETH to work with + deal(address(weth), alice, 100 ether); + } + + ////////////////////////////////////////////////////// + /// --- Happy paths --- + ////////////////////////////////////////////////////// + function test_Deposit_Default() public { + // Given + uint256 amount = 1 ether; + uint256 expectedShares = amount; // 1:1 for simplicity + assertEq(lidoARM.convertToShares(amount), expectedShares, "convertToShares"); + assertEq(lidoARM.totalAssets(), 1e12, "totalAssets pre"); + assertEq(lidoARM.totalSupply(), 1e12, "totalSupply pre"); + assertEq(lidoARM.previewDeposit(amount), expectedShares, "previewDeposit"); + + // Expect + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amount); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), alice, expectedShares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.Deposit(alice, amount, expectedShares); + + // When + vm.prank(alice); + lidoARM.deposit(amount); + + // Then + assertEq(weth.balanceOf(alice), 99 ether, "alice weth"); + assertEq(lidoARM.balanceOf(alice), expectedShares, "alice shares"); + assertEq(lidoARM.totalAssets(), 1e12 + amount, "totalAssets"); + assertEq(lidoARM.totalSupply(), 1e12 + expectedShares, "totalSupply"); + } + + function test_Deposit_SecondDeposit() public { + // Given + uint256 firstAmount = 1 ether; + uint256 secondAmount = 3 ether; + uint256 expectedFirstShares = firstAmount; // 1:1 for simplicity + uint256 expectedSecondShares = secondAmount; // 1:1 for simplicity + + // Give Bobby some WETH for the second deposit + deal(address(weth), bobby, 100 ether); + + // First deposit by Alice + vm.prank(alice); + lidoARM.deposit(firstAmount); + + // Sanity check state after first deposit + assertEq(lidoARM.balanceOf(alice), expectedFirstShares, "alice shares pre"); + assertEq(lidoARM.totalAssets(), 1e12 + firstAmount, "totalAssets pre"); + assertEq(lidoARM.totalSupply(), 1e12 + expectedFirstShares, "totalSupply pre"); + + // Expect events for second deposit (by Bobby) + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(bobby, address(lidoARM), secondAmount); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), bobby, expectedSecondShares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.Deposit(bobby, secondAmount, expectedSecondShares); + + // When: second deposit by Bobby + vm.prank(bobby); + lidoARM.deposit(secondAmount); + + // Then + assertEq(weth.balanceOf(alice), 99 ether, "alice weth"); + assertEq(weth.balanceOf(bobby), 97 ether, "bobby weth"); + assertEq(lidoARM.balanceOf(alice), expectedFirstShares, "alice shares"); + assertEq(lidoARM.balanceOf(bobby), expectedSecondShares, "bobby shares"); + assertEq(lidoARM.totalAssets(), 1e12 + firstAmount + secondAmount, "totalAssets"); + assertEq(lidoARM.totalSupply(), 1e12 + expectedFirstShares + expectedSecondShares, "totalSupply"); + } + + function test_Deposit_Default_WithCap() public { + // Given + uint256 amount = 1 ether; + uint256 expectedShares = amount; // 1:1 for simplicity + assertEq(lidoARM.convertToShares(amount), expectedShares, "convertToShares"); + assertEq(lidoARM.totalAssets(), 1e12, "totalAssets pre"); + assertEq(lidoARM.totalSupply(), 1e12, "totalSupply pre"); + + // Set a cap that allows the deposit + address[] memory lps = new address[](1); + lps[0] = alice; + vm.startPrank(governor); + capManager.setLiquidityProviderCaps(lps, 10 ether); + capManager.setTotalAssetsCap(10 ether); + lidoARM.setCapManager(address(capManager)); + vm.stopPrank(); + + // Expect + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amount); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), alice, expectedShares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.Deposit(alice, amount, expectedShares); + + // When + vm.prank(alice); + lidoARM.deposit(amount); + + // Then + assertEq(weth.balanceOf(alice), 99 ether, "alice weth"); + assertEq(lidoARM.balanceOf(alice), expectedShares, "alice shares"); + assertEq(lidoARM.totalAssets(), 1e12 + amount, "totalAssets"); + assertEq(lidoARM.totalSupply(), 1e12 + expectedShares, "totalSupply"); + } + + function test_Deposit_DifferentReceiver() public { + // Given + uint256 amount = 1 ether; + uint256 expectedShares = amount; // 1:1 for simplicity + assertEq(lidoARM.convertToShares(amount), expectedShares, "convertToShares"); + assertEq(lidoARM.totalAssets(), 1e12, "totalAssets pre"); + assertEq(lidoARM.totalSupply(), 1e12, "totalSupply pre"); + deal(address(weth), bobby, 100 ether); + + // Expect + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amount); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), bobby, expectedShares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.Deposit(bobby, amount, expectedShares); + + // When + vm.prank(alice); + lidoARM.deposit(amount, bobby); + + // Then + assertEq(weth.balanceOf(alice), 99 ether, "alice weth"); + assertEq(weth.balanceOf(bobby), 100 ether, "bobby weth"); + assertEq(lidoARM.balanceOf(alice), 0, "alice shares"); + assertEq(lidoARM.balanceOf(bobby), expectedShares, "bobby shares"); + assertEq(lidoARM.totalAssets(), 1e12 + amount, "totalAssets"); + assertEq(lidoARM.totalSupply(), 1e12 + expectedShares, "totalSupply"); + } + + function test_Deposit_SharesAreAbove1() public { + aliceFirstDeposit(); + uint256 rewards = 1.235679154167425791 ether; + // Simulate rewards by donating WETH directly to the ARM. totalSupply stays at 1e12 + 100 ether, + // while totalAssets grows by `rewards`, so 1 share is now worth >1 asset. + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) + rewards); + + // Given + uint256 amount = 10 ether; + uint256 expectedShares = amount.mulDiv(100 ether + 1e12, 100 ether + 1e12 + rewards, Math.Rounding.Floor); + assertEq(lidoARM.convertToShares(amount), expectedShares, "convertToShares"); + assertLt(expectedShares, amount, "shares < assets"); + + // Fund Bobby for the deposit + deal(address(weth), bobby, amount); + + uint256 totalAssetsBefore = lidoARM.totalAssets(); + uint256 totalSupplyBefore = lidoARM.totalSupply(); + + // Expect + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(bobby, address(lidoARM), amount); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), bobby, expectedShares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.Deposit(bobby, amount, expectedShares); + + // When + vm.prank(bobby); + lidoARM.deposit(amount); + + // Then + assertEq(weth.balanceOf(bobby), 0, "bobby weth"); + assertEq(lidoARM.balanceOf(bobby), expectedShares, "bobby shares"); + assertEq(lidoARM.totalAssets(), totalAssetsBefore + amount, "totalAssets"); + assertEq(lidoARM.totalSupply(), totalSupplyBefore + expectedShares, "totalSupply"); + } + + ////////////////////////////////////////////////////// + /// --- REVERTS --- + ////////////////////////////////////////////////////// + function test_Deposit_RevertWhen_Insolvent() public { + // Alice deposit 100 ether to be solvent + aliceFirstDeposit(); + + // Alice withdraw some share to prevent `reservedWithdrawLiquidity == 0`. + vm.prank(alice); + lidoARM.requestRedeem(50 ether); + + // Simulate a loss by reducing the total assets (e.g., due to a failed strategy or slashing) + uint256 balance = weth.balanceOf(address(lidoARM)); + vm.prank(address(lidoARM)); + weth.transfer(address(0), balance - 10 wei); // Leave a tiny amount to avoid zero total assets + + // Expect revert due to insolvency + vm.prank(alice); + vm.expectRevert(AbstractARM.Insolvent.selector); + lidoARM.deposit(1 ether); + } +} diff --git a/test/unit/LidoARM/concrete/ManageMarket.t.sol b/test/unit/LidoARM/concrete/ManageMarket.t.sol new file mode 100644 index 00000000..b2147736 --- /dev/null +++ b/test/unit/LidoARM/concrete/ManageMarket.t.sol @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; +import {Ownable} from "contracts/Ownable.sol"; +import {OwnableOperable} from "contracts/OwnableOperable.sol"; + +/// @notice Coverage for the Active Market admin surface on AbstractARM: +/// `addMarkets`, `removeMarket`, `setActiveMarket`, `setARMBuffer`. +contract Unit_LidoARM_ManageMarket_Test is Unit_LidoARM_Shared_Test { + function setUp() public override { + super.setUp(); + desactiveCapManager(); + } + + ////////////////////////////////////////////////////// + /// --- addMarkets + ////////////////////////////////////////////////////// + function test_AddMarkets_Single() public { + assertEq(lidoARM.supportedMarkets(address(mockERC4626Market)), false, "supported pre"); + + address[] memory markets = new address[](1); + markets[0] = address(mockERC4626Market); + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.MarketAdded(address(mockERC4626Market)); + + vm.prank(governor); + lidoARM.addMarkets(markets); + + assertEq(lidoARM.supportedMarkets(address(mockERC4626Market)), true, "supported post"); + } + + function test_AddMarkets_Multiple() public { + // Second slot is a fake address with a mocked asset() so we don't need a second real ERC4626. + address fakeMarket = address(0x1234); + vm.mockCall(fakeMarket, abi.encodeWithSignature("asset()"), abi.encode(address(weth))); + + address[] memory markets = new address[](2); + markets[0] = address(mockERC4626Market); + markets[1] = fakeMarket; + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.MarketAdded(markets[0]); + vm.expectEmit(address(lidoARM)); + emit AbstractARM.MarketAdded(markets[1]); + + vm.prank(governor); + lidoARM.addMarkets(markets); + + assertEq(lidoARM.supportedMarkets(markets[0]), true, "first supported"); + assertEq(lidoARM.supportedMarkets(markets[1]), true, "second supported"); + } + + function test_AddMarkets_RevertWhen_NotOwner() public { + vm.prank(alice); + vm.expectRevert(Ownable.OnlyOwner.selector); + lidoARM.addMarkets(new address[](0)); + } + + function test_AddMarkets_RevertWhen_AddressZero() public { + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidMarket.selector); + lidoARM.addMarkets(new address[](1)); + } + + function test_AddMarkets_RevertWhen_AlreadySupported() public { + addMarket(address(mockERC4626Market)); + + address[] memory markets = new address[](1); + markets[0] = address(mockERC4626Market); + + vm.prank(governor); + vm.expectRevert(AbstractARM.MarketAlreadySupported.selector); + lidoARM.addMarkets(markets); + } + + function test_AddMarkets_RevertWhen_InvalidMarketAsset() public { + address fakeMarket = address(0x1234); + vm.mockCall(fakeMarket, abi.encodeWithSignature("asset()"), abi.encode(address(0))); + + address[] memory markets = new address[](1); + markets[0] = fakeMarket; + + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidMarketAsset.selector); + lidoARM.addMarkets(markets); + } + + ////////////////////////////////////////////////////// + /// --- removeMarket + ////////////////////////////////////////////////////// + function test_RemoveMarket_Default() public { + addMarket(address(mockERC4626Market)); + assertEq(lidoARM.supportedMarkets(address(mockERC4626Market)), true, "supported pre"); + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.MarketRemoved(address(mockERC4626Market)); + + vm.prank(governor); + lidoARM.removeMarket(address(mockERC4626Market)); + + assertEq(lidoARM.supportedMarkets(address(mockERC4626Market)), false, "supported post"); + } + + function test_RemoveMarket_RevertWhen_NotOwner() public { + vm.prank(alice); + vm.expectRevert(Ownable.OnlyOwner.selector); + lidoARM.removeMarket(address(mockERC4626Market)); + } + + function test_RemoveMarket_RevertWhen_AddressZero() public { + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidMarket.selector); + lidoARM.removeMarket(address(0)); + } + + function test_RemoveMarket_RevertWhen_NotSupported() public { + vm.prank(governor); + vm.expectRevert(AbstractARM.MarketNotSupported.selector); + lidoARM.removeMarket(address(mockERC4626Market)); + } + + function test_RemoveMarket_RevertWhen_MarketIsActive() public { + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + + vm.prank(governor); + vm.expectRevert(AbstractARM.MarketActive.selector); + lidoARM.removeMarket(address(mockERC4626Market)); + } + + ////////////////////////////////////////////////////// + /// --- setActiveMarket + ////////////////////////////////////////////////////// + function test_SetActiveMarket_NoPreviousMarket() public { + addMarket(address(mockERC4626Market)); + assertEq(lidoARM.activeMarket(), address(0), "activeMarket pre"); + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.ActiveMarketUpdated(address(mockERC4626Market)); + + vm.prank(governor); + lidoARM.setActiveMarket(address(mockERC4626Market)); + + assertEq(lidoARM.activeMarket(), address(mockERC4626Market), "activeMarket post"); + } + + function test_SetActiveMarket_ToZero() public { + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + assertEq(lidoARM.activeMarket(), address(mockERC4626Market), "activeMarket pre"); + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.ActiveMarketUpdated(address(0)); + + vm.prank(governor); + lidoARM.setActiveMarket(address(0)); + + assertEq(lidoARM.activeMarket(), address(0), "activeMarket post"); + } + + function test_SetActiveMarket_WithPreviousMarket_Empty() public { + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + addMarket(address(mockERC4626Market2)); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 0, "prev market shares pre"); + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.ActiveMarketUpdated(address(mockERC4626Market2)); + + vm.prank(governor); + lidoARM.setActiveMarket(address(mockERC4626Market2)); + + assertEq(lidoARM.activeMarket(), address(mockERC4626Market2), "activeMarket post"); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 0, "prev market shares post"); + } + + function test_SetActiveMarket_WithPreviousMarket_NonEmpty_WithShares() public { + // Deposit alice → ARM holds liquid WETH. Buffer 0 + active market triggers an allocation, + // so by the time we switch markets, the previous market actually holds the ARM's shares. + aliceFirstDeposit(); + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + addMarket(address(mockERC4626Market2)); + + uint256 prevMarketShares = mockERC4626Market.balanceOf(address(lidoARM)); + assertGt(prevMarketShares, 0, "prev market shares pre"); + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.ActiveMarketUpdated(address(mockERC4626Market2)); + + vm.prank(governor); + lidoARM.setActiveMarket(address(mockERC4626Market2)); + + // All shares were redeemed from the previous market before switching. + assertEq(lidoARM.activeMarket(), address(mockERC4626Market2), "activeMarket post"); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), 0, "prev market shares post"); + } + + function test_SetActiveMarket_ToSameMarket() public { + addMarket(address(mockERC4626Market)); + setActiveMarket(address(mockERC4626Market)); + + // Early-return path — should not emit ActiveMarketUpdated again. Recording logs lets us + // assert no event was emitted by the second call. + vm.recordLogs(); + vm.prank(governor); + lidoARM.setActiveMarket(address(mockERC4626Market)); + assertEq(vm.getRecordedLogs().length, 0, "no events emitted"); + + assertEq(lidoARM.activeMarket(), address(mockERC4626Market), "activeMarket unchanged"); + } + + function test_SetActiveMarket_RevertWhen_NotAuthorized() public { + vm.prank(alice); + vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + lidoARM.setActiveMarket(address(mockERC4626Market)); + } + + function test_SetActiveMarket_RevertWhen_NotSupported() public { + vm.prank(governor); + vm.expectRevert(AbstractARM.MarketNotSupported.selector); + lidoARM.setActiveMarket(address(mockERC4626Market)); + } + + ////////////////////////////////////////////////////// + /// --- setARMBuffer + ////////////////////////////////////////////////////// + function test_SetARMBuffer_Default() public { + uint256 newBuffer = 0.3 ether; + assertEq(lidoARM.armBuffer(), 0, "armBuffer pre"); + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.ARMBufferUpdated(newBuffer); + + vm.prank(governor); + lidoARM.setARMBuffer(newBuffer); + + assertEq(lidoARM.armBuffer(), newBuffer, "armBuffer post"); + } + + function test_SetARMBuffer_RevertWhen_NotAuthorized() public { + vm.prank(alice); + vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + lidoARM.setARMBuffer(0); + } + + function test_SetARMBuffer_RevertWhen_Above1e18() public { + vm.prank(governor); + vm.expectRevert(AbstractARM.InvalidARMBuffer.selector); + lidoARM.setARMBuffer(1e18 + 1); + } +} diff --git a/test/unit/LidoARM/concrete/MigrateLegacyWithdrawQueue.t.sol b/test/unit/LidoARM/concrete/MigrateLegacyWithdrawQueue.t.sol new file mode 100644 index 00000000..7ed25cf7 --- /dev/null +++ b/test/unit/LidoARM/concrete/MigrateLegacyWithdrawQueue.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {stdStorage, StdStorage} from "forge-std/Test.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; +import {Ownable} from "contracts/Ownable.sol"; +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +contract Unit_Concrete_LidoARM_MigrateLegacyWithdrawQueue_Test_ is Unit_LidoARM_Shared_Test { + using stdStorage for StdStorage; + + uint256 internal constant LEGACY_PACKED_WITHDRAW_QUEUE_SLOT = 53; + uint256 internal constant NEXT_WITHDRAWAL_INDEX_SLOT = 54; + + function test_RevertWhen_MigrateLegacyWithdrawQueue_Because_NotGovernor() public { + vm.prank(alice); + vm.expectRevert(Ownable.OnlyOwner.selector); + lidoARM.migrateLegacyWithdrawQueue(); + } + + function test_MigrateLegacyWithdrawQueue_When_LegacyQueueIsZero() public { + _writeNextWithdrawalIndex(3); + + vm.prank(governor); + lidoARM.migrateLegacyWithdrawQueue(); + + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); + assertEq(lidoARM.legacyWithdrawalRequestCount(), 3, "legacy request count"); + } + + function test_MigrateLegacyWithdrawQueue_When_LegacyQueueIsFullyClaimed() public { + uint128 legacyQueued = 5 ether; + uint128 legacyClaimed = legacyQueued; + _writeLegacyWithdrawQueue(legacyQueued, legacyClaimed); + _writeNextWithdrawalIndex(3); + + vm.prank(governor); + lidoARM.migrateLegacyWithdrawQueue(); + + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); + assertEq(lidoARM.legacyWithdrawalRequestCount(), 3, "legacy request count"); + assertEq( + _readLegacyWithdrawQueue(), _packLegacyWithdrawQueue(legacyQueued, legacyClaimed), "legacy queue preserved" + ); + } + + function test_MigrateLegacyWithdrawQueue_When_LegacyWithdrawalsPending() public { + _writeLegacyWithdrawQueue(5 ether, 4 ether); + _writeNextWithdrawalIndex(3); + + vm.prank(governor); + lidoARM.migrateLegacyWithdrawQueue(); + + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); + assertEq(lidoARM.legacyWithdrawalRequestCount(), 3, "legacy request count"); + assertEq(_readLegacyWithdrawQueue(), _packLegacyWithdrawQueue(5 ether, 4 ether), "legacy queue preserved"); + } + + function test_RevertWhen_MigrateLegacyWithdrawQueue_Because_NewQueueAlreadyUsed() public { + desactiveCapManager(); + aliceFirstDeposit(DEFAULT_AMOUNT); + + vm.prank(alice); + lidoARM.requestRedeem(DEFAULT_AMOUNT); + + vm.prank(governor); + vm.expectRevert(AbstractARM.AlreadyMigrated.selector); + lidoARM.migrateLegacyWithdrawQueue(); + } + + function _writeLegacyWithdrawQueue(uint128 legacyQueued, uint128 legacyClaimed) internal { + uint256 packedLegacyQueue = _packLegacyWithdrawQueue(legacyQueued, legacyClaimed); + + vm.store(address(lidoARM), bytes32(LEGACY_PACKED_WITHDRAW_QUEUE_SLOT), bytes32(packedLegacyQueue)); + assertEq(_readLegacyWithdrawQueue(), packedLegacyQueue, "packed legacy queue"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); + } + + function _readLegacyWithdrawQueue() internal view returns (uint256) { + return uint256(vm.load(address(lidoARM), bytes32(LEGACY_PACKED_WITHDRAW_QUEUE_SLOT))); + } + + function _writeNextWithdrawalIndex(uint256 nextWithdrawalIndex) internal { + vm.store(address(lidoARM), bytes32(NEXT_WITHDRAWAL_INDEX_SLOT), bytes32(nextWithdrawalIndex)); + assertEq(lidoARM.nextWithdrawalIndex(), nextWithdrawalIndex, "next withdrawal index"); + } + + function _packLegacyWithdrawQueue(uint128 legacyQueued, uint128 legacyClaimed) internal pure returns (uint256) { + return uint256(legacyQueued) | (uint256(legacyClaimed) << 128); + } +} diff --git a/test/unit/LidoARM/concrete/Pause.t.sol b/test/unit/LidoARM/concrete/Pause.t.sol new file mode 100644 index 00000000..6035b170 --- /dev/null +++ b/test/unit/LidoARM/concrete/Pause.t.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; +import {Ownable} from "contracts/Ownable.sol"; +import {OwnableOperable} from "contracts/OwnableOperable.sol"; + +/// @notice Coverage for `pause()` (operator or owner) and `unpause()` (owner only). +/// The downstream `whenNotPaused` reverts on user-facing functions are +/// already covered in the per-function test files (Deposit, ClaimRedeem, +/// RequestRedeem, Swap*). Here we focus on the access control, the +/// `paused` state flip, and the events. +contract Unit_LidoARM_Pause_Test is Unit_LidoARM_Shared_Test { + ////////////////////////////////////////////////////// + /// --- pause + ////////////////////////////////////////////////////// + function test_Pause_ByOwner() public { + assertEq(lidoARM.paused(), false, "paused pre"); + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.Paused(governor); + + vm.prank(governor); + lidoARM.pause(); + + assertEq(lidoARM.paused(), true, "paused post"); + } + + function test_Pause_ByOperator() public { + assertEq(lidoARM.paused(), false, "paused pre"); + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.Paused(operator); + + vm.prank(operator); + lidoARM.pause(); + + assertEq(lidoARM.paused(), true, "paused post"); + } + + function test_Pause_RevertWhen_NotAuthorized() public { + vm.prank(alice); + vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + lidoARM.pause(); + } + + ////////////////////////////////////////////////////// + /// --- unpause + ////////////////////////////////////////////////////// + function test_Unpause_ByOwner() public { + vm.prank(governor); + lidoARM.pause(); + assertEq(lidoARM.paused(), true, "paused pre"); + + vm.expectEmit(address(lidoARM)); + emit AbstractARM.Unpaused(governor); + + vm.prank(governor); + lidoARM.unpause(); + + assertEq(lidoARM.paused(), false, "paused post"); + } + + function test_Unpause_RevertWhen_Operator() public { + // The operator can pause but cannot unpause — that's reserved for the owner. + vm.prank(operator); + lidoARM.pause(); + + vm.prank(operator); + vm.expectRevert(Ownable.OnlyOwner.selector); + lidoARM.unpause(); + } + + function test_Unpause_RevertWhen_NotAuthorized() public { + vm.prank(governor); + lidoARM.pause(); + + vm.prank(alice); + vm.expectRevert(Ownable.OnlyOwner.selector); + lidoARM.unpause(); + } +} diff --git a/test/unit/LidoARM/concrete/RequestRedeem.t.sol b/test/unit/LidoARM/concrete/RequestRedeem.t.sol new file mode 100644 index 00000000..0a3ebd6a --- /dev/null +++ b/test/unit/LidoARM/concrete/RequestRedeem.t.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Libraries +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +contract Unit_LidoARM_RequestRedeem_Test is Unit_LidoARM_Shared_Test { + using Math for uint256; + + function setUp() public override { + super.setUp(); + desactiveCapManager(); + aliceFirstDeposit(); + } + + ////////////////////////////////////////////////////// + /// --- Happy paths --- + ////////////////////////////////////////////////////// + function test_RequestRedeem_Default() public { + // Given: Alice already deposited 100 ether in setUp + uint256 shares = 50 ether; + uint256 expectedAssets = shares; // 1:1 + uint256 expectedRequestId = 0; + uint256 expectedQueued = shares; + uint256 expectedClaimTimestamp = block.timestamp + CLAIM_DELAY; + + assertEq(lidoARM.convertToAssets(shares), expectedAssets, "convertToAssets"); + assertEq(lidoARM.previewRedeem(shares), expectedAssets, "previewRedeem"); + assertEq(lidoARM.balanceOf(address(lidoARM)), 0, "escrow pre"); + assertEq(lidoARM.nextWithdrawalIndex(), 0, "nextIndex pre"); + assertEq(lidoARM.withdrawsQueuedShares(), 0, "queued pre"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved pre"); + + // Expect + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(alice, address(lidoARM), shares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemRequested( + alice, expectedRequestId, expectedAssets, expectedQueued, expectedClaimTimestamp + ); + + // When + vm.prank(alice); + (uint256 requestId, uint256 assets) = lidoARM.requestRedeem(shares); + + // Then + assertEq(requestId, expectedRequestId, "requestId"); + assertEq(assets, expectedAssets, "assets"); + assertEq(lidoARM.balanceOf(alice), 100 ether - shares, "alice shares"); + assertEq(lidoARM.balanceOf(address(lidoARM)), shares, "escrow"); + assertEq(lidoARM.totalSupply(), 1e12 + 100 ether, "totalSupply"); + assertEq(lidoARM.nextWithdrawalIndex(), 1, "nextIndex"); + assertEq(lidoARM.withdrawsQueuedShares(), shares, "queued"); + assertEq(lidoARM.reservedWithdrawLiquidity(), expectedAssets, "reserved"); + + // Stored withdrawal request + _assertStoredRequest(expectedRequestId, alice, expectedClaimTimestamp, expectedAssets, expectedQueued, shares); + } + + function test_RequestRedeem_WithYield() public { + // Given: Alice already deposited 100 ether in setUp. Simulate yield accrued to the ARM by + // donating WETH directly. totalSupply is unchanged, so the share price moves above 1. + uint256 yield = 10.582931746103928574 ether; + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) + yield); + + uint256 shares = 50 ether; + uint256 expectedAssets = shares.mulDiv(1e12 + 100 ether + yield, 1e12 + 100 ether, Math.Rounding.Floor); + uint256 expectedClaimTimestamp = block.timestamp + CLAIM_DELAY; + + assertGt(expectedAssets, shares, "assets > shares"); + assertEq(lidoARM.convertToAssets(shares), expectedAssets, "convertToAssets"); + assertEq(lidoARM.previewRedeem(shares), expectedAssets, "previewRedeem"); + assertEq(lidoARM.totalAssets(), 1e12 + 100 ether + yield, "totalAssets pre"); + assertEq(lidoARM.totalSupply(), 1e12 + 100 ether, "totalSupply pre"); + assertEq(lidoARM.nextWithdrawalIndex(), 0, "nextIndex pre"); + assertEq(lidoARM.withdrawsQueuedShares(), 0, "queued pre"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved pre"); + + // Expect + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(alice, address(lidoARM), shares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemRequested(alice, 0, expectedAssets, shares, expectedClaimTimestamp); + + // When + vm.prank(alice); + (uint256 requestId, uint256 assets) = lidoARM.requestRedeem(shares); + + // Then + assertEq(requestId, 0, "requestId"); + assertEq(assets, expectedAssets, "assets"); + assertEq(lidoARM.balanceOf(alice), 100 ether - shares, "alice shares"); + assertEq(lidoARM.balanceOf(address(lidoARM)), shares, "escrow"); + assertEq(lidoARM.totalSupply(), 1e12 + 100 ether, "totalSupply"); + assertEq(lidoARM.totalAssets(), 1e12 + 100 ether + yield, "totalAssets"); + assertEq(lidoARM.nextWithdrawalIndex(), 1, "nextIndex"); + assertEq(lidoARM.withdrawsQueuedShares(), shares, "queued"); + assertEq(lidoARM.reservedWithdrawLiquidity(), expectedAssets, "reserved"); + + // Stored withdrawal request + _assertStoredRequest(0, alice, expectedClaimTimestamp, expectedAssets, shares, shares); + } +} diff --git a/test/unit/LidoARM/concrete/SwapExactTokensForTokens.t.sol b/test/unit/LidoARM/concrete/SwapExactTokensForTokens.t.sol new file mode 100644 index 00000000..b1346eb1 --- /dev/null +++ b/test/unit/LidoARM/concrete/SwapExactTokensForTokens.t.sol @@ -0,0 +1,547 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Libraries +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +// External +import {ERC4626} from "@solmate/mixins/ERC4626.sol"; + +/// @author Origin Protocol Inc +/// @notice Tests exact-input swaps between the Lido ARM liquidity asset and supported base assets. +/// @dev Expected swap outputs are precomputed with Chisel and hardcoded on purpose. Recomputing +/// them in the test with the same math path as the contract would mostly prove that both sides +/// share the same formula, not that the contract returns the correct values. Deltas and fees are +/// derived from those fixed outputs when that keeps the test easier to read. +contract Unit_Concrete_LidoARM_SwapExactTokensForTokens_Test is Unit_LidoARM_Shared_Test { + using Math for uint256; + + ////////////////////////////////////////////////////// + /// --- SETUP --- + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + desactiveCapManager(); + addBaseAsset(steth); + addBaseAsset(wsteth); + seedWstETHWithTargetExchangeRate(); + aliceFirstDeposit(); + } + + ////////////////////////////////////////////////////// + /// --- Happy paths: stETH -> WETH --- + ////////////////////////////////////////////////////// + function test_SwapExactTokensForTokens_Steth_To_Weth_Default() public { + // Given + uint256 amountIn = 50 ether; + + // stETH is valued 1:1 with WETH in these tests. + // expectedAmountOut = 50 stETH * 0.992 buy price = 49.6 WETH. + // expectedTotalAssetsIncrease = amountIn - expectedAmountOut. + // expectedFee = expectedTotalAssetsIncrease * 20% default fee. + uint256 expectedAmountOut = 49.6 ether; + uint256 expectedTotalAssetsIncrease = amountIn - expectedAmountOut; + uint256 expectedFee = expectedTotalAssetsIncrease.mulDiv(DEFAULT_FEE, FEE_SCALE); + deal(address(steth), alice, amountIn); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), amountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens(steth, weth, amountIn, expectedAmountOut, alice); + + // Then + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 1); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - expectedAmountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), expectedAmountOut); + assertEq(steth.balanceOf(alice), 0); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore - expectedAmountOut); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore + amountIn); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 1); + } + + function test_SwapExactTokensForTokens_Steth_To_Weth_Router() public { + // Given + uint256 amountIn = 50 ether; + + // stETH is valued 1:1 with WETH in these tests. + // expectedAmountOut = 50 stETH * 0.992 buy price = 49.6 WETH. + // expectedTotalAssetsIncrease = amountIn - expectedAmountOut. + // expectedFee = expectedTotalAssetsIncrease * 20% default fee. + uint256 expectedAmountOut = 49.6 ether; + uint256 expectedTotalAssetsIncrease = amountIn - expectedAmountOut; + uint256 expectedFee = expectedTotalAssetsIncrease.mulDiv(DEFAULT_FEE, FEE_SCALE); + deal(address(steth), alice, amountIn); + + address[] memory path = new address[](2); + path[0] = address(steth); + path[1] = address(weth); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), amountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = + lidoARM.swapExactTokensForTokens(amountIn, expectedAmountOut, path, alice, block.timestamp); + + // Then + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 1); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - expectedAmountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), expectedAmountOut); + assertEq(steth.balanceOf(alice), 0); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore - expectedAmountOut); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore + amountIn); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 1); + } + + function test_SwapExactTokensForTokens_Steth_To_Weth_NoFees() public { + // Set fee to 0 for this test to isolate swap logic without fees. + vm.prank(governor); + lidoARM.setFee(0); + + // Given + uint256 amountIn = 50 ether; + + // stETH is valued 1:1 with WETH in these tests. + // expectedAmountOut = 50 stETH * 0.992 buy price = 49.6 WETH. + // expectedTotalAssetsIncrease = amountIn - expectedAmountOut. + // The fee is disabled, so the whole spread stays in totalAssets. + uint256 expectedAmountOut = 49.6 ether; + uint256 expectedTotalAssetsIncrease = amountIn - expectedAmountOut; + deal(address(steth), alice, amountIn); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), amountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens(steth, weth, amountIn, expectedAmountOut, alice); + + // Then + assertEq(lidoARM.feesAccrued(), 0); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - expectedAmountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), expectedAmountOut); + assertEq(steth.balanceOf(alice), 0); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore - expectedAmountOut); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore + amountIn); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + assertEq(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease); + } + + function test_SwapExactTokensForTokens_Steth_To_Weth_UseMarket() public { + // Route excess WETH through the mock market so the swap must pull the shortfall from it. + vm.startPrank(governor); + address[] memory markets = new address[](1); + markets[0] = address(mockERC4626Market); + lidoARM.addMarkets(markets); + lidoARM.setActiveMarket(address(mockERC4626Market)); + // 0.5e18 = 50% buffer kept in the ARM. + lidoARM.setARMBuffer(0.5 ether); + lidoARM.allocate(); + vm.stopPrank(); + + // Given + uint256 amountIn = 75 ether; + + // stETH is valued 1:1 with WETH in these tests. + // expectedAmountOut = 75 stETH * 0.992 buy price = 74.4 WETH. + // expectedTotalAssetsIncrease = amountIn - expectedAmountOut. + // expectedFee = expectedTotalAssetsIncrease * 20% default fee. + uint256 expectedAmountOut = 74.4 ether; + uint256 expectedTotalAssetsIncrease = amountIn - expectedAmountOut; + uint256 expectedFee = expectedTotalAssetsIncrease.mulDiv(DEFAULT_FEE, FEE_SCALE); + deal(address(steth), alice, amountIn); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), amountIn); + // The 50% buffer applies to Alice's 100 WETH deposit plus the 1e12 minimum liquidity. + assertEq(weth.balanceOf(address(lidoARM)), 50 ether + MIN_TOTAL_SUPPLY / 2); + assertEq(steth.balanceOf(address(mockERC4626Market)), 0); + + uint256 expectedMarketWithdrawal = expectedAmountOut - weth.balanceOf(address(lidoARM)); + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 marketSharesBefore = mockERC4626Market.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // The ARM pays Alice with its on-hand WETH first; the ERC4626 withdrawal covers only the shortfall. + vm.expectEmit({emitter: address(mockERC4626Market)}); + emit ERC4626.Withdraw( + address(lidoARM), address(lidoARM), address(lidoARM), expectedMarketWithdrawal, expectedMarketWithdrawal + ); + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens(steth, weth, amountIn, expectedAmountOut, alice); + + // Then + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 1); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - expectedAmountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), expectedAmountOut); + assertEq(steth.balanceOf(alice), 0); + // On-hand WETH plus the market withdrawal was paid to Alice. + assertEq(weth.balanceOf(address(lidoARM)), 0); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore + amountIn); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), marketSharesBefore - expectedMarketWithdrawal); + assertEq(steth.balanceOf(address(mockERC4626Market)), 0); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 1); + } + + ////////////////////////////////////////////////////// + /// --- Happy paths: WETH -> stETH --- + ////////////////////////////////////////////////////// + function test_SwapExactTokensForTokens_Weth_To_Steth_Default() public { + // Seed stETH sell liquidity directly instead of calling the stETH -> WETH test. + // Calling another test would make coverage include that test's swap path too. + uint256 armStethLiquidity = 50 ether; + deal(address(steth), address(lidoARM), armStethLiquidity); + + // Given + uint256 amountIn = 25 ether; + + // stETH is valued 1:1 with WETH in these tests. + // expectedAmountOut = 25 WETH / 1.001 sell price = 24.975024975024975024 stETH. + // expectedTotalAssetsIncrease = amountIn - expectedAmountOut. + // Sell-side swaps do not accrue fees, so the whole spread stays in totalAssets. + uint256 expectedAmountOut = 24.975024975024975024 ether; + uint256 expectedTotalAssetsIncrease = amountIn - expectedAmountOut; + deal(address(weth), alice, amountIn); + + assertEq(weth.balanceOf(alice), amountIn); + uint256 feeAccruedBefore = lidoARM.feesAccrued(); + uint256 stethBalanceBefore = steth.balanceOf(alice); + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens(weth, steth, amountIn, expectedAmountOut, alice); + + // Then + // No fees on sell side. + assertEq(lidoARM.feesAccrued(), feeAccruedBefore); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore - expectedAmountOut); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), expectedAmountOut + stethBalanceBefore); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + amountIn); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore - expectedAmountOut); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + assertEq(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease); + } + + ////////////////////////////////////////////////////// + /// --- Happy paths: wstETH <-> WETH --- + ////////////////////////////////////////////////////// + function test_SwapExactTokensForTokens_Wsteth_To_Weth_Default() public { + // Given + uint256 amountIn = 50 ether; + + // wstETH is valued through its stETH backing, and these tests assume stETH = WETH. + // amountInAssets = 50 wstETH * 1.237 stETH/wstETH = 61.85 stETH. + // expectedAmountOut = 61.85 stETH * 0.992 buy price = 61.3552 WETH. + // expectedTotalAssetsIncrease = amountInAssets - expectedAmountOut. + // expectedFee = expectedTotalAssetsIncrease * 20% default fee. + uint256 amountInAssets = 61.85 ether; + uint256 expectedAmountOut = 61.3552 ether; + uint256 expectedTotalAssetsIncrease = amountInAssets - expectedAmountOut; + uint256 expectedFee = expectedTotalAssetsIncrease.mulDiv(DEFAULT_FEE, FEE_SCALE); + dealWsteth(alice, amountIn); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(wsteth.balanceOf(alice), amountIn); + assertEq(mockWstETH.getStETHByWstETH(amountIn), amountInAssets); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(wsteth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(wsteth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armWstethBefore = wsteth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(wsteth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens(wsteth, weth, amountIn, expectedAmountOut, alice); + + // Then + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 1); + assertEq(buyLiquidityRemaining(wsteth), buyLiquidityBefore - expectedAmountOut); + assertEq(sellLiquidityRemaining(wsteth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), expectedAmountOut); + assertEq(wsteth.balanceOf(alice), 0); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore - expectedAmountOut); + assertEq(wsteth.balanceOf(address(lidoARM)), armWstethBefore + amountIn); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 1); + } + + function test_SwapExactTokensForTokens_Weth_To_Wsteth_Default() public { + // Seed wstETH sell liquidity directly instead of calling the wstETH -> WETH test. + // Calling another test would make coverage include that test's swap path too. + uint256 armWstethLiquidity = 50 ether; + dealWsteth(address(lidoARM), armWstethLiquidity); + + // Given + uint256 amountIn = 25 ether; + + // wstETH is valued through its stETH backing, and these tests assume stETH = WETH. + // convertedAmountInShares = 25 WETH / 1.237 stETH/wstETH = 20.210185933710590137 wstETH. + // expectedAmountOut = 20.210185933710590137 wstETH / 1.001 sell price. + // expectedAmountOutAssets = expectedAmountOut * 1.237 stETH/wstETH = 24.975024975024975023 WETH. + // expectedTotalAssetsIncrease = 25 WETH - 24.975024975024975024 WETH. + // The 1 wei difference from expectedAmountOutAssets comes from valuing the ARM's remaining wstETH balance. + // Sell-side swaps do not accrue fees, so the whole spread stays in totalAssets. + uint256 convertedAmountInShares = 20.210185933710590137 ether; + uint256 expectedAmountOut = 20.189995937772817319 ether; + uint256 expectedAmountOutAssets = 24.975024975024975023 ether; + uint256 expectedTotalAssetsIncrease = 0.024975024975024976 ether; + deal(address(weth), alice, amountIn); + + assertEq(weth.balanceOf(alice), amountIn); + assertEq(wsteth.balanceOf(alice), 0); + assertEq(mockWstETH.getWstETHByStETH(amountIn), convertedAmountInShares); + assertEq(mockWstETH.getStETHByWstETH(expectedAmountOut), expectedAmountOutAssets); + + uint256 feeAccruedBefore = lidoARM.feesAccrued(); + uint256 buyLiquidityBefore = buyLiquidityRemaining(wsteth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(wsteth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armWstethBefore = wsteth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(wsteth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens(weth, wsteth, amountIn, expectedAmountOut, alice); + + // Then + // No fees on sell side. + assertEq(lidoARM.feesAccrued(), feeAccruedBefore); + assertEq(buyLiquidityRemaining(wsteth), buyLiquidityBefore); + assertEq(sellLiquidityRemaining(wsteth), sellLiquidityBefore - expectedAmountOut); + assertEq(weth.balanceOf(alice), 0); + assertEq(wsteth.balanceOf(alice), expectedAmountOut); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + amountIn); + assertEq(wsteth.balanceOf(address(lidoARM)), armWstethBefore - expectedAmountOut); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + assertEq(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease); + } + + ////////////////////////////////////////////////////// + /// --- REVERTS --- + ////////////////////////////////////////////////////// + function test_SwapExactTokensForTokens_RevertWhen_InvalidSwapAssets() public { + // Same token for both sides of the swap, even if it's a supported base asset + vm.expectRevert("ARM: Invalid swap assets"); + lidoARM.swapExactTokensForTokens(steth, steth, 1 ether, 1 ether, alice); + + // Same token for both sides of the swap, even if it's liquidity asset + vm.expectRevert("ARM: Invalid swap assets"); + lidoARM.swapExactTokensForTokens(weth, weth, 1 ether, 1 ether, alice); + + // Both tokens are base assets supported by the ARM + vm.expectRevert("ARM: Invalid swap assets"); + lidoARM.swapExactTokensForTokens(steth, wsteth, 1 ether, 1 ether, alice); + + // Unsupported token as liquidity asset + vm.expectRevert("ARM: Invalid swap assets"); + lidoARM.swapExactTokensForTokens(steth, IERC20(address(0x1234)), 1 ether, 1 ether, alice); + + // Unsupported token as base asset + vm.expectRevert("ARM: Invalid swap assets"); + lidoARM.swapExactTokensForTokens(weth, IERC20(address(0x1234)), 1 ether, 1 ether, alice); + } + + function test_SwapExactTokensForTokens_RevertWhen_InsufficientLiquidity() public { + // Not enough liquidity - no active market and the swap amount exceeds the ARM's balance. + uint256 amountIn = weth.balanceOf(address(lidoARM)) + 1 ether; + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapExactTokensForTokens(steth, weth, amountIn, 0, alice); + + // Route excess WETH through the mock market so the swap must pull the shortfall from it. + vm.startPrank(governor); + address[] memory markets = new address[](1); + markets[0] = address(mockERC4626Market); + lidoARM.addMarkets(markets); + lidoARM.setActiveMarket(address(mockERC4626Market)); + // 0.5e18 = 50% buffer kept in the ARM. + lidoARM.setARMBuffer(0.5 ether); + lidoARM.allocate(); + vm.stopPrank(); + + // Still not enough liquidity - the market buffer plus the ARM's balance is insufficient. + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapExactTokensForTokens(steth, weth, amountIn, 0, alice); + + vm.prank(governor); + lidoARM.setPrices(address(steth), 992 * 1e33, 1001 * 1e33, 1 ether, 2 ether); + // Not enough sell liquidity at this price + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapExactTokensForTokens(steth, weth, 10 ether, 0, alice); + + // Not enough buy liquidity at this price + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapExactTokensForTokens(weth, steth, 10 ether, 0, alice); + + vm.prank(governor); + lidoARM.setPrices(address(steth), 20e32, 1e36, 10 ether, 5 ether); + deal(address(steth), address(lidoARM), 10 ether); + // The buy-side cap check should run before fee accrual, even when the requested swap would overflow fees. + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapExactTokensForTokens(weth, steth, 7 ether, 0, alice); + } + + function test_SwapExactTokensForTokens_RevertWhen_InsuffisantOutputAmount() public { + uint256 amountIn = 1 ether; + uint256 amountOutMin = 1 ether; + deal(address(steth), alice, amountIn); + + // Direct overload: + // swapExactTokensForTokens(IERC20,IERC20,uint256,uint256,address). + vm.prank(alice); + vm.expectRevert("ARM: Insufficient output amount"); + lidoARM.swapExactTokensForTokens(steth, weth, amountIn, amountOutMin, alice); + + // Route through the Uniswap V2-compatible overload: + // swapExactTokensForTokens(uint256,uint256,address[],address,uint256). + address[] memory path = new address[](2); + path[0] = address(steth); + path[1] = address(weth); + + deal(address(steth), alice, amountIn); + + vm.prank(alice); + vm.expectRevert("ARM: Insufficient output amount"); + lidoARM.swapExactTokensForTokens(amountIn, amountOutMin, path, alice, block.timestamp); + } + + function test_SwapExactTokensForTokens_RevertWhen_InvalidPathLength() public { + address[] memory path = new address[](3); + vm.expectRevert("ARM: Invalid path length"); + lidoARM.swapExactTokensForTokens(0, 0, path, alice, 0); + } + + function test_SwapExactTokensForTokens_RevertWhen_DeadlineExpired() public { + address[] memory path = new address[](2); + path[0] = address(steth); + path[1] = address(weth); + vm.expectRevert("ARM: Deadline expired"); + lidoARM.swapExactTokensForTokens(0, 0, path, alice, block.timestamp - 1); + } + + function test_SwapExactTokensForTokens_RevertWhen_Paused() public { + vm.prank(governor); + lidoARM.pause(); + + vm.expectRevert(AbstractARM.ContractPaused.selector); + lidoARM.swapExactTokensForTokens(steth, weth, 1 ether, 0, alice); + + vm.expectRevert(AbstractARM.ContractPaused.selector); + lidoARM.swapExactTokensForTokens(1 ether, 0, new address[](2), alice, block.timestamp); + } +} diff --git a/test/unit/LidoARM/concrete/SwapTokensForExactTokens.t.sol b/test/unit/LidoARM/concrete/SwapTokensForExactTokens.t.sol new file mode 100644 index 00000000..be146620 --- /dev/null +++ b/test/unit/LidoARM/concrete/SwapTokensForExactTokens.t.sol @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Libraries +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +// External +import {ERC4626} from "@solmate/mixins/ERC4626.sol"; + +/// @author Origin Protocol Inc +/// @notice Tests exact-output swaps between the Lido ARM liquidity asset and supported base assets. +/// @dev Expected swap inputs are precomputed with Chisel and hardcoded on purpose. Recomputing +/// them in the test with the same math path as the contract would mostly prove that both sides +/// share the same formula, not that the contract returns the correct values. Deltas and fees are +/// derived from those fixed amounts when that keeps the test easier to read. +contract Unit_Concrete_LidoARM_SwapTokensForExactTokens_Test is Unit_LidoARM_Shared_Test { + using Math for uint256; + + ////////////////////////////////////////////////////// + /// --- SETUP --- + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + desactiveCapManager(); + addBaseAsset(steth); + addBaseAsset(wsteth); + seedWstETHWithTargetExchangeRate(); + aliceFirstDeposit(); + } + + ////////////////////////////////////////////////////// + /// --- Happy paths: stETH -> WETH --- + ////////////////////////////////////////////////////// + function test_SwapTokensForExactTokens_Steth_To_Weth_Default() public { + // Given + uint256 amountOut = 50 ether; + + // stETH is valued 1:1 with WETH in these tests. + // expectedAmountIn = 50 WETH / 0.992 buy price + 3 wei rounding buffer. + // expectedTotalAssetsIncrease = expectedAmountIn - amountOut. + // expectedFee = expectedTotalAssetsIncrease * 20% default fee. + uint256 expectedAmountIn = 50.403225806451612903 ether + 3 wei; + uint256 expectedTotalAssetsIncrease = expectedAmountIn - amountOut; + uint256 expectedFee = expectedTotalAssetsIncrease.mulDiv(DEFAULT_FEE, FEE_SCALE); + deal(address(steth), alice, expectedAmountIn); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), expectedAmountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens(steth, weth, amountOut, expectedAmountIn, alice); + + // Then + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 1); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - amountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), amountOut); + assertEq(steth.balanceOf(alice), 0); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore - amountOut); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore + expectedAmountIn); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 1); + } + + function test_SwapTokensForExactTokens_Steth_To_Weth_Router() public { + // Given + uint256 amountOut = 49.6 ether; + + // stETH is valued 1:1 with WETH in these tests. + // expectedAmountIn = 49.6 WETH / 0.992 buy price + 3 wei rounding buffer. + // expectedTotalAssetsIncrease = expectedAmountIn - amountOut. + // expectedFee = expectedTotalAssetsIncrease * 20% default fee. + uint256 expectedAmountIn = 50 ether + 3 wei; + uint256 expectedTotalAssetsIncrease = expectedAmountIn - amountOut; + uint256 expectedFee = expectedTotalAssetsIncrease.mulDiv(DEFAULT_FEE, FEE_SCALE); + deal(address(steth), alice, expectedAmountIn); + + address[] memory path = new address[](2); + path[0] = address(steth); + path[1] = address(weth); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), expectedAmountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = + lidoARM.swapTokensForExactTokens(amountOut, expectedAmountIn, path, alice, block.timestamp); + + // Then + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 1); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - amountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), amountOut); + assertEq(steth.balanceOf(alice), 0); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore - amountOut); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore + expectedAmountIn); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 1); + } + + function test_SwapTokensForExactTokens_Steth_To_Weth_NoFees() public { + // Set fee to 0 for this test to isolate swap logic without fees. + vm.prank(governor); + lidoARM.setFee(0); + + // Given + uint256 amountOut = 49.6 ether; + + // stETH is valued 1:1 with WETH in these tests. + // expectedAmountIn = 49.6 WETH / 0.992 buy price + 3 wei rounding buffer. + // expectedTotalAssetsIncrease = expectedAmountIn - amountOut. + // The fee is disabled, so the whole spread stays in totalAssets. + uint256 expectedAmountIn = 50 ether + 3 wei; + uint256 expectedTotalAssetsIncrease = expectedAmountIn - amountOut; + deal(address(steth), alice, expectedAmountIn); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), expectedAmountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens(steth, weth, amountOut, expectedAmountIn, alice); + + // Then + assertEq(lidoARM.feesAccrued(), 0); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - amountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), amountOut); + assertEq(steth.balanceOf(alice), 0); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore - amountOut); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore + expectedAmountIn); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + assertEq(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease); + } + + function test_SwapTokensForExactTokens_Steth_To_Weth_UseMarket() public { + // Route excess WETH through the mock market so the swap must pull the shortfall from it. + vm.startPrank(governor); + address[] memory markets = new address[](1); + markets[0] = address(mockERC4626Market); + lidoARM.addMarkets(markets); + lidoARM.setActiveMarket(address(mockERC4626Market)); + // 0.5e18 = 50% buffer kept in the ARM. + lidoARM.setARMBuffer(0.5 ether); + lidoARM.allocate(); + vm.stopPrank(); + + // Given + uint256 amountOut = 74.4 ether; + + // stETH is valued 1:1 with WETH in these tests. + // expectedAmountIn = 74.4 WETH / 0.992 buy price + 3 wei rounding buffer. + // expectedTotalAssetsIncrease = expectedAmountIn - amountOut. + // expectedFee = expectedTotalAssetsIncrease * 20% default fee. + uint256 expectedAmountIn = 75 ether + 3 wei; + uint256 expectedTotalAssetsIncrease = expectedAmountIn - amountOut; + uint256 expectedFee = expectedTotalAssetsIncrease.mulDiv(DEFAULT_FEE, FEE_SCALE); + deal(address(steth), alice, expectedAmountIn); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), expectedAmountIn); + // The 50% buffer applies to Alice's 100 WETH deposit plus the 1e12 minimum liquidity. + assertEq(weth.balanceOf(address(lidoARM)), 50 ether + MIN_TOTAL_SUPPLY / 2); + assertEq(steth.balanceOf(address(mockERC4626Market)), 0); + + uint256 expectedMarketWithdrawal = amountOut - weth.balanceOf(address(lidoARM)); + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 marketSharesBefore = mockERC4626Market.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // The ARM pays Alice with its on-hand WETH first; the ERC4626 withdrawal covers only the shortfall. + vm.expectEmit({emitter: address(mockERC4626Market)}); + emit ERC4626.Withdraw( + address(lidoARM), address(lidoARM), address(lidoARM), expectedMarketWithdrawal, expectedMarketWithdrawal + ); + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens(steth, weth, amountOut, expectedAmountIn, alice); + + // Then + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 1); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - amountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), amountOut); + assertEq(steth.balanceOf(alice), 0); + // On-hand WETH plus the market withdrawal was paid to Alice. + assertEq(weth.balanceOf(address(lidoARM)), 0); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore + expectedAmountIn); + assertEq(mockERC4626Market.balanceOf(address(lidoARM)), marketSharesBefore - expectedMarketWithdrawal); + assertEq(steth.balanceOf(address(mockERC4626Market)), 0); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 1); + } + + ////////////////////////////////////////////////////// + /// --- Happy paths: WETH -> stETH --- + ////////////////////////////////////////////////////// + function test_SwapTokensForExactTokens_Weth_To_Steth_Default() public { + // Seed stETH sell liquidity directly instead of calling the stETH -> WETH test. + // Calling another test would make coverage include that test's swap path too. + uint256 armStethLiquidity = 50 ether; + deal(address(steth), address(lidoARM), armStethLiquidity); + + // Given + uint256 amountOut = 24.975024975024975024 ether; + + // stETH is valued 1:1 with WETH in these tests. + // expectedAmountIn = 24.975024975024975024 stETH * 1.001 sell price + 3 wei rounding buffer. + // expectedTotalAssetsIncrease = expectedAmountIn - amountOut. + // Sell-side swaps do not accrue fees, so the whole spread stays in totalAssets. + uint256 expectedAmountIn = 25 ether + 2 wei; + uint256 expectedTotalAssetsIncrease = expectedAmountIn - amountOut; + deal(address(weth), alice, expectedAmountIn); + + assertEq(weth.balanceOf(alice), expectedAmountIn); + uint256 feeAccruedBefore = lidoARM.feesAccrued(); + uint256 stethBalanceBefore = steth.balanceOf(alice); + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens(weth, steth, amountOut, expectedAmountIn, alice); + + // Then + // No fees on sell side. + assertEq(lidoARM.feesAccrued(), feeAccruedBefore); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore - amountOut); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), amountOut + stethBalanceBefore); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + expectedAmountIn); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore - amountOut); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + assertEq(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease); + } + + ////////////////////////////////////////////////////// + /// --- Happy paths: wstETH <-> WETH --- + ////////////////////////////////////////////////////// + function test_SwapTokensForExactTokens_Wsteth_To_Weth_Default() public { + // Given + uint256 amountOut = 50 ether; + + // wstETH is valued through its stETH backing, and these tests assume stETH = WETH. + // convertedAmountOutShares = 50 WETH / 1.237 stETH/wstETH = 40.420371867421180274 wstETH. + // expectedAmountIn = convertedAmountOutShares / 0.992 buy price + 3 wei rounding buffer. + // amountInAssets = expectedAmountIn * 1.237 stETH/wstETH. + // expectedTotalAssetsIncrease = amountInAssets - amountOut. + // expectedFee = expectedTotalAssetsIncrease * 20% default fee. + uint256 expectedAmountIn = 40.746342608287480117 ether; + uint256 expectedTotalAssetsIncrease = 0.403225806451612904 ether; + uint256 expectedFee = expectedTotalAssetsIncrease.mulDiv(DEFAULT_FEE, FEE_SCALE); + dealWsteth(alice, 50 ether); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(wsteth.balanceOf(alice), 50 ether); + assertEq(mockWstETH.getWstETHByStETH(amountOut), 40.420371867421180274 ether); + assertEq(mockWstETH.getStETHByWstETH(expectedAmountIn), 50.403225806451612904 ether); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(wsteth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(wsteth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armWstethBefore = wsteth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(wsteth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens(wsteth, weth, amountOut, expectedAmountIn, alice); + + // Then + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 1); + assertEq(buyLiquidityRemaining(wsteth), buyLiquidityBefore - amountOut); + assertEq(sellLiquidityRemaining(wsteth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), amountOut); + assertEq(wsteth.balanceOf(alice), 50 ether - expectedAmountIn); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore - amountOut); + assertEq(wsteth.balanceOf(address(lidoARM)), armWstethBefore + expectedAmountIn); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 1); + } + + function test_SwapTokensForExactTokens_Weth_To_Wsteth_Default() public { + // Seed wstETH sell liquidity directly instead of calling the wstETH -> WETH test. + // Calling another test would make coverage include that test's swap path too. + uint256 armWstethLiquidity = 50 ether; + dealWsteth(address(lidoARM), armWstethLiquidity); + + // Given + uint256 amountOut = 20.189995937772817319 ether; + + // wstETH is valued through its stETH backing, and these tests assume stETH = WETH. + // amountOutAssets = amountOut * 1.237 stETH/wstETH = 24.975024975024975023 WETH. + // expectedAmountIn = amountOutAssets * 1.001 sell price + 3 wei rounding buffer. + // expectedTotalAssetsIncrease is 1 wei lower than expectedAmountIn - amountOutAssets because totalAssets() + // values the ARM's remaining wstETH balance after the transfer. + // Sell-side swaps do not accrue fees, so the whole spread stays in totalAssets. + uint256 amountOutAssets = 24.975024975024975023 ether; + uint256 expectedAmountIn = 25 ether + 1 wei; + uint256 expectedTotalAssetsIncrease = 0.024975024975024977 ether; + deal(address(weth), alice, expectedAmountIn); + + assertEq(weth.balanceOf(alice), expectedAmountIn); + assertEq(wsteth.balanceOf(alice), 0); + assertEq(mockWstETH.getStETHByWstETH(amountOut), amountOutAssets); + + uint256 feeAccruedBefore = lidoARM.feesAccrued(); + uint256 buyLiquidityBefore = buyLiquidityRemaining(wsteth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(wsteth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armWstethBefore = wsteth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(wsteth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens(weth, wsteth, amountOut, expectedAmountIn, alice); + + // Then + // No fees on sell side. + assertEq(lidoARM.feesAccrued(), feeAccruedBefore); + assertEq(buyLiquidityRemaining(wsteth), buyLiquidityBefore); + assertEq(sellLiquidityRemaining(wsteth), sellLiquidityBefore - amountOut); + assertEq(weth.balanceOf(alice), 0); + assertEq(wsteth.balanceOf(alice), amountOut); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + expectedAmountIn); + assertEq(wsteth.balanceOf(address(lidoARM)), armWstethBefore - amountOut); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + assertEq(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease); + } + + ////////////////////////////////////////////////////// + /// --- REVERTS --- + ////////////////////////////////////////////////////// + function test_SwapTokensForExactTokens_RevertWhen_InvalidSwapAssets() public { + // Same token for both sides of the swap, even if it's a supported base asset + vm.expectRevert("ARM: Invalid swap assets"); + lidoARM.swapTokensForExactTokens(steth, steth, 1 ether, 1 ether, alice); + + // Same token for both sides of the swap, even if it's liquidity asset + vm.expectRevert("ARM: Invalid swap assets"); + lidoARM.swapTokensForExactTokens(weth, weth, 1 ether, 1 ether, alice); + + // Both tokens are base assets supported by the ARM + vm.expectRevert("ARM: Invalid swap assets"); + lidoARM.swapTokensForExactTokens(steth, wsteth, 1 ether, 1 ether, alice); + + // Unsupported token as liquidity asset + vm.expectRevert("ARM: Invalid swap assets"); + lidoARM.swapTokensForExactTokens(steth, IERC20(address(0x1234)), 1 ether, 1 ether, alice); + + // Unsupported token as base asset + vm.expectRevert("ARM: Invalid swap assets"); + lidoARM.swapTokensForExactTokens(weth, IERC20(address(0x1234)), 1 ether, 1 ether, alice); + } + + function test_SwapTokensForExactTokens_RevertWhen_InsufficientLiquidity() public { + // Not enough liquidity - no active market and the swap amount exceeds the ARM's balance. + uint256 amountIn = weth.balanceOf(address(lidoARM)) + 1 ether; + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapTokensForExactTokens(steth, weth, amountIn, 0, alice); + + // Route excess WETH through the mock market so the swap must pull the shortfall from it. + vm.startPrank(governor); + address[] memory markets = new address[](1); + markets[0] = address(mockERC4626Market); + lidoARM.addMarkets(markets); + lidoARM.setActiveMarket(address(mockERC4626Market)); + // 0.5e18 = 50% buffer kept in the ARM. + lidoARM.setARMBuffer(0.5 ether); + lidoARM.allocate(); + vm.stopPrank(); + + // Still not enough liquidity - the market buffer plus the ARM's balance is insufficient. + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapTokensForExactTokens(steth, weth, amountIn, 0, alice); + + vm.prank(governor); + lidoARM.setPrices(address(steth), 992 * 1e33, 1001 * 1e33, 1 ether, 2 ether); + // Not enough buy liquidity at this price. + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapTokensForExactTokens(steth, weth, 10 ether, 0, alice); + + // Not enough sell liquidity at this price. + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapTokensForExactTokens(weth, steth, 10 ether, 0, alice); + + vm.prank(governor); + lidoARM.setPrices(address(steth), 20e32, 1e36, 10 ether, 5 ether); + deal(address(steth), address(lidoARM), 10 ether); + // The buy-side cap check should run before fee accrual, even when the requested output would overflow fees. + vm.expectRevert("ARM: Insufficient liquidity"); + lidoARM.swapTokensForExactTokens(weth, steth, 7 ether, type(uint256).max, alice); + } + + function test_SwapTokensForExactTokens_RevertWhen_ExcessInputAmount() public { + uint256 amountOut = 1 ether; + uint256 amountInMax = 1 ether; + deal(address(steth), alice, 2 ether); + + // Direct overload: + // swapTokensForExactTokens(IERC20,IERC20,uint256,uint256,address). + vm.prank(alice); + vm.expectRevert("ARM: Excess input amount"); + lidoARM.swapTokensForExactTokens(steth, weth, amountOut, amountInMax, alice); + + // Route through the Uniswap V2-compatible overload: + // swapTokensForExactTokens(uint256,uint256,address[],address,uint256). + address[] memory path = new address[](2); + path[0] = address(steth); + path[1] = address(weth); + + deal(address(steth), alice, 2 ether); + + vm.prank(alice); + vm.expectRevert("ARM: Excess input amount"); + lidoARM.swapTokensForExactTokens(amountOut, amountInMax, path, alice, block.timestamp); + } + + function test_SwapTokensForExactTokens_RevertWhen_InvalidPathLength() public { + address[] memory path = new address[](3); + vm.expectRevert("ARM: Invalid path length"); + lidoARM.swapTokensForExactTokens(0, 0, path, alice, 0); + } + + function test_SwapTokensForExactTokens_RevertWhen_DeadlineExpired() public { + address[] memory path = new address[](2); + path[0] = address(steth); + path[1] = address(weth); + vm.expectRevert("ARM: Deadline expired"); + lidoARM.swapTokensForExactTokens(0, 0, path, alice, block.timestamp - 1); + } + + function test_SwapTokensForExactTokens_RevertWhen_Paused() public { + vm.prank(governor); + lidoARM.pause(); + + vm.expectRevert(AbstractARM.ContractPaused.selector); + lidoARM.swapTokensForExactTokens(steth, weth, 1 ether, 0, alice); + + vm.expectRevert(AbstractARM.ContractPaused.selector); + lidoARM.swapTokensForExactTokens(1 ether, 0, new address[](2), alice, block.timestamp); + } +} diff --git a/test/unit/LidoARM/concrete/adapters/AbstractLidoAssetAdapter.t.sol b/test/unit/LidoARM/concrete/adapters/AbstractLidoAssetAdapter.t.sol new file mode 100644 index 00000000..5b6b8d48 --- /dev/null +++ b/test/unit/LidoARM/concrete/adapters/AbstractLidoAssetAdapter.t.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../../Shared.t.sol"; + +// Contracts +import {AbstractLidoAssetAdapter} from "contracts/adapters/AbstractLidoAssetAdapter.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +/// @notice Test-only concrete adapter that exposes injectable values for the +/// abstract's two virtual hooks. Lets `_splitShares` be driven through +/// its two defensive branches: +/// - `splitShares > remainingShares` (cap to remaining) +/// - `splitShares == 0` (proportional fallback) +/// Neither branch is reachable through `StETHAssetAdapter` (1:1) or +/// `WstETHAssetAdapter` (rate ≥ 1) under self-consistent unwrap math. +contract TestableLidoAdapter is AbstractLidoAssetAdapter { + uint256 public mockAssetsOut; // value returned by _pullSharesAndConvertToSteth + uint256 public mockAssetsToSharesRate; // _assetsToShares(x) = x * rate / 1e18 + bool public mockReturnZero; // if true, _assetsToShares unconditionally returns 0 + + constructor(address _arm, address _weth, address _steth, address _queue) + AbstractLidoAssetAdapter(_arm, _weth, _steth, _queue) + {} + + function setMockAssetsOut(uint256 v) external { + mockAssetsOut = v; + } + + function setMockAssetsToSharesRate(uint256 v) external { + mockAssetsToSharesRate = v; + } + + function setMockReturnZero(bool v) external { + mockReturnZero = v; + } + + // Required by IAssetAdapter; unused by the tests in this file. + function convertToAssets(uint256 shares) external pure returns (uint256) { + return shares; + } + + function convertToShares(uint256 assets) external pure returns (uint256) { + return assets; + } + + function _pullSharesAndConvertToSteth( + address owner, + uint256 /*shares*/ + ) + internal + override + returns (uint256 assetsOut) + { + assetsOut = mockAssetsOut; + IERC20(address(steth)).transferFrom(owner, address(this), assetsOut); + } + + function _assetsToShares(uint256 assets) internal view override returns (uint256) { + if (mockReturnZero) return 0; + return assets * mockAssetsToSharesRate / 1e18; + } +} + +/// @notice Targeted branch coverage for `AbstractLidoAssetAdapter._splitShares`. +/// The two defensive branches inside the loop are unreachable from the +/// shipped concrete adapters under consistent math, so we drive them +/// through a `TestableLidoAdapter` that injects arbitrary values for +/// `_pullSharesAndConvertToSteth` and `_assetsToShares`. +contract Unit_LidoARM_AbstractLidoAssetAdapter_Test is Unit_LidoARM_Shared_Test { + TestableLidoAdapter internal testableAdapter; + + function setUp() public override { + super.setUp(); + + // Use the test contract itself as the simulated ARM. No prank needed: msg.sender + // during direct calls into the adapter is already `address(this)`. + testableAdapter = new TestableLidoAdapter({ + _arm: address(this), _weth: address(weth), _steth: address(steth), _queue: address(lidoWithdrawalQueue) + }); + testableAdapter.initialize(); + + // Seed stETH at the simulated ARM and approve the adapter to pull it. + deal(address(steth), address(this), 10_000 ether); + steth.approve(address(testableAdapter), type(uint256).max); + } + + ////////////////////////////////////////////////////// + /// --- _splitShares branch coverage + ////////////////////////////////////////////////////// + + /// @notice Hits `if (splitShares > remainingShares) splitShares = remainingShares;` + /// on a non-final chunk while keeping the capped value > 0 (so the + /// zero-fallback branch is not entered). + /// Setup: assetsOut = 2500e18 → 3 chunks of 1000/1000/500. With rate + /// 1.0, `_assetsToShares(1000e18) = 1000e18`. Passing only 1500e18 + /// totalShares means at i=1 we have remainingShares=500e18 but the + /// computed splitShares=1000e18 — overshooting and triggering the cap. + function test_SplitShares_CapsWhenSplitExceedsRemaining() public { + testableAdapter.setMockAssetsOut(2_500 ether); + testableAdapter.setMockAssetsToSharesRate(1e18); + + uint256 totalShares = 1_500 ether; + + testableAdapter.requestRedeem(totalShares); + + assertEq(testableAdapter.pendingRequestIdsLength(), 3, "chunks"); + uint256 id0 = testableAdapter.pendingRequestId(0); + uint256 id1 = testableAdapter.pendingRequestId(1); + uint256 id2 = testableAdapter.pendingRequestId(2); + + // i=0: splitShares=1000e18 ≤ remainingShares=1500e18 → no cap. shareSplits[0]=1000e18. + assertEq(testableAdapter.requestShares(id0), 1_000 ether, "chunk0 not capped"); + // i=1: splitShares=1000e18 > remainingShares=500e18 → cap to 500e18. + assertEq(testableAdapter.requestShares(id1), 500 ether, "chunk1 capped to remainingShares"); + // i=2 (last): absorbs the (zero) remainder. + assertEq(testableAdapter.requestShares(id2), 0, "chunk2 absorbs zero remainder"); + + // Invariant: sum equals totalShares. + uint256 sum = testableAdapter.requestShares(id0) + testableAdapter.requestShares(id1) + + testableAdapter.requestShares(id2); + assertEq(sum, totalShares, "sum == totalShares"); + } + + /// @notice Hits `if (splitShares == 0) splitShares = remainingShares * amounts[i] / remainingAssets;` + /// on a non-final chunk. Drive it by forcing `_assetsToShares` to + /// return zero — the fallback then assigns a proportional split. + /// Setup: assetsOut = 2500e18 → 3 chunks of 1000/1000/500. + /// totalShares = 500e18. With mockReturnZero, every non-final + /// iteration enters the fallback branch. + function test_SplitShares_FallbackWhenSplitIsZero() public { + testableAdapter.setMockAssetsOut(2_500 ether); + testableAdapter.setMockReturnZero(true); + + uint256 totalShares = 500 ether; + + testableAdapter.requestRedeem(totalShares); + + assertEq(testableAdapter.pendingRequestIdsLength(), 3, "chunks"); + uint256 id0 = testableAdapter.pendingRequestId(0); + uint256 id1 = testableAdapter.pendingRequestId(1); + uint256 id2 = testableAdapter.pendingRequestId(2); + + // i=0: splitShares = 0 → fallback = remainingShares(500) * amounts[0](1000) / remainingAssets(2500) = 200. + assertEq(testableAdapter.requestShares(id0), 200 ether, "chunk0 fallback"); + // i=1: remainingShares=300, remainingAssets=1500. fallback = 300 * 1000 / 1500 = 200. + assertEq(testableAdapter.requestShares(id1), 200 ether, "chunk1 fallback"); + // i=2 (last): shareSplits[2] = remainingShares = 100. + assertEq(testableAdapter.requestShares(id2), 100 ether, "chunk2 takes remainder"); + + // Invariant: sum equals totalShares. + uint256 sum = testableAdapter.requestShares(id0) + testableAdapter.requestShares(id1) + + testableAdapter.requestShares(id2); + assertEq(sum, totalShares, "sum == totalShares"); + } + + ////////////////////////////////////////////////////// + /// --- requestRedeem edge cases (documented behavior) + ////////////////////////////////////////////////////// + + /// @notice Documents the silent no-op when `_pullSharesAndConvertToSteth` returns 0 + /// (the underlying conversion produced no stETH at all — e.g. an extreme rate- + /// down scenario on wstETH where shares are too small to round to any stETH). + /// The current adapter returns `(sharesRequested = shares, assetsExpected = 0)` + /// without queueing anything or reverting. The caller (LidoARM) increments + /// `pendingRedeemAssets` by 0 — so the user's shares are NOT actually queued + /// despite the non-zero `sharesRequested` return. This is a known sharp edge, + /// and this test pins the behavior so a future fix becomes a deliberate change. + function test_RequestRedeem_ZeroAssetsOut_IsSilentNoOp() public { + testableAdapter.setMockAssetsOut(0); + testableAdapter.setMockAssetsToSharesRate(1e18); + + uint256 shares = 100 ether; + uint256 stethBefore = steth.balanceOf(address(this)); + uint256 queueStethBefore = steth.balanceOf(address(lidoWithdrawalQueue)); + + (uint256 sharesRequested, uint256 assetsExpected) = testableAdapter.requestRedeem(shares); + + // Return values: sharesRequested echoes the input despite the no-op. + assertEq(sharesRequested, shares, "sharesRequested echoes input even on no-op"); + assertEq(assetsExpected, 0, "assetsExpected reflects the zero conversion"); + + // No tokens moved: nothing pulled from the simulated ARM, nothing forwarded to the queue. + assertEq(steth.balanceOf(address(this)), stethBefore, "ARM stETH unchanged"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), queueStethBefore, "queue stETH unchanged"); + assertEq(steth.balanceOf(address(testableAdapter)), 0, "adapter stETH still zero"); + + // No queue entries created. + assertEq(testableAdapter.pendingRequestIdsLength(), 0, "no pending ids registered"); + + // Calling redeem afterwards reverts (no requests to claim), confirming nothing leaked + // into adapter state. + vm.expectRevert("Adapter: no pending requests"); + testableAdapter.redeem(shares); + } +} diff --git a/test/unit/LidoARM/concrete/adapters/StETHAssetAdapter.t.sol b/test/unit/LidoARM/concrete/adapters/StETHAssetAdapter.t.sol new file mode 100644 index 00000000..0729382d --- /dev/null +++ b/test/unit/LidoARM/concrete/adapters/StETHAssetAdapter.t.sol @@ -0,0 +1,528 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../../Shared.t.sol"; + +// Contracts +import {AbstractLidoAssetAdapter} from "contracts/adapters/AbstractLidoAssetAdapter.sol"; + +/// @notice Direct unit tests for `AbstractLidoAssetAdapter` exercised through +/// `StETHAssetAdapter` (1:1 share/asset math). Covers the adapter +/// contract in isolation by pranking `address(lidoARM)` — the ARM-side +/// flow already has coverage in `BaseAssetRedeem.t.sol`. +contract Unit_LidoARM_StETHAssetAdapter_Test is Unit_LidoARM_Shared_Test { + uint256 internal constant ARM_STETH_BALANCE = 5_000 ether; + + function setUp() public override { + super.setUp(); + desactiveCapManager(); + // addBaseAsset registers the adapter and sets the ARM → adapter approval for stETH. + addBaseAsset(steth); + // Seed the ARM with enough stETH to cover the largest multi-chunk request in this file. + deal(address(steth), address(lidoARM), ARM_STETH_BALANCE); + } + + ////////////////////////////////////////////////////// + /// --- initialize / view / approvals + ////////////////////////////////////////////////////// + function test_Initialize_RevertWhen_AlreadyInitialized() public { + vm.expectRevert(); // OZ Initializable: InvalidInitialization + stETHAssetAdapter.initialize(); + } + + function test_Asset_ReturnsWeth() public view { + assertEq(stETHAssetAdapter.asset(), address(weth), "asset"); + } + + function test_StETHApprovalToQueueIsMax() public view { + assertEq( + steth.allowance(address(stETHAssetAdapter), address(lidoWithdrawalQueue)), + type(uint256).max, + "stETH allowance adapter -> queue" + ); + } + + ////////////////////////////////////////////////////// + /// --- modifiers + ////////////////////////////////////////////////////// + function test_RequestRedeem_RevertWhen_NotARM() public { + vm.prank(alice); + vm.expectRevert("Adapter: only ARM"); + stETHAssetAdapter.requestRedeem(1 ether); + } + + function test_RequestRedeem_RevertWhen_ZeroShares() public { + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: zero shares"); + stETHAssetAdapter.requestRedeem(0); + } + + function test_Redeem_RevertWhen_NotARM() public { + vm.prank(alice); + vm.expectRevert("Adapter: only ARM"); + stETHAssetAdapter.redeem(1 ether); + } + + function test_Redeem_RevertWhen_ZeroShares() public { + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: zero shares"); + stETHAssetAdapter.redeem(0); + } + + ////////////////////////////////////////////////////// + /// --- requestRedeem — single chunk + ////////////////////////////////////////////////////// + function test_RequestRedeem_SingleChunk_500Ether() public { + uint256 shares = 500 ether; + + // Pre + assertEq(steth.balanceOf(address(lidoARM)), ARM_STETH_BALANCE, "ARM stETH pre"); + assertEq(steth.balanceOf(address(stETHAssetAdapter)), 0, "adapter stETH pre"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), 0, "queue stETH pre"); + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 0, "pendingIds pre"); + + // When + vm.prank(address(lidoARM)); + (uint256 sharesRequested, uint256 assetsExpected) = stETHAssetAdapter.requestRedeem(shares); + + // Then — return values (1:1 math) + assertEq(sharesRequested, shares, "sharesRequested"); + assertEq(assetsExpected, shares, "assetsExpected"); + + // stETH flowed ARM → withdrawal queue, adapter holds no residual stETH. + assertEq(steth.balanceOf(address(lidoARM)), ARM_STETH_BALANCE - shares, "ARM stETH post"); + assertEq(steth.balanceOf(address(stETHAssetAdapter)), 0, "adapter stETH post"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), shares, "queue stETH post"); + + // Adapter storage tracks the new request. + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 1, "pendingIds post"); + uint256 id = stETHAssetAdapter.pendingRequestId(0); + assertEq(stETHAssetAdapter.requestShares(id), shares, "requestShares"); + assertEq(stETHAssetAdapter.requestAssets(id), shares, "requestAssets"); + + // Queue recorded the request against the adapter as the owner. + (address owner, uint256 amount,, bool finalized) = lidoWithdrawalQueue.requests(id); + assertEq(owner, address(stETHAssetAdapter), "request.owner"); + assertEq(amount, shares, "request.amount"); + assertTrue(finalized, "request.finalized"); + } + + function test_RequestRedeem_ExactBoundary_1000Ether() public { + uint256 shares = 1_000 ether; + + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(shares); + + // Exactly one chunk; no second request created. + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 1, "single chunk at boundary"); + assertEq(lidoWithdrawalQueue.counter(), 1, "queue counter"); + + uint256 id = stETHAssetAdapter.pendingRequestId(0); + assertEq(stETHAssetAdapter.requestAssets(id), shares, "single chunk amount == request"); + } + + function test_RequestRedeem_JustAboveBoundary_1001Ether() public { + uint256 shares = 1_001 ether; + + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(shares); + + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 2, "two chunks above boundary"); + + uint256 id0 = stETHAssetAdapter.pendingRequestId(0); + uint256 id1 = stETHAssetAdapter.pendingRequestId(1); + assertEq(stETHAssetAdapter.requestAssets(id0), 1_000 ether, "chunk0 amount"); + assertEq(stETHAssetAdapter.requestAssets(id1), 1 ether, "chunk1 amount"); + + // Share splits sum back to total (1:1). + assertEq( + stETHAssetAdapter.requestShares(id0) + stETHAssetAdapter.requestShares(id1), shares, "share splits sum" + ); + } + + function test_RequestRedeem_MultiChunk_2500Ether() public { + uint256 shares = 2_500 ether; + + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(shares); + + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 3, "three chunks"); + + uint256 sumAssets; + uint256 sumShares; + uint256[3] memory expectedAmounts = [uint256(1_000 ether), uint256(1_000 ether), uint256(500 ether)]; + for (uint256 i; i < 3; ++i) { + uint256 id = stETHAssetAdapter.pendingRequestId(i); + assertEq(stETHAssetAdapter.requestAssets(id), expectedAmounts[i], "chunk amount"); + sumAssets += stETHAssetAdapter.requestAssets(id); + sumShares += stETHAssetAdapter.requestShares(id); + } + assertEq(sumAssets, shares, "sum chunk amounts == request"); + assertEq(sumShares, shares, "sum chunk shares == request"); + } + + function test_RequestRedeem_TwoSequentialCalls() public { + vm.startPrank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(300 ether); + stETHAssetAdapter.requestRedeem(200 ether); + vm.stopPrank(); + + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 2, "pendingIds after two calls"); + uint256 id0 = stETHAssetAdapter.pendingRequestId(0); + uint256 id1 = stETHAssetAdapter.pendingRequestId(1); + assertTrue(id0 != id1, "ids distinct"); + assertEq(stETHAssetAdapter.requestAssets(id0), 300 ether, "first chunk"); + assertEq(stETHAssetAdapter.requestAssets(id1), 200 ether, "second chunk"); + } + + ////////////////////////////////////////////////////// + /// --- redeem — happy paths + ////////////////////////////////////////////////////// + function test_Redeem_SingleRequest() public { + uint256 shares = 500 ether; + + vm.startPrank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(shares); + + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 id = stETHAssetAdapter.pendingRequestId(0); + + // When + (uint256 sharesClaimed, uint256 assetsExpected, uint256 assetsReceived) = stETHAssetAdapter.redeem(shares); + vm.stopPrank(); + + // Return values + assertEq(sharesClaimed, shares, "sharesClaimed"); + assertEq(assetsExpected, shares, "assetsExpected"); + assertEq(assetsReceived, shares, "assetsReceived"); + + // WETH lands on the ARM; adapter holds no residual ETH or WETH. + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + shares, "ARM weth post"); + assertEq(weth.balanceOf(address(stETHAssetAdapter)), 0, "adapter weth post"); + assertEq(address(stETHAssetAdapter).balance, 0, "adapter eth post"); + + // Mappings cleared and the queue marked the request as claimed. + assertEq(stETHAssetAdapter.requestShares(id), 0, "requestShares cleared"); + assertEq(stETHAssetAdapter.requestAssets(id), 0, "requestAssets cleared"); + (,, bool claimed,) = lidoWithdrawalQueue.requests(id); + assertTrue(claimed, "queue.claimed"); + } + + function test_Redeem_MultipleRequests_FullDrain() public { + vm.startPrank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(400 ether); + stETHAssetAdapter.requestRedeem(600 ether); + + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 id0 = stETHAssetAdapter.pendingRequestId(0); + uint256 id1 = stETHAssetAdapter.pendingRequestId(1); + + (uint256 sharesClaimed,, uint256 assetsReceived) = stETHAssetAdapter.redeem(1_000 ether); + vm.stopPrank(); + + assertEq(sharesClaimed, 1_000 ether, "sharesClaimed"); + assertEq(assetsReceived, 1_000 ether, "assetsReceived"); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + 1_000 ether, "ARM weth post"); + + // Both mappings cleared. + assertEq(stETHAssetAdapter.requestShares(id0), 0, "id0 cleared"); + assertEq(stETHAssetAdapter.requestShares(id1), 0, "id1 cleared"); + } + + function test_Redeem_PartialDrain_FirstChunkOnly() public { + // Single requestRedeem(2500) creates three chunks: 1000/1000/500. + vm.startPrank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(2_500 ether); + + uint256 id0 = stETHAssetAdapter.pendingRequestId(0); + uint256 id1 = stETHAssetAdapter.pendingRequestId(1); + uint256 id2 = stETHAssetAdapter.pendingRequestId(2); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + + // Redeem only the first chunk's shares. + (uint256 sharesClaimed,, uint256 assetsReceived) = stETHAssetAdapter.redeem(1_000 ether); + vm.stopPrank(); + + assertEq(sharesClaimed, 1_000 ether, "sharesClaimed"); + assertEq(assetsReceived, 1_000 ether, "assetsReceived"); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + 1_000 ether, "ARM weth post"); + + // Only id0 cleared; id1 and id2 still queued. + assertEq(stETHAssetAdapter.requestShares(id0), 0, "id0 cleared"); + assertEq(stETHAssetAdapter.requestShares(id1), 1_000 ether, "id1 retained"); + assertEq(stETHAssetAdapter.requestShares(id2), 500 ether, "id2 retained"); + + // pendingRequestIds array length is unchanged; the index moved forward. + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 3, "pendingIds length unchanged"); + } + + function test_Redeem_WrapsEthToWeth() public { + uint256 shares = 750 ether; + + vm.startPrank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(shares); + + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + stETHAssetAdapter.redeem(shares); + vm.stopPrank(); + + assertEq(address(stETHAssetAdapter).balance, 0, "adapter eth post"); + assertEq(weth.balanceOf(address(stETHAssetAdapter)), 0, "adapter weth post"); + assertEq(weth.balanceOf(address(lidoARM)) - armWethBefore, shares, "ARM weth delta == eth received"); + } + + ////////////////////////////////////////////////////// + /// --- redeem — revert branch coverage + ////////////////////////////////////////////////////// + function test_Redeem_RevertWhen_NoPendingRequests() public { + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: no pending requests"); + stETHAssetAdapter.redeem(1 ether); + } + + function test_Redeem_RevertWhen_FirstUnfinalized() public { + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(500 ether); + + uint256 id0 = stETHAssetAdapter.pendingRequestId(0); + lidoWithdrawalQueue.mock_setFinalized(id0, false); + + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: redeem exceeds claimable"); + stETHAssetAdapter.redeem(500 ether); + } + + function test_Redeem_RevertWhen_FirstAlreadyClaimed() public { + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(500 ether); + + uint256 id0 = stETHAssetAdapter.pendingRequestId(0); + lidoWithdrawalQueue.mock_setClaimed(id0, true); + + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: redeem exceeds claimable"); + stETHAssetAdapter.redeem(500 ether); + } + + function test_Redeem_RevertWhen_FirstOwnerChanged() public { + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(500 ether); + + uint256 id0 = stETHAssetAdapter.pendingRequestId(0); + lidoWithdrawalQueue.mock_setOwner(id0, alice); + + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: redeem exceeds claimable"); + stETHAssetAdapter.redeem(500 ether); + } + + function test_Redeem_StopsAtFirstUnfinalized() public { + vm.startPrank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(300 ether); + stETHAssetAdapter.requestRedeem(200 ether); + vm.stopPrank(); + + uint256 id1 = stETHAssetAdapter.pendingRequestId(1); + lidoWithdrawalQueue.mock_setFinalized(id1, false); + + // Redeeming the first id's shares still succeeds; loop breaks before consuming id1. + vm.prank(address(lidoARM)); + (uint256 sharesClaimed,,) = stETHAssetAdapter.redeem(300 ether); + assertEq(sharesClaimed, 300 ether, "claimed first only"); + + // Redeeming further reverts since id1 is still un-finalized. + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: redeem exceeds claimable"); + stETHAssetAdapter.redeem(200 ether); + } + + function test_Redeem_RevertWhen_InvalidRedeemAmount_FirstChunkOvershoots() public { + // 1500 → chunks of 1000 + 500. Redeeming 700 overshoots the first chunk. + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(1_500 ether); + + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: invalid redeem amount"); + stETHAssetAdapter.redeem(700 ether); + } + + function test_Redeem_RevertWhen_InvalidRedeemAmount_BetweenChunks() public { + // 1500 → chunks of 1000 + 500. Redeeming 1200 lands between the two chunk totals. + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(1_500 ether); + + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: invalid redeem amount"); + stETHAssetAdapter.redeem(1_200 ether); + } + + ////////////////////////////////////////////////////// + /// --- claimableRedeem + ////////////////////////////////////////////////////// + function test_ClaimableRedeem_ZeroWhenEmpty() public view { + (uint256 shares, uint256 assets) = stETHAssetAdapter.claimableRedeem(); + assertEq(shares, 0, "claimable shares"); + assertEq(assets, 0, "claimable assets"); + } + + function test_ClaimableRedeem_AllFinalized_ReturnsSum() public { + vm.startPrank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(300 ether); + stETHAssetAdapter.requestRedeem(200 ether); + vm.stopPrank(); + + (uint256 shares, uint256 assets) = stETHAssetAdapter.claimableRedeem(); + assertEq(shares, 500 ether, "claimable shares"); + assertEq(assets, 500 ether, "claimable assets"); + } + + function test_ClaimableRedeem_StopsAtFirstUnfinalized() public { + vm.startPrank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(300 ether); + stETHAssetAdapter.requestRedeem(200 ether); + vm.stopPrank(); + + uint256 id1 = stETHAssetAdapter.pendingRequestId(1); + lidoWithdrawalQueue.mock_setFinalized(id1, false); + + (uint256 shares, uint256 assets) = stETHAssetAdapter.claimableRedeem(); + assertEq(shares, 300 ether, "claimable shares"); + assertEq(assets, 300 ether, "claimable assets"); + } + + function test_ClaimableRedeem_UpdatesAfterPartialRedeem() public { + vm.startPrank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(300 ether); + stETHAssetAdapter.requestRedeem(200 ether); + + // Drain the first request; nextPendingIndex advances by 1. + stETHAssetAdapter.redeem(300 ether); + vm.stopPrank(); + + (uint256 shares, uint256 assets) = stETHAssetAdapter.claimableRedeem(); + assertEq(shares, 200 ether, "remaining claimable shares"); + assertEq(assets, 200 ether, "remaining claimable assets"); + } + + ////////////////////////////////////////////////////// + /// --- getters + ////////////////////////////////////////////////////// + function test_PendingRequestIdsLength_GrowsWithRequests() public { + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 0, "initial"); + + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(500 ether); + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 1, "after single-chunk request"); + + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(1_500 ether); + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 3, "after two-chunk request"); + } + + function test_PendingRequestId_IndexableAndOrdered() public { + vm.prank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(2_500 ether); + + // Mock counter increments by one per chunk, so ids are 0,1,2 in order. + assertEq(stETHAssetAdapter.pendingRequestId(0), 0, "id at index 0"); + assertEq(stETHAssetAdapter.pendingRequestId(1), 1, "id at index 1"); + assertEq(stETHAssetAdapter.pendingRequestId(2), 2, "id at index 2"); + } + + function test_PendingRequestId_RevertWhen_OutOfBounds() public { + // Empty array — index 0 is out of bounds. + vm.expectRevert(); + stETHAssetAdapter.pendingRequestId(0); + } + + ////////////////////////////////////////////////////// + /// --- state machine integration + ////////////////////////////////////////////////////// + + /// @notice End-to-end state-machine check across two requests with progressive finalization + /// and three sequential claims. Catches integration-level bugs that the isolated + /// partial-drain / stops-at-unfinalized / claimableRedeem tests can't, because here + /// every state transition must compose with the next one — `nextPendingIndex` must + /// advance exactly, mappings must clear at the right moment, and `claimableRedeem` + /// must track finalization toggles in both directions. + function test_StateMachine_MultiRequestMixedFinalization() public { + // --- Setup: two requests, three queue ids (0 from req1, 1+2 from req2). + vm.startPrank(address(lidoARM)); + stETHAssetAdapter.requestRedeem(500 ether); // id 0 + stETHAssetAdapter.requestRedeem(1_500 ether); // ids 1 and 2 (chunks 1000 + 500) + vm.stopPrank(); + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 3, "3 queue ids registered"); + + uint256 id0 = stETHAssetAdapter.pendingRequestId(0); + uint256 id1 = stETHAssetAdapter.pendingRequestId(1); + uint256 id2 = stETHAssetAdapter.pendingRequestId(2); + + // --- Roll id 1 back to un-finalized; id 0 and id 2 stay finalized. + // This shape (finalized, NOT-finalized, finalized) is the strongest test for the + // adapter's "stop at first non-finalized" rule: id 2 is finalizable but unreachable + // until id 1 catches up, because claims are strictly FIFO. + lidoWithdrawalQueue.mock_setFinalized(id1, false); + + // Stage 1: claimable should include id 0 only (loop breaks at id 1). + { + (uint256 cShares, uint256 cAssets) = stETHAssetAdapter.claimableRedeem(); + assertEq(cShares, 500 ether, "stage1 claimable shares == id0"); + assertEq(cAssets, 500 ether, "stage1 claimable assets == id0"); + } + + // Stage 2: redeem id 0; mappings for id 0 clear, id 1 and id 2 mappings untouched. + vm.prank(address(lidoARM)); + (uint256 sc1,, uint256 ar1) = stETHAssetAdapter.redeem(500 ether); + assertEq(sc1, 500 ether, "stage2 sharesClaimed"); + assertEq(ar1, 500 ether, "stage2 assetsReceived"); + assertEq(stETHAssetAdapter.requestShares(id0), 0, "id0 mapping cleared"); + assertEq(stETHAssetAdapter.requestShares(id1), 1_000 ether, "id1 mapping intact"); + assertEq(stETHAssetAdapter.requestShares(id2), 500 ether, "id2 mapping intact"); + // The pendingRequestIds array is append-only; only the read cursor advances. + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 3, "pendingIds length still 3"); + + // Stage 3: with id 1 still un-finalized, claimable == 0 even though id 2 IS finalized. + // This is the FIFO property: id 2 cannot be skipped over id 1. + { + (uint256 cShares, uint256 cAssets) = stETHAssetAdapter.claimableRedeem(); + assertEq(cShares, 0, "stage3 claimable shares == 0 (id1 blocks id2)"); + assertEq(cAssets, 0, "stage3 claimable assets == 0"); + } + + // Stage 4: any redeem attempt now reverts on the FIFO check. + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: redeem exceeds claimable"); + stETHAssetAdapter.redeem(1_000 ether); + + // Stage 5: finalize id 1. claimable jumps to id 1 + id 2 in a single step. + lidoWithdrawalQueue.mock_setFinalized(id1, true); + { + (uint256 cShares, uint256 cAssets) = stETHAssetAdapter.claimableRedeem(); + assertEq(cShares, 1_500 ether, "stage5 claimable shares == id1 + id2"); + assertEq(cAssets, 1_500 ether, "stage5 claimable assets == id1 + id2"); + } + + // Stage 6: redeem id 1 alone (not id 1 + id 2 together). Cursor advances by exactly one. + vm.prank(address(lidoARM)); + stETHAssetAdapter.redeem(1_000 ether); + assertEq(stETHAssetAdapter.requestShares(id1), 0, "id1 mapping cleared"); + assertEq(stETHAssetAdapter.requestShares(id2), 500 ether, "id2 mapping intact after id1 claim"); + + // Stage 7: claim id 2. Adapter is fully drained but the array remains length 3. + vm.prank(address(lidoARM)); + stETHAssetAdapter.redeem(500 ether); + assertEq(stETHAssetAdapter.requestShares(id2), 0, "id2 mapping cleared"); + { + (uint256 cShares, uint256 cAssets) = stETHAssetAdapter.claimableRedeem(); + assertEq(cShares, 0, "stage7 claimable shares == 0 (drained)"); + assertEq(cAssets, 0, "stage7 claimable assets == 0"); + } + assertEq(stETHAssetAdapter.pendingRequestIdsLength(), 3, "pendingIds length unchanged after full drain"); + + // Stage 8: any further redeem reverts with the empty-queue message (cursor == length). + vm.prank(address(lidoARM)); + vm.expectRevert("Adapter: no pending requests"); + stETHAssetAdapter.redeem(1 ether); + } +} diff --git a/test/unit/LidoARM/concrete/adapters/WstETHAssetAdapter.t.sol b/test/unit/LidoARM/concrete/adapters/WstETHAssetAdapter.t.sol new file mode 100644 index 00000000..6f49392a --- /dev/null +++ b/test/unit/LidoARM/concrete/adapters/WstETHAssetAdapter.t.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../../Shared.t.sol"; + +/// @notice Non-1:1 unit tests for `AbstractLidoAssetAdapter` exercised through +/// `WstETHAssetAdapter`. Coverage of the wrap/unwrap path and the +/// `_splitShares` arithmetic that only matters when 1 share != 1 asset. +/// The fixture seeds the wrapper so 1 wstETH = 1.237 stETH. +/// +/// NOTE: `_splitShares` also has two defensive branches (`splitShares > +/// remainingShares` cap and `splitShares == 0` fallback) that are +/// unreachable with a stETH-per-wstETH rate >= 1. Those are covered via +/// a test-only adapter in `AbstractLidoAssetAdapter.t.sol`. +contract Unit_LidoARM_WstETHAssetAdapter_Test is Unit_LidoARM_Shared_Test { + uint256 internal constant ARM_WSTETH_BALANCE = 5_000 ether; + + function setUp() public override { + super.setUp(); + desactiveCapManager(); + // 1 wstETH = 1.237 stETH after seeding. Must run BEFORE dealWsteth so the + // exchange rate is set when the helper computes the stETH required to mint. + seedWstETHWithTargetExchangeRate(); + addBaseAsset(wsteth); + dealWsteth(address(lidoARM), ARM_WSTETH_BALANCE); + } + + ////////////////////////////////////////////////////// + /// --- sanity + ////////////////////////////////////////////////////// + function test_Asset_ReturnsWeth() public view { + assertEq(wstETHAssetAdapter.asset(), address(weth), "asset"); + } + + function test_StETHApprovalToQueueIsMax() public view { + assertEq( + steth.allowance(address(wstETHAssetAdapter), address(lidoWithdrawalQueue)), + type(uint256).max, + "stETH allowance adapter -> queue" + ); + } + + ////////////////////////////////////////////////////// + /// --- requestRedeem — non-1:1 conversion + ////////////////////////////////////////////////////// + function test_RequestRedeem_NonOneToOne_SingleChunk() public { + uint256 shares = 100 ether; // 100 wstETH + uint256 expectedStETH = 123.7 ether; // 100 * 1.237 + + // Pre + assertEq(wsteth.balanceOf(address(lidoARM)), ARM_WSTETH_BALANCE, "ARM wstETH pre"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), 0, "queue stETH pre"); + + // When + vm.prank(address(lidoARM)); + (uint256 sharesRequested, uint256 assetsExpected) = wstETHAssetAdapter.requestRedeem(shares); + + // Return values: shares are wstETH; assets are stETH (the asset the queue receives). + assertEq(sharesRequested, shares, "sharesRequested"); + assertEq(assetsExpected, expectedStETH, "assetsExpected (stETH)"); + + // Token flow: ARM lost wstETH; adapter holds no residual wstETH or stETH; + // queue received the unwrapped stETH amount. + assertEq(wsteth.balanceOf(address(lidoARM)), ARM_WSTETH_BALANCE - shares, "ARM wstETH post"); + assertEq(wsteth.balanceOf(address(wstETHAssetAdapter)), 0, "adapter wstETH post"); + assertEq(steth.balanceOf(address(wstETHAssetAdapter)), 0, "adapter stETH post"); + assertEq(steth.balanceOf(address(lidoWithdrawalQueue)), expectedStETH, "queue stETH post"); + + // Storage: single chunk records full shares against the stETH amount. + assertEq(wstETHAssetAdapter.pendingRequestIdsLength(), 1, "pendingIds"); + uint256 id = wstETHAssetAdapter.pendingRequestId(0); + assertEq(wstETHAssetAdapter.requestShares(id), shares, "requestShares"); + assertEq(wstETHAssetAdapter.requestAssets(id), expectedStETH, "requestAssets"); + } + + function test_RequestRedeem_NonOneToOne_MultiChunk() public { + // 900 wstETH → 1113.3 stETH → two chunks of 1000 + 113.3. + uint256 shares = 900 ether; + uint256 expectedStETH = 1_113.3 ether; + + vm.prank(address(lidoARM)); + (, uint256 assetsExpected) = wstETHAssetAdapter.requestRedeem(shares); + assertEq(assetsExpected, expectedStETH, "assetsExpected total"); + + assertEq(wstETHAssetAdapter.pendingRequestIdsLength(), 2, "two chunks"); + uint256 id0 = wstETHAssetAdapter.pendingRequestId(0); + uint256 id1 = wstETHAssetAdapter.pendingRequestId(1); + + // Asset amounts: capped at MAX_WITHDRAWAL_AMOUNT then the remainder. + assertEq(wstETHAssetAdapter.requestAssets(id0), 1_000 ether, "chunk0 stETH"); + assertEq(wstETHAssetAdapter.requestAssets(id1), 113.3 ether, "chunk1 stETH"); + + // _splitShares: per-chunk share count rounds down; the final chunk absorbs + // the rounding remainder. The invariant is `sum(shareSplits) == totalShares`. + uint256 s0 = wstETHAssetAdapter.requestShares(id0); + uint256 s1 = wstETHAssetAdapter.requestShares(id1); + assertGt(s0, 0, "chunk0 shares non-zero"); + assertGt(s1, 0, "chunk1 shares non-zero"); + assertEq(s0 + s1, shares, "share splits sum == totalShares"); + } + + function test_RequestRedeem_SplitShares_ThreeChunks() public { + // 2000 wstETH → 2474 stETH → three chunks of 1000 + 1000 + 474. + uint256 shares = 2_000 ether; + uint256 expectedStETH = 2_474 ether; + + vm.prank(address(lidoARM)); + (, uint256 assetsExpected) = wstETHAssetAdapter.requestRedeem(shares); + assertEq(assetsExpected, expectedStETH, "assetsExpected total"); + + assertEq(wstETHAssetAdapter.pendingRequestIdsLength(), 3, "three chunks"); + + uint256[3] memory expectedAmounts = [uint256(1_000 ether), uint256(1_000 ether), uint256(474 ether)]; + + uint256 sumShares; + uint256 sumAssets; + for (uint256 i; i < 3; ++i) { + uint256 id = wstETHAssetAdapter.pendingRequestId(i); + assertEq(wstETHAssetAdapter.requestAssets(id), expectedAmounts[i], "chunk asset amount"); + uint256 chunkShares = wstETHAssetAdapter.requestShares(id); + assertGt(chunkShares, 0, "chunk shares non-zero"); + sumShares += chunkShares; + sumAssets += wstETHAssetAdapter.requestAssets(id); + } + assertEq(sumAssets, expectedStETH, "sum asset amounts == total stETH"); + assertEq(sumShares, shares, "sum share splits == totalShares (remainder absorbed by last chunk)"); + } + + ////////////////////////////////////////////////////// + /// --- redeem — non-1:1 full cycle + ////////////////////////////////////////////////////// + function test_Redeem_NonOneToOne_FullCycle() public { + uint256 shares = 100 ether; // 100 wstETH + uint256 expectedStETH = 123.7 ether; + + vm.startPrank(address(lidoARM)); + wstETHAssetAdapter.requestRedeem(shares); + + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + (uint256 sharesClaimed, uint256 assetsExpected, uint256 assetsReceived) = wstETHAssetAdapter.redeem(shares); + vm.stopPrank(); + + assertEq(sharesClaimed, shares, "sharesClaimed (wstETH)"); + assertEq(assetsExpected, expectedStETH, "assetsExpected (stETH)"); + assertEq(assetsReceived, expectedStETH, "assetsReceived (WETH)"); + + // ARM receives WETH equal to the unwrapped stETH amount, NOT the wstETH share count. + assertEq(weth.balanceOf(address(lidoARM)) - armWethBefore, expectedStETH, "ARM WETH delta"); + assertEq(weth.balanceOf(address(wstETHAssetAdapter)), 0, "adapter WETH post"); + assertEq(address(wstETHAssetAdapter).balance, 0, "adapter ETH post"); + } + + function test_Redeem_NonOneToOne_PartialDrain() public { + // Multi-chunk request; redeem only the first chunk's shares so we exercise + // the partial-drain path with non-1:1 share/asset arithmetic. + uint256 shares = 900 ether; + + vm.startPrank(address(lidoARM)); + wstETHAssetAdapter.requestRedeem(shares); + + uint256 id0 = wstETHAssetAdapter.pendingRequestId(0); + uint256 id1 = wstETHAssetAdapter.pendingRequestId(1); + uint256 firstChunkShares = wstETHAssetAdapter.requestShares(id0); + uint256 firstChunkAssets = wstETHAssetAdapter.requestAssets(id0); // 1000 stETH + uint256 secondChunkShares = wstETHAssetAdapter.requestShares(id1); + + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + (uint256 sharesClaimed,, uint256 assetsReceived) = wstETHAssetAdapter.redeem(firstChunkShares); + vm.stopPrank(); + + assertEq(sharesClaimed, firstChunkShares, "sharesClaimed == first chunk shares"); + assertEq(assetsReceived, firstChunkAssets, "assetsReceived == first chunk stETH"); + assertEq(weth.balanceOf(address(lidoARM)) - armWethBefore, firstChunkAssets, "ARM WETH delta"); + + // id0 cleared, id1 retained — nextPendingIndex moved forward by exactly one. + assertEq(wstETHAssetAdapter.requestShares(id0), 0, "id0 cleared"); + assertEq(wstETHAssetAdapter.requestShares(id1), secondChunkShares, "id1 retained"); + assertEq(wstETHAssetAdapter.pendingRequestIdsLength(), 2, "pendingIds length unchanged"); + } +} diff --git a/test/unit/LidoARM/fuzz/ClaimRedeem.fuzz.t.sol b/test/unit/LidoARM/fuzz/ClaimRedeem.fuzz.t.sol new file mode 100644 index 00000000..52438144 --- /dev/null +++ b/test/unit/LidoARM/fuzz/ClaimRedeem.fuzz.t.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Libraries +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +/// @author Origin Protocol Inc +/// @notice Fuzzes LP claim flows across share amounts, post-request yield, post-request loss, and +/// claim warp duration to confirm the request struct, payout math (min of request-time vs +/// claim-time value), and reserved-liquidity release stay consistent. +contract Unit_Fuzz_LidoARM_ClaimRedeem_Test is Unit_LidoARM_Shared_Test { + using Math for uint256; + + ////////////////////////////////////////////////////// + /// --- SETUP --- + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + desactiveCapManager(); + aliceFirstDeposit(100 ether); + } + + ////////////////////////////////////////////////////// + /// --- Fuzz share count, no PnL change --- + ////////////////////////////////////////////////////// + function testFuzz_ClaimRedeem_Shares(uint128 fuzzedShares) public { + uint256 shares = _bound(uint256(fuzzedShares), 1, 100 ether); + (uint256 requestId, uint256 requestAssets) = aliceRequest(shares); + // At 1:1 the request locks exactly `shares` assets. + assertEq(requestAssets, shares, "requestAssets == shares at 1:1"); + + skip(CLAIM_DELAY); + + uint256 supplyBefore = lidoARM.totalSupply(); + uint256 reservedBefore = lidoARM.reservedWithdrawLiquidity(); + uint256 claimedSharesBefore = lidoARM.withdrawsClaimedShares(); + + // Expect events + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(lidoARM), address(0), shares); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, requestAssets); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemClaimed(alice, requestId, requestAssets); + + // When + vm.prank(alice); + uint256 assets = lidoARM.claimRedeem(requestId); + + // Then + assertEq(assets, requestAssets, "assets returned"); + assertEq(weth.balanceOf(alice), requestAssets, "alice weth"); + assertEq(lidoARM.balanceOf(alice), 100 ether - shares, "alice shares"); + assertEq(lidoARM.balanceOf(address(lidoARM)), 0, "escrow burned"); + assertEq(lidoARM.totalSupply(), supplyBefore - shares, "totalSupply"); + assertEq(lidoARM.totalAssets(), supplyBefore - shares, "totalAssets matches at 1:1"); + assertEq(lidoARM.reservedWithdrawLiquidity(), reservedBefore - requestAssets, "reserved released"); + assertEq(lidoARM.withdrawsClaimedShares(), claimedSharesBefore + shares, "withdrawsClaimedShares"); + + (, bool claimed,,,,) = lidoARM.withdrawalRequests(requestId); + assertTrue(claimed, "request marked claimed"); + } + + ////////////////////////////////////////////////////// + /// --- Fuzz yield between request and claim --- + ////////////////////////////////////////////////////// + function testFuzz_ClaimRedeem_GainAfterRequest(uint128 fuzzedYield) public { + // Bring in a second LP so the post-claim share price uplift has a holder to land on; without + // Bobby the only shares left after Alice's claim are the dead-account ones, making the property + // check less expressive. + bobbyFirstDeposit(100 ether); + + (uint256 requestId, uint256 requestAssets) = aliceRequest(50 ether); + assertEq(requestAssets, 50 ether, "requestAssets at 1:1"); + + // Lower yield bound at 1 ether so the post-claim share price gain is visible after integer + // truncation. Upper bound at uint96.max keeps arithmetic well clear of uint128 overflow paths. + uint256 yield = _bound(uint256(fuzzedYield), 1 ether, type(uint96).max); + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) + yield); + + skip(CLAIM_DELAY); + + // Sanity: claim-time conversion would value Alice's shares strictly above the request, so the + // min() at AbstractARM.sol:813-815 selects the request-time value. + assertGt(lidoARM.convertToAssets(50 ether), requestAssets, "claim-time value above request"); + + // Expect events + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(lidoARM), address(0), 50 ether); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, requestAssets); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemClaimed(alice, requestId, requestAssets); + + // When + vm.prank(alice); + uint256 assets = lidoARM.claimRedeem(requestId); + + // Then + assertEq(assets, requestAssets, "assets returned = request value"); + assertEq(weth.balanceOf(alice), requestAssets, "alice weth"); + assertEq(lidoARM.balanceOf(alice), 50 ether, "alice remaining shares"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved released"); + assertEq(lidoARM.withdrawsClaimedShares(), 50 ether, "withdrawsClaimedShares"); + + // The yield stays with the remaining LPs: share price strictly above 1 after the claim. + assertGt(lidoARM.convertToAssets(1 ether), 1 ether, "share price > 1 after claim"); + + (, bool claimed,,,,) = lidoARM.withdrawalRequests(requestId); + assertTrue(claimed, "request marked claimed"); + } + + ////////////////////////////////////////////////////// + /// --- Fuzz loss between request and claim --- + ////////////////////////////////////////////////////// + function testFuzz_ClaimRedeem_LossAfterRequest(uint128 fuzzedLoss) public { + // Alice requests ALL of her shares so the entire pre-loss assets are reserved. + (uint256 requestId, uint256 requestAssets) = aliceRequest(100 ether); + assertEq(requestAssets, 100 ether, "requestAssets at 1:1"); + + // Bound the loss strictly below the LP-provided assets so the totalAssets() clamp at + // AbstractARM.sol:901 stays inactive; this keeps the simple mulDiv expectation valid. + uint256 loss = _bound(uint256(fuzzedLoss), 1, 100 ether - 1); + vm.prank(address(lidoARM)); + weth.transfer(address(0), loss); + + skip(CLAIM_DELAY); + + // Expected payout computed via mulDiv with Floor — algebraically identical to the contract's + // `shares * totalAssets() / totalSupply()` (no overflow since 100e18 * (1e12 + 100e18) ≪ 2^256). + // Equality must be exact, no rounding tolerance, so a bug that flips num/denom, uses post-burn + // supply, or changes rounding direction would diverge here. + uint256 totalAssetsAfterLoss = MIN_TOTAL_SUPPLY + 100 ether - loss; + uint256 totalSupplyAtClaim = MIN_TOTAL_SUPPLY + 100 ether; + uint256 expectedPayout = + uint256(100 ether).mulDiv(totalAssetsAfterLoss, totalSupplyAtClaim, Math.Rounding.Floor); + // Property: loss > 0 ⇒ claim-time value strictly below request-time value, so min() selects it. + assertLt(expectedPayout, requestAssets, "loss should reduce payout below request"); + // Cross-check: the contract's own claim-time conversion matches the test formula. + assertEq(lidoARM.convertToAssets(100 ether), expectedPayout, "convertToAssets matches expected"); + + // Expect events (loss path: payout < requestAssets) + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(lidoARM), address(0), 100 ether); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedPayout); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemClaimed(alice, requestId, expectedPayout); + + // When + vm.prank(alice); + uint256 assets = lidoARM.claimRedeem(requestId); + + // Then: exact equality — contract and test compute the same floor division. + assertEq(assets, expectedPayout, "assets returned"); + assertEq(weth.balanceOf(alice), expectedPayout, "alice weth"); + + // reservedWithdrawLiquidity is decreased by request.assets (the full reservation), not by the + // loss-adjusted payout. See AbstractARM.sol:820. + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved released in full"); + assertEq(lidoARM.withdrawsClaimedShares(), 100 ether, "withdrawsClaimedShares"); + assertEq(lidoARM.balanceOf(alice), 0, "alice shares"); + assertEq(lidoARM.balanceOf(address(lidoARM)), 0, "escrow burned"); + + (, bool claimed,,,,) = lidoARM.withdrawalRequests(requestId); + assertTrue(claimed, "request marked claimed"); + } + + ////////////////////////////////////////////////////// + /// --- Fuzz claim warp duration --- + ////////////////////////////////////////////////////// + function testFuzz_ClaimRedeem_ClaimTimestamp(uint64 fuzzedWarp) public { + (uint256 requestId, uint256 requestAssets) = aliceRequest(50 ether); + + // Domain restricted to [CLAIM_DELAY, CLAIM_DELAY + 365 days]; we never fuzz into the revert + // zone (consistent with Swap.fuzz.t.sol's no-revert convention). + uint256 warp = _bound(uint256(fuzzedWarp), CLAIM_DELAY, CLAIM_DELAY + 365 days); + skip(warp); + + // Expect events + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(lidoARM), address(0), 50 ether); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, requestAssets); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemClaimed(alice, requestId, requestAssets); + + // When + vm.prank(alice); + uint256 assets = lidoARM.claimRedeem(requestId); + + // Then + assertEq(assets, requestAssets, "assets returned"); + assertEq(weth.balanceOf(alice), requestAssets, "alice weth"); + assertEq(lidoARM.reservedWithdrawLiquidity(), 0, "reserved released"); + assertEq(lidoARM.withdrawsClaimedShares(), 50 ether, "withdrawsClaimedShares"); + + (, bool claimed,,,,) = lidoARM.withdrawalRequests(requestId); + assertTrue(claimed, "request marked claimed"); + } +} diff --git a/test/unit/LidoARM/fuzz/Deposit.fuzz.t.sol b/test/unit/LidoARM/fuzz/Deposit.fuzz.t.sol new file mode 100644 index 00000000..26c0213b --- /dev/null +++ b/test/unit/LidoARM/fuzz/Deposit.fuzz.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Libraries +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +/// @author Origin Protocol Inc +/// @notice Fuzzes LP deposits at three share-price regimes (1:1, post-yield, post-loss) to confirm the +/// ERC-4626-style mint formula, balances, and totalAssets/totalSupply accounting stay consistent +/// across the full amount range. +contract Unit_Fuzz_LidoARM_Deposit_Test is Unit_LidoARM_Shared_Test { + using Math for uint256; + + ////////////////////////////////////////////////////// + /// --- SETUP --- + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + desactiveCapManager(); + } + + ////////////////////////////////////////////////////// + /// --- Share price = 1 --- + ////////////////////////////////////////////////////// + function testFuzz_Deposit_Amount(uint128 amount) public { + // 1:1 regime: only the MIN_TOTAL_SUPPLY dead-shares exist, so shares == assets exactly. + // Upper bound is uint128.max because there is no SafeCast on deposit; the only revert path is + // `ARM: insolvent`, which cannot fire here (reservedWithdrawLiquidity == 0). + uint256 amountIn = _bound(uint256(amount), 1, type(uint128).max); + + uint256 supplyBefore = lidoARM.totalSupply(); + uint256 assetsBefore = lidoARM.totalAssets(); + // Expected shares computed via the same mulDiv as the contract; written explicitly so any future + // change to convertToShares (e.g. rounding direction) shows up here. + uint256 expectedShares = amountIn.mulDiv(supplyBefore, assetsBefore, Math.Rounding.Floor); + // Sanity: in the 1:1 setup state shares must equal assets. + assertEq(expectedShares, amountIn, "expectedShares == amountIn at 1:1"); + + deal(address(weth), alice, amountIn); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), alice, expectedShares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.Deposit(alice, amountIn, expectedShares); + + // When + vm.prank(alice); + uint256 shares = lidoARM.deposit(amountIn); + + // Then + assertEq(shares, expectedShares, "shares returned"); + assertEq(lidoARM.balanceOf(alice), expectedShares, "alice shares"); + assertEq(weth.balanceOf(alice), 0, "alice weth"); + assertEq(weth.balanceOf(address(lidoARM)), assetsBefore + amountIn, "arm weth"); + assertEq(lidoARM.totalAssets(), assetsBefore + amountIn, "totalAssets"); + assertEq(lidoARM.totalSupply(), supplyBefore + expectedShares, "totalSupply"); + } + + ////////////////////////////////////////////////////// + /// --- Share price > 1 --- + ////////////////////////////////////////////////////// + function testFuzz_Deposit_AfterYield(uint128 fuzzedYield, uint128 amount) public { + aliceFirstDeposit(100 ether); + + // Lower yield bound at 1 ether so the share price is meaningfully above 1; below this, integer + // truncation can collapse expectedShares back to amountIn on small deposits. + // Upper bound at uint96.max keeps (supply + yield) safely inside uint128 downstream. + uint256 yield = _bound(uint256(fuzzedYield), 1 ether, type(uint96).max); + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) + yield); + + uint256 amountIn = _bound(uint256(amount), 1, type(uint128).max); + deal(address(weth), alice, amountIn); + + uint256 supplyBefore = lidoARM.totalSupply(); + uint256 assetsBefore = lidoARM.totalAssets(); + uint256 aliceSharesBefore = lidoARM.balanceOf(alice); + uint256 expectedShares = amountIn.mulDiv(supplyBefore, assetsBefore, Math.Rounding.Floor); + + // Property: yield > 0 ⇒ totalSupply < totalAssets ⇒ floor(amountIn * S / A) < amountIn strictly, + // since amountIn * S < amountIn * A and integer division can only floor. Holds for amountIn >= 1. + assertLt(expectedShares, amountIn, "shares < amountIn after yield"); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), alice, expectedShares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.Deposit(alice, amountIn, expectedShares); + + // When + vm.prank(alice); + uint256 shares = lidoARM.deposit(amountIn); + + // Then + assertEq(shares, expectedShares, "shares returned"); + assertEq(lidoARM.balanceOf(alice), aliceSharesBefore + expectedShares, "alice shares"); + assertEq(weth.balanceOf(alice), 0, "alice weth"); + assertEq(lidoARM.totalAssets(), assetsBefore + amountIn, "totalAssets"); + assertEq(lidoARM.totalSupply(), supplyBefore + expectedShares, "totalSupply"); + } + + ////////////////////////////////////////////////////// + /// --- Share price < 1 --- + ////////////////////////////////////////////////////// + function testFuzz_Deposit_AfterLoss(uint128 fuzzedLoss, uint128 amount) public { + aliceFirstDeposit(100 ether); + + // Bound loss strictly below the LP liquid asset to keep totalAssets > MIN_TOTAL_SUPPLY, otherwise + // the totalAssets() clamp at AbstractARM.sol:901 kicks in and the simple mulDiv expectation no + // longer matches the contract's view. The insolvency require passes because reservedWithdrawLiquidity == 0. + uint256 loss = _bound(uint256(fuzzedLoss), 1, 100 ether - 1); + vm.prank(address(lidoARM)); + weth.transfer(address(0), loss); + + uint256 amountIn = _bound(uint256(amount), 1, type(uint128).max); + deal(address(weth), alice, amountIn); + + uint256 supplyBefore = lidoARM.totalSupply(); + uint256 assetsBefore = lidoARM.totalAssets(); + uint256 aliceSharesBefore = lidoARM.balanceOf(alice); + uint256 expectedShares = amountIn.mulDiv(supplyBefore, assetsBefore, Math.Rounding.Floor); + + // Property: loss > 0 ⇒ share price < 1 ⇒ shares >= amountIn. Equality only on inputs small enough + // for the spread to truncate; for any non-trivial amountIn the inequality is strict. + assertGe(expectedShares, amountIn, "shares >= amountIn after loss"); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(address(0), alice, expectedShares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.Deposit(alice, amountIn, expectedShares); + + // When + vm.prank(alice); + uint256 shares = lidoARM.deposit(amountIn); + + // Then + assertEq(shares, expectedShares, "shares returned"); + assertEq(lidoARM.balanceOf(alice), aliceSharesBefore + expectedShares, "alice shares"); + assertEq(weth.balanceOf(alice), 0, "alice weth"); + assertEq(lidoARM.totalAssets(), assetsBefore + amountIn, "totalAssets"); + assertEq(lidoARM.totalSupply(), supplyBefore + expectedShares, "totalSupply"); + } +} diff --git a/test/unit/LidoARM/fuzz/RequestRedeem.fuzz.t.sol b/test/unit/LidoARM/fuzz/RequestRedeem.fuzz.t.sol new file mode 100644 index 00000000..ccf78222 --- /dev/null +++ b/test/unit/LidoARM/fuzz/RequestRedeem.fuzz.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Contracts +import {AbstractARM} from "contracts/AbstractARM.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Libraries +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +/// @author Origin Protocol Inc +/// @notice Fuzzes LP redeem requests across share counts, yield levels, and sequential request splits +/// to confirm the request struct, cumulative `queued` tracking, and `reservedWithdrawLiquidity` +/// all stay consistent. +contract Unit_Fuzz_LidoARM_RequestRedeem_Test is Unit_LidoARM_Shared_Test { + using Math for uint256; + + ////////////////////////////////////////////////////// + /// --- SETUP --- + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + desactiveCapManager(); + aliceFirstDeposit(100 ether); + } + + ////////////////////////////////////////////////////// + /// --- Fuzz share count --- + ////////////////////////////////////////////////////// + function testFuzz_RequestRedeem_Shares(uint128 fuzzedShares) public { + // requestRedeem does not enforce MIN_SHARES_TO_REDEEM, so the lower bound is 1 wei. + uint256 shares = _bound(uint256(fuzzedShares), 1, lidoARM.balanceOf(alice)); + + uint256 supplyBefore = lidoARM.totalSupply(); + uint256 assetsBefore = lidoARM.totalAssets(); + uint256 expectedAssets = shares.mulDiv(assetsBefore, supplyBefore, Math.Rounding.Floor); + uint256 expectedClaimTimestamp = block.timestamp + CLAIM_DELAY; + + assertEq(lidoARM.previewRedeem(shares), expectedAssets, "previewRedeem"); + + // Expect events + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(alice, address(lidoARM), shares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemRequested(alice, 0, expectedAssets, shares, expectedClaimTimestamp); + + // When + vm.prank(alice); + (uint256 requestId, uint256 assets) = lidoARM.requestRedeem(shares); + + // Then + assertEq(requestId, 0, "requestId"); + assertEq(assets, expectedAssets, "assets returned"); + assertEq(lidoARM.balanceOf(alice), 100 ether - shares, "alice shares"); + assertEq(lidoARM.balanceOf(address(lidoARM)), shares, "escrow"); + assertEq(lidoARM.totalSupply(), supplyBefore, "totalSupply unchanged"); + assertEq(lidoARM.totalAssets(), assetsBefore, "totalAssets unchanged"); + assertEq(lidoARM.nextWithdrawalIndex(), 1, "nextWithdrawalIndex"); + assertEq(lidoARM.withdrawsQueuedShares(), shares, "withdrawsQueuedShares"); + assertEq(lidoARM.reservedWithdrawLiquidity(), expectedAssets, "reservedWithdrawLiquidity"); + + _assertStoredRequest(0, alice, expectedClaimTimestamp, expectedAssets, shares, shares); + } + + ////////////////////////////////////////////////////// + /// --- Fuzz yield level --- + ////////////////////////////////////////////////////// + function testFuzz_RequestRedeem_Yield(uint128 fuzzedYield) public { + // Lower bound at 1 ether so the share-price uplift is large enough to survive truncation on + // a 50 ether redeem (otherwise expectedAssets == shares). Upper bound at uint96.max so the + // SafeCast on `assets` inside requestRedeem (l. 787) never reverts. + uint256 yield = _bound(uint256(fuzzedYield), 1 ether, type(uint96).max); + deal(address(weth), address(lidoARM), weth.balanceOf(address(lidoARM)) + yield); + + uint256 shares = 50 ether; + uint256 supplyBefore = lidoARM.totalSupply(); + uint256 assetsBefore = lidoARM.totalAssets(); + uint256 expectedAssets = shares.mulDiv(assetsBefore, supplyBefore, Math.Rounding.Floor); + uint256 expectedClaimTimestamp = block.timestamp + CLAIM_DELAY; + + // Property: yield > 0 ⇒ assets per share > 1 ⇒ expectedAssets > shares for non-truncating yields. + assertGt(expectedAssets, shares, "yield should grow assets above shares"); + + assertEq(lidoARM.previewRedeem(shares), expectedAssets, "previewRedeem"); + + // Expect events + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(alice, address(lidoARM), shares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemRequested(alice, 0, expectedAssets, shares, expectedClaimTimestamp); + + // When + vm.prank(alice); + (uint256 requestId, uint256 assets) = lidoARM.requestRedeem(shares); + + // Then + assertEq(requestId, 0, "requestId"); + assertEq(assets, expectedAssets, "assets returned"); + assertEq(lidoARM.balanceOf(alice), 100 ether - shares, "alice shares"); + assertEq(lidoARM.balanceOf(address(lidoARM)), shares, "escrow"); + assertEq(lidoARM.totalSupply(), supplyBefore, "totalSupply unchanged"); + assertEq(lidoARM.totalAssets(), assetsBefore, "totalAssets unchanged"); + assertEq(lidoARM.nextWithdrawalIndex(), 1, "nextWithdrawalIndex"); + assertEq(lidoARM.withdrawsQueuedShares(), shares, "withdrawsQueuedShares"); + assertEq(lidoARM.reservedWithdrawLiquidity(), expectedAssets, "reservedWithdrawLiquidity"); + + _assertStoredRequest(0, alice, expectedClaimTimestamp, expectedAssets, shares, shares); + } + + ////////////////////////////////////////////////////// + /// --- Fuzz split between two requests --- + ////////////////////////////////////////////////////// + function testFuzz_RequestRedeem_Sequential(uint128 fuzzedSplit) public { + // Two consecutive requests sharing alice's 100 ether of shares. Probes the cumulative `queued` + // tracking used by the FIFO gate at claim time. + uint256 totalShares = 100 ether; + uint256 firstShares = _bound(uint256(fuzzedSplit), 1, totalShares - 1); + uint256 secondShares = totalShares - firstShares; + + uint256 expectedClaimTimestamp = block.timestamp + CLAIM_DELAY; + + // First request + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(alice, address(lidoARM), firstShares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemRequested(alice, 0, firstShares, firstShares, expectedClaimTimestamp); + + vm.prank(alice); + (uint256 firstRequestId, uint256 firstAssets) = lidoARM.requestRedeem(firstShares); + + // Second request + vm.expectEmit({emitter: address(lidoARM)}); + emit IERC20.Transfer(alice, address(lidoARM), secondShares); + vm.expectEmit({emitter: address(lidoARM)}); + emit AbstractARM.RedeemRequested(alice, 1, secondShares, totalShares, expectedClaimTimestamp); + + vm.prank(alice); + (uint256 secondRequestId, uint256 secondAssets) = lidoARM.requestRedeem(secondShares); + + // Then + assertEq(firstRequestId, 0, "firstRequestId"); + assertEq(firstAssets, firstShares, "firstAssets at 1:1"); + assertEq(secondRequestId, 1, "secondRequestId"); + assertEq(secondAssets, secondShares, "secondAssets at 1:1"); + + assertEq(lidoARM.balanceOf(alice), 0, "alice shares"); + assertEq(lidoARM.balanceOf(address(lidoARM)), totalShares, "escrow"); + assertEq(lidoARM.nextWithdrawalIndex(), 2, "nextWithdrawalIndex"); + assertEq(lidoARM.withdrawsQueuedShares(), totalShares, "withdrawsQueuedShares"); + assertEq(lidoARM.reservedWithdrawLiquidity(), totalShares, "reservedWithdrawLiquidity"); + + // First request: queued cumulative == firstShares. + _assertStoredRequest(0, alice, expectedClaimTimestamp, firstShares, firstShares, firstShares); + // Second request: queued cumulative == totalShares (firstShares + secondShares). + _assertStoredRequest(1, alice, expectedClaimTimestamp, secondShares, totalShares, secondShares); + } +} diff --git a/test/unit/LidoARM/fuzz/Swap.fuzz.t.sol b/test/unit/LidoARM/fuzz/Swap.fuzz.t.sol new file mode 100644 index 00000000..a6b2fae3 --- /dev/null +++ b/test/unit/LidoARM/fuzz/Swap.fuzz.t.sol @@ -0,0 +1,574 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../Shared.t.sol"; + +// Interfaces +import {IERC20} from "contracts/Interfaces.sol"; + +// Libraries +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +/// @author Origin Protocol Inc +/// @notice Fuzzes both exact-input and exact-output swaps between the Lido ARM liquidity asset and +/// stETH to confirm price, fee accrual, balances, liquidity accounting, and totalAssets all +/// behave consistently across the input/output and price ranges. +contract Unit_Fuzz_LidoARM_Swap_Test is Unit_LidoARM_Shared_Test { + using Math for uint256; + + ////////////////////////////////////////////////////// + /// --- CONSTANTS --- + ////////////////////////////////////////////////////// + // Buy price set in Shared.t.sol::addBaseAsset: 992 * 1e33 (i.e. 0.992 WETH per stETH). + uint256 internal constant BUY_PRICE_NUMERATOR = 992; + uint256 internal constant BUY_PRICE_DENOMINATOR = 1000; + + // Sell price set in Shared.t.sol::addBaseAsset: 1001 * 1e33 (i.e. 1.001 WETH per stETH). + uint256 internal constant SELL_PRICE_NUMERATOR = 1001; + uint256 internal constant SELL_PRICE_DENOMINATOR = 1000; + + // AbstractARM._swapTokensForExactTokens adds 3 wei to amountIn to absorb stETH rounding on + // larger transfers (observed up to 2 wei; 3 is for safety). + uint256 internal constant ROUNDING_BUFFER = 3; + + ////////////////////////////////////////////////////// + /// --- SETUP --- + ////////////////////////////////////////////////////// + function setUp() public override { + super.setUp(); + desactiveCapManager(); + addBaseAsset(steth); + addBaseAsset(wsteth); + seedWstETHWithTargetExchangeRate(); + } + + ////////////////////////////////////////////////////// + /// --- SwapExactTokensForTokens (exact input) --- + ////////////////////////////////////////////////////// + function testFuzz_SwapExactTokensForTokens_Steth_To_Weth_Amount(uint128 stethAmount) public { + deal(address(weth), address(lidoARM), 50_000 ether); + + // Bound the input so the ARM can pay the WETH it owes Alice without pulling from a market. + // The ARM's WETH balance is read live (Alice's deposit + the 1e12 minimum supply seed), + // so the cap automatically tracks setUp() changes. + // amountOut = amountIn * 992 / 1000, so cap amountIn at armWeth * 1000 / 992. + // Lower bound is 1 wei: the spread (amountIn - amountOut) is at least 1 wei for amountIn >= 1 + // because integer truncation of (amountIn * 992 / 1000) loses fractional WETH, which is required + // for the totalAssets-must-strictly-increase assertion to hold. + uint256 armWeth = weth.balanceOf(address(lidoARM)); + uint256 maxAmountIn = armWeth * BUY_PRICE_DENOMINATOR / BUY_PRICE_NUMERATOR; + uint256 amountIn = _bound(uint256(stethAmount), 1, maxAmountIn); + + // Expected output is computed without going through the contract's PRICE_SCALE path, so a + // bug that swaps numerator/denominator or changes the buy price would be caught here. + uint256 expectedAmountOut = amountIn.mulDiv(BUY_PRICE_NUMERATOR, BUY_PRICE_DENOMINATOR); + uint256 expectedTotalAssetsIncrease = amountIn - expectedAmountOut; + uint256 expectedFee = expectedTotalAssetsIncrease.mulDiv(DEFAULT_FEE, FEE_SCALE); + + // Sanity-check the bound so a future change cannot silently push amountOut above the ARM's + // WETH balance and turn revert paths into spurious failures. + assertLe(expectedAmountOut, armWeth, "amountOut exceeds ARM WETH balance"); + + deal(address(steth), alice, amountIn); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), amountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens(steth, weth, amountIn, expectedAmountOut, alice); + + // Then + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 1); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - expectedAmountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), expectedAmountOut); + assertEq(steth.balanceOf(alice), 0); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore - expectedAmountOut); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore + amountIn); + assertEq(amounts.length, 2); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 1); + } + + function testFuzz_SwapExactTokensForTokens_Steth_To_Weth_BuyPrice(uint128 fuzzedBuyPrice) public { + // Isolate the price dimension: amountIn is fixed so any failure points at the price/fee math + // rather than at amount bounding or liquidity exhaustion. + uint256 amountIn = 50 ether; + uint256 wethSeed = 50_000 ether; + deal(address(weth), address(lidoARM), wethSeed); + deal(address(steth), alice, amountIn); + + // Valid buyPrice range from AbstractARM._validatePrices: + // buyPrice >= MAX_CROSS_PRICE_DEVIATION (20e32) and buyPrice < crossPrice (1e36 here). + // Reuse the existing sellPrice from setUp() — _validatePrices also requires sellPrice >= crossPrice. + uint256 spread; + uint256 buyPriceFuzzed; + { + uint256 crossPriceCurrent = crossPrice(steth); + buyPriceFuzzed = _bound(uint256(fuzzedBuyPrice), MAX_CROSS_PRICE_DEVIATION, crossPriceCurrent - 1); + spread = crossPriceCurrent - buyPriceFuzzed; + + // Resolve every setPrices arg before the prank so no view-call between them consumes it. + uint128 sellPriceArg = uint128(sellPrice(steth)); + vm.prank(governor); + lidoARM.setPrices(address(steth), buyPriceFuzzed, sellPriceArg, type(uint128).max, type(uint128).max); + } + + // Expected output uses the same scaling as the contract because there is no algebraic + // shortcut once buyPrice is arbitrary. The value of the test sits in the fee formula below. + uint256 expectedAmountOut = amountIn * buyPriceFuzzed / PRICE_SCALE; + uint256 expectedTotalAssetsIncrease = amountIn - expectedAmountOut; + + // Fee derivation takes a different multiply/divide order than the contract: + // contract: fee = amountOut * floor((cross - buy) * feeRate * PRICE_SCALE / (buy * FEE_SCALE)) / PRICE_SCALE + // test: fee = floor(amountOut * (cross - buy) / buy) * feeRate / FEE_SCALE + // Both converge on the same mathematical value, so a bug that mangles the multiplier + // scaling, swaps numerator/denominator, or applies the fee on the wrong side of the spread + // will diverge here. + uint256 expectedFee = expectedAmountOut.mulDiv(spread, buyPriceFuzzed) * DEFAULT_FEE / FEE_SCALE; + + // Property guard: buyPrice < crossPrice guarantees amountOut < amountIn (trader pays the spread). + assertLt(expectedAmountOut, amountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens(steth, weth, amountIn, expectedAmountOut, alice); + + // Then + // Tolerance of 2 wei: the two formulas above each truncate at one step, so they can disagree + // by up to 1 wei from rounding, plus the contract's PRICE_SCALE intermediate truncation can + // shift the result by another wei at extreme prices. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 2); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - expectedAmountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), expectedAmountOut); + assertEq(steth.balanceOf(alice), 0); + // The ARM started with the WETH seed and zero stETH; the swap moves expectedAmountOut WETH out + // and amountIn stETH in. + assertEq(weth.balanceOf(address(lidoARM)), wethSeed - expectedAmountOut); + assertEq(steth.balanceOf(address(lidoARM)), amountIn); + assertEq(amounts.length, 2); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + // fee = gain * feeRate / FEE_SCALE algebraically, so with feeRate = 20% < 100% the gain + // always exceeds the fee and totalAssets must strictly increase. + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 2); + } + + function testFuzz_SwapExactTokensForTokens_Weth_To_Steth_Amount(uint128 wethAmount) public { + // Seed stETH liquidity so the ARM can pay out the base asset. The WETH side does not need + // seeding here because the trader is bringing WETH in. + uint256 armStethSeed = 50_000 ether; + deal(address(steth), address(lidoARM), armStethSeed); + + // Bound the input so amountOut never exceeds the ARM's stETH balance. + // amountOut = amountIn * 1000 / 1001 (PRICE_SCALE / sellPrice), so cap amountIn at + // armSteth * 1001 / 1000 to keep the swap inside the liquidity check. + // Lower bound is 1 wei: the spread (amountIn - amountOut) is at least 1 wei for amountIn >= 1 + // because integer truncation of (amountIn * 1000 / 1001) loses fractional stETH, which is + // required for the totalAssets-must-strictly-increase assertion to hold. + uint256 armSteth = steth.balanceOf(address(lidoARM)); + uint256 maxAmountIn = armSteth.mulDiv(SELL_PRICE_NUMERATOR, SELL_PRICE_DENOMINATOR); + uint256 amountIn = _bound(uint256(wethAmount), 1, maxAmountIn); + + // Expected output is computed without going through the contract's PRICE_SCALE path, so a + // bug that swaps numerator/denominator or changes the sell price would be caught here. + uint256 expectedAmountOut = amountIn.mulDiv(SELL_PRICE_DENOMINATOR, SELL_PRICE_NUMERATOR); + uint256 expectedTotalAssetsIncrease = amountIn - expectedAmountOut; + + // Sanity-check the bound so a future change cannot silently push amountOut above the ARM's + // stETH balance and turn revert paths into spurious failures. + assertLe(expectedAmountOut, armSteth, "amountOut exceeds ARM stETH balance"); + + deal(address(weth), alice, amountIn); + + uint256 feeAccruedBefore = lidoARM.feesAccrued(); + assertEq(weth.balanceOf(alice), amountIn); + uint256 stethBalanceBefore = steth.balanceOf(alice); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens(weth, steth, amountIn, expectedAmountOut, alice); + + // Then + // No fees on sell side: feesAccrued must stay exactly where it was. + assertEq(lidoARM.feesAccrued(), feeAccruedBefore); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore - expectedAmountOut); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), stethBalanceBefore + expectedAmountOut); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + amountIn); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore - expectedAmountOut); + assertEq(amounts.length, 2); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + assertEq(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease); + } + + function testFuzz_SwapExactTokensForTokens_Weth_To_Steth_SellPrice(uint128 fuzzedSellPrice) public { + // Isolate the price dimension: amountIn is fixed so any failure points at the price math + // rather than at amount bounding or liquidity exhaustion. + uint256 amountIn = 25 ether; + uint256 stethSeed = 50_000 ether; + deal(address(steth), address(lidoARM), stethSeed); + deal(address(weth), alice, amountIn); + + // Valid sellPrice range from AbstractARM._validatePrices: sellPrice >= crossPrice. + // Use crossPrice + 1 as the lower bound to guarantee a strictly positive spread, which is + // required for the totalAssets-must-strictly-increase assertion to hold. + // Reuse the existing buyPrice from setUp() — _validatePrices also requires buyPrice < crossPrice. + uint256 sellPriceFuzzed; + { + uint256 crossPriceCurrent = crossPrice(steth); + sellPriceFuzzed = _bound(uint256(fuzzedSellPrice), crossPriceCurrent + 1, type(uint128).max); + + // Resolve every setPrices arg before the prank so no view-call between them consumes it. + uint128 buyPriceArg = uint128(buyPrice(steth)); + vm.prank(governor); + lidoARM.setPrices( + address(steth), buyPriceArg, uint128(sellPriceFuzzed), type(uint128).max, type(uint128).max + ); + } + + // amountOut = amountIn * PRICE_SCALE / sellPrice (pegged base asset, no adapter conversion). + // Same code path as the contract: there is no algebraic shortcut once sellPrice is arbitrary. + // The value of the test is in checking the surrounding invariants across the full price range. + uint256 expectedAmountOut = amountIn.mulDiv(PRICE_SCALE, sellPriceFuzzed); + uint256 expectedTotalAssetsIncrease = amountIn - expectedAmountOut; + + // Property guard: sellPrice > crossPrice guarantees amountOut < amountIn (trader pays the spread). + assertLt(expectedAmountOut, amountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), amountIn); + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(address(lidoARM), alice, expectedAmountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapExactTokensForTokens(weth, steth, amountIn, expectedAmountOut, alice); + + // Then + // No fees on sell side: feesAccrued must stay at 0. + assertEq(lidoARM.feesAccrued(), 0); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore - expectedAmountOut); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), expectedAmountOut); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + amountIn); + assertEq(steth.balanceOf(address(lidoARM)), stethSeed - expectedAmountOut); + assertEq(amounts.length, 2); + assertEq(amounts[0], amountIn); + assertEq(amounts[1], expectedAmountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Exact equality on the sell side: no fee path runs, so no rounding tolerance is needed. + assertEq(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease); + } + + ////////////////////////////////////////////////////// + /// --- SwapTokensForExactTokens (exact output) --- + ////////////////////////////////////////////////////// + + function testFuzz_SwapTokensForExactTokens_Steth_To_Weth_Amount(uint128 wethAmount) public { + // Seed WETH liquidity so the ARM can pay out the exact amountOut to the trader. + uint256 wethSeed = 50_000 ether; + deal(address(weth), address(lidoARM), wethSeed); + + // amountOut (WETH) is bounded by the ARM's WETH balance. + // Lower bound is 1 wei: even at amountOut = 1, the 3 wei rounding buffer gives a positive + // spread so the totalAssets-must-strictly-increase assertion holds. + uint256 armWeth = weth.balanceOf(address(lidoARM)); + uint256 amountOut = _bound(uint256(wethAmount), 1, armWeth); + + // amountIn = amountOut * 1000 / 992 + 3 (mathematical equivalent of + // contract's amountOut * PRICE_SCALE / buyPrice + 3). Going through the simple ratio + // catches bugs that swap numerator/denominator or change the buy price. + uint256 expectedAmountIn = amountOut.mulDiv(BUY_PRICE_DENOMINATOR, BUY_PRICE_NUMERATOR) + ROUNDING_BUFFER; + uint256 expectedTotalAssetsIncrease = expectedAmountIn - amountOut; + uint256 expectedFee = expectedTotalAssetsIncrease.mulDiv(DEFAULT_FEE, FEE_SCALE); + + deal(address(steth), alice, expectedAmountIn); + + assertEq(lidoARM.feesAccrued(), 0); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), expectedAmountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens(steth, weth, amountOut, expectedAmountIn, alice); + + // Then + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 1); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - amountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), amountOut); + assertEq(steth.balanceOf(alice), 0); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore - amountOut); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore + expectedAmountIn); + assertEq(amounts.length, 2); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Note: Temporary 1 wei tolerance while the fee rounding issue is being fixed. + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 1); + } + + function testFuzz_SwapTokensForExactTokens_Steth_To_Weth_BuyPrice(uint128 fuzzedBuyPrice) public { + // Isolate the price dimension: amountOut is fixed so any failure points at the price/fee math + // rather than at amount bounding or liquidity exhaustion. + uint256 amountOut = 50 ether; + uint256 wethSeed = 50_000 ether; + deal(address(weth), address(lidoARM), wethSeed); + + // Valid buyPrice range from AbstractARM._validatePrices: + // buyPrice >= MAX_CROSS_PRICE_DEVIATION (20e32) and buyPrice < crossPrice (1e36 here). + uint256 spread; + uint256 buyPriceFuzzed; + { + uint256 crossPriceCurrent = crossPrice(steth); + buyPriceFuzzed = _bound(uint256(fuzzedBuyPrice), MAX_CROSS_PRICE_DEVIATION, crossPriceCurrent - 1); + spread = crossPriceCurrent - buyPriceFuzzed; + + // Resolve every setPrices arg before the prank so no view-call between them consumes it. + uint128 sellPriceArg = uint128(sellPrice(steth)); + vm.prank(governor); + lidoARM.setPrices(address(steth), buyPriceFuzzed, sellPriceArg, type(uint128).max, type(uint128).max); + } + + // expectedAmountIn uses the same scaling as the contract because there is no algebraic + // shortcut once buyPrice is arbitrary. The value of the test sits in the fee formula below. + uint256 expectedAmountIn = amountOut.mulDiv(PRICE_SCALE, buyPriceFuzzed) + ROUNDING_BUFFER; + uint256 expectedTotalAssetsIncrease = expectedAmountIn - amountOut; + + // Fee derivation takes a different multiply/divide order than the contract: + // contract: fee = amountOut * floor((cross - buy) * feeRate * PRICE_SCALE / (buy * FEE_SCALE)) / PRICE_SCALE + // test: fee = floor(amountOut * (cross - buy) / buy) * feeRate / FEE_SCALE + // Both converge on the same mathematical value, so a bug that mangles the multiplier + // scaling, swaps numerator/denominator, or applies the fee on the wrong side of the spread + // will diverge here. + uint256 expectedFee = amountOut.mulDiv(spread, buyPriceFuzzed) * DEFAULT_FEE / FEE_SCALE; + + // Property guard: buyPrice < crossPrice guarantees amountIn > amountOut (trader pays the spread). + assertGt(expectedAmountIn, amountOut); + + deal(address(steth), alice, expectedAmountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens(steth, weth, amountOut, expectedAmountIn, alice); + + // Then + // Tolerance of 2 wei: each formula truncates at a different step, so they can disagree by + // up to 1 wei from rounding, plus the contract's PRICE_SCALE intermediate truncation can + // shift the result by another wei at extreme prices. + assertApproxEqAbs(lidoARM.feesAccrued(), expectedFee, 2); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore - amountOut); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore); + assertEq(weth.balanceOf(alice), amountOut); + assertEq(steth.balanceOf(alice), 0); + // The ARM started with the WETH seed and zero stETH; the swap moves amountOut WETH out + // and expectedAmountIn stETH in. + assertEq(weth.balanceOf(address(lidoARM)), wethSeed - amountOut); + assertEq(steth.balanceOf(address(lidoARM)), expectedAmountIn); + assertEq(amounts.length, 2); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + // fee = gain * feeRate / FEE_SCALE algebraically, so with feeRate = 20% < 100% the gain + // always exceeds the fee and totalAssets must strictly increase. + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + assertApproxEqAbs(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease - expectedFee, 2); + } + + function testFuzz_SwapTokensForExactTokens_Weth_To_Steth_Amount(uint128 stethAmount) public { + // Seed stETH liquidity so the ARM can pay out the exact amountOut to the trader. + uint256 armStethSeed = 50_000 ether; + deal(address(steth), address(lidoARM), armStethSeed); + + // amountOut (stETH) is bounded by the ARM's stETH balance. + // Lower bound is 1 wei: even at amountOut = 1, the 3 wei rounding buffer gives a positive + // spread so the totalAssets-must-strictly-increase assertion holds. + uint256 armSteth = steth.balanceOf(address(lidoARM)); + uint256 amountOut = _bound(uint256(stethAmount), 1, armSteth); + + // amountIn = amountOut * 1001 / 1000 + 3 (mathematical equivalent of + // contract's amountOut * sellPrice / PRICE_SCALE + 3). Going through the simple ratio + // catches bugs that swap numerator/denominator or change the sell price. + uint256 expectedAmountIn = amountOut.mulDiv(SELL_PRICE_NUMERATOR, SELL_PRICE_DENOMINATOR) + ROUNDING_BUFFER; + uint256 expectedTotalAssetsIncrease = expectedAmountIn - amountOut; + + deal(address(weth), alice, expectedAmountIn); + + uint256 feeAccruedBefore = lidoARM.feesAccrued(); + assertEq(weth.balanceOf(alice), expectedAmountIn); + uint256 stethBalanceBefore = steth.balanceOf(alice); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 armStethBefore = steth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens(weth, steth, amountOut, expectedAmountIn, alice); + + // Then + // No fees on sell side: feesAccrued must stay exactly where it was. + assertEq(lidoARM.feesAccrued(), feeAccruedBefore); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore - amountOut); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), stethBalanceBefore + amountOut); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + expectedAmountIn); + assertEq(steth.balanceOf(address(lidoARM)), armStethBefore - amountOut); + assertEq(amounts.length, 2); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + assertEq(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease); + } + + function testFuzz_SwapTokensForExactTokens_Weth_To_Steth_SellPrice(uint128 fuzzedSellPrice) public { + // Isolate the price dimension: amountOut is fixed so any failure points at the price math + // rather than at amount bounding or liquidity exhaustion. + uint256 amountOut = 25 ether; + uint256 stethSeed = 50_000 ether; + deal(address(steth), address(lidoARM), stethSeed); + + // Valid sellPrice range from AbstractARM._validatePrices: sellPrice >= crossPrice. + // Use crossPrice + 1 as the lower bound to guarantee a strictly positive spread, which is + // required for the totalAssets-must-strictly-increase assertion. + uint256 sellPriceFuzzed; + { + uint256 crossPriceCurrent = crossPrice(steth); + sellPriceFuzzed = _bound(uint256(fuzzedSellPrice), crossPriceCurrent + 1, type(uint128).max); + + // Resolve every setPrices arg before the prank so no view-call between them consumes it. + uint128 buyPriceArg = uint128(buyPrice(steth)); + vm.prank(governor); + lidoARM.setPrices( + address(steth), buyPriceArg, uint128(sellPriceFuzzed), type(uint128).max, type(uint128).max + ); + } + + // amountIn = amountOut * sellPrice / PRICE_SCALE + 3 (pegged base asset, no adapter conversion). + // Same code path as the contract: there is no algebraic shortcut once sellPrice is arbitrary. + // The value of the test is in checking the surrounding invariants across the full price range. + uint256 expectedAmountIn = amountOut.mulDiv(sellPriceFuzzed, PRICE_SCALE) + ROUNDING_BUFFER; + uint256 expectedTotalAssetsIncrease = expectedAmountIn - amountOut; + + // Property guard: sellPrice > crossPrice guarantees amountIn > amountOut (trader pays the spread). + assertGt(expectedAmountIn, amountOut); + + deal(address(weth), alice, expectedAmountIn); + + uint256 buyLiquidityBefore = buyLiquidityRemaining(steth); + uint256 sellLiquidityBefore = sellLiquidityRemaining(steth); + uint256 armWethBefore = weth.balanceOf(address(lidoARM)); + uint256 totalAssetsBefore = lidoARM.totalAssets(); + + // Expect events + vm.expectEmit({emitter: address(weth)}); + emit IERC20.Transfer(alice, address(lidoARM), expectedAmountIn); + vm.expectEmit({emitter: address(steth)}); + emit IERC20.Transfer(address(lidoARM), alice, amountOut); + + // When + vm.prank(alice); + uint256[] memory amounts = lidoARM.swapTokensForExactTokens(weth, steth, amountOut, expectedAmountIn, alice); + + // Then + // No fees on sell side: feesAccrued must stay at 0. + assertEq(lidoARM.feesAccrued(), 0); + assertEq(buyLiquidityRemaining(steth), buyLiquidityBefore); + assertEq(sellLiquidityRemaining(steth), sellLiquidityBefore - amountOut); + assertEq(weth.balanceOf(alice), 0); + assertEq(steth.balanceOf(alice), amountOut); + assertEq(weth.balanceOf(address(lidoARM)), armWethBefore + expectedAmountIn); + assertEq(steth.balanceOf(address(lidoARM)), stethSeed - amountOut); + assertEq(amounts.length, 2); + assertEq(amounts[0], expectedAmountIn); + assertEq(amounts[1], amountOut); + assertGt(lidoARM.totalAssets(), totalAssetsBefore); + // Exact equality on the sell side: no fee path runs, so no rounding tolerance is needed. + assertEq(lidoARM.totalAssets(), totalAssetsBefore + expectedTotalAssetsIncrease); + } +} diff --git a/test/unit/LidoARM/fuzz/adapters/Split.fuzz.t.sol b/test/unit/LidoARM/fuzz/adapters/Split.fuzz.t.sol new file mode 100644 index 00000000..b1d8d3c5 --- /dev/null +++ b/test/unit/LidoARM/fuzz/adapters/Split.fuzz.t.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Test +import {Unit_LidoARM_Shared_Test} from "../../Shared.t.sol"; + +// Contracts +import {AbstractLidoAssetAdapter} from "contracts/adapters/AbstractLidoAssetAdapter.sol"; + +// Libraries +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +/// @notice Test-only adapter that exposes `_splitAmounts` and `_splitShares` as +/// external functions, and lets `_assetsToShares` be driven by a stored +/// rate. The rate is interpreted as `_assetsToShares(x) = x * rate / 1e18`, +/// so `rate == 1e18` is 1:1, `rate < 1e18` makes shares smaller than assets +/// (rate-up scenario), and `rate > 1e18` makes them larger. +contract ExposedLidoAdapter is AbstractLidoAssetAdapter { + uint256 public mockRate; + + constructor(address _arm, address _weth, address _steth, address _queue) + AbstractLidoAssetAdapter(_arm, _weth, _steth, _queue) + {} + + function setMockRate(uint256 v) external { + mockRate = v; + } + + function exposed_splitAmounts(uint256 amount) external pure returns (uint256[] memory) { + return _splitAmounts(amount); + } + + function exposed_splitShares(uint256 totalShares, uint256[] memory amounts, uint256 totalAssets) + external + view + returns (uint256[] memory) + { + return _splitShares(totalShares, amounts, totalAssets); + } + + function MAX_AMOUNT() external pure returns (uint256) { + return MAX_WITHDRAWAL_AMOUNT; + } + + // Required by IAssetAdapter; unused by these fuzz tests. + function convertToAssets(uint256 shares) external pure returns (uint256) { + return shares; + } + + function convertToShares(uint256 assets) external pure returns (uint256) { + return assets; + } + + function _pullSharesAndConvertToSteth(address, uint256) internal pure override returns (uint256) { + return 0; + } + + function _assetsToShares(uint256 assets) internal view override returns (uint256) { + return Math.mulDiv(assets, mockRate, 1e18, Math.Rounding.Floor); + } +} + +/// @notice Property-based fuzz tests for the two pure-ish chunking helpers in +/// `AbstractLidoAssetAdapter`: `_splitAmounts` and `_splitShares`. Both +/// functions feed `requestRedeem` and their invariants are essential to +/// the ARM's withdrawal accounting (queue chunks sum to the request, +/// share splits sum to the user's redeem amount). +contract Unit_Fuzz_LidoARM_Split_Test is Unit_LidoARM_Shared_Test { + ExposedLidoAdapter internal exposed; + uint256 internal MAX; + + function setUp() public override { + super.setUp(); + // Deploy directly (not via proxy); the constructor sets the stETH approval on this contract's + // own storage, which is all `_splitAmounts` / `_splitShares` need. + exposed = new ExposedLidoAdapter({ + _arm: address(this), _weth: address(weth), _steth: address(steth), _queue: address(lidoWithdrawalQueue) + }); + MAX = exposed.MAX_AMOUNT(); + } + + ////////////////////////////////////////////////////// + /// --- _splitAmounts + ////////////////////////////////////////////////////// + + /// @notice Invariants over `_splitAmounts(amount)`: + /// 1. chunk count == ceil(amount / MAX) + /// 2. sum(chunks) == amount (exact, no loss) + /// 3. every non-final chunk equals MAX + /// 4. final chunk is in (0, MAX] + function testFuzz_SplitAmounts_Invariants(uint256 amount) public view { + // Cap at ~100 chunks (100_000 ether). The chunking arithmetic is invariant in the chunk count, + // so 100 chunks is enough to exercise the loop while keeping memory allocation well within + // EVM limits. + amount = _bound(amount, 1, 100 * MAX); + + uint256[] memory chunks = exposed.exposed_splitAmounts(amount); + + // Property 1: chunk count + uint256 expectedCount = (amount + MAX - 1) / MAX; + assertEq(chunks.length, expectedCount, "chunk count == ceil(amount / MAX)"); + + // Property 2 + 3 + 4 in a single pass + uint256 sum; + uint256 lastIdx = chunks.length - 1; + for (uint256 i; i < chunks.length; ++i) { + if (i < lastIdx) { + assertEq(chunks[i], MAX, "non-final chunk == MAX"); + } else { + assertGt(chunks[i], 0, "final chunk > 0"); + assertLe(chunks[i], MAX, "final chunk <= MAX"); + } + sum += chunks[i]; + } + assertEq(sum, amount, "sum(chunks) == amount"); + } + + /// @notice Boundary cases — amounts at and around MAX_WITHDRAWAL_AMOUNT. + function testFuzz_SplitAmounts_AroundBoundary(uint256 offset) public view { + // Probe amount = MAX +/- offset to stress the boundary between 1-chunk and 2-chunk splits. + offset = _bound(offset, 0, MAX - 1); + + // amount = MAX - offset (clamped to >= 1): always exactly 1 chunk + { + uint256 amount = MAX - offset; + if (amount == 0) amount = 1; + uint256[] memory chunks = exposed.exposed_splitAmounts(amount); + assertEq(chunks.length, 1, "(MAX - offset) -> 1 chunk"); + assertEq(chunks[0], amount, "(MAX - offset) -> chunk == amount"); + } + + // amount = MAX + offset: 1 chunk when offset == 0, otherwise 2 chunks of MAX + offset + { + uint256 amount = MAX + offset; + uint256[] memory chunks = exposed.exposed_splitAmounts(amount); + if (offset == 0) { + assertEq(chunks.length, 1, "MAX -> 1 chunk"); + assertEq(chunks[0], MAX, "MAX -> chunk == MAX"); + } else { + assertEq(chunks.length, 2, "(MAX + offset) -> 2 chunks"); + assertEq(chunks[0], MAX, "first chunk == MAX"); + assertEq(chunks[1], offset, "second chunk == offset"); + } + } + } + + ////////////////////////////////////////////////////// + /// --- _splitShares + ////////////////////////////////////////////////////// + + /// @notice Invariants over `_splitShares(totalShares, amounts, totalAssets)`: + /// 1. shareSplits.length == amounts.length + /// 2. sum(shareSplits) == totalShares (last chunk absorbs all rounding) + /// 3. each non-final shareSplit <= cumulative remainingShares at that step + /// The function is exercised across a wide range of share/asset ratios + /// (mockRate) so all three branches inside the loop are explored: + /// - happy path (splitShares <= remainingShares, splitShares > 0) + /// - cap branch (splitShares > remainingShares) + /// - zero-fallback branch (splitShares == 0) + function testFuzz_SplitShares_Invariants(uint256 totalAssets, uint256 totalShares, uint256 rate) public { + // Multi-chunk regime (>= 2 chunks) up to ~100 chunks. The loop logic is invariant in chunk + // count beyond this; 100 chunks is enough to exercise branches without OOG on array allocation. + totalAssets = _bound(totalAssets, MAX + 1, 100 * MAX); + // totalShares is decoupled from totalAssets on purpose: extreme imbalances drive the cap and + // zero-fallback branches inside the loop. + totalShares = _bound(totalShares, 1, type(uint96).max); + // Rate spans 6 orders of magnitude below 1:1 to 6 orders above, covering both the cap branch + // (rate big enough that one chunk's worth of shares overshoots remainingShares) and the zero + // branch (rate small enough that floor(MAX * rate / 1e18) rounds to zero). + rate = _bound(rate, 1, 1e24); + + exposed.setMockRate(rate); + uint256[] memory amounts = exposed.exposed_splitAmounts(totalAssets); + uint256[] memory splits = exposed.exposed_splitShares(totalShares, amounts, totalAssets); + + // Property 1: length matches amounts + assertEq(splits.length, amounts.length, "length matches amounts"); + + // Property 2: sum equals totalShares + // Property 3: no non-final split exceeds the shares that were still available before it + uint256 sum; + uint256 remaining = totalShares; + uint256 lastIdx = splits.length - 1; + for (uint256 i; i < splits.length; ++i) { + if (i < lastIdx) { + // The non-final cap ensures splits[i] never exceeds what was available going in. + assertLe(splits[i], remaining, "split <= remaining at iteration"); + } + sum += splits[i]; + // remaining cannot underflow because of the assertion above + remaining -= splits[i]; + } + assertEq(sum, totalShares, "sum(splits) == totalShares"); + assertEq(remaining, 0, "remaining drained to zero"); + } + + /// @notice With a consistent rate (totalShares ~= totalAssets * rate / 1e18) the function should + /// produce splits that mirror the asset proportions to within the last-chunk remainder. + /// This is the "real world" path — wstETH-style — and it must never enter the cap branch + /// on non-final chunks under self-consistent math. + function testFuzz_SplitShares_ConsistentRate(uint256 totalAssets, uint256 rate) public { + totalAssets = _bound(totalAssets, MAX + 1, 100 * MAX); + // Restrict rate so 1 asset is worth at least 1e-3 share and at most 1e3 shares; outside this + // range the consistency property below collapses because `_assetsToShares(MAX)` underflows to + // zero or `totalShares` overflows the assertion bounds. + rate = _bound(rate, 1e15, 1e21); + + exposed.setMockRate(rate); + uint256 totalShares = Math.mulDiv(totalAssets, rate, 1e18, Math.Rounding.Floor); + // Skip degenerate runs where rounding wipes out totalShares (rate near 1e15 with small assets). + vm.assume(totalShares > 0); + + uint256[] memory amounts = exposed.exposed_splitAmounts(totalAssets); + uint256[] memory splits = exposed.exposed_splitShares(totalShares, amounts, totalAssets); + + // Per-chunk lower bound: the natural conversion of the chunk's assets. + uint256 expectedPerFullChunk = Math.mulDiv(MAX, rate, 1e18, Math.Rounding.Floor); + + uint256 sum; + uint256 lastIdx = splits.length - 1; + for (uint256 i; i < splits.length; ++i) { + if (i < lastIdx) { + // Non-final splits equal _assetsToShares(MAX) under self-consistent inputs, because + // the cap and zero-fallback branches don't fire when totalShares matches totalAssets. + assertEq(splits[i], expectedPerFullChunk, "non-final split == _assetsToShares(MAX)"); + } + sum += splits[i]; + } + assertEq(sum, totalShares, "sum(splits) == totalShares"); + } +} diff --git a/test/unit/LidoARM/mocks/MockERC4626Market.sol b/test/unit/LidoARM/mocks/MockERC4626Market.sol new file mode 100644 index 00000000..7306f9c3 --- /dev/null +++ b/test/unit/LidoARM/mocks/MockERC4626Market.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {IERC20} from "contracts/Interfaces.sol"; +import {ERC20, MockERC4626} from "@solmate/test/utils/mocks/MockERC4626.sol"; + +contract MockERC4626Market is MockERC4626 { + constructor(IERC20 _token) + MockERC4626( + ERC20(address(_token)), + string(abi.encode("MockERC4626", ERC20(address(_token)).name())), + string(abi.encode("MockERC4626", ERC20(address(_token)).symbol())) + ) + {} +} diff --git a/test/unit/LidoARM/mocks/MockLidoWithdraw.sol b/test/unit/LidoARM/mocks/MockLidoWithdraw.sol new file mode 100644 index 00000000..26e8f586 --- /dev/null +++ b/test/unit/LidoARM/mocks/MockLidoWithdraw.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +// Foundry +import {Vm} from "forge-std/Vm.sol"; + +// Solmate +import {ERC20} from "@solmate/tokens/ERC20.sol"; + +/// @notice Test double for Lido's `WithdrawalQueueERC721`. Implements the subset of +/// `IStETHWithdrawal` exercised by `AbstractLidoAssetAdapter` and exposes +/// `mock_*` setters so unit tests can drive un-finalized / claimed / re-owned +/// requests through the adapter's edge-case branches. +contract MockLidoWithdraw { + ////////////////////////////////////////////////////// + /// --- CONSTANTS && IMMUTABLES + ////////////////////////////////////////////////////// + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + ////////////////////////////////////////////////////// + /// --- STRUCTS + ////////////////////////////////////////////////////// + struct Request { + address owner; + uint256 amount; + bool claimed; + bool finalized; + } + + // Field order must match IStETHWithdrawal.WithdrawalRequestStatus exactly. + struct WithdrawalRequestStatus { + uint256 amountOfStETH; + uint256 amountOfShares; + address owner; + uint256 timestamp; + bool isFinalized; + bool isClaimed; + } + + ////////////////////////////////////////////////////// + /// --- STATE + ////////////////////////////////////////////////////// + ERC20 public steth; + uint256 public counter; + uint256 public lastCheckpointIndex; + mapping(uint256 => Request) public requests; + + ////////////////////////////////////////////////////// + /// --- CONSTRUCTOR + ////////////////////////////////////////////////////// + constructor(address _steth) { + steth = ERC20(_steth); + } + + ////////////////////////////////////////////////////// + /// --- IStETHWithdrawal (subset used by the adapter) + ////////////////////////////////////////////////////// + function requestWithdrawals(uint256[] calldata amounts, address owner) external returns (uint256[] memory ids) { + uint256 len = amounts.length; + ids = new uint256[](len); + + for (uint256 i; i < len; ++i) { + require(amounts[i] <= 1_000 ether, "Mock LW: Withdraw amount too big"); + + // stETH transfers can lose 1 wei to rounding; measure the actual delta. + uint256 balBefore = steth.balanceOf(address(this)); + steth.transferFrom(msg.sender, address(this), amounts[i]); + uint256 amount = steth.balanceOf(address(this)) - balBefore; + + requests[counter] = Request({owner: owner, amount: amount, claimed: false, finalized: true}); + ids[i] = counter; + counter++; + } + } + + function claimWithdrawals(uint256[] calldata requestIds, uint256[] calldata) external { + uint256 sum; + uint256 len = requestIds.length; + for (uint256 i; i < len; ++i) { + uint256 id = requestIds[i]; + Request storage r = requests[id]; + require(r.owner == msg.sender, "Mock LW: Not owner"); + require(!r.claimed, "Mock LW: Already claimed"); + require(r.finalized, "Mock LW: Not finalized"); + + r.claimed = true; + sum += r.amount; + } + + // Fund the caller (the adapter) with ETH; it will wrap to WETH itself. + vm.deal(msg.sender, msg.sender.balance + sum); + } + + function getWithdrawalStatus(uint256[] calldata requestIds) + external + view + returns (WithdrawalRequestStatus[] memory statuses) + { + uint256 len = requestIds.length; + statuses = new WithdrawalRequestStatus[](len); + for (uint256 i; i < len; ++i) { + Request memory r = requests[requestIds[i]]; + statuses[i] = WithdrawalRequestStatus({ + amountOfStETH: r.amount, + amountOfShares: r.amount, + owner: r.owner, + timestamp: block.timestamp, + isFinalized: r.finalized, + isClaimed: r.claimed + }); + } + } + + function getLastCheckpointIndex() external view returns (uint256) { + return lastCheckpointIndex; + } + + function findCheckpointHints(uint256[] calldata requestIds, uint256, uint256) + external + pure + returns (uint256[] memory hints) + { + // Hints array must match requestIds length; values are unused by this mock. + hints = new uint256[](requestIds.length); + } + + ////////////////////////////////////////////////////// + /// --- Test knobs + ////////////////////////////////////////////////////// + function mock_setFinalized(uint256 id, bool value) external { + requests[id].finalized = value; + } + + function mock_setClaimed(uint256 id, bool value) external { + requests[id].claimed = value; + } + + function mock_setOwner(uint256 id, address newOwner) external { + requests[id].owner = newOwner; + } + + function mock_setLastCheckpointIndex(uint256 idx) external { + lastCheckpointIndex = idx; + } + + receive() external payable {} +} diff --git a/test/unit/LidoARM/mocks/MockWstETH.sol b/test/unit/LidoARM/mocks/MockWstETH.sol new file mode 100644 index 00000000..dba2f5ed --- /dev/null +++ b/test/unit/LidoARM/mocks/MockWstETH.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {IERC20} from "contracts/Interfaces.sol"; +import {ERC20, MockERC4626} from "@solmate/test/utils/mocks/MockERC4626.sol"; + +contract MockWstETH is MockERC4626 { + ERC20 public immutable stETH; + + constructor(IERC20 _stETH) MockERC4626(ERC20(address(_stETH)), "Wrapped liquid staked Ether 2.0", "wstETH") { + stETH = ERC20(address(_stETH)); + } + + function wrap(uint256 stETHAmount) external returns (uint256 wstETHAmount) { + wstETHAmount = deposit(stETHAmount, msg.sender); + } + + function unwrap(uint256 wstETHAmount) external returns (uint256 stETHAmount) { + stETHAmount = redeem(wstETHAmount, msg.sender, msg.sender); + } + + function getStETHByWstETH(uint256 wstETHAmount) external view returns (uint256 stETHAmount) { + stETHAmount = convertToAssets(wstETHAmount); + } + + function getWstETHByStETH(uint256 stETHAmount) external view returns (uint256 wstETHAmount) { + wstETHAmount = convertToShares(stETHAmount); + } + + /// @notice Returns stETH per 1 wstETH. For example, 1e18 = 1 stETH per wstETH. + function stEthPerToken() external view returns (uint256) { + return convertToAssets(1e18); + } + + /// @notice Returns wstETH per 1 stETH. For example, 1e18 = 1 wstETH per stETH. + function tokensPerStEth() external view returns (uint256) { + return convertToShares(1e18); + } +} diff --git a/test/unit/OriginARM/Allocate.sol b/test/unit/OriginARM/Allocate.sol deleted file mode 100644 index 89077e47..00000000 --- a/test/unit/OriginARM/Allocate.sol +++ /dev/null @@ -1,271 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; - -contract Unit_Concrete_OriginARM_Allocate_Test_ is Unit_Shared_Test { - function setUp() public virtual override { - super.setUp(); - } - - function test_RevertWhen_Allocate_Because_NoActiveMarket() public asRandomCaller { - vm.expectRevert("ARM: no active market"); - originARM.allocate(); - } - - function test_Allocate_When_NoAvailableAsset() - public - forceAvailableAssetsToZero - addMarket(address(market)) - setActiveMarket(address(market)) - asRandomCaller - { - // Ensure there is nothing already allocated - assertEq(market.balanceOf(address(originARM)), 0); - - // Allocated - originARM.allocate(); - - // Ensure there nothing has been allocated - assertEq(market.balanceOf(address(originARM)), 0); - } - - function test_Allocate_When_LiquidityDelta_IsPositive_NoOutstandingWithdraw() - public - addMarket(address(market)) - setActiveMarket(address(market)) - setARMBuffer(0) - asRandomCaller - { - assertEq(market.balanceOf(address(originARM)), 0, "Market balance should be zero"); - - // Cheat and increase the available assets on ARM - deal(address(weth), address(originARM), 2 * DEFAULT_AMOUNT); - - // Allocate - originARM.allocate(); - - // As we simulate a benefit from trade, we need to check the fees accrued - uint256 feesAccrued = originARM.feesAccrued(); - assertEq( - market.balanceOf(address(originARM)), - 2 * DEFAULT_AMOUNT, - "Market balance should be increased by DEFAULT_AMOUNT" - ); - assertEq( - originARM.totalAssets(), - 2 * DEFAULT_AMOUNT - feesAccrued, - "Total assets should be DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY" - ); - } - - function test_Allocate_When_LiquidityDelta_IsPositive_WithOutstandingWithdraw() - public - addMarket(address(market)) - setActiveMarket(address(market)) - setARMBuffer(0) - deposit(alice, 2 * DEFAULT_AMOUNT) - requestRedeem(alice, 0.5 ether) // redeem 50% of shares - asRandomCaller - { - assertEq(market.balanceOf(address(originARM)), 0, "Market balance should be zero"); - assertEq( - originARM.totalAssets(), - 2 * DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, - "Total assets should include escrowed shares" - ); - - // Allocate - originARM.allocate(); - - assertEq( - market.balanceOf(address(originARM)), - MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT, - "Market balance should be increased by DEFAULT_AMOUNT" - ); - assertEq( - originARM.totalAssets(), - 2 * DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, - "Total assets should include escrowed shares" - ); - } - - /// @dev in this situation there is no direct WETH liquidity and armBuffer is set to 0% - /// This means that the ARM wants to have 50% of his liquidity out of market. - function test_Allocate_When_LiquidityDelta_IsNegative_PartialWithdraw_EnoughLiquidityOnMarket() - public - addMarket(address(market)) - setActiveMarket(address(market)) - deposit(alice, 4 * DEFAULT_AMOUNT) - allocate - setARMBuffer(0.3 ether) // 30% of the assets in the ARM, 70% in the market - requestRedeem(alice, 0.25 ether) // redeem 25% of shares leaving 3 * DEFAULT_AMOUNT - asRandomCaller - { - assertEq( - market.balanceOf(address(originARM)), - MIN_TOTAL_SUPPLY + 4 * DEFAULT_AMOUNT, - "Market balance should be all assets before redeem request" - ); - assertEq( - originARM.totalAssets(), - MIN_TOTAL_SUPPLY + 4 * DEFAULT_AMOUNT, - "Total assets should include escrowed shares" - ); - assertEq(weth.balanceOf(address(originARM)), 0, "ARM WETH balance should be zero"); - - // Allocate - originARM.allocate(); - - assertEq( - market.balanceOf(address(originARM)), - (MIN_TOTAL_SUPPLY + 4 * DEFAULT_AMOUNT) * 70 / 100 - DEFAULT_AMOUNT, - "Market balance should be 75% of the available liquidity" - ); - assertEq( - originARM.totalAssets(), - MIN_TOTAL_SUPPLY + 4 * DEFAULT_AMOUNT, - "Total assets should include escrowed shares" - ); - assertEq( - weth.balanceOf(address(originARM)), - (MIN_TOTAL_SUPPLY + 4 * DEFAULT_AMOUNT) * 30 / 100 + DEFAULT_AMOUNT, - "ARM WETH balance should be 30% of the available liquidity plus pending redeem" - ); - } - - function test_Allocate_When_LiquidityDelta_IsNegative_FullWithdraw_EnoughLiquidityOnMarket() - public - addMarket(address(market)) - setActiveMarket(address(market)) - setARMBuffer(1 ether) // 100% of the assets in the market - deposit(alice, DEFAULT_AMOUNT) - requestRedeem(alice, 1 ether) // redeem 100% of shares - asRandomCaller - { - assertEq(market.balanceOf(address(originARM)), 0, "Market balance should be zero"); - assertEq( - originARM.totalAssets(), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "Total assets should include escrowed shares" - ); - - // Allocate - originARM.allocate(); - - assertEq(market.balanceOf(address(originARM)), 0, "Market balance should be 0"); - assertEq( - originARM.totalAssets(), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "Total assets should include escrowed shares" - ); - assertEq( - weth.balanceOf(address(originARM)), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "WETH balance should be increased" - ); - } - - // As we are below threshold, the redeem should be skipped. However, in this situation this is not easy to test. - // We will add this test back when we can adjust the MIN_SHARES_TO_REDEEM. - /* - function test_Allocate_When_LiquidityDelta_IsNegative_FullWithdraw_NotEnoughLiquidityOnMarket_BelowThreshold() - public - setARMBuffer(0 ether) // 0% of the assets in the market - addMarket(address(market)) - setActiveMarket(address(market)) - marketLoss(address(market), 0.5 ether) // simulate a 50% loss on the market - setARMBuffer(1 ether) // 100% of the assets in the market - asRandomCaller - { - assertEq(market.balanceOf(address(originARM)), MIN_TOTAL_SUPPLY, "Market balance should be MIN_TOTAL_SUPPLY"); - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY / 2, "Total assets should be MIN_TOTAL_SUPPLY/2"); - - // Cheat and increase the available assets on ARM - deal(address(oeth), address(originARM), 1e18); - // Get fees accrued - uint256 feesAccrued = originARM.feesAccrued(); - - // Allocate - originARM.allocate(); - - - //assertEq(market.balanceOf(address(originARM)), MIN_TOTAL_SUPPLY, "Market balance should be 0"); - assertEq( - originARM.totalAssets(), - DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY / 2 - feesAccrued, - "Total assets should be correctly updated" - ); - } - */ - - function test_Allocate_When_LiquidityDelta_IsNegative_FullWithdraw_NotEnoughLiquidityOnMarket_AboveThreshold() - public - setFee(0) - setARMBuffer(0 ether) // 0% of the assets in the market - deposit(alice, DEFAULT_AMOUNT) - addMarket(address(market)) - setActiveMarket(address(market)) // this allocate too - marketLoss(address(market), 0.5 ether) // simulate a 50% loss on the market - setARMBuffer(1 ether) // 100% of the assets in the market - asRandomCaller - { - assertEq( - market.balanceOf(address(originARM)), - DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, - "Market balance should be DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY" - ); - assertEq( - originARM.totalAssets(), - (DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) / 2, - "Total assets should be (DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) / 2" - ); - - // Cheat and increase the available assets on ARM - deal(address(oeth), address(originARM), DEFAULT_AMOUNT); - - // Allocate - originARM.allocate(); - - assertEq(market.balanceOf(address(originARM)), 0, "Market balance should be 0"); - assertEq( - originARM.totalAssets(), - DEFAULT_AMOUNT + (DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY) / 2, - "Total assets should be correctly updated" - ); - } - - function test_Allocate_When_LiquidityDelta_IsNull() - public - deposit(alice, 10 * DEFAULT_AMOUNT) - setARMBuffer(0.2 ether) - addMarket(address(market)) - setActiveMarket(address(market)) - asRandomCaller - { - assertEq( - market.balanceOf(address(originARM)), - (MIN_TOTAL_SUPPLY + 10 * DEFAULT_AMOUNT) * 80 / 100, - "Market balance should be 80% of available liquidity" - ); - assertEq( - originARM.totalAssets(), - MIN_TOTAL_SUPPLY + 10 * DEFAULT_AMOUNT, - "Total assets should be MIN_TOTAL_SUPPLY + 10 * DEFAULT_AMOUNT" - ); - assertEq( - weth.balanceOf(address(originARM)), - (MIN_TOTAL_SUPPLY + 10 * DEFAULT_AMOUNT) * 20 / 100, - "ARM WETH balance should be 20% of available liquidity" - ); - - // Allocate - originARM.allocate(); - - assertEq( - market.balanceOf(address(originARM)), - (MIN_TOTAL_SUPPLY + 10 * DEFAULT_AMOUNT) * 80 / 100, - "Market balance should be the same" - ); - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY + 10 * DEFAULT_AMOUNT, "Total assets should be the same"); - assertEq( - weth.balanceOf(address(originARM)), - (MIN_TOTAL_SUPPLY + 10 * DEFAULT_AMOUNT) * 20 / 100, - "ARM WETH balance should be the same" - ); - } -} diff --git a/test/unit/OriginARM/AvailableLiquidity.sol b/test/unit/OriginARM/AvailableLiquidity.sol deleted file mode 100644 index b36da41c..00000000 --- a/test/unit/OriginARM/AvailableLiquidity.sol +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; - -contract Unit_Concrete_OriginARM_AvailableLiquidity_Test_ is Unit_Shared_Test { - function setUp() public virtual override { - super.setUp(); - - // Give Alice some WETH - deal(address(weth), alice, 1_000 * DEFAULT_AMOUNT); - - // Alice approve max WETH to the ARM - vm.prank(alice); - weth.approve(address(originARM), type(uint256).max); - } - - function test_AvailableLiquidity_AfterDeposit() public deposit(alice, DEFAULT_AMOUNT) { - (uint256 balance0, uint256 balance1) = _getReserves(); - assertEq(balance0, DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY); - assertEq(balance1, 0); - } - - function test_AvailableLiquidity_AfterDepositAndSwap() public deposit(alice, DEFAULT_AMOUNT) swapAllWETHForOETH { - (uint256 balance0, uint256 balance1) = _getReserves(); - assertEq(balance0, 0); - assertApproxEqRel(balance1, DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, 1e16); - } - - function test_AvailableLiquidity_AfterDepositSwapRequest() - public - deposit(alice, DEFAULT_AMOUNT) - swapWETHForOETH(DEFAULT_AMOUNT / 2) - requestRedeemAll(alice) - { - (uint256 balance0, uint256 balance1) = _getReserves(); - assertApproxEqRel(weth.balanceOf(address(originARM)), DEFAULT_AMOUNT / 2, 1e16); - assertApproxEqRel(balance1, DEFAULT_AMOUNT / 2, 1e16); // - assertEq(balance0, 0); // Because outstanding withdraw are 1 ether - } - - function test_AvailableLiquidity_IncludesWithdrawableMarketLiquidity_WhenEnabled() - public - deposit(alice, 2 * DEFAULT_AMOUNT) - addMarket(address(market)) - setActiveMarket(address(market)) - { - (uint256 balance0, uint256 balance1) = _getReserves(); - - assertEq(balance0, 2 * DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY); - assertEq(balance1, 0); - } - - function test_RevertWhen_GetReserves_Because_UnsupportedBaseAsset() public { - vm.expectRevert("ARM: unsupported asset"); - originARM.getReserves(address(weth)); - } - - function _getReserves() internal view returns (uint256 reserve0, uint256 reserve1) { - (reserve0, reserve1) = originARM.getReserves(address(oeth)); - } -} diff --git a/test/unit/OriginARM/ClaimRedeem.sol b/test/unit/OriginARM/ClaimRedeem.sol deleted file mode 100644 index bb97e652..00000000 --- a/test/unit/OriginARM/ClaimRedeem.sol +++ /dev/null @@ -1,265 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; -import {AbstractARM} from "contracts/AbstractARM.sol"; - -contract Unit_Concrete_OriginARM_ClaimRedeem_Test_ is Unit_Shared_Test { - uint256 internal constant LEGACY_PACKED_WITHDRAW_QUEUE_SLOT = 53; - uint256 internal constant NEXT_WITHDRAWAL_INDEX_SLOT = 54; - uint256 internal constant WITHDRAWAL_REQUESTS_SLOT = 55; - - function setUp() public virtual override { - super.setUp(); - - // Give Alice some WETH - deal(address(weth), alice, 1_000 * DEFAULT_AMOUNT); - - // Alice approve max WETH to the ARM - vm.startPrank(alice); - weth.approve(address(originARM), type(uint256).max); - originARM.deposit(DEFAULT_AMOUNT); - vm.stopPrank(); - } - - function test_RevertWhen_ClaimRedeem_Because_DelayNotMet() public requestRedeemAll(alice) { - vm.expectRevert(bytes4(keccak256("ClaimDelayNotMet()"))); - originARM.claimRedeem(0); - } - - function test_RevertWhen_ClaimRedeem_Because_QueuePendingLiquidity() - public - swapAllWETHForOETH - requestRedeemAll(alice) - timejump(CLAIM_DELAY) - { - vm.expectRevert(bytes4(keccak256("QueuePendingLiquidity()"))); - originARM.claimRedeem(0); - } - - function test_RevertWhen_ClaimRedeem_Because_NotWithdrawer() - public - requestRedeemAll(alice) - timejump(CLAIM_DELAY) - asNot(alice) - { - vm.expectRevert(bytes4(keccak256("NotRequesterOrOperator()"))); - originARM.claimRedeem(0); - } - - function test_ClaimRedeem_WhenOperatorClaimsForWithdrawer() public requestRedeemAll(alice) timejump(CLAIM_DELAY) { - uint256 aliceBalanceBefore = weth.balanceOf(alice); - uint256 operatorBalanceBefore = weth.balanceOf(operator); - - vm.prank(operator); - vm.expectEmit(address(originARM)); - emit AbstractARM.RedeemClaimed(alice, 0, DEFAULT_AMOUNT); - originARM.claimRedeem(0); - - (, bool claimed,,,,) = originARM.withdrawalRequests(0); - assertEq(claimed, true, "Claimed should be true"); - assertEq(originARM.reservedWithdrawLiquidity(), 0, "Reserved liquidity should be released"); - assertEq(originARM.withdrawsClaimedShares(), DEFAULT_AMOUNT, "Claimed shares should be DEFAULT_AMOUNT"); - assertEq(weth.balanceOf(alice), aliceBalanceBefore + DEFAULT_AMOUNT, "Alice should receive her WETH"); - assertEq(weth.balanceOf(operator), operatorBalanceBefore, "Operator should not receive WETH"); - assertEq(originARM.claimable(), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "Claimable should be updated"); - } - - function test_ClaimRedeem_WhenLegacyRequestClaimedByWithdrawer() public timejump(CLAIM_DELAY) { - uint256 legacyRequestId = 1; - uint128 legacyAssets = 0.4 ether; - uint128 legacyShares = 0.5 ether; - uint128 legacyQueued = 1 ether; - uint128 legacyClaimed = legacyQueued - legacyAssets; - _writeLegacyWithdrawQueue(legacyQueued, legacyClaimed); - _writeLegacyWithdrawalRequest( - legacyRequestId, alice, false, uint40(block.timestamp), legacyAssets, legacyQueued, legacyShares - ); - _migrateWithLegacyBoundary(3); - - uint256 aliceBalanceBefore = weth.balanceOf(alice); - uint256 armSharesBefore = originARM.balanceOf(address(originARM)); - - vm.prank(alice); - vm.expectEmit(address(originARM)); - emit AbstractARM.RedeemClaimed(alice, legacyRequestId, legacyAssets); - originARM.claimRedeem(legacyRequestId); - - (, bool claimed,,,, uint256 shares) = originARM.withdrawalRequests(legacyRequestId); - assertEq(claimed, true, "claimed"); - assertEq(shares, legacyShares, "legacy shares"); - assertEq(weth.balanceOf(alice), aliceBalanceBefore + legacyAssets, "alice WETH"); - assertEq(originARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); - assertEq(originARM.withdrawsClaimedShares(), 0, "claimed shares"); - assertEq(originARM.balanceOf(address(originARM)), armSharesBefore, "escrowed shares"); - assertEq(_readLegacyWithdrawQueue(), _packLegacyWithdrawQueue(legacyQueued, legacyQueued), "legacy claimed"); - } - - function test_ClaimRedeem_WhenLegacyRequestClaimedByOperator() public timejump(CLAIM_DELAY) { - uint256 legacyRequestId = 2; - uint128 legacyAssets = 0.25 ether; - uint128 legacyShares = 0.3 ether; - uint128 legacyQueued = 1 ether; - uint128 legacyClaimed = legacyQueued - legacyAssets; - _writeLegacyWithdrawQueue(legacyQueued, legacyClaimed); - _writeLegacyWithdrawalRequest( - legacyRequestId, alice, false, uint40(block.timestamp), legacyAssets, legacyQueued, legacyShares - ); - _migrateWithLegacyBoundary(3); - - uint256 aliceBalanceBefore = weth.balanceOf(alice); - uint256 operatorBalanceBefore = weth.balanceOf(operator); - uint256 armSharesBefore = originARM.balanceOf(address(originARM)); - - vm.prank(operator); - originARM.claimRedeem(legacyRequestId); - - assertEq(weth.balanceOf(alice), aliceBalanceBefore + legacyAssets, "alice WETH"); - assertEq(weth.balanceOf(operator), operatorBalanceBefore, "operator WETH"); - assertEq(originARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); - assertEq(originARM.withdrawsClaimedShares(), 0, "claimed shares"); - assertEq(originARM.balanceOf(address(originARM)), armSharesBefore, "escrowed shares"); - assertEq(_readLegacyWithdrawQueue(), _packLegacyWithdrawQueue(legacyQueued, legacyQueued), "legacy claimed"); - } - - function test_ClaimRedeem_WhenRequestIdAtMigrationBoundaryUsesNewQueue() public timejump(CLAIM_DELAY) { - _migrateWithLegacyBoundary(3); - - vm.prank(alice); - (uint256 requestId,) = originARM.requestRedeem(DEFAULT_AMOUNT); - assertEq(requestId, 3, "new request id"); - - uint256 aliceBalanceBefore = weth.balanceOf(alice); - uint256 armSharesBefore = originARM.balanceOf(address(originARM)); - - vm.warp(block.timestamp + CLAIM_DELAY); - vm.prank(alice); - originARM.claimRedeem(requestId); - - assertEq(originARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); - assertEq(originARM.withdrawsClaimedShares(), DEFAULT_AMOUNT, "claimed shares"); - assertEq(originARM.balanceOf(address(originARM)), armSharesBefore - DEFAULT_AMOUNT, "escrowed shares burned"); - assertEq(weth.balanceOf(alice), aliceBalanceBefore + DEFAULT_AMOUNT, "alice WETH"); - } - - function test_RevertWhen_ClaimRedeem_Because_AlreadyClaimed() public requestRedeemAll(alice) timejump(CLAIM_DELAY) { - // Alice claims her redeem - vm.prank(alice); - originARM.claimRedeem(0); - - // Attempt to claim again - vm.prank(alice); - vm.expectRevert(bytes4(keccak256("AlreadyClaimed()"))); - originARM.claimRedeem(0); - } - - function test_ClaimRedeem_WithoutActiveMarket() public requestRedeemAll(alice) timejump(CLAIM_DELAY) { - uint256 balanceBefore = weth.balanceOf(alice); - // Alice claims her redeem - vm.prank(alice); - vm.expectEmit(address(originARM)); - emit AbstractARM.RedeemClaimed(alice, 0, DEFAULT_AMOUNT); - originARM.claimRedeem(0); - - (, bool claimed,,,,) = originARM.withdrawalRequests(0); - // Assertions - assertEq(claimed, true, "Claimed should be true"); - assertEq(originARM.reservedWithdrawLiquidity(), 0, "Reserved liquidity should be released"); - assertEq(originARM.withdrawsClaimedShares(), DEFAULT_AMOUNT, "Claimed shares should be DEFAULT_AMOUNT"); - assertEq(weth.balanceOf(alice), balanceBefore + DEFAULT_AMOUNT, "Alice should receive her WETH"); - assertEq(originARM.claimable(), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "Claimable should be updated"); - } - - function test_ClaimRedeem_WithActiveMarket_EnoughLiquidity() - public - setARMBuffer(1e18) - addMarket(address(market)) - setActiveMarket(address(market)) - requestRedeemAll(alice) - timejump(CLAIM_DELAY) - { - uint256 balanceBefore = weth.balanceOf(alice); - // Alice claims her redeem - vm.prank(alice); - vm.expectEmit(address(originARM)); - emit AbstractARM.RedeemClaimed(alice, 0, DEFAULT_AMOUNT); - originARM.claimRedeem(0); - - (, bool claimed,,,,) = originARM.withdrawalRequests(0); - // Assertions - assertEq(claimed, true, "Claimed should be true"); - assertEq(originARM.reservedWithdrawLiquidity(), 0, "Reserved liquidity should be released"); - assertEq(originARM.withdrawsClaimedShares(), DEFAULT_AMOUNT, "Claimed shares should be DEFAULT_AMOUNT"); - assertEq(weth.balanceOf(alice), balanceBefore + DEFAULT_AMOUNT, "Alice should receive her WETH"); - assertEq(originARM.claimable(), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "Claimable should be updated"); - } - - function test_ClaimRedeem_WithActiveMarket_NotEnoughLiquidity() - public - setARMBuffer(0) - addMarket(address(market)) - setActiveMarket(address(market)) - requestRedeemAll(alice) - timejump(CLAIM_DELAY) - { - uint256 balanceBefore = weth.balanceOf(alice); - // Alice claims her redeem - vm.prank(alice); - vm.expectEmit(address(originARM)); - emit AbstractARM.RedeemClaimed(alice, 0, DEFAULT_AMOUNT); - originARM.claimRedeem(0); - - (, bool claimed,,,,) = originARM.withdrawalRequests(0); - // Assertions - assertEq(claimed, true, "Claimed should be true"); - assertEq(originARM.reservedWithdrawLiquidity(), 0, "Reserved liquidity should be released"); - assertEq(originARM.withdrawsClaimedShares(), DEFAULT_AMOUNT, "Claimed shares should be DEFAULT_AMOUNT"); - assertEq(weth.balanceOf(alice), balanceBefore + DEFAULT_AMOUNT, "Alice should receive her WETH"); - assertEq(originARM.claimable(), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "Claimable should be updated"); - } - - function _writeLegacyWithdrawQueue(uint128 legacyQueued, uint128 legacyClaimed) internal { - vm.store( - address(originARM), - bytes32(LEGACY_PACKED_WITHDRAW_QUEUE_SLOT), - bytes32(_packLegacyWithdrawQueue(legacyQueued, legacyClaimed)) - ); - } - - function _writeLegacyWithdrawalRequest( - uint256 requestId, - address withdrawer, - bool claimed, - uint40 claimTimestamp, - uint128 assets, - uint128 queued, - uint128 shares - ) internal { - bytes32 requestSlot = keccak256(abi.encode(requestId, WITHDRAWAL_REQUESTS_SLOT)); - uint256 slot0 = - uint256(uint160(withdrawer)) | (claimed ? uint256(1) << 160 : 0) | (uint256(claimTimestamp) << 168); - - vm.store(address(originARM), requestSlot, bytes32(slot0)); - vm.store( - address(originARM), bytes32(uint256(requestSlot) + 1), bytes32(uint256(assets) | (uint256(queued) << 128)) - ); - vm.store(address(originARM), bytes32(uint256(requestSlot) + 2), bytes32(uint256(shares))); - } - - function _migrateWithLegacyBoundary(uint256 nextWithdrawalIndex) internal { - vm.store(address(originARM), bytes32(NEXT_WITHDRAWAL_INDEX_SLOT), bytes32(nextWithdrawalIndex)); - - vm.prank(governor); - originARM.migrateLegacyWithdrawQueue(); - - assertEq(originARM.legacyWithdrawalRequestCount(), nextWithdrawalIndex, "legacy request count"); - } - - function _readLegacyWithdrawQueue() internal view returns (uint256) { - return uint256(vm.load(address(originARM), bytes32(LEGACY_PACKED_WITHDRAW_QUEUE_SLOT))); - } - - function _packLegacyWithdrawQueue(uint128 legacyQueued, uint128 legacyClaimed) internal pure returns (uint256) { - return uint256(legacyQueued) | (uint256(legacyClaimed) << 128); - } -} diff --git a/test/unit/OriginARM/CollectFees.sol b/test/unit/OriginARM/CollectFees.sol deleted file mode 100644 index fbf9036a..00000000 --- a/test/unit/OriginARM/CollectFees.sol +++ /dev/null @@ -1,93 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; -import {AbstractARM} from "src/contracts/AbstractARM.sol"; - -contract Unit_Concrete_OriginARM_CollectFees_Test_ is Unit_Shared_Test { - function _swapBaseForLiquidity(uint256 amountOut) internal returns (uint256 amountIn, uint256 expectedFee) { - vm.startPrank(bob); - deal(address(oeth), bob, 1_000 * DEFAULT_AMOUNT); - oeth.approve(address(originARM), type(uint256).max); - uint256[] memory amounts = originARM.swapTokensForExactTokens(oeth, weth, amountOut, type(uint256).max, bob); - vm.stopPrank(); - - amountIn = amounts[0]; - expectedFee = amountOut * _swapFeeMultiplier(_buyPrice(), _crossPrice(), originARM.fee()) / PRICE_SCALE; - } - - function test_RevertWhen_CollectFees_Because_InsufficientLiquidity() public deposit(alice, DEFAULT_AMOUNT) { - _swapBaseForLiquidity(DEFAULT_AMOUNT / 2); - uint256 shares = originARM.balanceOf(alice); - vm.prank(alice); - originARM.requestRedeem(shares); - - vm.prank(vm.randomAddress()); - vm.expectRevert("ARM: Insufficient liquidity"); - originARM.collectFees(); - } - - function test_RevertWhen_CollectFees_Because_InsufficientLiquidityBis() public { - _swapBaseForLiquidity(1e12); - - vm.prank(vm.randomAddress()); - vm.expectRevert("ARM: Insufficient liquidity"); - originARM.collectFees(); - } - - function test_CollectFees_When_NoFeeToCollect() public deposit(alice, DEFAULT_AMOUNT) requestRedeemAll(alice) { - uint256 collectorBalance = weth.balanceOf(feeCollector); - - // Collect fees - vm.prank(vm.randomAddress()); - originARM.collectFees(); - - // Ensure there nothing has been allocated - assertEq(weth.balanceOf(feeCollector), collectorBalance, "Collector balance should not change"); - } - - function test_CollectFees_When_FeeToCollect() public { - uint256 collectorBalance = weth.balanceOf(feeCollector); - deal(address(weth), address(originARM), DEFAULT_AMOUNT); - (, uint256 expectedFees) = _swapBaseForLiquidity(DEFAULT_AMOUNT / 2); - - vm.expectEmit(address(originARM)); - emit AbstractARM.FeeCollected(feeCollector, expectedFees); - - // Collect fees - vm.prank(vm.randomAddress()); - originARM.collectFees(); - - // Ensure there nothing has been allocated - assertEq(weth.balanceOf(feeCollector), collectorBalance + expectedFees, "Collector balance should change"); - } - - function test_SwapFee_IsBoundedByCrossPriceNavGain() public { - uint256 crossPrice = 9998 * 1e32; - uint256 buyPrice = 9997 * 1e32; - uint256 amountIn = 100 ether; - - vm.startPrank(governor); - originARM.setFee(FEE_SCALE / 2); - originARM.setCrossPrice(address(oeth), crossPrice); - originARM.setPrices(address(oeth), buyPrice, crossPrice, type(uint128).max, type(uint128).max); - vm.stopPrank(); - - deal(address(weth), address(originARM), amountIn); - deal(address(oeth), bob, amountIn); - uint256 totalAssetsBefore = originARM.totalAssets(); - - vm.startPrank(bob); - oeth.approve(address(originARM), amountIn); - uint256[] memory amounts = originARM.swapExactTokensForTokens(oeth, weth, amountIn, 0, bob); - vm.stopPrank(); - - uint256 amountOut = amounts[1]; - uint256 recognizedNavGain = amountOut * (crossPrice - buyPrice) / buyPrice; - uint256 expectedFee = amountOut * _swapFeeMultiplier(buyPrice, crossPrice, originARM.fee()) / PRICE_SCALE; - - assertEq(originARM.feesAccrued(), expectedFee, "Wrong bounded swap fee"); - assertLe(originARM.feesAccrued(), recognizedNavGain, "Fee exceeds recognized NAV gain"); - assertGe(originARM.totalAssets(), totalAssetsBefore, "Swap fee should not reduce total assets"); - } -} diff --git a/test/unit/OriginARM/Deposit.sol b/test/unit/OriginARM/Deposit.sol deleted file mode 100644 index e7984bff..00000000 --- a/test/unit/OriginARM/Deposit.sol +++ /dev/null @@ -1,291 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; -import {AbstractARM} from "contracts/AbstractARM.sol"; -import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; - -contract Unit_Concrete_OriginARM_Deposit_Test_ is Unit_Shared_Test { - using SafeCast for int256; - using SafeCast for int128; - - function setUp() public virtual override { - super.setUp(); - - // Give Alice some WETH - deal(address(weth), alice, 1_000 * DEFAULT_AMOUNT); - - // Alice approve max WETH to the ARM - vm.prank(alice); - weth.approve(address(originARM), type(uint256).max); - } - - /// @notice Test under the following assumptions: - /// - WETH in the ARM is MIN_TOTAL_SUPPLY - /// - OETH in the ARM is null - /// - vaultWithdrawalAmount is null - /// - no default market - /// - no outstanding withdrawal requests - /// - available assets is not null - /// - assetIncrease is null - /// - totalAssets is MIN_TOTAL_SUPPLY - /// - lastAvailableAssets is MIN_TOTAL_SUPPLY - function test_Deposit_FirstDeposit() public { - // Expected values - uint256 expectedShares = originARM.convertToShares(DEFAULT_AMOUNT); - - uint256 previewDeposit = originARM.previewDeposit(DEFAULT_AMOUNT); - assertEq(previewDeposit, expectedShares, "Preview deposit should match expected shares"); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.Deposit(alice, DEFAULT_AMOUNT, expectedShares); - - // Alice deposits 1 WETH - vm.prank(alice); - originARM.deposit(DEFAULT_AMOUNT); - - // Assertions - assertEq(originARM.totalAssets(), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "Last available assets should be updated"); - } - - /// @notice Test under the following assumptions: - /// - WETH in the ARM is null - /// - OETH in the ARM is approx. MIN_TOTAL_SUPPLY - /// - vaultWithdrawalAmount is null - /// - no default market - /// - no outstanding withdrawal requests - /// - available assets is not null - /// - assetIncrease is not null - /// - totalAssets is approx MIN_TOTAL_SUPPLY - /// - lastAvailableAssets is MIN_TOTAL_SUPPLY - /// - fees are not null - function test_Deposit_When_NoWETH() public { - // A swap happen before - vm.startPrank(bob); - deal(address(oeth), bob, 1_000 * DEFAULT_AMOUNT); - oeth.approve(address(originARM), type(uint256).max); - originARM.swapTokensForExactTokens(oeth, weth, 1e12, type(uint256).max, bob); - vm.stopPrank(); - uint256 accruedFees = originARM.feesAccrued(); - assertEq(weth.balanceOf(address(originARM)), 0, "WETH balance should be 0"); - assertGt(originARM.fee(), 0, "Fee should be greater than 0"); - assertGt(accruedFees, 0, "Fees should be accrued"); - assertGt(originARM.totalAssets(), MIN_TOTAL_SUPPLY, "Total assets should be > MIN_TOTAL_SUPPLY"); - - // Expected values - uint256 expectedShares = originARM.convertToShares(DEFAULT_AMOUNT); - assertLt(expectedShares, DEFAULT_AMOUNT, "Shares should be less than amount"); - assertGt(expectedShares, DEFAULT_AMOUNT * 99 / 100, "Shares should be close to amount"); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.Deposit(alice, DEFAULT_AMOUNT, expectedShares); - - // Alice deposits 1 WETH - vm.prank(alice); - originARM.deposit(DEFAULT_AMOUNT); - assertEq(accruedFees, originARM.feesAccrued(), "Fees should be accrued"); - } - - /// @notice Test under the following assumptions: - /// - WETH in the ARM is MIN_TOTAL_SUPPLY / 2 - /// - OETH in the ARM is approx. MIN_TOTAL_SUPPLY / 2 - /// - vaultWithdrawalAmount is null - /// - no default market - /// - no outstanding withdrawal requests - /// - available assets is not null - /// - assetIncrease is not null - /// - totalAssets is approx MIN_TOTAL_SUPPLY - /// - lastAvailableAssets is MIN_TOTAL_SUPPLY - /// - fees are not null - function test_Deposit_When_HalfWETHOETH() public { - // A swap happen before - vm.startPrank(bob); - deal(address(oeth), bob, 1_000 * DEFAULT_AMOUNT); - oeth.approve(address(originARM), type(uint256).max); - originARM.swapTokensForExactTokens(oeth, weth, 1e12 / 2, type(uint256).max, bob); - vm.stopPrank(); - uint256 accruedFees = originARM.feesAccrued(); - assertEq(weth.balanceOf(address(originARM)), 1e12 / 2, "WETH balance should be 1e12/2"); - assertGt(originARM.fee(), 0, "Fee should be greater than 0"); - assertGt(accruedFees, 0, "Fees should be accrued"); - assertGt(originARM.totalAssets(), MIN_TOTAL_SUPPLY, "Total assets should be > MIN_TOTAL_SUPPLY"); - - // Expected values - uint256 expectedShares = originARM.convertToShares(DEFAULT_AMOUNT); - assertLt(expectedShares, DEFAULT_AMOUNT, "Shares should be less than amount"); - assertGt(expectedShares, DEFAULT_AMOUNT * 99 / 100, "Shares should be close to amount"); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.Deposit(alice, DEFAULT_AMOUNT, expectedShares); - - // Alice deposits 1 WETH - vm.prank(alice); - originARM.deposit(DEFAULT_AMOUNT); - assertEq(accruedFees, originARM.feesAccrued(), "Fees should be accrued"); - } - - /// @notice Test under the following assumptions: - /// - WETH in the ARM is MIN_TOTAL_SUPPLY / 2 - /// - OETH in the ARM is approx. null - /// - vaultWithdrawalAmount is not null - /// - no default market - /// - no outstanding withdrawal requests - /// - available assets is not null - /// - assetIncrease is not null - /// - totalAssets is approx MIN_TOTAL_SUPPLY - /// - lastAvailableAssets is MIN_TOTAL_SUPPLY / 2 - /// - fees are not null - function test_Deposit_When_VaultWithdrawalAmount_IsNotNull() public { - // First there is a swap to convert WETH in OETH - vm.startPrank(bob); - deal(address(oeth), bob, 1_000 * DEFAULT_AMOUNT); - oeth.approve(address(originARM), type(uint256).max); - originARM.swapTokensForExactTokens(oeth, weth, 1e12 / 2, type(uint256).max, bob); - vm.stopPrank(); - - // Then request a withdrawal, this will decrease the available assets - vm.prank(governor); - originARM.requestBaseAssetRedeem(address(oeth), 1e12 / 2); - uint256 lastAvailableAssets = originARM.totalAssets(); - - // Expected values - uint256 expectedShares = originARM.convertToShares(DEFAULT_AMOUNT); - assertApproxEqAbs(expectedShares, DEFAULT_AMOUNT, 1e16, "Shares should be eq amount"); - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.Deposit(alice, DEFAULT_AMOUNT, expectedShares); - // Alice deposits 1 WETH - vm.prank(alice); - originARM.deposit(DEFAULT_AMOUNT); - // Assertions - assertEq( - originARM.totalAssets(), DEFAULT_AMOUNT + lastAvailableAssets, "Last available assets should be updated" - ); - } - - /// @notice Test under the following assumptions: - /// - WETH in the ARM is null (all in market) - /// - OETH in the ARM is null - /// - vaultWithdrawalAmount is null - /// - default market is set - /// - no outstanding withdrawal requests - /// - available assets is not null - /// - assetIncrease is null - /// - totalAssets is approx MIN_TOTAL_SUPPLY - /// - lastAvailableAssets is approx MIN_TOTAL_SUPPLY - /// - fees are not null - function test_Deposit_When_DefaultStrategyIsSet() - public - setARMBuffer(1e18) - addMarket(address(market)) - setActiveMarket(address(market)) - { - // Allocated as been call in the modifier - - // Expected values - uint256 expectedShares = originARM.convertToShares(DEFAULT_AMOUNT); - assertEq(expectedShares, DEFAULT_AMOUNT, "Shares should be eq amount"); - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.Deposit(alice, DEFAULT_AMOUNT, expectedShares); - // Alice deposits 1 WETH - vm.prank(alice); - originARM.deposit(DEFAULT_AMOUNT); - // Assertions - assertEq(originARM.totalAssets(), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "Last available assets should be updated"); - } - - function test_Deposit_When_CapManagerIsSet() public setCapManager setTotalAssetsCapUnlimited { - // Expected values - uint256 expectedShares = originARM.convertToShares(DEFAULT_AMOUNT); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.Deposit(alice, DEFAULT_AMOUNT, expectedShares); - - // Alice deposits 1 WETH - vm.prank(alice); - originARM.deposit(DEFAULT_AMOUNT); - - // Assertions - assertEq(originARM.totalAssets(), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "Last available assets should be updated"); - } - - /// @notice Deposit reverts when the ARM is insolvent (totalAssets floored to MIN_TOTAL_SUPPLY) - /// and there are outstanding withdrawal requests. - function test_RevertWhen_Deposit_Because_Insolvent() public deposit(alice, DEFAULT_AMOUNT) requestRedeemAll(alice) { - // Drain all WETH → rawTotal (0) < outstanding (DEFAULT_AMOUNT) → insolvent - deal(address(weth), address(originARM), 0); - - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY, "totalAssets should be floored at MIN_TOTAL_SUPPLY"); - assertGt(originARM.reservedWithdrawLiquidity(), 0, "should have outstanding requests"); - - vm.expectRevert(bytes4(keccak256("Insolvent()"))); - vm.prank(alice); - originARM.deposit(DEFAULT_AMOUNT); - } - - /// @notice Deposits remain priced from gross assets when escrowed redeem shares share a partial loss. - function test_Deposit_When_OutstandingRequestSharesShareSmallLoss() - public - deposit(alice, DEFAULT_AMOUNT) - requestRedeemAll(alice) - { - uint256 wethAfterLoss = MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT * 9 / 10; - deal(address(weth), address(originARM), wethAfterLoss); - - assertEq(originARM.totalAssets(), wethAfterLoss, "totalAssets should remain gross"); - assertGt(originARM.reservedWithdrawLiquidity(), 0, "should have outstanding requests"); - - deal(address(weth), bob, DEFAULT_AMOUNT); - vm.startPrank(bob); - weth.approve(address(originARM), DEFAULT_AMOUNT); - uint256 bobShares = originARM.deposit(DEFAULT_AMOUNT); - vm.stopPrank(); - - assertGt(bobShares, 0, "bob should receive shares at the loss-adjusted price"); - } - - /// @notice Deposit is allowed when there are outstanding requests but the ARM remains solvent. - /// Documents the totalAssets() > MIN_TOTAL_SUPPLY branch of the insolvent guard. - /// Alice deposits 2x and redeems 50%, leaving LP equity = MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT. - function test_Deposit_When_SolventWithOutstandingRequests() - public - deposit(alice, DEFAULT_AMOUNT * 2) - requestRedeem(alice, 5e17) // 50% of alice's shares → DEFAULT_AMOUNT queued - - { - // rawTotal = MIN_TOTAL_SUPPLY + 2*DEFAULT_AMOUNT, outstanding = DEFAULT_AMOUNT - // totalAssets() = MIN_TOTAL_SUPPLY + DEFAULT_AMOUNT > MIN_TOTAL_SUPPLY → solvent - assertGt(originARM.totalAssets(), MIN_TOTAL_SUPPLY, "should be solvent with LP equity"); - assertGt(originARM.reservedWithdrawLiquidity(), 0, "should have outstanding requests"); - - deal(address(weth), bob, DEFAULT_AMOUNT); - vm.startPrank(bob); - weth.approve(address(originARM), DEFAULT_AMOUNT); - originARM.deposit(DEFAULT_AMOUNT); - vm.stopPrank(); - - assertGt(originARM.balanceOf(bob), 0, "bob should have received shares"); - } - - function test_Deposit_ForSomeoneElse() public { - // Expected values - uint256 expectedShares = originARM.convertToShares(DEFAULT_AMOUNT); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.Deposit(bob, DEFAULT_AMOUNT, expectedShares); - - // Alice deposits 1 WETH - vm.prank(alice); - originARM.deposit(DEFAULT_AMOUNT, bob); - - // Assertions - assertEq(originARM.totalAssets(), DEFAULT_AMOUNT + MIN_TOTAL_SUPPLY, "Last available assets should be updated"); - assertEq(originARM.balanceOf(bob), expectedShares, "Bob should have the shares"); - } -} diff --git a/test/unit/OriginARM/ManageMarket.sol b/test/unit/OriginARM/ManageMarket.sol deleted file mode 100644 index 3bdb9fa1..00000000 --- a/test/unit/OriginARM/ManageMarket.sol +++ /dev/null @@ -1,251 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; -import {AbstractARM} from "contracts/AbstractARM.sol"; - -contract Unit_Concrete_OriginARM_ManageMarket_Test_ is Unit_Shared_Test { - //////////////////////////////////////////////////// - /// --- SETUP - //////////////////////////////////////////////////// - function setUp() public virtual override { - super.setUp(); - - // Give Alice some WETH - deal(address(weth), alice, 1_000 * DEFAULT_AMOUNT); - - // Alice approve max WETH to the ARM - vm.prank(alice); - weth.approve(address(originARM), type(uint256).max); - } - - //////////////////////////////////////////////////// - /// --- REVERTS - //////////////////////////////////////////////////// - function test_RevertWhen_AddMarkets_Because_NotGovernor() public asNotGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); - originARM.addMarkets(new address[](0)); - } - - function test_RevertWhen_AddMarkets_Because_AddressZero() public asGovernor { - vm.expectRevert(bytes4(keccak256("InvalidMarket()"))); - originARM.addMarkets(new address[](1)); - } - - function test_RevertWhen_AddMarkets_Because_MarketAlreadySupported() - public - setARMBuffer(0) - addMarket(address(market)) - setActiveMarket(address(market)) - asGovernor - { - address[] memory strategies = new address[](1); - strategies[0] = address(market); - vm.expectRevert(bytes4(keccak256("MarketAlreadySupported()"))); - originARM.addMarkets(strategies); - } - - function test_RevertWhen_AddMarkets_Because_InvalidMarketAsset() public asGovernor { - address[] memory strategies = new address[](1); - strategies[0] = address(0x123); - // Using mockCall to simulate the asset() function on a simple address - vm.mockCall(strategies[0], abi.encodeWithSignature("asset()"), abi.encode(address(0))); - vm.expectRevert(bytes4(keccak256("InvalidMarketAsset()"))); - originARM.addMarkets(strategies); - } - - function test_RevertWhen_RemoveMarket_Because_NotGovernor() public asNotGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); - originARM.removeMarket(address(market)); - } - - function test_RevertWhen_RemoveMarket_Because_MarketIsAddressZero() public asGovernor { - vm.expectRevert(bytes4(keccak256("InvalidMarket()"))); - originARM.removeMarket(address(0)); - } - - function test_RevertWhen_RemoveMarket_Because_MarketNotSupported() public asGovernor { - vm.expectRevert(bytes4(keccak256("MarketNotSupported()"))); - originARM.removeMarket(address(market)); - } - - function test_RevertWhen_RemoveMarket_Because_MarketIsActive() - public - forceAvailableAssetsToZero - addMarket(address(market)) - setActiveMarket(address(market)) - asGovernor - { - vm.expectRevert(bytes4(keccak256("MarketActive()"))); - originARM.removeMarket(address(market)); - } - - function test_RevertWhen_SetActiveMarket_Because_NotGovernor() public asNotGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOperatorOrOwner()"))); - originARM.setActiveMarket(address(market)); - } - - function test_RevertWhen_SetActiveMarket_Because_MarketNotSupported() public asGovernor { - vm.expectRevert(bytes4(keccak256("MarketNotSupported()"))); - originARM.setActiveMarket(address(market)); - } - - //////////////////////////////////////////////////// - /// --- TESTS - //////////////////////////////////////////////////// - - function test_AddMarkets_Single() public asGovernor { - // Assertions before - assertEq(originARM.supportedMarkets(address(market)), false); - - address[] memory strategies = new address[](1); - strategies[0] = address(market); - vm.expectEmit(address(originARM)); - emit AbstractARM.MarketAdded(address(market)); - originARM.addMarkets(strategies); - - // Assertions after - assertEq(originARM.supportedMarkets(address(market)), true); - } - - function test_AddMarkets_Multiple() public asGovernor { - address[] memory strategies = new address[](2); - strategies[0] = address(market); - strategies[1] = address(0x1234); - // Using mockCall to simulate the asset() function on a simple address - vm.mockCall(strategies[1], abi.encodeWithSignature("asset()"), abi.encode(address(weth))); - - // Assertions before - assertEq(originARM.supportedMarkets(strategies[0]), false); - assertEq(originARM.supportedMarkets(strategies[1]), false); - - vm.expectEmit(address(originARM)); - emit AbstractARM.MarketAdded(strategies[0]); - emit AbstractARM.MarketAdded(strategies[1]); - originARM.addMarkets(strategies); - - // Assertions after - assertEq(originARM.supportedMarkets(strategies[0]), true); - assertEq(originARM.supportedMarkets(strategies[1]), true); - } - - function test_RemoveMarket() public addMarket(address(market)) asGovernor { - // Assertions before - assertEq(originARM.supportedMarkets(address(market)), true); - - vm.expectEmit(address(originARM)); - emit AbstractARM.MarketRemoved(address(market)); - originARM.removeMarket(address(market)); - - // Assertions after - assertEq(originARM.supportedMarkets(address(market)), false); - } - - function test_SetActiveMarket_NoPreviousMarket() - public - forceAvailableAssetsToZero - addMarket(address(market)) - asGovernor - { - // Assertions before - assertEq(originARM.activeMarket(), address(0)); - - vm.expectEmit(address(originARM)); - emit AbstractARM.ActiveMarketUpdated(address(market)); - originARM.setActiveMarket(address(market)); - - // Assertions after - assertEq(originARM.activeMarket(), address(market)); - } - - function test_SetActiveMarket_ToZero() - public - forceAvailableAssetsToZero - addMarket(address(market)) - setActiveMarket(address(market)) - asGovernor - { - // Assertions before - assertEq(originARM.activeMarket(), address(market)); - - vm.expectEmit(address(originARM)); - emit AbstractARM.ActiveMarketUpdated(address(0)); - originARM.setActiveMarket(address(0)); - - // Assertions after - assertEq(originARM.activeMarket(), address(0)); - } - - function test_SetActiveMarket_WithPreviousMarket_Empty() - public - forceAvailableAssetsToZero - addMarket(address(market)) - setActiveMarket(address(market)) - addMarket(address(market2)) - asGovernor - { - // Assertions before - assertEq(originARM.activeMarket(), address(market)); - - vm.expectEmit(address(originARM)); - emit AbstractARM.ActiveMarketUpdated(address(market2)); - originARM.setActiveMarket(address(market2)); - - // Assertions after - assertEq(originARM.activeMarket(), address(market2)); - } - - function test_SetActiveMarket_WithPreviousMarket_NonEmpty_NoShares() - public - addMarket(address(market)) - setActiveMarket(address(market)) - addMarket(address(market2)) - asGovernor - { - // Assertions before - assertEq(originARM.activeMarket(), address(market)); - - vm.expectEmit(address(originARM)); - emit AbstractARM.ActiveMarketUpdated(address(market2)); - originARM.setActiveMarket(address(market2)); - - // Assertions after - assertEq(originARM.activeMarket(), address(market2)); - } - - function test_SetActiveMarket_WithPreviousMarket_NonEmpty_WithShares() - public - deposit(alice, DEFAULT_AMOUNT) - setARMBuffer(0) - addMarket(address(market)) - setActiveMarket(address(market)) - addMarket(address(market2)) - asGovernor - { - // Assertions before - assertEq(originARM.activeMarket(), address(market)); - - vm.expectEmit(address(originARM)); - emit AbstractARM.ActiveMarketUpdated(address(market2)); - originARM.setActiveMarket(address(market2)); - - // Assertions after - assertEq(originARM.activeMarket(), address(market2)); - } - - function test_SetActiveMarket_ToPreviousMarket() - public - addMarket(address(market)) - setActiveMarket(address(market)) - addMarket(address(market2)) - asGovernor - { - // Assertions before - assertEq(originARM.activeMarket(), address(market)); - - originARM.setActiveMarket(address(market)); - - // Assertions after - assertEq(originARM.activeMarket(), address(market)); - } -} diff --git a/test/unit/OriginARM/MigrateLegacyWithdrawQueue.sol b/test/unit/OriginARM/MigrateLegacyWithdrawQueue.sol deleted file mode 100644 index 49f42d8b..00000000 --- a/test/unit/OriginARM/MigrateLegacyWithdrawQueue.sol +++ /dev/null @@ -1,95 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {stdStorage, StdStorage} from "forge-std/Test.sol"; -import {AbstractARM} from "contracts/AbstractARM.sol"; -import {OriginARM} from "contracts/OriginARM.sol"; -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; - -contract Unit_Concrete_OriginARM_MigrateLegacyWithdrawQueue_Test_ is Unit_Shared_Test { - using stdStorage for StdStorage; - - uint256 internal constant LEGACY_PACKED_WITHDRAW_QUEUE_SLOT = 53; - uint256 internal constant NEXT_WITHDRAWAL_INDEX_SLOT = 54; - - function test_RevertWhen_MigrateLegacyWithdrawQueue_Because_NotGovernor() public asNotGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); - originARM.migrateLegacyWithdrawQueue(); - } - - function test_MigrateLegacyWithdrawQueue_When_LegacyQueueIsZero() public asGovernor { - _writeNextWithdrawalIndex(3); - - originARM.migrateLegacyWithdrawQueue(); - - assertEq(originARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); - assertEq(originARM.legacyWithdrawalRequestCount(), 3, "legacy request count"); - } - - function test_MigrateLegacyWithdrawQueue_When_LegacyQueueIsFullyClaimed() public asGovernor { - uint128 legacyQueued = 5 ether; - uint128 legacyClaimed = legacyQueued; - _writeLegacyWithdrawQueue(legacyQueued, legacyClaimed); - _writeNextWithdrawalIndex(3); - - originARM.migrateLegacyWithdrawQueue(); - - assertEq(originARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); - assertEq(originARM.legacyWithdrawalRequestCount(), 3, "legacy request count"); - assertEq( - _readLegacyWithdrawQueue(), _packLegacyWithdrawQueue(legacyQueued, legacyClaimed), "legacy queue preserved" - ); - } - - function test_MigrateLegacyWithdrawQueue_When_LegacyWithdrawalsPending() public asGovernor { - _writeLegacyWithdrawQueue(5 ether, 4 ether); - _writeNextWithdrawalIndex(3); - - originARM.migrateLegacyWithdrawQueue(); - - assertEq(originARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); - assertEq(originARM.legacyWithdrawalRequestCount(), 3, "legacy request count"); - assertEq(_readLegacyWithdrawQueue(), _packLegacyWithdrawQueue(5 ether, 4 ether), "legacy queue preserved"); - } - - function test_RevertWhen_MigrateLegacyWithdrawQueue_Because_LegacyOriginWithdrawalsPending() public asGovernor { - stdstore.target(address(originARM)).sig(originARM.vaultWithdrawalAmount.selector) - .checked_write(uint256(1 ether)); - - vm.expectRevert(AbstractARM.LegacyWithdrawalsPending.selector); - originARM.migrateLegacyWithdrawQueue(); - } - - function test_RevertWhen_MigrateLegacyWithdrawQueue_Because_NewQueueAlreadyUsed() - public - deposit(alice, DEFAULT_AMOUNT) - { - vm.prank(alice); - originARM.requestRedeem(DEFAULT_AMOUNT); - - vm.prank(governor); - vm.expectRevert(bytes4(keccak256("AlreadyMigrated()"))); - originARM.migrateLegacyWithdrawQueue(); - } - - function _writeLegacyWithdrawQueue(uint128 legacyQueued, uint128 legacyClaimed) internal { - uint256 packedLegacyQueue = _packLegacyWithdrawQueue(legacyQueued, legacyClaimed); - - vm.store(address(originARM), bytes32(LEGACY_PACKED_WITHDRAW_QUEUE_SLOT), bytes32(packedLegacyQueue)); - assertEq(_readLegacyWithdrawQueue(), packedLegacyQueue, "packed legacy queue"); - assertEq(originARM.reservedWithdrawLiquidity(), 0, "reserved liquidity"); - } - - function _readLegacyWithdrawQueue() internal view returns (uint256) { - return uint256(vm.load(address(originARM), bytes32(LEGACY_PACKED_WITHDRAW_QUEUE_SLOT))); - } - - function _writeNextWithdrawalIndex(uint256 nextWithdrawalIndex) internal { - vm.store(address(originARM), bytes32(NEXT_WITHDRAWAL_INDEX_SLOT), bytes32(nextWithdrawalIndex)); - assertEq(originARM.nextWithdrawalIndex(), nextWithdrawalIndex, "next withdrawal index"); - } - - function _packLegacyWithdrawQueue(uint128 legacyQueued, uint128 legacyClaimed) internal pure returns (uint256) { - return uint256(legacyQueued) | (uint256(legacyClaimed) << 128); - } -} diff --git a/test/unit/OriginARM/Pause.sol b/test/unit/OriginARM/Pause.sol deleted file mode 100644 index e70147cd..00000000 --- a/test/unit/OriginARM/Pause.sol +++ /dev/null @@ -1,220 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {AbstractARM} from "contracts/AbstractARM.sol"; -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; - -contract Unit_Concrete_OriginARM_Pause_Test_ is Unit_Shared_Test { - function test_Pause_ByOperator() public { - vm.expectEmit(address(originARM)); - emit AbstractARM.Paused(operator); - - vm.prank(operator); - originARM.pause(); - - assertEq(originARM.paused(), true, "ARM should be paused"); - } - - function test_Pause_ByGovernor() public { - vm.expectEmit(address(originARM)); - emit AbstractARM.Paused(governor); - - vm.prank(governor); - originARM.pause(); - - assertEq(originARM.paused(), true, "ARM should be paused"); - } - - function test_RevertWhen_Pause_Because_NotOperatorNorGovernor() public asNotOperatorNorGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOperatorOrOwner()"))); - originARM.pause(); - } - - function test_Unpause_ByGovernor() public { - vm.prank(operator); - originARM.pause(); - - vm.expectEmit(address(originARM)); - emit AbstractARM.Unpaused(governor); - - vm.prank(governor); - originARM.unpause(); - - assertEq(originARM.paused(), false, "ARM should be unpaused"); - } - - function test_RevertWhen_Unpause_Because_Operator() public { - vm.prank(operator); - originARM.pause(); - - vm.prank(operator); - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); - originARM.unpause(); - } - - function test_RevertWhen_Unpause_Because_RandomCaller() public asNotOperatorNorGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); - originARM.unpause(); - } - - function test_RevertWhen_Deposit_Because_Paused() public { - _pause(); - deal(address(weth), alice, DEFAULT_AMOUNT); - - vm.startPrank(alice); - weth.approve(address(originARM), DEFAULT_AMOUNT); - vm.expectRevert(AbstractARM.ContractPaused.selector); - originARM.deposit(DEFAULT_AMOUNT); - vm.stopPrank(); - } - - function test_RevertWhen_DepositToReceiver_Because_Paused() public { - _pause(); - deal(address(weth), alice, DEFAULT_AMOUNT); - - vm.startPrank(alice); - weth.approve(address(originARM), DEFAULT_AMOUNT); - vm.expectRevert(AbstractARM.ContractPaused.selector); - originARM.deposit(DEFAULT_AMOUNT, bob); - vm.stopPrank(); - } - - function test_RevertWhen_RequestRedeem_Because_Paused() public deposit(alice, DEFAULT_AMOUNT) { - vm.prank(operator); - originARM.pause(); - - vm.prank(alice); - vm.expectRevert(AbstractARM.ContractPaused.selector); - originARM.requestRedeem(DEFAULT_AMOUNT); - } - - function test_RevertWhen_SwapExactTokensForTokens_Sig1_Because_Paused() public { - _pause(); - deal(address(oeth), alice, DEFAULT_AMOUNT); - - vm.startPrank(alice); - oeth.approve(address(originARM), DEFAULT_AMOUNT); - vm.expectRevert(AbstractARM.ContractPaused.selector); - originARM.swapExactTokensForTokens(oeth, weth, DEFAULT_AMOUNT, 0, alice); - vm.stopPrank(); - } - - function test_RevertWhen_SwapExactTokensForTokens_Sig2_Because_Paused() public { - _pause(); - deal(address(oeth), alice, DEFAULT_AMOUNT); - address[] memory path = _path(address(oeth), address(weth)); - - vm.startPrank(alice); - oeth.approve(address(originARM), DEFAULT_AMOUNT); - vm.expectRevert(AbstractARM.ContractPaused.selector); - originARM.swapExactTokensForTokens(DEFAULT_AMOUNT, 0, path, alice, block.timestamp + 1); - vm.stopPrank(); - } - - function test_RevertWhen_SwapTokensForExactTokens_Sig1_Because_Paused() public { - _pause(); - deal(address(oeth), alice, DEFAULT_AMOUNT); - - vm.startPrank(alice); - oeth.approve(address(originARM), DEFAULT_AMOUNT); - vm.expectRevert(AbstractARM.ContractPaused.selector); - originARM.swapTokensForExactTokens(oeth, weth, 1e12, DEFAULT_AMOUNT, alice); - vm.stopPrank(); - } - - function test_RevertWhen_SwapTokensForExactTokens_Sig2_Because_Paused() public { - _pause(); - deal(address(oeth), alice, DEFAULT_AMOUNT); - address[] memory path = _path(address(oeth), address(weth)); - - vm.startPrank(alice); - oeth.approve(address(originARM), DEFAULT_AMOUNT); - vm.expectRevert(AbstractARM.ContractPaused.selector); - originARM.swapTokensForExactTokens(1e12, DEFAULT_AMOUNT, path, alice, block.timestamp + 1); - vm.stopPrank(); - } - - function test_RevertWhen_ClaimRedeem_Because_Paused() public deposit(alice, DEFAULT_AMOUNT) { - vm.prank(alice); - originARM.requestRedeem(DEFAULT_AMOUNT); - - _pause(); - vm.warp(block.timestamp + CLAIM_DELAY); - - vm.prank(alice); - vm.expectRevert(AbstractARM.ContractPaused.selector); - originARM.claimRedeem(0); - } - - function test_CollectFees_WhenPaused() public { - uint256 amountOut = 1e12; - deal(address(weth), address(originARM), DEFAULT_AMOUNT); - deal(address(oeth), alice, DEFAULT_AMOUNT); - - vm.startPrank(alice); - oeth.approve(address(originARM), DEFAULT_AMOUNT); - originARM.swapTokensForExactTokens(oeth, weth, amountOut, DEFAULT_AMOUNT, alice); - vm.stopPrank(); - - uint256 fees = originARM.feesAccrued(); - _pause(); - - vm.expectEmit(address(originARM)); - emit AbstractARM.FeeCollected(feeCollector, fees); - originARM.collectFees(); - } - - function test_OperationalFunctions_WhenPaused() public { - _pause(); - - uint256 crossPrice = _crossPrice(); - vm.prank(operator); - vm.expectEmit(address(originARM)); - emit AbstractARM.TraderateChanged(address(oeth), crossPrice - 1, crossPrice, 2 ether, 3 ether); - originARM.setPrices(address(oeth), crossPrice - 1, crossPrice, 2 ether, 3 ether); - - vm.prank(operator); - vm.expectEmit(address(originARM)); - emit AbstractARM.ARMBufferUpdated(0); - originARM.setARMBuffer(0); - - address[] memory markets = new address[](1); - markets[0] = address(market); - vm.prank(governor); - originARM.addMarkets(markets); - - vm.prank(operator); - vm.expectEmit(address(originARM)); - emit AbstractARM.ActiveMarketUpdated(address(market)); - originARM.setActiveMarket(address(market)); - - deal(address(weth), address(originARM), DEFAULT_AMOUNT); - originARM.allocate(); - } - - function test_BaseAssetRedeemAndClaim_WhenPaused() public { - deal(address(oeth), address(originARM), DEFAULT_AMOUNT); - deal(address(weth), address(vault), DEFAULT_AMOUNT); - _pause(); - - vm.prank(operator); - originARM.requestBaseAssetRedeem(address(oeth), DEFAULT_AMOUNT); - (,,,,, uint120 pendingRedeemAssets,,) = originARM.baseAssetConfigs(address(oeth)); - assertEq(pendingRedeemAssets, DEFAULT_AMOUNT, "Pending redeem assets"); - - vm.prank(operator); - (,, uint256 assetsReceived) = originARM.claimBaseAssetRedeem(address(oeth), DEFAULT_AMOUNT); - assertEq(assetsReceived, DEFAULT_AMOUNT, "Assets received"); - } - - function _pause() internal { - vm.prank(operator); - originARM.pause(); - } - - function _path(address inToken, address outToken) internal pure returns (address[] memory path) { - path = new address[](2); - path[0] = inToken; - path[1] = outToken; - } -} diff --git a/test/unit/OriginARM/Reentrancy.sol b/test/unit/OriginARM/Reentrancy.sol deleted file mode 100644 index 219b58a4..00000000 --- a/test/unit/OriginARM/Reentrancy.sol +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; -import {MockERC20} from "@solmate/test/utils/mocks/MockERC20.sol"; - -import {IERC20} from "contracts/Interfaces.sol"; -import {OriginARM} from "contracts/OriginARM.sol"; -import {OriginAssetAdapter} from "contracts/adapters/OriginAssetAdapter.sol"; -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; -import {MockVault} from "test/unit/mocks/MockVault.sol"; - -contract Unit_Concrete_OriginARM_Reentrancy_Test_ is Unit_Shared_Test { - ReentrantGuardHarnessARM internal harness; - - function setUp() public virtual override { - super.setUp(); - - ReentrantGuardHarnessARM harnessImpl = - new ReentrantGuardHarnessARM(address(oeth), address(weth), address(vault), CLAIM_DELAY, 1e7, 1e18); - - vm.prank(governor); - originARMProxy.upgradeTo(address(harnessImpl)); - - harness = ReentrantGuardHarnessARM(address(originARM)); - } - - function test_RevertWhen_BuySideTransferFromReentersSwap() public deposit(alice, 4 * DEFAULT_AMOUNT) { - ReentrantBaseToken reentrantBase = new ReentrantBaseToken(); - OriginAssetAdapter adapter = new OriginAssetAdapter( - address(originARM), - address(reentrantBase), - address(weth), - address(new MockVault(IERC20(address(reentrantBase)), weth)) - ); - adapter.initialize(); - - vm.prank(governor); - originARM.addBaseAsset( - address(reentrantBase), - address(adapter), - 992 * 1e33, - 1001 * 1e33, - type(uint128).max, - type(uint128).max, - 1e36, - true - ); - - uint256 sharesToRedeem = originARM.balanceOf(alice) / 2; - vm.prank(alice); - originARM.requestRedeem(sharesToRedeem); - - address swapper = makeAddr("reentrant swapper"); - reentrantBase.mint(swapper, DEFAULT_AMOUNT); - reentrantBase.mint(address(reentrantBase), DEFAULT_AMOUNT); - reentrantBase.configure(originARM, weth, swapper, DEFAULT_AMOUNT); - - vm.startPrank(swapper); - reentrantBase.approve(address(originARM), type(uint256).max); - - vm.expectRevert(ReentrancyGuardUpgradeable.ReentrancyGuardReentrantCall.selector); - originARM.swapTokensForExactTokens( - IERC20(address(reentrantBase)), weth, DEFAULT_AMOUNT, type(uint256).max, swapper - ); - - vm.stopPrank(); - } - - function test_RevertWhen_DepositReentersDeposit() public { - vm.expectRevert(ReentrancyGuardUpgradeable.ReentrancyGuardReentrantCall.selector); - harness.enterThenDeposit(DEFAULT_AMOUNT); - } - - function test_RevertWhen_DepositToReceiverReentersDepositToReceiver() public { - vm.expectRevert(ReentrancyGuardUpgradeable.ReentrancyGuardReentrantCall.selector); - harness.enterThenDeposit(DEFAULT_AMOUNT, alice); - } - - function test_RevertWhen_RequestRedeemReentersRequestRedeem() public { - vm.expectRevert(ReentrancyGuardUpgradeable.ReentrancyGuardReentrantCall.selector); - harness.enterThenRequestRedeem(DEFAULT_AMOUNT); - } - - function test_RevertWhen_ClaimRedeemReentersClaimRedeem() public { - vm.expectRevert(ReentrancyGuardUpgradeable.ReentrancyGuardReentrantCall.selector); - harness.enterThenClaimRedeem(0); - } -} - -contract ReentrantGuardHarnessARM is OriginARM { - constructor( - address _otoken, - address _liquidityAsset, - address _vault, - uint256 _claimDelay, - uint256 _minSharesToRedeem, - int256 _allocateThreshold - ) OriginARM(_otoken, _liquidityAsset, _vault, _claimDelay, _minSharesToRedeem, _allocateThreshold) {} - - function enterThenDeposit(uint256 assets) external nonReentrant { - this.deposit(assets); - } - - function enterThenDeposit(uint256 assets, address receiver) external nonReentrant { - this.deposit(assets, receiver); - } - - function enterThenRequestRedeem(uint256 shares) external nonReentrant { - this.requestRedeem(shares); - } - - function enterThenClaimRedeem(uint256 requestId) external nonReentrant { - this.claimRedeem(requestId); - } -} - -contract ReentrantBaseToken is MockERC20 { - OriginARM public arm; - IERC20 public outToken; - address public recipient; - uint256 public amountOut; - bool public reenter; - - constructor() MockERC20("Reentrant OETH", "rOETH", 18) {} - - function configure(OriginARM _arm, IERC20 _outToken, address _recipient, uint256 _amountOut) external { - arm = _arm; - outToken = _outToken; - recipient = _recipient; - amountOut = _amountOut; - reenter = true; - allowance[address(this)][address(_arm)] = type(uint256).max; - } - - function transferFrom(address from, address to, uint256 amount) public override returns (bool) { - if (reenter) { - reenter = false; - arm.swapTokensForExactTokens(IERC20(address(this)), outToken, amountOut, type(uint256).max, recipient); - } - - return super.transferFrom(from, to, amount); - } -} diff --git a/test/unit/OriginARM/RequestRedeem.sol b/test/unit/OriginARM/RequestRedeem.sol deleted file mode 100644 index ebcef9dc..00000000 --- a/test/unit/OriginARM/RequestRedeem.sol +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; -import {AbstractARM} from "contracts/AbstractARM.sol"; -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; - -contract Unit_Concrete_OriginARM_RequestRedeem_Test_ is Unit_Shared_Test { - using SafeCast for int256; - using SafeCast for int128; - - function setUp() public virtual override { - super.setUp(); - - // Give Alice some WETH - deal(address(weth), alice, 1_000 * DEFAULT_AMOUNT); - // Alice approve max WETH to the ARM - vm.startPrank(alice); - weth.approve(address(originARM), type(uint256).max); - originARM.deposit(DEFAULT_AMOUNT); - vm.stopPrank(); - } - - function test_RequestRedeem() public { - // Expected values - uint256 expectedShares = originARM.convertToShares(DEFAULT_AMOUNT); - uint256 expectedOETH = originARM.convertToAssets(expectedShares); - uint256 requestIndex = originARM.nextWithdrawalIndex(); - uint128 queuedShares = originARM.withdrawsQueuedShares(); - uint256 lastAvailableAssets = originARM.totalAssets(); - uint256 previewRedeem = originARM.previewRedeem(DEFAULT_AMOUNT); - assertEq(previewRedeem, expectedShares, "Preview redeem should match expected shares"); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.RedeemRequested(alice, 0, expectedOETH, DEFAULT_AMOUNT, block.timestamp + CLAIM_DELAY); - - // Alice requests a redeem of 1 WETH - vm.prank(alice); - originARM.requestRedeem(DEFAULT_AMOUNT); - - (address withdrawer, bool claimed, uint256 requestTimestamp, uint256 amount, uint256 queued_, uint256 shares) = - originARM.withdrawalRequests(0); - // Assertions - assertEq(originARM.totalAssets(), lastAvailableAssets, "Total assets should include escrowed shares"); - assertEq(originARM.reservedWithdrawLiquidity(), DEFAULT_AMOUNT, "Reserved liquidity should be updated"); - assertEq(originARM.withdrawsQueuedShares(), queuedShares + expectedShares, "Queued shares should be updated"); - assertEq(originARM.nextWithdrawalIndex(), requestIndex + 1, "Next withdrawal index should be updated"); - assertEq(withdrawer, alice, "Withdrawer should be Alice"); - assertEq(claimed, false, "Claimed should be false"); - assertEq(requestTimestamp, block.timestamp + CLAIM_DELAY, "Request timestamp should be updated"); - assertEq(amount, DEFAULT_AMOUNT, "Amount should be updated"); - assertEq(queued_, queuedShares + expectedShares, "Queued should be updated"); - assertEq(shares, expectedShares, "Shares should be updated"); - } - - function test_RevertWhen_RequestRedeem_Because_ZeroShares() public { - vm.prank(alice); - vm.expectRevert(bytes4(keccak256("ZeroShares()"))); - originARM.requestRedeem(0); - } -} diff --git a/test/unit/OriginARM/Setters.sol b/test/unit/OriginARM/Setters.sol deleted file mode 100644 index 55513110..00000000 --- a/test/unit/OriginARM/Setters.sol +++ /dev/null @@ -1,343 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; -import {AbstractARM} from "contracts/AbstractARM.sol"; - -contract Unit_Concrete_OriginARM_Setters_Test_ is Unit_Shared_Test { - //////////////////////////////////////////////////// - /// --- REVERT - //////////////////////////////////////////////////// - function test_RevertWhen_SetFeeCollector_Because_NotGovernor() public asNotGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); - originARM.setFeeCollector(address(0)); - } - - function test_RevertWhen_SetFeeCollector_Because_FeeCollectorIsZero() public asGovernor { - vm.expectRevert(bytes4(keccak256("InvalidFeeCollector()"))); - originARM.setFeeCollector(address(0)); - } - - function test_RevertWhen_SetFee_Because_NotGovernor() public asNotGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); - originARM.setFee(0); - } - - function test_RevertWhen_SetFee_Because_FeeIsTooHigh() public asGovernor { - vm.expectRevert(bytes4(keccak256("FeeTooHigh()"))); - originARM.setFee(FEE_SCALE / 2 + 1); - } - - function test_RevertWhen_SetCapManager_Because_NotGovernor() public asNotGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); - originARM.setCapManager(address(0)); - } - - function test_RevertWhen_SetARMBuffer_Because_NotGovernorNorOperator() public asRandomCaller { - vm.expectRevert(bytes4(keccak256("OnlyOperatorOrOwner()"))); - originARM.setARMBuffer(0); - } - - function test_RevertWhen_SetARMBuffer_Because_Above1e18() public asGovernor { - vm.expectRevert(bytes4(keccak256("InvalidARMBuffer()"))); - originARM.setARMBuffer(1e18 + 1); - } - - function test_RevertWhen_SetPrices_Because_NotOperator() public asNotOperatorNorGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOperatorOrOwner()"))); - originARM.setPrices(address(oeth), 0, 0, 0, 0); - } - - function test_RevertWhen_SetPrices_Because_SellPriceTooLow() public asOperator { - uint256 crossPrice = _crossPrice(); - vm.expectRevert(bytes4(keccak256("SellPriceTooLow()"))); - originARM.setPrices(address(oeth), 0, crossPrice - 1, 0, 0); - } - - function test_RevertWhen_SetPrices_Because_BuyPriceTooHigh() public asOperator { - uint256 crossPrice = _crossPrice(); - vm.expectRevert(bytes4(keccak256("InvalidBuyPrice()"))); - originARM.setPrices(address(oeth), crossPrice, crossPrice, 0, 0); - } - - function test_RevertWhen_SetPrices_Because_BuyPriceTooLow() public asOperator { - uint256 crossPrice = _crossPrice(); - vm.expectRevert(bytes4(keccak256("InvalidBuyPrice()"))); - originARM.setPrices(address(oeth), MAX_CROSS_PRICE_DEVIATION - 1, crossPrice, 0, 0); - } - - function test_SetPrices_WithMinimumBuyPrice() public asOperator { - uint256 crossPrice = _crossPrice(); - vm.expectEmit(address(originARM)); - emit AbstractARM.TraderateChanged(address(oeth), MAX_CROSS_PRICE_DEVIATION, crossPrice, 0, 0); - - originARM.setPrices(address(oeth), MAX_CROSS_PRICE_DEVIATION, crossPrice, 0, 0); - assertEq(_buyPrice(), MAX_CROSS_PRICE_DEVIATION, "Wrong buy price"); - } - - function test_RevertWhen_SetCrossPrice_Because_NotGovernor() public asNotGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); - originARM.setCrossPrice(address(oeth), 0); - } - - function test_RevertWhen_SetCrossPrice_Because_CrossPriceTooLow() public asGovernor { - // Far bellow the limit - vm.expectRevert(bytes4(keccak256("CrossPriceTooLow()"))); - originARM.setCrossPrice(address(oeth), 0); - - // Just below the limit - uint256 priceScale = PRICE_SCALE; - uint256 maxCrossPriceDeviation = MAX_CROSS_PRICE_DEVIATION; - vm.expectRevert(bytes4(keccak256("CrossPriceTooLow()"))); - originARM.setCrossPrice(address(oeth), priceScale - maxCrossPriceDeviation - 1); - } - - function test_RevertWhen_SetCrossPrice_Because_CrossPriceTooHigh() public asGovernor { - // Far above the limit - vm.expectRevert(bytes4(keccak256("CrossPriceTooHigh()"))); - originARM.setCrossPrice(address(oeth), type(uint256).max); - - // Just above the limit - uint256 priceScale = PRICE_SCALE; - vm.expectRevert(bytes4(keccak256("CrossPriceTooHigh()"))); - originARM.setCrossPrice(address(oeth), priceScale + 1); - } - - function test_RevertWhen_SetCrossPrice_Because_SellPriceTooLow() public asGovernor { - // Fecth useful data - uint256 priceScale = PRICE_SCALE; - uint256 maxCrossPriceDeviation = MAX_CROSS_PRICE_DEVIATION; - - // Reduce the cross price to be able to reduce the sell price after - originARM.setCrossPrice(address(oeth), priceScale - maxCrossPriceDeviation); - - // Set sellT1 to the minimum value (crossPrice - 1) - originARM.setPrices(address(oeth), PRICE_SCALE / 2, _crossPrice(), type(uint128).max, type(uint128).max); - - // Now we have enough space between PRICE_SCALE and sellT1 to set the cross price to a wrong value - uint256 sellT1 = _sellPrice(); - vm.expectRevert(bytes4(keccak256("SellPriceTooLow()"))); - originARM.setCrossPrice(address(oeth), sellT1 + 1); - } - - function test_RevertWhen_SetCrossPrice_Because_BuyPriceTooHigh() public asGovernor { - // Fecth useful data - uint256 priceScale = PRICE_SCALE; - uint256 maxCrossPriceDeviation = MAX_CROSS_PRICE_DEVIATION; - - // Reduce the cross price to be able to reduce the buy price after - originARM.setCrossPrice(address(oeth), priceScale - (maxCrossPriceDeviation) / 2); - - // Set sellT1 to the maximul value (PRICE_SCALE) and buyT1 to the minimum value (crossPrice - 1) - uint256 crossPrice = _crossPrice(); - originARM.setPrices(address(oeth), crossPrice - 1, priceScale, type(uint128).max, type(uint128).max); - - // Now we have enough space between PRICE_SCALE and buyT1 to set the cross price to a wrong value - vm.expectRevert(bytes4(keccak256("InvalidBuyPrice()"))); - originARM.setCrossPrice(address(oeth), priceScale - maxCrossPriceDeviation); - } - - function test_RevertWhen_SetCrossPrice_Because_TooManyBaseAssets() public asGovernor { - uint256 crossPrice = _crossPrice(); - - // Simlate OETH in the ARM. - deal(address(oeth), address(originARM), 1e18); - vm.expectRevert(bytes4(keccak256("TooManyBaseAssets()"))); - originARM.setCrossPrice(address(oeth), crossPrice - 1); - } - - function test_RevertWhen_SetCrossPrice_Because_TooManyQueuedBaseAssets() public asGovernor { - uint256 crossPrice = _crossPrice(); - - // Queue OETH for protocol withdrawal so it is no longer held directly by the ARM. - deal(address(oeth), address(originARM), 1e18); - originARM.requestBaseAssetRedeem(address(oeth), 1e18); - assertEq(oeth.balanceOf(address(originARM)), 0, "ARM OETH balance"); - - vm.expectRevert(bytes4(keccak256("TooManyBaseAssets()"))); - originARM.setCrossPrice(address(oeth), crossPrice - 1); - } - - //////////////////////////////////////////////////// - /// --- TESTS - //////////////////////////////////////////////////// - function test_SetFeeCollector() public asGovernor { - address newCollector = vm.randomAddress(); - assertNotEq(originARM.feeCollector(), newCollector, "Wrong fee collector"); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.FeeCollectorUpdated(newCollector); - - originARM.setFeeCollector(newCollector); - assertEq(originARM.feeCollector(), newCollector, "Wrong fee collector"); - } - - function test_SetFee_When_NothingToClaim() public asGovernor { - // In this situation there is nothing to claim as we are right after deployment - // and no swap has been done yet, so no fee has to be collected. - uint256 newFee = originARM.fee() + 1; - assertNotEq(originARM.fee(), newFee, "Wrong fee"); - uint256 feeCollectorBalanceBefore = weth.balanceOf(originARM.feeCollector()); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.FeeUpdated(newFee); - - originARM.setFee(newFee); - assertEq(originARM.fee(), newFee, "Wrong fee"); - assertEq( - _swapFeeMultiplier(_buyPrice(), _crossPrice(), originARM.fee()), - _expectedSwapFeeMultiplier(_buyPrice(), _crossPrice(), newFee), - "Wrong swap fee multiplier" - ); - assertEq(weth.balanceOf(originARM.feeCollector()), feeCollectorBalanceBefore, "Wrong fee collector balance"); - } - - function test_SetFee_When_SomethingToClaim() public swapAllWETHForOETH swapAllOETHForWETH asGovernor { - // Swap one way and then the other way to have some fees to claim and liquidity to claim it. - uint256 newFee = originARM.fee() + 1; - assertNotEq(originARM.fee(), newFee, "Wrong fee"); - uint256 feeToCollect = originARM.feesAccrued(); - address feeCollector = originARM.feeCollector(); - uint256 feeCollectorBalanceBefore = weth.balanceOf(feeCollector); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.FeeCollected(feeCollector, feeToCollect); - vm.expectEmit(address(originARM)); - emit AbstractARM.FeeUpdated(newFee); - - originARM.setFee(newFee); - assertEq(originARM.fee(), newFee, "Wrong fee"); - assertEq( - _swapFeeMultiplier(_buyPrice(), _crossPrice(), originARM.fee()), - _expectedSwapFeeMultiplier(_buyPrice(), _crossPrice(), newFee), - "Wrong swap fee multiplier" - ); - assertEq(weth.balanceOf(feeCollector), feeCollectorBalanceBefore + feeToCollect, "Wrong fee collector balance"); - } - - function test_SetCapManager_ToNotZero() public asGovernor { - address newCapManager = vm.randomAddress(); - assertNotEq(originARM.capManager(), newCapManager, "Wrong cap manager"); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.CapManagerUpdated(newCapManager); - - originARM.setCapManager(newCapManager); - assertEq(originARM.capManager(), newCapManager, "Wrong cap manager"); - } - - function test_SetCapManager_ToZero() public asGovernor { - address newCapManager = address(0); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.CapManagerUpdated(newCapManager); - - originARM.setCapManager(newCapManager); - assertEq(originARM.capManager(), newCapManager, "Wrong cap manager"); - } - - function test_SetARMBuffer() public asGovernor { - uint256 newBuffer = originARM.armBuffer() + 1; - assertNotEq(originARM.armBuffer(), newBuffer, "Wrong buffer"); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.ARMBufferUpdated(newBuffer); - - originARM.setARMBuffer(newBuffer); - assertEq(originARM.armBuffer(), newBuffer, "Wrong buffer"); - } - - function test_SetPrices() public asOperator { - uint256 crossPrice = _crossPrice(); - uint256 newSellPrice = crossPrice; - uint256 newBuyPrice = crossPrice - 1; - uint256 newBuyLiquidity = 5 ether; - uint256 newSellLiquidity = 7 ether; - assertNotEq(_sellPrice(), newSellPrice, "Identical sell price"); - assertNotEq(_buyPrice(), newBuyPrice, "Identical buy price"); - assertNotEq(_buyLiquidityRemaining(), newBuyLiquidity, "Identical buy liquidity"); - assertNotEq(_sellLiquidityRemaining(), newSellLiquidity, "Identical sell liquidity"); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.TraderateChanged(address(oeth), newBuyPrice, newSellPrice, newBuyLiquidity, newSellLiquidity); - - originARM.setPrices(address(oeth), newBuyPrice, newSellPrice, newBuyLiquidity, newSellLiquidity); - - // Assertions - assertEq(_sellPrice(), newSellPrice, "Wrong sell price"); - assertEq(_buyPrice(), newBuyPrice, "Wrong buy price"); - assertEq(_buyLiquidityRemaining(), newBuyLiquidity, "Wrong buy liquidity"); - assertEq(_sellLiquidityRemaining(), newSellLiquidity, "Wrong sell liquidity"); - assertEq( - _swapFeeMultiplier(_buyPrice(), _crossPrice(), originARM.fee()), - _expectedSwapFeeMultiplier(newBuyPrice, _crossPrice(), originARM.fee()), - "Wrong swap fee multiplier" - ); - } - - function test_SetPrices_ResetsRemainingLiquidity() public asOperator { - uint256 crossPrice = _crossPrice(); - - originARM.setPrices(address(oeth), crossPrice - 1, crossPrice, 3 ether, 4 ether); - - deal(address(weth), address(originARM), 10 ether); - deal(address(oeth), alice, 2 ether); - vm.startPrank(alice); - oeth.approve(address(originARM), type(uint256).max); - originARM.swapTokensForExactTokens(oeth, weth, 1 ether, type(uint256).max, alice); - vm.stopPrank(); - - assertEq(_buyLiquidityRemaining(), 2 ether, "Buy liquidity not consumed"); - - vm.prank(operator); - originARM.setPrices(address(oeth), crossPrice - 2, crossPrice, 8 ether, 9 ether); - - assertEq(_buyLiquidityRemaining(), 8 ether, "Buy liquidity not reset"); - assertEq(_sellLiquidityRemaining(), 9 ether, "Sell liquidity not reset"); - } - - function test_SetCrossPrice_Below() public asGovernor { - uint256 crossPrice = _crossPrice(); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.CrossPriceUpdated(address(oeth), crossPrice - 1); - - originARM.setCrossPrice(address(oeth), crossPrice - 1); - - assertEq(_crossPrice(), crossPrice - 1, "Wrong cross price"); - } - - function test_SetCrossPrice_Above() public asGovernor { - uint256 crossPrice = _crossPrice(); - - // Reduce the cross price to be able to increase it after - originARM.setCrossPrice(address(oeth), crossPrice - 1); - crossPrice = _crossPrice(); - - // Expected event - vm.expectEmit(address(originARM)); - emit AbstractARM.CrossPriceUpdated(address(oeth), crossPrice + 1); - - originARM.setCrossPrice(address(oeth), crossPrice + 1); - - assertEq(_crossPrice(), crossPrice + 1, "Wrong cross price"); - } - - function _expectedSwapFeeMultiplier(uint256 buyT1, uint256 crossPrice, uint256 fee) - internal - view - returns (uint256) - { - uint256 priceScale = PRICE_SCALE; - if (buyT1 == 0 || fee == 0) return 0; - return (crossPrice - buyT1) * fee * priceScale / (buyT1 * FEE_SCALE); - } -} diff --git a/test/unit/OriginARM/SwapLiquidityFromMarket.sol b/test/unit/OriginARM/SwapLiquidityFromMarket.sol deleted file mode 100644 index 539170dd..00000000 --- a/test/unit/OriginARM/SwapLiquidityFromMarket.sol +++ /dev/null @@ -1,259 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; -import {OriginARM} from "contracts/OriginARM.sol"; -import {Proxy} from "contracts/Proxy.sol"; -import {IERC20} from "contracts/Interfaces.sol"; -import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; - -contract Unit_Concrete_OriginARM_SwapLiquidityFromMarket_Test_ is Unit_Shared_Test { - function test_SwapExactTokensForTokens_WithMarketShortfall_WithdrawsExactShortfall() - public - deposit(alice, 2 * DEFAULT_AMOUNT) - addMarket(address(market)) - setActiveMarket(address(market)) - { - address swapper = makeAddr("swapper"); - uint256 amountIn = DEFAULT_AMOUNT / 2; - uint256 expectedAmountOut = amountIn * _buyPrice() / 1e36; - vm.prank(operator); - originARM.setPrices(address(oeth), 992 * 1e33, 1001 * 1e33, expectedAmountOut, type(uint128).max); - - deal(address(oeth), swapper, amountIn); - vm.startPrank(swapper); - oeth.approve(address(originARM), amountIn); - - uint256 marketBalanceBefore = market.balanceOf(address(originARM)); - uint256[] memory amounts = originARM.swapExactTokensForTokens(oeth, weth, amountIn, expectedAmountOut, swapper); - - vm.stopPrank(); - - assertEq(amounts[0], amountIn, "input amount"); - assertEq(amounts[1], expectedAmountOut, "output amount"); - assertEq(weth.balanceOf(address(originARM)), 0, "no extra WETH should stay in ARM"); - assertEq(market.balanceOf(address(originARM)), marketBalanceBefore - expectedAmountOut, "market shortfall only"); - assertEq(_buyLiquidityRemaining(), 0, "buy cap not consumed"); - } - - function test_SwapTokensForExactTokens_WithMarketShortfall_WithdrawsExactShortfall() - public - deposit(alice, 2 * DEFAULT_AMOUNT) - addMarket(address(market)) - setActiveMarket(address(market)) - { - address swapper = makeAddr("swapper"); - uint256 amountOut = DEFAULT_AMOUNT / 2; - vm.prank(operator); - originARM.setPrices(address(oeth), 992 * 1e33, 1001 * 1e33, amountOut, type(uint128).max); - - deal(address(oeth), swapper, DEFAULT_AMOUNT); - vm.startPrank(swapper); - oeth.approve(address(originARM), type(uint256).max); - - uint256 marketBalanceBefore = market.balanceOf(address(originARM)); - uint256[] memory amounts = originARM.swapTokensForExactTokens(oeth, weth, amountOut, type(uint256).max, swapper); - - vm.stopPrank(); - - assertEq(amounts[1], amountOut, "exact output"); - assertEq(weth.balanceOf(address(originARM)), 0, "no extra WETH should stay in ARM"); - assertEq(market.balanceOf(address(originARM)), marketBalanceBefore - amountOut, "market shortfall only"); - assertEq(_buyLiquidityRemaining(), 0, "buy cap not consumed"); - } - - function test_SwapWithdrawFromMarket_PreservesQueuedRedeemLiquidity() - public - deposit(alice, 4 * DEFAULT_AMOUNT) - addMarket(address(market)) - setActiveMarket(address(market)) - { - uint256 sharesToRedeem = originARM.balanceOf(alice) / 4; - vm.prank(alice); - (, uint256 queuedAssets) = originARM.requestRedeem(sharesToRedeem); - - address swapper = makeAddr("swapper"); - uint256 amountOut = DEFAULT_AMOUNT / 2; - deal(address(oeth), swapper, DEFAULT_AMOUNT); - - vm.startPrank(swapper); - oeth.approve(address(originARM), type(uint256).max); - originARM.swapTokensForExactTokens(oeth, weth, amountOut, type(uint256).max, swapper); - vm.stopPrank(); - - assertEq(originARM.reservedWithdrawLiquidity(), queuedAssets, "reserved amount tracked"); - assertEq(weth.balanceOf(address(originARM)), queuedAssets, "queued redeem liquidity remains in ARM"); - } - - function test_RevertWhen_SwapNeedsMarketLiquidity_ButNoActiveMarket() public { - address swapper = makeAddr("swapper"); - uint256 amountOut = DEFAULT_AMOUNT; - - deal(address(oeth), swapper, DEFAULT_AMOUNT * 2); - vm.startPrank(swapper); - oeth.approve(address(originARM), type(uint256).max); - - vm.expectRevert("ARM: Insufficient liquidity"); - originARM.swapTokensForExactTokens(oeth, weth, amountOut, type(uint256).max, swapper); - - vm.stopPrank(); - } - - function test_RevertWhen_SwapNeedsMoreLiquidityThanMarketCanProvide() - public - deposit(alice, 2 * DEFAULT_AMOUNT) - addMarket(address(market)) - setActiveMarket(address(market)) - { - address swapper = makeAddr("swapper"); - uint256 amountOut = DEFAULT_AMOUNT / 2; - - deal(address(oeth), swapper, DEFAULT_AMOUNT); - vm.mockCallRevert( - address(market), - abi.encodeWithSelector(IERC4626.withdraw.selector, amountOut, address(originARM), address(originARM)), - bytes("mock market withdraw failure") - ); - - vm.startPrank(swapper); - oeth.approve(address(originARM), type(uint256).max); - - vm.expectRevert("ARM: Insufficient liquidity"); - originARM.swapTokensForExactTokens(oeth, weth, amountOut, type(uint256).max, swapper); - - vm.stopPrank(); - } - - function test_SwapWithEnoughOnHandLiquidity_DoesNotTouchMarket() - public - deposit(alice, DEFAULT_AMOUNT) - setARMBuffer(1 ether) - addMarket(address(market)) - setActiveMarket(address(market)) - { - address swapper = makeAddr("local liquidity swapper"); - uint256 amountOut = DEFAULT_AMOUNT / 2; - - deal(address(oeth), swapper, DEFAULT_AMOUNT); - vm.startPrank(swapper); - oeth.approve(address(originARM), type(uint256).max); - - uint256 marketBalanceBefore = market.balanceOf(address(originARM)); - originARM.swapTokensForExactTokens(oeth, weth, amountOut, type(uint256).max, swapper); - - vm.stopPrank(); - - assertEq(market.balanceOf(address(originARM)), marketBalanceBefore, "market balance should not change"); - } - - function test_SwapExactTokensForTokens_ConsumesBuyLimitBeforeMarketWithdraw() - public - deposit(alice, 2 * DEFAULT_AMOUNT) - { - ReentrantSwapMarket reentrantMarket = new ReentrantSwapMarket(weth, originARM, oeth); - uint256 amountIn = DEFAULT_AMOUNT / 2; - uint256 amountOut = amountIn * 999e33 / 1e36; - - vm.prank(governor); - originARM.setARMBuffer(0); - - address[] memory markets = new address[](1); - markets[0] = address(reentrantMarket); - vm.prank(governor); - originARM.addMarkets(markets); - - vm.prank(governor); - originARM.setActiveMarket(address(reentrantMarket)); - - vm.prank(operator); - originARM.setPrices(address(oeth), 999e33, 1001 * 1e33, amountOut, type(uint128).max); - - deal(address(weth), address(reentrantMarket), amountOut); - deal(address(oeth), address(reentrantMarket), amountIn); - reentrantMarket.setReentrantSwap(amountIn); - - address swapper = makeAddr("reentrant market swapper"); - deal(address(oeth), swapper, amountIn); - - vm.startPrank(swapper); - oeth.approve(address(originARM), amountIn); - uint256[] memory amounts = originARM.swapExactTokensForTokens(oeth, weth, amountIn, amountOut, swapper); - vm.stopPrank(); - - assertEq(amounts[1], amountOut, "output amount"); - assertTrue(reentrantMarket.reentryFailed(), "reentrant swap should fail"); - assertEq(_buyLiquidityRemaining(), 0, "buy cap not consumed"); - } -} - -contract ReentrantSwapMarket { - IERC20 public immutable liquidityAsset; - OriginARM public immutable originARM; - IERC20 public immutable baseAsset; - - uint256 public reentrantAmountIn; - bool public reentryAttempted; - bool public reentryFailed; - - constructor(IERC20 _liquidityAsset, OriginARM _originARM, IERC20 _baseAsset) { - liquidityAsset = _liquidityAsset; - originARM = _originARM; - baseAsset = _baseAsset; - } - - function asset() external view returns (address) { - return address(liquidityAsset); - } - - function setReentrantSwap(uint256 amountIn) external { - reentrantAmountIn = amountIn; - baseAsset.approve(address(originARM), type(uint256).max); - } - - function withdraw(uint256 assets, address receiver, address) external returns (uint256 shares) { - _withdraw(assets, receiver); - return assets; - } - - function deposit(uint256 assets, address) external returns (uint256 shares) { - liquidityAsset.transferFrom(msg.sender, address(this), assets); - return assets; - } - - function redeem(uint256 shares, address receiver, address) external returns (uint256 assets) { - _withdraw(shares, receiver); - return shares; - } - - function _withdraw(uint256 assets, address receiver) internal { - if (!reentryAttempted) { - reentryAttempted = true; - try originARM.swapExactTokensForTokens(baseAsset, liquidityAsset, reentrantAmountIn, 0, address(this)) {} - catch { - reentryFailed = true; - } - } - - liquidityAsset.transfer(receiver, assets); - } - - function maxWithdraw(address) external view returns (uint256) { - return liquidityAsset.balanceOf(address(this)); - } - - function maxRedeem(address) external view returns (uint256) { - return liquidityAsset.balanceOf(address(this)); - } - - function convertToAssets(uint256 shares) external pure returns (uint256) { - return shares; - } - - function convertToShares(uint256 assets) external pure returns (uint256) { - return assets; - } - - function balanceOf(address) external view returns (uint256) { - return liquidityAsset.balanceOf(address(this)); - } -} diff --git a/test/unit/OriginARM/SwapLiquidityLimits.sol b/test/unit/OriginARM/SwapLiquidityLimits.sol deleted file mode 100644 index 3c941752..00000000 --- a/test/unit/OriginARM/SwapLiquidityLimits.sol +++ /dev/null @@ -1,159 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; - -contract Unit_Concrete_OriginARM_SwapLiquidityLimits_Test_ is Unit_Shared_Test { - uint256 internal constant MAX_SWAP_LIQUIDITY = type(uint128).max; - - function test_SwapExactTokensForTokens_BuySide_ConsumesLiquidityAssetCap() public { - uint256 buyCap = 3 ether; - _setSwapCaps(buyCap, MAX_SWAP_LIQUIDITY); - - deal(address(weth), address(originARM), 10 ether); - deal(address(oeth), alice, buyCap); - vm.startPrank(alice); - oeth.approve(address(originARM), type(uint256).max); - uint256[] memory amounts = originARM.swapExactTokensForTokens(oeth, weth, 1 ether, 0, alice); - vm.stopPrank(); - - assertEq(_buyLiquidityRemaining(), buyCap - amounts[1], "buy cap not consumed"); - assertEq(_sellLiquidityRemaining(), MAX_SWAP_LIQUIDITY, "sell cap changed"); - } - - function test_SwapTokensForExactTokens_BuySide_ConsumesExactOutputCap() public { - uint256 buyCap = 3 ether; - _setSwapCaps(buyCap, MAX_SWAP_LIQUIDITY); - - deal(address(weth), address(originARM), 10 ether); - deal(address(oeth), alice, buyCap); - vm.startPrank(alice); - oeth.approve(address(originARM), type(uint256).max); - uint256[] memory amounts = originARM.swapTokensForExactTokens(oeth, weth, 1 ether, type(uint256).max, alice); - vm.stopPrank(); - - assertEq(amounts[1], 1 ether, "wrong output"); - assertEq(_buyLiquidityRemaining(), buyCap - amounts[1], "buy cap not consumed by amount out"); - } - - function test_SwapExactTokensForTokens_BuySide_ConsumesMaxCap() public { - _setSwapCaps(MAX_SWAP_LIQUIDITY, MAX_SWAP_LIQUIDITY); - - deal(address(weth), address(originARM), 10 ether); - deal(address(oeth), alice, 3 ether); - vm.startPrank(alice); - oeth.approve(address(originARM), type(uint256).max); - uint256[] memory amounts = originARM.swapExactTokensForTokens(oeth, weth, 1 ether, 0, alice); - vm.stopPrank(); - - assertEq(_buyLiquidityRemaining(), MAX_SWAP_LIQUIDITY - amounts[1], "buy cap not consumed"); - assertEq(_sellLiquidityRemaining(), MAX_SWAP_LIQUIDITY, "sell cap changed"); - } - - function test_SwapExactTokensForTokens_SellSide_ConsumesBaseAssetCap() public { - uint256 sellCap = 4 ether; - _setSwapCaps(MAX_SWAP_LIQUIDITY, sellCap); - - deal(address(oeth), address(originARM), 10 ether); - deal(address(weth), alice, sellCap); - vm.startPrank(alice); - weth.approve(address(originARM), type(uint256).max); - uint256[] memory amounts = originARM.swapExactTokensForTokens(weth, oeth, 1 ether, 0, alice); - vm.stopPrank(); - - assertEq(_sellLiquidityRemaining(), sellCap - amounts[1], "sell cap not consumed"); - assertEq(_buyLiquidityRemaining(), MAX_SWAP_LIQUIDITY, "buy cap changed"); - } - - function test_SwapTokensForExactTokens_SellSide_ConsumesExactOutputCap() public { - uint256 sellCap = 4 ether; - _setSwapCaps(MAX_SWAP_LIQUIDITY, sellCap); - - deal(address(oeth), address(originARM), 10 ether); - deal(address(weth), alice, sellCap); - vm.startPrank(alice); - weth.approve(address(originARM), type(uint256).max); - uint256[] memory amounts = originARM.swapTokensForExactTokens(weth, oeth, 1 ether, type(uint256).max, alice); - vm.stopPrank(); - - assertEq(amounts[1], 1 ether, "wrong output"); - assertEq(_sellLiquidityRemaining(), sellCap - amounts[1], "sell cap not consumed by amount out"); - } - - function test_SwapExactTokensForTokens_SellSide_ConsumesMaxCap() public { - _setSwapCaps(MAX_SWAP_LIQUIDITY, MAX_SWAP_LIQUIDITY); - - deal(address(oeth), address(originARM), 10 ether); - deal(address(weth), alice, 3 ether); - vm.startPrank(alice); - weth.approve(address(originARM), type(uint256).max); - uint256[] memory amounts = originARM.swapExactTokensForTokens(weth, oeth, 1 ether, 0, alice); - vm.stopPrank(); - - assertEq(_buyLiquidityRemaining(), MAX_SWAP_LIQUIDITY, "buy cap changed"); - assertEq(_sellLiquidityRemaining(), MAX_SWAP_LIQUIDITY - amounts[1], "sell cap not consumed"); - } - - function test_RevertWhen_BuySideSwapExceedsRemainingCap() public { - _setSwapCaps(1 ether, MAX_SWAP_LIQUIDITY); - - deal(address(weth), address(originARM), 10 ether); - deal(address(oeth), alice, 2 ether); - vm.startPrank(alice); - oeth.approve(address(originARM), type(uint256).max); - - vm.expectRevert("ARM: Insufficient liquidity"); - originARM.swapTokensForExactTokens(oeth, weth, 1 ether + 1, type(uint256).max, alice); - - vm.stopPrank(); - } - - function test_RevertWhen_SellSideSwapExceedsRemainingCap() public { - _setSwapCaps(MAX_SWAP_LIQUIDITY, 1 ether); - - deal(address(oeth), address(originARM), 10 ether); - deal(address(weth), alice, 2 ether); - vm.startPrank(alice); - weth.approve(address(originARM), type(uint256).max); - - vm.expectRevert("ARM: Insufficient liquidity"); - originARM.swapTokensForExactTokens(weth, oeth, 1 ether + 1, type(uint256).max, alice); - - vm.stopPrank(); - } - - function test_RevertWhen_SwapExactTokensForTokens_SellSideInsufficientBaseAsset() public { - _setSwapCaps(MAX_SWAP_LIQUIDITY, MAX_SWAP_LIQUIDITY); - - deal(address(oeth), address(originARM), 0); - deal(address(weth), alice, 2 ether); - vm.startPrank(alice); - weth.approve(address(originARM), type(uint256).max); - - vm.expectRevert("ARM: Insufficient liquidity"); - originARM.swapExactTokensForTokens(weth, oeth, 1 ether, 0, alice); - - vm.stopPrank(); - assertEq(_sellLiquidityRemaining(), MAX_SWAP_LIQUIDITY, "sell cap changed"); - } - - function test_RevertWhen_SwapTokensForExactTokens_SellSideInsufficientBaseAsset() public { - _setSwapCaps(MAX_SWAP_LIQUIDITY, MAX_SWAP_LIQUIDITY); - - deal(address(oeth), address(originARM), 0); - deal(address(weth), alice, 2 ether); - vm.startPrank(alice); - weth.approve(address(originARM), type(uint256).max); - - vm.expectRevert("ARM: Insufficient liquidity"); - originARM.swapTokensForExactTokens(weth, oeth, 1 ether, type(uint256).max, alice); - - vm.stopPrank(); - assertEq(_sellLiquidityRemaining(), MAX_SWAP_LIQUIDITY, "sell cap changed"); - } - - function _setSwapCaps(uint256 buyCap, uint256 sellCap) internal { - vm.prank(operator); - originARM.setPrices(address(oeth), 992 * 1e33, 1001 * 1e33, buyCap, sellCap); - } -} diff --git a/test/unit/OriginARM/TotalAssets.sol b/test/unit/OriginARM/TotalAssets.sol deleted file mode 100644 index 78b8d3ba..00000000 --- a/test/unit/OriginARM/TotalAssets.sol +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; -import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; - -contract Unit_Concrete_OriginARM_TotalAssets_Test_ is Unit_Shared_Test { - function test_TotalAssets_RightAfterDeployment() public view { - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY, "Wrong total assets"); - } - - /// requestingOriginWithdrawal should have no impact on total assets - function test_TotalAssets_When_ExternalWithdrawQueue_IsNotZero() public swapAllWETHForOETH { - // Check the total assets before - uint256 totalAssetsBefore = originARM.totalAssets(); - - vm.prank(governor); - originARM.requestBaseAssetRedeem(address(oeth), MIN_TOTAL_SUPPLY / 2); - - // Ensure the total assets is equal to the external withdraw queue - assertEq(originARM.totalAssets(), totalAssetsBefore, "Wrong total assets"); - } - - function test_TotalAssets_ValuesPendingBaseRedeemsAtCrossPrice() public { - uint256 crossPrice = 0.999e36; - uint256 amount = DEFAULT_AMOUNT; - - vm.prank(governor); - originARM.setCrossPrice(address(oeth), crossPrice); - deal(address(oeth), address(originARM), amount); - - uint256 totalAssetsBefore = originARM.totalAssets(); - assertEq(totalAssetsBefore, MIN_TOTAL_SUPPLY + amount * crossPrice / 1e36, "total assets before"); - - vm.prank(governor); - originARM.requestBaseAssetRedeem(address(oeth), amount); - - assertEq(originARM.totalAssets(), totalAssetsBefore, "total assets after request"); - } - - function test_TotalAssets_PendingBaseRedeemsUseLiveCrossPrice() public { - uint256 amount = DEFAULT_AMOUNT; - uint256 crossPrice = 0.998e36; - uint256 newCrossPrice = 0.999e36; - - vm.prank(governor); - originARM.setCrossPrice(address(oeth), crossPrice); - deal(address(oeth), address(originARM), amount); - - vm.prank(governor); - originARM.requestBaseAssetRedeem(address(oeth), amount); - - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY + amount * crossPrice / 1e36, "initial pending value"); - - vm.prank(governor); - originARM.setCrossPrice(address(oeth), newCrossPrice); - - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY + amount * newCrossPrice / 1e36, "repriced pending value"); - } - - function test_TotalAssets_ClaimedPendingBaseRedeemsUseActualLiquidityReceived() public { - uint256 amount = DEFAULT_AMOUNT; - uint256 crossPrice = 0.999e36; - - vm.prank(governor); - originARM.setCrossPrice(address(oeth), crossPrice); - deal(address(oeth), address(originARM), amount); - - vm.prank(governor); - originARM.requestBaseAssetRedeem(address(oeth), amount); - - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY + amount * crossPrice / 1e36, "pending value"); - - deal(address(weth), address(vault), amount); - - vm.prank(governor); - originARM.claimBaseAssetRedeem(address(oeth), amount); - - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY + amount, "claimed value"); - } - - /// allocating to a market should have no impact on total assets - function test_TotalAssets_When_ActiveMarket() public addMarket(address(market)) setActiveMarket(address(market)) { - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY, "Wrong total assets"); - } - - /// deposit then redeem should have no impact on total assets - function test_TotalAssets_When_WithdrawQueue_IsNotZero() public deposit(alice, 1 ether) requestRedeemAll(alice) { - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY + 1 ether, "Wrong total assets"); - } - - /// queued shares remain in total supply, so a market loss is shared pro-rata - function test_TotalAssets_When_MarketLoseAll() - public - addMarket(address(market)) - setActiveMarket(address(market)) - deposit(alice, 1 ether) - setARMBuffer(0) - allocate - simulateMarketLoss(address(market), 1 ether) - requestRedeem(alice, 1 ether) - { - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY + 1 ether, "Wrong total assets"); - } - - function test_TotalAssets_When_AssetIsLessThanOutstandingWithdrawals() - public - deposit(alice, DEFAULT_AMOUNT) - requestRedeemAll(alice) - { - // Simulate a loss on the ARM - deal(address(weth), address(originARM), 0); - - assertEq(originARM.totalAssets(), MIN_TOTAL_SUPPLY, "Wrong total assets"); - } - - function test_TotalAssets_UsesConvertToAssets_When_PreviewRedeem_IsLiquidityConstrained() - public - addMarket(address(market)) - setActiveMarket(address(market)) - deposit(alice, DEFAULT_AMOUNT) - setARMBuffer(0) - allocate - { - uint256 marketShares = market.balanceOf(address(originARM)); - uint256 marketValue = market.convertToAssets(marketShares); - uint256 totalAssetsBefore = originARM.totalAssets(); - uint256 assetsPerShareBefore = originARM.convertToAssets(1 ether); - - assertGt(marketValue, 0, "market should have value after allocation"); - - vm.mockCall(address(market), abi.encodeWithSelector(IERC4626.previewRedeem.selector), abi.encode(0)); - vm.mockCall(address(market), abi.encodeWithSelector(IERC4626.maxWithdraw.selector), abi.encode(0)); - vm.mockCall(address(market), abi.encodeWithSelector(IERC4626.maxRedeem.selector), abi.encode(0)); - - assertEq(originARM.totalAssets(), totalAssetsBefore, "total assets should use convertToAssets"); - assertEq(originARM.convertToAssets(1 ether), assetsPerShareBefore, "asset per share should be unchanged"); - assertEq(originARM.claimable(), 0, "claimable should still reflect liquidity constraints"); - assertEq(market.previewRedeem(marketShares), 0, "previewRedeem should be constrained"); - assertEq(market.convertToAssets(marketShares), marketValue, "convertToAssets should still show economic value"); - } -} diff --git a/test/unit/OriginARM/WrappedOriginAssetAdapter.sol b/test/unit/OriginARM/WrappedOriginAssetAdapter.sol deleted file mode 100644 index 22e369d0..00000000 --- a/test/unit/OriginARM/WrappedOriginAssetAdapter.sol +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; -import {IERC20} from "contracts/Interfaces.sol"; - -contract Unit_Concrete_OriginARM_WrappedOriginAssetAdapter_Test_ is Unit_Shared_Test { - function test_WrappedOriginAssetAdapter_Conversion_UsesWrappedOTokenRate() public { - uint256 shares = _mintWOETH(address(originARM), 10 ether); - deal(address(oeth), address(woeth), oeth.balanceOf(address(woeth)) + 1 ether); - - uint256 expectedAssets = woeth.convertToAssets(shares); - uint256 expectedShares = woeth.convertToShares(expectedAssets); - - assertGt(expectedAssets, shares, "expected appreciating wrapper"); - assertEq(wrappedOriginAssetAdapter.convertToAssets(shares), expectedAssets, "assets"); - assertEq(wrappedOriginAssetAdapter.convertToShares(expectedAssets), expectedShares, "shares"); - } - - function test_SwapExactTokensForTokens_WOETH_For_WETH_UsesWrappedConversion() public { - uint256 sharesIn = _mintWOETH(alice, 10 ether); - deal(address(oeth), address(woeth), oeth.balanceOf(address(woeth)) + 1 ether); - deal(address(weth), address(originARM), 20 ether); - - uint256 convertedAmountIn = woeth.convertToAssets(sharesIn); - uint256 expectedAmountOut = convertedAmountIn * _woethBuyPrice() / PRICE_SCALE; - - vm.startPrank(alice); - woeth.approve(address(originARM), sharesIn); - uint256[] memory amounts = originARM.swapExactTokensForTokens(IERC20(address(woeth)), weth, sharesIn, 0, alice); - vm.stopPrank(); - - assertEq(amounts[0], sharesIn, "amount in"); - assertEq(amounts[1], expectedAmountOut, "amount out"); - assertEq(weth.balanceOf(alice), expectedAmountOut, "weth received"); - } - - function test_RequestAndClaimRedeem_WOETH() public { - uint256 shares = _mintWOETH(address(originARM), 10 ether); - deal(address(oeth), address(woeth), oeth.balanceOf(address(woeth)) + 1 ether); - uint256 assetsExpected = woeth.convertToAssets(shares); - - vm.prank(governor); - (uint256 sharesRequested, uint256 requestAssetsExpected) = - originARM.requestBaseAssetRedeem(address(woeth), shares); - - assertEq(sharesRequested, shares, "shares requested"); - assertEq(requestAssetsExpected, assetsExpected, "assets expected"); - (,,,,, uint120 pendingRedeemAssets,,) = originARM.baseAssetConfigs(address(woeth)); - assertEq(pendingRedeemAssets, assetsExpected, "pending redeem assets"); - assertEq(wrappedOriginAssetAdapter.pendingRequestIdsLength(), 1, "pending request length"); - - deal(address(weth), address(vault), assetsExpected); - - vm.prank(governor); - (uint256 sharesClaimed, uint256 claimAssetsExpected, uint256 assetsReceived) = - originARM.claimBaseAssetRedeem(address(woeth), shares); - - assertEq(sharesClaimed, shares, "shares claimed"); - assertEq(claimAssetsExpected, assetsExpected, "claim assets expected"); - assertEq(assetsReceived, assetsExpected, "assets received"); - assertEq(weth.balanceOf(address(originARM)), MIN_TOTAL_SUPPLY + assetsExpected, "arm weth"); - - (,,,,, pendingRedeemAssets,,) = originARM.baseAssetConfigs(address(woeth)); - assertEq(pendingRedeemAssets, 0, "pending redeem assets after claim"); - } - - function _mintWOETH(address to, uint256 assets) internal returns (uint256 shares) { - deal(address(oeth), address(this), assets); - oeth.approve(address(woeth), assets); - shares = woeth.deposit(assets, to); - } - - function _woethBuyPrice() internal view returns (uint256 buyPrice) { - (uint128 buyPriceMem,,,,,,,) = originARM.baseAssetConfigs(address(woeth)); - buyPrice = buyPriceMem; - } -} diff --git a/test/unit/SiloMarket/SiloMarket.sol b/test/unit/SiloMarket/SiloMarket.sol deleted file mode 100644 index fc09361e..00000000 --- a/test/unit/SiloMarket/SiloMarket.sol +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Unit_Shared_Test} from "test/unit/shared/Shared.sol"; -import {SiloMarket} from "contracts/markets/SiloMarket.sol"; -import {Abstract4626MarketWrapper} from "contracts/markets/Abstract4626MarketWrapper.sol"; -import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; - -contract Unit_Concrete_OriginARM_SiloMarket_Test_ is Unit_Shared_Test { - function setUp() public virtual override { - super.setUp(); - - vm.mockCall(address(market), abi.encodeWithSelector(IERC4626.maxWithdraw.selector), abi.encode(1)); - vm.mockCall(address(market), abi.encodeWithSelector(IERC4626.maxRedeem.selector), abi.encode(1)); - } - //////////////////////////////////////////////////// - /// --- REVERT - //////////////////////////////////////////////////// - - function test_RevertWhen_Deposit_Because_OnlyARMCanDeposit() public asNot(address(originARM)) { - vm.expectRevert("Only ARM can deposit"); - siloMarket.deposit(0, address(originARM)); - - vm.stopPrank(); - vm.prank(address(originARM)); - vm.expectRevert("Only ARM can deposit"); - siloMarket.deposit(0, address(this)); - } - - function test_RevertWhen_Withdraw_Because_OnlyARMCanWithdraw() public asNot(address(originARM)) { - vm.expectRevert("Only ARM can withdraw"); - siloMarket.withdraw(0, address(originARM), address(originARM)); - - vm.stopPrank(); - vm.prank(address(originARM)); - vm.expectRevert("Only ARM can withdraw"); - siloMarket.withdraw(0, address(this), address(originARM)); - - vm.prank(address(originARM)); - vm.expectRevert("Only ARM can withdraw"); - siloMarket.withdraw(0, address(originARM), address(this)); - } - - function test_RevertWhen_Redeem_Because_OnlyARMCanWithdraw() public asNot(address(originARM)) { - vm.expectRevert("Only ARM can redeem"); - siloMarket.redeem(0, address(originARM), address(originARM)); - - vm.stopPrank(); - vm.prank(address(originARM)); - vm.expectRevert("Only ARM can redeem"); - siloMarket.redeem(0, address(this), address(originARM)); - - vm.prank(address(originARM)); - vm.expectRevert("Only ARM can redeem"); - siloMarket.redeem(0, address(originARM), address(this)); - } - - function test_RevertWhen_SetHarvester_Because_OnlyOwner() public asNotGovernor { - vm.expectRevert(bytes4(keccak256("OnlyOwner()"))); - siloMarket.setHarvester(address(this)); - } - - function test_RevertWhen_SetHarvester_Because_AlreadySet() public asGovernor { - address currentHarvester = siloMarket.harvester(); - - vm.expectRevert("Harvester already set"); - siloMarket.setHarvester(currentHarvester); - } - - function test_RevertWhen_CollectRewardTokens_Because_OnlyHarvester() public asNotOperatorNorGovernor { - vm.expectRevert("Only harvester can collect"); - siloMarket.collectRewards(); - } - - //////////////////////////////////////////////////// - /// --- SETTERS - //////////////////////////////////////////////////// - function test_SetHarvester() public asGovernor { - address newHarvester = randomAddrDiff(siloMarket.harvester()); - - vm.expectEmit(address(siloMarket)); - emit Abstract4626MarketWrapper.HarvesterUpdated(newHarvester); - siloMarket.setHarvester(newHarvester); - - assertEq(siloMarket.harvester(), newHarvester, "harvester"); - } - - //////////////////////////////////////////////////// - /// --- VIEWS - //////////////////////////////////////////////////// - function test_MaxWithdraw() public view { - assertEq(siloMarket.maxWithdraw(address(originARM)), 1, "maxWithdraw"); - assertEq(siloMarket.maxWithdraw(address(this)), 0, "maxWithdraw"); - } - - function test_MaxRedeem() public view { - assertEq(siloMarket.maxRedeem(address(originARM)), 1, "maxRedeem"); - assertEq(siloMarket.maxRedeem(address(this)), 0, "maxRedeem"); - } -} diff --git a/test/unit/shared/Helpers.sol b/test/unit/shared/Helpers.sol deleted file mode 100644 index 891b752a..00000000 --- a/test/unit/shared/Helpers.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Base_Test_} from "test/Base.sol"; - -contract Helpers is Base_Test_ { - function randomAddrDiff(address _addr) public returns (address) { - address _rand = vm.randomAddress(); - while (_rand == _addr) { - _rand = vm.randomAddress(); - } - return _rand; - } - - function randomAddrDiff(address _addr1, address _addr2) public returns (address) { - address _rand = vm.randomAddress(); - while (_rand == _addr1 || _rand == _addr2) { - _rand = vm.randomAddress(); - } - return _rand; - } -} diff --git a/test/unit/shared/Modifiers.sol b/test/unit/shared/Modifiers.sol deleted file mode 100644 index ffcf6862..00000000 --- a/test/unit/shared/Modifiers.sol +++ /dev/null @@ -1,219 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -import {Helpers} from "test/unit/shared/Helpers.sol"; -import {stdStorage, StdStorage} from "forge-std/Test.sol"; -import {IERC20} from "contracts/Interfaces.sol"; -import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; - -contract Modifiers is Helpers { - using stdStorage for StdStorage; - - //////////////////////////////////////////////////// - /// --- PRANK - //////////////////////////////////////////////////// - modifier asGovernor() { - vm.startPrank(governor); - _; - vm.stopPrank(); - } - - modifier asOperator() { - vm.startPrank(operator); - _; - vm.stopPrank(); - } - - modifier asNotGovernor() { - vm.startPrank(randomAddrDiff(governor)); - _; - vm.stopPrank(); - } - - modifier asNotOperatorNorGovernor() { - vm.startPrank(randomAddrDiff(governor, operator)); - _; - vm.stopPrank(); - } - - modifier asRandomCaller() { - vm.startPrank(vm.randomAddress()); - _; - vm.stopPrank(); - } - - modifier asNot(address user) { - vm.startPrank(randomAddrDiff(user)); - _; - vm.stopPrank(); - } - - //////////////////////////////////////////////////// - /// --- SETTERS - //////////////////////////////////////////////////// - modifier addMarket(address market) { - address[] memory markets = new address[](1); - markets[0] = market; - vm.startPrank(governor); - originARM.addMarkets(markets); - vm.stopPrank(); - _; - } - - modifier setActiveMarket(address market) { - vm.startPrank(governor); - originARM.setActiveMarket(market); - vm.stopPrank(); - _; - } - - modifier setARMBuffer(uint256 buffer) { - vm.startPrank(governor); - originARM.setARMBuffer(buffer); - vm.stopPrank(); - _; - } - - modifier setCapManager() { - vm.startPrank(governor); - originARM.setCapManager(address(capManager)); - vm.stopPrank(); - _; - } - - modifier setTotalAssetsCapUnlimited() { - vm.startPrank(governor); - capManager.setTotalAssetsCap(type(uint248).max); - vm.stopPrank(); - _; - } - - modifier setFee(uint256 fee) { - vm.startPrank(governor); - originARM.setFee(fee); - vm.stopPrank(); - _; - } - - //////////////////////////////////////////////////// - /// --- ACTIONS - //////////////////////////////////////////////////// - modifier deposit(address user, uint256 amount) { - vm.startPrank(user); - deal(address(weth), user, amount); - weth.approve(address(originARM), amount); - originARM.deposit(amount); - vm.stopPrank(); - _; - } - - modifier requestOriginWithdrawal(uint256 amount) { - vm.startPrank(governor); - originARM.requestBaseAssetRedeem(address(oeth), amount); - vm.stopPrank(); - _; - } - - modifier swapAllWETHForOETH() { - address swapper = makeAddr("swapper"); - deal(address(oeth), swapper, 1_000_000 ether); - vm.startPrank(swapper); - oeth.approve(address(originARM), type(uint256).max); - originARM.swapTokensForExactTokens(oeth, weth, weth.balanceOf(address(originARM)), type(uint256).max, swapper); - vm.stopPrank(); - _; - } - - modifier swapAllOETHForWETH() { - address swapper = makeAddr("swapper"); - deal(address(weth), swapper, 1_000_000 ether); - vm.startPrank(swapper); - weth.approve(address(originARM), type(uint256).max); - originARM.swapTokensForExactTokens(weth, oeth, oeth.balanceOf(address(originARM)), type(uint256).max, swapper); - vm.stopPrank(); - _; - } - - modifier swapWETHForOETH(uint256 amount) { - address swapper = makeAddr("swapper"); - deal(address(oeth), swapper, amount * 2); - vm.startPrank(swapper); - oeth.approve(address(originARM), type(uint256).max); - originARM.swapTokensForExactTokens(oeth, weth, amount, type(uint256).max, swapper); - vm.stopPrank(); - _; - } - - modifier swapOETHForWETH(uint256 amount) { - address swapper = makeAddr("swapper"); - deal(address(weth), swapper, amount * 2); - vm.startPrank(swapper); - weth.approve(address(originARM), type(uint256).max); - originARM.swapTokensForExactTokens(weth, oeth, amount, type(uint256).max, swapper); - vm.stopPrank(); - _; - } - - modifier requestRedeem(address user, uint256 pct) { - uint256 shares = originARM.balanceOf(alice); - vm.prank(alice); - originARM.requestRedeem((shares * pct) / 1e18); - _; - } - - modifier requestRedeemAll(address user) { - uint256 shares = originARM.balanceOf(user); - vm.prank(user); - originARM.requestRedeem(shares); - _; - } - - modifier allocate() { - originARM.allocate(); - _; - } - - modifier marketLoss(address market, uint256 lossPct) { - uint256 balance = weth.balanceOf(market); - uint256 amountToThrow = (balance * lossPct) / 1e18; - vm.prank(market); - weth.transfer(address(0x1), amountToThrow); - _; - } - - modifier donate(IERC20 token, address user, uint256 amount) { - deal(address(token), address(this), amount); - token.transfer(user, amount); - _; - } - - /// @dev Cheat function to force available assets in the ARM to be 0 - /// Send OETH and WETH to address(0x1) - /// Write directly in the storage of the ARM the vaultWithdrawalAmount to 0 - modifier forceAvailableAssetsToZero() { - vm.startPrank(address(originARM)); - oeth.transfer(address(0x1), oeth.balanceOf(address(originARM))); - weth.transfer(address(0x1), weth.balanceOf(address(originARM))); - stdstore.target(address(originARM)).sig("vaultWithdrawalAmount()").checked_write(uint256(0)); - vm.stopPrank(); - _; - } - - //////////////////////////////////////////////////// - /// --- MOCK CALLS - //////////////////////////////////////////////////// - modifier simulateMarketLoss(address market, uint256 lossPct) { - uint256 maxWithdraw = IERC4626(market).maxWithdraw(address(originARM)); - uint256 maxRedeem = IERC4626(market).maxRedeem(address(originARM)); - uint256 lossOnWithdraw = lossPct == 1e18 ? 0 : (maxWithdraw * lossPct) / 1e18; - uint256 lossOnRedeem = lossPct == 1e18 ? 0 : (maxRedeem * lossPct) / 1e18; - vm.mockCall(market, abi.encodeWithSelector(IERC4626.maxWithdraw.selector), abi.encode(lossOnWithdraw)); - vm.mockCall(market, abi.encodeWithSelector(IERC4626.maxRedeem.selector), abi.encode(lossOnRedeem)); - _; - } - - modifier timejump(uint256 secondsToJump) { - vm.warp(block.timestamp + secondsToJump); - _; - } -} diff --git a/test/unit/shared/Shared.sol b/test/unit/shared/Shared.sol deleted file mode 100644 index b25d1041..00000000 --- a/test/unit/shared/Shared.sol +++ /dev/null @@ -1,178 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.23; - -// Test -import {Base_Test_} from "test/Base.sol"; -import {Modifiers} from "test/unit/shared/Modifiers.sol"; - -// Contracts -import {Proxy} from "contracts/Proxy.sol"; -import {OriginARM} from "contracts/OriginARM.sol"; -import {OriginAssetAdapter} from "contracts/adapters/OriginAssetAdapter.sol"; -import {WrappedOriginAssetAdapter} from "contracts/adapters/WrappedOriginAssetAdapter.sol"; -import {CapManager} from "contracts/CapManager.sol"; -import {SiloMarket} from "contracts/markets/SiloMarket.sol"; -import {Abstract4626MarketWrapper} from "contracts/markets/Abstract4626MarketWrapper.sol"; - -// Interfaces -import {IERC20} from "contracts/Interfaces.sol"; -import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import {IOriginVault} from "contracts/Interfaces.sol"; -// Mocks -import {MockVault} from "test/unit/mocks/MockVault.sol"; -import {MockERC20} from "@solmate/test/utils/mocks/MockERC20.sol"; -import {MockERC4626Market} from "test/unit/mocks/MockERC4626Market.sol"; - -abstract contract Unit_Shared_Test is Base_Test_, Modifiers { - uint256 public constant CLAIM_DELAY = 1 days; - uint256 public constant DEFAULT_FEE = 1000; // 10% - - ////////////////////////////////////////////////////// - /// --- SETUP - ////////////////////////////////////////////////////// - function setUp() public virtual override { - // Deploy Mock contracts - _deployMockContracts(); - - // Generate addresses - _generateAddresses(); - - // Deploy contracts - _deployContracts(); - - // Label contracts - labelAll(); - } - - function _deployMockContracts() internal { - oeth = IERC20(address(new MockERC20("Origin ETH", "OETH", 18))); - woeth = IERC4626(address(new MockERC4626Market(oeth))); - weth = IERC20(address(new MockERC20("Wrapped ETH", "WETH", 18))); - vault = IOriginVault(address(new MockVault(oeth, weth))); - market = IERC4626(address(new MockERC4626Market(IERC20(weth)))); - market2 = IERC4626(address(new MockERC4626Market(IERC20(weth)))); - } - - function _generateAddresses() internal { - // Users and multisigs - alice = makeAddr("alice"); - bob = makeAddr("bob"); - deployer = makeAddr("deployer"); - feeCollector = makeAddr("fee collector"); - - operator = makeAddr("OPERATOR"); - governor = makeAddr("GOVERNOR"); - } - - function _deployContracts() internal { - vm.startPrank(deployer); - - // --- Deploy Proxy - originARMProxy = new Proxy(); - Proxy capManagerProxy = new Proxy(); - Proxy siloMarketProxy = new Proxy(); - - // --- Deploy OriginARM implementation - originARM = new OriginARM(address(oeth), address(weth), address(vault), CLAIM_DELAY, 1e7, 1e18); - capManager = new CapManager(address(address(originARMProxy))); - - // --- Deploy SiloMarket implementation - siloMarket = new SiloMarket(address(originARMProxy), address(market), makeAddr("fake gauge")); - - // Initialization requires 1e12 liquid assets to mint to dead address. - // Deployer approve the proxy to transfer 1e12 liquid assets. - weth.approve(address(originARMProxy), 1e12); - // Mint 1e12 liquid assets to the deployer. - deal(address(weth), deployer, 1e12); - - // --- Initialize the proxy - originARMProxy.initialize( - address(originARM), - governor, - abi.encodeWithSelector( - OriginARM.initialize.selector, "Origin ARM", "OARM", operator, DEFAULT_FEE, feeCollector, address(0) - ) - ); - - // --- Initialize the CapManager proxy - capManagerProxy.initialize( - address(capManager), governor, abi.encodeWithSelector(CapManager.initialize.selector, operator) - ); - - // --- Initialize the SiloMarket proxy - siloMarketProxy.initialize( - address(siloMarket), - governor, - abi.encodeWithSelector(Abstract4626MarketWrapper.initialize.selector, operator, address(0x1)) - ); - - vm.stopPrank(); - - // --- Set the proxy as the OriginARM - originARM = OriginARM(address(originARMProxy)); - originAssetAdapter = new OriginAssetAdapter(address(originARM), address(oeth), address(weth), address(vault)); - wrappedOriginAssetAdapter = new WrappedOriginAssetAdapter( - address(originARM), address(woeth), address(oeth), address(weth), address(vault) - ); - originAssetAdapter.initialize(); - wrappedOriginAssetAdapter.initialize(); - capManager = CapManager(address(capManagerProxy)); - siloMarket = SiloMarket(address(siloMarketProxy)); - - // Register OETH as the base asset. - vm.prank(governor); - originARM.addBaseAsset( - address(oeth), - address(originAssetAdapter), - 992 * 1e33, - 1001 * 1e33, - type(uint128).max, - type(uint128).max, - 1e36, - true - ); - - vm.prank(governor); - originARM.addBaseAsset( - address(woeth), - address(wrappedOriginAssetAdapter), - 992 * 1e33, - 1001 * 1e33, - type(uint128).max, - type(uint128).max, - 1e36, - false - ); - } - - function _buyPrice() internal view returns (uint256 buyPrice) { - (uint128 buyPriceMem,,,,,,,) = originARM.baseAssetConfigs(address(oeth)); - buyPrice = buyPriceMem; - } - - function _sellPrice() internal view returns (uint256 sellPrice) { - (, uint128 sellPriceMem,,,,,,) = originARM.baseAssetConfigs(address(oeth)); - sellPrice = sellPriceMem; - } - - function _buyLiquidityRemaining() internal view returns (uint256 remaining) { - (,, uint128 _remaining,,,,,) = originARM.baseAssetConfigs(address(oeth)); - remaining = _remaining; - } - - function _sellLiquidityRemaining() internal view returns (uint256 remaining) { - (,,, uint128 _remaining,,,,) = originARM.baseAssetConfigs(address(oeth)); - remaining = _remaining; - } - - function _crossPrice() internal view returns (uint256 crossPrice) { - (,,,, uint128 crossPriceMem,,,) = originARM.baseAssetConfigs(address(oeth)); - crossPrice = crossPriceMem; - } - - function _swapFeeMultiplier(uint256 buyPrice, uint256 crossPrice, uint256 fee) internal view returns (uint256) { - uint256 priceScale = PRICE_SCALE; - if (buyPrice == 0 || fee == 0) return 0; - return (crossPrice - buyPrice) * fee * priceScale / (buyPrice * FEE_SCALE); - } -}