diff --git a/foundry.toml b/foundry.toml index 9c7ce0d..052fb1c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -13,10 +13,12 @@ runs = 1000 runs = 20 depth = 1000 shrink_run_limit = 1000 +fail_on_revert = true [profile.pr.invariant] runs = 200 depth = 1000 +shrink_run_limit = 50_000 [profile.pr.fuzz] runs = 100_000 @@ -24,6 +26,7 @@ runs = 100_000 [profile.master.invariant] runs = 200 depth = 10_000 +shrink_run_limit = 100_000 [profile.master.fuzz] runs = 1_000_000 diff --git a/test/invariant/Invariants.t.sol b/test/invariant/Invariants.t.sol index 3ac6cd4..043fbd7 100644 --- a/test/invariant/Invariants.t.sol +++ b/test/invariant/Invariants.t.sol @@ -42,13 +42,14 @@ abstract contract PSMInvariantTestBase is PSMTestBase { /**********************************************************************************************/ function _checkInvariant_A() public view { - assertEq( - psm.shares(address(lpHandler.lps(0))) + - psm.shares(address(lpHandler.lps(1))) + - psm.shares(address(lpHandler.lps(2))) + - 1e18, // Seed amount - psm.totalShares() - ); + uint256 lpShares = 1e18; // Seed amount + + // NOTE: Can be refactored to be dynamic + for (uint256 i = 0; i < 3; i++) { + lpShares += psm.shares(lpHandler.lps(i)); + } + + assertEq(lpShares, psm.totalShares()); } function _checkInvariant_B() public view { @@ -60,14 +61,108 @@ abstract contract PSMInvariantTestBase is PSMTestBase { } function _checkInvariant_C() public view { + uint256 lpAssetValue = psm.convertToAssetValue(1e18); // Seed amount + + for (uint256 i = 0; i < 3; i++) { + lpAssetValue += psm.convertToAssetValue(psm.shares(lpHandler.lps(i))); + } + + assertApproxEqAbs(lpAssetValue, psm.totalAssets(), 4); + } + + // This might be failing because of swap rounding errors. + function _checkInvariant_D() public view { + // Seed amounts + uint256 lpDeposits = 1e18; + uint256 lpAssetValue = psm.convertToAssetValue(1e18); + + for (uint256 i = 0; i < 3; i++) { + address lp = lpHandler.lps(i); + + lpDeposits += _getLpDepositsValue(lp); + lpAssetValue += psm.convertToAssetValue(psm.shares(lp)); + } + + // LPs position value can increase from transfers into the PSM and from swapping rounding + // errors increasing the value of the PSM slightly. + // Allow a 2e12 tolerance for negative rounding on conversion calculations. + assertGe(lpAssetValue + 2e12, lpDeposits); + + // Include seed deposit, allow for 2e12 negative tolerance. + assertGe(psm.totalAssets() + 2e12, lpDeposits); + } + + function _checkInvariant_E() public view { + uint256 expectedUsdcInflows = 0; + uint256 expectedDaiInflows = 1e18; // Seed amount + uint256 expectedSDaiInflows = 0; + + uint256 expectedUsdcOutflows = 0; + uint256 expectedDaiOutflows = 0; + uint256 expectedSDaiOutflows = 0; + + for(uint256 i; i < 3; i++) { + address lp = lpHandler.lps(i); + address swapper = swapperHandler.swappers(i); + + expectedUsdcInflows += lpHandler.lpDeposits(lp, address(usdc)); + expectedDaiInflows += lpHandler.lpDeposits(lp, address(dai)); + expectedSDaiInflows += lpHandler.lpDeposits(lp, address(sDai)); + + expectedUsdcInflows += swapperHandler.swapsIn(swapper, address(usdc)); + expectedDaiInflows += swapperHandler.swapsIn(swapper, address(dai)); + expectedSDaiInflows += swapperHandler.swapsIn(swapper, address(sDai)); + + expectedUsdcOutflows += lpHandler.lpWithdrawals(lp, address(usdc)); + expectedDaiOutflows += lpHandler.lpWithdrawals(lp, address(dai)); + expectedSDaiOutflows += lpHandler.lpWithdrawals(lp, address(sDai)); + + expectedUsdcOutflows += swapperHandler.swapsOut(swapper, address(usdc)); + expectedDaiOutflows += swapperHandler.swapsOut(swapper, address(dai)); + expectedSDaiOutflows += swapperHandler.swapsOut(swapper, address(sDai)); + } + + if (address(transferHandler) != address(0)) { + expectedUsdcInflows += transferHandler.transfersIn(address(usdc)); + expectedDaiInflows += transferHandler.transfersIn(address(dai)); + expectedSDaiInflows += transferHandler.transfersIn(address(sDai)); + } + + assertEq(usdc.balanceOf(address(psm)), expectedUsdcInflows - expectedUsdcOutflows); + assertEq(dai.balanceOf(address(psm)), expectedDaiInflows - expectedDaiOutflows); + assertEq(sDai.balanceOf(address(psm)), expectedSDaiInflows - expectedSDaiOutflows); + } + + function _checkInvariant_F() public view { + uint256 totalValueSwappedIn; + uint256 totalValueSwappedOut; + + for(uint256 i; i < 3; i++) { + address swapper = swapperHandler.swappers(i); + + uint256 valueSwappedIn = swapperHandler.valueSwappedIn(swapper); + uint256 valueSwappedOut = swapperHandler.valueSwappedOut(swapper); + + // TODO: Paramaterize the TimeBasedHandler and make this function take parameters. + // At really high rates the rounding errors can be quite large. + assertApproxEqAbs( + valueSwappedIn, + valueSwappedOut, + swapperHandler.swapperSwapCount(swapper) * 3e12 + ); + assertGe(valueSwappedIn, valueSwappedOut); + + totalValueSwappedIn += valueSwappedIn; + totalValueSwappedOut += valueSwappedOut; + } + + // Rounding error of up to 3e12 per swap, always rounding in favour of the PSM assertApproxEqAbs( - psm.convertToAssetValue(psm.shares(address(lpHandler.lps(0)))) + - psm.convertToAssetValue(psm.shares(address(lpHandler.lps(1)))) + - psm.convertToAssetValue(psm.shares(address(lpHandler.lps(2)))) + - psm.convertToAssetValue(1e18), // Seed amount - psm.totalAssets(), - 4 + totalValueSwappedIn, + totalValueSwappedOut, + swapperHandler.swapCount() * 3e12 ); + assertGe(totalValueSwappedIn, totalValueSwappedOut); } /**********************************************************************************************/ @@ -98,6 +193,20 @@ abstract contract PSMInvariantTestBase is PSMTestBase { return daiValue + usdcValue + sDaiValue; } + function _getLpDepositsValue(address lp) internal view returns (uint256) { + uint256 depositValue = + lpHandler.lpDeposits(lp, address(dai)) + + lpHandler.lpDeposits(lp, address(usdc)) * 1e12 + + lpHandler.lpDeposits(lp, address(sDai)) * rateProvider.getConversionRate() / 1e27; + + uint256 withdrawValue = + lpHandler.lpWithdrawals(lp, address(dai)) + + lpHandler.lpWithdrawals(lp, address(usdc)) * 1e12 + + lpHandler.lpWithdrawals(lp, address(sDai)) * rateProvider.getConversionRate() / 1e27; + + return withdrawValue > depositValue ? 0 : depositValue - withdrawValue; + } + function _getLpAPR(address lp, uint256 initialValue, uint256 warpTime) internal view returns (uint256) { @@ -172,7 +281,7 @@ abstract contract PSMInvariantTestBase is PSMTestBase { = sumLpValue - (lp0WithdrawsValue + lp1WithdrawsValue + lp2WithdrawsValue); // Assert that all funds were withdrawn equals the original value of the PSM minus the - // 1e18 share seed deposit. + // 1e18 share seed deposit, rounding for each LP. assertApproxEqAbs(totalWithdrawals, psmTotalValue - seedValue, 3); // Get the starting sum of all LPs' deposits and withdrawals. @@ -183,7 +292,7 @@ abstract contract PSMInvariantTestBase is PSMTestBase { // Assert that the sum of all LPs' deposits and withdrawals equals // the sum of all LPs' resulting token holdings. Rounding errors are accumulated to the // burn address. - assertApproxEqAbs(sumLpValue, sumStartingValue, seedValue - startingSeedValue + 2); + assertApproxEqAbs(sumLpValue, sumStartingValue, seedValue - startingSeedValue + 3); // NOTE: Below logic is not realistic, shown to demonstrate precision. @@ -197,7 +306,7 @@ abstract contract PSMInvariantTestBase is PSMTestBase { assertApproxEqAbs( sumLpValue + _getLpTokenValue(BURN_ADDRESS), sumStartingValue + startingSeedValue, - 5 + 6 ); // All funds can always be withdrawn completely. @@ -254,6 +363,9 @@ contract PSMInvariants_ConstantRate_NoTransfer is PSMInvariantTestBase { targetContract(address(lpHandler)); targetContract(address(swapperHandler)); + + // Check that LPs used for swap assertions are correct to not get zero values + assertEq(swapperHandler.lp0(), lpHandler.lps(0)); } function invariant_A() public view { @@ -268,6 +380,18 @@ contract PSMInvariants_ConstantRate_NoTransfer is PSMInvariantTestBase { _checkInvariant_C(); } + function invariant_D() public view { + _checkInvariant_D(); + } + + function invariant_E() public view { + _checkInvariant_E(); + } + + function invariant_F() public view { + _checkInvariant_F(); + } + function afterInvariant() public { _withdrawAllPositions(); } @@ -300,6 +424,17 @@ contract PSMInvariants_ConstantRate_WithTransfers is PSMInvariantTestBase { _checkInvariant_C(); } + // No invariant D because rate changes lead to large rounding errors when compared with + // ghost variables + + function invariant_E() public view { + _checkInvariant_E(); + } + + function invariant_F() public view { + _checkInvariant_F(); + } + function afterInvariant() public { _withdrawAllPositions(); } @@ -312,12 +447,15 @@ contract PSMInvariants_RateSetting_NoTransfer is PSMInvariantTestBase { super.setUp(); lpHandler = new LpHandler(psm, dai, usdc, sDai, 3); - rateSetterHandler = new RateSetterHandler(address(rateProvider), 1.25e27); + rateSetterHandler = new RateSetterHandler(psm, address(rateProvider), 1.25e27); swapperHandler = new SwapperHandler(psm, dai, usdc, sDai, 3); targetContract(address(lpHandler)); targetContract(address(rateSetterHandler)); targetContract(address(swapperHandler)); + + // Check that LPs used for swap assertions are correct to not get zero values + assertEq(swapperHandler.lp0(), lpHandler.lps(0)); } function invariant_A() public view { @@ -332,6 +470,17 @@ contract PSMInvariants_RateSetting_NoTransfer is PSMInvariantTestBase { _checkInvariant_C(); } + // No invariant D because rate changes lead to large rounding errors when compared with + // ghost variables + + function invariant_E() public view { + _checkInvariant_E(); + } + + function invariant_F() public view { + _checkInvariant_F(); + } + function afterInvariant() public { _withdrawAllPositions(); } @@ -344,7 +493,7 @@ contract PSMInvariants_RateSetting_WithTransfers is PSMInvariantTestBase { super.setUp(); lpHandler = new LpHandler(psm, dai, usdc, sDai, 3); - rateSetterHandler = new RateSetterHandler(address(rateProvider), 1.25e27); + rateSetterHandler = new RateSetterHandler(psm, address(rateProvider), 1.25e27); swapperHandler = new SwapperHandler(psm, dai, usdc, sDai, 3); transferHandler = new TransferHandler(psm, dai, usdc, sDai); @@ -352,6 +501,9 @@ contract PSMInvariants_RateSetting_WithTransfers is PSMInvariantTestBase { targetContract(address(rateSetterHandler)); targetContract(address(swapperHandler)); targetContract(address(transferHandler)); + + // Check that LPs used for swap assertions are correct to not get zero values + assertEq(swapperHandler.lp0(), lpHandler.lps(0)); } function invariant_A() public view { @@ -366,6 +518,17 @@ contract PSMInvariants_RateSetting_WithTransfers is PSMInvariantTestBase { _checkInvariant_C(); } + // No invariant D because rate changes lead to large rounding errors when compared with + // ghost variables + + function invariant_E() public view { + _checkInvariant_E(); + } + + function invariant_F() public view { + _checkInvariant_F(); + } + function afterInvariant() public { _withdrawAllPositions(); } @@ -387,7 +550,7 @@ contract PSMInvariants_TimeBasedRateSetting_NoTransfer is PSMInvariantTestBase { lpHandler = new LpHandler(psm, dai, usdc, sDai, 3); swapperHandler = new SwapperHandler(psm, dai, usdc, sDai, 3); - timeBasedRateHandler = new TimeBasedRateHandler(dsrOracle); + timeBasedRateHandler = new TimeBasedRateHandler(psm, dsrOracle); // Handler acts in the same way as a receiver on L2, so add as a data provider to the // oracle. @@ -396,11 +559,14 @@ contract PSMInvariants_TimeBasedRateSetting_NoTransfer is PSMInvariantTestBase { rateProvider = IRateProviderLike(address(dsrOracle)); // Manually set initial values for the oracle through the handler to start - timeBasedRateHandler.setPotData(1e27, block.timestamp); + timeBasedRateHandler.setPotData(1e27); targetContract(address(lpHandler)); targetContract(address(swapperHandler)); targetContract(address(timeBasedRateHandler)); + + // Check that LPs used for swap assertions are correct to not get zero values + assertEq(swapperHandler.lp0(), lpHandler.lps(0)); } function invariant_A() public view { @@ -415,6 +581,17 @@ contract PSMInvariants_TimeBasedRateSetting_NoTransfer is PSMInvariantTestBase { _checkInvariant_C(); } + // No invariant D because rate changes lead to large rounding errors when compared with + // ghost variables + + function invariant_E() public view { + _checkInvariant_E(); + } + + function invariant_F() public view { + _checkInvariant_F(); + } + function afterInvariant() public { uint256 snapshot = vm.snapshot(); @@ -442,7 +619,7 @@ contract PSMInvariants_TimeBasedRateSetting_WithTransfers is PSMInvariantTestBas lpHandler = new LpHandler(psm, dai, usdc, sDai, 3); swapperHandler = new SwapperHandler(psm, dai, usdc, sDai, 3); - timeBasedRateHandler = new TimeBasedRateHandler(dsrOracle); + timeBasedRateHandler = new TimeBasedRateHandler(psm, dsrOracle); transferHandler = new TransferHandler(psm, dai, usdc, sDai); // Handler acts in the same way as a receiver on L2, so add as a data provider to the @@ -452,12 +629,15 @@ contract PSMInvariants_TimeBasedRateSetting_WithTransfers is PSMInvariantTestBas rateProvider = IRateProviderLike(address(dsrOracle)); // Manually set initial values for the oracle through the handler to start - timeBasedRateHandler.setPotData(1e27, block.timestamp); + timeBasedRateHandler.setPotData(1e27); targetContract(address(lpHandler)); targetContract(address(swapperHandler)); targetContract(address(timeBasedRateHandler)); targetContract(address(transferHandler)); + + // Check that LPs used for swap assertions are correct to not get zero values + assertEq(swapperHandler.lp0(), lpHandler.lps(0)); } function invariant_A() public view { @@ -472,6 +652,17 @@ contract PSMInvariants_TimeBasedRateSetting_WithTransfers is PSMInvariantTestBas _checkInvariant_C(); } + // No invariant D because rate changes lead to large rounding errors when compared with + // ghost variables + + function invariant_E() public view { + _checkInvariant_E(); + } + + function invariant_F() public view { + _checkInvariant_F(); + } + function afterInvariant() public { uint256 snapshot = vm.snapshot(); diff --git a/test/invariant/handlers/HandlerBase.sol b/test/invariant/handlers/HandlerBase.sol index fbc8dff..828236a 100644 --- a/test/invariant/handlers/HandlerBase.sol +++ b/test/invariant/handlers/HandlerBase.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.13; -import { MockERC20 } from "erc20-helpers/MockERC20.sol"; - import { CommonBase } from "forge-std/Base.sol"; +import { console } from "forge-std/console.sol"; import { StdCheatsSafe } from "forge-std/StdCheats.sol"; +import { stdMath } from "forge-std/StdMath.sol"; import { StdUtils } from "forge-std/StdUtils.sol"; import { PSM3 } from "src/PSM3.sol"; @@ -13,27 +13,79 @@ contract HandlerBase is CommonBase, StdCheatsSafe, StdUtils { PSM3 public psm; - MockERC20[3] public assets; - - constructor( - PSM3 psm_, - MockERC20 asset0, - MockERC20 asset1, - MockERC20 asset2 - ) { + constructor(PSM3 psm_) { psm = psm_; + } - assets[0] = asset0; - assets[1] = asset1; - assets[2] = asset2; + function _hash(uint256 number_, string memory salt) internal pure returns (uint256 hash_) { + hash_ = uint256(keccak256(abi.encode(number_, salt))); } - function _getAsset(uint256 indexSeed) internal view returns (MockERC20) { - return assets[indexSeed % assets.length]; + /**********************************************************************************************/ + /*** Assertion helpers (copied from ds-test and modified to revert) ***/ + /**********************************************************************************************/ + + function assertEq(uint256 a, uint256 b, string memory err) internal view { + if (a != b) { + console.log("Error: a == b not satisfied [uint256]"); + console.log(" Left", a); + console.log(" Right", b); + revert(err); + } } - function _hash(uint256 number_, string memory salt) internal pure returns (uint256 hash_) { - hash_ = uint256(keccak256(abi.encode(number_, salt))); + function assertGe(uint256 a, uint256 b, string memory err) internal view { + if (a < b) { + console.log("Error: a >= b not satisfied [uint256]"); + console.log(" Left", a); + console.log(" Right", b); + revert(err); + } + } + + function assertLe(uint256 a, uint256 b, string memory err) internal view { + if (a > b) { + console.log("Error: a <= b not satisfied [uint256]"); + console.log(" Left", a); + console.log(" Right", b); + revert(err); + } + } + + function assertApproxEqAbs(uint256 a, uint256 b, uint256 maxDelta, string memory err) + internal view + { + uint256 delta = stdMath.delta(a, b); + + if (delta > maxDelta) { + console.log("Error: a ~= b not satisfied [uint]"); + console.log(" Left", a); + console.log(" Right", b); + console.log(" Max Delta", maxDelta); + console.log(" Delta", delta); + revert(err); + } + } + + function assertApproxEqRel( + uint256 a, + uint256 b, + uint256 maxPercentDelta, // An 18 decimal fixed point number, where 1e18 == 100% + string memory err + ) internal virtual { + // If the left is 0, right must be too. + if (b == 0) return assertEq(a, b, string(abi.encodePacked("assertEq - ", err))); + + uint256 percentDelta = stdMath.percentDelta(a, b); + + if (percentDelta > maxPercentDelta) { + console.log("Error: a ~= b not satisfied [uint]"); + console.log(" Left", a); + console.log(" Right", b); + console.log(" Max % Delta [wad]", maxPercentDelta); + console.log(" % Delta [wad]", percentDelta); + revert(err); + } } } diff --git a/test/invariant/handlers/LpHandler.sol b/test/invariant/handlers/LpHandler.sol index fcbb068..4c47205 100644 --- a/test/invariant/handlers/LpHandler.sol +++ b/test/invariant/handlers/LpHandler.sol @@ -3,58 +3,119 @@ pragma solidity ^0.8.13; import { MockERC20 } from "erc20-helpers/MockERC20.sol"; -import { HandlerBase } from "test/invariant/handlers/HandlerBase.sol"; - -import { PSM3 } from "src/PSM3.sol"; +import { HandlerBase, PSM3 } from "test/invariant/handlers/HandlerBase.sol"; contract LpHandler is HandlerBase { + MockERC20[3] public assets; + address[] public lps; uint256 public depositCount; uint256 public withdrawCount; + mapping(address user => mapping(address asset => uint256 deposits)) public lpDeposits; + mapping(address user => mapping(address asset => uint256 withdrawals)) public lpWithdrawals; + constructor( PSM3 psm_, MockERC20 asset0, MockERC20 asset1, MockERC20 asset2, uint256 lpCount - ) HandlerBase(psm_, asset0, asset1, asset2) { + ) HandlerBase(psm_) { + assets[0] = asset0; + assets[1] = asset1; + assets[2] = asset2; + for (uint256 i = 0; i < lpCount; i++) { lps.push(makeAddr(string(abi.encodePacked("lp-", vm.toString(i))))); } } + function _getAsset(uint256 indexSeed) internal view returns (MockERC20) { + return assets[indexSeed % assets.length]; + } + function _getLP(uint256 indexSeed) internal view returns (address) { return lps[indexSeed % lps.length]; } function deposit(uint256 assetSeed, uint256 lpSeed, uint256 amount) public { + // 1. Setup and bounds MockERC20 asset = _getAsset(assetSeed); address lp = _getLP(lpSeed); amount = _bound(amount, 1, 1e12 * 10 ** asset.decimals()); + // 2. Cache starting state + uint256 startingConversion = psm.convertToShares(1e18); + uint256 startingValue = psm.totalAssets(); + + // 3. Perform action against protocol vm.startPrank(lp); asset.mint(lp, amount); asset.approve(address(psm), amount); psm.deposit(address(asset), lp, amount); vm.stopPrank(); + // 4. Update ghost variable(s) + lpDeposits[lp][address(asset)] += amount; + + // 5. Perform action-specific assertions + assertApproxEqAbs( + psm.convertToShares(1e18), + startingConversion, + 2, + "LpHandler/deposit/conversion-rate-change" + ); + + assertGe( + psm.totalAssets() + 1, + startingValue, + "LpHandler/deposit/psm-total-value-decrease" + ); + + // 6. Update metrics tracking state depositCount++; } function withdraw(uint256 assetSeed, uint256 lpSeed, uint256 amount) public { + // 1. Setup and bounds MockERC20 asset = _getAsset(assetSeed); address lp = _getLP(lpSeed); amount = _bound(amount, 1, 1e12 * 10 ** asset.decimals()); + // 2. Cache starting state + uint256 startingConversion = psm.convertToShares(1e18); + uint256 startingValue = psm.totalAssets(); + + // 3. Perform action against protocol vm.prank(lp); - psm.withdraw(address(asset), lp, amount); + uint256 withdrawAmount = psm.withdraw(address(asset), lp, amount); vm.stopPrank(); + // 4. Update ghost variable(s) + lpWithdrawals[lp][address(asset)] += withdrawAmount; + + // 5. Perform action-specific assertions + + // Larger tolerance for rounding errors because of burning more shares on USDC withdraw + assertApproxEqAbs( + psm.convertToShares(1e18), + startingConversion, + 1e12, + "LpHandler/withdraw/conversion-rate-change" + ); + + assertLe( + psm.totalAssets(), + startingValue + 1, + "LpHandler/withdraw/psm-total-value-increase" + ); + + // 6. Update metrics tracking state withdrawCount++; } diff --git a/test/invariant/handlers/RateSetterHandler.sol b/test/invariant/handlers/RateSetterHandler.sol index 9eb0702..a80dca3 100644 --- a/test/invariant/handlers/RateSetterHandler.sol +++ b/test/invariant/handlers/RateSetterHandler.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.13; -import { StdUtils } from "forge-std/StdUtils.sol"; +import { HandlerBase, PSM3 } from "test/invariant/handlers/HandlerBase.sol"; import { MockRateProvider } from "test/mocks/MockRateProvider.sol"; -contract RateSetterHandler is StdUtils { +contract RateSetterHandler is HandlerBase { uint256 public rate; @@ -13,17 +13,38 @@ contract RateSetterHandler is StdUtils { uint256 public setRateCount; - constructor(address rateProvider_, uint256 initialRate) { + constructor(PSM3 psm_, address rateProvider_, uint256 initialRate) HandlerBase(psm_) { rateProvider = MockRateProvider(rateProvider_); rate = initialRate; } function setRate(uint256 rateIncrease) external { - // Increase the rate by up to 100% - rate += _bound(rateIncrease, 0, 1e27); + // 1. Setup and bounds + // Increase the rate by up to 5% + rate += _bound(rateIncrease, 0, 0.05e27); + + // 2. Cache starting state + uint256 startingConversion = psm.convertToAssetValue(1e18); + uint256 startingValue = psm.totalAssets(); + + // 3. Perform action against protocol rateProvider.__setConversionRate(rate); + // 4. Perform action-specific assertions + assertGe( + psm.convertToAssetValue(1e18) + 1, + startingConversion, + "RateSetterHandler/setRate/conversion-rate-decrease" + ); + + assertGe( + psm.totalAssets() + 1, + startingValue, + "RateSetterHandler/setRate/psm-total-value-decrease" + ); + + // 5. Update metrics tracking state setRateCount++; } diff --git a/test/invariant/handlers/SwapperHandler.sol b/test/invariant/handlers/SwapperHandler.sol index c28c83a..2b9fbd1 100644 --- a/test/invariant/handlers/SwapperHandler.sol +++ b/test/invariant/handlers/SwapperHandler.sol @@ -3,14 +3,28 @@ pragma solidity ^0.8.13; import { MockERC20 } from "erc20-helpers/MockERC20.sol"; -import { HandlerBase } from "test/invariant/handlers/HandlerBase.sol"; +import { HandlerBase, PSM3 } from "test/invariant/handlers/HandlerBase.sol"; -import { PSM3 } from "src/PSM3.sol"; +import { IRateProviderLike } from "src/interfaces/IRateProviderLike.sol"; contract SwapperHandler is HandlerBase { + MockERC20[3] public assets; + address[] public swappers; + IRateProviderLike public rateProvider; + + mapping(address user => mapping(address asset => uint256 deposits)) public swapsIn; + mapping(address user => mapping(address asset => uint256 deposits)) public swapsOut; + + mapping(address user => uint256) public valueSwappedIn; + mapping(address user => uint256) public valueSwappedOut; + mapping(address user => uint256) public swapperSwapCount; + + // Used for assertions, assumption made that LpHandler is used with at least 1 LP. + address public lp0; + uint256 public swapCount; uint256 public zeroBalanceCount; @@ -19,11 +33,24 @@ contract SwapperHandler is HandlerBase { MockERC20 asset0, MockERC20 asset1, MockERC20 asset2, - uint256 lpCount - ) HandlerBase(psm_, asset0, asset1, asset2) { - for (uint256 i = 0; i < lpCount; i++) { + uint256 swapperCount + ) HandlerBase(psm_) { + assets[0] = asset0; + assets[1] = asset1; + assets[2] = asset2; + + rateProvider = IRateProviderLike(psm.rateProvider()); + + for (uint256 i = 0; i < swapperCount; i++) { swappers.push(makeAddr(string(abi.encodePacked("swapper-", vm.toString(i))))); } + + // Derive LP-0 address for assertion + lp0 = makeAddr("lp-0"); + } + + function _getAsset(uint256 indexSeed) internal view returns (MockERC20) { + return assets[indexSeed % assets.length]; } function _getSwapper(uint256 indexSeed) internal view returns (address) { @@ -39,6 +66,8 @@ contract SwapperHandler is HandlerBase { ) public { + // 1. Setup and bounds + // Prevent overflow in if statement below assetOutSeed = _bound(assetOutSeed, 0, type(uint256).max - 2); @@ -74,15 +103,119 @@ contract SwapperHandler is HandlerBase { psm.previewSwapExactIn(address(assetIn), address(assetOut), amountIn) ); + // 2. Cache starting state + uint256 startingConversion = psm.convertToAssetValue(1e18); + uint256 startingConversionMillion = psm.convertToAssetValue(1e6 * 1e18); + uint256 startingConversionLp0 = psm.convertToAssetValue(psm.shares(lp0)); + uint256 startingValue = psm.totalAssets(); + + // 3. Perform action against protocol vm.startPrank(swapper); assetIn.mint(swapper, amountIn); assetIn.approve(address(psm), amountIn); - psm.swapExactIn(address(assetIn), address(assetOut), amountIn, minAmountOut, swapper, 0); + uint256 amountOut = psm.swapExactIn( + address(assetIn), + address(assetOut), + amountIn, + minAmountOut, + swapper, + 0 + ); vm.stopPrank(); + // 4. Update ghost variable(s) + swapsIn[swapper][address(assetIn)] += amountIn; + swapsOut[swapper][address(assetOut)] += amountOut; + + uint256 valueIn = _getAssetValue(address(assetIn), amountIn); + uint256 valueOut = _getAssetValue(address(assetOut), amountOut); + + valueSwappedIn[swapper] += valueIn; + valueSwappedOut[swapper] += valueOut; + + swapperSwapCount[swapper]++; + + // 5. Perform action-specific assertions + + // Rounding because of USDC precision, a the conversion rate of a + // user's position can fluctuate by up to 2e12 per 1e18 shares + assertApproxEqAbs( + psm.convertToAssetValue(1e18), + startingConversion, + 2e12, + "SwapperHandler/swap/conversion-rate-change" + ); + + // Demonstrate rounding scales with shares + assertApproxEqAbs( + psm.convertToAssetValue(1_000_000e18), + startingConversionMillion, + 2_000_000e12, // 2e18 of value + "SwapperHandler/swap/conversion-rate-change-million" + ); + + // Rounding is always in favour of the protocol + assertGe( + psm.convertToAssetValue(1_000_000e18), + startingConversionMillion, + "SwapperHandler/swap/conversion-rate-million-decrease" + ); + + // Disregard this assertion if the LP has less than a dollar of value + if (startingConversionLp0 > 1e18) { + // Position values can fluctuate by up to 0.00000002% on swaps + assertApproxEqRel( + psm.convertToAssetValue(psm.shares(lp0)), + startingConversionLp0, + 0.000002e18, + "SwapperHandler/swap/conversion-rate-change-lp" + ); + } + + // Rounding is always in favour of the user + assertGe( + psm.convertToAssetValue(psm.shares(lp0)), + startingConversionLp0, + "SwapperHandler/swap/conversion-rate-lp-decrease" + ); + + // PSM value can fluctuate by up to 0.00000002% on swaps because of USDC rounding + assertApproxEqRel( + psm.totalAssets(), + startingValue, + 0.000002e18, + "SwapperHandler/swap/psm-total-value-change" + ); + + // Rounding is always in favour of the protocol + assertGe( + psm.totalAssets(), + startingValue, + "SwapperHandler/swap/psm-total-value-decrease" + ); + + // High rates introduce larger rounding errors + uint256 rateIntroducedRounding = rateProvider.getConversionRate() / 1e27; + + assertApproxEqAbs( + valueIn, + valueOut, 1e12 + rateIntroducedRounding * 1e12, + "SwapperHandler/swap/value-mismatch" + ); + + assertGe(valueIn, valueOut, "SwapperHandler/swap/value-out-greater-than-in"); + + // 6. Update metrics tracking state swapCount++; } + function _getAssetValue(address asset, uint256 amount) internal view returns (uint256) { + if (asset == address(assets[0])) return amount; + else if (asset == address(assets[1])) return amount * 1e12; + else if (asset == address(assets[2])) return amount * rateProvider.getConversionRate() / 1e27; + else revert("SwapperHandler/asset-not-found"); + } + // TODO: Add swapExactOut in separate PR } diff --git a/test/invariant/handlers/TimeBasedRateHandler.sol b/test/invariant/handlers/TimeBasedRateHandler.sol index 5eb6f25..5d2353e 100644 --- a/test/invariant/handlers/TimeBasedRateHandler.sol +++ b/test/invariant/handlers/TimeBasedRateHandler.sol @@ -1,46 +1,94 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.13; +import { HandlerBase, PSM3 } from "test/invariant/handlers/HandlerBase.sol"; + import { StdCheats } from "forge-std/StdCheats.sol"; -import { StdUtils } from "forge-std/StdUtils.sol"; import { DSRAuthOracle } from "lib/xchain-dsr-oracle/src/DSRAuthOracle.sol"; import { IDSROracle } from "lib/xchain-dsr-oracle/src/interfaces/IDSROracle.sol"; -contract TimeBasedRateHandler is StdCheats, StdUtils { +contract TimeBasedRateHandler is HandlerBase, StdCheats { uint256 public dsr; - uint256 public rho; - uint256 constant ONE_HUNDRED_PCT_APY_DSR = 1.000000021979553151239153027e27; + uint256 constant TWENTY_PCT_APY_DSR = 1.000000005781378656804591712e27; DSRAuthOracle public dsrOracle; - uint256 public setRateCount; + uint256 public setPotDataCount; + uint256 public warpCount; - constructor(DSRAuthOracle dsrOracle_) { + constructor(PSM3 psm_, DSRAuthOracle dsrOracle_) HandlerBase(psm_) { dsrOracle = dsrOracle_; } // This acts as a receiver on an L2. - function setPotData(uint256 newDsr, uint256 newRho) external { - dsr = _bound(newDsr, 1e27, ONE_HUNDRED_PCT_APY_DSR); - rho = _bound(newRho, rho, block.timestamp); + function setPotData(uint256 newDsr) external { + // 1. Setup and bounds + dsr = _bound(newDsr, 1e27, TWENTY_PCT_APY_DSR); + + uint256 rho = block.timestamp; // If chi hasn't been set yet, set to 1e27, else recalculate it in the same way it would - // happen during a refresh. - uint256 rate = dsrOracle.getConversionRate(); + // happen during a refresh at `rho` + uint256 rate = dsrOracle.getConversionRate(rho); uint256 chi = rate == 0 ? 1e27 : rate; + // 2. Cache starting state + uint256 startingConversion = psm.convertToAssetValue(1e18); + uint256 startingValue = psm.totalAssets(); + + // 3. Perform action against protocol dsrOracle.setPotData(IDSROracle.PotData({ dsr: uint96(dsr), chi: uint120(chi), rho: uint40(rho) })); + + // 4. Perform action-specific assertions + assertGe( + psm.convertToAssetValue(1e18) + 1, + startingConversion, + "TimeBasedRateHandler/setPotData/conversion-rate-decrease" + ); + + assertGe( + psm.totalAssets() + 1, + startingValue, + "TimeBasedRateHandler/setPotData/psm-total-value-decrease" + ); + + // 5. Update metrics tracking state + setPotDataCount++; } function warp(uint256 skipTime) external { - skip(_bound(skipTime, 0, 45 days)); + // 1. Setup and bounds + uint256 warpTime = _bound(skipTime, 0, 10 days); + + // 2. Cache starting state + uint256 startingConversion = psm.convertToAssetValue(1e18); + uint256 startingValue = psm.totalAssets(); + + // 3. Perform action against protocol + skip(warpTime); + + // 4. Perform action-specific assertions + assertGe( + psm.convertToAssetValue(1e18), + startingConversion, + "RateSetterHandler/warp/conversion-rate-decrease" + ); + + assertGe( + psm.totalAssets(), + startingValue, + "RateSetterHandler/warp/psm-total-value-decrease" + ); + + // 5. Update metrics tracking state + warpCount++; } } diff --git a/test/invariant/handlers/TransferHandler.sol b/test/invariant/handlers/TransferHandler.sol index fe579e6..3fde075 100644 --- a/test/invariant/handlers/TransferHandler.sol +++ b/test/invariant/handlers/TransferHandler.sol @@ -9,29 +9,64 @@ import { PSM3 } from "src/PSM3.sol"; contract TransferHandler is HandlerBase { + MockERC20[3] public assets; + uint256 public transferCount; + mapping(address asset => uint256) public transfersIn; + constructor( PSM3 psm_, MockERC20 asset0, MockERC20 asset1, MockERC20 asset2 - ) HandlerBase(psm_, asset0, asset1, asset2) {} + ) HandlerBase(psm_) { + assets[0] = asset0; + assets[1] = asset1; + assets[2] = asset2; + } + + function _getAsset(uint256 indexSeed) internal view returns (MockERC20) { + return assets[indexSeed % assets.length]; + } function transfer(uint256 assetSeed, string memory senderSeed, uint256 amount) external { - MockERC20 asset = _getAsset(assetSeed); + // 1. Setup and bounds + + MockERC20 asset = _getAsset(assetSeed); address sender = makeAddr(senderSeed); + // 2. Cache starting state + uint256 startingConversion = psm.convertToAssetValue(1e18); + uint256 startingValue = psm.totalAssets(); + // Bounding to 10 million here because 1 trillion introduces unrealistic conditions with // large rounding errors. Would rather keep tolerances smaller with a lower upper bound // on transfer amounts. amount = _bound(amount, 1, 10_000_000 * 10 ** asset.decimals()); + // 3. Perform action against protocol asset.mint(sender, amount); - vm.prank(sender); asset.transfer(address(psm), amount); + // 4. Update ghost variable(s) + transfersIn[address(asset)] += amount; + + // 5. Perform action-specific assertions + assertGe( + psm.convertToAssetValue(1e18) + 1, + startingConversion, + "TransferHandler/transfer/conversion-rate-decrease" + ); + + assertGe( + psm.totalAssets() + 1, + startingValue, + "TransferHandler/transfer/psm-total-value-decrease" + ); + + // 6. Update metrics tracking state transferCount += 1; } diff --git a/test/unit/Conversions.t.sol b/test/unit/Conversions.t.sol index 1123c9e..b63fa0b 100644 --- a/test/unit/Conversions.t.sol +++ b/test/unit/Conversions.t.sol @@ -706,10 +706,9 @@ contract PSMConvertToSharesWithSDaiTests is PSMConversionTestBase { // 1.50/1.10 = 1.3636... shares mockRateProvider.__setConversionRate(1.5e27); - // TODO: Reinvestigate this, interesting difference in rounding assertEq(psm.convertToShares(address(sDai), 1), 0); assertEq(psm.convertToShares(address(sDai), 2), 2); - assertEq(psm.convertToShares(address(sDai), 3), 3); + assertEq(psm.convertToShares(address(sDai), 3), 3); // 3 * 1.5 / 1.1 = 3 because of rounding on first operation assertEq(psm.convertToShares(address(sDai), 4), 5); assertEq(psm.convertToShares(address(sDai), 1e18), 1.363636363636363636e18); diff --git a/test/unit/SwapExactOut.t.sol b/test/unit/SwapExactOut.t.sol index 13e9252..028296f 100644 --- a/test/unit/SwapExactOut.t.sol +++ b/test/unit/SwapExactOut.t.sol @@ -116,30 +116,6 @@ contract PSMSwapExactOutFailureTests is PSMTestBase { psm.swapExactOut(address(usdc), address(sDai), 100e18, 125e6 + 1, receiver, 0); } - // TODO: Cover this case in previews - // function test_demoRoundingIssue() public { - // sDai.mint(address(psm), 1_000_000e18); // Mint so balance isn't an issue - - // usdc.mint(swapper, 100e6); - - // vm.startPrank(swapper); - - // usdc.approve(address(psm), 100e6); - - // uint256 expectedAmountIn1 = psm.previewSwapExactOut(address(usdc), address(sDai), 80e18); - // uint256 expectedAmountIn2 = psm.previewSwapExactOut(address(usdc), address(sDai), 80e18 + 0.8e12 - 1); - // uint256 expectedAmountIn3 = psm.previewSwapExactOut(address(usdc), address(sDai), 80e18 + 0.8e12); - - // assertEq(expectedAmountIn1, 100e6); - // assertEq(expectedAmountIn2, 100e6); - // assertEq(expectedAmountIn3, 100e6 + 1); - - // vm.expectRevert("PSM3/amountIn-too-high"); - // psm.swapExactOut(address(usdc), address(sDai), 80e18 + 0.8e12, 100e6, receiver, 0); - - // psm.swapExactOut(address(usdc), address(sDai), 80e18 + 0.8e12 - 1, 100e6, receiver, 0); - // } - } contract PSMSwapExactOutSuccessTestsBase is PSMTestBase {