From 52b9593125d67d9d18d95e377dce21ad97ecc4a8 Mon Sep 17 00:00:00 2001 From: AlbertoCentonze <11707683+AlbertoCentonze@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:58:45 +0200 Subject: [PATCH 1/3] feat: add permissioned borrow access manager --- .../PermissionedBorrowAccessManager.sol | 52 +++++ .../interfaces/IBorrowerEligibility.sol | 10 + .../AccessManagedUpgradeable.sol | 6 +- .../openzeppelin/AccessManager.sol | 57 ++++++ .../openzeppelin/IAccessManager.sol | 11 ++ src/spoke/Spoke.sol | 16 +- .../PermissionedBorrowAccessManager.t.sol | 178 ++++++++++++++++++ .../Spoke.PositionManager.t.sol | 26 ++- tests/helpers/mocks/MockSpoke.sol | 2 +- 9 files changed, 332 insertions(+), 26 deletions(-) create mode 100644 src/access/PermissionedBorrowAccessManager.sol create mode 100644 src/access/interfaces/IBorrowerEligibility.sol create mode 100644 tests/contracts/access/PermissionedBorrowAccessManager.t.sol diff --git a/src/access/PermissionedBorrowAccessManager.sol b/src/access/PermissionedBorrowAccessManager.sol new file mode 100644 index 000000000..08a6b029a --- /dev/null +++ b/src/access/PermissionedBorrowAccessManager.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity 0.8.28; + +import {AccessManagerEnumerable} from 'src/access/AccessManagerEnumerable.sol'; +import {IBorrowerEligibility} from 'src/access/interfaces/IBorrowerEligibility.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @title PermissionedBorrowAccessManager +/// @author Aave Labs +/// @notice Contextual access manager that restricts borrowing to eligible position owners. +/// @dev Non-borrow position actions retain the Spoke's standard position-manager authorization. +/// Explicit AccessManager roles can authorize callers before this custom policy is evaluated. +contract PermissionedBorrowAccessManager is AccessManagerEnumerable { + /// @notice The Spoke controlled by this access manager. + ISpoke public immutable SPOKE; + + /// @notice The provider used to determine borrower eligibility. + IBorrowerEligibility public immutable BORROWER_ELIGIBILITY; + + /// @dev Constructor. + /// @param initialAdmin_ The address of the initial admin. + /// @param spoke_ The Spoke controlled by this access manager. + /// @param borrowerEligibility_ The provider used to determine borrower eligibility. + constructor( + address initialAdmin_, + ISpoke spoke_, + IBorrowerEligibility borrowerEligibility_ + ) AccessManagerEnumerable(initialAdmin_) { + require(address(spoke_) != address(0), ISpoke.InvalidAddress()); + require(address(borrowerEligibility_) != address(0), ISpoke.InvalidAddress()); + SPOKE = spoke_; + BORROWER_ELIGIBILITY = borrowerEligibility_; + } + + /// @dev Extends the default position-manager policy with borrower eligibility. + function _isPositionActionAllowed( + address caller, + address target, + bytes calldata data + ) internal view override returns (bool handled, bool allowed) { + if (target != address(SPOKE)) return super._isPositionActionAllowed(caller, target, data); + + (bool valid, address onBehalfOf) = _decodePositionAction(data); + if (!valid) return (true, false); + + (, allowed) = super._isPositionActionAllowed(caller, target, data); + if (!allowed) return (true, false); + + allowed = bytes4(data) != ISpoke.borrow.selector || BORROWER_ELIGIBILITY.isEligible(onBehalfOf); + return (true, allowed); + } +} diff --git a/src/access/interfaces/IBorrowerEligibility.sol b/src/access/interfaces/IBorrowerEligibility.sol new file mode 100644 index 000000000..833f30b3a --- /dev/null +++ b/src/access/interfaces/IBorrowerEligibility.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +/// @title IBorrowerEligibility +/// @author Aave Labs +/// @notice Interface for a provider of permissioned-borrowing eligibility. +interface IBorrowerEligibility { + /// @notice Returns whether `account` is eligible to borrow. + function isEligible(address account) external view returns (bool); +} diff --git a/src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.sol b/src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.sol index ceda3ced0..88bde226a 100644 --- a/src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.sol +++ b/src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.20; -import {AuthorityUtils} from '../openzeppelin/AuthorityUtils.sol'; import {IAccessManager} from '../openzeppelin/IAccessManager.sol'; import {IAccessManaged} from '../openzeppelin/IAccessManaged.sol'; import {ContextUpgradeable} from './ContextUpgradeable.sol'; @@ -114,11 +113,10 @@ abstract contract AccessManagedUpgradeable is Initializable, ContextUpgradeable, */ function _checkCanCall(address caller, bytes calldata data) internal virtual { AccessManagedStorage storage $ = _getAccessManagedStorage(); - (bool immediate, uint32 delay) = AuthorityUtils.canCallWithDelay( - authority(), + (bool immediate, uint32 delay) = IAccessManager(authority()).canCall( caller, address(this), - bytes4(data[0:4]) + data ); if (!immediate) { if (delay > 0) { diff --git a/src/dependencies/openzeppelin/AccessManager.sol b/src/dependencies/openzeppelin/AccessManager.sol index c3b32bb6d..d213fbcf7 100644 --- a/src/dependencies/openzeppelin/AccessManager.sol +++ b/src/dependencies/openzeppelin/AccessManager.sol @@ -11,6 +11,7 @@ import {Multicall} from './Multicall.sol'; import {Math} from './Math.sol'; import {Time} from './Time.sol'; import {Hashes} from './Hashes.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; /** * @dev AccessManager is a central contract to store the permissions of a system. @@ -155,6 +156,62 @@ contract AccessManager is Context, Multicall, IAccessManager { } } + /// @inheritdoc IAccessManager + function canCall( + address caller, + address target, + bytes calldata data + ) public view virtual returns (bool immediate, uint32 delay) { + if (data.length < 4) return (false, 0); + + if (_isPositionAction(bytes4(data))) { + (bool handled, bool allowed) = _isPositionActionAllowed(caller, target, data); + if (handled) return (allowed, 0); + } + + return canCall(caller, target, bytes4(data)); + } + + /// @notice Returns whether a position action is allowed by its contextual policy. + /// @dev The default policy preserves the Spoke's position-manager authorization. + function _isPositionActionAllowed( + address caller, + address target, + bytes calldata data + ) internal view virtual returns (bool handled, bool allowed) { + (bool valid, address onBehalfOf) = _decodePositionAction(data); + if (!valid) return (false, false); + + (bool success, bytes memory result) = target.staticcall( + abi.encodeCall(ISpoke.isPositionManager, (onBehalfOf, caller)) + ); + if (!success || result.length != 32) return (false, false); + return (true, abi.decode(result, (bool))); + } + + /// @notice Decodes the position owner from supported position-action calldata. + function _decodePositionAction( + bytes calldata data + ) internal pure returns (bool valid, address onBehalfOf) { + if (data.length != 100 || !_isPositionAction(bytes4(data))) return (false, address(0)); + + uint256 encodedOnBehalfOf; + assembly ('memory-safe') { + encodedOnBehalfOf := calldataload(add(data.offset, 68)) + } + if (encodedOnBehalfOf > type(uint160).max) return (false, address(0)); + return (true, address(uint160(encodedOnBehalfOf))); + } + + function _isPositionAction(bytes4 selector) internal pure returns (bool) { + return + selector == ISpoke.supply.selector || + selector == ISpoke.withdraw.selector || + selector == ISpoke.borrow.selector || + selector == ISpoke.repay.selector || + selector == ISpoke.setUsingAsCollateral.selector; + } + /// @inheritdoc IAccessManager function expiration() public view virtual returns (uint32) { return 1 weeks; diff --git a/src/dependencies/openzeppelin/IAccessManager.sol b/src/dependencies/openzeppelin/IAccessManager.sol index be3ce2a10..709bc91fe 100644 --- a/src/dependencies/openzeppelin/IAccessManager.sol +++ b/src/dependencies/openzeppelin/IAccessManager.sol @@ -124,6 +124,17 @@ interface IAccessManager { bytes4 selector ) external view returns (bool allowed, uint32 delay); + /** + * @dev Contextual overload of {canCall} that receives the complete target calldata. + * The default AccessManager implementation authorizes by selector, while derived managers may + * additionally inspect call arguments. + */ + function canCall( + address caller, + address target, + bytes calldata data + ) external view returns (bool allowed, uint32 delay); + /** * @dev Expiration delay for scheduled proposals. Defaults to 1 week. * diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index 9dd7beab9..d7d7f3ac5 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -86,12 +86,6 @@ abstract contract Spoke is uint256 internal constant DUST_LIQUIDATION_THRESHOLD = LiquidationLogic.DUST_LIQUIDATION_THRESHOLD; - /// @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()); - _; - } - /// @dev Constructor. /// @param oracle_ The address of the AaveOracle contract. /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. @@ -226,7 +220,7 @@ abstract contract Spoke is uint256 reserveId, uint256 amount, address onBehalfOf - ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + ) external nonReentrant restricted returns (uint256, uint256) { Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; _validateSupply(reserve.flags); @@ -245,7 +239,7 @@ abstract contract Spoke is uint256 reserveId, uint256 amount, address onBehalfOf - ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + ) external nonReentrant restricted returns (uint256, uint256) { Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; _validateWithdraw(reserve.flags); @@ -275,7 +269,7 @@ abstract contract Spoke is uint256 reserveId, uint256 amount, address onBehalfOf - ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + ) external nonReentrant restricted returns (uint256, uint256) { Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; @@ -306,7 +300,7 @@ abstract contract Spoke is uint256 reserveId, uint256 amount, address onBehalfOf - ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + ) external nonReentrant restricted returns (uint256, uint256) { Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; _validateRepay(reserve.flags); @@ -392,7 +386,7 @@ abstract contract Spoke is uint256 reserveId, bool usingAsCollateral, address onBehalfOf - ) external nonReentrant onlyPositionManager(onBehalfOf) { + ) external nonReentrant restricted { Reserve storage reserve = _reserves.get(reserveId); PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; if (positionStatus.isUsingAsCollateral(reserveId) == usingAsCollateral) { diff --git a/tests/contracts/access/PermissionedBorrowAccessManager.t.sol b/tests/contracts/access/PermissionedBorrowAccessManager.t.sol new file mode 100644 index 000000000..a48a70a60 --- /dev/null +++ b/tests/contracts/access/PermissionedBorrowAccessManager.t.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from 'forge-std/Test.sol'; +import {PermissionedBorrowAccessManager} from 'src/access/PermissionedBorrowAccessManager.sol'; +import {IBorrowerEligibility} from 'src/access/interfaces/IBorrowerEligibility.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +contract PermissionedBorrowAccessManagerTest is Test { + address internal constant ADMIN = address(0xA11CE); + address internal constant SPOKE = address(0x5A0CE); + address internal constant ELIGIBILITY = address(0xE11B1E); + address internal constant CALLER = address(0xCA11E2); + address internal constant ON_BEHALF_OF = address(0xB0220); + address internal constant OTHER_TARGET = address(0x0A7E2); + + uint64 internal constant GLOBAL_MANAGER_ROLE = 1; + + PermissionedBorrowAccessManager internal accessManager; + + function setUp() public { + accessManager = new PermissionedBorrowAccessManager( + ADMIN, + ISpoke(SPOKE), + IBorrowerEligibility(ELIGIBILITY) + ); + } + + function test_canCall_borrow_whenPositionManagerAndEligible() public { + _mockPositionManager(CALLER, ON_BEHALF_OF, true); + _mockEligibility(ON_BEHALF_OF, true); + + (bool immediate, uint32 delay) = accessManager.canCall( + CALLER, + SPOKE, + abi.encodeCall(ISpoke.borrow, (1, 100e6, ON_BEHALF_OF)) + ); + + assertTrue(immediate); + assertEq(delay, 0); + } + + function test_canCall_borrow_rejectsIneligiblePositionOwner() public { + _mockPositionManager(CALLER, ON_BEHALF_OF, true); + _mockEligibility(ON_BEHALF_OF, false); + + (bool immediate, uint32 delay) = accessManager.canCall( + CALLER, + SPOKE, + abi.encodeCall(ISpoke.borrow, (1, 100e6, ON_BEHALF_OF)) + ); + + assertFalse(immediate); + assertEq(delay, 0); + } + + function test_canCall_borrow_rejectsCallerWithoutPositionApproval() public { + _mockPositionManager(CALLER, ON_BEHALF_OF, false); + _mockEligibility(ON_BEHALF_OF, true); + + (bool immediate, uint32 delay) = accessManager.canCall( + CALLER, + SPOKE, + abi.encodeCall(ISpoke.borrow, (1, 100e6, ON_BEHALF_OF)) + ); + + assertFalse(immediate); + assertEq(delay, 0); + } + + function test_canCall_nonBorrowPositionAction_preservesPositionManagerApproval() public { + _mockPositionManager(CALLER, ON_BEHALF_OF, true); + + (bool immediate, uint32 delay) = accessManager.canCall( + CALLER, + SPOKE, + abi.encodeCall(ISpoke.withdraw, (1, 100e6, ON_BEHALF_OF)) + ); + + assertTrue(immediate); + assertEq(delay, 0); + } + + function test_canCall_explicitRoleDoesNotBypassPositionPolicy() public { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = ISpoke.borrow.selector; + + vm.startPrank(ADMIN); + accessManager.grantRole(GLOBAL_MANAGER_ROLE, CALLER, 0); + accessManager.setTargetFunctionRole(SPOKE, selectors, GLOBAL_MANAGER_ROLE); + vm.stopPrank(); + + _mockPositionManager(CALLER, ON_BEHALF_OF, false); + _mockEligibility(ON_BEHALF_OF, false); + + (bool immediate, uint32 delay) = accessManager.canCall( + CALLER, + SPOKE, + abi.encodeCall(ISpoke.borrow, (1, 100e6, ON_BEHALF_OF)) + ); + + assertFalse(immediate); + assertEq(delay, 0); + } + + function test_canCall_rejectsUnknownTarget() public view { + (bool immediate, uint32 delay) = accessManager.canCall( + CALLER, + OTHER_TARGET, + abi.encodeCall(ISpoke.borrow, (1, 100e6, ON_BEHALF_OF)) + ); + + assertFalse(immediate); + assertEq(delay, 0); + } + + function test_canCall_rejectsUnknownSelector() public view { + (bool immediate, uint32 delay) = accessManager.canCall( + CALLER, + SPOKE, + abi.encodeWithSelector( + bytes4(keccak256('unknown(uint256,uint256,address)')), + 1, + 2, + ON_BEHALF_OF + ) + ); + + assertFalse(immediate); + assertEq(delay, 0); + } + + function test_canCall_rejectsMalformedCalldata() public view { + (bool immediate, uint32 delay) = accessManager.canCall( + CALLER, + SPOKE, + abi.encodePacked(ISpoke.borrow.selector) + ); + + assertFalse(immediate); + assertEq(delay, 0); + } + + function test_canCall_rejectsDirtyAddressCalldata() public view { + bytes memory data = abi.encodeCall(ISpoke.borrow, (1, 100e6, ON_BEHALF_OF)); + assembly ('memory-safe') { + mstore(add(data, 100), or(mload(add(data, 100)), shl(160, 1))) + } + + (bool immediate, uint32 delay) = accessManager.canCall(CALLER, SPOKE, data); + + assertFalse(immediate); + assertEq(delay, 0); + } + + function test_selectorCanCall_retainsOrdinaryAccessManagerBehavior() public view { + (bool immediate, uint32 delay) = accessManager.canCall(ADMIN, SPOKE, ISpoke.borrow.selector); + + assertTrue(immediate); + assertEq(delay, 0); + } + + function _mockPositionManager(address caller, address onBehalfOf, bool allowed) internal { + vm.mockCall( + SPOKE, + abi.encodeCall(ISpoke.isPositionManager, (onBehalfOf, caller)), + abi.encode(allowed) + ); + } + + function _mockEligibility(address onBehalfOf, bool eligible) internal { + vm.mockCall( + ELIGIBILITY, + abi.encodeCall(IBorrowerEligibility.isEligible, (onBehalfOf)), + abi.encode(eligible) + ); + } +} diff --git a/tests/contracts/spoke/position-manager/Spoke.PositionManager.t.sol b/tests/contracts/spoke/position-manager/Spoke.PositionManager.t.sol index b018aa217..a11bd52f3 100644 --- a/tests/contracts/spoke/position-manager/Spoke.PositionManager.t.sol +++ b/tests/contracts/spoke/position-manager/Spoke.PositionManager.t.sol @@ -53,7 +53,7 @@ contract SpokePositionManagerTest is Base { uint256 reserveId = _usdxReserveId(spoke1); uint256 amount = 100e6; - vm.expectRevert(ISpoke.Unauthorized.selector); + _expectPositionManagerUnauthorized(); vm.prank(POSITION_MANAGER); spoke1.supply(reserveId, amount, alice); @@ -85,7 +85,7 @@ contract SpokePositionManagerTest is Base { assertEq(spoke1.getUserSuppliedAssets(reserveId, alice), amount); _disablePositionManager(); - vm.expectRevert(ISpoke.Unauthorized.selector); + _expectPositionManagerUnauthorized(); SpokeActions.supply({ spoke: spoke1, reserveId: reserveId, @@ -106,7 +106,7 @@ contract SpokePositionManagerTest is Base { onBehalfOf: alice }); - vm.expectRevert(ISpoke.Unauthorized.selector); + _expectPositionManagerUnauthorized(); SpokeActions.withdraw({ spoke: spoke1, reserveId: reserveId, @@ -144,7 +144,7 @@ contract SpokePositionManagerTest is Base { assertEq(spoke1.getUserSuppliedAssets(reserveId, alice), amount); _disablePositionManager(); - vm.expectRevert(ISpoke.Unauthorized.selector); + _expectPositionManagerUnauthorized(); SpokeActions.withdraw({ spoke: spoke1, reserveId: reserveId, @@ -165,7 +165,7 @@ contract SpokePositionManagerTest is Base { onBehalfOf: alice }); - vm.expectRevert(ISpoke.Unauthorized.selector); + _expectPositionManagerUnauthorized(); SpokeActions.borrow({ spoke: spoke1, reserveId: reserveId, @@ -204,7 +204,7 @@ contract SpokePositionManagerTest is Base { assertTrue(_isBorrowing(spoke1, reserveId, alice)); _disablePositionManager(); - vm.expectRevert(ISpoke.Unauthorized.selector); + _expectPositionManagerUnauthorized(); SpokeActions.borrow({ spoke: spoke1, reserveId: reserveId, @@ -232,7 +232,7 @@ contract SpokePositionManagerTest is Base { onBehalfOf: alice }); - vm.expectRevert(ISpoke.Unauthorized.selector); + _expectPositionManagerUnauthorized(); SpokeActions.repay({ spoke: spoke1, reserveId: reserveId, @@ -293,7 +293,7 @@ contract SpokePositionManagerTest is Base { assertFalse(_isBorrowing(spoke1, reserveId, alice)); _disablePositionManager(); - vm.expectRevert(ISpoke.Unauthorized.selector); + _expectPositionManagerUnauthorized(); SpokeActions.repay({ spoke: spoke1, reserveId: reserveId, @@ -309,7 +309,7 @@ contract SpokePositionManagerTest is Base { bool usingAsCollateral = true; - vm.expectRevert(ISpoke.Unauthorized.selector); + _expectPositionManagerUnauthorized(); SpokeActions.setUsingAsCollateral({ spoke: spoke1, reserveId: reserveId, @@ -338,7 +338,7 @@ contract SpokePositionManagerTest is Base { assertEq(_isUsingAsCollateral(spoke1, reserveId, alice), usingAsCollateral); _disablePositionManager(); - vm.expectRevert(ISpoke.Unauthorized.selector); + _expectPositionManagerUnauthorized(); SpokeActions.setUsingAsCollateral({ spoke: spoke1, reserveId: reserveId, @@ -401,6 +401,12 @@ contract SpokePositionManagerTest is Base { spoke1.updateUserRiskPremium(alice); } + function _expectPositionManagerUnauthorized() internal { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, POSITION_MANAGER) + ); + } + function test_onlyPositionManager_on_updateUserDynamicConfig() public { _openSupplyPosition(spoke1, _usdxReserveId(spoke1), 1500e6); SpokeActions.supplyCollateral({ diff --git a/tests/helpers/mocks/MockSpoke.sol b/tests/helpers/mocks/MockSpoke.sol index adb49b805..812b11f3d 100644 --- a/tests/helpers/mocks/MockSpoke.sol +++ b/tests/helpers/mocks/MockSpoke.sol @@ -37,7 +37,7 @@ contract MockSpoke is Spoke, Test { uint256 reserveId, uint256 amount, address onBehalfOf - ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + ) external nonReentrant restricted returns (uint256, uint256) { Reserve storage reserve = _reserves.get(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; From 746bab4f07082e648349b8a68c5e8cdee3f8befa Mon Sep 17 00:00:00 2001 From: AlbertoCentonze <11707683+AlbertoCentonze@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:16:17 +0200 Subject: [PATCH 2/3] test: add permissioned borrow gas snapshots --- snapshots/PermissionedBorrow.Operations.json | 13 ++ .../PermissionedBorrow.Operations.gas.t.sol | 132 ++++++++++++++++++ .../helpers/mocks/MockBorrowerEligibility.sol | 16 +++ 3 files changed, 161 insertions(+) create mode 100644 snapshots/PermissionedBorrow.Operations.json create mode 100644 tests/gas/PermissionedBorrow.Operations.gas.t.sol create mode 100644 tests/helpers/mocks/MockBorrowerEligibility.sol diff --git a/snapshots/PermissionedBorrow.Operations.json b/snapshots/PermissionedBorrow.Operations.json new file mode 100644 index 000000000..acb12c196 --- /dev/null +++ b/snapshots/PermissionedBorrow.Operations.json @@ -0,0 +1,13 @@ +{ + "borrow: default manager": "302603", + "borrow: permissioned manager": "308697", + "repay: partial, default manager": "155164", + "repay: partial, permissioned manager": "155849", + "supply: default manager": "136432", + "supply: permissioned manager": "137066", + "updateAuthority: permissioned manager": "46444", + "usingAsCollateral: enable, default manager": "68435", + "usingAsCollateral: enable, permissioned manager": "69136", + "withdraw: partial, default manager": "190049", + "withdraw: partial, permissioned manager": "190700" +} \ No newline at end of file diff --git a/tests/gas/PermissionedBorrow.Operations.gas.t.sol b/tests/gas/PermissionedBorrow.Operations.gas.t.sol new file mode 100644 index 000000000..f440b0982 --- /dev/null +++ b/tests/gas/PermissionedBorrow.Operations.gas.t.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/setup/Base.t.sol'; + +import {PermissionedBorrowAccessManager} from 'src/access/PermissionedBorrowAccessManager.sol'; +import {MockBorrowerEligibility} from 'tests/helpers/mocks/MockBorrowerEligibility.sol'; + +/// forge-config: default.isolate = true +contract PermissionedBorrowOperations_Gas_Tests is Base { + string internal NAMESPACE = 'PermissionedBorrow.Operations'; + + ISpoke internal spoke; + IAccessManager internal defaultAccessManager; + PermissionedBorrowAccessManager internal permissionedAccessManager; + MockBorrowerEligibility internal eligibility; + + uint256 internal wethReserveId; + uint256 internal usdxReserveId; + + function setUp() public virtual override { + super.setUp(); + + // Match PR #1334's isolated permissioned-Spoke setup. + TestTypes.TestEnvReport memory report = AaveV4TestOrchestration.deployTestEnv({ + admin: ADMIN, + treasuryAdmin: TREASURY_ADMIN, + hubCount: 0, + spokeCount: 1, + nativeWrapper: address(tokenList.weth), + hubBytecode: BytecodeHelper.getHubBytecode(), + spokeBytecode: BytecodeHelper.getSpokeBytecode(), + salt: bytes32(vm.randomBytes(32)) + }); + _setupFixturesRoles(report); + spoke = ISpoke(report.spokeReports[0].spoke); + defaultAccessManager = IAccessManager(report.accessManager); + eligibility = new MockBorrowerEligibility(); + permissionedAccessManager = new PermissionedBorrowAccessManager(ADMIN, spoke, eligibility); + + IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ + active: true, + halted: false, + addCap: MAX_ALLOWED_SPOKE_CAP, + drawCap: MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: MAX_ALLOWED_COLLATERAL_RISK + }); + + vm.startPrank(ADMIN); + wethReserveId = spoke.addReserve( + address(hub1), + wethAssetId, + _deployMockPriceFeed(spoke, 2000e8), + _getDefaultReserveConfig(15_00), + ISpoke.DynamicReserveConfig({ + collateralFactor: 80_00, + maxLiquidationBonus: 105_00, + liquidationFee: 10_00 + }) + ); + usdxReserveId = spoke.addReserve( + address(hub1), + usdxAssetId, + _deployMockPriceFeed(spoke, 1e8), + _getDefaultReserveConfig(20_00), + ISpoke.DynamicReserveConfig({ + collateralFactor: 78_00, + maxLiquidationBonus: 101_00, + liquidationFee: 12_00 + }) + ); + hub1.addSpoke(wethAssetId, address(spoke), spokeConfig); + hub1.addSpoke(usdxAssetId, address(spoke), spokeConfig); + vm.stopPrank(); + + address[2] memory users = [alice, bob]; + for (uint256 i = 0; i < users.length; ++i) { + vm.startPrank(users[i]); + tokenList.weth.approve(address(spoke), type(uint256).max); + tokenList.usdx.approve(address(spoke), type(uint256).max); + vm.stopPrank(); + } + + // Seed borrowable liquidity, matching PR #1334. + SpokeActions.supply({ + spoke: spoke, + reserveId: usdxReserveId, + caller: bob, + amount: 100_000e6, + onBehalfOf: bob + }); + } + + function test_updateAuthority() public { + vm.prank(ADMIN); + defaultAccessManager.updateAuthority(address(spoke), address(permissionedAccessManager)); + vm.snapshotGasLastCall(NAMESPACE, 'updateAuthority: permissioned manager'); + } + + function test_operations_defaultAccessManager() public { + _snapshotOperations('default manager'); + } + + function test_operations_permissionedBorrowAccessManager() public { + eligibility.setEligible(alice, true); + vm.prank(ADMIN); + defaultAccessManager.updateAuthority(address(spoke), address(permissionedAccessManager)); + + _snapshotOperations('permissioned manager'); + } + + function _snapshotOperations(string memory label) internal { + vm.startPrank(alice); + spoke.supply(usdxReserveId, 1000e6, alice); + vm.snapshotGasLastCall(NAMESPACE, string.concat('supply: ', label)); + + spoke.setUsingAsCollateral(usdxReserveId, true, alice); + vm.snapshotGasLastCall(NAMESPACE, string.concat('usingAsCollateral: enable, ', label)); + + spoke.borrow(usdxReserveId, 100e6, alice); + vm.snapshotGasLastCall(NAMESPACE, string.concat('borrow: ', label)); + + skip(100); + + spoke.repay(usdxReserveId, 50e6, alice); + vm.snapshotGasLastCall(NAMESPACE, string.concat('repay: partial, ', label)); + + spoke.withdraw(usdxReserveId, 100e6, alice); + vm.snapshotGasLastCall(NAMESPACE, string.concat('withdraw: partial, ', label)); + vm.stopPrank(); + } +} diff --git a/tests/helpers/mocks/MockBorrowerEligibility.sol b/tests/helpers/mocks/MockBorrowerEligibility.sol new file mode 100644 index 000000000..01d5c2a13 --- /dev/null +++ b/tests/helpers/mocks/MockBorrowerEligibility.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IBorrowerEligibility} from 'src/access/interfaces/IBorrowerEligibility.sol'; + +contract MockBorrowerEligibility is IBorrowerEligibility { + mapping(address account => bool) internal _eligible; + + function setEligible(address account, bool eligible) external { + _eligible[account] = eligible; + } + + function isEligible(address account) external view returns (bool) { + return _eligible[account]; + } +} From 8e65017f54a89a4c4b7c649855216372210f2ac3 Mon Sep 17 00:00:00 2001 From: AlbertoCentonze <11707683+AlbertoCentonze@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:28:35 +0200 Subject: [PATCH 3/3] test: update canonical Spoke gas snapshots --- .../Spoke.Operations.ZeroRiskPremium.json | 42 +++++++++---------- snapshots/Spoke.Operations.json | 42 +++++++++---------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/snapshots/Spoke.Operations.ZeroRiskPremium.json b/snapshots/Spoke.Operations.ZeroRiskPremium.json index fcf844086..2e7a93c12 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", + "borrow: first": "208244", + "borrow: second action, same reserve": "188110", "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", + "permitReserve + repay (multicall)": "178707", + "permitReserve + supply (multicall)": "160342", + "permitReserve + supply + enable collateral (multicall)": "179112", + "repay: full": "138045", + "repay: partial": "143003", "setUserPositionManagersWithSig: disable": "46772", "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", + "supply + enable collateral (multicall)": "159314", + "supply: 0 borrows, collateral disabled": "136432", + "supply: 0 borrows, collateral enabled": "119403", + "supply: second action, same reserve": "119332", "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" + "usingAsCollateral: 0 borrows, enable": "68435", + "usingAsCollateral: 1 borrow, disable": "123291", + "usingAsCollateral: 1 borrow, enable": "51323", + "usingAsCollateral: 2 borrows, disable": "146983", + "usingAsCollateral: 2 borrows, enable": "51335", + "withdraw: 0 borrows, full": "143759", + "withdraw: 0 borrows, partial": "149095", + "withdraw: 1 borrow, partial": "178292", + "withdraw: 2 borrows, partial": "194994", + "withdraw: non collateral": "120006" } \ No newline at end of file diff --git a/snapshots/Spoke.Operations.json b/snapshots/Spoke.Operations.json index 086bec26b..70f7f5c33 100644 --- a/snapshots/Spoke.Operations.json +++ b/snapshots/Spoke.Operations.json @@ -1,34 +1,34 @@ { - "borrow: first": "269297", - "borrow: second action, same reserve": "212163", + "borrow: first": "278033", + "borrow: second action, same reserve": "220899", "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", + "permitReserve + repay (multicall)": "173349", + "permitReserve + supply (multicall)": "160342", + "permitReserve + supply + enable collateral (multicall)": "179112", + "repay: full": "132124", + "repay: partial": "151482", "setUserPositionManagersWithSig: disable": "46772", "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", + "supply + enable collateral (multicall)": "159314", + "supply: 0 borrows, collateral disabled": "136432", + "supply: 0 borrows, collateral enabled": "119403", + "supply: second action, same reserve": "119332", "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" + "usingAsCollateral: 0 borrows, enable": "68435", + "usingAsCollateral: 1 borrow, disable": "177501", + "usingAsCollateral: 1 borrow, enable": "51323", + "usingAsCollateral: 2 borrows, disable": "250627", + "usingAsCollateral: 2 borrows, enable": "51335", + "withdraw: 0 borrows, full": "143759", + "withdraw: 0 borrows, partial": "149095", + "withdraw: 1 borrow, partial": "229999", + "withdraw: 2 borrows, partial": "279171", + "withdraw: non collateral": "120006" } \ No newline at end of file