diff --git a/snapshots/NativeTokenGateway.Operations.json b/snapshots/NativeTokenGateway.Operations.json index 85ac653d3..0de17886a 100644 --- a/snapshots/NativeTokenGateway.Operations.json +++ b/snapshots/NativeTokenGateway.Operations.json @@ -1,7 +1,7 @@ { - "borrowNative": "227997", + "borrowNative": "230143", "repayNative": "166265", - "supplyAsCollateralNative": "160205", + "supplyAsCollateralNative": "163411", "supplyNative": "135802", "withdrawNative: full": "125580", "withdrawNative: partial": "136774" diff --git a/snapshots/SignatureGateway.Operations.json b/snapshots/SignatureGateway.Operations.json index 8ed7e3c5e..9a148b03c 100644 --- a/snapshots/SignatureGateway.Operations.json +++ b/snapshots/SignatureGateway.Operations.json @@ -1,10 +1,10 @@ { - "borrowWithSig": "213175", + "borrowWithSig": "215321", "repayWithSig": "186537", "setSelfAsUserPositionManagerWithSig": "75385", - "setUsingAsCollateralWithSig": "85409", + "setUsingAsCollateralWithSig": "88615", "supplyWithSig": "152034", - "updateUserDynamicConfigWithSig": "63129", + "updateUserDynamicConfigWithSig": "65274", "updateUserRiskPremiumWithSig": "62099", "withdrawWithSig": "130834" } \ No newline at end of file diff --git a/snapshots/Spoke.Operations.ZeroRiskPremium.json b/snapshots/Spoke.Operations.ZeroRiskPremium.json index 51628e73c..1896e699b 100644 --- a/snapshots/Spoke.Operations.ZeroRiskPremium.json +++ b/snapshots/Spoke.Operations.ZeroRiskPremium.json @@ -1,6 +1,6 @@ { - "borrow: first": "189605", - "borrow: second action, same reserve": "169471", + "borrow: first": "191744", + "borrow: second action, same reserve": "171610", "liquidationCall (receiveShares): full": "295586", "liquidationCall (receiveShares): partial": "295304", "liquidationCall (reportDeficit): full": "350919", @@ -8,27 +8,27 @@ "liquidationCall: partial": "304796", "permitReserve + repay (multicall)": "164506", "permitReserve + supply (multicall)": "146828", - "permitReserve + supply + enable collateral (multicall)": "161301", + "permitReserve + supply + enable collateral (multicall)": "164507", "repay: full": "123800", "repay: partial": "128770", "setUserPositionManagersWithSig: disable": "47039", "setUserPositionManagersWithSig: enable": "68951", - "supply + enable collateral (multicall)": "141481", + "supply + enable collateral (multicall)": "144687", "supply: 0 borrows, collateral disabled": "122874", "supply: 0 borrows, collateral enabled": "105845", "supply: second action, same reserve": "105774", - "updateUserDynamicConfig: 1 collateral": "74603", - "updateUserDynamicConfig: 2 collaterals": "89520", + "updateUserDynamicConfig: 1 collateral": "76744", + "updateUserDynamicConfig: 2 collaterals": "91661", "updateUserRiskPremium: 1 borrow": "94940", "updateUserRiskPremium: 2 borrows": "104075", - "usingAsCollateral: 0 borrows, enable": "59638", - "usingAsCollateral: 1 borrow, disable": "104981", - "usingAsCollateral: 1 borrow, enable": "42526", - "usingAsCollateral: 2 borrows, disable": "126089", - "usingAsCollateral: 2 borrows, enable": "42538", - "withdraw: 0 borrows, full": "127997", - "withdraw: 0 borrows, partial": "132732", - "withdraw: 1 borrow, partial": "159169", - "withdraw: 2 borrows, partial": "173287", + "usingAsCollateral: 0 borrows, enable": "62844", + "usingAsCollateral: 1 borrow, disable": "107123", + "usingAsCollateral: 1 borrow, enable": "45732", + "usingAsCollateral: 2 borrows, disable": "128231", + "usingAsCollateral: 2 borrows, enable": "45744", + "withdraw: 0 borrows, full": "130135", + "withdraw: 0 borrows, partial": "134871", + "withdraw: 1 borrow, partial": "161308", + "withdraw: 2 borrows, partial": "175425", "withdraw: non collateral": "105920" } \ No newline at end of file diff --git a/snapshots/Spoke.Operations.json b/snapshots/Spoke.Operations.json index b90ec10ae..963dd7047 100644 --- a/snapshots/Spoke.Operations.json +++ b/snapshots/Spoke.Operations.json @@ -1,34 +1,34 @@ { - "borrow: first": "258588", - "borrow: second action, same reserve": "201454", - "liquidationCall (receiveShares): full": "327674", - "liquidationCall (receiveShares): partial": "327392", - "liquidationCall (reportDeficit): full": "345548", - "liquidationCall: full": "337166", - "liquidationCall: partial": "336884", + "borrow: first": "260734", + "borrow: second action, same reserve": "203600", + "liquidationCall (receiveShares): full": "327687", + "liquidationCall (receiveShares): partial": "327405", + "liquidationCall (reportDeficit): full": "345561", + "liquidationCall: full": "337179", + "liquidationCall: partial": "336897", "permitReserve + repay (multicall)": "161988", "permitReserve + supply (multicall)": "146828", - "permitReserve + supply + enable collateral (multicall)": "161301", + "permitReserve + supply + enable collateral (multicall)": "164507", "repay: full": "117879", "repay: partial": "137249", "setUserPositionManagersWithSig: disable": "47039", "setUserPositionManagersWithSig: enable": "68951", - "supply + enable collateral (multicall)": "141481", + "supply + enable collateral (multicall)": "144687", "supply: 0 borrows, collateral disabled": "122874", "supply: 0 borrows, collateral enabled": "105845", "supply: second action, same reserve": "105774", - "updateUserDynamicConfig: 1 collateral": "74603", - "updateUserDynamicConfig: 2 collaterals": "89520", - "updateUserRiskPremium: 1 borrow": "148344", - "updateUserRiskPremium: 2 borrows": "198096", - "usingAsCollateral: 0 borrows, enable": "59638", - "usingAsCollateral: 1 borrow, disable": "158385", - "usingAsCollateral: 1 borrow, enable": "42526", - "usingAsCollateral: 2 borrows, disable": "228110", - "usingAsCollateral: 2 borrows, enable": "42538", - "withdraw: 0 borrows, full": "127997", - "withdraw: 0 borrows, partial": "132732", - "withdraw: 1 borrow, partial": "210071", - "withdraw: 2 borrows, partial": "255841", + "updateUserDynamicConfig: 1 collateral": "76744", + "updateUserDynamicConfig: 2 collaterals": "91661", + "updateUserRiskPremium: 1 borrow": "148357", + "updateUserRiskPremium: 2 borrows": "198109", + "usingAsCollateral: 0 borrows, enable": "62844", + "usingAsCollateral: 1 borrow, disable": "160531", + "usingAsCollateral: 1 borrow, enable": "45732", + "usingAsCollateral: 2 borrows, disable": "230255", + "usingAsCollateral: 2 borrows, enable": "45744", + "withdraw: 0 borrows, full": "130135", + "withdraw: 0 borrows, partial": "134871", + "withdraw: 1 borrow, partial": "212216", + "withdraw: 2 borrows, partial": "257987", "withdraw: non collateral": "105920" } \ No newline at end of file diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index 7703c8069..b3fcf02a3 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -691,7 +691,7 @@ abstract contract Spoke is address user ) internal returns (UserAccountData memory) { UserAccountData memory accountData = _processUserAccountData(user, true); - emit RefreshAllUserDynamicConfig(user); + emit RefreshUserDynamicConfig(user, _positionStatus[user].getCollateralBitmap(_reserveCount)); require( accountData.healthFactor >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD, HealthFactorBelowThreshold() @@ -813,7 +813,10 @@ abstract contract Spoke is function _refreshDynamicConfig(address user, uint256 reserveId) internal { _userPositions[user][reserveId].dynamicConfigKey = _reserves[reserveId].dynamicConfigKey; - emit RefreshSingleUserDynamicConfig(user, reserveId); + emit RefreshUserDynamicConfig( + user, + PositionStatusMap.getSingleCollateralBitmap(reserveId, _reserveCount) + ); } /// @notice Refreshes premium for borrowed reserves of `user` with `newRiskPremium`. diff --git a/src/spoke/interfaces/ISpoke.sol b/src/spoke/interfaces/ISpoke.sol index 60445aa70..00c7ee0c2 100644 --- a/src/spoke/interfaces/ISpoke.sol +++ b/src/spoke/interfaces/ISpoke.sol @@ -204,12 +204,8 @@ interface ISpoke is ISpokeBase, IAccessManaged, IIntentConsumer, IExtSload, IMul /// @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); - - /// @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 reserveId The identifier of the reserve. - event RefreshSingleUserDynamicConfig(address indexed user, uint256 reserveId); + /// @param collateralBitmap The collateral bitmap containing only the collateral bits for the user. + event RefreshUserDynamicConfig(address indexed user, bytes collateralBitmap); /// @notice Emitted on updateUserRiskPremium action. /// @param user The owner of the position being modified. diff --git a/src/spoke/libraries/PositionStatusMap.sol b/src/spoke/libraries/PositionStatusMap.sol index e0b8be876..b451b4fbc 100644 --- a/src/spoke/libraries/PositionStatusMap.sol +++ b/src/spoke/libraries/PositionStatusMap.sol @@ -250,4 +250,95 @@ library PositionStatusMap { ret := and(word, shr(sub(256, shl(1, mod(reserveCount, 128))), COLLATERAL_MASK)) } } + + /// @notice Returns the collateral bitmap as bytes. + /// @dev Each bucket (128 reserves) is compressed to 16 bytes (uint128), with two buckets packed per 32-byte word. + /// @param reserveCount The current reserveCount to determine bucket boundaries. + /// @return The compressed collateral bitmap (16 bytes per bucket). + function getCollateralBitmap( + ISpoke.PositionStatus storage self, + uint256 reserveCount + ) internal view returns (bytes memory) { + unchecked { + uint256 bucketCount = reserveCount == 0 ? 0 : (reserveCount - 1).bucketId() + 1; + bytes memory result = new bytes(((bucketCount + 1) / 2) * 32); + + for (uint256 i; i < bucketCount; ) { + uint128 compressed0 = self.map[i].compressCollateral(); + // Compress next bucket if it exists, otherwise 0 + uint128 compressed1 = (i + 1 < bucketCount) ? self.map[i + 1].compressCollateral() : 0; + + assembly ('memory-safe') { + // Pack with compressed0 in lower 128 bits, compressed1 in upper 128 bits + // This ensures reserve N is at bit N in the continuous bitmap + let packed := or(compressed0, shl(128, compressed1)) + mstore(add(add(result, 32), shl(5, shr(1, i))), packed) + } + + i += 2; + } + + return result; + } + } + + /// @notice Returns the collateral bitmap with a signle reserve as bytes. + /// @param reserveId The reserveId to set in the bitmap. + /// @param reserveCount The current reserveCount to determine bucket boundaries. + /// @return The compressed collateral bitmap (16 bytes per bucket). + function getSingleCollateralBitmap( + uint256 reserveId, + uint256 reserveCount + ) internal pure returns (bytes memory) { + unchecked { + bytes memory result = new bytes(((reserveCount / 256) + 1) * 32); + + uint256 bucket = reserveId / 256; + uint256 positionInBucket = reserveId % 256; + + assembly ('memory-safe') { + let wordPtr := add(add(result, 32), shl(5, bucket)) + let currentWord := mload(wordPtr) + let updatedWord := or(currentWord, shl(positionInBucket, 1)) + mstore(wordPtr, updatedWord) + } + + return result; + } + } + + /// @notice Compresses collateral bits from a word into a densely packed uint128. + /// @dev Extracts odd bits (1,3,5,...,255) representing collateral flags for 128 reserves, + /// and packs them into consecutive bits (0,1,2,...,127) in a uint128. + /// @param word The word to compress. + /// @return packed The compressed uint128 containing only collateral bits. + function compressCollateral(uint256 word) internal pure returns (uint128 packed) { + assembly ('memory-safe') { + // (word & COLLATERAL_MASK) >> 1 moves bits from positions 1,3,5,... to 0,2,4,... + let x := shr(1, and(word, COLLATERAL_MASK)) + + // Progressively merge bits from sparse even positions into dense consecutive positions + // Merge bit pairs from positions (0,2) to (0,1), (4,6) to (4,5), ... + x := and(or(x, shr(1, x)), 0x3333333333333333333333333333333333333333333333333333333333333333) + // Merge nibbles from positions (0,1,4,5) to (0-3), (8,9,12,13) to (8-11), ... + x := and(or(x, shr(2, x)), 0x0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F) + // Merge bytes from positions (0-3, 8-11) to (0-7), (16-19, 24-27) to (16-23), ... + x := and(or(x, shr(4, x)), 0x00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF00FF) + // Merge 16-bit groups from positions (0-7, 16-23) to (0-15), (32-39, 48-55) to (32-47), ... + x := and(or(x, shr(8, x)), 0x0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF0000FFFF) + // Merge 32-bit groups from positions (0-15, 32-47) to (0-31), (64-79, 96-111) to (64-95), ... + x := and( + or(x, shr(16, x)), + 0x00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF00000000FFFFFFFF + ) + // Merge 64-bit groups from positions (0-31, 64-95) to (0-63), (128-159, 192-223) to (128-191). + x := and( + or(x, shr(32, x)), + 0x0000000000000000FFFFFFFFFFFFFFFF0000000000000000FFFFFFFFFFFFFFFF + ) + + // Move all 128 bits now in lower half + packed := and(or(x, shr(64, x)), 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) + } + } } diff --git a/tests/Base.t.sol b/tests/Base.t.sol index e6d714a84..3af5b7b41 100644 --- a/tests/Base.t.sol +++ b/tests/Base.t.sol @@ -2720,10 +2720,7 @@ abstract contract Base is Test { } function _assertDynamicConfigRefreshEventsNotEmitted() internal { - _assertEventsNotEmitted( - ISpoke.RefreshAllUserDynamicConfig.selector, - ISpoke.RefreshSingleUserDynamicConfig.selector - ); + _assertEventNotEmitted(ISpoke.RefreshUserDynamicConfig.selector); } // @dev Helper function to get asset position, valid if no time has passed since last action diff --git a/tests/mocks/PositionStatusMapWrapper.sol b/tests/mocks/PositionStatusMapWrapper.sol index 0774235ac..c958a1477 100644 --- a/tests/mocks/PositionStatusMapWrapper.sol +++ b/tests/mocks/PositionStatusMapWrapper.sol @@ -96,6 +96,21 @@ contract PositionStatusMapWrapper { return _p.nextCollateral(startReserveId); } + function getCollateralBitmap(uint256 reserveCount) external view returns (bytes memory) { + return _p.getCollateralBitmap(reserveCount); + } + + function getSingleCollateralBitmap( + uint256 reserveId, + uint256 reserveCount + ) external pure returns (bytes memory) { + return PositionStatusMap.getSingleCollateralBitmap(reserveId, reserveCount); + } + + function compressCollateral(uint256 bucket) external pure returns (uint128) { + return PositionStatusMap.compressCollateral(bucket); + } + function slot() external pure returns (bytes32 s) { assembly ('memory-safe') { s := _p.slot diff --git a/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol b/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol index ca16599c3..4267550b9 100644 --- a/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol +++ b/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol @@ -136,8 +136,11 @@ contract SpokeDynamicConfigTriggersTest is SpokeBase { configs = _getUserDynConfigKeys(spoke1, alice); Utils.supplyCollateral(spoke1, _wethReserveId(spoke1), alice, 1e18, alice); + uint256[] memory collateralReserves = new uint256[](2); + collateralReserves[0] = _usdxReserveId(spoke1); + collateralReserves[1] = _wethReserveId(spoke1); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshUserDynamicConfig(alice, _buildCollateralBitmap(collateralReserves)); Utils.borrow(spoke1, _daiReserveId(spoke1), alice, 100e18, alice); assertNotEq(_getUserDynConfigKeys(spoke1, alice), configs); @@ -166,8 +169,11 @@ contract SpokeDynamicConfigTriggersTest is SpokeBase { configs = _getUserDynConfigKeys(spoke1, alice); Utils.supplyCollateral(spoke1, _wethReserveId(spoke1), alice, 1e18, alice); + uint256[] memory collateralReserves = new uint256[](2); + collateralReserves[0] = _usdxReserveId(spoke1); + collateralReserves[1] = _wethReserveId(spoke1); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshUserDynamicConfig(alice, _buildCollateralBitmap(collateralReserves)); Utils.withdraw(spoke1, _usdxReserveId(spoke1), alice, 500e6, alice); assertNotEq(_getUserDynConfigKeys(spoke1, alice), configs); @@ -195,7 +201,10 @@ contract SpokeDynamicConfigTriggersTest is SpokeBase { // when enabling, only the relevant asset is refreshed vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshSingleUserDynamicConfig(alice, _wethReserveId(spoke1)); + emit ISpoke.RefreshUserDynamicConfig( + alice, + _buildCollateralBitmap(_wethReserveId(spoke1), spoke1.getReserveCount()) + ); vm.prank(alice); spoke1.setUsingAsCollateral(_wethReserveId(spoke1), true, alice); @@ -206,8 +215,11 @@ contract SpokeDynamicConfigTriggersTest is SpokeBase { assertNotEq(abi.encode(userConfig), abi.encode(spokeConfig)); // when disabling all configs are refreshed + // Note: bitmap reflects state AFTER the collateral is disabled, so only WETH remains + uint256[] memory remainingCollateral = new uint256[](1); + remainingCollateral[0] = _wethReserveId(spoke1); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshUserDynamicConfig(alice, _buildCollateralBitmap(remainingCollateral)); vm.prank(alice); spoke1.setUsingAsCollateral(_usdxReserveId(spoke1), false, alice); @@ -228,8 +240,11 @@ contract SpokeDynamicConfigTriggersTest is SpokeBase { assertNotEq(_getSpokeDynConfigKeys(spoke1), configs); // manually trigger update + uint256[] memory collateralReserves = new uint256[](2); + collateralReserves[0] = _usdxReserveId(spoke1); + collateralReserves[1] = _wethReserveId(spoke1); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshUserDynamicConfig(alice, _buildCollateralBitmap(collateralReserves)); vm.prank(alice); spoke1.updateUserDynamicConfig(alice); @@ -337,8 +352,11 @@ contract SpokeDynamicConfigTriggersTest is SpokeBase { ) internal { uint256 snapshotId = vm.snapshotState(); + uint256[] memory collateralReserves = new uint256[](2); + collateralReserves[0] = _usdxReserveId(spoke1); + collateralReserves[1] = _wethReserveId(spoke1); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshUserDynamicConfig(alice, _buildCollateralBitmap(collateralReserves)); vm.prank(caller); spoke1.updateUserDynamicConfig(alice); diff --git a/tests/unit/Spoke/Spoke.PositionManager.t.sol b/tests/unit/Spoke/Spoke.PositionManager.t.sol index 91b1d117d..8e95d7004 100644 --- a/tests/unit/Spoke/Spoke.PositionManager.t.sol +++ b/tests/unit/Spoke/Spoke.PositionManager.t.sol @@ -283,8 +283,11 @@ contract SpokePositionManagerTest is SpokeBase { _approvePositionManager(alice); + uint256[] memory collateralReserves = new uint256[](2); + collateralReserves[0] = _wethReserveId(spoke1); + collateralReserves[1] = _daiReserveId(spoke1); vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); + emit ISpoke.RefreshUserDynamicConfig(alice, _buildCollateralBitmap(collateralReserves)); vm.prank(POSITION_MANAGER); spoke1.updateUserDynamicConfig(alice); diff --git a/tests/unit/Spoke/Spoke.SetUsingAsCollateral.t.sol b/tests/unit/Spoke/Spoke.SetUsingAsCollateral.t.sol index b5bc7191c..c616a0045 100644 --- a/tests/unit/Spoke/Spoke.SetUsingAsCollateral.t.sol +++ b/tests/unit/Spoke/Spoke.SetUsingAsCollateral.t.sol @@ -146,8 +146,8 @@ contract SpokeSetUsingAsCollateralTest is SpokeBase { Utils.setUsingAsCollateral(spoke1, daiReserveId, bob, true, bob); _assertEventsNotEmitted( ISpoke.SetUsingAsCollateral.selector, - ISpoke.RefreshSingleUserDynamicConfig.selector, - ISpoke.RefreshAllUserDynamicConfig.selector + ISpoke.RefreshUserDynamicConfig.selector, + ISpoke.RefreshUserDynamicConfig.selector ); assertTrue(_isUsingAsCollateral(spoke1, daiReserveId, bob)); diff --git a/tests/unit/Spoke/SpokeBase.t.sol b/tests/unit/Spoke/SpokeBase.t.sol index fce1e58fd..b1c991237 100644 --- a/tests/unit/Spoke/SpokeBase.t.sol +++ b/tests/unit/Spoke/SpokeBase.t.sol @@ -1141,6 +1141,71 @@ contract SpokeBase is Base { }); } + function _buildCollateralBitmap( + uint256[] memory reserveIds + ) internal pure returns (bytes memory) { + if (reserveIds.length == 0) return ''; + + // Find the max bucket needed + uint256 maxBucket = 0; + for (uint256 i = 0; i < reserveIds.length; ++i) { + uint256 bucket = reserveIds[i] / 256; + if (bucket > maxBucket) maxBucket = bucket; + } + + // Allocate bytes for all buckets (compressed format) + bytes memory result = new bytes((maxBucket + 1) * 32); + + // Set the collateral bits for each reserve in compressed format + for (uint256 i = 0; i < reserveIds.length; ++i) { + uint256 reserveId = reserveIds[i]; + uint256 bucket = reserveId / 256; + uint256 bitPosition = reserveId % 256; + + uint256 offset = 32 + bucket * 32; + + // Read current word, set bit, write back + uint256 word; + assembly { + word := mload(add(result, offset)) + } + word |= (1 << bitPosition); + assembly { + mstore(add(result, offset), word) + } + } + + return result; + } + + function _buildCollateralBitmap( + uint256 reserveId, + uint256 reserveCount + ) internal pure returns (bytes memory) { + if (reserveCount == 0) return ''; + + uint256 expectedBuckets = (reserveCount + 255) / 256; + // Allocate bytes for all buckets (compressed format) + bytes memory result = new bytes(expectedBuckets * 32); + + uint256 bucketIndex = reserveId / 256; + uint256 bitPosition = reserveId % 256; + + uint256 offset = 32 + bucketIndex * 32; + + // Read current word, set bit, write back + uint256 word; + assembly { + word := mload(add(result, offset)) + } + word |= (1 << bitPosition); + assembly { + mstore(add(result, offset), word) + } + + return result; + } + /// @dev Helper to etch spoke's implementation with a new maxUserReservesLimit function _updateMaxUserReservesLimit(ISpoke spoke, uint16 newLimit) internal { address currentImpl = _getImplementationAddress(address(spoke)); diff --git a/tests/unit/libraries/PositionStatusMap.t.sol b/tests/unit/libraries/PositionStatusMap.t.sol index d0d74aa36..51a24ab2f 100644 --- a/tests/unit/libraries/PositionStatusMap.t.sol +++ b/tests/unit/libraries/PositionStatusMap.t.sol @@ -551,4 +551,184 @@ contract PositionStatusMapTest is Base { } assertEq(LibBit.fls(0), 256); } + + function test_compressCollateral(uint256 word) public view { + uint128 packed = p.compressCollateral(word); + for (uint256 i; i < 128; ++i) { + bool originalBit = (word >> (i * 2 + 1)) & 1 != 0; + bool packedBit = (packed >> i) & 1 != 0; + assertEq(packedBit, originalBit); + } + } + + function test_getCollateralBitmap_emptyWhenNoReserves() public view { + bytes memory bitmap = p.getCollateralBitmap(0); + assertEq(bitmap.length, 0); + } + + function test_getCollateralBitmap_singleBucket() public { + // Set collateral for reserves 0, 3, and 5 + p.setUsingAsCollateral(0, true); + p.setUsingAsCollateral(3, true); + p.setUsingAsCollateral(5, true); + + bytes memory bitmap = p.getCollateralBitmap(10); + assertEq(bitmap.length, 32); + + // Verify the bitmap contains the expected collateral bits + uint256 word; + assembly { + word := mload(add(bitmap, 32)) + } + + // Collateral bits are at the position represented by their reserveId + // Reserve 0: bit 0, Reserve 3: bit 3, Reserve 5: bit 5 + uint256 expected = (1 << 0) | (1 << 3) | (1 << 5); + // Apply collateral mask to verify only collateral bits are set + assertEq(word, expected); + } + + function test_getCollateralBitmap_multipleBuckets() public { + // Set collateral across multiple buckets + p.setUsingAsCollateral(0, true); // bucket 0 + p.setUsingAsCollateral(127, true); // bucket 0 + p.setUsingAsCollateral(128, true); // bucket 1 + p.setUsingAsCollateral(255, true); // bucket 1 + + bytes memory bitmap = p.getCollateralBitmap(256); + assertEq(bitmap.length, 32); + + uint256 word; + assembly { + word := mload(add(bitmap, 32)) + } + + // Verify bucket 0 has collateral bits for reserves 0 and 127 + uint256 expected0 = (1 << 0) | (1 << 127); // bits 0 and 127 + // Verify bucket 1 has collateral bits for reserves 128 and 255 + uint256 expected1 = (1 << 0) | (1 << 127); // bits 0 and 127 (relative to bucket 1) + + uint256 expectedCompressed = expected0 | (expected1 << 128); + + assertEq(word, expectedCompressed); + } + + function test_getCollateralBitmap_excludesBorrowingBits() public { + // Set both collateral and borrowing for same reserve + p.setUsingAsCollateral(5, true); + p.setBorrowing(5, true); + + // Also set borrowing-only for another reserve + p.setBorrowing(10, true); + + bytes memory bitmap = p.getCollateralBitmap(20); + assertEq(bitmap.length, 32); + + uint256 word; + assembly { + word := mload(add(bitmap, 32)) + } + + // Only reserve 5 collateral bit should be set (bit 5) + // Borrowing bits should not be present + uint256 expected = (1 << 5); + assertEq(word, expected); + } + + function test_getCollateralBitmap_fuzz(uint256 reserveCount) public { + reserveCount = bound(reserveCount, 1, 512); // up to 4 buckets + + // Randomly set collateral for some reserves + for (uint256 i = 0; i < reserveCount; ++i) { + p.setUsingAsCollateral(i, vm.randomBool()); + p.setBorrowing(i, vm.randomBool()); // should not affect bitmap + } + + bytes memory bitmap = p.getCollateralBitmap(reserveCount); + uint256 expectedBuckets = (reserveCount + 255) / 256; + assertEq(bitmap.length, expectedBuckets * 32); + + // Verify each collateral bit matches + for (uint256 i = 0; i < reserveCount; ++i) { + uint256 bucketIndex = i / 256; + uint256 bitPosition = i % 256; + + uint256 word; + assembly { + word := mload(add(add(bitmap, 32), mul(bucketIndex, 32))) + } + + bool bitSet = (word >> bitPosition) & 1 == 1; + assertEq(bitSet, p.isUsingAsCollateral(i)); + } + } + + function test_getSingleCollateralBitmap() public view { + bytes memory bitmap = p.getSingleCollateralBitmap(0, 50); + assertEq(bitmap.length, 32); + uint256 word; + assembly { + word := mload(add(bitmap, 32)) + } + uint256 expected = (1 << 0); + assertEq(word, expected); + + bitmap = p.getSingleCollateralBitmap(7, 50); + assertEq(bitmap.length, 32); + assembly { + word := mload(add(bitmap, 32)) + } + expected = (1 << 7); + assertEq(word, expected); + + bitmap = p.getSingleCollateralBitmap(25, 50); + assertEq(bitmap.length, 32); + assembly { + word := mload(add(bitmap, 32)) + } + expected = (1 << 25); + assertEq(word, expected); + + bitmap = p.getSingleCollateralBitmap(49, 50); + assertEq(bitmap.length, 32); + assembly { + word := mload(add(bitmap, 32)) + } + expected = (1 << 49); + assertEq(word, expected); + } + + function test_getSingleCollateralBitmap_fuzz( + uint256 reserveId, + uint256 reserveCount + ) public view { + reserveCount = bound(reserveCount, 1, 1024); + reserveId = bound(reserveId, 0, reserveCount - 1); + + bytes memory bitmap = p.getSingleCollateralBitmap(reserveId, reserveCount); + + uint256 expectedBuckets = (reserveCount / 256) + 1; + assertEq(bitmap.length, expectedBuckets * 32); + + uint256 bucketIndex = reserveId / 256; + uint256 bitPosition = reserveId % 256; + + uint256 word; + assembly { + word := mload(add(add(bitmap, 32), mul(bucketIndex, 32))) + } + uint256 expected = (1 << bitPosition); + assertEq(word, expected); + + // verify other words are empty + for (uint256 i; i < expectedBuckets; ++i) { + if (i != bucketIndex) { + uint256 otherWord; + assembly { + otherWord := mload(add(add(bitmap, 32), mul(i, 32))) + } + assertEq(otherWord, 0); + } + } + } } diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.t.sol index 7a0d19e52..1bfc91c8c 100644 --- a/tests/unit/misc/SignatureGateway/SignatureGateway.t.sol +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.t.sol @@ -224,9 +224,9 @@ contract SignatureGatewayTest is SignatureGatewayBaseTest { p.nonce = _burnRandomNoncesAtKey(gateway, alice); bytes memory signature = _sign(alicePk, _getTypedDataHash(gateway, p)); + // alice has no collateral set up, so bitmap has all zeros (but sized for reserve count) vm.expectEmit(address(spoke1)); - emit ISpoke.RefreshAllUserDynamicConfig(alice); - + emit ISpoke.RefreshUserDynamicConfig(alice, new bytes(32)); vm.prank(vm.randomAddress()); gateway.updateUserDynamicConfigWithSig(p, signature);