Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/contracts/AbstractARM.sol
Original file line number Diff line number Diff line change
Expand Up @@ -783,11 +783,12 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu
uint256 grossAssets = _availableAssets();
uint256 feesAccruedMem = feesAccrued;

// Treat accrued fees as a senior liability. If gross assets no longer cover
// accrued fees plus the minimum native-liquidity floor, new deposits would be
// minted against the floor and backfill the shortfall, so block deposits.
// At the native-liquidity floor, a new deposit would either backfill senior liabilities
// or dilute live LPs after a real asset loss. Allow only the initial deposit, when the dead
// shares are the entire supply and no fees or withdrawals are outstanding.
bool atAssetFloor = feesAccruedMem + MIN_LIQUIDITY >= grossAssets;
if (atAssetFloor && (feesAccruedMem != 0 || reservedWithdrawLiquidity != 0)) revert Insolvent();
bool hasLiveLps = totalSupply() > MIN_TOTAL_SUPPLY;
if (atAssetFloor && (hasLiveLps || feesAccruedMem != 0 || reservedWithdrawLiquidity != 0)) revert Insolvent();

uint256 netAssets = atAssetFloor ? MIN_LIQUIDITY : grossAssets - feesAccruedMem;
shares = assets * totalSupply() / netAssets;
Expand Down
11 changes: 7 additions & 4 deletions test/invariants/EthenaARM/TargetFunctions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,13 @@ abstract contract TargetFunctions is Setup, StdUtils {
}

