From c3dd8f2bee00f38a87b1c675fbcfc36b0ad58a22 Mon Sep 17 00:00:00 2001 From: babymakh2026-maker Date: Tue, 4 Aug 2026 10:36:02 +0330 Subject: [PATCH] feat: preview dynamic config refresh account data --- src/spoke/Spoke.sol | 42 ++++++++++++++----- src/spoke/interfaces/ISpoke.sol | 8 ++++ .../Spoke.DynamicConfig.Triggers.t.sol | 33 +++++++++++++++ tests/helpers/mocks/MockSpoke.sol | 12 ++++-- 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index 9dd7beab9..89b9adae9 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -64,6 +64,15 @@ abstract contract Spoke is /// @dev The number of decimals used by the oracle. uint8 internal constant ORACLE_DECIMALS = SpokeUtils.ORACLE_DECIMALS; + /// @dev Account data calculation mode that uses each user's stored dynamic config keys. + uint256 internal constant DYNAMIC_CONFIG_MODE_CURRENT = 0; + + /// @dev Account data calculation mode that uses latest dynamic config keys and stores them. + uint256 internal constant DYNAMIC_CONFIG_MODE_REFRESH = 1; + + /// @dev Account data calculation mode that uses latest dynamic config keys without storing them. + uint256 internal constant DYNAMIC_CONFIG_MODE_PREVIEW_REFRESH = 2; + /// @dev The maximum allowed value for an asset identifier (inclusive). uint256 internal constant MAX_ALLOWED_ASSET_ID = type(uint16).max; @@ -631,8 +640,16 @@ abstract contract Spoke is /// @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); + // SAFETY: function does not modify state with `DYNAMIC_CONFIG_MODE_CURRENT`. + return _castToView(_processUserAccountData)(user, DYNAMIC_CONFIG_MODE_CURRENT); + } + + /// @inheritdoc ISpoke + function getUserAccountDataAfterDynamicConfigRefresh( + address user + ) external view returns (UserAccountData memory) { + // SAFETY: function does not modify state with `DYNAMIC_CONFIG_MODE_PREVIEW_REFRESH`. + return _castToView(_processUserAccountData)(user, DYNAMIC_CONFIG_MODE_PREVIEW_REFRESH); } /// @inheritdoc ISpoke @@ -686,7 +703,7 @@ abstract contract Spoke is function _refreshAndValidateUserAccountData( address user ) internal returns (UserAccountData memory) { - UserAccountData memory accountData = _processUserAccountData(user, true); + UserAccountData memory accountData = _processUserAccountData(user, DYNAMIC_CONFIG_MODE_REFRESH); emit RefreshAllUserDynamicConfig(user); require( accountData.healthFactor >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD, @@ -697,15 +714,15 @@ abstract contract Spoke is /// @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 + return _processUserAccountData(user, DYNAMIC_CONFIG_MODE_CURRENT); // does not modify state } - /// @notice Process the user account data and updates dynamic config of the user if `refreshConfig` is true. + /// @notice Process the user account data according to the selected dynamic config mode. /// @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 + uint256 dynamicConfigMode ) internal returns (UserAccountData memory accountData) { PositionStatus storage positionStatus = _positionStatus[user]; @@ -726,10 +743,13 @@ abstract contract Spoke is uint256 assetDecimals = reserve.decimals; if (collateral) { + if (dynamicConfigMode == DYNAMIC_CONFIG_MODE_REFRESH) { + userPosition.dynamicConfigKey = reserve.dynamicConfigKey; + } uint256 collateralFactor = _dynamicConfig[reserveId][ - refreshConfig - ? (userPosition.dynamicConfigKey = reserve.dynamicConfigKey) - : userPosition.dynamicConfigKey + dynamicConfigMode == DYNAMIC_CONFIG_MODE_CURRENT + ? userPosition.dynamicConfigKey + : reserve.dynamicConfigKey ].collateralFactor; if (collateralFactor > 0) { uint256 suppliedShares = userPosition.suppliedShares; @@ -934,11 +954,11 @@ abstract contract Spoke is } function _castToView( - function(address, bool) internal returns (UserAccountData memory) fnIn + function(address, uint256) internal returns (UserAccountData memory) fnIn ) internal pure - returns (function(address, bool) internal view returns (UserAccountData memory) fnOut) + returns (function(address, uint256) internal view returns (UserAccountData memory) fnOut) { assembly ('memory-safe') { fnOut := fnIn diff --git a/src/spoke/interfaces/ISpoke.sol b/src/spoke/interfaces/ISpoke.sol index e61be5ffb..a2bce84ec 100644 --- a/src/spoke/interfaces/ISpoke.sol +++ b/src/spoke/interfaces/ISpoke.sol @@ -744,6 +744,14 @@ interface ISpoke is IAccessManaged, IIntentConsumer, IExtSload, IMulticall { /// @return The user account data struct. function getUserAccountData(address user) external view returns (UserAccountData memory); + /// @notice Returns the user account data as if the user's dynamic configuration was refreshed. + /// @dev Does not update the user's stored dynamic config keys. + /// @param user The address of the user. + /// @return The user account data struct calculated with the latest dynamic reserve configurations. + function getUserAccountDataAfterDynamicConfigRefresh( + address user + ) 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. diff --git a/tests/contracts/spoke/configuration/Spoke.DynamicConfig.Triggers.t.sol b/tests/contracts/spoke/configuration/Spoke.DynamicConfig.Triggers.t.sol index 1cf3825c3..f97e7b676 100644 --- a/tests/contracts/spoke/configuration/Spoke.DynamicConfig.Triggers.t.sol +++ b/tests/contracts/spoke/configuration/Spoke.DynamicConfig.Triggers.t.sol @@ -470,6 +470,39 @@ contract SpokeDynamicConfigTriggersTest is Base { assertNotEq(initialRP, newRP); } + function test_getUserAccountDataAfterDynamicConfigRefresh_previews_withoutUpdatingStorage() + public + { + uint256 reserveId = _usdxReserveId(spoke1); + + SpokeActions.supplyCollateral({ + spoke: spoke1, + reserveId: reserveId, + caller: alice, + amount: 1000e6, + onBehalfOf: alice + }); + + DynamicConfigEntry[] memory configs = _getUserDynConfigKeys(spoke1, alice); + ISpoke.UserAccountData memory staleAccountData = spoke1.getUserAccountData(alice); + + _updateCollateralFactor(spoke1, reserveId, 95_00); + + ISpoke.UserAccountData memory previewAccountData = spoke1 + .getUserAccountDataAfterDynamicConfigRefresh(alice); + + assertEq(_getUserDynConfigKeys(spoke1, alice), configs); + assertEq(spoke1.getUserAccountData(alice), staleAccountData); + assertNotEq(previewAccountData.avgCollateralFactor, staleAccountData.avgCollateralFactor); + assertEq(previewAccountData.avgCollateralFactor, 0.95e18); + + vm.prank(alice); + spoke1.updateUserDynamicConfig(alice); + + assertEq(spoke1.getUserAccountData(alice), previewAccountData); + assertEq(_getSpokeDynConfigKeys(spoke1), _getUserDynConfigKeys(spoke1, alice)); + } + function test_updateUserDynamicConfig_doesHFCheck() public { // Supply 1 collateral that is sufficient to cover debt SpokeActions.supplyCollateral({ diff --git a/tests/helpers/mocks/MockSpoke.sol b/tests/helpers/mocks/MockSpoke.sol index adb49b805..527cfecbc 100644 --- a/tests/helpers/mocks/MockSpoke.sol +++ b/tests/helpers/mocks/MockSpoke.sol @@ -55,8 +55,10 @@ contract MockSpoke is Spoke, Test { positionStatus.setBorrowing(reserveId, true); } - uint256 newRiskPremium = _processUserAccountData({user: onBehalfOf, refreshConfig: true}) - .riskPremium; + uint256 newRiskPremium = _processUserAccountData({ + user: onBehalfOf, + dynamicConfigMode: DYNAMIC_CONFIG_MODE_REFRESH + }).riskPremium; emit RefreshAllUserDynamicConfig(onBehalfOf); _notifyRiskPremiumUpdate(onBehalfOf, newRiskPremium); @@ -115,7 +117,11 @@ contract MockSpoke is Spoke, Test { address user, bool refreshConfig ) external returns (UserAccountData memory) { - return _processUserAccountData(user, refreshConfig); + return + _processUserAccountData( + user, + refreshConfig ? DYNAMIC_CONFIG_MODE_REFRESH : DYNAMIC_CONFIG_MODE_CURRENT + ); } function getRiskPremium(address user) external view returns (uint24) {