Skip to content

Commit 7dbea62

Browse files
authored
fix: spoke fixes (#664)
* fix: skip isCollateral branch when cf is 0 * fix: split debt in base currency * chore: retain current behaviour * test: hf calc does not include zero cf collateral * chore: zero addr check for authority * feat: finalise * chore: rename * chore: fix test name
1 parent 4802b3f commit 7dbea62

File tree

11 files changed

+138
-63
lines changed

11 files changed

+138
-63
lines changed

snapshots/Spoke.Getters.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"getUserAccountData: supplies: 0, borrows: 0": "9239",
3-
"getUserAccountData: supplies: 1, borrows: 0": "44949",
4-
"getUserAccountData: supplies: 2, borrows: 0": "75566",
5-
"getUserAccountData: supplies: 2, borrows: 1": "98873",
6-
"getUserAccountData: supplies: 2, borrows: 2": "120687"
3+
"getUserAccountData: supplies: 1, borrows: 0": "44980",
4+
"getUserAccountData: supplies: 2, borrows: 0": "75628",
5+
"getUserAccountData: supplies: 2, borrows: 1": "99115",
6+
"getUserAccountData: supplies: 2, borrows: 2": "121137"
77
}

snapshots/Spoke.Operations.json

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
{
2-
"borrow: first": "250206",
3-
"borrow: second action, same reserve": "213225",
4-
"liquidationCall: full": "309118",
5-
"liquidationCall: partial": "383728",
6-
"permitReserve + repay (multicall)": "270499",
2+
"borrow: first": "250417",
3+
"borrow: second action, same reserve": "213436",
4+
"liquidationCall: full": "309540",
5+
"liquidationCall: partial": "384150",
6+
"permitReserve + repay (multicall)": "270710",
77
"permitReserve + supply (multicall)": "140611",
88
"permitReserve + supply + enable collateral (multicall)": "173849",
9-
"repay: full": "174561",
10-
"repay: partial": "229281",
9+
"repay: full": "174592",
10+
"repay: partial": "229492",
1111
"supply + enable collateral (multicall)": "152282",
1212
"supply: 0 borrows, collateral disabled": "116445",
1313
"supply: 0 borrows, collateral enabled": "119441",
1414
"supply: 1 borrow": "119430",
1515
"supply: second action, same reserve": "102341",
1616
"updateUserDynamicConfig: 1 collateral": "53113",
1717
"updateUserDynamicConfig: 2 collaterals": "58377",
18-
"updateUserRiskPremium: 1 borrow": "122002",
19-
"updateUserRiskPremium: 2 borrows": "157802",
18+
"updateUserRiskPremium: 1 borrow": "122213",
19+
"updateUserRiskPremium: 2 borrows": "158221",
2020
"usingAsCollateral: 0 borrows, enable": "53930",
21-
"usingAsCollateral: 1 borrow, disable": "132204",
21+
"usingAsCollateral: 1 borrow, disable": "132415",
2222
"usingAsCollateral: 1 borrow, enable": "24831",
23-
"withdraw: 0 borrows, full": "136205",
24-
"withdraw: 0 borrows, partial": "137732",
25-
"withdraw: 1 borrow, partial": "239439"
23+
"withdraw: 0 borrows, full": "136236",
24+
"withdraw: 0 borrows, partial": "137763",
25+
"withdraw: 1 borrow, partial": "239650"
2626
}

src/contracts/Spoke.sol

Lines changed: 35 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
6060
* @param authority_ The address of the authority contract which manages permissions.
6161
*/
6262
constructor(address authority_) AccessManaged(authority_) {
63+
require(authority_ != address(0), InvalidAddress());
6364
_liquidationConfig.closeFactor = Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD;
6465
emit LiquidationConfigUpdate(_liquidationConfig);
6566
}
@@ -68,10 +69,13 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
6869
// Governance
6970
// /////
7071