function targetARMDeposit(uint88 amount, uint256 randomAddressIndex) external ensureExchangeRateIncrease {
// Mirror AbstractARM._deposit's Insolvent() guard: when the ARM sits at the asset floor
// (totalAssets() clamped to MIN_LIQUIDITY == 1e12), deposits revert if any senior liability
// (accrued fees OR reserved LP withdrawals) is outstanding. Skip those inputs instead of reverting.
if (assume(arm.totalAssets() > 1e12 || (arm.feesAccrued() == 0 && arm.reservedWithdrawLiquidity() == 0))) {
// Mirror AbstractARM._deposit's Insolvent() guard: at the asset floor, deposits are allowed
// only before any live LP shares exist and when there are no senior liabilities.
bool initialDeposit = arm.totalSupply() == DEFAULT_MIN_TOTAL_SUPPLY;
if (assume(
arm.totalAssets() > 1e12
|| (initialDeposit && arm.feesAccrued() == 0 && arm.reservedWithdrawLiquidity() == 0)
)) {
return;
}
// Select a random user from makers
Expand Down
93 changes: 59 additions & 34 deletions test/invariants/LidoARM/TargetFunction.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {Invariant_LidoARM_Setup_Test} from "./base/Setup.t.sol";
/// @title TargetFunctions
/// @notice TargetFunctions contract for tests, containing the target functions that should be tested.
/// This is the entry point with the contract we are testing. Ideally, it should never revert.
/// @dev Target parameters use full ABI words so corpus mutations remain decodable in strict mode. Handlers
/// derive booleans and bound narrower production values internally.
abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
// ╔══════════════════════════════════════════════════════════════════════════════╗
// ║ ✦✦✦ LIDO ARM ✦✦✦ ║
Expand Down Expand Up @@ -56,10 +58,12 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
////////////////////////////////////////////////////
/// --- SWAPS
////////////////////////////////////////////////////
function targetSwapExactTokensForTokens(uint88 amount, bool stETHOrWstETH, bool buyOrSell)
function targetSwapExactTokensForTokens(uint256 amount, uint256 baseAssetSeed, uint256 sideSeed)
public
ensureSharePriceNotDecreased
{
bool stETHOrWstETH = baseAssetSeed % 2 == 0;
bool buyOrSell = sideSeed % 2 == 0;
address baseAsset = stETHOrWstETH ? address(steth) : address(wsteth);
// buyOrSell: true = ARM buys base asset (trader sends base, gets WETH)
// false = ARM sells base asset (trader sends WETH, gets base)
Expand Down Expand Up @@ -105,6 +109,10 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
amountIn = stETHOrWstETH ? sharesNeeded : mockWstETH.getStETHByWstETH(sharesNeeded);
}

// Reverse conversion can round a dust wstETH input down to zero. The wstETH mint helper
// rejects zero-share mints, and a zero-input swap does not exercise meaningful behavior.
vm.assume(amountIn > 0);

// 3. Deal tokenIn to swapper and execute
if (tokenIn == address(wsteth)) {
dealWsteth(grace, amountIn);
Expand All @@ -127,10 +135,12 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetSwapTokensForExactTokens(uint88 amount, bool stETHOrWstETH, bool buyOrSell)
function targetSwapTokensForExactTokens(uint256 amount, uint256 baseAssetSeed, uint256 sideSeed)
public
ensureSharePriceNotDecreased
{
bool stETHOrWstETH = baseAssetSeed % 2 == 0;
bool buyOrSell = sideSeed % 2 == 0;
address baseAsset = stETHOrWstETH ? address(steth) : address(wsteth);
// buyOrSell: true = ARM buys base asset (trader sends base, gets WETH)
// false = ARM sells base asset (trader sends WETH, gets base)
Expand Down Expand Up @@ -195,19 +205,21 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
////////////////////////////////////////////////////
/// --- LIQUIDITY PROVIDERS
////////////////////////////////////////////////////
function targetDeposit(uint128 amount, uint16 from) public ensureSharePriceNotDecreased {
function targetDeposit(uint256 amount, uint256 from) public ensureSharePriceNotDecreased {
(address user, uint256 balance) = selectUserWithLiqudity(from);
vm.assume(user != address(0)); // Ensure we found a user with liquidity

// Mirror AbstractARM._deposit's Insolvent() guard: at the asset floor (totalAssets() clamped to
// MIN_LIQUIDITY == 1e12) deposits revert when any senior liability (accrued fees or reserved LP
// redeems) is outstanding. Skip those inputs so strict-mode fuzzing does not fail on the revert.
// Mirror AbstractARM._deposit's Insolvent() guard: at the asset floor, deposits are allowed
// only before any live LP shares exist and when there are no senior liabilities.
vm.assume(
lidoARM.totalAssets() > 1e12 || (lidoARM.feesAccrued() == 0 && lidoARM.reservedWithdrawLiquidity() == 0)
lidoARM.totalAssets() > 1e12
|| (lidoARM.totalSupply() == MIN_TOTAL_SUPPLY
&& lidoARM.feesAccrued() == 0
&& lidoARM.reservedWithdrawLiquidity() == 0)
);

// Bound amount
uint256 boundedAmount = _bound(amount, MINIMUM_DEPOSIT, uint128(balance));
uint256 boundedAmount = _bound(amount, MINIMUM_DEPOSIT, balance);
vm.prank(user);
lidoARM.deposit(boundedAmount);
sum_weth_deposit += boundedAmount;
Expand All @@ -220,14 +232,14 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetRequestRedeem(uint128 shares, uint16 from) public ensureSharePriceNotDecreased {
function targetRequestRedeem(uint256 shares, uint256 from) public ensureSharePriceNotDecreased {
(address user, uint256 balance) = selectUserWithShares(from);
vm.assume(user != address(0)); // Ensure we found a user with shares to redeem

// Bound shares
uint256 boundedShares = _bound(shares, MIN_SHARES_TO_REQUEST, uint128(balance));
uint256 boundedShares = _bound(shares, MIN_SHARES_TO_REQUEST, balance);
vm.prank(user);
(uint256 requestId, uint256 requestAssets) = lidoARM.requestRedeem(boundedShares);
(uint256 requestId,) = lidoARM.requestRedeem(boundedShares);
ghost_requestCounter++;
sum_shares_requested += boundedShares;

Expand All @@ -240,7 +252,7 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
shuffle(_pendingRequestIds, from); // Shuffle pending request IDs to ensure randomness in claim
}

function targetClaimRedeem(uint16 seed) public ensureSharePriceNotDecreased {
function targetClaimRedeem(uint256 seed) public ensureSharePriceNotDecreased {
(address user, uint256 requestId, uint256 positionInList) = selectUserWithPendingRequest();
vm.assume(user != address(0));

Expand All @@ -267,7 +279,7 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetTransferShares(uint128 amount, uint16 from, uint16 to) public ensureSharePriceNotDecreased {
function targetTransferShares(uint256 amount, uint256 from, uint256 to) public ensureSharePriceNotDecreased {
(address source, uint256 balance) = selectUserWithShares(from);
vm.assume(source != address(0));

Expand All @@ -288,7 +300,7 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetDonate(uint88 amount, uint8 tokenSeed) public ensureSharePriceNotDecreased {
function targetDonate(uint256 amount, uint256 tokenSeed) public ensureSharePriceNotDecreased {
address donor = address(0xd074);
uint256 boundedAmount = _bound(amount, 1, 1 ether);

Expand Down Expand Up @@ -321,15 +333,16 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
////////////////////////////////////////////////////
/// --- BASE ASSET REDEMPTIONS
////////////////////////////////////////////////////
function targetRequestBaseWithdrawal(uint128 amount, bool stETHOrWstETH) public ensureSharePriceNotDecreased {
function targetRequestBaseWithdrawal(uint256 amount, uint256 baseAssetSeed) public ensureSharePriceNotDecreased {
bool stETHOrWstETH = baseAssetSeed % 2 == 0;
address baseAsset = stETHOrWstETH ? address(steth) : address(wsteth);
uint256 bal = IERC20(baseAsset).balanceOf(address(lidoARM));
vm.assume(bal > 0);

uint256 boundedAmount = _bound(amount, 1, bal);

vm.prank(operator);
(uint256 sharesRequested, uint256 assetsExpected) = lidoARM.requestBaseAssetRedeem(baseAsset, boundedAmount);
(uint256 sharesRequested,) = lidoARM.requestBaseAssetRedeem(baseAsset, boundedAmount);

// Ghost: track base asset outflows
if (stETHOrWstETH) sum_steth_baseRedeemRequested += boundedAmount;
Expand All @@ -348,7 +361,8 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetClaimBaseWithdrawals(uint8 count, bool stETHOrWstETH) public ensureSharePriceNotDecreased {
function targetClaimBaseWithdrawals(uint256 count, uint256 baseAssetSeed) public ensureSharePriceNotDecreased {
bool stETHOrWstETH = baseAssetSeed % 2 == 0;
address baseAsset = stETHOrWstETH ? address(steth) : address(wsteth);
uint256[] storage queue = stETHOrWstETH ? _pendingBaseRedeemShares_stETH : _pendingBaseRedeemShares_wstETH;
vm.assume(queue.length > 0);
Expand Down Expand Up @@ -384,14 +398,14 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
////////////////////////////////////////////////////
/// --- LIQUIDITY MANAGMENT
////////////////////////////////////////////////////
function targetSetActiveMarket(uint16 seed) public ensureSharePriceNotDecreased {
function targetSetActiveMarket(uint256 seed) public ensureSharePriceNotDecreased {
address current = lidoARM.activeMarket();
address[3] memory candidates = [address(0), address(mockERC4626Market_A), address(mockERC4626Market_B)];

// Pick among the 2 candidates that differ from current
uint256 s = seed;
address picked = candidates[s % 3];
if (picked == current) picked = candidates[(s + 1) % 3];
uint256 candidateIndex = seed % 3;
address picked = candidates[candidateIndex];
if (picked == current) picked = candidates[(candidateIndex + 1) % 3];

// Switching away from a market redeems ALL shares via balanceOf. Skip the call if that full
// redeem would revert: either the market can't cover it (maxRedeem < balanceOf), or the shares
Expand Down Expand Up @@ -425,7 +439,7 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetSetARMBuffer(uint16 seed) public ensureSharePriceNotDecreased {
function targetSetARMBuffer(uint256 seed) public ensureSharePriceNotDecreased {
uint256 picked = uint256(keccak256(abi.encodePacked(seed))) % (1e18 + 1);
uint256 bps = picked / 0.0001e18;

Expand All @@ -440,7 +454,7 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
////////////////////////////////////////////////////
/// --- LIDO (external protocol simulation)
////////////////////////////////////////////////////
function targetRebase(uint16 seed) public ensureSharePriceNotDecreased {
function targetRebase(uint256 seed) public ensureSharePriceNotDecreased {
// Simulate stETH rebase by minting proportional stETH to all holders.
// Max 10% APR → max ~0.027% per day → 27 bps per call.
uint256 rebaseBps = uint256(keccak256(abi.encodePacked(seed))) % 28;
Expand All @@ -462,7 +476,8 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
////////////////////////////////////////////////////
/// --- ERC4626 MARKETS (external protocol simulation)
////////////////////////////////////////////////////
function targetSetUtilizationRate(uint8 seed, bool marketA) public ensureSharePriceNotDecreased {
function targetSetUtilizationRate(uint256 seed, uint256 marketSeed) public ensureSharePriceNotDecreased {
bool marketA = marketSeed % 2 == 0;
MockMorpho market = marketA ? mockERC4626Market_A : mockERC4626Market_B;

// Hash the seed to get uniform distribution across the range, avoiding _bound's edge bias
Expand All @@ -478,7 +493,8 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetMarketDeposit(uint128 amount, bool marketA) public ensureSharePriceNotDecreased {
function targetMarketDeposit(uint256 amount, uint256 marketSeed) public ensureSharePriceNotDecreased {
bool marketA = marketSeed % 2 == 0;
MockMorpho market = marketA ? mockERC4626Market_A : mockERC4626Market_B;
uint256 bal = weth.balanceOf(hanna);
vm.assume(bal > 0);
Expand All @@ -496,7 +512,8 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetMarketWithdraw(uint128 amount, bool marketA) public ensureSharePriceNotDecreased {
function targetMarketWithdraw(uint256 amount, uint256 marketSeed) public ensureSharePriceNotDecreased {
bool marketA = marketSeed % 2 == 0;
MockMorpho market = marketA ? mockERC4626Market_A : mockERC4626Market_B;
uint256 maxW = market.maxWithdraw(hanna);
vm.assume(maxW > 0);
Expand All @@ -511,7 +528,8 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetMarketTransferRewards(uint16 seed, bool marketA) public ensureSharePriceNotDecreased {
function targetMarketTransferRewards(uint256 seed, uint256 marketSeed) public ensureSharePriceNotDecreased {
bool marketA = marketSeed % 2 == 0;
MockMorpho market = marketA ? mockERC4626Market_A : mockERC4626Market_B;
uint256 totalAssets = market.totalAssets();
vm.assume(totalAssets > 0);
Expand Down Expand Up @@ -541,10 +559,14 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
////////////////////////////////////////////////////
/// --- PRICES AND FEES MANAGEMENT
////////////////////////////////////////////////////
function targetSetPrices(bool stETHOrWstETH, uint16 buySeed, uint16 sellSeed, uint128 buyAmount, uint128 sellAmount)
public
ensureSharePriceNotDecreased
{
function targetSetPrices(
uint256 baseAssetSeed,
uint256 buySeed,
uint256 sellSeed,
uint256 buyAmount,
uint256 sellAmount
) public ensureSharePriceNotDecreased {
bool stETHOrWstETH = baseAssetSeed % 2 == 0;
address baseAsset = stETHOrWstETH ? address(steth) : address(wsteth);
(,,,, uint128 crossPrice,,,,) = lidoARM.baseAssetConfigs(baseAsset);

Expand All @@ -554,9 +576,11 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
uint256 sellRange = MINUMUM_SELL_PRICE - crossPrice;
uint256 buyPrice = MINIMUM_BUY_PRICE + uint256(keccak256(abi.encodePacked(buySeed))) % (buyRange + 1);
uint256 sellPrice = crossPrice + uint256(keccak256(abi.encodePacked(sellSeed))) % (sellRange + 1);
uint256 boundedBuyAmount = _bound(buyAmount, 0, type(uint128).max);
uint256 boundedSellAmount = _bound(sellAmount, 0, type(uint128).max);

vm.prank(operator);
lidoARM.setPrices(baseAsset, buyPrice, sellPrice, buyAmount, sellAmount);
lidoARM.setPrices(baseAsset, buyPrice, sellPrice, boundedBuyAmount, boundedSellAmount);

if (consoleLogs) {
string memory asset = stETHOrWstETH ? "stETH" : "wstETH";
Expand All @@ -565,7 +589,8 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetSetCrossPrice(bool stETHOrWstETH, uint16 seed) public updateSharePrice {
function targetSetCrossPrice(uint256 baseAssetSeed, uint256 seed) public updateSharePrice {
bool stETHOrWstETH = baseAssetSeed % 2 == 0;
address baseAsset = stETHOrWstETH ? address(steth) : address(wsteth);
(uint128 buyPrice, uint128 sellPrice,,, uint128 currentCross,,,,) = lidoARM.baseAssetConfigs(baseAsset);

Expand Down Expand Up @@ -612,7 +637,7 @@ abstract contract TargetFunction is Invariant_LidoARM_Setup_Test {
}
}

function targetSetFee(uint16 seed) public ensureSharePriceNotDecreased {
function targetSetFee(uint256 seed) public ensureSharePriceNotDecreased {
// Fee in [0, FEE_SCALE / 2] (0% to 50%)
uint256 newFee = uint256(keccak256(abi.encodePacked(seed))) % (FEE_SCALE / 2 + 1);

Expand Down
10 changes: 6 additions & 4 deletions test/invariants/LidoARM/helpers/Helpers.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ abstract contract Helpers is Base_Test_ {
/// @notice Pick the first LP (round-robin from `seed`) that holds WETH.
/// @return user Address of the selected LP, or address(0) if none found.
/// @return balance WETH balance of the selected LP.
function selectUserWithLiqudity(uint16 seed) internal view returns (address, uint256) {
function selectUserWithLiqudity(uint256 seed) internal view returns (address, uint256) {
uint256 start = seed % LP_COUNT;
for (uint256 i; i < LP_COUNT; i++) {
address user = lps[(seed + i) % LP_COUNT];
address user = lps[(start + i) % LP_COUNT];
if (weth.balanceOf(user) > 0) {
return (user, weth.balanceOf(user));
}
Expand All @@ -72,9 +73,10 @@ abstract contract Helpers is Base_Test_ {
/// @notice Pick the first LP (round-robin from `seed`) that holds ARM shares.
/// @return user Address of the selected LP, or address(0) if none found.
/// @return balance ARM share balance of the selected LP.
function selectUserWithShares(uint16 seed) internal view returns (address, uint256) {
function selectUserWithShares(uint256 seed) internal view returns (address, uint256) {
uint256 start = seed % LP_COUNT;
for (uint256 i; i < LP_COUNT; i++) {
address user = lps[(seed + i) % LP_COUNT];
address user = lps[(start + i) % LP_COUNT];
if (lidoARM.balanceOf(user) > 0) {
return (user, lidoARM.balanceOf(user));
}
Expand Down
Loading
Loading