Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions src/spoke/Spoke.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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];

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/spoke/interfaces/ISpoke.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 9 additions & 3 deletions tests/helpers/mocks/MockSpoke.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand Down