72+
/// @inheritdoc ISpoke
7173
function updateOracle(address newOracle) external restricted {
72-
require(newOracle != address(0), InvalidOracle());
7374
oracle = IAaveOracle(newOracle);
74-
require(oracle.DECIMALS() == 8, InvalidOracle());
75+
require(
76+
newOracle != address(0) && oracle.DECIMALS() == Constants.ORACLE_DECIMALS,
77+
InvalidOracle()
78+
);
7579
emit OracleUpdate(newOracle);
7680
}
7781

@@ -95,10 +99,11 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
9599
DataTypes.ReserveConfig calldata config,
96100
DataTypes.DynamicReserveConfig calldata dynamicConfig
97101
) external restricted returns (uint256) {
98-
require(hub != address(0), InvalidHubAddress());
102+
require(hub != address(0), InvalidAddress());
99103
require(!_reserveExists[hub][assetId], ReserveExists());
100104

101105
_validateReserveConfig(config);
106+
_validateDynamicReserveConfig(dynamicConfig);
102107
uint256 reserveId = _reserveCount++;
103108
uint16 dynamicConfigKey; // 0 as first key to use
104109

@@ -222,7 +227,6 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
222227

223228
userPosition.suppliedShares -= withdrawnShares.toUint128();
224229

225-
// calc needs new user position, just updating drawn debt is enough
226230
uint256 newUserRiskPremium = _refreshAndValidateUserPosition(onBehalfOf); // validates HF
227231
_notifyRiskPremiumUpdate(onBehalfOf, newUserRiskPremium);
228232

@@ -250,7 +254,6 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
250254
positionStatus.setBorrowing(reserveId, true);
251255
}
252256

253-
// calc needs new user position, just updating drawn debt is enough
254257
uint256 newUserRiskPremium = _refreshAndValidateUserPosition(onBehalfOf); // validates HF
255258
_notifyRiskPremiumUpdate(onBehalfOf, newUserRiskPremium);
256259

@@ -503,13 +506,11 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
503506
return drawnDebt + premiumDebt;
504507
}
505508

506-
/// @dev We do not differentiate between duplicate reserves (assetId) on the same hub
507509
function getReserveSuppliedAmount(uint256 reserveId) external view returns (uint256) {
508510
DataTypes.Reserve storage reserve = _reserves[reserveId];
509511
return reserve.hub.getSpokeAddedAmount(reserve.assetId, address(this));
510512
}
511513

