diff --git a/Makefile b/Makefile index a93614417..c37e09477 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,10 @@ update:; forge update # Build & test build :; forge build --sizes -test :; forge test -vvv +test :; forge test -vvv --no-match-path 'tests/fork/**' + +# Mainnet-fork upgrade-safety suite (requires RPC_MAINNET; pin a block with FORK_BLOCK=) +test-fork :; forge test -vvv --match-path 'tests/fork/**' # Utilities download :; cast etherscan-source --chain ${chain} -d src/etherscan/${chain}_${address} ${address} diff --git a/snapshots/ConfigPositionManager.Operations.json b/snapshots/ConfigPositionManager.Operations.json index d6a8bc2dc..2e16a7cfb 100644 --- a/snapshots/ConfigPositionManager.Operations.json +++ b/snapshots/ConfigPositionManager.Operations.json @@ -7,7 +7,7 @@ "setCanUpdateUserDynamicConfigPermission": "50158", "setCanUpdateUserRiskPremiumPermission": "50158", "setGlobalPermission": "50110", - "setUsingAsCollateralOnBehalfOf": "77828", - "updateUserDynamicConfigOnBehalfOf": "52342", - "updateUserRiskPremiumOnBehalfOf": "140135" + "setUsingAsCollateralOnBehalfOf": "78361", + "updateUserDynamicConfigOnBehalfOf": "57354", + "updateUserRiskPremiumOnBehalfOf": "145038" } \ No newline at end of file diff --git a/snapshots/GiverPositionManager.Operations.json b/snapshots/GiverPositionManager.Operations.json index 35e0a2001..0c8d902d6 100644 --- a/snapshots/GiverPositionManager.Operations.json +++ b/snapshots/GiverPositionManager.Operations.json @@ -1,4 +1,4 @@ { - "repayOnBehalfOf": "176497", - "supplyOnBehalfOf": "143072" + "repayOnBehalfOf": "177119", + "supplyOnBehalfOf": "143426" } \ No newline at end of file diff --git a/snapshots/NativeTokenGateway.Operations.json b/snapshots/NativeTokenGateway.Operations.json index 837c577c6..664c00e1a 100644 --- a/snapshots/NativeTokenGateway.Operations.json +++ b/snapshots/NativeTokenGateway.Operations.json @@ -1,8 +1,8 @@ { - "borrowNative": "238707", - "repayNative": "172321", - "supplyAsCollateralNative": "165113", - "supplyNative": "139728", - "withdrawNative: full": "129898", - "withdrawNative: partial": "142172" + "borrowNative": "245575", + "repayNative": "172943", + "supplyAsCollateralNative": "165840", + "supplyNative": "140082", + "withdrawNative: full": "130096", + "withdrawNative: partial": "142419" } \ No newline at end of file diff --git a/snapshots/PositionManagerBase.Operations.json b/snapshots/PositionManagerBase.Operations.json index afd9324b0..10ec82cb9 100644 --- a/snapshots/PositionManagerBase.Operations.json +++ b/snapshots/PositionManagerBase.Operations.json @@ -1,3 +1,3 @@ { - "setSelfAsUserPositionManagerWithSig": "75041" + "setSelfAsUserPositionManagerWithSig": "75029" } \ No newline at end of file diff --git a/snapshots/SignatureGateway.Operations.json b/snapshots/SignatureGateway.Operations.json index 93a4414b0..d44917de0 100644 --- a/snapshots/SignatureGateway.Operations.json +++ b/snapshots/SignatureGateway.Operations.json @@ -1,10 +1,10 @@ { - "borrowWithSig": "222144", - "repayWithSig": "192513", - "setSelfAsUserPositionManagerWithSig": "75138", - "setUsingAsCollateralWithSig": "85380", - "supplyWithSig": "155914", - "updateUserDynamicConfigWithSig": "63113", - "updateUserRiskPremiumWithSig": "61995", - "withdrawWithSig": "135124" + "borrowWithSig": "229167", + "repayWithSig": "193135", + "setSelfAsUserPositionManagerWithSig": "75126", + "setUsingAsCollateralWithSig": "85665", + "supplyWithSig": "156268", + "updateUserDynamicConfigWithSig": "68089", + "updateUserRiskPremiumWithSig": "66249", + "withdrawWithSig": "135321" } \ No newline at end of file diff --git a/snapshots/Spoke.Getters.json b/snapshots/Spoke.Getters.json index 157832701..48c81c17f 100644 --- a/snapshots/Spoke.Getters.json +++ b/snapshots/Spoke.Getters.json @@ -1,7 +1,9 @@ { - "getUserAccountData: supplies: 0, borrows: 0": "13014", - "getUserAccountData: supplies: 1, borrows: 0": "56072", - "getUserAccountData: supplies: 2, borrows: 0": "89894", - "getUserAccountData: supplies: 2, borrows: 1": "112389", - "getUserAccountData: supplies: 2, borrows: 2": "133792" + "getUserAccountData (positionSalt): supplies: 1, borrows: 0": "59912", + "getUserAccountData (positionSalt): supplies: 2, borrows: 1": "116758", + "getUserAccountData: supplies: 0, borrows: 0": "17361", + "getUserAccountData: supplies: 1, borrows: 0": "60561", + "getUserAccountData: supplies: 2, borrows: 0": "94432", + "getUserAccountData: supplies: 2, borrows: 1": "117407", + "getUserAccountData: supplies: 2, borrows: 2": "138569" } \ No newline at end of file diff --git a/snapshots/Spoke.Operations.PositionSalt.json b/snapshots/Spoke.Operations.PositionSalt.json new file mode 100644 index 000000000..8c8fee74a --- /dev/null +++ b/snapshots/Spoke.Operations.PositionSalt.json @@ -0,0 +1,29 @@ +{ + "borrow: first": "275121", + "borrow: second action, same reserve": "217987", + "liquidationCall (receiveShares): full": "354539", + "liquidationCall (receiveShares): partial": "353957", + "liquidationCall (reportDeficit): full": "377878", + "liquidationCall: full": "373075", + "liquidationCall: partial": "372493", + "repay: full": "124224", + "repay: partial": "143600", + "supply + enable collateral (multicall)": "165399", + "supply: 0 borrows, collateral disabled": "129220", + "supply: 0 borrows, collateral enabled": "112191", + "supply: second action, same reserve": "112120", + "updateUserDynamicConfig: 1 collateral": "82893", + "updateUserDynamicConfig: 2 collaterals": "100176", + "updateUserRiskPremium: 1 borrow": "164520", + "updateUserRiskPremium: 2 borrows": "215966", + "usingAsCollateral: 0 borrows, enable": "59876", + "usingAsCollateral: 1 borrow, disable": "175274", + "usingAsCollateral: 1 borrow, enable": "42764", + "usingAsCollateral: 2 borrows, disable": "248127", + "usingAsCollateral: 2 borrows, enable": "42776", + "withdraw: 0 borrows, full": "141390", + "withdraw: 0 borrows, partial": "147177", + "withdraw: 1 borrow, partial": "228420", + "withdraw: 2 borrows, partial": "277319", + "withdraw: non collateral": "112134" +} \ No newline at end of file diff --git a/snapshots/Spoke.Operations.ZeroRiskPremium.json b/snapshots/Spoke.Operations.ZeroRiskPremium.json index fcf844086..464862e99 100644 --- a/snapshots/Spoke.Operations.ZeroRiskPremium.json +++ b/snapshots/Spoke.Operations.ZeroRiskPremium.json @@ -1,34 +1,34 @@ { - "borrow: first": "199509", - "borrow: second action, same reserve": "179375", - "liquidationCall (receiveShares): full": "314227", - "liquidationCall (receiveShares): partial": "313645", - "liquidationCall (reportDeficit): full": "380307", - "liquidationCall: full": "332763", - "liquidationCall: partial": "332181", - "permitReserve + repay (multicall)": "169938", - "permitReserve + supply (multicall)": "151663", - "permitReserve + supply + enable collateral (multicall)": "166114", - "repay: full": "129276", - "repay: partial": "134234", - "setUserPositionManagersWithSig: disable": "46772", + "borrow: first": "206210", + "borrow: second action, same reserve": "186076", + "liquidationCall (receiveShares): full": "321243", + "liquidationCall (receiveShares): partial": "320661", + "liquidationCall (reportDeficit): full": "385225", + "liquidationCall: full": "339779", + "liquidationCall: partial": "339197", + "permitReserve + repay (multicall)": "170580", + "permitReserve + supply (multicall)": "152215", + "permitReserve + supply + enable collateral (multicall)": "166951", + "repay: full": "129588", + "repay: partial": "134564", + "setUserPositionManagersWithSig: disable": "46760", "setUserPositionManagersWithSig: enable": "68684", - "supply + enable collateral (multicall)": "146316", - "supply: 0 borrows, collateral disabled": "127753", - "supply: 0 borrows, collateral enabled": "110724", - "supply: second action, same reserve": "110653", - "updateUserDynamicConfig: 1 collateral": "76251", - "updateUserDynamicConfig: 2 collaterals": "92825", - "updateUserRiskPremium: 1 borrow": "104446", - "updateUserRiskPremium: 2 borrows": "114563", - "usingAsCollateral: 0 borrows, enable": "59616", - "usingAsCollateral: 1 borrow, disable": "114490", - "usingAsCollateral: 1 borrow, enable": "42504", - "usingAsCollateral: 2 borrows, disable": "138182", - "usingAsCollateral: 2 borrows, enable": "42516", - "withdraw: 0 borrows, full": "135058", - "withdraw: 0 borrows, partial": "140394", - "withdraw: 1 borrow, partial": "169591", - "withdraw: 2 borrows, partial": "186292", - "withdraw: non collateral": "111299" + "supply + enable collateral (multicall)": "147109", + "supply: 0 borrows, collateral disabled": "127975", + "supply: 0 borrows, collateral enabled": "110946", + "supply: second action, same reserve": "110875", + "updateUserDynamicConfig: 1 collateral": "82031", + "updateUserDynamicConfig: 2 collaterals": "99314", + "updateUserRiskPremium: 1 borrow": "109235", + "updateUserRiskPremium: 2 borrows": "119278", + "usingAsCollateral: 0 borrows, enable": "59901", + "usingAsCollateral: 1 borrow, disable": "121143", + "usingAsCollateral: 1 borrow, enable": "42789", + "usingAsCollateral: 2 borrows, disable": "144593", + "usingAsCollateral: 2 borrows, enable": "42801", + "withdraw: 0 borrows, full": "140582", + "withdraw: 0 borrows, partial": "146369", + "withdraw: 1 borrow, partial": "175960", + "withdraw: 2 borrows, partial": "192419", + "withdraw: non collateral": "111326" } \ No newline at end of file diff --git a/snapshots/Spoke.Operations.json b/snapshots/Spoke.Operations.json index 086bec26b..94498da3f 100644 --- a/snapshots/Spoke.Operations.json +++ b/snapshots/Spoke.Operations.json @@ -1,34 +1,34 @@ { - "borrow: first": "269297", - "borrow: second action, same reserve": "212163", - "liquidationCall (receiveShares): full": "347124", - "liquidationCall (receiveShares): partial": "346542", - "liquidationCall (reportDeficit): full": "372507", - "liquidationCall: full": "365660", - "liquidationCall: partial": "365078", - "permitReserve + repay (multicall)": "166334", - "permitReserve + supply (multicall)": "151663", - "permitReserve + supply + enable collateral (multicall)": "166114", - "repay: full": "123355", - "repay: partial": "142713", - "setUserPositionManagersWithSig: disable": "46772", + "borrow: first": "275945", + "borrow: second action, same reserve": "218811", + "liquidationCall (receiveShares): full": "354086", + "liquidationCall (receiveShares): partial": "353504", + "liquidationCall (reportDeficit): full": "377425", + "liquidationCall: full": "372622", + "liquidationCall: partial": "372040", + "permitReserve + repay (multicall)": "166848", + "permitReserve + supply (multicall)": "152215", + "permitReserve + supply + enable collateral (multicall)": "166951", + "repay: full": "123667", + "repay: partial": "143043", + "setUserPositionManagersWithSig: disable": "46760", "setUserPositionManagersWithSig: enable": "68684", - "supply + enable collateral (multicall)": "146316", - "supply: 0 borrows, collateral disabled": "127753", - "supply: 0 borrows, collateral enabled": "110724", - "supply: second action, same reserve": "110653", - "updateUserDynamicConfig: 1 collateral": "76251", - "updateUserDynamicConfig: 2 collaterals": "92825", - "updateUserRiskPremium: 1 borrow": "158658", - "updateUserRiskPremium: 2 borrows": "210210", - "usingAsCollateral: 0 borrows, enable": "59616", - "usingAsCollateral: 1 borrow, disable": "168699", - "usingAsCollateral: 1 borrow, enable": "42504", - "usingAsCollateral: 2 borrows, disable": "241825", - "usingAsCollateral: 2 borrows, enable": "42516", - "withdraw: 0 borrows, full": "135058", - "withdraw: 0 borrows, partial": "140394", - "withdraw: 1 borrow, partial": "221298", - "withdraw: 2 borrows, partial": "270470", - "withdraw: non collateral": "111299" + "supply + enable collateral (multicall)": "147109", + "supply: 0 borrows, collateral disabled": "127975", + "supply: 0 borrows, collateral enabled": "110946", + "supply: second action, same reserve": "110875", + "updateUserDynamicConfig: 1 collateral": "82031", + "updateUserDynamicConfig: 2 collaterals": "99314", + "updateUserRiskPremium: 1 borrow": "163394", + "updateUserRiskPremium: 2 borrows": "214840", + "usingAsCollateral: 0 borrows, enable": "59901", + "usingAsCollateral: 1 borrow, disable": "175299", + "usingAsCollateral: 1 borrow, enable": "42789", + "usingAsCollateral: 2 borrows, disable": "248152", + "usingAsCollateral: 2 borrows, enable": "42801", + "withdraw: 0 borrows, full": "140582", + "withdraw: 0 borrows, partial": "146369", + "withdraw: 1 borrow, partial": "227612", + "withdraw: 2 borrows, partial": "276511", + "withdraw: non collateral": "111326" } \ No newline at end of file diff --git a/snapshots/TakerPositionManager.Operations.json b/snapshots/TakerPositionManager.Operations.json index fac6a661e..45e36d62b 100644 --- a/snapshots/TakerPositionManager.Operations.json +++ b/snapshots/TakerPositionManager.Operations.json @@ -1,11 +1,11 @@ { "approveBorrow": "49807", - "approveBorrowWithSig": "65689", + "approveBorrowWithSig": "65677", "approveWithdraw": "49816", "approveWithdrawWithSig": "65643", - "borrowOnBehalfOf": "332512", + "borrowOnBehalfOf": "339691", "renounceBorrowAllowance": "27929", "renounceWithdrawAllowance": "27983", - "withdrawOnBehalfOf: full": "127209", - "withdrawOnBehalfOf: partial": "138811" + "withdrawOnBehalfOf: full": "127407", + "withdrawOnBehalfOf: partial": "139058" } \ No newline at end of file diff --git a/src/deployments/utils/libraries/Roles.sol b/src/deployments/utils/libraries/Roles.sol index 697d9cf74..6d7e901e5 100644 --- a/src/deployments/utils/libraries/Roles.sol +++ b/src/deployments/utils/libraries/Roles.sol @@ -121,9 +121,13 @@ library Roles { /// @notice Returns the function selectors associated with the Spoke Position Updater role. function getSpokePositionUpdaterRoleSelectors() internal pure returns (bytes4[] memory) { - bytes4[] memory selectors = new bytes4[](2); - selectors[0] = ISpoke.updateUserDynamicConfig.selector; - selectors[1] = ISpoke.updateUserRiskPremium.selector; + bytes4[] memory selectors = new bytes4[](4); + // `updateUserDynamicConfig` and `updateUserRiskPremium` are overloaded, so their selectors are + // referenced by explicit signature to gate both the default and salted position variants. + selectors[0] = bytes4(keccak256('updateUserDynamicConfig(address)')); + selectors[1] = bytes4(keccak256('updateUserDynamicConfig(address,bytes32)')); + selectors[2] = bytes4(keccak256('updateUserRiskPremium(address)')); + selectors[3] = bytes4(keccak256('updateUserRiskPremium(address,bytes32)')); return selectors; } diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index 9dd7beab9..10dea61f7 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: LicenseRef-BUSL pragma solidity 0.8.28; +import {Hashes} from 'src/dependencies/openzeppelin/Hashes.sol'; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; import {Math} from 'src/dependencies/openzeppelin/Math.sol'; import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; import {ReentrancyGuardTransient} from 'src/dependencies/openzeppelin/ReentrancyGuardTransient.sol'; -import {Math} from 'src/dependencies/openzeppelin/Math.sol'; import {AccessManagedUpgradeable} from 'src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.sol'; import {MathUtils} from 'src/libraries/math/MathUtils.sol'; import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; @@ -15,6 +15,7 @@ import {SpokeUtils} from 'src/spoke/libraries/SpokeUtils.sol'; import {EIP712Hash} from 'src/spoke/libraries/EIP712Hash.sol'; import {KeyValueList} from 'src/spoke/libraries/KeyValueList.sol'; import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; +import {UserAccountDataLogic} from 'src/spoke/libraries/UserAccountDataLogic.sol'; import {PositionStatusMap} from 'src/spoke/libraries/PositionStatusMap.sol'; import {ReserveFlags, ReserveFlagsMap} from 'src/spoke/libraries/ReserveFlagsMap.sol'; import {UserPositionUtils} from 'src/spoke/libraries/UserPositionUtils.sol'; @@ -86,6 +87,8 @@ abstract contract Spoke is uint256 internal constant DUST_LIQUIDATION_THRESHOLD = LiquidationLogic.DUST_LIQUIDATION_THRESHOLD; + bytes32 internal constant USER_POSITION_DEFAULT_SALT = bytes32(0); + /// @notice Modifier that checks if the caller is an approved positionManager for `onBehalfOf`. modifier onlyPositionManager(address onBehalfOf) { require(_isPositionManager({user: onBehalfOf, manager: msg.sender}), Unauthorized()); @@ -227,17 +230,17 @@ abstract contract Spoke is uint256 amount, address onBehalfOf ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { - Reserve storage reserve = _reserves.get(reserveId); - UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; - _validateSupply(reserve.flags); - - IERC20(reserve.underlying).safeTransferFrom(msg.sender, address(reserve.hub), amount); - uint256 suppliedShares = reserve.hub.add(reserve.assetId, amount); - userPosition.suppliedShares += suppliedShares.toUint120(); - - emit Supply(reserveId, msg.sender, onBehalfOf, suppliedShares, amount); + return _supply(reserveId, amount, onBehalfOf, USER_POSITION_DEFAULT_SALT); + } - return (suppliedShares, amount); + /// @inheritdoc ISpoke + function supply( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + return _supply(reserveId, amount, onBehalfOf, positionSalt); } /// @inheritdoc ISpoke @@ -246,28 +249,17 @@ abstract contract Spoke is uint256 amount, address onBehalfOf ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { - Reserve storage reserve = _reserves.get(reserveId); - UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; - _validateWithdraw(reserve.flags); - IHubBase hub = reserve.hub; - uint256 assetId = reserve.assetId; - - uint256 withdrawnAmount = MathUtils.min( - amount, - hub.previewRemoveByShares(assetId, userPosition.suppliedShares) - ); - uint256 withdrawnShares = hub.remove(assetId, withdrawnAmount, msg.sender); - - userPosition.suppliedShares -= withdrawnShares.toUint120(); - - if (_positionStatus[onBehalfOf].isUsingAsCollateral(reserveId)) { - uint256 newRiskPremium = _refreshAndValidateUserAccountData(onBehalfOf).riskPremium; - _notifyRiskPremiumUpdate(onBehalfOf, newRiskPremium); - } - - emit Withdraw(reserveId, msg.sender, onBehalfOf, withdrawnShares, withdrawnAmount); + return _withdraw(reserveId, amount, onBehalfOf, USER_POSITION_DEFAULT_SALT); + } - return (withdrawnShares, withdrawnAmount); + /// @inheritdoc ISpoke + function withdraw( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + return _withdraw(reserveId, amount, onBehalfOf, positionSalt); } /// @inheritdoc ISpoke @@ -276,29 +268,17 @@ abstract contract Spoke is uint256 amount, address onBehalfOf ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { - Reserve storage reserve = _reserves.get(reserveId); - UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; - PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; - _validateBorrow(reserve.flags); - IHubBase hub = reserve.hub; - - uint256 drawnShares = hub.draw(reserve.assetId, amount, msg.sender); - userPosition.drawnShares += drawnShares.toUint120(); - if (!positionStatus.isBorrowing(reserveId)) { - require( - MAX_USER_RESERVES_LIMIT == MAX_ALLOWED_USER_RESERVES_LIMIT || - positionStatus.borrowCount(_reserveCount) < MAX_USER_RESERVES_LIMIT, - MaximumUserReservesExceeded() - ); - positionStatus.setBorrowing(reserveId, true); - } - - uint256 newRiskPremium = _refreshAndValidateUserAccountData(onBehalfOf).riskPremium; - _notifyRiskPremiumUpdate(onBehalfOf, newRiskPremium); - - emit Borrow(reserveId, msg.sender, onBehalfOf, drawnShares, amount); + return _borrow(reserveId, amount, onBehalfOf, USER_POSITION_DEFAULT_SALT); + } - return (drawnShares, amount); + /// @inheritdoc ISpoke + function borrow( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + return _borrow(reserveId, amount, onBehalfOf, positionSalt); } /// @inheritdoc ISpoke @@ -307,40 +287,36 @@ abstract contract Spoke is uint256 amount, address onBehalfOf ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { - Reserve storage reserve = _reserves.get(reserveId); - UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; - _validateRepay(reserve.flags); - - uint256 drawnIndex = reserve.hub.getAssetDrawnIndex(reserve.assetId); - (uint256 drawnDebtRestored, uint256 premiumDebtRayRestored) = userPosition - .calculateRestoreAmount(drawnIndex, amount); - uint256 restoredShares = drawnDebtRestored.rayDivDown(drawnIndex); + return _repay(reserveId, amount, onBehalfOf, USER_POSITION_DEFAULT_SALT); + } - IHubBase.PremiumDelta memory premiumDelta = userPosition.calculatePremiumDelta({ - drawnSharesTaken: restoredShares, - drawnIndex: drawnIndex, - riskPremium: _positionStatus[onBehalfOf].riskPremium, - restoredPremiumRay: premiumDebtRayRestored - }); + /// @inheritdoc ISpoke + function repay( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + return _repay(reserveId, amount, onBehalfOf, positionSalt); + } - uint256 totalDebtRestored = drawnDebtRestored + premiumDebtRayRestored.fromRayUp(); - IERC20(reserve.underlying).safeTransferFrom( - msg.sender, - address(reserve.hub), - totalDebtRestored + /// @inheritdoc ISpoke + function liquidationCall( + uint256 collateralReserveId, + uint256 debtReserveId, + address user, + uint256 debtToCover, + bool receiveShares + ) external nonReentrant { + _liquidationCall( + collateralReserveId, + debtReserveId, + user, + USER_POSITION_DEFAULT_SALT, + USER_POSITION_DEFAULT_SALT, + debtToCover, + receiveShares ); - reserve.hub.restore(reserve.assetId, drawnDebtRestored, premiumDelta); - - userPosition.applyPremiumDelta(premiumDelta); - userPosition.drawnShares -= restoredShares.toUint120(); - if (userPosition.drawnShares == 0) { - PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; - positionStatus.setBorrowing(reserveId, false); - } - - emit Repay(reserveId, msg.sender, onBehalfOf, restoredShares, totalDebtRestored, premiumDelta); - - return (restoredShares, totalDebtRestored); } /// @inheritdoc ISpoke @@ -348,43 +324,20 @@ abstract contract Spoke is uint256 collateralReserveId, uint256 debtReserveId, address user, + bytes32 positionSalt, + bytes32 liquidatorPositionSalt, uint256 debtToCover, bool receiveShares ) external nonReentrant { - UserAccountData memory userAccountData = _calculateUserAccountData(user); - LiquidationLogic.LiquidateUserParams memory params = LiquidationLogic.LiquidateUserParams({ - collateralReserveId: collateralReserveId, - debtReserveId: debtReserveId, - liquidationConfig: _liquidationConfig, - oracle: ORACLE, - user: user, - debtToCover: debtToCover, - userAccountData: userAccountData, - liquidator: msg.sender, - receiveShares: receiveShares - }); - - bool isUserInDeficit = LiquidationLogic.liquidateUser({ - reserves: _reserves, - userPositions: _userPositions, - positionStatus: _positionStatus, - dynamicConfig: _dynamicConfig, - params: params - }); - - if (isUserInDeficit) { - // report deficit for all debt reserves, including the reserve being repaid - LiquidationLogic.notifyReportDeficit( - _reserves, - _userPositions, - _positionStatus, - _reserveCount, - user - ); - } else { - uint256 newRiskPremium = _calculateUserAccountData(user).riskPremium; - _notifyRiskPremiumUpdate(user, newRiskPremium); - } + _liquidationCall( + collateralReserveId, + debtReserveId, + user, + positionSalt, + liquidatorPositionSalt, + debtToCover, + receiveShares + ); } /// @inheritdoc ISpoke @@ -393,40 +346,37 @@ abstract contract Spoke is bool usingAsCollateral, address onBehalfOf ) external nonReentrant onlyPositionManager(onBehalfOf) { - Reserve storage reserve = _reserves.get(reserveId); - PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; - if (positionStatus.isUsingAsCollateral(reserveId) == usingAsCollateral) { - return; - } - _validateSetUsingAsCollateral(positionStatus, reserve.flags, usingAsCollateral); - positionStatus.setUsingAsCollateral(reserveId, usingAsCollateral); - - if (usingAsCollateral) { - _refreshDynamicConfig(onBehalfOf, reserveId); - } else { - uint256 newRiskPremium = _refreshAndValidateUserAccountData(onBehalfOf).riskPremium; - _notifyRiskPremiumUpdate(onBehalfOf, newRiskPremium); - } + _setUsingAsCollateral(reserveId, usingAsCollateral, onBehalfOf, USER_POSITION_DEFAULT_SALT); + } - emit SetUsingAsCollateral(reserveId, msg.sender, onBehalfOf, usingAsCollateral); + /// @inheritdoc ISpoke + function setUsingAsCollateral( + uint256 reserveId, + bool usingAsCollateral, + address onBehalfOf, + bytes32 positionSalt + ) external nonReentrant onlyPositionManager(onBehalfOf) { + _setUsingAsCollateral(reserveId, usingAsCollateral, onBehalfOf, positionSalt); } /// @inheritdoc ISpoke function updateUserRiskPremium(address onBehalfOf) external nonReentrant { - if (!_isPositionManager({user: onBehalfOf, manager: msg.sender})) { - _checkCanCall(msg.sender, msg.data); - } - uint256 newRiskPremium = _calculateUserAccountData(onBehalfOf).riskPremium; - _notifyRiskPremiumUpdate(onBehalfOf, newRiskPremium); + _updateUserRiskPremium(onBehalfOf, USER_POSITION_DEFAULT_SALT); + } + + /// @inheritdoc ISpoke + function updateUserRiskPremium(address onBehalfOf, bytes32 positionSalt) external nonReentrant { + _updateUserRiskPremium(onBehalfOf, positionSalt); } /// @inheritdoc ISpoke function updateUserDynamicConfig(address onBehalfOf) external nonReentrant { - if (!_isPositionManager({user: onBehalfOf, manager: msg.sender})) { - _checkCanCall(msg.sender, msg.data); - } - uint256 newRiskPremium = _refreshAndValidateUserAccountData(onBehalfOf).riskPremium; - _notifyRiskPremiumUpdate(onBehalfOf, newRiskPremium); + _updateUserDynamicConfig(onBehalfOf, USER_POSITION_DEFAULT_SALT); + } + + /// @inheritdoc ISpoke + function updateUserDynamicConfig(address onBehalfOf, bytes32 positionSalt) external nonReentrant { + _updateUserDynamicConfig(onBehalfOf, positionSalt); } /// @inheritdoc ISpoke @@ -565,7 +515,22 @@ abstract contract Spoke is address user ) external view returns (bool, bool) { _reserves.get(reserveId); - PositionStatus storage positionStatus = _positionStatus[user]; + PositionStatus storage positionStatus = _positionStatus[ + _getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT) + ]; + return (positionStatus.isUsingAsCollateral(reserveId), positionStatus.isBorrowing(reserveId)); + } + + /// @inheritdoc ISpoke + function getUserReserveStatus( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (bool, bool) { + _reserves.get(reserveId); + PositionStatus storage positionStatus = _positionStatus[ + _getPositionIdentifier(user, positionSalt) + ]; return (positionStatus.isUsingAsCollateral(reserveId), positionStatus.isBorrowing(reserveId)); } @@ -575,20 +540,66 @@ abstract contract Spoke is return reserve.hub.previewRemoveByShares( reserve.assetId, - _userPositions[user][reserveId].suppliedShares + _userPositions[_getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT)][reserveId] + .suppliedShares + ); + } + + /// @inheritdoc ISpoke + function getUserSuppliedAssets( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (uint256) { + Reserve storage reserve = _reserves.get(reserveId); + return + reserve.hub.previewRemoveByShares( + reserve.assetId, + _userPositions[_getPositionIdentifier(user, positionSalt)][reserveId].suppliedShares ); } /// @inheritdoc ISpoke function getUserSuppliedShares(uint256 reserveId, address user) external view returns (uint256) { _reserves.get(reserveId); - return _userPositions[user][reserveId].suppliedShares; + return + _userPositions[_getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT)][reserveId] + .suppliedShares; + } + + /// @inheritdoc ISpoke + function getUserSuppliedShares( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (uint256) { + _reserves.get(reserveId); + return _userPositions[_getPositionIdentifier(user, positionSalt)][reserveId].suppliedShares; } /// @inheritdoc ISpoke function getUserDebt(uint256 reserveId, address user) external view returns (uint256, uint256) { Reserve storage reserve = _reserves.get(reserveId); - UserPosition storage userPosition = _userPositions[user][reserveId]; + UserPosition storage userPosition = _userPositions[ + _getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT) + ][reserveId]; + (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( + reserve.hub, + reserve.assetId + ); + return (drawnDebt, premiumDebtRay.fromRayUp()); + } + + /// @inheritdoc ISpoke + function getUserDebt( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (uint256, uint256) { + Reserve storage reserve = _reserves.get(reserveId); + UserPosition storage userPosition = _userPositions[_getPositionIdentifier(user, positionSalt)][ + reserveId + ]; (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( reserve.hub, reserve.assetId @@ -599,7 +610,26 @@ abstract contract Spoke is /// @inheritdoc ISpoke function getUserTotalDebt(uint256 reserveId, address user) external view returns (uint256) { Reserve storage reserve = _reserves.get(reserveId); - UserPosition storage userPosition = _userPositions[user][reserveId]; + UserPosition storage userPosition = _userPositions[ + _getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT) + ][reserveId]; + (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( + reserve.hub, + reserve.assetId + ); + return (drawnDebt + premiumDebtRay.fromRayUp()); + } + + /// @inheritdoc ISpoke + function getUserTotalDebt( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (uint256) { + Reserve storage reserve = _reserves.get(reserveId); + UserPosition storage userPosition = _userPositions[_getPositionIdentifier(user, positionSalt)][ + reserveId + ]; (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( reserve.hub, reserve.assetId @@ -610,7 +640,23 @@ abstract contract Spoke is /// @inheritdoc ISpoke function getUserPremiumDebtRay(uint256 reserveId, address user) external view returns (uint256) { Reserve storage reserve = _reserves.get(reserveId); - UserPosition storage userPosition = _userPositions[user][reserveId]; + UserPosition storage userPosition = _userPositions[ + _getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT) + ][reserveId]; + (, uint256 premiumDebtRay) = userPosition.getDebt(reserve.hub, reserve.assetId); + return premiumDebtRay; + } + + /// @inheritdoc ISpoke + function getUserPremiumDebtRay( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (uint256) { + Reserve storage reserve = _reserves.get(reserveId); + UserPosition storage userPosition = _userPositions[_getPositionIdentifier(user, positionSalt)][ + reserveId + ]; (, uint256 premiumDebtRay) = userPosition.getDebt(reserve.hub, reserve.assetId); return premiumDebtRay; } @@ -621,18 +667,65 @@ abstract contract Spoke is address user ) external view returns (UserPosition memory) { _reserves.get(reserveId); - return _userPositions[user][reserveId]; + return _userPositions[_getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT)][reserveId]; + } + + /// @inheritdoc ISpoke + function getUserPosition( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (UserPosition memory) { + _reserves.get(reserveId); + return _userPositions[_getPositionIdentifier(user, positionSalt)][reserveId]; } /// @inheritdoc ISpoke function getUserLastRiskPremium(address user) external view returns (uint256) { - return _positionStatus[user].riskPremium; + return _positionStatus[_getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT)].riskPremium; + } + + /// @inheritdoc ISpoke + function getUserLastRiskPremium( + address user, + bytes32 positionSalt + ) external view returns (uint256) { + return _positionStatus[_getPositionIdentifier(user, positionSalt)].riskPremium; } /// @inheritdoc ISpoke function getUserAccountData(address user) external view returns (UserAccountData memory) { - // SAFETY: function does not modify state when `refreshConfig` is false. - return _castToView(_processUserAccountData)(user, false); + return + UserAccountDataLogic.processUserAccountData({ + reserves: _reserves, + userPositions: _userPositions, + positionStatus: _positionStatus, + dynamicConfig: _dynamicConfig, + params: UserAccountDataLogic.ProcessUserAccountDataParams({ + oracle: IAaveOracle(ORACLE), + reserveCount: _reserveCount, + positionId: _getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT) + }) + }); + } + + /// @inheritdoc ISpoke + function getUserAccountData( + address user, + bytes32 positionSalt + ) external view returns (UserAccountData memory) { + return + UserAccountDataLogic.processUserAccountData({ + reserves: _reserves, + userPositions: _userPositions, + positionStatus: _positionStatus, + dynamicConfig: _dynamicConfig, + params: UserAccountDataLogic.ProcessUserAccountDataParams({ + oracle: IAaveOracle(ORACLE), + reserveCount: _reserveCount, + positionId: _getPositionIdentifier(user, positionSalt) + }) + }); } /// @inheritdoc ISpoke @@ -648,7 +741,27 @@ abstract contract Spoke is liquidationBonusFactor: _liquidationConfig.liquidationBonusFactor, healthFactor: healthFactor, maxLiquidationBonus: _dynamicConfig[reserveId][ - _userPositions[user][reserveId].dynamicConfigKey + _userPositions[_getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT)][reserveId] + .dynamicConfigKey + ].maxLiquidationBonus + }); + } + + /// @inheritdoc ISpoke + function getLiquidationBonus( + uint256 reserveId, + address user, + bytes32 positionSalt, + uint256 healthFactor + ) external view returns (uint256) { + _reserves.get(reserveId); + return + LiquidationLogic.calculateLiquidationBonus({ + healthFactorForMaxBonus: _liquidationConfig.healthFactorForMaxBonus, + liquidationBonusFactor: _liquidationConfig.liquidationBonusFactor, + healthFactor: healthFactor, + maxLiquidationBonus: _dynamicConfig[reserveId][ + _userPositions[_getPositionIdentifier(user, positionSalt)][reserveId].dynamicConfigKey ].maxLiquidationBonus }); } @@ -674,153 +787,300 @@ abstract contract Spoke is emit UpdateReservePriceSource(reserveId, priceSource); } - function _setUserPositionManager(address positionManager, address user, bool approve) internal { - PositionManagerConfig storage config = _positionManager[positionManager]; - config.approval[user] = approve; - emit SetUserPositionManager(user, positionManager, approve); + function _supply( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) internal returns (uint256, uint256) { + Reserve storage reserve = _reserves.get(reserveId); + bytes32 positionId = _getPositionIdentifier(onBehalfOf, positionSalt); + UserPosition storage userPosition = _userPositions[positionId][reserveId]; + _validateSupply(reserve.flags); + + IERC20(reserve.underlying).safeTransferFrom(msg.sender, address(reserve.hub), amount); + uint256 suppliedShares = reserve.hub.add(reserve.assetId, amount); + userPosition.suppliedShares += suppliedShares.toUint120(); + + emit Supply(reserveId, msg.sender, positionId, suppliedShares, amount); + + return (suppliedShares, amount); } - /// @notice Calculates and validates the user account data. - /// @dev It refreshes the dynamic config before calculation. - /// @dev It checks that the health factor is above the liquidation threshold. - function _refreshAndValidateUserAccountData( - address user - ) internal returns (UserAccountData memory) { - UserAccountData memory accountData = _processUserAccountData(user, true); - emit RefreshAllUserDynamicConfig(user); - require( - accountData.healthFactor >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD, - HealthFactorBelowThreshold() + function _withdraw( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) internal returns (uint256, uint256) { + Reserve storage reserve = _reserves.get(reserveId); + bytes32 positionId = _getPositionIdentifier(onBehalfOf, positionSalt); + UserPosition storage userPosition = _userPositions[positionId][reserveId]; + _validateWithdraw(reserve.flags); + IHubBase hub = reserve.hub; + uint256 assetId = reserve.assetId; + + uint256 withdrawnAmount = MathUtils.min( + amount, + hub.previewRemoveByShares(assetId, userPosition.suppliedShares) ); - return accountData; - } + uint256 withdrawnShares = hub.remove(assetId, withdrawnAmount, msg.sender); - /// @notice Calculates the user account data with the current user dynamic config. - function _calculateUserAccountData(address user) internal returns (UserAccountData memory) { - return _processUserAccountData(user, false); // does not modify state + userPosition.suppliedShares -= withdrawnShares.toUint120(); + + if (_positionStatus[positionId].isUsingAsCollateral(reserveId)) { + _notifyRiskPremiumUpdate( + positionId, + _refreshAndValidateUserAccountData(positionId).riskPremium + ); + } + + emit Withdraw(reserveId, msg.sender, positionId, withdrawnShares, withdrawnAmount); + + return (withdrawnShares, withdrawnAmount); } - /// @notice Process the user account data and updates dynamic config of the user if `refreshConfig` is true. - /// @dev Collateral is rounded against the user, while debt is calculated with full precision. - /// @dev If user has no debt, it returns health factor of `type(uint256).max` and risk premium of 0. - function _processUserAccountData( - address user, - bool refreshConfig - ) internal returns (UserAccountData memory accountData) { - PositionStatus storage positionStatus = _positionStatus[user]; + function _borrow( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) internal returns (uint256, uint256) { + Reserve storage reserve = _reserves.get(reserveId); + bytes32 positionId = _getPositionIdentifier(onBehalfOf, positionSalt); + UserPosition storage userPosition = _userPositions[positionId][reserveId]; + PositionStatus storage positionStatus = _positionStatus[positionId]; + _validateBorrow(reserve.flags); - uint256 reserveId = _reserveCount; - KeyValueList.List memory collateralInfo = KeyValueList.init( - positionStatus.collateralCount(reserveId) + uint256 drawnShares = reserve.hub.draw(reserve.assetId, amount, msg.sender); + userPosition.drawnShares += drawnShares.toUint120(); + if (!positionStatus.isBorrowing(reserveId)) { + require( + MAX_USER_RESERVES_LIMIT == MAX_ALLOWED_USER_RESERVES_LIMIT || + positionStatus.borrowCount(_reserveCount) < MAX_USER_RESERVES_LIMIT, + MaximumUserReservesExceeded() + ); + positionStatus.setBorrowing(reserveId, true); + } + + _notifyRiskPremiumUpdate( + positionId, + _refreshAndValidateUserAccountData(positionId).riskPremium ); - bool borrowing; - bool collateral; - while (true) { - (reserveId, borrowing, collateral) = positionStatus.next(reserveId); - if (reserveId == PositionStatusMap.NOT_FOUND) break; - UserPosition storage userPosition = _userPositions[user][reserveId]; - Reserve storage reserve = _reserves[reserveId]; + emit Borrow(reserveId, msg.sender, positionId, drawnShares, amount); + + return (drawnShares, amount); + } + + function _repay( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) internal returns (uint256, uint256) { + Reserve storage reserve = _reserves.get(reserveId); + bytes32 positionId = _getPositionIdentifier(onBehalfOf, positionSalt); + UserPosition storage userPosition = _userPositions[positionId][reserveId]; + _validateRepay(reserve.flags); + + uint256 restoredShares; + uint256 totalDebtRestored; + IHubBase.PremiumDelta memory premiumDelta; + { + uint256 drawnIndex = reserve.hub.getAssetDrawnIndex(reserve.assetId); + (uint256 drawnDebtRestored, uint256 premiumDebtRayRestored) = userPosition + .calculateRestoreAmount(drawnIndex, amount); + restoredShares = drawnDebtRestored.rayDivDown(drawnIndex); + + premiumDelta = userPosition.calculatePremiumDelta({ + drawnSharesTaken: restoredShares, + drawnIndex: drawnIndex, + riskPremium: _positionStatus[positionId].riskPremium, + restoredPremiumRay: premiumDebtRayRestored + }); + + totalDebtRestored = drawnDebtRestored + premiumDebtRayRestored.fromRayUp(); + IERC20(reserve.underlying).safeTransferFrom( + msg.sender, + address(reserve.hub), + totalDebtRestored + ); + reserve.hub.restore(reserve.assetId, drawnDebtRestored, premiumDelta); + } - uint256 assetPrice = IAaveOracle(ORACLE).getReservePrice(reserveId); - uint256 assetDecimals = reserve.decimals; - - if (collateral) { - uint256 collateralFactor = _dynamicConfig[reserveId][ - refreshConfig - ? (userPosition.dynamicConfigKey = reserve.dynamicConfigKey) - : userPosition.dynamicConfigKey - ].collateralFactor; - if (collateralFactor > 0) { - uint256 suppliedShares = userPosition.suppliedShares; - if (suppliedShares > 0) { - // cannot round down to zero - uint256 userCollateralValue = reserve - .hub - .previewRemoveByShares(reserve.assetId, suppliedShares) - .toValue({decimals: assetDecimals, price: assetPrice}); - accountData.totalCollateralValue += userCollateralValue; - collateralInfo.add( - accountData.activeCollateralCount, - reserve.collateralRisk, - userCollateralValue - ); - accountData.avgCollateralFactor += collateralFactor * userCollateralValue; - accountData.activeCollateralCount = accountData.activeCollateralCount.uncheckedAdd(1); - } - } - } - - if (borrowing) { - UserPositionUtils.DebtComponents memory debtComponents = userPosition.getDebtComponents( - reserve.hub, - reserve.assetId - ); - uint256 debtRay = debtComponents.drawnShares * debtComponents.drawnIndex + - debtComponents.premiumDebtRay; - accountData.totalDebtValueRay += debtRay.toValue({ - decimals: assetDecimals, - price: assetPrice - }); - accountData.borrowCount = accountData.borrowCount.uncheckedAdd(1); - } + userPosition.applyPremiumDelta(premiumDelta); + userPosition.drawnShares -= restoredShares.toUint120(); + if (userPosition.drawnShares == 0) { + _positionStatus[positionId].setBorrowing(reserveId, false); } - if (accountData.totalDebtValueRay > 0) { - // at this point, `avgCollateralFactor` is the total collateral value weighted by collateral factors, - // expressed in units of Value and scaled by BPS. We convert it from BPS to WAD, since this will - // ultimately define the scaling factor of the health factor. - accountData.healthFactor = Math.mulDiv( - accountData.avgCollateralFactor.bpsToWad(), - WadRayMath.RAY, - accountData.totalDebtValueRay, - Math.Rounding.Floor + emit Repay(reserveId, msg.sender, positionId, restoredShares, totalDebtRestored, premiumDelta); + + return (restoredShares, totalDebtRestored); + } + + function _liquidationCall( + uint256 collateralReserveId, + uint256 debtReserveId, + address user, + bytes32 positionSalt, + bytes32 liquidatorSalt, + uint256 debtToCover, + bool receiveShares + ) internal { + bytes32 positionId = _getPositionIdentifier(user, positionSalt); + bytes32 liquidatorPositionId = _getPositionIdentifier(msg.sender, liquidatorSalt); + UserAccountData memory userAccountData = _calculateUserAccountData(positionId); + LiquidationLogic.LiquidateUserParams memory params = LiquidationLogic.LiquidateUserParams({ + collateralReserveId: collateralReserveId, + debtReserveId: debtReserveId, + liquidationConfig: _liquidationConfig, + oracle: ORACLE, + user: user, + positionId: positionId, + debtToCover: debtToCover, + userAccountData: userAccountData, + liquidator: msg.sender, + liquidatorPositionId: liquidatorPositionId, + receiveShares: receiveShares + }); + + bool isUserInDeficit = LiquidationLogic.liquidateUser({ + reserves: _reserves, + userPositions: _userPositions, + positionStatus: _positionStatus, + dynamicConfig: _dynamicConfig, + params: params + }); + + if (isUserInDeficit) { + // report deficit for all debt reserves, including the reserve being repaid + LiquidationLogic.notifyReportDeficit( + _reserves, + _userPositions, + _positionStatus, + _reserveCount, + positionId ); } else { - accountData.healthFactor = type(uint256).max; + uint256 newRiskPremium = _calculateUserAccountData(positionId).riskPremium; + _notifyRiskPremiumUpdate(positionId, newRiskPremium); } + } - if (accountData.totalCollateralValue > 0) { - accountData.avgCollateralFactor = - accountData.avgCollateralFactor.bpsToWad() / accountData.totalCollateralValue; + function _setUsingAsCollateral( + uint256 reserveId, + bool usingAsCollateral, + address onBehalfOf, + bytes32 positionSalt + ) internal { + Reserve storage reserve = _reserves.get(reserveId); + bytes32 positionId = _getPositionIdentifier(onBehalfOf, positionSalt); + PositionStatus storage positionStatus = _positionStatus[positionId]; + if (positionStatus.isUsingAsCollateral(reserveId) == usingAsCollateral) { + return; } + _validateSetUsingAsCollateral(positionStatus, reserve.flags, usingAsCollateral); + positionStatus.setUsingAsCollateral(reserveId, usingAsCollateral); - // sort by collateral risk in ASC, collateral value in DESC - collateralInfo.sortByKey(); - - // runs until either the collateral or debt is exhausted - uint256 totalDebtValue = accountData.totalDebtValueRay.fromRayUp(); - uint256 debtValueLeftToCover = totalDebtValue; + if (usingAsCollateral) { + _refreshDynamicConfig(positionId, reserveId); + } else { + uint256 newRiskPremium = _refreshAndValidateUserAccountData(positionId).riskPremium; + _notifyRiskPremiumUpdate(positionId, newRiskPremium); + } - for (uint256 index = 0; index < collateralInfo.length(); ++index) { - if (debtValueLeftToCover == 0) { - break; - } + emit SetUsingAsCollateral(reserveId, msg.sender, positionId, usingAsCollateral); + } - (uint256 collateralRisk, uint256 userCollateralValue) = collateralInfo.uncheckedAt(index); - userCollateralValue = userCollateralValue.min(debtValueLeftToCover); - accountData.riskPremium += userCollateralValue * collateralRisk; - debtValueLeftToCover = debtValueLeftToCover.uncheckedSub(userCollateralValue); + function _updateUserRiskPremium(address onBehalfOf, bytes32 positionSalt) internal { + if (!_isPositionManager({user: onBehalfOf, manager: msg.sender})) { + _checkCanCall(msg.sender, msg.data); } + bytes32 positionId = _getPositionIdentifier(onBehalfOf, positionSalt); + uint256 newRiskPremium = _calculateUserAccountData(positionId).riskPremium; + _notifyRiskPremiumUpdate(positionId, newRiskPremium); + } - if (debtValueLeftToCover < totalDebtValue) { - accountData.riskPremium = accountData.riskPremium.divUp( - totalDebtValue.uncheckedSub(debtValueLeftToCover) - ); + function _updateUserDynamicConfig(address onBehalfOf, bytes32 positionSalt) internal { + if (!_isPositionManager({user: onBehalfOf, manager: msg.sender})) { + _checkCanCall(msg.sender, msg.data); } + bytes32 positionId = _getPositionIdentifier(onBehalfOf, positionSalt); + uint256 newRiskPremium = _refreshAndValidateUserAccountData(positionId).riskPremium; + _notifyRiskPremiumUpdate(positionId, newRiskPremium); + } + function _setUserPositionManager(address positionManager, address user, bool approve) internal { + PositionManagerConfig storage config = _positionManager[positionManager]; + config.approval[user] = approve; + emit SetUserPositionManager(user, positionManager, approve); + } + + /// @notice Calculates and validates the user account data. + /// @dev It refreshes the dynamic config before calculation. + /// @dev It checks that the health factor is above the liquidation threshold. + function _refreshAndValidateUserAccountData( + bytes32 positionId + ) internal returns (UserAccountData memory) { + _refreshAllDynamicConfig(positionId); + UserAccountData memory accountData = UserAccountDataLogic.processUserAccountData({ + reserves: _reserves, + userPositions: _userPositions, + positionStatus: _positionStatus, + dynamicConfig: _dynamicConfig, + params: UserAccountDataLogic.ProcessUserAccountDataParams({ + oracle: IAaveOracle(ORACLE), + reserveCount: _reserveCount, + positionId: positionId + }) + }); + require( + accountData.healthFactor >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD, + HealthFactorBelowThreshold() + ); return accountData; } - function _refreshDynamicConfig(address user, uint256 reserveId) internal { - _userPositions[user][reserveId].dynamicConfigKey = _reserves[reserveId].dynamicConfigKey; - emit RefreshSingleUserDynamicConfig(user, reserveId); + /// @notice Calculates the user account data with the current user dynamic config. + function _calculateUserAccountData( + bytes32 positionId + ) internal view returns (UserAccountData memory) { + return + UserAccountDataLogic.processUserAccountData({ + reserves: _reserves, + userPositions: _userPositions, + positionStatus: _positionStatus, + dynamicConfig: _dynamicConfig, + params: UserAccountDataLogic.ProcessUserAccountDataParams({ + oracle: IAaveOracle(ORACLE), + reserveCount: _reserveCount, + positionId: positionId + }) + }); + } + + function _refreshAllDynamicConfig(bytes32 positionId) internal { + PositionStatus storage positionStatus = _positionStatus[positionId]; + + uint256 reserveId = _reserveCount; + while ((reserveId = positionStatus.nextCollateral(reserveId)) != PositionStatusMap.NOT_FOUND) { + _userPositions[positionId][reserveId].dynamicConfigKey = _reserves[reserveId] + .dynamicConfigKey; + } + emit RefreshAllUserDynamicConfig(positionId); + } + + function _refreshDynamicConfig(bytes32 positionId, uint256 reserveId) internal { + _userPositions[positionId][reserveId].dynamicConfigKey = _reserves[reserveId].dynamicConfigKey; + emit RefreshSingleUserDynamicConfig(positionId, reserveId); } /// @notice Refreshes premium for borrowed reserves of `user` with `newRiskPremium`. /// @dev Skips the refresh if the user risk premium remains zero. - function _notifyRiskPremiumUpdate(address user, uint256 newRiskPremium) internal { - PositionStatus storage positionStatus = _positionStatus[user]; + function _notifyRiskPremiumUpdate(bytes32 positionId, uint256 newRiskPremium) internal { + PositionStatus storage positionStatus = _positionStatus[positionId]; if (newRiskPremium == 0 && positionStatus.riskPremium == 0) { return; } @@ -828,7 +1088,7 @@ abstract contract Spoke is uint256 reserveId = _reserveCount; while ((reserveId = positionStatus.nextBorrowing(reserveId)) != PositionStatusMap.NOT_FOUND) { - UserPosition storage userPosition = _userPositions[user][reserveId]; + UserPosition storage userPosition = _userPositions[positionId][reserveId]; Reserve storage reserve = _reserves[reserveId]; uint256 assetId = reserve.assetId; IHubBase hub = reserve.hub; @@ -842,10 +1102,10 @@ abstract contract Spoke is hub.refreshPremium(assetId, premiumDelta); userPosition.applyPremiumDelta(premiumDelta); - emit RefreshPremiumDebt(reserveId, user, premiumDelta); + emit RefreshPremiumDebt(reserveId, positionId, premiumDelta); } - emit UpdateUserRiskPremium(user, newRiskPremium); + emit UpdateUserRiskPremium(positionId, newRiskPremium); } /// @dev CollateralFactor of historical config keys cannot be 0, which allows liquidations to proceed. @@ -929,19 +1189,17 @@ abstract contract Spoke is require(config.liquidationFee <= PercentageMath.PERCENTAGE_FACTOR, InvalidLiquidationFee()); } - function _domainNameAndVersion() internal pure override returns (string memory, string memory) { - return ('Spoke', '1'); + function _getPositionIdentifier(address user, bytes32 salt) internal pure returns (bytes32) { + // The default salt maps to the legacy address-keyed storage slot, so positions + // created before the position-nonces upgrade remain accessible. The chance of a non-default, + // keccak-derived identifier colliding with such a value is negligible (~2^-96). + return + salt == USER_POSITION_DEFAULT_SALT + ? bytes32(uint256(uint160(user))) + : Hashes.efficientKeccak256(bytes32(uint256(uint160(user))), salt); } - function _castToView( - function(address, bool) internal returns (UserAccountData memory) fnIn - ) - internal - pure - returns (function(address, bool) internal view returns (UserAccountData memory) fnOut) - { - assembly ('memory-safe') { - fnOut := fnIn - } + function _domainNameAndVersion() internal pure override returns (string memory, string memory) { + return ('Spoke', '1'); } } diff --git a/src/spoke/SpokeStorage.sol b/src/spoke/SpokeStorage.sol index 726398afd..ad8af9b81 100644 --- a/src/spoke/SpokeStorage.sol +++ b/src/spoke/SpokeStorage.sol @@ -25,11 +25,11 @@ abstract contract SpokeStorage { mapping(uint256 reserveId => mapping(uint32 dynamicConfigKey => ISpoke.DynamicReserveConfig)) internal _dynamicConfig; - /// @dev Map of user addresses to their position status. - mapping(address user => ISpoke.PositionStatus) internal _positionStatus; + /// @dev Map of position identifiers to their position status. + mapping(bytes32 positionId => ISpoke.PositionStatus) internal _positionStatus; - /// @dev Map of user addresses and reserve identifiers to user positions. - mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) + /// @dev Map of position identifiers and reserve identifiers to user positions. + mapping(bytes32 positionId => mapping(uint256 reserveId => ISpoke.UserPosition)) internal _userPositions; /// @dev Map of position manager addresses to their configuration data. diff --git a/src/spoke/instances/SpokeInstance.sol b/src/spoke/instances/SpokeInstance.sol index 2b8d06252..375cc0067 100644 --- a/src/spoke/instances/SpokeInstance.sol +++ b/src/spoke/instances/SpokeInstance.sol @@ -7,7 +7,7 @@ import {Spoke} from 'src/spoke/Spoke.sol'; /// @author Aave Labs /// @notice Implementation contract for the Spoke. contract SpokeInstance is Spoke { - uint64 public constant SPOKE_REVISION = 1; + uint64 public constant SPOKE_REVISION = 2; /// @dev Constructor. /// @dev During upgrade, must ensure that the new oracle is supporting existing assets on the Spoke and the replaced oracle. diff --git a/src/spoke/interfaces/ISpoke.sol b/src/spoke/interfaces/ISpoke.sol index e61be5ffb..573f28821 100644 --- a/src/spoke/interfaces/ISpoke.sol +++ b/src/spoke/interfaces/ISpoke.sol @@ -191,13 +191,13 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @notice Emitted on the supply action. /// @param reserveId The reserve identifier of the underlying asset. /// @param caller The transaction initiator, and supplier of the underlying asset. - /// @param user The owner of the modified position. + /// @param positionId The identifier of the modified position. /// @param suppliedShares The amount of supply shares minted. /// @param suppliedAmount The amount of underlying asset supplied. event Supply( uint256 indexed reserveId, address indexed caller, - address indexed user, + bytes32 indexed positionId, uint256 suppliedShares, uint256 suppliedAmount ); @@ -205,13 +205,13 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @notice Emitted on the withdraw action. /// @param reserveId The reserve identifier of the underlying asset. /// @param caller The transaction initiator, and recipient of the underlying asset being withdrawn. - /// @param user The owner of the modified position. + /// @param positionId The identifier of the modified position. /// @param withdrawnShares The amount of supply shares burned. /// @param withdrawnAmount The amount of underlying asset withdrawn. event Withdraw( uint256 indexed reserveId, address indexed caller, - address indexed user, + bytes32 indexed positionId, uint256 withdrawnShares, uint256 withdrawnAmount ); @@ -219,13 +219,13 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @notice Emitted on the borrow action. /// @param reserveId The reserve identifier of the underlying asset. /// @param caller The transaction initiator, and recipient of the underlying asset being borrowed. - /// @param user The owner of the position on which debt is generated. + /// @param positionId The identifier of the modified position. /// @param drawnShares The amount of debt shares minted. /// @param drawnAmount The amount of underlying asset borrowed. event Borrow( uint256 indexed reserveId, address indexed caller, - address indexed user, + bytes32 indexed positionId, uint256 drawnShares, uint256 drawnAmount ); @@ -233,14 +233,14 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @notice Emitted on the repay action. /// @param reserveId The reserve identifier of the underlying asset. /// @param caller The transaction initiator who is repaying the underlying asset. - /// @param user The owner of the position whose debt is being repaid. + /// @param positionId The identifier of the modified position. /// @param drawnShares The amount of drawn shares burned. /// @param totalAmountRepaid The amount of drawn and premium underlying assets repaid. /// @param premiumDelta A struct representing the changes to premium debt after repayment. event Repay( uint256 indexed reserveId, address indexed caller, - address indexed user, + bytes32 indexed positionId, uint256 drawnShares, uint256 totalAmountRepaid, IHubBase.PremiumDelta premiumDelta @@ -249,7 +249,7 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @dev Emitted when a borrower is liquidated. /// @param collateralReserveId The identifier of the reserve used as collateral, to receive as a result of the liquidation. /// @param debtReserveId The identifier of the reserve to be repaid with the liquidation. - /// @param user The address of the borrower getting liquidated. + /// @param positionId The identifier of the liquidated position. /// @param liquidator The address of the liquidator. /// @param receiveShares True if the liquidator received collateral in supplied shares rather than underlying assets. /// @param debtAmountRestored The amount of debt restored, expressed in asset units. @@ -261,7 +261,7 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { event LiquidationCall( uint256 indexed collateralReserveId, uint256 indexed debtReserveId, - address indexed user, + bytes32 indexed positionId, address liquidator, bool receiveShares, uint256 debtAmountRestored, @@ -274,12 +274,12 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @notice Emitted when a reserve deficit is reported to the Hub. /// @param reserveId The identifier of the reserve. - /// @param user The address of the user. + /// @param positionId The identifier of the position with the deficit. /// @param drawnShares The amount of drawn shares reported as deficit. /// @param premiumDelta The premium delta data struct reported as deficit. event ReportDeficit( uint256 indexed reserveId, - address indexed user, + bytes32 indexed positionId, uint256 drawnShares, IHubBase.PremiumDelta premiumDelta ); @@ -287,28 +287,28 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @notice Emitted on setUsingAsCollateral action. /// @param reserveId The reserve identifier of the underlying asset. /// @param caller The transaction initiator. - /// @param user The owner of the position being modified. + /// @param positionId The identifier of the position being modified. /// @param usingAsCollateral Whether the reserve is enabled or disabled as collateral. event SetUsingAsCollateral( uint256 indexed reserveId, address indexed caller, - address indexed user, + bytes32 indexed positionId, bool usingAsCollateral ); /// @notice Emitted on updateUserRiskPremium action. - /// @param user The owner of the position being modified. - /// @param riskPremium The new risk premium (BPS) value of user. - event UpdateUserRiskPremium(address indexed user, uint256 riskPremium); + /// @param positionId The position identifier of the user whose risk premium is being updated. + /// @param riskPremium The new risk premium (BPS) value of the user. + event UpdateUserRiskPremium(bytes32 indexed positionId, uint256 riskPremium); /// @notice Emitted when a user's dynamic config is refreshed for all reserves to their latest config key. - /// @param user The address of the user. - event RefreshAllUserDynamicConfig(address indexed user); + /// @param positionId The position identifier of the user. + event RefreshAllUserDynamicConfig(bytes32 indexed positionId); /// @notice Emitted when a user's dynamic config is refreshed for a single reserve to its latest config key. - /// @param user The address of the user. + /// @param positionId The position identifier of the user. /// @param reserveId The identifier of the reserve. - event RefreshSingleUserDynamicConfig(address indexed user, uint256 reserveId); + event RefreshSingleUserDynamicConfig(bytes32 indexed positionId, uint256 reserveId); /// @notice Emitted on setUserPositionManager or renouncePositionManagerRole action. /// @param user The address of the user on whose behalf position manager can act. @@ -318,11 +318,11 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @notice Emitted on refreshPremiumDebt action. /// @param reserveId The identifier of the reserve. - /// @param user The address of the user. + /// @param positionId The position identifier of the user. /// @param premiumDelta The change in premium values. event RefreshPremiumDebt( uint256 indexed reserveId, - address indexed user, + bytes32 indexed positionId, IHubBase.PremiumDelta premiumDelta ); @@ -491,6 +491,23 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { address onBehalfOf ) external returns (uint256, uint256); + /// @notice Supplies an amount of underlying asset of the specified reserve. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @dev The Spoke pulls the underlying asset from the caller, so prior token approval is required. + /// @dev Caller must be `onBehalfOf` or an authorized position manager for `onBehalfOf`. + /// @param reserveId The reserve identifier. + /// @param amount The amount of asset to supply. + /// @param onBehalfOf The owner of the position to add supply shares to. + /// @param positionSalt The salt to compute the position identifier. + /// @return The amount of shares supplied. + /// @return The amount of assets supplied. + function supply( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) external returns (uint256, uint256); + /// @notice Withdraws a specified amount of underlying asset from the given reserve. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @dev Providing an amount greater than the maximum withdrawable value signals a full withdrawal. @@ -507,6 +524,24 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { address onBehalfOf ) external returns (uint256, uint256); + /// @notice Withdraws a specified amount of underlying asset from the given reserve. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @dev Providing an amount greater than the maximum withdrawable value signals a full withdrawal. + /// @dev Caller must be `onBehalfOf` or an authorized position manager for `onBehalfOf`. + /// @dev Caller receives the underlying asset withdrawn. + /// @param reserveId The identifier of the reserve. + /// @param amount The amount of asset to withdraw. + /// @param onBehalfOf The owner of position to remove supply shares from. + /// @param positionSalt The salt to compute the position identifier. + /// @return The amount of shares withdrawn. + /// @return The amount of assets withdrawn. + function withdraw( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) external returns (uint256, uint256); + /// @notice Borrows a specified amount of underlying asset from the given reserve. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @dev It reverts if the user would borrow more than the maximum allowed number of borrowed reserves. @@ -523,6 +558,24 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { address onBehalfOf ) external returns (uint256, uint256); + /// @notice Borrows a specified amount of underlying asset from the given reserve. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @dev It reverts if the user would borrow more than the maximum allowed number of borrowed reserves. + /// @dev Caller must be `onBehalfOf` or an authorized position manager for `onBehalfOf`. + /// @dev Caller receives the underlying asset borrowed. + /// @param reserveId The identifier of the reserve. + /// @param amount The amount of asset to borrow. + /// @param onBehalfOf The owner of the position against which debt is generated. + /// @param positionSalt The salt to compute the position identifier. + /// @return The amount of shares borrowed. + /// @return The amount of assets borrowed. + function borrow( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) external returns (uint256, uint256); + /// @notice Repays a specified amount of underlying asset to a given reserve. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @dev The Spoke pulls the underlying asset from the caller, so prior approval is required. @@ -538,6 +591,23 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { address onBehalfOf ) external returns (uint256, uint256); + /// @notice Repays a specified amount of underlying asset to a given reserve. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @dev The Spoke pulls the underlying asset from the caller, so prior approval is required. + /// @dev Caller must be `onBehalfOf` or an authorized position manager for `onBehalfOf`. + /// @param reserveId The identifier of the reserve. + /// @param amount The amount of asset to repay. + /// @param onBehalfOf The owner of the position whose debt is repaid. + /// @param positionSalt The salt to compute the position identifier. + /// @return The amount of shares repaid. + /// @return The amount of assets repaid. + function repay( + uint256 reserveId, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) external returns (uint256, uint256); + /// @notice Liquidates a user position. /// @dev It reverts if the reserves associated with any of the given reserve identifiers are not listed. /// @dev The Spoke pulls underlying repaid debt assets from caller (Liquidator), hence it needs prior approval. @@ -554,6 +624,26 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { bool receiveShares ) external; + /// @notice Liquidates a user position. + /// @dev It reverts if the reserves associated with any of the given reserve identifiers are not listed. + /// @dev The Spoke pulls underlying repaid debt assets from caller (Liquidator), hence it needs prior approval. + /// @param collateralReserveId The reserveId of the underlying asset used as collateral by the liquidated user. + /// @param debtReserveId The reserveId of the underlying asset borrowed by the liquidated user, to be repaid by Liquidator. + /// @param user The address of the user to liquidate. + /// @param positionSalt The salt to compute the position identifier. + /// @param liquidatorPositionSalt The salt to compute the position identifier. + /// @param debtToCover The desired amount of debt to cover. + /// @param receiveShares True to receive collateral in supplied shares, false to receive in underlying assets. + function liquidationCall( + uint256 collateralReserveId, + uint256 debtReserveId, + address user, + bytes32 positionSalt, + bytes32 liquidatorPositionSalt, + uint256 debtToCover, + bool receiveShares + ) external; + /// @notice Allows suppliers to enable/disable a specific supplied reserve as collateral. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @dev It reverts if the user exceeds the maximum allowed collateral reserves when enabling. @@ -568,16 +658,44 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { address onBehalfOf ) external; + /// @notice Allows suppliers to enable/disable a specific supplied reserve as collateral. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @dev It reverts if the user exceeds the maximum allowed collateral reserves when enabling. + /// @dev Reserves with zero supplied or zero collateral factor count towards the max allowed collateral reserves. + /// @dev Caller must be `onBehalfOf` or an authorized position manager for `onBehalfOf`. + /// @param reserveId The reserve identifier of the underlying asset. + /// @param usingAsCollateral True if the user wants to use the supply as collateral. + /// @param onBehalfOf The owner of the position being modified. + /// @param positionSalt The salt to compute the position identifier. + function setUsingAsCollateral( + uint256 reserveId, + bool usingAsCollateral, + address onBehalfOf, + bytes32 positionSalt + ) external; + /// @notice Allows updating the risk premium on onBehalfOf position. /// @dev Caller must be `onBehalfOf`, an authorized position manager for `onBehalfOf`, or admin. /// @param onBehalfOf The owner of the position being modified. function updateUserRiskPremium(address onBehalfOf) external; + /// @notice Allows updating the risk premium on onBehalfOf position. + /// @dev Caller must be `onBehalfOf`, an authorized position manager for `onBehalfOf`, or admin. + /// @param onBehalfOf The owner of the position being modified. + /// @param positionSalt The salt to compute the position identifier. + function updateUserRiskPremium(address onBehalfOf, bytes32 positionSalt) external; + /// @notice Allows updating the dynamic configuration for all collateral reserves on onBehalfOf position. /// @dev Caller must be `onBehalfOf`, an authorized position manager for `onBehalfOf`, or admin. /// @param onBehalfOf The owner of the position being modified. function updateUserDynamicConfig(address onBehalfOf) external; + /// @notice Allows updating the dynamic configuration for all collateral reserves on onBehalfOf position. + /// @dev Caller must be `onBehalfOf`, an authorized position manager for `onBehalfOf`, or admin. + /// @param onBehalfOf The owner of the position being modified. + /// @param positionSalt The salt to compute the position identifier. + function updateUserDynamicConfig(address onBehalfOf, bytes32 positionSalt) external; + /// @notice Enables a user to grant or revoke approval for a position manager. /// @dev Allows approving inactive position managers. /// @param positionManager The address of the position manager. @@ -690,6 +808,20 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @return True if the reserve is borrowed by the user. function getUserReserveStatus(uint256 reserveId, address user) external view returns (bool, bool); + /// @notice Returns two flags indicating whether the reserve is used as collateral and whether it is borrowed by the user. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @dev Even if enabled as collateral, it will only count towards user position if the collateral factor is greater than 0. + /// @param reserveId The identifier of the reserve. + /// @param user The address of the user. + /// @param positionSalt The salt to compute the position identifier. + /// @return True if the reserve is enabled as collateral by the user. + /// @return True if the reserve is borrowed by the user. + function getUserReserveStatus( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (bool, bool); + /// @notice Returns the amount of assets supplied by a specific user for a given reserve. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @param reserveId The identifier of the reserve. @@ -697,6 +829,18 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @return The amount of assets supplied by the user. function getUserSuppliedAssets(uint256 reserveId, address user) external view returns (uint256); + /// @notice Returns the amount of assets supplied by a specific user for a given reserve. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @param reserveId The identifier of the reserve. + /// @param user The address of the user. + /// @param positionSalt The salt to compute the position identifier. + /// @return The amount of assets supplied by the user. + function getUserSuppliedAssets( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (uint256); + /// @notice Returns the amount of shares supplied by a specific user for a given reserve. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @param reserveId The identifier of the reserve. @@ -704,6 +848,18 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @return The amount of shares supplied by the user. function getUserSuppliedShares(uint256 reserveId, address user) external view returns (uint256); + /// @notice Returns the amount of shares supplied by a specific user for a given reserve. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @param reserveId The identifier of the reserve. + /// @param user The address of the user. + /// @param positionSalt The salt to compute the position identifier. + /// @return The amount of shares supplied by the user. + function getUserSuppliedShares( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (uint256); + /// @notice Returns the debt of a specific user for a given reserve. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @dev The total debt of the user is the sum of drawn debt and premium debt. @@ -713,6 +869,20 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @return The amount of premium debt. function getUserDebt(uint256 reserveId, address user) external view returns (uint256, uint256); + /// @notice Returns the debt of a specific user for a given reserve. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @dev The total debt of the user is the sum of drawn debt and premium debt. + /// @param reserveId The identifier of the reserve. + /// @param user The address of the user. + /// @param positionSalt The salt to compute the position identifier. + /// @return The amount of drawn debt. + /// @return The amount of premium debt. + function getUserDebt( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (uint256, uint256); + /// @notice Returns the total debt of a specific user for a given reserve. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @dev The total debt of the user is the sum of drawn debt and premium debt. @@ -721,6 +891,19 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @return The total debt amount. function getUserTotalDebt(uint256 reserveId, address user) external view returns (uint256); + /// @notice Returns the total debt of a specific user for a given reserve. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @dev The total debt of the user is the sum of drawn debt and premium debt. + /// @param reserveId The identifier of the reserve. + /// @param user The address of the user. + /// @param positionSalt The salt to compute the position identifier. + /// @return The total debt amount. + function getUserTotalDebt( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (uint256); + /// @notice Returns the full precision premium debt of a specific user for a given reserve. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @param reserveId The identifier of the reserve. @@ -728,6 +911,18 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @return The amount of premium debt, expressed in asset units and scaled by RAY. function getUserPremiumDebtRay(uint256 reserveId, address user) external view returns (uint256); + /// @notice Returns the full precision premium debt of a specific user for a given reserve. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @param reserveId The identifier of the reserve. + /// @param user The address of the user. + /// @param positionSalt The salt to compute the position identifier. + /// @return The amount of premium debt, expressed in asset units and scaled by RAY. + function getUserPremiumDebtRay( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (uint256); + /// @notice Returns the user position struct in storage. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @param reserveId The identifier of the reserve. @@ -738,26 +933,71 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { address user ) external view returns (UserPosition memory); + /// @notice Returns the user position struct in storage. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @param reserveId The identifier of the reserve. + /// @param user The address of the user. + /// @param positionSalt The salt to compute the position identifier. + /// @return The user position struct. + function getUserPosition( + uint256 reserveId, + address user, + bytes32 positionSalt + ) external view returns (UserPosition memory); + /// @notice Returns the most up-to-date user account data information. /// @dev Utilizes user's current dynamic configuration of user position. /// @param user The address of the user. /// @return The user account data struct. function getUserAccountData(address user) external view returns (UserAccountData memory); + /// @notice Returns the most up-to-date user account data information. + /// @dev Utilizes user's current dynamic configuration of user position. + /// @param user The address of the user. + /// @param positionSalt The salt to compute the position identifier. + /// @return The user account data struct. + function getUserAccountData( + address user, + bytes32 positionSalt + ) external view returns (UserAccountData memory); + /// @notice Returns the risk premium from the user's last position update. /// @param user The address of the user. /// @return The risk premium of the user from the last position update, expressed in BPS. function getUserLastRiskPremium(address user) external view returns (uint256); + /// @notice Returns the risk premium from the user's last position update. + /// @param user The address of the user. + /// @param positionSalt The salt to compute the position identifier. + /// @return The risk premium of the user from the last position update, expressed in BPS. + function getUserLastRiskPremium( + address user, + bytes32 positionSalt + ) external view returns (uint256); + + /// @notice Returns the liquidation bonus for a given health factor, based on the user's current dynamic configuration. + /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. + /// @param reserveId The identifier of the reserve. + /// @param user The address of the user. + /// @param healthFactor The health factor of the user, expressed in WAD. + /// @return The liquidation bonus for the user, expressed in BPS. + function getLiquidationBonus( + uint256 reserveId, + address user, + uint256 healthFactor + ) external view returns (uint256); + /// @notice Returns the liquidation bonus for a given health factor, based on the user's current dynamic configuration. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @param reserveId The identifier of the reserve. /// @param user The address of the user. + /// @param positionSalt The salt to compute the position identifier. /// @param healthFactor The health factor of the user, expressed in WAD. /// @return The liquidation bonus for the user, expressed in BPS. function getLiquidationBonus( uint256 reserveId, address user, + bytes32 positionSalt, uint256 healthFactor ) external view returns (uint256); diff --git a/src/spoke/libraries/LiquidationLogic.sol b/src/spoke/libraries/LiquidationLogic.sol index 2b2443915..f6c5aa67f 100644 --- a/src/spoke/libraries/LiquidationLogic.sol +++ b/src/spoke/libraries/LiquidationLogic.sol @@ -34,10 +34,12 @@ library LiquidationLogic { uint256 debtReserveId; address oracle; address user; + bytes32 positionId; ISpoke.LiquidationConfig liquidationConfig; uint256 debtToCover; ISpoke.UserAccountData userAccountData; address liquidator; + bytes32 liquidatorPositionId; bool receiveShares; } @@ -57,6 +59,7 @@ library LiquidationLogic { ISpoke.LiquidationConfig liquidationConfig; address oracle; address user; + bytes32 positionId; uint256 debtToCover; uint256 healthFactor; uint256 totalDebtValueRay; @@ -193,15 +196,15 @@ library LiquidationLogic { /// @return True if the liquidation results in deficit. function liquidateUser( mapping(uint256 reserveId => ISpoke.Reserve) storage reserves, - mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) storage userPositions, - mapping(address user => ISpoke.PositionStatus) storage positionStatus, + mapping(bytes32 positionId => mapping(uint256 reserveId => ISpoke.UserPosition)) storage userPositions, + mapping(bytes32 positionId => ISpoke.PositionStatus) storage positionStatus, mapping(uint256 reserveId => mapping(uint32 dynamicConfigKey => ISpoke.DynamicReserveConfig)) storage dynamicConfig, LiquidateUserParams memory params ) external returns (bool) { ISpoke.Reserve storage collateralReserve = reserves.get(params.collateralReserveId); ISpoke.Reserve storage debtReserve = reserves.get(params.debtReserveId); - ISpoke.UserPosition storage collateralUserPosition = userPositions[params.user][ + ISpoke.UserPosition storage collateralUserPosition = userPositions[params.positionId][ params.collateralReserveId ]; ISpoke.DynamicReserveConfig storage collateralDynConfig = dynamicConfig[ @@ -224,6 +227,7 @@ library LiquidationLogic { liquidationConfig: params.liquidationConfig, oracle: params.oracle, user: params.user, + positionId: params.positionId, debtToCover: params.debtToCover, healthFactor: params.userAccountData.healthFactor, totalDebtValueRay: params.userAccountData.totalDebtValueRay, @@ -233,11 +237,13 @@ library LiquidationLogic { receiveShares: params.receiveShares }); - ISpoke.UserPosition storage debtUserPosition = userPositions[params.user][params.debtReserveId]; - ISpoke.UserPosition storage collateralLiquidatorPosition = userPositions[params.liquidator][ - params.collateralReserveId + ISpoke.UserPosition storage debtUserPosition = userPositions[params.positionId][ + params.debtReserveId ]; - ISpoke.PositionStatus storage userPositionStatus = positionStatus[params.user]; + ISpoke.UserPosition storage collateralLiquidatorPosition = userPositions[ + params.liquidatorPositionId + ][params.collateralReserveId]; + ISpoke.PositionStatus storage userPositionStatus = positionStatus[params.positionId]; return _executeLiquidation({ @@ -256,22 +262,22 @@ library LiquidationLogic { /// @param userPositions The mapping of user positions per reserve per user. /// @param positionStatus The mapping of position status per user. /// @param reserveCount The number of reserves. - /// @param user The address of the user. + /// @param positionId The identifier of the position to report deficit for. function notifyReportDeficit( mapping(uint256 reserveId => ISpoke.Reserve) storage reserves, - mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) storage userPositions, - mapping(address user => ISpoke.PositionStatus) storage positionStatus, + mapping(bytes32 positionId => mapping(uint256 reserveId => ISpoke.UserPosition)) storage userPositions, + mapping(bytes32 positionId => ISpoke.PositionStatus) storage positionStatus, uint256 reserveCount, - address user + bytes32 positionId ) external { - ISpoke.PositionStatus storage userPositionStatus = positionStatus[user]; + ISpoke.PositionStatus storage userPositionStatus = positionStatus[positionId]; userPositionStatus.riskPremium = 0; uint256 reserveId = reserveCount; while ( (reserveId = userPositionStatus.nextBorrowing(reserveId)) != PositionStatusMap.NOT_FOUND ) { - ISpoke.UserPosition storage userPosition = userPositions[user][reserveId]; + ISpoke.UserPosition storage userPosition = userPositions[positionId][reserveId]; ISpoke.Reserve storage reserve = reserves[reserveId]; IHubBase hub = reserve.hub; uint256 assetId = reserve.assetId; @@ -296,10 +302,10 @@ library LiquidationLogic { userPosition.drawnShares -= debtComponents.drawnShares.toUint120(); userPositionStatus.setBorrowing(reserveId, false); - emit ISpoke.ReportDeficit(reserveId, user, debtComponents.drawnShares, premiumDelta); + emit ISpoke.ReportDeficit(reserveId, positionId, debtComponents.drawnShares, premiumDelta); } - emit ISpoke.UpdateUserRiskPremium(user, 0); + emit ISpoke.UpdateUserRiskPremium(positionId, 0); } /// @notice Calculates the liquidation bonus at a given health factor. @@ -425,7 +431,7 @@ library LiquidationLogic { emit ISpoke.LiquidationCall({ collateralReserveId: params.collateralReserveId, debtReserveId: params.debtReserveId, - user: params.user, + positionId: params.positionId, liquidator: params.liquidator, receiveShares: params.receiveShares, debtAmountRestored: liquidateDebtResult.amountRestored, diff --git a/src/spoke/libraries/UserAccountDataLogic.sol b/src/spoke/libraries/UserAccountDataLogic.sol new file mode 100644 index 000000000..f5ebbf8f2 --- /dev/null +++ b/src/spoke/libraries/UserAccountDataLogic.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity 0.8.28; + +import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; +import {Math} from 'src/dependencies/openzeppelin/Math.sol'; +import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; +import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; +import {SpokeUtils} from 'src/spoke/libraries/SpokeUtils.sol'; +import {KeyValueList} from 'src/spoke/libraries/KeyValueList.sol'; +import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; +import {PositionStatusMap} from 'src/spoke/libraries/PositionStatusMap.sol'; +import {UserPositionUtils} from 'src/spoke/libraries/UserPositionUtils.sol'; +import {IAaveOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {SpokeStorage} from 'src/spoke/SpokeStorage.sol'; + +/// @title UserAccountDataLogic library +/// @author Aave Labs +/// @notice Implements the logic to process a position's account data. +library UserAccountDataLogic { + using SafeCast for *; + using MathUtils for *; + using PercentageMath for *; + using WadRayMath for *; + using SpokeUtils for *; + using KeyValueList for KeyValueList.List; + using PositionStatusMap for *; + using UserPositionUtils for ISpoke.UserPosition; + + struct ProcessUserAccountDataParams { + IAaveOracle oracle; + uint256 reserveCount; + bytes32 positionId; + } + + /// @notice Process the user account data. + /// @dev Collateral is rounded against the user, while debt is calculated with full precision. + /// @dev If user has no debt, it returns health factor of `type(uint256).max` and risk premium of 0. + /// @param reserves The mapping of reserves per reserve id. + /// @param userPositions The mapping of user positions per position per reserve. + /// @param positionStatus The mapping of position status per position. + /// @param dynamicConfig The mapping of dynamic config per reserve per dynamic config key. + /// @param params The process user account data params. + /// @return accountData The user account data. + function processUserAccountData( + mapping(uint256 reserveId => ISpoke.Reserve) storage reserves, + mapping(bytes32 positionId => mapping(uint256 reserveId => ISpoke.UserPosition)) storage userPositions, + mapping(bytes32 positionId => ISpoke.PositionStatus) storage positionStatus, + mapping(uint256 reserveId => mapping(uint32 dynamicConfigKey => ISpoke.DynamicReserveConfig)) storage dynamicConfig, + ProcessUserAccountDataParams memory params + ) external view returns (ISpoke.UserAccountData memory accountData) { + ISpoke.PositionStatus storage userPositionStatus = positionStatus[params.positionId]; + + uint256 reserveId = params.reserveCount; + KeyValueList.List memory collateralInfo = KeyValueList.init( + userPositionStatus.collateralCount(reserveId) + ); + bool borrowing; + bool collateral; + while (true) { + (reserveId, borrowing, collateral) = userPositionStatus.next(reserveId); + if (reserveId == PositionStatusMap.NOT_FOUND) break; + + ISpoke.UserPosition storage userPosition = userPositions[params.positionId][reserveId]; + ISpoke.Reserve storage reserve = reserves[reserveId]; + + uint256 assetPrice = params.oracle.getReservePrice(reserveId); + uint256 assetDecimals = reserve.decimals; + + if (collateral) { + uint256 collateralFactor = dynamicConfig[reserveId][userPosition.dynamicConfigKey] + .collateralFactor; + if (collateralFactor > 0) { + uint256 suppliedShares = userPosition.suppliedShares; + if (suppliedShares > 0) { + // cannot round down to zero + uint256 userCollateralValue = reserve + .hub + .previewRemoveByShares(reserve.assetId, suppliedShares) + .toValue({decimals: assetDecimals, price: assetPrice}); + accountData.totalCollateralValue += userCollateralValue; + collateralInfo.add( + accountData.activeCollateralCount, + reserve.collateralRisk, + userCollateralValue + ); + accountData.avgCollateralFactor += collateralFactor * userCollateralValue; + accountData.activeCollateralCount = accountData.activeCollateralCount.uncheckedAdd(1); + } + } + } + + if (borrowing) { + UserPositionUtils.DebtComponents memory debtComponents = userPosition.getDebtComponents( + reserve.hub, + reserve.assetId + ); + uint256 debtRay = debtComponents.drawnShares * debtComponents.drawnIndex + + debtComponents.premiumDebtRay; + accountData.totalDebtValueRay += debtRay.toValue({ + decimals: assetDecimals, + price: assetPrice + }); + accountData.borrowCount = accountData.borrowCount.uncheckedAdd(1); + } + } + + if (accountData.totalDebtValueRay > 0) { + // at this point, `avgCollateralFactor` is the total collateral value weighted by collateral factors, + // expressed in units of Value and scaled by BPS. We convert it from BPS to WAD, since this will + // ultimately define the scaling factor of the health factor. + accountData.healthFactor = Math.mulDiv( + accountData.avgCollateralFactor.bpsToWad(), + WadRayMath.RAY, + accountData.totalDebtValueRay, + Math.Rounding.Floor + ); + } else { + accountData.healthFactor = type(uint256).max; + } + + if (accountData.totalCollateralValue > 0) { + accountData.avgCollateralFactor = + accountData.avgCollateralFactor.bpsToWad() / accountData.totalCollateralValue; + } + + // sort by collateral risk in ASC, collateral value in DESC + collateralInfo.sortByKey(); + + // runs until either the collateral or debt is exhausted + uint256 totalDebtValue = accountData.totalDebtValueRay.fromRayUp(); + uint256 debtValueLeftToCover = totalDebtValue; + + for (uint256 index = 0; index < collateralInfo.length(); ++index) { + if (debtValueLeftToCover == 0) { + break; + } + + (uint256 collateralRisk, uint256 userCollateralValue) = collateralInfo.uncheckedAt(index); + userCollateralValue = userCollateralValue.min(debtValueLeftToCover); + accountData.riskPremium += userCollateralValue * collateralRisk; + debtValueLeftToCover = debtValueLeftToCover.uncheckedSub(userCollateralValue); + } + + if (debtValueLeftToCover < totalDebtValue) { + accountData.riskPremium = accountData.riskPremium.divUp( + totalDebtValue.uncheckedSub(debtValueLeftToCover) + ); + } + + return accountData; + } +} diff --git a/tests/contracts/position-manager/ConfigPositionManager/ConfigPositionManager.t.sol b/tests/contracts/position-manager/ConfigPositionManager/ConfigPositionManager.t.sol index a44774a87..d02efb921 100644 --- a/tests/contracts/position-manager/ConfigPositionManager/ConfigPositionManager.t.sol +++ b/tests/contracts/position-manager/ConfigPositionManager/ConfigPositionManager.t.sol @@ -431,7 +431,12 @@ contract ConfigPositionManagerTest is ConfigPositionManagerBaseTest { assertEq(isCollateral, !useAsCollateral); vm.expectEmit(address(spoke1)); - emit ISpoke.SetUsingAsCollateral(reserveId, address(positionManager), alice, useAsCollateral); + emit ISpoke.SetUsingAsCollateral( + reserveId, + address(positionManager), + _getPositionId(alice), + useAsCollateral + ); vm.expectEmit(address(positionManager)); emit IConfigPositionManager.SetUsingAsCollateralOnBehalfOf( address(spoke1), @@ -468,7 +473,12 @@ contract ConfigPositionManagerTest is ConfigPositionManagerBaseTest { assertEq(isCollateral, !useAsCollateral); vm.expectEmit(address(spoke1)); - emit ISpoke.SetUsingAsCollateral(reserveId, address(positionManager), alice, useAsCollateral); + emit ISpoke.SetUsingAsCollateral( + reserveId, + address(positionManager), + _getPositionId(alice), + useAsCollateral + ); vm.expectEmit(address(positionManager)); emit IConfigPositionManager.SetUsingAsCollateralOnBehalfOf( address(spoke1), @@ -559,7 +569,10 @@ contract ConfigPositionManagerTest is ConfigPositionManagerBaseTest { }); vm.expectEmit(address(spoke1)); - emit ISpoke.UpdateUserRiskPremium(alice, _calculateExpectedUserRP(spoke1, alice)); + emit ISpoke.UpdateUserRiskPremium( + _getPositionId(alice), + _calculateExpectedUserRP(spoke1, alice) + ); vm.expectEmit(address(positionManager)); emit IConfigPositionManager.UpdateUserRiskPremiumOnBehalfOf(address(spoke1), bob, alice); vm.prank(bob); @@ -586,7 +599,10 @@ contract ConfigPositionManagerTest is ConfigPositionManagerBaseTest { }); vm.expectEmit(address(spoke1)); - emit ISpoke.UpdateUserRiskPremium(alice, _calculateExpectedUserRP(spoke1, alice)); + emit ISpoke.UpdateUserRiskPremium( + _getPositionId(alice), + _calculateExpectedUserRP(spoke1, alice) + ); vm.expectEmit(address(positionManager)); emit IConfigPositionManager.UpdateUserRiskPremiumOnBehalfOf(address(spoke1), bob, alice); vm.prank(bob); @@ -610,7 +626,7 @@ contract ConfigPositionManagerTest is ConfigPositionManagerBaseTest { positionManager.setCanUpdateUserDynamicConfigPermission(address(spoke1), bob, true); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshAllUserDynamicConfig(_getPositionId(alice)); vm.expectEmit(address(positionManager)); emit IConfigPositionManager.UpdateUserDynamicConfigOnBehalfOf(address(spoke1), bob, alice); vm.prank(bob); @@ -622,7 +638,7 @@ contract ConfigPositionManagerTest is ConfigPositionManagerBaseTest { positionManager.setGlobalPermission(address(spoke1), bob, true); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshAllUserDynamicConfig(_getPositionId(alice)); vm.expectEmit(address(positionManager)); emit IConfigPositionManager.UpdateUserDynamicConfigOnBehalfOf(address(spoke1), bob, alice); vm.prank(bob); diff --git a/tests/contracts/position-manager/GiverPositionManager.t.sol b/tests/contracts/position-manager/GiverPositionManager.t.sol index 0a6a1da93..051d7da86 100644 --- a/tests/contracts/position-manager/GiverPositionManager.t.sol +++ b/tests/contracts/position-manager/GiverPositionManager.t.sol @@ -42,7 +42,7 @@ contract GiverPositionManagerTest is Base { emit ISpoke.Supply( _daiReserveId(spoke1), address(positionManager), - alice, + _getPositionId(alice), hub1.previewAddByAssets(daiAssetId, amount), amount ); @@ -155,7 +155,7 @@ contract GiverPositionManagerTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), address(positionManager), - alice, + _getPositionId(alice), hub1.previewRestoreByAssets(daiAssetId, baseRestored), repayAmount, expectedPremiumDelta @@ -251,7 +251,7 @@ contract GiverPositionManagerTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), address(positionManager), - alice, + _getPositionId(alice), hub1.previewRestoreByAssets(daiAssetId, baseRestored), repaidAmount, expectedPremiumDelta @@ -362,7 +362,7 @@ contract GiverPositionManagerTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), address(positionManager), - alice, + _getPositionId(alice), hub1.previewRestoreByAssets(daiAssetId, baseRestored), totalRepaid, expectedPremiumDelta diff --git a/tests/contracts/position-manager/NativeTokenGateway.t.sol b/tests/contracts/position-manager/NativeTokenGateway.t.sol index c787105b6..7916d435e 100644 --- a/tests/contracts/position-manager/NativeTokenGateway.t.sol +++ b/tests/contracts/position-manager/NativeTokenGateway.t.sol @@ -56,7 +56,7 @@ contract NativeTokenGatewayTest is Base { emit ISpoke.Supply( _wethReserveId(spoke1), address(nativeTokenGateway), - bob, + _getPositionId(bob), hub1.previewAddByAssets(wethAssetId, amount), amount ); @@ -94,7 +94,7 @@ contract NativeTokenGatewayTest is Base { vm.mockFunction( address(spoke1), address(reentrantCaller), - abi.encodeWithSelector(ISpoke.supply.selector) + abi.encodeWithSelector(bytes4(keccak256('supply(uint256,uint256,address)'))) ); vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); vm.prank(bob); @@ -180,7 +180,7 @@ contract NativeTokenGatewayTest is Base { emit ISpoke.Supply( _wethReserveId(spoke1), address(nativeTokenGateway), - bob, + _getPositionId(bob), hub1.previewAddByAssets(wethAssetId, amount), amount ); @@ -232,7 +232,7 @@ contract NativeTokenGatewayTest is Base { emit ISpoke.Withdraw( _wethReserveId(spoke1), address(nativeTokenGateway), - bob, + _getPositionId(bob), hub1.previewRemoveByAssets(wethAssetId, amount), amount ); @@ -279,7 +279,7 @@ contract NativeTokenGatewayTest is Base { emit ISpoke.Withdraw( _wethReserveId(spoke1), address(nativeTokenGateway), - bob, + _getPositionId(bob), expectedSupplyShares, supplyAmount ); @@ -351,7 +351,7 @@ contract NativeTokenGatewayTest is Base { emit ISpoke.Withdraw( _wethReserveId(spoke1), address(nativeTokenGateway), - bob, + _getPositionId(bob), expectedSupplyShares, expectedWithdrawAmount ); @@ -384,7 +384,7 @@ contract NativeTokenGatewayTest is Base { vm.mockFunction( address(spoke1), address(reentrantCaller), - abi.encodeWithSelector(ISpoke.withdraw.selector) + abi.encodeWithSelector(bytes4(keccak256('withdraw(uint256,uint256,address)'))) ); vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); vm.prank(bob); @@ -470,7 +470,7 @@ contract NativeTokenGatewayTest is Base { emit ISpoke.Borrow( _wethReserveId(spoke1), address(nativeTokenGateway), - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(wethAssetId, borrowAmount), borrowAmount ); @@ -508,7 +508,7 @@ contract NativeTokenGatewayTest is Base { vm.mockFunction( address(spoke1), address(reentrantCaller), - abi.encodeWithSelector(ISpoke.borrow.selector) + abi.encodeWithSelector(bytes4(keccak256('borrow(uint256,uint256,address)'))) ); vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); vm.prank(bob); @@ -620,7 +620,7 @@ contract NativeTokenGatewayTest is Base { emit ISpoke.Repay( _wethReserveId(spoke1), address(nativeTokenGateway), - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(wethAssetId, baseRestored), repayAmount, expectedPremiumDelta @@ -702,7 +702,7 @@ contract NativeTokenGatewayTest is Base { emit ISpoke.Repay( _wethReserveId(spoke1), address(nativeTokenGateway), - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(wethAssetId, baseRestored), repaidAmount, expectedPremiumDelta @@ -794,7 +794,7 @@ contract NativeTokenGatewayTest is Base { emit ISpoke.Repay( _wethReserveId(spoke1), address(nativeTokenGateway), - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(wethAssetId, baseRestored), totalRepaid, expectedPremiumDelta @@ -830,7 +830,7 @@ contract NativeTokenGatewayTest is Base { vm.mockFunction( address(spoke1), address(reentrantCaller), - abi.encodeWithSelector(ISpoke.repay.selector) + abi.encodeWithSelector(bytes4(keccak256('repay(uint256,uint256,address)'))) ); vm.expectRevert(ReentrancyGuardTransient.ReentrancyGuardReentrantCall.selector); vm.prank(bob); diff --git a/tests/contracts/position-manager/SignatureGateway/SignatureGateway.t.sol b/tests/contracts/position-manager/SignatureGateway/SignatureGateway.t.sol index f29b89cc2..29abb0832 100644 --- a/tests/contracts/position-manager/SignatureGateway/SignatureGateway.t.sol +++ b/tests/contracts/position-manager/SignatureGateway/SignatureGateway.t.sol @@ -71,7 +71,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { SharesAndAmount memory returnValues; vm.expectEmit(address(spoke1)); - emit ISpoke.Supply(p.reserveId, address(gateway), alice, shares, p.amount); + emit ISpoke.Supply(p.reserveId, address(gateway), _getPositionId(alice), shares, p.amount); vm.prank(vm.randomAddress()); (returnValues.shares, returnValues.amount) = gateway.supplyWithSig(p, signature); @@ -107,7 +107,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { ); SharesAndAmount memory returnValues; vm.expectEmit(address(spoke1)); - emit ISpoke.Withdraw(p.reserveId, address(gateway), alice, shares, p.amount); + emit ISpoke.Withdraw(p.reserveId, address(gateway), _getPositionId(alice), shares, p.amount); vm.prank(vm.randomAddress()); (returnValues.shares, returnValues.amount) = gateway.withdrawWithSig(p, signature); @@ -144,7 +144,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { ); SharesAndAmount memory returnValues; vm.expectEmit(address(spoke1)); - emit ISpoke.Borrow(p.reserveId, address(gateway), alice, shares, p.amount); + emit ISpoke.Borrow(p.reserveId, address(gateway), _getPositionId(alice), shares, p.amount); vm.prank(vm.randomAddress()); (returnValues.shares, returnValues.amount) = gateway.borrowWithSig(p, signature); @@ -204,7 +204,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { emit ISpoke.Repay( p.reserveId, address(gateway), - alice, + _getPositionId(alice), shares, baseRestored + premiumRestored, _getExpectedPremiumDelta(spoke1, alice, p.reserveId, premiumRestored) @@ -237,7 +237,12 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { if (_isUsingAsCollateral(spoke1, p.reserveId, alice) != p.useAsCollateral) { vm.expectEmit(address(spoke1)); - emit ISpoke.SetUsingAsCollateral(p.reserveId, address(gateway), alice, p.useAsCollateral); + emit ISpoke.SetUsingAsCollateral( + p.reserveId, + address(gateway), + _getPositionId(alice), + p.useAsCollateral + ); } vm.prank(vm.randomAddress()); @@ -274,7 +279,10 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { }); vm.expectEmit(address(spoke1)); - emit ISpoke.UpdateUserRiskPremium(alice, _calculateExpectedUserRP(spoke1, alice)); + emit ISpoke.UpdateUserRiskPremium( + _getPositionId(alice), + _calculateExpectedUserRP(spoke1, alice) + ); vm.prank(vm.randomAddress()); gateway.updateUserRiskPremiumWithSig(p, signature); @@ -294,7 +302,7 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshAllUserDynamicConfig(_getPositionId(alice)); vm.prank(vm.randomAddress()); gateway.updateUserDynamicConfigWithSig(p, signature); diff --git a/tests/contracts/position-manager/TakerPositionManager/TakerPositionManager.t.sol b/tests/contracts/position-manager/TakerPositionManager/TakerPositionManager.t.sol index b671dcf81..b533841f0 100644 --- a/tests/contracts/position-manager/TakerPositionManager/TakerPositionManager.t.sol +++ b/tests/contracts/position-manager/TakerPositionManager/TakerPositionManager.t.sol @@ -92,7 +92,7 @@ contract TakerPositionManagerTest is TakerPositionManagerBaseTest { emit ISpoke.Withdraw( _daiReserveId(spoke1), address(positionManager), - alice, + _getPositionId(alice), expectedShares, amount ); @@ -176,7 +176,7 @@ contract TakerPositionManagerTest is TakerPositionManagerBaseTest { emit ISpoke.Withdraw( _daiReserveId(spoke1), address(positionManager), - alice, + _getPositionId(alice), expectedSupplyShares, supplyAmount ); @@ -247,7 +247,7 @@ contract TakerPositionManagerTest is TakerPositionManagerBaseTest { emit ISpoke.Withdraw( _daiReserveId(spoke1), address(positionManager), - alice, + _getPositionId(alice), expectedSupplyShares, supplyAmount ); @@ -345,7 +345,7 @@ contract TakerPositionManagerTest is TakerPositionManagerBaseTest { emit ISpoke.Withdraw( _daiReserveId(spoke1), address(positionManager), - alice, + _getPositionId(alice), expectedSupplyShares, expectedWithdrawAmount ); @@ -533,7 +533,7 @@ contract TakerPositionManagerTest is TakerPositionManagerBaseTest { emit ISpoke.Borrow( _daiReserveId(spoke1), address(positionManager), - alice, + _getPositionId(alice), expectedBorrowShares, borrowAmount ); @@ -703,7 +703,7 @@ contract TakerPositionManagerTest is TakerPositionManagerBaseTest { emit ISpoke.Borrow( _daiReserveId(spoke1), address(positionManager), - alice, + _getPositionId(alice), hub1.previewRestoreByAssets(daiAssetId, borrowAmount), borrowAmount ); diff --git a/tests/contracts/spoke/borrow/Spoke.Borrow.t.sol b/tests/contracts/spoke/borrow/Spoke.Borrow.t.sol index dad82031e..0ece23be8 100644 --- a/tests/contracts/spoke/borrow/Spoke.Borrow.t.sol +++ b/tests/contracts/spoke/borrow/Spoke.Borrow.t.sol @@ -16,7 +16,7 @@ contract SpokeBorrowTest is Base { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke1), - ISpoke.borrow.selector + bytes4(keccak256('borrow(uint256,uint256,address)')) ); vm.mockFunction( @@ -41,7 +41,7 @@ contract SpokeBorrowTest is Base { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke1), - ISpoke.borrow.selector + bytes4(keccak256('borrow(uint256,uint256,address)')) ); vm.mockFunction( @@ -152,7 +152,7 @@ contract SpokeBorrowTest is Base { emit ISpoke.Borrow({ reserveId: state.daiReserveId, caller: bob, - user: bob, + positionId: _getPositionId(bob), drawnShares: expectedShares, drawnAmount: state.daiBob.borrowAmount }); @@ -325,7 +325,7 @@ contract SpokeBorrowTest is Base { emit ISpoke.Borrow({ reserveId: state.daiReserveId, caller: bob, - user: bob, + positionId: _getPositionId(bob), drawnShares: expectedShares, drawnAmount: state.daiBob.borrowAmount }); diff --git a/tests/contracts/spoke/configuration/Spoke.DynamicConfig.Triggers.t.sol b/tests/contracts/spoke/configuration/Spoke.DynamicConfig.Triggers.t.sol index 1cf3825c3..4329c9414 100644 --- a/tests/contracts/spoke/configuration/Spoke.DynamicConfig.Triggers.t.sol +++ b/tests/contracts/spoke/configuration/Spoke.DynamicConfig.Triggers.t.sol @@ -202,7 +202,7 @@ contract SpokeDynamicConfigTriggersTest is Base { }); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshAllUserDynamicConfig(_getPositionId(alice)); SpokeActions.borrow({ spoke: spoke1, reserveId: _daiReserveId(spoke1), @@ -256,7 +256,7 @@ contract SpokeDynamicConfigTriggersTest is Base { }); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshAllUserDynamicConfig(_getPositionId(alice)); SpokeActions.withdraw({ spoke: spoke1, reserveId: _usdxReserveId(spoke1), @@ -308,7 +308,7 @@ contract SpokeDynamicConfigTriggersTest is Base { // when enabling, only the relevant asset is refreshed vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshSingleUserDynamicConfig(alice, _wethReserveId(spoke1)); + emit ISpoke.RefreshSingleUserDynamicConfig(_getPositionId(alice), _wethReserveId(spoke1)); vm.prank(alice); spoke1.setUsingAsCollateral(_wethReserveId(spoke1), true, alice); @@ -320,7 +320,7 @@ contract SpokeDynamicConfigTriggersTest is Base { // when disabling all configs are refreshed vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshAllUserDynamicConfig(_getPositionId(alice)); vm.prank(alice); spoke1.setUsingAsCollateral(_usdxReserveId(spoke1), false, alice); @@ -354,7 +354,7 @@ contract SpokeDynamicConfigTriggersTest is Base { // manually trigger update vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshAllUserDynamicConfig(_getPositionId(alice)); vm.prank(alice); spoke1.updateUserDynamicConfig(alice); @@ -504,7 +504,7 @@ contract SpokeDynamicConfigTriggersTest is Base { uint256 snapshotId = vm.snapshotState(); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshAllUserDynamicConfig(_getPositionId(alice)); vm.prank(caller); spoke1.updateUserDynamicConfig(alice); diff --git a/tests/contracts/spoke/configuration/Spoke.UpdateUserDynamicConfig.t.sol b/tests/contracts/spoke/configuration/Spoke.UpdateUserDynamicConfig.t.sol index 30509a655..df4e29266 100644 --- a/tests/contracts/spoke/configuration/Spoke.UpdateUserDynamicConfig.t.sol +++ b/tests/contracts/spoke/configuration/Spoke.UpdateUserDynamicConfig.t.sol @@ -23,7 +23,7 @@ contract SpokeUpdateUserDynamicConfigTest is Base { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke1), - ISpoke.updateUserDynamicConfig.selector + bytes4(keccak256('updateUserDynamicConfig(address)')) ); // reentrant hub.refreshPremium call diff --git a/tests/contracts/spoke/libraries/liquidation-logic/LiquidationLogic.ExecuteLiquidation.t.sol b/tests/contracts/spoke/libraries/liquidation-logic/LiquidationLogic.ExecuteLiquidation.t.sol index ae6baa240..dc9f83375 100644 --- a/tests/contracts/spoke/libraries/liquidation-logic/LiquidationLogic.ExecuteLiquidation.t.sol +++ b/tests/contracts/spoke/libraries/liquidation-logic/LiquidationLogic.ExecuteLiquidation.t.sol @@ -68,6 +68,7 @@ contract LiquidationLogicExecuteLiquidationTest is LiquidationLogicBaseTest { }), oracle: address(oracle1), user: makeAddr('user'), + positionId: _getPositionId(makeAddr('user')), debtToCover: 3e18, healthFactor: 0.8e18, totalDebtValueRay: 10_000e26 * WadRayMath.RAY, diff --git a/tests/contracts/spoke/libraries/liquidation-logic/LiquidationLogic.LiquidateUser.t.sol b/tests/contracts/spoke/libraries/liquidation-logic/LiquidationLogic.LiquidateUser.t.sol index c2261d97f..00149a634 100644 --- a/tests/contracts/spoke/libraries/liquidation-logic/LiquidationLogic.LiquidateUser.t.sol +++ b/tests/contracts/spoke/libraries/liquidation-logic/LiquidationLogic.LiquidateUser.t.sol @@ -50,6 +50,7 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { debtReserveId: wethReserveId, oracle: address(oracle1), user: makeAddr('user'), + positionId: _getPositionId(makeAddr('user')), liquidationConfig: ISpoke.LiquidationConfig({ targetHealthFactor: 1e18, healthFactorForMaxBonus: 0.8e18, @@ -66,6 +67,7 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { avgCollateralFactor: 0 // not used }), liquidator: makeAddr('liquidator'), + liquidatorPositionId: _getPositionId(makeAddr('liquidator')), receiveShares: false }); diff --git a/tests/contracts/spoke/liquidation/Spoke.LiquidationCall.Base.t.sol b/tests/contracts/spoke/liquidation/Spoke.LiquidationCall.Base.t.sol index bcd4cdb6d..7e8d0dc59 100644 --- a/tests/contracts/spoke/liquidation/Spoke.LiquidationCall.Base.t.sol +++ b/tests/contracts/spoke/liquidation/Spoke.LiquidationCall.Base.t.sol @@ -427,7 +427,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { emit ISpoke.LiquidationCall({ collateralReserveId: params.collateralReserveId, debtReserveId: params.debtReserveId, - user: params.user, + positionId: _getPositionId(params.user), liquidator: params.liquidator, receiveShares: params.receiveShares, debtAmountRestored: amountToRestore, @@ -536,7 +536,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { vm.expectEmit(address(params.spoke)); emit ISpoke.ReportDeficit({ reserveId: reserveId, - user: params.user, + positionId: _getPositionId(params.user), drawnShares: vars.userReservePosition.drawnShares, premiumDelta: premiumDelta }); @@ -566,7 +566,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { vm.expectEmit(address(params.spoke)); emit ISpoke.RefreshPremiumDebt({ reserveId: reserveId, - user: params.user, + positionId: _getPositionId(params.user), premiumDelta: premiumDelta }); } else { @@ -583,7 +583,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { if (!liquidationMetadata.hasDeficit && !riskPremiumOptimisation) { vm.expectEmit(address(params.spoke)); emit ISpoke.UpdateUserRiskPremium({ - user: params.user, + positionId: _getPositionId(params.user), riskPremium: expectedUserAccountData.riskPremium }); } diff --git a/tests/contracts/spoke/liquidation/Spoke.LiquidationCall.Scenarios.t.sol b/tests/contracts/spoke/liquidation/Spoke.LiquidationCall.Scenarios.t.sol index 86fec3f54..bc4828577 100644 --- a/tests/contracts/spoke/liquidation/Spoke.LiquidationCall.Scenarios.t.sol +++ b/tests/contracts/spoke/liquidation/Spoke.LiquidationCall.Scenarios.t.sol @@ -66,7 +66,7 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke), - ISpoke.liquidationCall.selector + bytes4(keccak256('liquidationCall(uint256,uint256,address,uint256,bool)')) ); vm.mockFunction( @@ -93,7 +93,7 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke), - ISpoke.liquidationCall.selector + bytes4(keccak256('liquidationCall(uint256,uint256,address,uint256,bool)')) ); vm.mockFunction( @@ -122,7 +122,7 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke), - ISpoke.liquidationCall.selector + bytes4(keccak256('liquidationCall(uint256,uint256,address,uint256,bool)')) ); vm.mockFunction( @@ -149,7 +149,7 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke), - ISpoke.liquidationCall.selector + bytes4(keccak256('liquidationCall(uint256,uint256,address,uint256,bool)')) ); vm.mockFunction( diff --git a/tests/contracts/spoke/misc/Spoke.Multicall.t.sol b/tests/contracts/spoke/misc/Spoke.Multicall.t.sol index d221d517c..fba7f7d10 100644 --- a/tests/contracts/spoke/misc/Spoke.Multicall.t.sol +++ b/tests/contracts/spoke/misc/Spoke.Multicall.t.sol @@ -33,14 +33,24 @@ contract SpokeMulticall is Base { // Set up the multicall bytes[] memory calls = new bytes[](2); - calls[0] = abi.encodeCall(ISpoke.supply, (daiReserveId, supplyAmount, bob)); - calls[1] = abi.encodeCall(ISpoke.setUsingAsCollateral, (daiReserveId, true, bob)); + calls[0] = abi.encodeWithSignature( + 'supply(uint256,uint256,address)', + daiReserveId, + supplyAmount, + bob + ); + calls[1] = abi.encodeWithSignature( + 'setUsingAsCollateral(uint256,bool,address)', + daiReserveId, + true, + bob + ); vm.expectEmit(address(spoke1)); emit ISpoke.Supply({ reserveId: daiReserveId, caller: bob, - user: bob, + positionId: _getPositionId(bob), suppliedShares: hub1.previewAddByAssets(daiAssetId, supplyAmount), suppliedAmount: supplyAmount }); @@ -48,7 +58,7 @@ contract SpokeMulticall is Base { emit ISpoke.SetUsingAsCollateral({ reserveId: daiReserveId, caller: bob, - user: bob, + positionId: _getPositionId(bob), usingAsCollateral: true }); @@ -92,15 +102,25 @@ contract SpokeMulticall is Base { // Set up the multicall bytes[] memory calls = new bytes[](3); - calls[0] = abi.encodeCall(ISpoke.supply, (_daiReserveId(spoke2), MAX_SUPPLY_AMOUNT, bob)); - calls[1] = abi.encodeCall(ISpoke.setUsingAsCollateral, (_daiReserveId(spoke2), true, bob)); - calls[2] = abi.encodeCall(ISpoke.updateUserRiskPremium, (bob)); + calls[0] = abi.encodeWithSignature( + 'supply(uint256,uint256,address)', + _daiReserveId(spoke2), + MAX_SUPPLY_AMOUNT, + bob + ); + calls[1] = abi.encodeWithSignature( + 'setUsingAsCollateral(uint256,bool,address)', + _daiReserveId(spoke2), + true, + bob + ); + calls[2] = abi.encodeWithSignature('updateUserRiskPremium(address)', bob); vm.expectEmit(address(spoke2)); emit ISpoke.Supply({ reserveId: _daiReserveId(spoke2), caller: bob, - user: bob, + positionId: _getPositionId(bob), suppliedShares: hub1.previewAddByAssets(daiAssetId, MAX_SUPPLY_AMOUNT), suppliedAmount: MAX_SUPPLY_AMOUNT }); @@ -108,11 +128,14 @@ contract SpokeMulticall is Base { emit ISpoke.SetUsingAsCollateral({ reserveId: _daiReserveId(spoke2), caller: bob, - user: bob, + positionId: _getPositionId(bob), usingAsCollateral: true }); vm.expectEmit(address(spoke2)); - emit ISpoke.UpdateUserRiskPremium(bob, _getCollateralRisk(spoke2, _daiReserveId(spoke2))); + emit ISpoke.UpdateUserRiskPremium( + _getPositionId(bob), + _getCollateralRisk(spoke2, _daiReserveId(spoke2)) + ); // Then he supplies dai and sets as collateral, so user rp should decrease vm.prank(bob); @@ -245,10 +268,29 @@ contract SpokeMulticall is Base { uint256 borrowAmount = 80e18; bytes[] memory calls = new bytes[](4); - calls[0] = abi.encodeCall(ISpoke.supply, (_daiReserveId(spoke1), supplyAmount, alice)); - calls[1] = abi.encodeCall(ISpoke.setUsingAsCollateral, (_daiReserveId(spoke1), true, alice)); - calls[2] = abi.encodeCall(ISpoke.borrow, (_daiReserveId(spoke1), borrowAmount, alice)); - calls[3] = abi.encodeCall(ISpoke.getUserDebt, (_daiReserveId(spoke1), alice)); + calls[0] = abi.encodeWithSignature( + 'supply(uint256,uint256,address)', + _daiReserveId(spoke1), + supplyAmount, + alice + ); + calls[1] = abi.encodeWithSignature( + 'setUsingAsCollateral(uint256,bool,address)', + _daiReserveId(spoke1), + true, + alice + ); + calls[2] = abi.encodeWithSignature( + 'borrow(uint256,uint256,address)', + _daiReserveId(spoke1), + borrowAmount, + alice + ); + calls[3] = abi.encodeWithSignature( + 'getUserDebt(uint256,address)', + _daiReserveId(spoke1), + alice + ); vm.prank(alice); bytes[] memory ret = spoke1.multicall(calls); @@ -264,9 +306,24 @@ contract SpokeMulticall is Base { uint256 supplyAmount = 120e18; bytes[] memory calls = new bytes[](3); - calls[0] = abi.encodeCall(ISpoke.supply, (_daiReserveId(spoke1), supplyAmount, alice)); - calls[1] = abi.encodeCall(ISpoke.withdraw, (_daiReserveId(spoke1), 0, alice)); - calls[2] = abi.encodeCall(ISpoke.setUsingAsCollateral, (_daiReserveId(spoke1), true, alice)); + calls[0] = abi.encodeWithSignature( + 'supply(uint256,uint256,address)', + _daiReserveId(spoke1), + supplyAmount, + alice + ); + calls[1] = abi.encodeWithSignature( + 'withdraw(uint256,uint256,address)', + _daiReserveId(spoke1), + 0, + alice + ); + calls[2] = abi.encodeWithSignature( + 'setUsingAsCollateral(uint256,bool,address)', + _daiReserveId(spoke1), + true, + alice + ); vm.prank(alice); vm.expectRevert(IHub.InvalidAmount.selector); diff --git a/tests/contracts/spoke/misc/Spoke.SetUsingAsCollateral.t.sol b/tests/contracts/spoke/misc/Spoke.SetUsingAsCollateral.t.sol index 425186b6b..b4dba6408 100644 --- a/tests/contracts/spoke/misc/Spoke.SetUsingAsCollateral.t.sol +++ b/tests/contracts/spoke/misc/Spoke.SetUsingAsCollateral.t.sol @@ -82,7 +82,7 @@ contract SpokeSetUsingAsCollateralTest is Base { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke1), - ISpoke.setUsingAsCollateral.selector + bytes4(keccak256('setUsingAsCollateral(uint256,bool,address)')) ); // reentrant hub.refreshPremium call @@ -193,7 +193,7 @@ contract SpokeSetUsingAsCollateralTest is Base { emit ISpoke.SetUsingAsCollateral({ reserveId: daiReserveId, caller: bob, - user: bob, + positionId: _getPositionId(bob), usingAsCollateral: usingAsCollateral }); spoke1.setUsingAsCollateral(daiReserveId, usingAsCollateral, bob); diff --git a/tests/contracts/spoke/misc/Spoke.Upgradeable.t.sol b/tests/contracts/spoke/misc/Spoke.Upgradeable.t.sol index a81ced420..813745580 100644 --- a/tests/contracts/spoke/misc/Spoke.Upgradeable.t.sol +++ b/tests/contracts/spoke/misc/Spoke.Upgradeable.t.sol @@ -150,7 +150,7 @@ contract SpokeUpgradeableTest is Base { address(_deploySpokeProxy(address(spokeImpl))) ); - ISpokeInstance spokeImpl2 = _deployMockSpokeInstance(2); + ISpokeInstance spokeImpl2 = _deployMockSpokeInstance(3); vm.expectRevert(ISpoke.InvalidAddress.selector); vm.prank(ProxyHelper.getProxyAdmin(address(spokeProxy))); spokeProxy.upgradeToAndCall(address(spokeImpl2), _getInitializeCalldata(address(0))); @@ -205,7 +205,7 @@ contract SpokeUpgradeableTest is Base { ); ISpokeInstance spokeProxy = ISpokeInstance(address(_deploySpokeProxy(address(spokeImpl)))); - assertEq(spokeProxy.SPOKE_REVISION(), 1); + assertEq(spokeProxy.SPOKE_REVISION(), 2); } function _deploySpokeProxy(address spokeImpl) internal returns (ISpoke) { diff --git a/tests/contracts/spoke/position-manager/Spoke.PositionManager.t.sol b/tests/contracts/spoke/position-manager/Spoke.PositionManager.t.sol index b018aa217..d8cfe5bb5 100644 --- a/tests/contracts/spoke/position-manager/Spoke.PositionManager.t.sol +++ b/tests/contracts/spoke/position-manager/Spoke.PositionManager.t.sol @@ -68,7 +68,7 @@ contract SpokePositionManagerTest is Base { emit ISpoke.Supply({ reserveId: reserveId, caller: POSITION_MANAGER, - user: alice, + positionId: _getPositionId(alice), suppliedShares: hub1.previewAddByAssets(usdxAssetId, amount), suppliedAmount: amount }); @@ -127,7 +127,7 @@ contract SpokePositionManagerTest is Base { emit ISpoke.Withdraw({ reserveId: reserveId, caller: POSITION_MANAGER, - user: alice, + positionId: _getPositionId(alice), withdrawnShares: hub1.previewRemoveByAssets(usdxAssetId, amount), withdrawnAmount: amount }); @@ -185,7 +185,7 @@ contract SpokePositionManagerTest is Base { emit ISpoke.Borrow({ reserveId: reserveId, caller: POSITION_MANAGER, - user: alice, + positionId: _getPositionId(alice), drawnShares: hub1.previewRestoreByAssets(usdxAssetId, amount), drawnAmount: amount }); @@ -260,7 +260,7 @@ contract SpokePositionManagerTest is Base { emit ISpoke.Repay( reserveId, POSITION_MANAGER, - alice, + _getPositionId(alice), hub1.previewRestoreByAssets(usdxAssetId, repayAmount), repayAmount, expectedPremiumDelta @@ -324,7 +324,7 @@ contract SpokePositionManagerTest is Base { emit ISpoke.SetUsingAsCollateral({ reserveId: reserveId, caller: POSITION_MANAGER, - user: alice, + positionId: _getPositionId(alice), usingAsCollateral: usingAsCollateral }); SpokeActions.setUsingAsCollateral({ @@ -385,7 +385,10 @@ contract SpokePositionManagerTest is Base { _approvePositionManager(alice); vm.expectEmit(address(spoke1)); - emit ISpoke.UpdateUserRiskPremium(alice, _calculateExpectedUserRP(spoke1, alice)); + emit ISpoke.UpdateUserRiskPremium( + _getPositionId(alice), + _calculateExpectedUserRP(spoke1, alice) + ); vm.prank(POSITION_MANAGER); spoke1.updateUserRiskPremium(alice); @@ -438,7 +441,7 @@ contract SpokePositionManagerTest is Base { _approvePositionManager(alice); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshAllUserDynamicConfig(_getPositionId(alice)); vm.prank(POSITION_MANAGER); spoke1.updateUserDynamicConfig(alice); diff --git a/tests/contracts/spoke/position-salt/Spoke.PositionSalt.Getters.t.sol b/tests/contracts/spoke/position-salt/Spoke.PositionSalt.Getters.t.sol new file mode 100644 index 000000000..b18225448 --- /dev/null +++ b/tests/contracts/spoke/position-salt/Spoke.PositionSalt.Getters.t.sol @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/setup/Base.t.sol'; + +contract SpokePositionSaltGettersTest is Base { + bytes32 internal constant SALT_A = keccak256('position-salt-a'); + + function test_getters_readSaltedPosition() public { + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 wethReserveId = _wethReserveId(spoke1); + + // default position holds only a dai supply + SpokeActions.supply({ + spoke: spoke1, + reserveId: daiReserveId, + caller: bob, + amount: 200e18, + onBehalfOf: bob + }); + + // salted position holds weth collateral and a dai borrow + SpokeActions.supply({ + spoke: spoke1, + reserveId: daiReserveId, + caller: alice, + amount: 1000e18, + onBehalfOf: alice + }); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: wethReserveId, + caller: bob, + amount: 10e18, + onBehalfOf: bob, + positionSalt: SALT_A + }); + SpokeActions.borrow({ + spoke: spoke1, + reserveId: daiReserveId, + caller: bob, + amount: 100e18, + onBehalfOf: bob, + positionSalt: SALT_A + }); + skip(365 days); + + (bool usingColl, ) = spoke1.getUserReserveStatus(wethReserveId, bob, SALT_A); + (, bool borrowing) = spoke1.getUserReserveStatus(daiReserveId, bob, SALT_A); + assertTrue(usingColl); + assertTrue(borrowing); + (bool defaultUsingColl, bool defaultBorrowing) = spoke1.getUserReserveStatus( + wethReserveId, + bob + ); + assertFalse(defaultUsingColl); + assertFalse(defaultBorrowing); + + assertEq( + spoke1.getUserSuppliedShares(wethReserveId, bob, SALT_A), + spoke1.getUserPosition(wethReserveId, bob, SALT_A).suppliedShares + ); + assertGt(spoke1.getUserSuppliedAssets(wethReserveId, bob, SALT_A), 0); + assertEq(spoke1.getUserSuppliedShares(wethReserveId, bob), 0); + + (uint256 drawn, uint256 premium) = spoke1.getUserDebt(daiReserveId, bob, SALT_A); + assertGt(drawn, 0); + assertGt(premium, 0); + assertEq(spoke1.getUserTotalDebt(daiReserveId, bob, SALT_A), drawn + premium); + assertGt(spoke1.getUserPremiumDebtRay(daiReserveId, bob, SALT_A), 0); + (uint256 defaultDrawn, ) = spoke1.getUserDebt(daiReserveId, bob); + assertEq(defaultDrawn, 0); + assertEq(spoke1.getUserTotalDebt(daiReserveId, bob), 0); + + ISpoke.UserAccountData memory saltedAccountData = spoke1.getUserAccountData(bob, SALT_A); + assertGt(saltedAccountData.totalDebtValueRay, 0); + assertGt(saltedAccountData.riskPremium, 0); + assertEq(spoke1.getUserAccountData(bob).totalDebtValueRay, 0); + + assertGt(spoke1.getUserLastRiskPremium(bob, SALT_A), 0); + assertEq(spoke1.getUserLastRiskPremium(bob), 0); + } + + function test_getters_defaultSaltEquivalence() public { + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 wethReserveId = _wethReserveId(spoke1); + SpokeActions.supply({ + spoke: spoke1, + reserveId: daiReserveId, + caller: alice, + amount: 1000e18, + onBehalfOf: alice + }); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: wethReserveId, + caller: bob, + amount: 10e18, + onBehalfOf: bob + }); + SpokeActions.borrow({ + spoke: spoke1, + reserveId: daiReserveId, + caller: bob, + amount: 100e18, + onBehalfOf: bob + }); + skip(365 days); + + (bool collateral, bool borrowing) = spoke1.getUserReserveStatus(daiReserveId, bob); + (bool collateralSalt, bool borrowingSalt) = spoke1.getUserReserveStatus( + daiReserveId, + bob, + bytes32(0) + ); + assertEq(collateral, collateralSalt); + assertEq(borrowing, borrowingSalt); + + assertEq( + spoke1.getUserSuppliedShares(wethReserveId, bob), + spoke1.getUserSuppliedShares(wethReserveId, bob, bytes32(0)) + ); + assertEq( + spoke1.getUserSuppliedAssets(wethReserveId, bob), + spoke1.getUserSuppliedAssets(wethReserveId, bob, bytes32(0)) + ); + + (uint256 drawn, uint256 premium) = spoke1.getUserDebt(daiReserveId, bob); + (uint256 drawnSalt, uint256 premiumSalt) = spoke1.getUserDebt(daiReserveId, bob, bytes32(0)); + assertEq(drawn, drawnSalt); + assertEq(premium, premiumSalt); + assertEq( + spoke1.getUserTotalDebt(daiReserveId, bob), + spoke1.getUserTotalDebt(daiReserveId, bob, bytes32(0)) + ); + assertEq( + spoke1.getUserPremiumDebtRay(daiReserveId, bob), + spoke1.getUserPremiumDebtRay(daiReserveId, bob, bytes32(0)) + ); + assertEq( + spoke1.getUserPosition(daiReserveId, bob), + spoke1.getUserPosition(daiReserveId, bob, bytes32(0)) + ); + assertEq(spoke1.getUserLastRiskPremium(bob), spoke1.getUserLastRiskPremium(bob, bytes32(0))); + assertEq(spoke1.getUserAccountData(bob), spoke1.getUserAccountData(bob, bytes32(0))); + + uint256 healthFactor = HEALTH_FACTOR_LIQUIDATION_THRESHOLD - 1; + assertEq( + spoke1.getLiquidationBonus(wethReserveId, bob, healthFactor), + spoke1.getLiquidationBonus(wethReserveId, bob, bytes32(0), healthFactor) + ); + } + + function test_getUserAccountData_fuzz_salt(bytes32 salt, uint256 supplyAmount) public { + supplyAmount = bound(supplyAmount, 1e18, MAX_SUPPLY_AMOUNT); + uint256 reserveId = _daiReserveId(spoke1); + deal(address(tokenList.dai), bob, supplyAmount); + + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: supplyAmount, + onBehalfOf: bob, + positionSalt: salt + }); + + ISpoke.UserAccountData memory accountData = spoke1.getUserAccountData(bob, salt); + assertEq(accountData.totalDebtValueRay, 0); + assertGt(accountData.totalCollateralValue, 0); + assertEq( + spoke1.getUserSuppliedShares(reserveId, bob, salt), + hub1.previewAddByAssets(daiAssetId, supplyAmount) + ); + + bytes32 otherSalt = bytes32(uint256(salt) ^ 1); + assertEq(spoke1.getUserAccountData(bob, otherSalt).totalCollateralValue, 0); + assertEq(spoke1.getUserSuppliedShares(reserveId, bob, otherSalt), 0); + } + + function test_getLiquidationBonus_fuzz_salt(bytes32 salt, uint256 healthFactor) public { + uint256 reserveId = _daiReserveId(spoke1); + healthFactor = bound(healthFactor, 0, HEALTH_FACTOR_LIQUIDATION_THRESHOLD); + assertEq( + spoke1.getLiquidationBonus(reserveId, bob, salt, healthFactor), + spoke1.getLiquidationBonus(reserveId, bob, healthFactor) + ); + } +} diff --git a/tests/contracts/spoke/position-salt/Spoke.PositionSalt.Isolation.t.sol b/tests/contracts/spoke/position-salt/Spoke.PositionSalt.Isolation.t.sol new file mode 100644 index 000000000..15ea06150 --- /dev/null +++ b/tests/contracts/spoke/position-salt/Spoke.PositionSalt.Isolation.t.sol @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/setup/Base.t.sol'; + +contract SpokePositionSaltIsolationTest is Base { + bytes32 internal constant SALT_A = keccak256('position-salt-a'); + bytes32 internal constant SALT_B = keccak256('position-salt-b'); + + function test_supply_isolation() public { + uint256 reserveId = _daiReserveId(spoke1); + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 30e18, + onBehalfOf: bob + }); + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 50e18, + onBehalfOf: bob, + positionSalt: SALT_B + }); + ISpoke.UserPosition memory defaultBefore = spoke1.getUserPosition(reserveId, bob); + ISpoke.UserPosition memory saltBBefore = spoke1.getUserPosition(reserveId, bob, SALT_B); + + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 100e18, + onBehalfOf: bob, + positionSalt: SALT_A + }); + + assertEq( + spoke1.getUserSuppliedShares(reserveId, bob, SALT_A), + hub1.previewAddByAssets(daiAssetId, 100e18) + ); + assertEq(spoke1.getUserPosition(reserveId, bob), defaultBefore); + assertEq(spoke1.getUserPosition(reserveId, bob, SALT_B), saltBBefore); + } + + function test_withdraw_isolation() public { + uint256 reserveId = _daiReserveId(spoke1); + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 30e18, + onBehalfOf: bob + }); + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 50e18, + onBehalfOf: bob, + positionSalt: SALT_B + }); + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 100e18, + onBehalfOf: bob, + positionSalt: SALT_A + }); + ISpoke.UserPosition memory defaultBefore = spoke1.getUserPosition(reserveId, bob); + ISpoke.UserPosition memory saltBBefore = spoke1.getUserPosition(reserveId, bob, SALT_B); + + SpokeActions.withdraw({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 100e18, + onBehalfOf: bob, + positionSalt: SALT_A + }); + + assertEq(spoke1.getUserSuppliedShares(reserveId, bob, SALT_A), 0); + assertEq(spoke1.getUserPosition(reserveId, bob), defaultBefore); + assertEq(spoke1.getUserPosition(reserveId, bob, SALT_B), saltBBefore); + } + + function test_borrow_isolation() public { + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 wethReserveId = _wethReserveId(spoke1); + SpokeActions.supply({ + spoke: spoke1, + reserveId: daiReserveId, + caller: alice, + amount: 1000e18, + onBehalfOf: alice + }); + _openBorrow(bob, bytes32(0), 100e18); + _openBorrow(bob, SALT_B, 100e18); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: wethReserveId, + caller: bob, + amount: 10e18, + onBehalfOf: bob, + positionSalt: SALT_A + }); + + ISpoke.UserPosition memory defaultDai = spoke1.getUserPosition(daiReserveId, bob); + ISpoke.UserPosition memory defaultWeth = spoke1.getUserPosition(wethReserveId, bob); + ISpoke.UserPosition memory saltBDai = spoke1.getUserPosition(daiReserveId, bob, SALT_B); + ISpoke.UserPosition memory saltBWeth = spoke1.getUserPosition(wethReserveId, bob, SALT_B); + + SpokeActions.borrow({ + spoke: spoke1, + reserveId: daiReserveId, + caller: bob, + amount: 100e18, + onBehalfOf: bob, + positionSalt: SALT_A + }); + + (uint256 drawnA, ) = spoke1.getUserDebt(daiReserveId, bob, SALT_A); + assertEq(drawnA, 100e18); + assertEq(spoke1.getUserPosition(daiReserveId, bob), defaultDai); + assertEq(spoke1.getUserPosition(wethReserveId, bob), defaultWeth); + assertEq(spoke1.getUserPosition(daiReserveId, bob, SALT_B), saltBDai); + assertEq(spoke1.getUserPosition(wethReserveId, bob, SALT_B), saltBWeth); + } + + function test_repay_isolation() public { + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 wethReserveId = _wethReserveId(spoke1); + SpokeActions.supply({ + spoke: spoke1, + reserveId: daiReserveId, + caller: alice, + amount: 1000e18, + onBehalfOf: alice + }); + _openBorrow(bob, bytes32(0), 100e18); + _openBorrow(bob, SALT_B, 100e18); + _openBorrow(bob, SALT_A, 100e18); + + ISpoke.UserPosition memory defaultDai = spoke1.getUserPosition(daiReserveId, bob); + ISpoke.UserPosition memory saltBDai = spoke1.getUserPosition(daiReserveId, bob, SALT_B); + + SpokeActions.repay({ + spoke: spoke1, + reserveId: daiReserveId, + caller: bob, + amount: type(uint256).max, + onBehalfOf: bob, + positionSalt: SALT_A + }); + + (uint256 drawnA, ) = spoke1.getUserDebt(daiReserveId, bob, SALT_A); + assertEq(drawnA, 0); + assertEq(spoke1.getUserPosition(daiReserveId, bob), defaultDai); + assertEq(spoke1.getUserPosition(daiReserveId, bob, SALT_B), saltBDai); + assertEq( + spoke1.getUserPosition(wethReserveId, bob), + spoke1.getUserPosition(wethReserveId, bob) + ); + } + + function test_setUsingAsCollateral_isolation() public { + uint256 reserveId = _daiReserveId(spoke1); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 30e18, + onBehalfOf: bob + }); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 50e18, + onBehalfOf: bob, + positionSalt: SALT_B + }); + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 100e18, + onBehalfOf: bob, + positionSalt: SALT_A + }); + ISpoke.UserPosition memory defaultBefore = spoke1.getUserPosition(reserveId, bob); + ISpoke.UserPosition memory saltBBefore = spoke1.getUserPosition(reserveId, bob, SALT_B); + + SpokeActions.setUsingAsCollateral({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + usingAsCollateral: true, + onBehalfOf: bob, + positionSalt: SALT_A + }); + + (bool usingA, ) = spoke1.getUserReserveStatus(reserveId, bob, SALT_A); + assertTrue(usingA); + (bool usingDefault, ) = spoke1.getUserReserveStatus(reserveId, bob); + (bool usingB, ) = spoke1.getUserReserveStatus(reserveId, bob, SALT_B); + assertTrue(usingDefault); + assertTrue(usingB); + assertEq(spoke1.getUserPosition(reserveId, bob), defaultBefore); + assertEq(spoke1.getUserPosition(reserveId, bob, SALT_B), saltBBefore); + } + + function test_updateUserRiskPremium_isolation() public { + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 wethReserveId = _wethReserveId(spoke1); + SpokeActions.supply({ + spoke: spoke1, + reserveId: daiReserveId, + caller: alice, + amount: 1000e18, + onBehalfOf: alice + }); + _openBorrow(bob, bytes32(0), 100e18); + _openBorrow(bob, SALT_A, 100e18); + skip(100); + + ISpoke.UserPosition memory defaultDai = spoke1.getUserPosition(daiReserveId, bob); + ISpoke.UserPosition memory defaultWeth = spoke1.getUserPosition(wethReserveId, bob); + uint256 defaultRiskPremium = spoke1.getUserLastRiskPremium(bob); + + vm.prank(bob); + spoke1.updateUserRiskPremium(bob, SALT_A); + + assertEq(spoke1.getUserPosition(daiReserveId, bob), defaultDai); + assertEq(spoke1.getUserPosition(wethReserveId, bob), defaultWeth); + assertEq(spoke1.getUserLastRiskPremium(bob), defaultRiskPremium); + } + + function test_updateUserDynamicConfig_isolation() public { + uint256 reserveId = _usdxReserveId(spoke1); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 1000e6, + onBehalfOf: bob + }); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 1000e6, + onBehalfOf: bob, + positionSalt: SALT_A + }); + _updateLiquidationFee(spoke1, reserveId, 10_00); + ISpoke.UserPosition memory defaultBefore = spoke1.getUserPosition(reserveId, bob); + + vm.prank(bob); + spoke1.updateUserDynamicConfig(bob, SALT_A); + + assertEq(spoke1.getUserPosition(reserveId, bob), defaultBefore); + } + + function test_liquidationCall_isolation() public { + uint256 collateralReserveId = _usdxReserveId(spoke1); + uint256 debtReserveId = _daiReserveId(spoke1); + bytes32 liquidatorSalt = keccak256('liquidator-salt'); + + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: collateralReserveId, + caller: bob, + amount: 1000e6, + onBehalfOf: bob + }); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: collateralReserveId, + caller: bob, + amount: 2000e6, + onBehalfOf: bob, + positionSalt: SALT_B + }); + _seedSaltedLiquidatable(bob, SALT_A); + + deal(address(tokenList.dai), carol, 100_000e18); + SpokeActions.approve({ + spoke: spoke1, + reserveId: debtReserveId, + owner: carol, + amount: type(uint256).max + }); + + ISpoke.UserPosition memory defaultBefore = spoke1.getUserPosition(collateralReserveId, bob); + ISpoke.UserPosition memory saltBBefore = spoke1.getUserPosition( + collateralReserveId, + bob, + SALT_B + ); + + SpokeActions.liquidationCall({ + spoke: spoke1, + collateralReserveId: collateralReserveId, + debtReserveId: debtReserveId, + user: bob, + positionSalt: SALT_A, + liquidatorPositionSalt: liquidatorSalt, + debtToCover: 50_000e18, + receiveShares: true, + caller: carol + }); + + assertEq(spoke1.getUserPosition(collateralReserveId, bob), defaultBefore); + assertEq(spoke1.getUserPosition(collateralReserveId, bob, SALT_B), saltBBefore); + assertGt(spoke1.getUserSuppliedShares(collateralReserveId, carol, liquidatorSalt), 0); + assertEq( + spoke1.getUserSuppliedShares(collateralReserveId, carol), + 0, + 'liquidator default position untouched' + ); + } + + function test_supply_isolation_fuzz( + bytes32 saltA, + bytes32 saltB, + uint256 amountA, + uint256 amountB + ) public { + vm.assume(saltA != saltB); + amountA = bound(amountA, 1, MAX_SUPPLY_AMOUNT / 2); + amountB = bound(amountB, 1, MAX_SUPPLY_AMOUNT / 2); + uint256 reserveId = _daiReserveId(spoke1); + deal(address(tokenList.dai), bob, amountA + amountB); + + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: amountB, + onBehalfOf: bob, + positionSalt: saltB + }); + uint256 saltBSharesBefore = spoke1.getUserSuppliedShares(reserveId, bob, saltB); + + uint256 expectedShares = hub1.previewAddByAssets(daiAssetId, amountA); + vm.assume(expectedShares > 0); + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: amountA, + onBehalfOf: bob, + positionSalt: saltA + }); + + assertEq(spoke1.getUserSuppliedShares(reserveId, bob, saltA), expectedShares); + assertEq(spoke1.getUserSuppliedShares(reserveId, bob, saltB), saltBSharesBefore); + } + + function test_successiveSalts_supply_isolation() public { + uint256 reserveId = _daiReserveId(spoke1); + uint256 positions = 5; + deal(address(tokenList.dai), bob, 1000e18); + + uint256[] memory amounts = new uint256[](positions); + for (uint256 i = 0; i < positions; i++) { + amounts[i] = (i + 1) * 10e18; + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: amounts[i], + onBehalfOf: bob, + positionSalt: bytes32(i) + }); + } + + ISpoke.UserPosition[] memory before = new ISpoke.UserPosition[](positions); + for (uint256 i = 0; i < positions; i++) { + assertApproxEqAbs(spoke1.getUserSuppliedAssets(reserveId, bob, bytes32(i)), amounts[i], 1); + before[i] = spoke1.getUserPosition(reserveId, bob, bytes32(i)); + } + + uint256 target = 2; + SpokeActions.withdraw({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: amounts[target], + onBehalfOf: bob, + positionSalt: bytes32(target) + }); + + assertEq(spoke1.getUserSuppliedShares(reserveId, bob, bytes32(target)), 0); + for (uint256 i = 0; i < positions; i++) { + if (i == target) continue; + assertEq(spoke1.getUserPosition(reserveId, bob, bytes32(i)), before[i]); + } + } + + function test_successiveSalts_borrow_isolation() public { + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 positions = 4; + SpokeActions.supply({ + spoke: spoke1, + reserveId: daiReserveId, + caller: alice, + amount: 1000e18, + onBehalfOf: alice + }); + + for (uint256 i = 0; i < positions; i++) { + _openBorrow(bob, bytes32(i), 50e18); + } + + ISpoke.UserPosition[] memory before = new ISpoke.UserPosition[](positions); + for (uint256 i = 0; i < positions; i++) { + (uint256 drawn, ) = spoke1.getUserDebt(daiReserveId, bob, bytes32(i)); + assertEq(drawn, 50e18); + before[i] = spoke1.getUserPosition(daiReserveId, bob, bytes32(i)); + } + + uint256 target = 1; + SpokeActions.repay({ + spoke: spoke1, + reserveId: daiReserveId, + caller: bob, + amount: type(uint256).max, + onBehalfOf: bob, + positionSalt: bytes32(target) + }); + + (uint256 drawnTarget, ) = spoke1.getUserDebt(daiReserveId, bob, bytes32(target)); + assertEq(drawnTarget, 0); + for (uint256 i = 0; i < positions; i++) { + if (i == target) continue; + assertEq(spoke1.getUserPosition(daiReserveId, bob, bytes32(i)), before[i]); + } + } + + function _openBorrow(address user, bytes32 salt, uint256 borrowAmount) internal { + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: _wethReserveId(spoke1), + caller: user, + amount: 10e18, + onBehalfOf: user, + positionSalt: salt + }); + SpokeActions.borrow({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: user, + amount: borrowAmount, + onBehalfOf: user, + positionSalt: salt + }); + } + + function _seedSaltedLiquidatable(address user, bytes32 salt) internal { + uint256 collateralReserveId = _usdxReserveId(spoke1); + uint256 debtReserveId = _daiReserveId(spoke1); + _updateMaxLiquidationBonus(spoke1, collateralReserveId, 105_00); + _updateLiquidationFee(spoke1, collateralReserveId, 10_00); + + SpokeActions.supply({ + spoke: spoke1, + reserveId: debtReserveId, + caller: alice, + amount: 1_000_000e18, + onBehalfOf: alice + }); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: collateralReserveId, + caller: user, + amount: 1_000_000e6, + onBehalfOf: user, + positionSalt: salt + }); + SpokeActions.borrow({ + spoke: spoke1, + reserveId: debtReserveId, + caller: user, + amount: 500_000e18, + onBehalfOf: user, + positionSalt: salt + }); + + _mockReservePriceByPercent(spoke1, collateralReserveId, 50_00); + assertLt( + spoke1.getUserAccountData(user, salt).healthFactor, + HEALTH_FACTOR_LIQUIDATION_THRESHOLD + ); + } +} diff --git a/tests/contracts/spoke/position-salt/Spoke.PositionSalt.Operations.t.sol b/tests/contracts/spoke/position-salt/Spoke.PositionSalt.Operations.t.sol new file mode 100644 index 000000000..4a900641d --- /dev/null +++ b/tests/contracts/spoke/position-salt/Spoke.PositionSalt.Operations.t.sol @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/setup/Base.t.sol'; + +contract SpokePositionSaltOperationsTest is Base { + bytes32 internal constant SALT_A = keccak256('position-salt-a'); + + function test_supply() public { + uint256 reserveId = _daiReserveId(spoke1); + uint256 amount = 100e18; + bytes32 positionId = _getPositionId(bob, SALT_A); + uint256 expectedShares = hub1.previewAddByAssets(daiAssetId, amount); + + vm.expectEmit(address(spoke1)); + emit ISpoke.Supply({ + reserveId: reserveId, + caller: bob, + positionId: positionId, + suppliedShares: expectedShares, + suppliedAmount: amount + }); + vm.prank(bob); + (uint256 shares, uint256 suppliedAmount) = spoke1.supply(reserveId, amount, bob, SALT_A); + + assertEq(shares, expectedShares); + assertEq(suppliedAmount, amount); + assertEq(spoke1.getUserSuppliedShares(reserveId, bob, SALT_A), expectedShares); + assertEq(spoke1.getUserSuppliedAssets(reserveId, bob, SALT_A), amount); + assertEq(spoke1.getUserSuppliedShares(reserveId, bob), 0, 'default position untouched'); + } + + function test_withdraw() public { + uint256 reserveId = _daiReserveId(spoke1); + uint256 amount = 100e18; + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: amount, + onBehalfOf: bob, + positionSalt: SALT_A + }); + bytes32 positionId = _getPositionId(bob, SALT_A); + uint256 suppliedShares = spoke1.getUserSuppliedShares(reserveId, bob, SALT_A); + + vm.expectEmit(address(spoke1)); + emit ISpoke.Withdraw(reserveId, bob, positionId, suppliedShares, amount); + vm.prank(bob); + (uint256 withdrawnShares, uint256 withdrawnAmount) = spoke1.withdraw( + reserveId, + amount, + bob, + SALT_A + ); + + assertEq(withdrawnShares, suppliedShares); + assertEq(withdrawnAmount, amount); + assertEq(spoke1.getUserSuppliedShares(reserveId, bob, SALT_A), 0); + } + + function test_borrow() public { + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 borrowAmount = 100e18; + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: _wethReserveId(spoke1), + caller: bob, + amount: 10e18, + onBehalfOf: bob, + positionSalt: SALT_A + }); + SpokeActions.supply({ + spoke: spoke1, + reserveId: daiReserveId, + caller: alice, + amount: borrowAmount, + onBehalfOf: alice + }); + bytes32 positionId = _getPositionId(bob, SALT_A); + uint256 expectedShares = hub1.previewRestoreByAssets(daiAssetId, borrowAmount); + + vm.expectEmit(address(spoke1)); + emit ISpoke.Borrow({ + reserveId: daiReserveId, + caller: bob, + positionId: positionId, + drawnShares: expectedShares, + drawnAmount: borrowAmount + }); + vm.prank(bob); + (uint256 shares, uint256 amount) = spoke1.borrow(daiReserveId, borrowAmount, bob, SALT_A); + + assertEq(shares, expectedShares); + assertEq(amount, borrowAmount); + (uint256 drawn, ) = spoke1.getUserDebt(daiReserveId, bob, SALT_A); + assertEq(drawn, borrowAmount); + (uint256 defaultDrawn, ) = spoke1.getUserDebt(daiReserveId, bob); + assertEq(defaultDrawn, 0, 'default position untouched'); + } + + function test_repay() public { + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 borrowAmount = 100e18; + _seedSaltedBorrow(bob, SALT_A, borrowAmount); + bytes32 positionId = _getPositionId(bob, SALT_A); + + IHubBase.PremiumDelta memory premiumDelta; + vm.expectEmit(true, true, true, false, address(spoke1)); + emit ISpoke.Repay(daiReserveId, bob, positionId, 0, 0, premiumDelta); + vm.prank(bob); + spoke1.repay(daiReserveId, type(uint256).max, bob, SALT_A); + + (uint256 drawn, uint256 premium) = spoke1.getUserDebt(daiReserveId, bob, SALT_A); + assertEq(drawn, 0); + assertEq(premium, 0); + (, bool borrowing) = spoke1.getUserReserveStatus(daiReserveId, bob, SALT_A); + assertFalse(borrowing); + } + + function test_setUsingAsCollateral() public { + uint256 reserveId = _daiReserveId(spoke1); + SpokeActions.supply({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 100e18, + onBehalfOf: bob, + positionSalt: SALT_A + }); + bytes32 positionId = _getPositionId(bob, SALT_A); + + vm.expectEmit(address(spoke1)); + emit ISpoke.SetUsingAsCollateral(reserveId, bob, positionId, true); + vm.prank(bob); + spoke1.setUsingAsCollateral(reserveId, true, bob, SALT_A); + + (bool usingAsCollateral, ) = spoke1.getUserReserveStatus(reserveId, bob, SALT_A); + assertTrue(usingAsCollateral); + (bool defaultUsing, ) = spoke1.getUserReserveStatus(reserveId, bob); + assertFalse(defaultUsing, 'default position untouched'); + } + + function test_updateUserRiskPremium() public { + _seedSaltedBorrow(bob, SALT_A, 100e18); + skip(100); + bytes32 positionId = _getPositionId(bob, SALT_A); + uint256 expectedRiskPremium = spoke1.getUserAccountData(bob, SALT_A).riskPremium; + assertGt(expectedRiskPremium, 0); + + vm.expectEmit(true, false, false, true, address(spoke1)); + emit ISpoke.UpdateUserRiskPremium(positionId, expectedRiskPremium); + vm.prank(bob); + spoke1.updateUserRiskPremium(bob, SALT_A); + + assertEq(spoke1.getUserLastRiskPremium(bob, SALT_A), expectedRiskPremium); + assertEq(spoke1.getUserLastRiskPremium(bob), 0, 'default position untouched'); + } + + function test_updateUserDynamicConfig() public { + uint256 reserveId = _usdxReserveId(spoke1); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: reserveId, + caller: bob, + amount: 1000e6, + onBehalfOf: bob, + positionSalt: SALT_A + }); + bytes32 positionId = _getPositionId(bob, SALT_A); + + vm.expectEmit(true, false, false, false, address(spoke1)); + emit ISpoke.RefreshAllUserDynamicConfig(positionId); + vm.prank(bob); + spoke1.updateUserDynamicConfig(bob, SALT_A); + } + + function test_liquidationCall() public { + uint256 collateralReserveId = _usdxReserveId(spoke1); + uint256 debtReserveId = _daiReserveId(spoke1); + _seedSaltedLiquidatable(bob, SALT_A); + bytes32 positionId = _getPositionId(bob, SALT_A); + + (uint256 drawnBefore, ) = spoke1.getUserDebt(debtReserveId, bob, SALT_A); + uint256 collateralBefore = spoke1.getUserSuppliedShares(collateralReserveId, bob, SALT_A); + + IHubBase.PremiumDelta memory premiumDelta; + vm.expectEmit(true, true, true, false, address(spoke1)); + emit ISpoke.LiquidationCall({ + collateralReserveId: collateralReserveId, + debtReserveId: debtReserveId, + positionId: positionId, + liquidator: carol, + receiveShares: false, + debtAmountRestored: 0, + drawnSharesLiquidated: 0, + premiumDelta: premiumDelta, + collateralAmountRemoved: 0, + collateralSharesLiquidated: 0, + collateralSharesToLiquidator: 0 + }); + SpokeActions.liquidationCall({ + spoke: spoke1, + collateralReserveId: collateralReserveId, + debtReserveId: debtReserveId, + user: bob, + positionSalt: SALT_A, + liquidatorPositionSalt: bytes32(0), + debtToCover: 50_000e18, + receiveShares: false, + caller: carol + }); + + (uint256 drawnAfter, ) = spoke1.getUserDebt(debtReserveId, bob, SALT_A); + assertLt(drawnAfter, drawnBefore, 'debt liquidated on salted position'); + assertLt( + spoke1.getUserSuppliedShares(collateralReserveId, bob, SALT_A), + collateralBefore, + 'collateral seized on salted position' + ); + } + + function test_supply_fuzz(bytes32 positionSalt, uint256 amount) public { + amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); + deal(address(tokenList.dai), bob, amount); + uint256 reserveId = _daiReserveId(spoke1); + uint256 expectedShares = hub1.previewAddByAssets(daiAssetId, amount); + vm.assume(expectedShares > 0); + + vm.expectEmit(address(spoke1)); + emit ISpoke.Supply({ + reserveId: reserveId, + caller: bob, + positionId: _getPositionId(bob, positionSalt), + suppliedShares: expectedShares, + suppliedAmount: amount + }); + vm.prank(bob); + (uint256 shares, ) = spoke1.supply(reserveId, amount, bob, positionSalt); + + assertEq(shares, expectedShares); + assertEq(spoke1.getUserSuppliedShares(reserveId, bob, positionSalt), expectedShares); + } + + function test_borrow_fuzz(bytes32 positionSalt, uint256 borrowAmount) public { + borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT); + uint256 daiReserveId = _daiReserveId(spoke1); + uint256 wethSupply = _calcMinimumCollAmount( + spoke1, + _wethReserveId(spoke1), + daiReserveId, + borrowAmount + ); + vm.assume(wethSupply <= MAX_SUPPLY_AMOUNT); + + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: _wethReserveId(spoke1), + caller: bob, + amount: wethSupply, + onBehalfOf: bob, + positionSalt: positionSalt + }); + SpokeActions.supply({ + spoke: spoke1, + reserveId: daiReserveId, + caller: alice, + amount: borrowAmount, + onBehalfOf: alice + }); + + uint256 expectedShares = hub1.previewRestoreByAssets(daiAssetId, borrowAmount); + vm.expectEmit(address(spoke1)); + emit ISpoke.Borrow({ + reserveId: daiReserveId, + caller: bob, + positionId: _getPositionId(bob, positionSalt), + drawnShares: expectedShares, + drawnAmount: borrowAmount + }); + vm.prank(bob); + (uint256 shares, ) = spoke1.borrow(daiReserveId, borrowAmount, bob, positionSalt); + + assertEq(shares, expectedShares); + (uint256 drawn, ) = spoke1.getUserDebt(daiReserveId, bob, positionSalt); + assertEq(drawn, borrowAmount); + } + + function test_supply_revertsWith_Unauthorized() public { + vm.expectRevert(ISpoke.Unauthorized.selector); + vm.prank(carol); + spoke1.supply(_daiReserveId(spoke1), 100e18, bob, SALT_A); + } + + function test_supply_revertsWith_ReserveNotListed() public { + uint256 reserveId = spoke1.getReserveCount() + 1; + vm.expectRevert(ISpoke.ReserveNotListed.selector); + vm.prank(bob); + spoke1.supply(reserveId, 100e18, bob, SALT_A); + } + + function test_withdraw_revertsWith_Unauthorized() public { + vm.expectRevert(ISpoke.Unauthorized.selector); + vm.prank(carol); + spoke1.withdraw(_daiReserveId(spoke1), 100e18, bob, SALT_A); + } + + function test_borrow_revertsWith_Unauthorized() public { + vm.expectRevert(ISpoke.Unauthorized.selector); + vm.prank(carol); + spoke1.borrow(_daiReserveId(spoke1), 100e18, bob, SALT_A); + } + + function test_repay_revertsWith_Unauthorized() public { + vm.expectRevert(ISpoke.Unauthorized.selector); + vm.prank(carol); + spoke1.repay(_daiReserveId(spoke1), 100e18, bob, SALT_A); + } + + function test_setUsingAsCollateral_revertsWith_Unauthorized() public { + vm.expectRevert(ISpoke.Unauthorized.selector); + vm.prank(carol); + spoke1.setUsingAsCollateral(_daiReserveId(spoke1), true, bob, SALT_A); + } + + function _seedSaltedBorrow(address user, bytes32 salt, uint256 borrowAmount) internal { + SpokeActions.supply({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: alice, + amount: borrowAmount, + onBehalfOf: alice + }); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: _wethReserveId(spoke1), + caller: user, + amount: 10e18, + onBehalfOf: user, + positionSalt: salt + }); + SpokeActions.borrow({ + spoke: spoke1, + reserveId: _daiReserveId(spoke1), + caller: user, + amount: borrowAmount, + onBehalfOf: user, + positionSalt: salt + }); + } + + function _seedSaltedLiquidatable(address user, bytes32 salt) internal { + uint256 collateralReserveId = _usdxReserveId(spoke1); + uint256 debtReserveId = _daiReserveId(spoke1); + _updateMaxLiquidationBonus(spoke1, collateralReserveId, 105_00); + _updateLiquidationFee(spoke1, collateralReserveId, 10_00); + + SpokeActions.supply({ + spoke: spoke1, + reserveId: debtReserveId, + caller: alice, + amount: 1_000_000e18, + onBehalfOf: alice + }); + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: collateralReserveId, + caller: user, + amount: 1_000_000e6, + onBehalfOf: user, + positionSalt: salt + }); + SpokeActions.borrow({ + spoke: spoke1, + reserveId: debtReserveId, + caller: user, + amount: 500_000e18, + onBehalfOf: user, + positionSalt: salt + }); + + _mockReservePriceByPercent(spoke1, collateralReserveId, 50_00); + assertLt( + spoke1.getUserAccountData(user, salt).healthFactor, + HEALTH_FACTOR_LIQUIDATION_THRESHOLD + ); + } +} diff --git a/tests/contracts/spoke/repay/Spoke.Repay.EdgeCases.t.sol b/tests/contracts/spoke/repay/Spoke.Repay.EdgeCases.t.sol index 7d7bf4777..ccb5007c1 100644 --- a/tests/contracts/spoke/repay/Spoke.Repay.EdgeCases.t.sol +++ b/tests/contracts/spoke/repay/Spoke.Repay.EdgeCases.t.sol @@ -72,7 +72,7 @@ contract SpokeRepayEdgeCaseTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), 0, baseRestored + premiumRestored, expectedPremiumDelta @@ -428,7 +428,14 @@ contract SpokeRepayEdgeCaseTest is Base { vm.expectEmit(address(spoke1)); // 0 drawn shares restored - emit ISpoke.Repay(_daiReserveId(spoke1), bob, bob, 0, repayAmount, expectedPremiumDelta); + emit ISpoke.Repay( + _daiReserveId(spoke1), + bob, + _getPositionId(bob), + 0, + repayAmount, + expectedPremiumDelta + ); CheckedRepayResult memory r = _checkedRepay( CheckedRepayParams({ @@ -561,7 +568,7 @@ contract SpokeRepayEdgeCaseTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseRestored), daiRepayAmount, expectedPremiumDelta @@ -666,7 +673,7 @@ contract SpokeRepayEdgeCaseTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, daiRepayAmount), daiRepayAmount, expectedPremiumDelta diff --git a/tests/contracts/spoke/repay/Spoke.Repay.Scenario.t.sol b/tests/contracts/spoke/repay/Spoke.Repay.Scenario.t.sol index 0f32b22bc..5770c39a1 100644 --- a/tests/contracts/spoke/repay/Spoke.Repay.Scenario.t.sol +++ b/tests/contracts/spoke/repay/Spoke.Repay.Scenario.t.sol @@ -1193,7 +1193,7 @@ contract SpokeRepayScenarioTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseRestored), action1.repayAmount, expectedPremiumDelta @@ -1305,7 +1305,7 @@ contract SpokeRepayScenarioTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseRestored), action2.repayAmount, expectedPremiumDelta @@ -1416,7 +1416,7 @@ contract SpokeRepayScenarioTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseRestored), baseRestored + premiumRestored, expectedPremiumDelta @@ -1482,7 +1482,7 @@ contract SpokeRepayScenarioTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseRestored), drawnDebt + premiumDebt, expectedPremiumDelta diff --git a/tests/contracts/spoke/repay/Spoke.Repay.t.sol b/tests/contracts/spoke/repay/Spoke.Repay.t.sol index 2be881a42..d0a20da7f 100644 --- a/tests/contracts/spoke/repay/Spoke.Repay.t.sol +++ b/tests/contracts/spoke/repay/Spoke.Repay.t.sol @@ -123,7 +123,7 @@ contract SpokeRepayTest is Base { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke1), - ISpoke.repay.selector + bytes4(keccak256('repay(uint256,uint256,address)')) ); vm.mockFunction( address(_hub(spoke1, _daiReserveId(spoke1))), @@ -211,7 +211,7 @@ contract SpokeRepayTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), expectedShares, daiRepayAmount, expectedPremiumDelta @@ -376,7 +376,7 @@ contract SpokeRepayTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseRestored), daiRepayAmount, expectedPremiumDelta @@ -491,7 +491,7 @@ contract SpokeRepayTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), expectedShares, daiRepayAmount, expectedPremiumDelta @@ -598,7 +598,14 @@ contract SpokeRepayTest is Base { ); vm.expectEmit(address(spoke1)); - emit ISpoke.Repay(_daiReserveId(spoke1), bob, bob, 0, daiRepayAmount, expectedPremiumDelta); + emit ISpoke.Repay( + _daiReserveId(spoke1), + bob, + _getPositionId(bob), + 0, + daiRepayAmount, + expectedPremiumDelta + ); _assertRefreshPremiumNotCalled(hub1); CheckedRepayResult memory r = _checkedRepay( @@ -701,7 +708,7 @@ contract SpokeRepayTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), expectedShares, fullDebt, expectedPremiumDelta @@ -820,7 +827,7 @@ contract SpokeRepayTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), expectedShares, daiRepayAmount, expectedPremiumDelta @@ -946,7 +953,7 @@ contract SpokeRepayTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseRestored), daiRepayAmount, expectedPremiumDelta @@ -1094,7 +1101,7 @@ contract SpokeRepayTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseRestored), daiRepayAmount, expectedPremiumDelta @@ -1224,7 +1231,14 @@ contract SpokeRepayTest is Base { daiRepayAmount ); vm.expectEmit(address(spoke1)); - emit ISpoke.Repay(_daiReserveId(spoke1), bob, bob, 0, daiRepayAmount, expectedPremiumDelta); + emit ISpoke.Repay( + _daiReserveId(spoke1), + bob, + _getPositionId(bob), + 0, + daiRepayAmount, + expectedPremiumDelta + ); } _assertRefreshPremiumNotCalled(hub1); @@ -1377,7 +1391,7 @@ contract SpokeRepayTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseRestored), daiRepayAmount, expectedPremiumDelta @@ -1525,7 +1539,7 @@ contract SpokeRepayTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseRestored), daiRepayAmount, expectedPremiumDelta @@ -2000,7 +2014,7 @@ contract SpokeRepayTest is Base { emit ISpoke.Repay( _daiReserveId(spoke1), bob, - bob, + _getPositionId(bob), hub1.previewRestoreByAssets(daiAssetId, baseDebt), baseDebt + premiumDebt, expectedPremiumDelta diff --git a/tests/contracts/spoke/risk-premium/Spoke.UpdateUserRiskPremium.t.sol b/tests/contracts/spoke/risk-premium/Spoke.UpdateUserRiskPremium.t.sol index c831eea9d..e4ff26d0c 100644 --- a/tests/contracts/spoke/risk-premium/Spoke.UpdateUserRiskPremium.t.sol +++ b/tests/contracts/spoke/risk-premium/Spoke.UpdateUserRiskPremium.t.sol @@ -23,7 +23,7 @@ contract SpokeUpdateUserRiskPremiumTest is Base { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke1), - ISpoke.updateUserRiskPremium.selector + bytes4(keccak256('updateUserRiskPremium(address)')) ); // reentrant hub.refreshPremium call diff --git a/tests/contracts/spoke/supply/Spoke.Supply.t.sol b/tests/contracts/spoke/supply/Spoke.Supply.t.sol index 81e4718d9..c644bf8dc 100644 --- a/tests/contracts/spoke/supply/Spoke.Supply.t.sol +++ b/tests/contracts/spoke/supply/Spoke.Supply.t.sol @@ -89,7 +89,7 @@ contract SpokeSupplyTest is Base { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke1), - ISpoke.supply.selector + bytes4(keccak256('supply(uint256,uint256,address)')) ); vm.mockFunction( @@ -115,7 +115,7 @@ contract SpokeSupplyTest is Base { emit ISpoke.Supply({ reserveId: _daiReserveId(spoke1), caller: bob, - user: bob, + positionId: _getPositionId(bob), suppliedShares: expectedShares, suppliedAmount: amount }); @@ -200,7 +200,7 @@ contract SpokeSupplyTest is Base { emit ISpoke.Supply({ reserveId: _daiReserveId(spoke1), caller: bob, - user: bob, + positionId: _getPositionId(bob), suppliedShares: expectedShares, suppliedAmount: amount }); @@ -286,7 +286,7 @@ contract SpokeSupplyTest is Base { emit ISpoke.Supply({ reserveId: _daiReserveId(spoke1), caller: carol, - user: carol, + positionId: _getPositionId(carol), suppliedShares: expectedShares, suppliedAmount: amount }); @@ -413,7 +413,7 @@ contract SpokeSupplyTest is Base { emit ISpoke.Supply({ reserveId: reserveId, caller: carol, - user: carol, + positionId: _getPositionId(carol), suppliedShares: state.expectedShares, suppliedAmount: amount }); @@ -490,7 +490,7 @@ contract SpokeSupplyTest is Base { emit ISpoke.Supply({ reserveId: _daiReserveId(spoke1), caller: carol, - user: carol, + positionId: _getPositionId(carol), suppliedShares: expectedShares, suppliedAmount: amount }); @@ -604,7 +604,7 @@ contract SpokeSupplyTest is Base { emit ISpoke.Supply({ reserveId: reserveId, caller: carol, - user: carol, + positionId: _getPositionId(carol), suppliedShares: expectedShares, suppliedAmount: amount }); diff --git a/tests/contracts/spoke/withdraw/Spoke.Withdraw.Scenario.t.sol b/tests/contracts/spoke/withdraw/Spoke.Withdraw.Scenario.t.sol index 36caba91c..b5253ae07 100644 --- a/tests/contracts/spoke/withdraw/Spoke.Withdraw.Scenario.t.sol +++ b/tests/contracts/spoke/withdraw/Spoke.Withdraw.Scenario.t.sol @@ -421,7 +421,7 @@ contract SpokeWithdrawScenarioTest is Base { emit ISpoke.Supply({ reserveId: reserveId, caller: caller, - user: caller, + positionId: _getPositionId(caller), suppliedShares: shares1, suppliedAmount: assets }); @@ -435,7 +435,7 @@ contract SpokeWithdrawScenarioTest is Base { emit ISpoke.Withdraw({ reserveId: reserveId, caller: caller, - user: caller, + positionId: _getPositionId(caller), withdrawnShares: shares2, withdrawnAmount: assets }); @@ -503,7 +503,7 @@ contract SpokeWithdrawScenarioTest is Base { emit ISpoke.Withdraw({ reserveId: reserveId, caller: caller, - user: caller, + positionId: _getPositionId(caller), withdrawnShares: shares1, withdrawnAmount: assets }); @@ -517,7 +517,7 @@ contract SpokeWithdrawScenarioTest is Base { emit ISpoke.Supply({ reserveId: reserveId, caller: caller, - user: caller, + positionId: _getPositionId(caller), suppliedShares: shares2, suppliedAmount: assets }); diff --git a/tests/contracts/spoke/withdraw/Spoke.Withdraw.t.sol b/tests/contracts/spoke/withdraw/Spoke.Withdraw.t.sol index 4bc8ee5d1..0335fab74 100644 --- a/tests/contracts/spoke/withdraw/Spoke.Withdraw.t.sol +++ b/tests/contracts/spoke/withdraw/Spoke.Withdraw.t.sol @@ -46,7 +46,7 @@ contract SpokeWithdrawTest is Base { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke1), - ISpoke.withdraw.selector + bytes4(keccak256('withdraw(uint256,uint256,address)')) ); vm.mockFunction( @@ -78,7 +78,7 @@ contract SpokeWithdrawTest is Base { MockReentrantCaller reentrantCaller = new MockReentrantCaller( address(spoke1), - ISpoke.withdraw.selector + bytes4(keccak256('withdraw(uint256,uint256,address)')) ); vm.mockFunction( @@ -123,7 +123,13 @@ contract SpokeWithdrawTest is Base { // Bob withdraws immediately in the same block vm.expectEmit(address(spoke1)); - emit ISpoke.Withdraw(_daiReserveId(spoke1), bob, bob, expectedSupplyShares, amount); + emit ISpoke.Withdraw( + _daiReserveId(spoke1), + bob, + _getPositionId(bob), + expectedSupplyShares, + amount + ); CheckedWithdrawResult memory r = _checkedWithdraw( CheckedWithdrawParams({ spoke: spoke1, diff --git a/tests/deployments/procedures/deploy/roles/AaveV4SpokeRolesProcedure.t.sol b/tests/deployments/procedures/deploy/roles/AaveV4SpokeRolesProcedure.t.sol index 50e32dd84..c474eb73c 100644 --- a/tests/deployments/procedures/deploy/roles/AaveV4SpokeRolesProcedure.t.sol +++ b/tests/deployments/procedures/deploy/roles/AaveV4SpokeRolesProcedure.t.sol @@ -103,7 +103,7 @@ contract AaveV4SpokeRolesProcedureTest is ProceduresBase { assertEq( IAccessManager(accessManager).getTargetFunctionRole( spoke, - ISpoke.updateUserDynamicConfig.selector + bytes4(keccak256('updateUserDynamicConfig(address)')) ), Roles.SPOKE_USER_POSITION_UPDATER_ROLE ); @@ -121,9 +121,11 @@ contract AaveV4SpokeRolesProcedureTest is ProceduresBase { function test_getSpokePositionUpdaterRoleSelectors() public view { bytes4[] memory selectors = aaveV4SpokeRolesProcedureWrapper .getSpokePositionUpdaterRoleSelectors(); - assertEq(selectors.length, 2); - assertEq(selectors[0], ISpoke.updateUserDynamicConfig.selector); - assertEq(selectors[1], ISpoke.updateUserRiskPremium.selector); + assertEq(selectors.length, 4); + assertEq(selectors[0], bytes4(keccak256('updateUserDynamicConfig(address)'))); + assertEq(selectors[1], bytes4(keccak256('updateUserDynamicConfig(address,bytes32)'))); + assertEq(selectors[2], bytes4(keccak256('updateUserRiskPremium(address)'))); + assertEq(selectors[3], bytes4(keccak256('updateUserRiskPremium(address,bytes32)'))); } function test_getSpokeConfiguratorRoleSelectors() public view { diff --git a/tests/fork/AaveV4MainnetAddresses.sol b/tests/fork/AaveV4MainnetAddresses.sol new file mode 100644 index 000000000..bcb65a710 --- /dev/null +++ b/tests/fork/AaveV4MainnetAddresses.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title AaveV4MainnetAddresses +/// @notice Ethereum mainnet addresses of the live Aave V4 deployment. +/// @dev Copied from `aave-dao/aave-address-book` (`src/AaveV4Ethereum.sol`) to avoid pulling the whole +/// registry as a dependency. Only the values exercised by the fork suite are mirrored here. +/// `TreasurySpoke` / `TokenizationSpoke` are intentionally omitted: they are different +/// implementations from `Spoke.sol` and are out of scope for this upgrade. +library AaveV4MainnetAddresses { + address internal constant ACCESS_MANAGER = 0x08aE3BE30958cDd1847ec58fFfd4C451a87fDF01; + + // AaveV4EthereumHubs + address internal constant CORE_HUB = 0xCca852Bc40e560adC3b1Cc58CA5b55638ce826c9; + address internal constant PLUS_HUB = 0x06002e9c4412CB7814a791eA3666D905871E536A; + address internal constant PRIME_HUB = 0x943827DCA022D0F354a8a8c332dA1e5Eb9f9F931; + + // AaveV4EthereumSpokes (generic `SpokeInstance`-based spokes only) + address internal constant MAIN_SPOKE = 0x94e7A5dCbE816e498b89aB752661904E2F56c485; + address internal constant BLUECHIP_SPOKE = 0x973a023A77420ba610f06b3858aD991Df6d85A08; +} diff --git a/tests/fork/ForkBase.t.sol b/tests/fork/ForkBase.t.sol new file mode 100644 index 000000000..06bd6bcfc --- /dev/null +++ b/tests/fork/ForkBase.t.sol @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from 'forge-std/Test.sol'; + +import {IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; +import {ITransparentUpgradeableProxy} from 'src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {IAaveOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; +import {IPriceFeed} from 'src/spoke/interfaces/IPriceFeed.sol'; +import {SpokeInstance} from 'src/spoke/instances/SpokeInstance.sol'; + +import {ProxyHelper} from 'tests/utils/ProxyHelper.sol'; +import {AaveV4MainnetAddresses} from 'tests/fork/AaveV4MainnetAddresses.sol'; + +/// @title ForkBase +/// @notice Base harness for Ethereum-mainnet fork tests that upgrade a live Aave V4 Spoke to the +/// current branch implementation and verify storage/behavior are preserved. +/// @dev Tests self-skip when no mainnet RPC is configured, so the default `forge test` run is unaffected. +/// Run with `RPC_MAINNET` set (see `make test-fork`). Pin a block via the `FORK_BLOCK` env var. +abstract contract ForkBase is Test { + /// @dev Snapshot of a single (user, reserve) position. + struct PositionSnapshot { + uint256 suppliedShares; + uint256 suppliedAssets; + uint256 drawnDebt; + uint256 premiumDebt; + uint256 totalDebt; + uint256 premiumDebtRay; + bool usingAsCollateral; + bool borrowing; + } + + /// @dev Snapshot of a user's account-level data. + struct AccountSnapshot { + uint256 riskPremium; + uint256 avgCollateralFactor; + uint256 healthFactor; + uint256 totalCollateralValue; + uint256 totalDebtValueRay; + uint256 activeCollateralCount; + uint256 borrowCount; + uint256 lastRiskPremium; + } + + /// @dev Snapshot of reserve-level aggregates. + struct ReserveSnapshot { + uint256 suppliedShares; + uint256 suppliedAssets; + uint256 drawnDebt; + uint256 premiumDebt; + uint256 totalDebt; + } + + bool internal forkReady; + ISpoke internal spoke; + address internal oracle; + uint16 internal maxUserReservesLimit; + address internal proxyAdmin; + address internal oldImplementation; + + /// @dev Spoke targeted by the suite. Override `_target()` to point at a different live spoke. + function _target() internal pure virtual returns (address) { + return AaveV4MainnetAddresses.MAIN_SPOKE; + } + + function setUp() public virtual { + forkReady = _createFork(); + if (!forkReady) return; + + address target = _target(); + if (target.code.length == 0) { + // The forked block predates the deployment (or the address is wrong): skip rather than fail. + forkReady = false; + return; + } + _useSpoke(target); + } + + /// @dev Guards every fork test: marks it skipped (not failed) when no fork is available. + modifier onFork() { + if (!forkReady) { + vm.skip(true); + return; + } + _; + } + + function _createFork() private returns (bool) { + uint256 forkBlock = vm.envOr('FORK_BLOCK', uint256(0)); + if (forkBlock == 0) { + try vm.createSelectFork('mainnet') returns (uint256) { + return true; + } catch { + return false; + } + } + try vm.createSelectFork('mainnet', forkBlock) returns (uint256) { + return true; + } catch { + return false; + } + } + + function _useSpoke(address target) internal { + spoke = ISpoke(target); + oracle = spoke.ORACLE(); + maxUserReservesLimit = spoke.MAX_USER_RESERVES_LIMIT(); + proxyAdmin = ProxyHelper.getProxyAdmin(target); + oldImplementation = ProxyHelper.getImplementation(target); + vm.label(target, 'Spoke'); + vm.label(oracle, 'AaveOracle'); + } + + /// @dev Deploys the branch implementation (immutables copied from the live proxy) and upgrades the + /// proxy to it. Empty calldata performs a pure implementation swap with no reinitialization, + /// which is the strongest test of storage preservation. + function _upgradeSpoke() internal returns (address newImplementation) { + newImplementation = address(new SpokeInstance(oracle, maxUserReservesLimit)); + vm.prank(proxyAdmin); + ITransparentUpgradeableProxy(address(spoke)).upgradeToAndCall(newImplementation, ''); + assertEq( + ProxyHelper.getImplementation(address(spoke)), + newImplementation, + 'implementation not updated' + ); + } + + /// @dev Finds a reserve usable as collateral (collateral factor > 0) and a borrowable reserve, both + /// active and distinct when possible. Reverts if the live spoke has no suitable reserves. + function _collateralAndBorrowReserves() + internal + view + returns (uint256 collateralReserveId, uint256 borrowReserveId) + { + uint256 count = spoke.getReserveCount(); + + bool foundCollateral; + for (uint256 i = 0; i < count; i++) { + ISpoke.ReserveConfig memory cfg = spoke.getReserveConfig(i); + if (cfg.paused || cfg.frozen) continue; + ISpoke.Reserve memory r = spoke.getReserve(i); + if (spoke.getDynamicReserveConfig(i, r.dynamicConfigKey).collateralFactor > 0) { + collateralReserveId = i; + foundCollateral = true; + break; + } + } + require(foundCollateral, 'no collateral reserve on live spoke'); + + bool foundBorrow; + for (uint256 i = 0; i < count; i++) { + ISpoke.ReserveConfig memory cfg = spoke.getReserveConfig(i); + if (cfg.paused || cfg.frozen || !cfg.borrowable) continue; + borrowReserveId = i; + foundBorrow = true; + if (i != collateralReserveId) break; // prefer a reserve distinct from the collateral + } + require(foundBorrow, 'no borrowable reserve on live spoke'); + } + + /// @dev A modest amount expressed in the reserve's underlying decimals. + function _unit(uint256 reserveId) internal view returns (uint256) { + return 10 ** spoke.getReserve(reserveId).decimals; + } + + /// @dev Funds `user`, approves the spoke, and supplies `amount` of `reserveId` (default salt). + function _seedSupply(address user, uint256 reserveId, uint256 amount) internal { + address underlying = spoke.getReserve(reserveId).underlying; + deal(underlying, user, amount); + vm.startPrank(user); + IERC20(underlying).approve(address(spoke), amount); + spoke.supply(reserveId, amount, user); + vm.stopPrank(); + } + + /// @dev Mocks `reserveId`'s price feed down to `numerator/denominator` of its current value, e.g. to + /// push a borrower below the liquidation threshold on the fork. + function _dropReservePrice(uint256 reserveId, uint256 numerator, uint256 denominator) internal { + address source = IAaveOracle(oracle).getReserveSource(reserveId); + int256 current = IPriceFeed(source).latestAnswer(); + int256 dropped = (current * int256(numerator)) / int256(denominator); + vm.mockCall( + source, + abi.encodeWithSelector(IPriceFeed.latestAnswer.selector), + abi.encode(dropped) + ); + } + + function _snapshotPosition( + address user, + uint256 reserveId + ) internal view returns (PositionSnapshot memory s) { + s.suppliedShares = spoke.getUserSuppliedShares(reserveId, user); + s.suppliedAssets = spoke.getUserSuppliedAssets(reserveId, user); + (s.drawnDebt, s.premiumDebt) = spoke.getUserDebt(reserveId, user); + s.totalDebt = spoke.getUserTotalDebt(reserveId, user); + s.premiumDebtRay = spoke.getUserPremiumDebtRay(reserveId, user); + (s.usingAsCollateral, s.borrowing) = spoke.getUserReserveStatus(reserveId, user); + } + + function _snapshotAccount(address user) internal view returns (AccountSnapshot memory s) { + ISpoke.UserAccountData memory d = spoke.getUserAccountData(user); + s.riskPremium = d.riskPremium; + s.avgCollateralFactor = d.avgCollateralFactor; + s.healthFactor = d.healthFactor; + s.totalCollateralValue = d.totalCollateralValue; + s.totalDebtValueRay = d.totalDebtValueRay; + s.activeCollateralCount = d.activeCollateralCount; + s.borrowCount = d.borrowCount; + s.lastRiskPremium = spoke.getUserLastRiskPremium(user); + } + + function _snapshotReserve(uint256 reserveId) internal view returns (ReserveSnapshot memory s) { + s.suppliedShares = spoke.getReserveSuppliedShares(reserveId); + s.suppliedAssets = spoke.getReserveSuppliedAssets(reserveId); + (s.drawnDebt, s.premiumDebt) = spoke.getReserveDebt(reserveId); + s.totalDebt = spoke.getReserveTotalDebt(reserveId); + } + + function _assertPositionPreserved( + PositionSnapshot memory pre, + PositionSnapshot memory post, + string memory ctx + ) internal pure { + assertEq(post.suppliedShares, pre.suppliedShares, string.concat(ctx, ': suppliedShares')); + assertEq(post.suppliedAssets, pre.suppliedAssets, string.concat(ctx, ': suppliedAssets')); + assertEq(post.drawnDebt, pre.drawnDebt, string.concat(ctx, ': drawnDebt')); + assertEq(post.premiumDebt, pre.premiumDebt, string.concat(ctx, ': premiumDebt')); + assertEq(post.totalDebt, pre.totalDebt, string.concat(ctx, ': totalDebt')); + assertEq(post.premiumDebtRay, pre.premiumDebtRay, string.concat(ctx, ': premiumDebtRay')); + assertEq( + post.usingAsCollateral, + pre.usingAsCollateral, + string.concat(ctx, ': usingAsCollateral') + ); + assertEq(post.borrowing, pre.borrowing, string.concat(ctx, ': borrowing')); + } + + function _assertAccountPreserved( + AccountSnapshot memory pre, + AccountSnapshot memory post, + string memory ctx + ) internal pure { + assertEq(post.riskPremium, pre.riskPremium, string.concat(ctx, ': riskPremium')); + assertEq(post.avgCollateralFactor, pre.avgCollateralFactor, string.concat(ctx, ': avgCF')); + assertEq(post.healthFactor, pre.healthFactor, string.concat(ctx, ': healthFactor')); + assertEq( + post.totalCollateralValue, + pre.totalCollateralValue, + string.concat(ctx, ': collateral') + ); + assertEq(post.totalDebtValueRay, pre.totalDebtValueRay, string.concat(ctx, ': debtValue')); + assertEq( + post.activeCollateralCount, + pre.activeCollateralCount, + string.concat(ctx, ': activeColl') + ); + assertEq(post.borrowCount, pre.borrowCount, string.concat(ctx, ': borrowCount')); + assertEq(post.lastRiskPremium, pre.lastRiskPremium, string.concat(ctx, ': lastRiskPremium')); + } + + function _assertReservePreserved( + ReserveSnapshot memory pre, + ReserveSnapshot memory post, + string memory ctx + ) internal pure { + assertEq(post.suppliedShares, pre.suppliedShares, string.concat(ctx, ': res suppliedShares')); + assertEq(post.suppliedAssets, pre.suppliedAssets, string.concat(ctx, ': res suppliedAssets')); + assertEq(post.drawnDebt, pre.drawnDebt, string.concat(ctx, ': res drawnDebt')); + assertEq(post.premiumDebt, pre.premiumDebt, string.concat(ctx, ': res premiumDebt')); + assertEq(post.totalDebt, pre.totalDebt, string.concat(ctx, ': res totalDebt')); + } +} diff --git a/tests/fork/Spoke.Upgrade.Integration.fork.t.sol b/tests/fork/Spoke.Upgrade.Integration.fork.t.sol new file mode 100644 index 000000000..f614ebee2 --- /dev/null +++ b/tests/fork/Spoke.Upgrade.Integration.fork.t.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; +import {ForkBase} from 'tests/fork/ForkBase.t.sol'; + +/// @notice Full-lifecycle integration tests run against a live mainnet Spoke AFTER it has been upgraded +/// to the position-nonces implementation: pre-existing positions keep working, and the new +/// salt-based positions behave as isolated sub-accounts. +contract SpokeUpgradeIntegrationForkTest is ForkBase { + address internal alice = makeAddr('alice'); + address internal bob = makeAddr('bob'); + address internal dave = makeAddr('dave'); + address internal liquidator = makeAddr('liquidator'); + + /// @dev Supplies collateral, enables it, and borrows a small amount for `user`. + function _seedBorrower( + address user, + uint256 collId, + uint256 borrowId + ) internal returns (uint256 collAmount, uint256 borrowAmount) { + collAmount = 1000 * _unit(collId); + borrowAmount = _unit(borrowId); + _seedSupply(user, collId, collAmount); + vm.prank(user); + spoke.setUsingAsCollateral(collId, true, user); + vm.prank(user); + spoke.borrow(borrowId, borrowAmount, user); + } + + function test_withdrawAfterUpgrade() public onFork { + (uint256 reserveId, ) = _collateralAndBorrowReserves(); + _seedSupply(alice, reserveId, 1000 * _unit(reserveId)); + _upgradeSpoke(); + + address underlying = spoke.getReserve(reserveId).underlying; + uint256 sharesBefore = spoke.getUserSuppliedShares(reserveId, alice); + uint256 balanceBefore = IERC20(underlying).balanceOf(alice); + uint256 withdrawAmount = spoke.getUserSuppliedAssets(reserveId, alice) / 2; + + vm.prank(alice); + spoke.withdraw(reserveId, withdrawAmount, alice); + + assertLt(spoke.getUserSuppliedShares(reserveId, alice), sharesBefore, 'shares not reduced'); + assertGt(IERC20(underlying).balanceOf(alice), balanceBefore, 'no assets received'); + } + + function test_repayAfterUpgrade() public onFork { + (uint256 collId, uint256 borrowId) = _collateralAndBorrowReserves(); + _seedBorrower(bob, collId, borrowId); + _upgradeSpoke(); + + uint256 debtBefore = spoke.getUserTotalDebt(borrowId, bob); + assertGt(debtBefore, 0, 'precondition: bob has debt'); + + address debtAsset = spoke.getReserve(borrowId).underlying; + deal(debtAsset, bob, debtBefore * 2); + vm.startPrank(bob); + IERC20(debtAsset).approve(address(spoke), type(uint256).max); + spoke.repay(borrowId, debtBefore * 2, bob); + vm.stopPrank(); + + assertEq(spoke.getUserTotalDebt(borrowId, bob), 0, 'debt not fully repaid'); + } + + function test_borrowMoreAfterUpgrade() public onFork { + (uint256 collId, uint256 borrowId) = _collateralAndBorrowReserves(); + _seedBorrower(bob, collId, borrowId); + _upgradeSpoke(); + + uint256 drawnBefore = spoke.getUserPosition(borrowId, bob).drawnShares; + uint256 borrowAmount = _unit(borrowId); // precompute: an arg-call would consume the prank below + vm.prank(bob); + spoke.borrow(borrowId, borrowAmount, bob); + + assertGt(spoke.getUserPosition(borrowId, bob).drawnShares, drawnBefore, 'debt not increased'); + } + + function test_toggleCollateralAfterUpgrade() public onFork { + (uint256 reserveId, ) = _collateralAndBorrowReserves(); + _seedSupply(alice, reserveId, 1000 * _unit(reserveId)); + _upgradeSpoke(); + + vm.prank(alice); + spoke.setUsingAsCollateral(reserveId, true, alice); + (bool enabled, ) = spoke.getUserReserveStatus(reserveId, alice); + assertTrue(enabled, 'collateral not enabled'); + + vm.prank(alice); + spoke.setUsingAsCollateral(reserveId, false, alice); + (enabled, ) = spoke.getUserReserveStatus(reserveId, alice); + assertFalse(enabled, 'collateral not disabled'); + } + + /// @dev A non-default salt opens an isolated sub-account that does not touch the default position. + function test_saltedPositionIsIsolated() public onFork { + (uint256 reserveId, ) = _collateralAndBorrowReserves(); + uint256 amount = 1000 * _unit(reserveId); + _seedSupply(alice, reserveId, amount); // default position + _upgradeSpoke(); + + uint256 defaultSharesBefore = spoke.getUserSuppliedShares(reserveId, alice); + assertGt(defaultSharesBefore, 0, 'precondition: default position'); + + bytes32 salt = keccak256('position-2'); + address underlying = spoke.getReserve(reserveId).underlying; + deal(underlying, alice, amount); + vm.startPrank(alice); + IERC20(underlying).approve(address(spoke), amount); + spoke.supply(reserveId, amount, alice, salt); + vm.stopPrank(); + + assertEq( + spoke.getUserSuppliedShares(reserveId, alice), + defaultSharesBefore, + 'default position affected by salted supply' + ); + assertGt(spoke.getUserSuppliedShares(reserveId, alice, salt), 0, 'salted position not created'); + } + + function test_multicallAfterUpgrade() public onFork { + (uint256 reserveId, ) = _collateralAndBorrowReserves(); + _upgradeSpoke(); + + uint256 amount = 1000 * _unit(reserveId); + address underlying = spoke.getReserve(reserveId).underlying; + deal(underlying, alice, amount); + + // `supply` and `setUsingAsCollateral` are overloaded (salt variants), so encode by signature. + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSignature('supply(uint256,uint256,address)', reserveId, amount, alice); + calls[1] = abi.encodeWithSignature( + 'setUsingAsCollateral(uint256,bool,address)', + reserveId, + true, + alice + ); + + vm.startPrank(alice); + IERC20(underlying).approve(address(spoke), amount); + spoke.multicall(calls); + vm.stopPrank(); + + assertGt(spoke.getUserSuppliedShares(reserveId, alice), 0, 'multicall supply failed'); + (bool enabled, ) = spoke.getUserReserveStatus(reserveId, alice); + assertTrue(enabled, 'multicall collateral toggle failed'); + } + + function test_liquidationAfterUpgrade() public onFork { + (uint256 collId, uint256 borrowId) = _collateralAndBorrowReserves(); + _seedBorrower(dave, collId, borrowId); + _upgradeSpoke(); + + // Crash the collateral price far enough below the current health factor to force liquidation. + uint256 hf0 = spoke.getUserAccountData(dave).healthFactor; + uint256 denominator = (hf0 / 1e18 + 2) * 10; + _dropReservePrice(collId, 1, denominator); + assertLt( + spoke.getUserAccountData(dave).healthFactor, + 1e18, + 'precondition: position not liquidatable' + ); + + uint256 debtBefore = spoke.getUserTotalDebt(borrowId, dave); + uint256 collSharesBefore = spoke.getUserSuppliedShares(collId, dave); + + address debtAsset = spoke.getReserve(borrowId).underlying; + deal(debtAsset, liquidator, debtBefore * 2); + vm.startPrank(liquidator); + IERC20(debtAsset).approve(address(spoke), type(uint256).max); + spoke.liquidationCall(collId, borrowId, dave, type(uint256).max, false); + vm.stopPrank(); + + // The collateral is worthless after the crash, so it is fully seized. + assertLt(spoke.getUserSuppliedShares(collId, dave), collSharesBefore, 'collateral not seized'); + } +} diff --git a/tests/fork/Spoke.Upgrade.PositionPreservation.fork.t.sol b/tests/fork/Spoke.Upgrade.PositionPreservation.fork.t.sol new file mode 100644 index 000000000..8c8a6c947 --- /dev/null +++ b/tests/fork/Spoke.Upgrade.PositionPreservation.fork.t.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {ForkBase} from 'tests/fork/ForkBase.t.sol'; + +/// @notice Proves that upgrading a live mainnet Spoke to the position-nonces implementation preserves +/// existing storage and user positions byte-for-byte. The default-salt position must keep +/// resolving to the legacy address-keyed storage slot (see `_getPositionIdentifier`). +contract SpokeUpgradePositionPreservationForkTest is ForkBase { + address internal alice = makeAddr('alice'); + address internal bob = makeAddr('bob'); + address internal carol = makeAddr('carol'); + + function test_supplierPositionPreserved() public onFork { + (uint256 reserveId, ) = _collateralAndBorrowReserves(); + _seedSupply(alice, reserveId, 1000 * _unit(reserveId)); + + PositionSnapshot memory prePos = _snapshotPosition(alice, reserveId); + AccountSnapshot memory preAcc = _snapshotAccount(alice); + assertGt(prePos.suppliedShares, 0, 'precondition: alice supplied'); + + _upgradeSpoke(); + + _assertPositionPreserved(prePos, _snapshotPosition(alice, reserveId), 'alice'); + _assertAccountPreserved(preAcc, _snapshotAccount(alice), 'alice'); + } + + function test_borrowerPositionPreserved() public onFork { + (uint256 collId, uint256 borrowId) = _collateralAndBorrowReserves(); + uint256 borrowAmount = _unit(borrowId); // precompute: an arg-call would consume the prank below + + _seedSupply(bob, collId, 1000 * _unit(collId)); + vm.prank(bob); + spoke.setUsingAsCollateral(collId, true, bob); + vm.prank(bob); + spoke.borrow(borrowId, borrowAmount, bob); + + PositionSnapshot memory preColl = _snapshotPosition(bob, collId); + PositionSnapshot memory preDebt = _snapshotPosition(bob, borrowId); + AccountSnapshot memory preAcc = _snapshotAccount(bob); + assertTrue(preColl.usingAsCollateral, 'precondition: collateral enabled'); + assertGt(preDebt.totalDebt, 0, 'precondition: bob borrowed'); + + _upgradeSpoke(); + + _assertPositionPreserved(preColl, _snapshotPosition(bob, collId), 'bob collateral'); + _assertPositionPreserved(preDebt, _snapshotPosition(bob, borrowId), 'bob debt'); + _assertAccountPreserved(preAcc, _snapshotAccount(bob), 'bob account'); + } + + /// @dev The regression guard: without the `_getPositionIdentifier` fix, the default position would + /// be looked up under `keccak256(user, 0)` after the upgrade and read as empty. + function test_defaultSaltMapsToLegacyAddressSlot() public onFork { + (uint256 reserveId, ) = _collateralAndBorrowReserves(); + _seedSupply(carol, reserveId, 1000 * _unit(reserveId)); + + uint256 preShares = spoke.getUserSuppliedShares(reserveId, carol); + assertGt(preShares, 0, 'precondition: carol supplied'); + + _upgradeSpoke(); + + assertEq( + spoke.getUserSuppliedShares(reserveId, carol), + preShares, + 'default position orphaned by upgrade' + ); + assertEq( + spoke.getUserSuppliedShares(reserveId, carol, bytes32(0)), + preShares, + 'default getter disagrees with explicit default salt' + ); + } + + function test_reserveAggregatesAndImmutablesPreserved() public onFork { + uint256 count = spoke.getReserveCount(); + uint256 n = count < 12 ? count : 12; + + ReserveSnapshot[] memory pre = new ReserveSnapshot[](n); + for (uint256 i = 0; i < n; i++) { + pre[i] = _snapshotReserve(i); + } + address preOracle = spoke.ORACLE(); + uint16 preMax = spoke.MAX_USER_RESERVES_LIMIT(); + ISpoke.LiquidationConfig memory preLiq = spoke.getLiquidationConfig(); + + _upgradeSpoke(); + + for (uint256 i = 0; i < n; i++) { + _assertReservePreserved(pre[i], _snapshotReserve(i), vm.toString(i)); + } + assertEq(spoke.ORACLE(), preOracle, 'ORACLE changed'); + assertEq(spoke.MAX_USER_RESERVES_LIMIT(), preMax, 'MAX_USER_RESERVES_LIMIT changed'); + + ISpoke.LiquidationConfig memory postLiq = spoke.getLiquidationConfig(); + assertEq(postLiq.targetHealthFactor, preLiq.targetHealthFactor, 'targetHealthFactor'); + assertEq(postLiq.healthFactorForMaxBonus, preLiq.healthFactorForMaxBonus, 'hfForMaxBonus'); + assertEq(postLiq.liquidationBonusFactor, preLiq.liquidationBonusFactor, 'liqBonusFactor'); + } +} diff --git a/tests/gas/Spoke.Getters.gas.t.sol b/tests/gas/Spoke.Getters.gas.t.sol index aae8526c6..9999cbc00 100644 --- a/tests/gas/Spoke.Getters.gas.t.sol +++ b/tests/gas/Spoke.Getters.gas.t.sol @@ -71,4 +71,40 @@ contract SpokeGetters_Gas_Tests is Base { vm.snapshotGasLastCall('Spoke.Getters', 'getUserAccountData: supplies: 2, borrows: 2'); vm.stopPrank(); } + + function test_getUserAccountData_positionSalt_oneSupplies() external { + bytes32 salt = keccak256('position-salt'); + vm.startPrank(alice); + spoke1.supply(_daiReserveId(spoke1), 1000e18, alice, salt); + spoke1.setUsingAsCollateral(_daiReserveId(spoke1), true, alice, salt); + + spoke1.getUserAccountData(alice, salt); + vm.snapshotGasLastCall( + 'Spoke.Getters', + 'getUserAccountData (positionSalt): supplies: 1, borrows: 0' + ); + vm.stopPrank(); + } + + function test_getUserAccountData_positionSalt_twoSupplies_oneBorrows() external { + bytes32 salt = keccak256('position-salt'); + vm.prank(bob); + spoke1.supply(_usdxReserveId(spoke1), 1000e6, bob); + + vm.startPrank(alice); + spoke1.supply(_daiReserveId(spoke1), 1000e18, alice, salt); + spoke1.setUsingAsCollateral(_daiReserveId(spoke1), true, alice, salt); + + spoke1.supply(_wethReserveId(spoke1), 1000e18, alice, salt); + spoke1.setUsingAsCollateral(_wethReserveId(spoke1), true, alice, salt); + + spoke1.borrow(_usdxReserveId(spoke1), 800e6, alice, salt); + + spoke1.getUserAccountData(alice, salt); + vm.snapshotGasLastCall( + 'Spoke.Getters', + 'getUserAccountData (positionSalt): supplies: 2, borrows: 1' + ); + vm.stopPrank(); + } } diff --git a/tests/gas/Spoke.Operations.gas.t.sol b/tests/gas/Spoke.Operations.gas.t.sol index de6df29af..d589ad462 100644 --- a/tests/gas/Spoke.Operations.gas.t.sol +++ b/tests/gas/Spoke.Operations.gas.t.sol @@ -16,119 +16,165 @@ contract SpokeOperations_Gas_Tests is Base { _seed(); } + function _positionSalt() internal pure virtual returns (bytes32) { + return bytes32(0); + } + + function _opSupply(uint256 reserveId_, uint256 amount, address onBehalfOf) internal virtual { + spoke.supply(reserveId_, amount, onBehalfOf); + } + + function _opSetUsingAsCollateral( + uint256 reserveId_, + bool usingAsCollateral, + address onBehalfOf + ) internal virtual { + spoke.setUsingAsCollateral(reserveId_, usingAsCollateral, onBehalfOf); + } + + function _opWithdraw(uint256 reserveId_, uint256 amount, address onBehalfOf) internal virtual { + spoke.withdraw(reserveId_, amount, onBehalfOf); + } + + function _opBorrow(uint256 reserveId_, uint256 amount, address onBehalfOf) internal virtual { + spoke.borrow(reserveId_, amount, onBehalfOf); + } + + function _opRepay(uint256 reserveId_, uint256 amount, address onBehalfOf) internal virtual { + spoke.repay(reserveId_, amount, onBehalfOf); + } + + function _opLiquidationCall( + uint256 collateralReserveId, + uint256 debtReserveId, + address user, + uint256 debtToCover, + bool receiveShares + ) internal virtual { + spoke.liquidationCall(collateralReserveId, debtReserveId, user, debtToCover, receiveShares); + } + + function _opUpdateUserRiskPremium(address onBehalfOf) internal virtual { + spoke.updateUserRiskPremium(onBehalfOf); + } + + function _opUpdateUserDynamicConfig(address onBehalfOf) internal virtual { + spoke.updateUserDynamicConfig(onBehalfOf); + } + function test_supply() public { vm.startPrank(alice); - spoke.supply(reserveId.usdx, 1000e6, alice); + _opSupply(reserveId.usdx, 1000e6, alice); vm.snapshotGasLastCall(NAMESPACE, 'supply: 0 borrows, collateral disabled'); - spoke.supply(reserveId.usdx, 1000e6, alice); + _opSupply(reserveId.usdx, 1000e6, alice); vm.snapshotGasLastCall(NAMESPACE, 'supply: second action, same reserve'); - spoke.supply(reserveId.weth, 1000e18, alice); + _opSupply(reserveId.weth, 1000e18, alice); - spoke.setUsingAsCollateral(reserveId.weth, true, alice); - spoke.supply(reserveId.weth, 1e18, alice); + _opSetUsingAsCollateral(reserveId.weth, true, alice); + _opSupply(reserveId.weth, 1e18, alice); vm.snapshotGasLastCall(NAMESPACE, 'supply: 0 borrows, collateral enabled'); vm.stopPrank(); } function test_usingAsCollateral() public { vm.prank(bob); - spoke.supply(reserveId.dai, 1000e18, bob); + _opSupply(reserveId.dai, 1000e18, bob); vm.startPrank(alice); - spoke.setUsingAsCollateral(reserveId.usdx, true, alice); + _opSetUsingAsCollateral(reserveId.usdx, true, alice); vm.snapshotGasLastCall(NAMESPACE, 'usingAsCollateral: 0 borrows, enable'); - spoke.supply(reserveId.usdx, 10000e6, alice); - spoke.borrow(reserveId.dai, 100e18, alice); + _opSupply(reserveId.usdx, 10000e6, alice); + _opBorrow(reserveId.dai, 100e18, alice); skip(100); - spoke.setUsingAsCollateral(reserveId.weth, true, alice); + _opSetUsingAsCollateral(reserveId.weth, true, alice); vm.snapshotGasLastCall(NAMESPACE, 'usingAsCollateral: 1 borrow, enable'); - spoke.setUsingAsCollateral(reserveId.weth, false, alice); + _opSetUsingAsCollateral(reserveId.weth, false, alice); vm.snapshotGasLastCall(NAMESPACE, 'usingAsCollateral: 1 borrow, disable'); - spoke.borrow(reserveId.weth, 0.1e18, alice); + _opBorrow(reserveId.weth, 0.1e18, alice); skip(100); - spoke.setUsingAsCollateral(reserveId.wbtc, true, alice); + _opSetUsingAsCollateral(reserveId.wbtc, true, alice); vm.snapshotGasLastCall(NAMESPACE, 'usingAsCollateral: 2 borrows, enable'); - spoke.setUsingAsCollateral(reserveId.wbtc, false, alice); + _opSetUsingAsCollateral(reserveId.wbtc, false, alice); vm.snapshotGasLastCall(NAMESPACE, 'usingAsCollateral: 2 borrows, disable'); vm.stopPrank(); } function test_withdraw() public { vm.startPrank(alice); - spoke.supply(reserveId.usdx, 100e6, alice); - spoke.setUsingAsCollateral(reserveId.usdx, true, alice); + _opSupply(reserveId.usdx, 100e6, alice); + _opSetUsingAsCollateral(reserveId.usdx, true, alice); - spoke.withdraw(reserveId.usdx, 1e6, alice); + _opWithdraw(reserveId.usdx, 1e6, alice); vm.snapshotGasLastCall(NAMESPACE, 'withdraw: 0 borrows, partial'); skip(100); - spoke.withdraw(reserveId.usdx, UINT256_MAX, alice); + _opWithdraw(reserveId.usdx, UINT256_MAX, alice); vm.snapshotGasLastCall(NAMESPACE, 'withdraw: 0 borrows, full'); - spoke.supply(reserveId.usdx, 10000e6, alice); - spoke.borrow(reserveId.dai, 1e18, alice); + _opSupply(reserveId.usdx, 10000e6, alice); + _opBorrow(reserveId.dai, 1e18, alice); skip(100); - spoke.withdraw(reserveId.usdx, 1e6, alice); + _opWithdraw(reserveId.usdx, 1e6, alice); vm.snapshotGasLastCall(NAMESPACE, 'withdraw: 1 borrow, partial'); - spoke.borrow(reserveId.weth, 1e18, alice); + _opBorrow(reserveId.weth, 1e18, alice); - spoke.withdraw(reserveId.usdx, 1e6, alice); + _opWithdraw(reserveId.usdx, 1e6, alice); vm.snapshotGasLastCall(NAMESPACE, 'withdraw: 2 borrows, partial'); - spoke.supply(reserveId.weth, 1000e18, alice); + _opSupply(reserveId.weth, 1000e18, alice); - spoke.withdraw(reserveId.weth, UINT256_MAX, alice); + _opWithdraw(reserveId.weth, UINT256_MAX, alice); vm.snapshotGasLastCall(NAMESPACE, 'withdraw: non collateral'); vm.stopPrank(); } function test_borrow() public { vm.startPrank(bob); - spoke.supply(reserveId.dai, 1000e18, bob); - spoke.setUsingAsCollateral(reserveId.dai, true, bob); - spoke.borrow(reserveId.dai, 500e18, bob); + _opSupply(reserveId.dai, 1000e18, bob); + _opSetUsingAsCollateral(reserveId.dai, true, bob); + _opBorrow(reserveId.dai, 500e18, bob); skip(100); - spoke.borrow(reserveId.dai, 1e18, bob); + _opBorrow(reserveId.dai, 1e18, bob); vm.stopPrank(); skip(100); vm.startPrank(alice); - spoke.supply(reserveId.usdx, 1000e6, alice); - spoke.setUsingAsCollateral(reserveId.usdx, true, alice); + _opSupply(reserveId.usdx, 1000e6, alice); + _opSetUsingAsCollateral(reserveId.usdx, true, alice); - spoke.borrow(reserveId.dai, 500e18, alice); + _opBorrow(reserveId.dai, 500e18, alice); vm.snapshotGasLastCall(NAMESPACE, 'borrow: first'); skip(100); - spoke.borrow(reserveId.dai, 1e18, alice); + _opBorrow(reserveId.dai, 1e18, alice); vm.snapshotGasLastCall(NAMESPACE, 'borrow: second action, same reserve'); vm.stopPrank(); } function test_repay() public { vm.prank(bob); - spoke.supply(reserveId.dai, 1000e18, bob); + _opSupply(reserveId.dai, 1000e18, bob); vm.startPrank(alice); - spoke.supply(reserveId.usdx, 1000e6, alice); - spoke.setUsingAsCollateral(reserveId.usdx, true, alice); - spoke.borrow(reserveId.dai, 500e18, alice); + _opSupply(reserveId.usdx, 1000e6, alice); + _opSetUsingAsCollateral(reserveId.usdx, true, alice); + _opBorrow(reserveId.dai, 500e18, alice); - spoke.repay(reserveId.dai, 200e18, alice); + _opRepay(reserveId.dai, 200e18, alice); vm.snapshotGasLastCall(NAMESPACE, 'repay: partial'); - spoke.repay(reserveId.dai, UINT256_MAX, alice); + _opRepay(reserveId.dai, UINT256_MAX, alice); vm.snapshotGasLastCall(NAMESPACE, 'repay: full'); vm.stopPrank(); } @@ -137,13 +183,7 @@ contract SpokeOperations_Gas_Tests is Base { _liquidationSetup(85_00); vm.startPrank(bob); - spoke.liquidationCall({ - collateralReserveId: reserveId.usdx, - debtReserveId: reserveId.dai, - user: alice, - debtToCover: 100_000e18, - receiveShares: false - }); + _opLiquidationCall(reserveId.usdx, reserveId.dai, alice, 100_000e18, false); vm.snapshotGasLastCall(NAMESPACE, 'liquidationCall: partial'); vm.stopPrank(); } @@ -152,13 +192,7 @@ contract SpokeOperations_Gas_Tests is Base { _liquidationSetup(85_00); vm.startPrank(bob); - spoke.liquidationCall({ - collateralReserveId: reserveId.usdx, - debtReserveId: reserveId.dai, - user: alice, - debtToCover: UINT256_MAX, - receiveShares: false - }); + _opLiquidationCall(reserveId.usdx, reserveId.dai, alice, UINT256_MAX, false); vm.snapshotGasLastCall(NAMESPACE, 'liquidationCall: full'); vm.stopPrank(); @@ -168,13 +202,7 @@ contract SpokeOperations_Gas_Tests is Base { _liquidationSetup(85_00); vm.startPrank(bob); - spoke.liquidationCall({ - collateralReserveId: reserveId.usdx, - debtReserveId: reserveId.dai, - user: alice, - debtToCover: 100_000e18, - receiveShares: true - }); + _opLiquidationCall(reserveId.usdx, reserveId.dai, alice, 100_000e18, true); vm.snapshotGasLastCall(NAMESPACE, 'liquidationCall (receiveShares): partial'); vm.stopPrank(); @@ -184,13 +212,7 @@ contract SpokeOperations_Gas_Tests is Base { _liquidationSetup(85_00); vm.startPrank(bob); - spoke.liquidationCall({ - collateralReserveId: reserveId.usdx, - debtReserveId: reserveId.dai, - user: alice, - debtToCover: UINT256_MAX, - receiveShares: true - }); + _opLiquidationCall(reserveId.usdx, reserveId.dai, alice, UINT256_MAX, true); vm.snapshotGasLastCall(NAMESPACE, 'liquidationCall (receiveShares): full'); vm.stopPrank(); @@ -200,13 +222,7 @@ contract SpokeOperations_Gas_Tests is Base { _liquidationSetup(45_00); vm.startPrank(bob); - spoke.liquidationCall({ - collateralReserveId: reserveId.usdx, - debtReserveId: reserveId.dai, - user: alice, - debtToCover: UINT256_MAX, - receiveShares: false - }); + _opLiquidationCall(reserveId.usdx, reserveId.dai, alice, UINT256_MAX, false); vm.snapshotGasLastCall(NAMESPACE, 'liquidationCall (reportDeficit): full'); vm.stopPrank(); @@ -214,51 +230,61 @@ contract SpokeOperations_Gas_Tests is Base { function test_updateRiskPremium() public { vm.prank(bob); - spoke.supply(reserveId.dai, 1000e18, bob); + _opSupply(reserveId.dai, 1000e18, bob); vm.startPrank(alice); - spoke.supply(reserveId.usdx, 2000e6, alice); - spoke.setUsingAsCollateral(reserveId.usdx, true, alice); + _opSupply(reserveId.usdx, 2000e6, alice); + _opSetUsingAsCollateral(reserveId.usdx, true, alice); - spoke.borrow(reserveId.dai, 500e18, alice); + _opBorrow(reserveId.dai, 500e18, alice); skip(100); - spoke.updateUserRiskPremium(alice); + _opUpdateUserRiskPremium(alice); vm.snapshotGasLastCall(NAMESPACE, 'updateUserRiskPremium: 1 borrow'); - spoke.borrow(reserveId.usdx, 500e6, alice); + _opBorrow(reserveId.usdx, 500e6, alice); skip(100); - spoke.updateUserRiskPremium(alice); + _opUpdateUserRiskPremium(alice); vm.snapshotGasLastCall(NAMESPACE, 'updateUserRiskPremium: 2 borrows'); vm.stopPrank(); } function test_updateUserDynamicConfig() public { vm.startPrank(alice); - spoke.setUsingAsCollateral(reserveId.usdx, true, alice); + _opSetUsingAsCollateral(reserveId.usdx, true, alice); _updateLiquidationFee(spoke, reserveId.usdx, 10_00); - spoke.updateUserDynamicConfig(alice); + _opUpdateUserDynamicConfig(alice); vm.snapshotGasLastCall(NAMESPACE, 'updateUserDynamicConfig: 1 collateral'); - spoke.setUsingAsCollateral(reserveId.dai, true, alice); + _opSetUsingAsCollateral(reserveId.dai, true, alice); _updateLiquidationFee(spoke, reserveId.dai, 15_00); - spoke.updateUserDynamicConfig(alice); + _opUpdateUserDynamicConfig(alice); vm.snapshotGasLastCall(NAMESPACE, 'updateUserDynamicConfig: 2 collaterals'); vm.stopPrank(); } - function test_multicall_ops() public { + function test_multicall_ops() public virtual { vm.startPrank(bob); spoke.supply(reserveId.dai, 1000e18, bob); spoke.supply(reserveId.usdx, 1000e6, bob); spoke.supply(reserveId.wbtc, 1e18, bob); bytes[] memory calls = new bytes[](2); - calls[0] = abi.encodeCall(ISpoke.supply, (reserveId.dai, 1000e18, bob)); - calls[1] = abi.encodeCall(ISpoke.setUsingAsCollateral, (reserveId.dai, true, bob)); + calls[0] = abi.encodeWithSignature( + 'supply(uint256,uint256,address)', + reserveId.dai, + 1000e18, + bob + ); + calls[1] = abi.encodeWithSignature( + 'setUsingAsCollateral(uint256,bool,address)', + reserveId.dai, + true, + bob + ); spoke.multicall(calls); vm.snapshotGasLastCall(NAMESPACE, 'supply + enable collateral (multicall)'); @@ -277,7 +303,12 @@ contract SpokeOperations_Gas_Tests is Base { ISpoke.permitReserve, (reserveId.dai, permit.owner, permit.value, permit.deadline, v, r, s) ); - calls[1] = abi.encodeCall(ISpoke.supply, (reserveId.dai, permit.value, permit.owner)); + calls[1] = abi.encodeWithSignature( + 'supply(uint256,uint256,address)', + reserveId.dai, + permit.value, + permit.owner + ); spoke.multicall(calls); vm.snapshotGasLastCall(NAMESPACE, 'permitReserve + supply (multicall)'); @@ -297,7 +328,12 @@ contract SpokeOperations_Gas_Tests is Base { ISpoke.permitReserve, (reserveId.usdx, permit.owner, permit.value, permit.deadline, v, r, s) ); - calls[1] = abi.encodeCall(ISpoke.repay, (reserveId.usdx, permit.value, permit.owner)); + calls[1] = abi.encodeWithSignature( + 'repay(uint256,uint256,address)', + reserveId.usdx, + permit.value, + permit.owner + ); spoke.multicall(calls); vm.snapshotGasLastCall(NAMESPACE, 'permitReserve + repay (multicall)'); @@ -316,15 +352,25 @@ contract SpokeOperations_Gas_Tests is Base { ISpoke.permitReserve, (reserveId.wbtc, permit.owner, permit.value, permit.deadline, v, r, s) ); - calls[1] = abi.encodeCall(ISpoke.supply, (reserveId.wbtc, permit.value, permit.owner)); - calls[2] = abi.encodeCall(ISpoke.setUsingAsCollateral, (reserveId.wbtc, true, permit.owner)); + calls[1] = abi.encodeWithSignature( + 'supply(uint256,uint256,address)', + reserveId.wbtc, + permit.value, + permit.owner + ); + calls[2] = abi.encodeWithSignature( + 'setUsingAsCollateral(uint256,bool,address)', + reserveId.wbtc, + true, + permit.owner + ); spoke.multicall(calls); vm.snapshotGasLastCall(NAMESPACE, 'permitReserve + supply + enable collateral (multicall)'); vm.stopPrank(); } - function test_setUserPositionManagersWithSig() public { + function test_setUserPositionManagersWithSig() public virtual { (address user, uint256 userPk) = makeAddrAndKey('user'); address positionManager = makeAddr('positionManager'); vm.prank(SPOKE_ADMIN); @@ -414,3 +460,92 @@ contract SpokeOperations_ZeroRiskPremium_Gas_Tests is SpokeOperations_Gas_Tests _updateCollateralRisk(spoke, reserveId.wbtc, 0); } } + +/// forge-config: default.isolate = true +contract SpokeOperations_PositionSalt_Gas_Tests is SpokeOperations_Gas_Tests { + bytes32 internal constant SALT = keccak256('position-salt'); + + function setUp() public override { + super.setUp(); + NAMESPACE = 'Spoke.Operations.PositionSalt'; + } + + function _positionSalt() internal pure override returns (bytes32) { + return SALT; + } + + function _opSupply(uint256 reserveId_, uint256 amount, address onBehalfOf) internal override { + spoke.supply(reserveId_, amount, onBehalfOf, SALT); + } + + function _opSetUsingAsCollateral( + uint256 reserveId_, + bool usingAsCollateral, + address onBehalfOf + ) internal override { + spoke.setUsingAsCollateral(reserveId_, usingAsCollateral, onBehalfOf, SALT); + } + + function _opWithdraw(uint256 reserveId_, uint256 amount, address onBehalfOf) internal override { + spoke.withdraw(reserveId_, amount, onBehalfOf, SALT); + } + + function _opBorrow(uint256 reserveId_, uint256 amount, address onBehalfOf) internal override { + spoke.borrow(reserveId_, amount, onBehalfOf, SALT); + } + + function _opRepay(uint256 reserveId_, uint256 amount, address onBehalfOf) internal override { + spoke.repay(reserveId_, amount, onBehalfOf, SALT); + } + + function _opLiquidationCall( + uint256 collateralReserveId, + uint256 debtReserveId, + address user, + uint256 debtToCover, + bool receiveShares + ) internal override { + spoke.liquidationCall( + collateralReserveId, + debtReserveId, + user, + bytes32(0), + SALT, + debtToCover, + receiveShares + ); + } + + function _opUpdateUserRiskPremium(address onBehalfOf) internal override { + spoke.updateUserRiskPremium(onBehalfOf, SALT); + } + + function _opUpdateUserDynamicConfig(address onBehalfOf) internal override { + spoke.updateUserDynamicConfig(onBehalfOf, SALT); + } + + function test_multicall_ops() public override { + vm.startPrank(alice); + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeWithSignature( + 'supply(uint256,uint256,address,bytes32)', + reserveId.usdx, + 1000e6, + alice, + SALT + ); + calls[1] = abi.encodeWithSignature( + 'setUsingAsCollateral(uint256,bool,address,bytes32)', + reserveId.usdx, + true, + alice, + SALT + ); + spoke.multicall(calls); + vm.snapshotGasLastCall(NAMESPACE, 'supply + enable collateral (multicall)'); + vm.stopPrank(); + } + + // position managers are not scoped by position salt + function test_setUserPositionManagersWithSig() public override {} +} diff --git a/tests/helpers/mocks/LiquidationLogicWrapper.sol b/tests/helpers/mocks/LiquidationLogicWrapper.sol index 91d0d066d..e8c2d65d0 100644 --- a/tests/helpers/mocks/LiquidationLogicWrapper.sol +++ b/tests/helpers/mocks/LiquidationLogicWrapper.sol @@ -19,9 +19,9 @@ contract LiquidationLogicWrapper { using ReserveFlagsMap for ReserveFlags; mapping(uint256 reserveId => ISpoke.Reserve) internal _reserves; - mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) + mapping(bytes32 positionId => mapping(uint256 reserveId => ISpoke.UserPosition)) internal _userPositions; - mapping(address user => ISpoke.PositionStatus) internal _positionStatuses; + mapping(bytes32 positionId => ISpoke.PositionStatus) internal _positionStatuses; mapping(uint256 reserveId => mapping(uint32 dynamicConfigKey => ISpoke.DynamicReserveConfig)) internal _dynamicConfig; address internal _borrower; @@ -34,6 +34,11 @@ contract LiquidationLogicWrapper { _liquidator = liquidator_; } + /// @dev Default position identifier for `user` (salt = bytes32(0)), matching Spoke._getPositionIdentifier. + function _positionId(address user) internal pure returns (bytes32) { + return bytes32(uint256(uint160(user))); + } + function setBorrower(address borrower) public { _borrower = borrower; } @@ -65,20 +70,24 @@ contract LiquidationLogicWrapper { function setDynamicCollateralConfig( ISpoke.DynamicReserveConfig memory newDynamicCollateralConfig ) public { - uint32 dynamicConfigKey = _userPositions[_borrower][_collateralReserveId].dynamicConfigKey; + uint32 dynamicConfigKey = _userPositions[_positionId(_borrower)][_collateralReserveId] + .dynamicConfigKey; _dynamicConfig[_collateralReserveId][dynamicConfigKey] = newDynamicCollateralConfig; } function setCollateralPositionSuppliedShares(uint256 suppliedShares) public { - _userPositions[_borrower][_collateralReserveId].suppliedShares = suppliedShares.toUint120(); + _userPositions[_positionId(_borrower)][_collateralReserveId].suppliedShares = suppliedShares + .toUint120(); } function setCollateralPositionDynamicConfigKey(uint256 dynamicConfigKey) public { - _userPositions[_borrower][_collateralReserveId].dynamicConfigKey = dynamicConfigKey.toUint24(); + _userPositions[_positionId(_borrower)][_collateralReserveId].dynamicConfigKey = dynamicConfigKey + .toUint24(); } function setLiquidatorPositionSuppliedShares(address liquidator, uint256 suppliedShares) public { - _userPositions[liquidator][_collateralReserveId].suppliedShares = suppliedShares.toUint120(); + _userPositions[_positionId(liquidator)][_collateralReserveId].suppliedShares = suppliedShares + .toUint120(); } function setDebtReserveId(uint256 reserveId) public { @@ -106,31 +115,33 @@ contract LiquidationLogicWrapper { } function setDebtPositionDrawnShares(uint256 drawnShares) public { - _userPositions[_borrower][_debtReserveId].drawnShares = drawnShares.toUint120(); + _userPositions[_positionId(_borrower)][_debtReserveId].drawnShares = drawnShares.toUint120(); } function setDebtPositionPremiumShares(uint256 premiumShares) public { - _userPositions[_borrower][_debtReserveId].premiumShares = premiumShares.toUint120(); + _userPositions[_positionId(_borrower)][_debtReserveId].premiumShares = premiumShares + .toUint120(); } function setDebtPositionPremiumOffsetRay(int256 premiumOffsetRay) public { - _userPositions[_borrower][_debtReserveId].premiumOffsetRay = premiumOffsetRay.toInt200(); + _userPositions[_positionId(_borrower)][_debtReserveId].premiumOffsetRay = premiumOffsetRay + .toInt200(); } function setBorrowerCollateralStatus(uint256 reserveId, bool status) public { - _positionStatuses[_borrower].setUsingAsCollateral(reserveId, status); + _positionStatuses[_positionId(_borrower)].setUsingAsCollateral(reserveId, status); } function setBorrowerBorrowingStatus(uint256 reserveId, bool status) public { - _positionStatuses[_borrower].setBorrowing(reserveId, status); + _positionStatuses[_positionId(_borrower)].setBorrowing(reserveId, status); } function setLiquidatorCollateralStatus(uint256 reserveId, bool status) public { - _positionStatuses[_liquidator].setUsingAsCollateral(reserveId, status); + _positionStatuses[_positionId(_liquidator)].setUsingAsCollateral(reserveId, status); } function setLiquidatorBorrowingStatus(uint256 reserveId, bool status) public { - _positionStatuses[_liquidator].setBorrowing(reserveId, status); + _positionStatuses[_positionId(_liquidator)].setBorrowing(reserveId, status); } function liquidateCollateral( @@ -138,8 +149,8 @@ contract LiquidationLogicWrapper { ) public returns (LiquidationLogic.LiquidateCollateralResult memory) { return LiquidationLogic._liquidateCollateral( - _userPositions[_borrower][_collateralReserveId], - _userPositions[_liquidator][_collateralReserveId], + _userPositions[_positionId(_borrower)][_collateralReserveId], + _userPositions[_positionId(_liquidator)][_collateralReserveId], params ); } @@ -149,8 +160,8 @@ contract LiquidationLogicWrapper { ) public returns (LiquidationLogic.LiquidateDebtResult memory) { return LiquidationLogic._liquidateDebt( - _userPositions[_borrower][_debtReserveId], - _positionStatuses[_borrower], + _userPositions[_positionId(_borrower)][_debtReserveId], + _positionStatuses[_positionId(_borrower)], params ); } @@ -160,10 +171,10 @@ contract LiquidationLogicWrapper { ) public returns (bool) { return LiquidationLogic._executeLiquidation( - _userPositions[_borrower][_collateralReserveId], - _userPositions[_borrower][_debtReserveId], - _userPositions[_liquidator][_collateralReserveId], - _positionStatuses[_borrower], + _userPositions[_positionId(_borrower)][_collateralReserveId], + _userPositions[_positionId(_borrower)][_debtReserveId], + _userPositions[_positionId(_liquidator)][_collateralReserveId], + _positionStatuses[_positionId(_borrower)], params ); } @@ -184,7 +195,7 @@ contract LiquidationLogicWrapper { } function getCollateralPosition(address user) public view returns (ISpoke.UserPosition memory) { - return _userPositions[user][_collateralReserveId]; + return _userPositions[_positionId(user)][_collateralReserveId]; } function getDebtReserve() public view returns (ISpoke.Reserve memory) { @@ -192,23 +203,23 @@ contract LiquidationLogicWrapper { } function getDebtPosition(address user) public view returns (ISpoke.UserPosition memory) { - return _userPositions[user][_debtReserveId]; + return _userPositions[_positionId(user)][_debtReserveId]; } function getBorrowerCollateralStatus(uint256 reserveId) public view returns (bool) { - return _positionStatuses[_borrower].isUsingAsCollateral(reserveId); + return _positionStatuses[_positionId(_borrower)].isUsingAsCollateral(reserveId); } function getBorrowerBorrowingStatus(uint256 reserveId) public view returns (bool) { - return _positionStatuses[_borrower].isBorrowing(reserveId); + return _positionStatuses[_positionId(_borrower)].isBorrowing(reserveId); } function getLiquidatorCollateralStatus(uint256 reserveId) public view returns (bool) { - return _positionStatuses[_liquidator].isUsingAsCollateral(reserveId); + return _positionStatuses[_positionId(_liquidator)].isUsingAsCollateral(reserveId); } function getLiquidatorBorrowingStatus(uint256 reserveId) public view returns (bool) { - return _positionStatuses[_liquidator].isBorrowing(reserveId); + return _positionStatuses[_positionId(_liquidator)].isBorrowing(reserveId); } function calculateLiquidationAmounts( diff --git a/tests/helpers/mocks/MockSpoke.sol b/tests/helpers/mocks/MockSpoke.sol index adb49b805..6f51cd98e 100644 --- a/tests/helpers/mocks/MockSpoke.sol +++ b/tests/helpers/mocks/MockSpoke.sol @@ -39,12 +39,12 @@ contract MockSpoke is Spoke, Test { address onBehalfOf ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { Reserve storage reserve = _reserves.get(reserveId); - UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; - PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; + bytes32 positionId = _getPositionIdentifier(onBehalfOf, USER_POSITION_DEFAULT_SALT); + UserPosition storage userPosition = _userPositions[positionId][reserveId]; + PositionStatus storage positionStatus = _positionStatus[positionId]; _validateBorrow(reserve.flags); - IHubBase hub = reserve.hub; - uint256 drawnShares = hub.draw(reserve.assetId, amount, msg.sender); + uint256 drawnShares = reserve.hub.draw(reserve.assetId, amount, msg.sender); userPosition.drawnShares += drawnShares.toUint120(); if (!positionStatus.isBorrowing(reserveId)) { require( @@ -55,35 +55,35 @@ contract MockSpoke is Spoke, Test { positionStatus.setBorrowing(reserveId, true); } - uint256 newRiskPremium = _processUserAccountData({user: onBehalfOf, refreshConfig: true}) - .riskPremium; - emit RefreshAllUserDynamicConfig(onBehalfOf); - _notifyRiskPremiumUpdate(onBehalfOf, newRiskPremium); + _refreshAllDynamicConfig(positionId); + uint256 newRiskPremium = _calculateUserAccountData(positionId).riskPremium; + _notifyRiskPremiumUpdate(positionId, newRiskPremium); - emit Borrow(reserveId, msg.sender, onBehalfOf, drawnShares, amount); + emit Borrow(reserveId, msg.sender, positionId, drawnShares, amount); return (drawnShares, amount); } // Mock the user account data function mockStorage(address user, AccountDataInfo memory info) external { - PositionStatus storage positionStatus = _positionStatus[user]; + bytes32 positionId = _getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT); + PositionStatus storage positionStatus = _positionStatus[positionId]; for (uint256 i = 0; i < info.collateralReserveIds.length; i++) { positionStatus.setUsingAsCollateral(info.collateralReserveIds[i], true); Reserve storage reserve = _reserves[info.collateralReserveIds[i]]; - _userPositions[user][info.collateralReserveIds[i]].suppliedShares = reserve + _userPositions[positionId][info.collateralReserveIds[i]].suppliedShares = reserve .hub .previewAddByAssets(reserve.assetId, info.collateralAmounts[i]) .toUint120(); - _userPositions[user][info.collateralReserveIds[i]].dynamicConfigKey = info + _userPositions[positionId][info.collateralReserveIds[i]].dynamicConfigKey = info .collateralDynamicConfigKeys[i] .toUint32(); } for (uint256 i = 0; i < info.suppliedAssetsReserveIds.length; i++) { Reserve storage reserve = _reserves[info.suppliedAssetsReserveIds[i]]; - _userPositions[user][info.suppliedAssetsReserveIds[i]].suppliedShares = reserve + _userPositions[positionId][info.suppliedAssetsReserveIds[i]].suppliedShares = reserve .hub .previewAddByAssets(reserve.assetId, info.suppliedAssetsAmounts[i]) .toUint120(); @@ -92,18 +92,18 @@ contract MockSpoke is Spoke, Test { for (uint256 i = 0; i < info.debtReserveIds.length; i++) { positionStatus.setBorrowing(info.debtReserveIds[i], true); Reserve storage reserve = _reserves[info.debtReserveIds[i]]; - _userPositions[user][info.debtReserveIds[i]].drawnShares = reserve + _userPositions[positionId][info.debtReserveIds[i]].drawnShares = reserve .hub .previewDrawByAssets(reserve.assetId, info.drawnDebtAmounts[i]) .toUint120(); - _userPositions[user][info.debtReserveIds[i]].premiumShares = vm + _userPositions[positionId][info.debtReserveIds[i]].premiumShares = vm .randomUint( reserve.hub.previewRemoveByAssets(reserve.assetId, info.accruedPremiumAmounts[i]), 100e18 ) .toUint120(); - _userPositions[user][info.debtReserveIds[i]].premiumOffsetRay = - (_userPositions[user][info.debtReserveIds[i]].premiumShares * + _userPositions[positionId][info.debtReserveIds[i]].premiumOffsetRay = + (_userPositions[positionId][info.debtReserveIds[i]].premiumShares * reserve.hub.getAssetDrawnIndex(reserve.assetId)).toInt256().toInt200() - (info.accruedPremiumAmounts[i] * WadRayMath.RAY).toInt256().toInt200() - (info.realizedPremiumAmountsRay[i]).toInt256().toInt200(); @@ -115,11 +115,15 @@ contract MockSpoke is Spoke, Test { address user, bool refreshConfig ) external returns (UserAccountData memory) { - return _processUserAccountData(user, refreshConfig); + bytes32 positionId = _getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT); + if (refreshConfig) { + _refreshAllDynamicConfig(positionId); + } + return _calculateUserAccountData(positionId); } function getRiskPremium(address user) external view returns (uint24) { - return _positionStatus[user].riskPremium; + return _positionStatus[_getPositionIdentifier(user, USER_POSITION_DEFAULT_SALT)].riskPremium; } function setReserveDynamicConfigKey(uint256 reserveId, uint32 configKey) external { diff --git a/tests/helpers/spoke/SpokeActions.sol b/tests/helpers/spoke/SpokeActions.sol index 32d752ca4..f5e71eccc 100644 --- a/tests/helpers/spoke/SpokeActions.sol +++ b/tests/helpers/spoke/SpokeActions.sol @@ -27,6 +27,18 @@ library SpokeActions { spoke.setUsingAsCollateral(reserveId, usingAsCollateral, onBehalfOf); } + function setUsingAsCollateral( + ISpoke spoke, + uint256 reserveId, + address caller, + bool usingAsCollateral, + address onBehalfOf, + bytes32 positionSalt + ) internal { + vm.prank(caller); + spoke.setUsingAsCollateral(reserveId, usingAsCollateral, onBehalfOf, positionSalt); + } + function supply( ISpoke spoke, uint256 reserveId, @@ -38,6 +50,18 @@ library SpokeActions { spoke.supply(reserveId, amount, onBehalfOf); } + function supply( + ISpoke spoke, + uint256 reserveId, + address caller, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) internal { + vm.prank(caller); + spoke.supply(reserveId, amount, onBehalfOf, positionSalt); + } + function supplyCollateral( ISpoke spoke, uint256 reserveId, @@ -55,6 +79,25 @@ library SpokeActions { }); } + function supplyCollateral( + ISpoke spoke, + uint256 reserveId, + address caller, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) internal { + supply(spoke, reserveId, caller, amount, onBehalfOf, positionSalt); + setUsingAsCollateral({ + spoke: spoke, + reserveId: reserveId, + caller: caller, + usingAsCollateral: true, + onBehalfOf: onBehalfOf, + positionSalt: positionSalt + }); + } + function withdraw( ISpoke spoke, uint256 reserveId, @@ -66,6 +109,18 @@ library SpokeActions { spoke.withdraw(reserveId, amount, onBehalfOf); } + function withdraw( + ISpoke spoke, + uint256 reserveId, + address caller, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) internal { + vm.prank(caller); + spoke.withdraw(reserveId, amount, onBehalfOf, positionSalt); + } + function borrow( ISpoke spoke, uint256 reserveId, @@ -77,6 +132,18 @@ library SpokeActions { spoke.borrow(reserveId, amount, onBehalfOf); } + function borrow( + ISpoke spoke, + uint256 reserveId, + address caller, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) internal { + vm.prank(caller); + spoke.borrow(reserveId, amount, onBehalfOf, positionSalt); + } + function repay( ISpoke spoke, uint256 reserveId, @@ -88,6 +155,18 @@ library SpokeActions { spoke.repay(reserveId, amount, onBehalfOf); } + function repay( + ISpoke spoke, + uint256 reserveId, + address caller, + uint256 amount, + address onBehalfOf, + bytes32 positionSalt + ) internal { + vm.prank(caller); + spoke.repay(reserveId, amount, onBehalfOf, positionSalt); + } + function liquidationCall( ISpoke spoke, uint256 collateralReserveId, @@ -101,6 +180,29 @@ library SpokeActions { spoke.liquidationCall(collateralReserveId, debtReserveId, user, debtToCover, receiveShares); } + function liquidationCall( + ISpoke spoke, + uint256 collateralReserveId, + uint256 debtReserveId, + address user, + bytes32 positionSalt, + bytes32 liquidatorPositionSalt, + uint256 debtToCover, + bool receiveShares, + address caller + ) internal { + vm.prank(caller); + spoke.liquidationCall( + collateralReserveId, + debtReserveId, + user, + positionSalt, + liquidatorPositionSalt, + debtToCover, + receiveShares + ); + } + /////////////////////////////////////////////////////////////////////////////////////////////// // CONFIG ACTIONS // /////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/tests/setup/BaseHelpers.sol b/tests/setup/BaseHelpers.sol index c267845bc..099c5e844 100644 --- a/tests/setup/BaseHelpers.sol +++ b/tests/setup/BaseHelpers.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.0; import {BaseState} from 'tests/setup/BaseState.sol'; +import {Hashes} from 'src/dependencies/openzeppelin/Hashes.sol'; import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; import {IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; @@ -51,6 +52,19 @@ abstract contract BaseHelpers is BaseState { }); } + /// @dev Default position identifier for `user` (salt = bytes32(0)), matching Spoke._getPositionIdentifier. + function _getPositionId(address user) internal pure returns (bytes32) { + return _getPositionId(user, bytes32(0)); + } + + /// @dev Position identifier for `user` under `salt`, matching Spoke._getPositionIdentifier. + function _getPositionId(address user, bytes32 salt) internal pure returns (bytes32) { + return + salt == bytes32(0) + ? bytes32(uint256(uint160(user))) + : Hashes.efficientKeccak256(bytes32(uint256(uint160(user))), salt); + } + /////////////////////////////////////////////////////////////////////////////////////////////// // CROSS-LAYER ASSERTIONS // ///////////////////////////////////////////////////////////////////////////////////////////////