512-
/// @dev We do not differentiate between duplicate reserves (assetId) on the same hub
513514
function getReserveSuppliedShares(uint256 reserveId) external view returns (uint256) {
514515
DataTypes.Reserve storage reserve = _reserves[reserveId];
515516
return reserve.hub.getSpokeAddedShares(reserve.assetId, address(this));
@@ -676,15 +677,10 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
676677

677678
/**
678679
* @dev Calculates the user's premium debt offset in assets amount from a given share amount.
679-
* @dev Rounds down to the nearest assets amount.
680-
* @dev Uses the opposite rounding direction of the debt shares-to-assets conversion to prevent underflow
681-
* in premium debt.
682-
* @param hub The liquidity hub of the reserve.
683-
* @param assetId The identifier of the asset.
684-
* @param shares The amount of shares to convert to assets amount.
685-
* @return The amount of assets converted corresponding to user's premium offset.
680+
* @dev Rounds down to the nearest assets amount. Uses the opposite rounding direction of the
681+
* debt shares-to-assets conversion to prevent underflow in premium debt.
686682
*/
687-
function _previewOffset(
683+
function _previewPremiumOffset(
688684
IHub hub,
689685
uint256 assetId,
690686
uint256 shares
@@ -728,7 +724,10 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
728724
}
729725

730726
function _validateLiquidationConfig(DataTypes.LiquidationConfig calldata config) internal pure {
731-
_validateCloseFactor(config.closeFactor);
727+
require(
728+
config.closeFactor >= Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD,
729+
InvalidCloseFactor()
730+
);
732731
require(
733732
config.liquidationBonusFactor <= PercentageMath.PERCENTAGE_FACTOR,
734733
InvalidLiquidationBonusFactor()
@@ -739,10 +738,6 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
739738
);
740739
}
741740

742-
function _validateCloseFactor(uint256 closeFactor) internal pure {
743-
require(closeFactor >= Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD, InvalidCloseFactor());
744-
}
745-
746741
/**
747742
* @dev Validates the reserve can be set as collateral.
748743
* @dev Collateral can be disabled if the reserve is frozen.
@@ -847,21 +842,23 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
847842
? (userPosition.configKey = reserve.dynamicConfigKey)
848843
: userPosition.configKey
849844
];
850-
851-
vars.userCollateralInBaseCurrency = _getUserBalanceInBaseCurrency(
852-
userPosition,
853-
hub,
854-
vars.assetId,
855-
vars.assetPrice,
856-
vars.assetUnit
857-
);
858-
859-
vars.totalCollateralInBaseCurrency += vars.userCollateralInBaseCurrency;
860-
list.add(vars.i, reserve.collateralRisk, vars.userCollateralInBaseCurrency);
861-
vars.avgCollateralFactor += vars.userCollateralInBaseCurrency * dynConfig.collateralFactor;
862-
863-
unchecked {
864-
++vars.i;
845+
uint256 collateralFactor = dynConfig.collateralFactor;
846+
if (collateralFactor != 0) {
847+
vars.userCollateralInBaseCurrency = _getUserBalanceInBaseCurrency(
848+
userPosition,
849+
hub,
850+
vars.assetId,
851+
vars.assetPrice,
852+
vars.assetUnit
853+
);
854+
855+
vars.totalCollateralInBaseCurrency += vars.userCollateralInBaseCurrency;
856+
list.add(vars.i, reserve.collateralRisk, vars.userCollateralInBaseCurrency);
857+
vars.avgCollateralFactor += vars.userCollateralInBaseCurrency * collateralFactor;
858+
859+
unchecked {
860+
++vars.i;
861+
}
865862
}
866863
}
867864

@@ -899,7 +896,6 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
899896
// @dev from this point onwards, `collateralCounterInBaseCurrency` represents running collateral
900897
// value used in risk premium, `debtCounterInBaseCurrency` represents running outstanding debt
901898
while (vars.i < list.length() && vars.debtCounterInBaseCurrency > 0) {
902-
if (vars.debtCounterInBaseCurrency == 0) break;
903899
(vars.collateralRisk, vars.userCollateralInBaseCurrency) = list.get(vars.i);
904900
if (vars.userCollateralInBaseCurrency > vars.debtCounterInBaseCurrency) {
905901
vars.userCollateralInBaseCurrency = vars.debtCounterInBaseCurrency;
@@ -933,7 +929,8 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
933929
uint256 assetUnit
934930
) internal view returns (uint256) {
935931
(uint256 drawnDebt, uint256 premiumDebt, ) = _getUserDebt(hub, assetId, userPosition);
936-
return ((drawnDebt + premiumDebt) * assetPrice).wadDivUp(assetUnit);
932+
return
933+
(drawnDebt * assetPrice).wadDivUp(assetUnit) + (premiumDebt * assetPrice).wadDivUp(assetUnit);
937934
}
938935

939936
function _getUserBalanceInBaseCurrency(
@@ -996,7 +993,7 @@ contract Spoke is ISpoke, Multicall, AccessManaged, EIP712 {
996993
.drawnShares
997994
.percentMulUp(newUserRiskPremium)
998995
.toUint128());
999-
uint256 newPremiumOffset = (userPosition.premiumOffset = _previewOffset(
996+
uint256 newPremiumOffset = (userPosition.premiumOffset = _previewPremiumOffset(
1000997
vars.hub,
1001998
vars.assetId,
1002999
userPosition.premiumShares

src/contracts/TreasurySpoke.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ contract TreasurySpoke is ITreasurySpoke, Ownable {
2727
* @param hub_ The address of the Hub
2828
*/
2929
constructor(address owner_, address hub_) Ownable(owner_) {
30-
require(hub_ != address(0), InvalidHubAddress());
30+
require(hub_ != address(0), InvalidAddress());
3131

3232
HUB = IHub(hub_);
3333
}

src/interfaces/ISpoke.sol

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,15 @@ interface ISpoke is ISpokeBase, IMulticall, IAccessManaged {
127127
error Unauthorized();
128128
error ConfigKeyUninitialized();
129129
error InactivePositionManager();
130+
error InvalidAddress();
130131
error InvalidSignature();
131132

132133
function updateLiquidationConfig(DataTypes.LiquidationConfig calldata config) external;
133134

135+
/**
136+
* @notice Allows governance to update the spoke oracle.
137+
* @dev Does not validate all existing reserves are supported on `newOracle`.
138+
*/
134139
function updateOracle(address newOracle) external;
135140

136141
function updateReservePriceSource(uint256 reserveId, address priceSource) external;

src/interfaces/ISpokeBase.sol

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,6 @@ import {DataTypes} from 'src/libraries/types/DataTypes.sol';
1010
* @notice Minimal interface for Spoke
1111
*/
1212
interface ISpokeBase {
13-
/**
14-
* @notice Error thrown when the hub address is invalid.
15-
*/
16-
error InvalidHubAddress();
17-
1813
/**
1914
* @notice Emitted on the supply action.
2015
* @param reserveId The reserve identifier of the underlying asset as registered on the spoke.

src/interfaces/ITreasurySpoke.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {ISpokeBase} from 'src/interfaces/ISpokeBase.sol';
1010
*/
1111
interface ITreasurySpoke is ISpokeBase {
1212
error UnsupportedAction();
13+
error InvalidAddress();
1314

1415
/**
1516
* @notice Supplies a specified amount of the underlying asset to a given reserve.

src/libraries/helpers/Constants.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ library Constants {
77
uint8 public constant MAX_ALLOWED_ASSET_DECIMALS = 18;
88
uint56 internal constant MAX_CAP = type(uint56).max;
99
/// @dev Spoke Constants
10+
uint8 public constant ORACLE_DECIMALS = 8;
1011
uint64 public constant HEALTH_FACTOR_LIQUIDATION_THRESHOLD = 1e18;
1112
uint24 public constant MAX_COLLATERAL_RISK = 1000_00; // 1000.00%
1213
// keccak256('SetUserPositionManager(address positionManager,address user,bool approve,uint256 nonce,uint256 deadline)')

tests/Base.t.sol

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,23 @@ abstract contract Base is Test {
10671067
return configKey;
10681068
}
10691069

1070+
function updateCollateralFactorAtKey(
1071+
ISpoke spoke,
1072+
uint256 reserveId,
1073+
uint16 configKey,
1074+
uint256 newCollateralFactor
1075+
) internal pausePrank {
1076+
DataTypes.DynamicReserveConfig memory config = spoke.getDynamicReserveConfig(
1077+
reserveId,
1078+
configKey
1079+
);
1080+
config.collateralFactor = newCollateralFactor.toUint16();
1081+
vm.prank(SPOKE_ADMIN);
1082+
spoke.updateDynamicReserveConfig(reserveId, configKey, config);
1083+
1084+
assertEq(spoke.getDynamicReserveConfig(reserveId), config);
1085+
}
1086+
10701087
function updateReserveBorrowableFlag(
10711088
ISpoke spoke,
10721089
uint256 reserveId,
@@ -1302,8 +1319,23 @@ abstract contract Base is Test {
13021319
IPriceOracle oracle = spoke.oracle();
13031320
uint256 assetId = spoke.getReserve(reserveId).assetId;
13041321
return
1305-
(amount * oracle.getReservePrice(reserveId).toWad()) /
1306-
(10 ** spoke.getReserve(reserveId).hub.getAsset(assetId).decimals);
1322+
(amount * oracle.getReservePrice(reserveId)).wadDivDown(
1323+
10 ** spoke.getReserve(reserveId).hub.getAsset(assetId).decimals
1324+
);
1325+
}
1326+
1327+
/// returns the USD value of the reserve normalized by it's decimals, in terms of WAD
1328+
function _getDebtValueInBaseCurrency(
1329+
ISpoke spoke,
1330+
uint256 reserveId,
1331+
uint256 amount
1332+
) internal view returns (uint256) {
1333+
IPriceOracle oracle = spoke.oracle();
1334+
uint256 assetId = spoke.getReserve(reserveId).assetId;
1335+
return
1336+
(amount * oracle.getReservePrice(reserveId)).wadDivUp(
1337+
10 ** spoke.getReserve(reserveId).hub.getAsset(assetId).decimals
1338+
);
13071339
}
13081340

13091341
/// @notice Convert 1 asset amount to equivalent amount in another asset.
@@ -1869,6 +1901,13 @@ abstract contract Base is Test {
18691901
return spoke.getDynamicReserveConfig(reserveId).collateralFactor;
18701902
}
18711903

1904+
function _getCollateralFactor(
1905+
ISpoke spoke,
1906+
function(ISpoke) internal view returns (uint256) reserveId
1907+
) internal view returns (uint16) {
1908+
return spoke.getDynamicReserveConfig(reserveId(spoke)).collateralFactor;
1909+
}
1910+
18721911
function _hasRole(
18731912
IAccessManager authority,
18741913
uint64 role,

tests/unit/Spoke/Spoke.Borrow.Scenario.t.sol

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,4 +576,36 @@ contract SpokeBorrowScenarioTest is SpokeBase {
576576
label: 'bob dai data after'
577577
});
578578
}
579+
580+
function test_userAccountData_does_not_include_zero_cf_collateral() public {
581+
uint256 coll1ReserveId = _daiReserveId(spoke1);
582+
uint256 coll1Amount = 1000e18;
583+
uint256 coll2ReserveId = _wethReserveId(spoke1);
584+
uint256 coll2Amount = 1e18;
585+
uint256 debtReserveId = _usdxReserveId(spoke1);
586+
uint256 debtBorrowAmount = 500e6;
587+
588+
assertNotEq(_getCollateralFactor(spoke1, coll1ReserveId), 0);
589+
assertNotEq(_getCollateralFactor(spoke1, coll2ReserveId), 0);
590+
591+
uint256 coll1InBaseCurrency = _getValueInBaseCurrency(spoke1, coll1ReserveId, coll1Amount);
592+
uint256 coll2InBaseCurrency = _getValueInBaseCurrency(spoke1, coll2ReserveId, coll2Amount);
593+
594+
Utils.supplyCollateral(spoke1, coll1ReserveId, alice, coll1Amount, alice);
595+
Utils.supplyCollateral(spoke1, coll2ReserveId, alice, coll2Amount, alice);
596+
_openSupplyPosition(spoke1, debtReserveId, debtBorrowAmount);
597+
Utils.borrow(spoke1, debtReserveId, alice, debtBorrowAmount, alice);
598+
599+
(uint256 userRiskPremium, , , uint256 totalCollateralInBaseCurrency, ) = spoke1
600+
.getUserAccountData(alice);
601+
assertEq(_calculateExpectedUserRP(alice, spoke1), userRiskPremium);
602+
assertEq(coll1InBaseCurrency + coll2InBaseCurrency, totalCollateralInBaseCurrency);
603+
604+
uint16 configKey = spoke1.getUserPosition(coll1ReserveId, alice).configKey;
605+
updateCollateralFactorAtKey(spoke1, coll1ReserveId, configKey, 0);
606+
607+
(userRiskPremium, , , totalCollateralInBaseCurrency, ) = spoke1.getUserAccountData(alice);
608+
assertEq(_calculateExpectedUserRP(alice, spoke1), userRiskPremium);
609+
assertEq(coll2InBaseCurrency, totalCollateralInBaseCurrency); // coll1 is not included
610+
}
579611
}

0 commit comments

Comments
 (0)