From 78049b9ec687f8b3163ec25c1353515ed2adffae Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:10:09 +0300 Subject: [PATCH 01/10] feat: add permissioned spoke instance with mandatory position manager --- foundry.toml | 1 + src/spoke/Spoke.sol | 10 +- .../instances/PermissionedSpokeInstance.sol | 49 +++ .../interfaces/IMandatoryPositionManager.sol | 20 ++ src/spoke/interfaces/IPermissionedSpoke.sol | 20 ++ .../spoke/misc/PermissionedSpoke.t.sol | 339 ++++++++++++++++++ .../mocks/MockMandatoryPositionManager.sol | 43 +++ 7 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 src/spoke/instances/PermissionedSpokeInstance.sol create mode 100644 src/spoke/interfaces/IMandatoryPositionManager.sol create mode 100644 src/spoke/interfaces/IPermissionedSpoke.sol create mode 100644 tests/contracts/spoke/misc/PermissionedSpoke.t.sol create mode 100644 tests/helpers/mocks/MockMandatoryPositionManager.sol diff --git a/foundry.toml b/foundry.toml index 1411ebe9a..72f9ab48a 100644 --- a/foundry.toml +++ b/foundry.toml @@ -27,6 +27,7 @@ additional_compiler_profiles = [ compilation_restrictions = [ { paths = "src/hub/instances/HubInstance.sol", via_ir = true, optimizer_runs = 22_300 }, { paths = "src/spoke/instances/SpokeInstance.sol", via_ir = true, optimizer_runs = 750 }, + { paths = "src/spoke/instances/PermissionedSpokeInstance.sol", via_ir = true, optimizer_runs = 750 }, ] [bind_json] diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index 9dd7beab9..fb13362f2 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -86,9 +86,9 @@ 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`. + /// @notice Modifier that checks if the caller is authorized to act on the position of `onBehalfOf`. modifier onlyPositionManager(address onBehalfOf) { - require(_isPositionManager({user: onBehalfOf, manager: msg.sender}), Unauthorized()); + require(_isAuthorizedPositionManagerCall(onBehalfOf), Unauthorized()); _; } @@ -912,6 +912,12 @@ abstract contract Spoke is return config.active && config.approval[user]; } + /// @notice Returns whether the current call is authorized to act on the position of `user`. + /// @dev The default implementation requires the caller to be `user` or an approved position manager for `user`. + function _isAuthorizedPositionManagerCall(address user) internal view virtual returns (bool) { + return _isPositionManager({user: user, manager: msg.sender}); + } + function _validateReserveConfig(ReserveConfig calldata config) internal pure { require(config.collateralRisk <= MAX_ALLOWED_COLLATERAL_RISK, InvalidCollateralRisk()); } diff --git a/src/spoke/instances/PermissionedSpokeInstance.sol b/src/spoke/instances/PermissionedSpokeInstance.sol new file mode 100644 index 000000000..22d7d8740 --- /dev/null +++ b/src/spoke/instances/PermissionedSpokeInstance.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity 0.8.28; + +import {SpokeInstance} from 'src/spoke/instances/SpokeInstance.sol'; +import {IMandatoryPositionManager} from 'src/spoke/interfaces/IMandatoryPositionManager.sol'; +import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; + +/// @title PermissionedSpokeInstance +/// @author Aave Labs +/// @notice Spoke implementation with a configurable mandatory position manager, which replaces the +/// default position manager authorization on position actions. +contract PermissionedSpokeInstance is SpokeInstance, IPermissionedSpoke { + /// @dev Address of the mandatory position manager, or the zero address if unset. + address internal _mandatoryPositionManager; + + /// @dev Constructor. + /// @param oracle_ The address of the oracle. + /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. + constructor( + address oracle_, + uint16 maxUserReservesLimit_ + ) SpokeInstance(oracle_, maxUserReservesLimit_) {} + + /// @inheritdoc IPermissionedSpoke + function updateMandatoryPositionManager(address mandatoryPositionManager) external restricted { + _mandatoryPositionManager = mandatoryPositionManager; + emit UpdateMandatoryPositionManager(mandatoryPositionManager); + } + + /// @inheritdoc IPermissionedSpoke + function getMandatoryPositionManager() external view returns (address) { + return _mandatoryPositionManager; + } + + /// @dev When a mandatory position manager is set, it replaces the default authorization and fully + /// decides whether the call is allowed, based on the caller, the position owner and the calldata. + function _isAuthorizedPositionManagerCall(address user) internal view override returns (bool) { + address mandatoryPositionManager = _mandatoryPositionManager; + if (mandatoryPositionManager == address(0)) { + return super._isAuthorizedPositionManagerCall(user); + } + return + IMandatoryPositionManager(mandatoryPositionManager).isCallAllowed({ + caller: msg.sender, + onBehalfOf: user, + data: msg.data + }); + } +} diff --git a/src/spoke/interfaces/IMandatoryPositionManager.sol b/src/spoke/interfaces/IMandatoryPositionManager.sol new file mode 100644 index 000000000..080f5ffe1 --- /dev/null +++ b/src/spoke/interfaces/IMandatoryPositionManager.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +/// @title IMandatoryPositionManager +/// @author Aave Labs +/// @notice Interface for a mandatory position manager, which replaces the default position manager +/// authorization on position actions of a permissioned Spoke. +interface IMandatoryPositionManager { + /// @notice Returns whether a position action on the Spoke is allowed. + /// @dev It can preserve the default authorization by calling back `ISpoke.isPositionManager`. + /// @param caller The transaction initiator on the Spoke. + /// @param onBehalfOf The owner of the position being modified. + /// @param data The full calldata of the Spoke call, allowing per-action decoding. + /// @return True if the call is allowed. + function isCallAllowed( + address caller, + address onBehalfOf, + bytes calldata data + ) external view returns (bool); +} diff --git a/src/spoke/interfaces/IPermissionedSpoke.sol b/src/spoke/interfaces/IPermissionedSpoke.sol new file mode 100644 index 000000000..fd1482a4e --- /dev/null +++ b/src/spoke/interfaces/IPermissionedSpoke.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +/// @title IPermissionedSpoke +/// @author Aave Labs +/// @notice Interface for the permissioned functionality of a Spoke. +interface IPermissionedSpoke { + /// @notice Emitted when the mandatory position manager is updated. + /// @param mandatoryPositionManager The address of the mandatory position manager, or the zero address if removed. + event UpdateMandatoryPositionManager(address indexed mandatoryPositionManager); + + /// @notice Updates the mandatory position manager. + /// @dev When set, it replaces the default position manager authorization on position actions. + /// @dev Setting the zero address removes it, restoring the default authorization. + /// @param mandatoryPositionManager The address of the mandatory position manager. + function updateMandatoryPositionManager(address mandatoryPositionManager) external; + + /// @notice Returns the address of the mandatory position manager, or the zero address if unset. + function getMandatoryPositionManager() external view returns (address); +} diff --git a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol new file mode 100644 index 000000000..2eb875b6a --- /dev/null +++ b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/setup/Base.t.sol'; + +import {PermissionedSpokeInstance} from 'src/spoke/instances/PermissionedSpokeInstance.sol'; +import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; +import {MockMandatoryPositionManager} from 'tests/helpers/mocks/MockMandatoryPositionManager.sol'; + +contract PermissionedSpokeTest is Base { + ISpoke internal spoke; + MockMandatoryPositionManager internal mandatoryPositionManager; + address internal RWA_MANAGER = makeAddr('RWA_MANAGER'); + + uint256 internal wethReserveId; + uint256 internal usdxReserveId; + + function setUp() public virtual override { + super.setUp(); + + // Deploy a fresh spoke with the PermissionedSpokeInstance implementation + TestTypes.TestEnvReport memory report = AaveV4TestOrchestration.deployTestEnv({ + admin: ADMIN, + treasuryAdmin: TREASURY_ADMIN, + hubCount: 0, + spokeCount: 1, + nativeWrapper: address(tokenList.weth), + hubBytecode: BytecodeHelper.getHubBytecode(), + spokeBytecode: vm.getCode( + 'src/spoke/instances/PermissionedSpokeInstance.sol:PermissionedSpokeInstance' + ), + salt: bytes32(vm.randomBytes(32)) + }); + _setupFixturesRoles(report); + spoke = ISpoke(report.spokeReports[0].spoke); + mandatoryPositionManager = new MockMandatoryPositionManager(spoke); + + 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[3] memory users = [alice, bob, RWA_MANAGER]; + 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(); + } + } + + function test_defaultBehavior_withoutMandatoryPositionManager() public { + _supplyCollateralAndBorrow(alice, 100e6); + + // an unapproved caller still cannot act on behalf of alice + vm.expectRevert(ISpoke.Unauthorized.selector); + SpokeActions.withdraw({ + spoke: spoke, + reserveId: usdxReserveId, + caller: bob, + amount: 1e6, + onBehalfOf: alice + }); + } + + function test_updateMandatoryPositionManager() public { + vm.expectEmit(address(spoke)); + emit IPermissionedSpoke.UpdateMandatoryPositionManager(address(mandatoryPositionManager)); + + vm.prank(ADMIN); + PermissionedSpokeInstance(address(spoke)).updateMandatoryPositionManager( + address(mandatoryPositionManager) + ); + + assertEq( + PermissionedSpokeInstance(address(spoke)).getMandatoryPositionManager(), + address(mandatoryPositionManager) + ); + } + + function test_updateMandatoryPositionManager_removal() public { + _setMandatoryPositionManager(address(mandatoryPositionManager)); + _setMandatoryPositionManager(address(0)); + + assertEq(PermissionedSpokeInstance(address(spoke)).getMandatoryPositionManager(), address(0)); + + // default authorization is restored + SpokeActions.supply({ + spoke: spoke, + reserveId: usdxReserveId, + caller: alice, + amount: 100e6, + onBehalfOf: alice + }); + } + + function test_updateMandatoryPositionManager_revertsIfUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); + vm.prank(alice); + PermissionedSpokeInstance(address(spoke)).updateMandatoryPositionManager( + address(mandatoryPositionManager) + ); + } + + function test_permissionedBorrow() public { + mandatoryPositionManager.setGated(ISpoke.borrow.selector, true); + _setMandatoryPositionManager(address(mandatoryPositionManager)); + + // supply is not gated for ineligible users + SpokeActions.supplyCollateral({ + spoke: spoke, + reserveId: usdxReserveId, + caller: alice, + amount: 200e6, + onBehalfOf: alice + }); + + vm.expectRevert(ISpoke.Unauthorized.selector); + SpokeActions.borrow({ + spoke: spoke, + reserveId: usdxReserveId, + caller: alice, + amount: 100e6, + onBehalfOf: alice + }); + + mandatoryPositionManager.setEligible(alice, true); + SpokeActions.borrow({ + spoke: spoke, + reserveId: usdxReserveId, + caller: alice, + amount: 100e6, + onBehalfOf: alice + }); + + assertEq(spoke.getUserTotalDebt(usdxReserveId, alice), 100e6); + } + + function test_defaultApprovalsPreservedViaCallback() public { + _setMandatoryPositionManager(address(mandatoryPositionManager)); + + SpokeActions.supply({ + spoke: spoke, + reserveId: usdxReserveId, + caller: alice, + amount: 100e6, + onBehalfOf: alice + }); + + // bob is not an approved position manager for alice + vm.expectRevert(ISpoke.Unauthorized.selector); + SpokeActions.withdraw({ + spoke: spoke, + reserveId: usdxReserveId, + caller: bob, + amount: 50e6, + onBehalfOf: alice + }); + + // approving bob as position manager makes the call pass through the callback + vm.prank(SPOKE_ADMIN); + spoke.updatePositionManager(bob, true); + vm.prank(alice); + spoke.setUserPositionManager(bob, true); + + SpokeActions.withdraw({ + spoke: spoke, + reserveId: usdxReserveId, + caller: bob, + amount: 50e6, + onBehalfOf: alice + }); + + assertEq(spoke.getUserSuppliedAssets(usdxReserveId, alice), 50e6); + } + + /// @dev Horizon-style forced transfer: the RWA manager moves alice's position to bob by + /// withdrawing on her behalf and re-supplying to bob, without any user approval. + function test_forcedTransfer_viaGlobalManager() public { + mandatoryPositionManager.setGlobalManager(RWA_MANAGER, true); + _setMandatoryPositionManager(address(mandatoryPositionManager)); + + uint256 amount = 100e6; + SpokeActions.supply({ + spoke: spoke, + reserveId: usdxReserveId, + caller: alice, + amount: amount, + onBehalfOf: alice + }); + + uint256 balanceBefore = tokenList.usdx.balanceOf(RWA_MANAGER); + SpokeActions.withdraw({ + spoke: spoke, + reserveId: usdxReserveId, + caller: RWA_MANAGER, + amount: amount, + onBehalfOf: alice + }); + assertEq(tokenList.usdx.balanceOf(RWA_MANAGER), balanceBefore + amount); + + SpokeActions.supply({ + spoke: spoke, + reserveId: usdxReserveId, + caller: RWA_MANAGER, + amount: amount, + onBehalfOf: bob + }); + + assertEq(spoke.getUserSuppliedAssets(usdxReserveId, alice), 0); + assertEq(spoke.getUserSuppliedAssets(usdxReserveId, bob), amount); + } + + function test_forcedWithdraw_stillValidatesHealthFactor() public { + mandatoryPositionManager.setGlobalManager(RWA_MANAGER, true); + _setMandatoryPositionManager(address(mandatoryPositionManager)); + + _supplyCollateralAndBorrow(alice, 100e6); + + vm.expectRevert(ISpoke.HealthFactorBelowThreshold.selector); + SpokeActions.withdraw({ + spoke: spoke, + reserveId: usdxReserveId, + caller: RWA_MANAGER, + amount: 100e6, + onBehalfOf: alice + }); + } + + function test_liquidationCallUnaffected() public { + _setMandatoryPositionManager(address(mandatoryPositionManager)); + + SpokeActions.supply({ + spoke: spoke, + reserveId: usdxReserveId, + caller: bob, + amount: 10_000e6, + onBehalfOf: bob + }); + SpokeActions.supplyCollateral({ + spoke: spoke, + reserveId: wethReserveId, + caller: alice, + amount: 1e18, + onBehalfOf: alice + }); + _borrowToBeLiquidatableWithPriceChange({ + spoke: spoke, + user: alice, + reserveId: usdxReserveId, + collateralReserveId: wethReserveId, + desiredHf: 1.01e18, + pricePercentage: 90_00 + }); + + uint256 debtBefore = spoke.getUserTotalDebt(usdxReserveId, alice); + + // bob is neither eligible nor a global manager; liquidations are not gated + SpokeActions.liquidationCall({ + spoke: spoke, + collateralReserveId: wethReserveId, + debtReserveId: usdxReserveId, + user: alice, + debtToCover: type(uint256).max, + receiveShares: false, + caller: bob + }); + + assertLt(spoke.getUserTotalDebt(usdxReserveId, alice), debtBefore, 'liquidation executed'); + } + + function test_cannotBeBypassedWithMulticall() public { + mandatoryPositionManager.setGated(ISpoke.borrow.selector, true); + _setMandatoryPositionManager(address(mandatoryPositionManager)); + + bytes[] memory calls = new bytes[](1); + calls[0] = abi.encodeCall(ISpoke.borrow, (usdxReserveId, 100e6, alice)); + + vm.expectRevert(ISpoke.Unauthorized.selector); + vm.prank(alice); + spoke.multicall(calls); + } + + function _supplyCollateralAndBorrow(address user, uint256 amount) internal { + SpokeActions.supplyCollateral({ + spoke: spoke, + reserveId: usdxReserveId, + caller: user, + amount: amount * 2, + onBehalfOf: user + }); + SpokeActions.borrow({ + spoke: spoke, + reserveId: usdxReserveId, + caller: user, + amount: amount, + onBehalfOf: user + }); + } + + function _setMandatoryPositionManager(address newMandatoryPositionManager) internal { + vm.prank(ADMIN); + PermissionedSpokeInstance(address(spoke)).updateMandatoryPositionManager( + newMandatoryPositionManager + ); + } +} diff --git a/tests/helpers/mocks/MockMandatoryPositionManager.sol b/tests/helpers/mocks/MockMandatoryPositionManager.sol new file mode 100644 index 000000000..ed3614598 --- /dev/null +++ b/tests/helpers/mocks/MockMandatoryPositionManager.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IMandatoryPositionManager} from 'src/spoke/interfaces/IMandatoryPositionManager.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @dev Mandatory position manager mock: +/// - `globalManager`s are allowed to act on behalf of any user (e.g. an RWA manager) +/// - `gated` selectors additionally require the position owner to be `eligible` +/// - otherwise falls back to the Spoke's default position manager authorization +contract MockMandatoryPositionManager is IMandatoryPositionManager { + ISpoke public immutable SPOKE; + + mapping(address caller => bool) public globalManager; + mapping(bytes4 selector => bool) public gated; + mapping(address user => bool) public eligible; + + constructor(ISpoke spoke) { + SPOKE = spoke; + } + + function setGlobalManager(address caller, bool value) external { + globalManager[caller] = value; + } + + function setGated(bytes4 selector, bool value) external { + gated[selector] = value; + } + + function setEligible(address user, bool value) external { + eligible[user] = value; + } + + function isCallAllowed( + address caller, + address onBehalfOf, + bytes calldata data + ) external view returns (bool) { + if (globalManager[caller]) return true; + if (gated[bytes4(data)] && !eligible[onBehalfOf]) return false; + return SPOKE.isPositionManager(onBehalfOf, caller); + } +} From 62d80b05e1c09d372156856bb3bb07b9c7f1028b Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:25:44 +0300 Subject: [PATCH 02/10] refactor: extract SpokeInstanceBase, ERC-7201 storage for mandatory position manager --- foundry.toml | 3 +- .../instances/PermissionedSpokeInstance.sol | 32 +++++++++++++----- src/spoke/instances/SpokeInstance.sol | 28 ++++------------ src/spoke/instances/SpokeInstanceBase.sol | 33 +++++++++++++++++++ 4 files changed, 65 insertions(+), 31 deletions(-) create mode 100644 src/spoke/instances/SpokeInstanceBase.sol diff --git a/foundry.toml b/foundry.toml index 72f9ab48a..c9a15e3ca 100644 --- a/foundry.toml +++ b/foundry.toml @@ -22,12 +22,13 @@ dynamic_test_linking = true additional_compiler_profiles = [ { name = "hub", optimizer = true, via_ir = true, optimizer_runs = 22_300 }, { name = "spoke", optimizer = true, via_ir = true, optimizer_runs = 750 }, + { name = "permissioned-spoke", optimizer = true, via_ir = true, optimizer_runs = 200 }, ] compilation_restrictions = [ { paths = "src/hub/instances/HubInstance.sol", via_ir = true, optimizer_runs = 22_300 }, { paths = "src/spoke/instances/SpokeInstance.sol", via_ir = true, optimizer_runs = 750 }, - { paths = "src/spoke/instances/PermissionedSpokeInstance.sol", via_ir = true, optimizer_runs = 750 }, + { paths = "src/spoke/instances/PermissionedSpokeInstance.sol", via_ir = true, optimizer_runs = 200 }, ] [bind_json] diff --git a/src/spoke/instances/PermissionedSpokeInstance.sol b/src/spoke/instances/PermissionedSpokeInstance.sol index 22d7d8740..ed28e7e81 100644 --- a/src/spoke/instances/PermissionedSpokeInstance.sol +++ b/src/spoke/instances/PermissionedSpokeInstance.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LicenseRef-BUSL pragma solidity 0.8.28; -import {SpokeInstance} from 'src/spoke/instances/SpokeInstance.sol'; +import {SpokeInstanceBase} from 'src/spoke/instances/SpokeInstanceBase.sol'; import {IMandatoryPositionManager} from 'src/spoke/interfaces/IMandatoryPositionManager.sol'; import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; @@ -9,9 +9,25 @@ import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; /// @author Aave Labs /// @notice Spoke implementation with a configurable mandatory position manager, which replaces the /// default position manager authorization on position actions. -contract PermissionedSpokeInstance is SpokeInstance, IPermissionedSpoke { - /// @dev Address of the mandatory position manager, or the zero address if unset. - address internal _mandatoryPositionManager; +contract PermissionedSpokeInstance is SpokeInstanceBase, IPermissionedSpoke { + /// @custom:storage-location erc7201:aave.storage.PermissionedSpoke + struct PermissionedSpokeStorage { + address mandatoryPositionManager; + } + + // keccak256(abi.encode(uint256(keccak256('aave.storage.PermissionedSpoke')) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant PermissionedSpokeStorageLocation = + 0xad19adda25bc112a506d1eb6b62266ed84c7e8969fba16c536d63fc20c4fda00; + + function _getPermissionedSpokeStorage() + private + pure + returns (PermissionedSpokeStorage storage $) + { + assembly { + $.slot := PermissionedSpokeStorageLocation + } + } /// @dev Constructor. /// @param oracle_ The address of the oracle. @@ -19,23 +35,23 @@ contract PermissionedSpokeInstance is SpokeInstance, IPermissionedSpoke { constructor( address oracle_, uint16 maxUserReservesLimit_ - ) SpokeInstance(oracle_, maxUserReservesLimit_) {} + ) SpokeInstanceBase(oracle_, maxUserReservesLimit_) {} /// @inheritdoc IPermissionedSpoke function updateMandatoryPositionManager(address mandatoryPositionManager) external restricted { - _mandatoryPositionManager = mandatoryPositionManager; + _getPermissionedSpokeStorage().mandatoryPositionManager = mandatoryPositionManager; emit UpdateMandatoryPositionManager(mandatoryPositionManager); } /// @inheritdoc IPermissionedSpoke function getMandatoryPositionManager() external view returns (address) { - return _mandatoryPositionManager; + return _getPermissionedSpokeStorage().mandatoryPositionManager; } /// @dev When a mandatory position manager is set, it replaces the default authorization and fully /// decides whether the call is allowed, based on the caller, the position owner and the calldata. function _isAuthorizedPositionManagerCall(address user) internal view override returns (bool) { - address mandatoryPositionManager = _mandatoryPositionManager; + address mandatoryPositionManager = _getPermissionedSpokeStorage().mandatoryPositionManager; if (mandatoryPositionManager == address(0)) { return super._isAuthorizedPositionManagerCall(user); } diff --git a/src/spoke/instances/SpokeInstance.sol b/src/spoke/instances/SpokeInstance.sol index 2b8d06252..7da310634 100644 --- a/src/spoke/instances/SpokeInstance.sol +++ b/src/spoke/instances/SpokeInstance.sol @@ -1,33 +1,17 @@ // SPDX-License-Identifier: LicenseRef-BUSL pragma solidity 0.8.28; -import {Spoke} from 'src/spoke/Spoke.sol'; +import {SpokeInstanceBase} from 'src/spoke/instances/SpokeInstanceBase.sol'; /// @title SpokeInstance /// @author Aave Labs /// @notice Implementation contract for the Spoke. -contract SpokeInstance is Spoke { - uint64 public constant SPOKE_REVISION = 1; - +contract SpokeInstance is SpokeInstanceBase { /// @dev Constructor. - /// @dev During upgrade, must ensure that the new oracle is supporting existing assets on the Spoke and the replaced oracle. /// @param oracle_ The address of the oracle. /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. - constructor(address oracle_, uint16 maxUserReservesLimit_) Spoke(oracle_, maxUserReservesLimit_) { - _disableInitializers(); - } - - /// @notice Initializer. - /// @dev The authority contract must implement the `AccessManaged` interface for access control. - /// @param authority The address of the authority contract which manages permissions. - function initialize(address authority) external override reinitializer(SPOKE_REVISION) { - emit SetSpokeImmutables(ORACLE, MAX_USER_RESERVES_LIMIT); - - require(authority != address(0), InvalidAddress()); - __AccessManaged_init(authority); - if (_liquidationConfig.targetHealthFactor == 0) { - _liquidationConfig.targetHealthFactor = HEALTH_FACTOR_LIQUIDATION_THRESHOLD; - emit UpdateLiquidationConfig(_liquidationConfig); - } - } + constructor( + address oracle_, + uint16 maxUserReservesLimit_ + ) SpokeInstanceBase(oracle_, maxUserReservesLimit_) {} } diff --git a/src/spoke/instances/SpokeInstanceBase.sol b/src/spoke/instances/SpokeInstanceBase.sol new file mode 100644 index 000000000..5f4a5c109 --- /dev/null +++ b/src/spoke/instances/SpokeInstanceBase.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity 0.8.28; + +import {Spoke} from 'src/spoke/Spoke.sol'; + +/// @title SpokeInstanceBase +/// @author Aave Labs +/// @notice Base implementation contract for Spoke instances. +abstract contract SpokeInstanceBase is Spoke { + uint64 public constant SPOKE_REVISION = 1; + + /// @dev Constructor. + /// @dev During upgrade, must ensure that the new oracle is supporting existing assets on the Spoke and the replaced oracle. + /// @param oracle_ The address of the oracle. + /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. + constructor(address oracle_, uint16 maxUserReservesLimit_) Spoke(oracle_, maxUserReservesLimit_) { + _disableInitializers(); + } + + /// @notice Initializer. + /// @dev The authority contract must implement the `AccessManaged` interface for access control. + /// @param authority The address of the authority contract which manages permissions. + function initialize(address authority) external override reinitializer(SPOKE_REVISION) { + emit SetSpokeImmutables(ORACLE, MAX_USER_RESERVES_LIMIT); + + require(authority != address(0), InvalidAddress()); + __AccessManaged_init(authority); + if (_liquidationConfig.targetHealthFactor == 0) { + _liquidationConfig.targetHealthFactor = HEALTH_FACTOR_LIQUIDATION_THRESHOLD; + emit UpdateLiquidationConfig(_liquidationConfig); + } + } +} From d1dbb588206ec432e6de50bd29d3fad2a1f4408b Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:36:54 +0300 Subject: [PATCH 03/10] test: add permissioned spoke gas snapshots, raise optimizer runs to 600 --- foundry.toml | 4 +- snapshots/PermissionedSpoke.Operations.json | 13 +++ .../instances/PermissionedSpokeInstance.sol | 12 +- .../spoke/misc/PermissionedSpoke.t.sol | 103 +---------------- .../PermissionedSpoke.Operations.gas.t.sol | 67 +++++++++++ tests/setup/PermissionedSpokeBase.sol | 107 ++++++++++++++++++ 6 files changed, 195 insertions(+), 111 deletions(-) create mode 100644 snapshots/PermissionedSpoke.Operations.json create mode 100644 tests/gas/PermissionedSpoke.Operations.gas.t.sol create mode 100644 tests/setup/PermissionedSpokeBase.sol diff --git a/foundry.toml b/foundry.toml index c9a15e3ca..0606b3c3d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -22,13 +22,13 @@ dynamic_test_linking = true additional_compiler_profiles = [ { name = "hub", optimizer = true, via_ir = true, optimizer_runs = 22_300 }, { name = "spoke", optimizer = true, via_ir = true, optimizer_runs = 750 }, - { name = "permissioned-spoke", optimizer = true, via_ir = true, optimizer_runs = 200 }, + { name = "permissioned-spoke", optimizer = true, via_ir = true, optimizer_runs = 600 }, ] compilation_restrictions = [ { paths = "src/hub/instances/HubInstance.sol", via_ir = true, optimizer_runs = 22_300 }, { paths = "src/spoke/instances/SpokeInstance.sol", via_ir = true, optimizer_runs = 750 }, - { paths = "src/spoke/instances/PermissionedSpokeInstance.sol", via_ir = true, optimizer_runs = 200 }, + { paths = "src/spoke/instances/PermissionedSpokeInstance.sol", via_ir = true, optimizer_runs = 600 }, ] [bind_json] diff --git a/snapshots/PermissionedSpoke.Operations.json b/snapshots/PermissionedSpoke.Operations.json new file mode 100644 index 000000000..8b8ebe0ee --- /dev/null +++ b/snapshots/PermissionedSpoke.Operations.json @@ -0,0 +1,13 @@ +{ + "borrow: mpm set": "306882", + "borrow: mpm unset": "295338", + "repay: partial, mpm set": "159424", + "repay: partial, mpm unset": "147880", + "supply: mpm set": "141038", + "supply: mpm unset": "129479", + "updateMandatoryPositionManager: set": "64530", + "usingAsCollateral: enable, mpm set": "73333", + "usingAsCollateral: enable, mpm unset": "61768", + "withdraw: partial, mpm set": "194270", + "withdraw: partial, mpm unset": "182725" +} \ No newline at end of file diff --git a/src/spoke/instances/PermissionedSpokeInstance.sol b/src/spoke/instances/PermissionedSpokeInstance.sol index ed28e7e81..95266fb8d 100644 --- a/src/spoke/instances/PermissionedSpokeInstance.sol +++ b/src/spoke/instances/PermissionedSpokeInstance.sol @@ -19,11 +19,7 @@ contract PermissionedSpokeInstance is SpokeInstanceBase, IPermissionedSpoke { bytes32 private constant PermissionedSpokeStorageLocation = 0xad19adda25bc112a506d1eb6b62266ed84c7e8969fba16c536d63fc20c4fda00; - function _getPermissionedSpokeStorage() - private - pure - returns (PermissionedSpokeStorage storage $) - { + function _permissionedSpokeStorage() private pure returns (PermissionedSpokeStorage storage $) { assembly { $.slot := PermissionedSpokeStorageLocation } @@ -39,19 +35,19 @@ contract PermissionedSpokeInstance is SpokeInstanceBase, IPermissionedSpoke { /// @inheritdoc IPermissionedSpoke function updateMandatoryPositionManager(address mandatoryPositionManager) external restricted { - _getPermissionedSpokeStorage().mandatoryPositionManager = mandatoryPositionManager; + _permissionedSpokeStorage().mandatoryPositionManager = mandatoryPositionManager; emit UpdateMandatoryPositionManager(mandatoryPositionManager); } /// @inheritdoc IPermissionedSpoke function getMandatoryPositionManager() external view returns (address) { - return _getPermissionedSpokeStorage().mandatoryPositionManager; + return _permissionedSpokeStorage().mandatoryPositionManager; } /// @dev When a mandatory position manager is set, it replaces the default authorization and fully /// decides whether the call is allowed, based on the caller, the position owner and the calldata. function _isAuthorizedPositionManagerCall(address user) internal view override returns (bool) { - address mandatoryPositionManager = _getPermissionedSpokeStorage().mandatoryPositionManager; + address mandatoryPositionManager = _permissionedSpokeStorage().mandatoryPositionManager; if (mandatoryPositionManager == address(0)) { return super._isAuthorizedPositionManagerCall(user); } diff --git a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol index 2eb875b6a..de784d350 100644 --- a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol +++ b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol @@ -1,84 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import 'tests/setup/Base.t.sol'; - -import {PermissionedSpokeInstance} from 'src/spoke/instances/PermissionedSpokeInstance.sol'; -import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; -import {MockMandatoryPositionManager} from 'tests/helpers/mocks/MockMandatoryPositionManager.sol'; - -contract PermissionedSpokeTest is Base { - ISpoke internal spoke; - MockMandatoryPositionManager internal mandatoryPositionManager; - address internal RWA_MANAGER = makeAddr('RWA_MANAGER'); - - uint256 internal wethReserveId; - uint256 internal usdxReserveId; - - function setUp() public virtual override { - super.setUp(); - - // Deploy a fresh spoke with the PermissionedSpokeInstance implementation - TestTypes.TestEnvReport memory report = AaveV4TestOrchestration.deployTestEnv({ - admin: ADMIN, - treasuryAdmin: TREASURY_ADMIN, - hubCount: 0, - spokeCount: 1, - nativeWrapper: address(tokenList.weth), - hubBytecode: BytecodeHelper.getHubBytecode(), - spokeBytecode: vm.getCode( - 'src/spoke/instances/PermissionedSpokeInstance.sol:PermissionedSpokeInstance' - ), - salt: bytes32(vm.randomBytes(32)) - }); - _setupFixturesRoles(report); - spoke = ISpoke(report.spokeReports[0].spoke); - mandatoryPositionManager = new MockMandatoryPositionManager(spoke); - - 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[3] memory users = [alice, bob, RWA_MANAGER]; - 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(); - } - } +import 'tests/setup/PermissionedSpokeBase.sol'; +contract PermissionedSpokeTest is PermissionedSpokeBase { function test_defaultBehavior_withoutMandatoryPositionManager() public { _supplyCollateralAndBorrow(alice, 100e6); @@ -312,28 +237,4 @@ contract PermissionedSpokeTest is Base { vm.prank(alice); spoke.multicall(calls); } - - function _supplyCollateralAndBorrow(address user, uint256 amount) internal { - SpokeActions.supplyCollateral({ - spoke: spoke, - reserveId: usdxReserveId, - caller: user, - amount: amount * 2, - onBehalfOf: user - }); - SpokeActions.borrow({ - spoke: spoke, - reserveId: usdxReserveId, - caller: user, - amount: amount, - onBehalfOf: user - }); - } - - function _setMandatoryPositionManager(address newMandatoryPositionManager) internal { - vm.prank(ADMIN); - PermissionedSpokeInstance(address(spoke)).updateMandatoryPositionManager( - newMandatoryPositionManager - ); - } } diff --git a/tests/gas/PermissionedSpoke.Operations.gas.t.sol b/tests/gas/PermissionedSpoke.Operations.gas.t.sol new file mode 100644 index 000000000..d41e911ea --- /dev/null +++ b/tests/gas/PermissionedSpoke.Operations.gas.t.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/setup/PermissionedSpokeBase.sol'; + +/// forge-config: default.isolate = true +contract PermissionedSpokeOperations_Gas_Tests is PermissionedSpokeBase { + string internal NAMESPACE = 'PermissionedSpoke.Operations'; + + function setUp() public virtual override { + super.setUp(); + + // seed borrowable liquidity + SpokeActions.supply({ + spoke: spoke, + reserveId: usdxReserveId, + caller: bob, + amount: 100_000e6, + onBehalfOf: bob + }); + } + + function test_updateMandatoryPositionManager() public { + vm.prank(ADMIN); + PermissionedSpokeInstance(address(spoke)).updateMandatoryPositionManager( + address(mandatoryPositionManager) + ); + vm.snapshotGasLastCall(NAMESPACE, 'updateMandatoryPositionManager: set'); + } + + function test_operations_mandatoryPositionManagerUnset() public { + _snapshotOperations('mpm unset'); + } + + function test_operations_mandatoryPositionManagerSet() public { + mandatoryPositionManager.setGated(ISpoke.supply.selector, true); + mandatoryPositionManager.setGated(ISpoke.withdraw.selector, true); + mandatoryPositionManager.setGated(ISpoke.borrow.selector, true); + mandatoryPositionManager.setGated(ISpoke.repay.selector, true); + mandatoryPositionManager.setGated(ISpoke.setUsingAsCollateral.selector, true); + mandatoryPositionManager.setEligible(alice, true); + _setMandatoryPositionManager(address(mandatoryPositionManager)); + + _snapshotOperations('mpm set'); + } + + 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/setup/PermissionedSpokeBase.sol b/tests/setup/PermissionedSpokeBase.sol new file mode 100644 index 000000000..2219fb50c --- /dev/null +++ b/tests/setup/PermissionedSpokeBase.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/setup/Base.t.sol'; + +import {PermissionedSpokeInstance} from 'src/spoke/instances/PermissionedSpokeInstance.sol'; +import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; +import {MockMandatoryPositionManager} from 'tests/helpers/mocks/MockMandatoryPositionManager.sol'; + +/// @dev Deploys a spoke with the `PermissionedSpokeInstance` implementation, two reserves on hub1 +/// (weth as collateral, usdx as borrowable) and a mock mandatory position manager. +abstract contract PermissionedSpokeBase is Base { + ISpoke internal spoke; + MockMandatoryPositionManager internal mandatoryPositionManager; + address internal RWA_MANAGER = makeAddr('RWA_MANAGER'); + + uint256 internal wethReserveId; + uint256 internal usdxReserveId; + + function setUp() public virtual override { + super.setUp(); + + // Deploy a fresh spoke with the PermissionedSpokeInstance implementation + TestTypes.TestEnvReport memory report = AaveV4TestOrchestration.deployTestEnv({ + admin: ADMIN, + treasuryAdmin: TREASURY_ADMIN, + hubCount: 0, + spokeCount: 1, + nativeWrapper: address(tokenList.weth), + hubBytecode: BytecodeHelper.getHubBytecode(), + spokeBytecode: vm.getCode( + 'src/spoke/instances/PermissionedSpokeInstance.sol:PermissionedSpokeInstance' + ), + salt: bytes32(vm.randomBytes(32)) + }); + _setupFixturesRoles(report); + spoke = ISpoke(report.spokeReports[0].spoke); + mandatoryPositionManager = new MockMandatoryPositionManager(spoke); + + 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[3] memory users = [alice, bob, RWA_MANAGER]; + 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(); + } + } + + function _supplyCollateralAndBorrow(address user, uint256 amount) internal { + SpokeActions.supplyCollateral({ + spoke: spoke, + reserveId: usdxReserveId, + caller: user, + amount: amount * 2, + onBehalfOf: user + }); + SpokeActions.borrow({ + spoke: spoke, + reserveId: usdxReserveId, + caller: user, + amount: amount, + onBehalfOf: user + }); + } + + function _setMandatoryPositionManager(address newMandatoryPositionManager) internal { + vm.prank(ADMIN); + PermissionedSpokeInstance(address(spoke)).updateMandatoryPositionManager( + newMandatoryPositionManager + ); + } +} From 8992d2bfe4db33f7c6e9b9cdbe8ab2c6244b3ddd Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:12:45 +0300 Subject: [PATCH 04/10] refactor: rename mandatory position manager to gate, pass env data down from modifier --- snapshots/PermissionedSpoke.Operations.json | 22 ++++---- src/spoke/Spoke.sol | 12 +++-- .../instances/PermissionedSpokeInstance.sol | 41 ++++++++------- src/spoke/interfaces/IPermissionedSpoke.sol | 16 +++--- ...toryPositionManager.sol => ISpokeGate.sol} | 8 +-- .../spoke/misc/PermissionedSpoke.t.sol | 51 ++++++++----------- .../PermissionedSpoke.Operations.gas.t.sol | 30 +++++------ ...yPositionManager.sol => MockSpokeGate.sol} | 6 +-- tests/setup/PermissionedSpokeBase.sol | 12 ++--- 9 files changed, 95 insertions(+), 103 deletions(-) rename src/spoke/interfaces/{IMandatoryPositionManager.sol => ISpokeGate.sol} (73%) rename tests/helpers/mocks/{MockMandatoryPositionManager.sol => MockSpokeGate.sol} (85%) diff --git a/snapshots/PermissionedSpoke.Operations.json b/snapshots/PermissionedSpoke.Operations.json index 8b8ebe0ee..d7cc7f211 100644 --- a/snapshots/PermissionedSpoke.Operations.json +++ b/snapshots/PermissionedSpoke.Operations.json @@ -1,13 +1,13 @@ { - "borrow: mpm set": "306882", - "borrow: mpm unset": "295338", - "repay: partial, mpm set": "159424", - "repay: partial, mpm unset": "147880", - "supply: mpm set": "141038", - "supply: mpm unset": "129479", - "updateMandatoryPositionManager: set": "64530", - "usingAsCollateral: enable, mpm set": "73333", - "usingAsCollateral: enable, mpm unset": "61768", - "withdraw: partial, mpm set": "194270", - "withdraw: partial, mpm unset": "182725" + "borrow: gate set": "306868", + "borrow: gate unset": "295329", + "repay: partial, gate set": "159421", + "repay: partial, gate unset": "147882", + "supply: gate set": "141024", + "supply: gate unset": "129470", + "updateGate: set": "64794", + "usingAsCollateral: enable, gate set": "73297", + "usingAsCollateral: enable, gate unset": "61737", + "withdraw: partial, gate set": "194256", + "withdraw: partial, gate unset": "182716" } \ No newline at end of file diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index fb13362f2..959206ac5 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -88,7 +88,7 @@ abstract contract Spoke is /// @notice Modifier that checks if the caller is authorized to act on the position of `onBehalfOf`. modifier onlyPositionManager(address onBehalfOf) { - require(_isAuthorizedPositionManagerCall(onBehalfOf), Unauthorized()); + require(_isAuthorizedPositionManagerCall(msg.sender, onBehalfOf, msg.data), Unauthorized()); _; } @@ -912,10 +912,14 @@ abstract contract Spoke is return config.active && config.approval[user]; } - /// @notice Returns whether the current call is authorized to act on the position of `user`. + /// @notice Returns whether `caller` is authorized to act on the position of `user` for the given calldata. /// @dev The default implementation requires the caller to be `user` or an approved position manager for `user`. - function _isAuthorizedPositionManagerCall(address user) internal view virtual returns (bool) { - return _isPositionManager({user: user, manager: msg.sender}); + function _isAuthorizedPositionManagerCall( + address caller, + address user, + bytes calldata + ) internal view virtual returns (bool) { + return _isPositionManager({user: user, manager: caller}); } function _validateReserveConfig(ReserveConfig calldata config) internal pure { diff --git a/src/spoke/instances/PermissionedSpokeInstance.sol b/src/spoke/instances/PermissionedSpokeInstance.sol index 95266fb8d..9bbe56219 100644 --- a/src/spoke/instances/PermissionedSpokeInstance.sol +++ b/src/spoke/instances/PermissionedSpokeInstance.sol @@ -2,17 +2,17 @@ pragma solidity 0.8.28; import {SpokeInstanceBase} from 'src/spoke/instances/SpokeInstanceBase.sol'; -import {IMandatoryPositionManager} from 'src/spoke/interfaces/IMandatoryPositionManager.sol'; +import {ISpokeGate} from 'src/spoke/interfaces/ISpokeGate.sol'; import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; /// @title PermissionedSpokeInstance /// @author Aave Labs -/// @notice Spoke implementation with a configurable mandatory position manager, which replaces the -/// default position manager authorization on position actions. +/// @notice Spoke implementation with a configurable gate, which replaces the default position +/// manager authorization on position actions. contract PermissionedSpokeInstance is SpokeInstanceBase, IPermissionedSpoke { /// @custom:storage-location erc7201:aave.storage.PermissionedSpoke struct PermissionedSpokeStorage { - address mandatoryPositionManager; + address gate; } // keccak256(abi.encode(uint256(keccak256('aave.storage.PermissionedSpoke')) - 1)) & ~bytes32(uint256(0xff)) @@ -34,28 +34,27 @@ contract PermissionedSpokeInstance is SpokeInstanceBase, IPermissionedSpoke { ) SpokeInstanceBase(oracle_, maxUserReservesLimit_) {} /// @inheritdoc IPermissionedSpoke - function updateMandatoryPositionManager(address mandatoryPositionManager) external restricted { - _permissionedSpokeStorage().mandatoryPositionManager = mandatoryPositionManager; - emit UpdateMandatoryPositionManager(mandatoryPositionManager); + function updateGate(address gate) external restricted { + _permissionedSpokeStorage().gate = gate; + emit UpdateGate(gate); } /// @inheritdoc IPermissionedSpoke - function getMandatoryPositionManager() external view returns (address) { - return _permissionedSpokeStorage().mandatoryPositionManager; + function getGate() external view returns (address) { + return _permissionedSpokeStorage().gate; } - /// @dev When a mandatory position manager is set, it replaces the default authorization and fully - /// decides whether the call is allowed, based on the caller, the position owner and the calldata. - function _isAuthorizedPositionManagerCall(address user) internal view override returns (bool) { - address mandatoryPositionManager = _permissionedSpokeStorage().mandatoryPositionManager; - if (mandatoryPositionManager == address(0)) { - return super._isAuthorizedPositionManagerCall(user); + /// @dev When a gate is set, it replaces the default authorization and fully decides whether the + /// call is allowed, based on the caller, the position owner and the calldata. + function _isAuthorizedPositionManagerCall( + address caller, + address user, + bytes calldata data + ) internal view override returns (bool) { + address gate = _permissionedSpokeStorage().gate; + if (gate == address(0)) { + return super._isAuthorizedPositionManagerCall(caller, user, data); } - return - IMandatoryPositionManager(mandatoryPositionManager).isCallAllowed({ - caller: msg.sender, - onBehalfOf: user, - data: msg.data - }); + return ISpokeGate(gate).isCallAllowed({caller: caller, onBehalfOf: user, data: data}); } } diff --git a/src/spoke/interfaces/IPermissionedSpoke.sol b/src/spoke/interfaces/IPermissionedSpoke.sol index fd1482a4e..818cc2664 100644 --- a/src/spoke/interfaces/IPermissionedSpoke.sol +++ b/src/spoke/interfaces/IPermissionedSpoke.sol @@ -5,16 +5,16 @@ pragma solidity ^0.8.0; /// @author Aave Labs /// @notice Interface for the permissioned functionality of a Spoke. interface IPermissionedSpoke { - /// @notice Emitted when the mandatory position manager is updated. - /// @param mandatoryPositionManager The address of the mandatory position manager, or the zero address if removed. - event UpdateMandatoryPositionManager(address indexed mandatoryPositionManager); + /// @notice Emitted when the gate is updated. + /// @param gate The address of the gate, or the zero address if removed. + event UpdateGate(address indexed gate); - /// @notice Updates the mandatory position manager. + /// @notice Updates the gate. /// @dev When set, it replaces the default position manager authorization on position actions. /// @dev Setting the zero address removes it, restoring the default authorization. - /// @param mandatoryPositionManager The address of the mandatory position manager. - function updateMandatoryPositionManager(address mandatoryPositionManager) external; + /// @param gate The address of the gate. + function updateGate(address gate) external; - /// @notice Returns the address of the mandatory position manager, or the zero address if unset. - function getMandatoryPositionManager() external view returns (address); + /// @notice Returns the address of the gate, or the zero address if unset. + function getGate() external view returns (address); } diff --git a/src/spoke/interfaces/IMandatoryPositionManager.sol b/src/spoke/interfaces/ISpokeGate.sol similarity index 73% rename from src/spoke/interfaces/IMandatoryPositionManager.sol rename to src/spoke/interfaces/ISpokeGate.sol index 080f5ffe1..e68617528 100644 --- a/src/spoke/interfaces/IMandatoryPositionManager.sol +++ b/src/spoke/interfaces/ISpokeGate.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: LicenseRef-BUSL pragma solidity ^0.8.0; -/// @title IMandatoryPositionManager +/// @title ISpokeGate /// @author Aave Labs -/// @notice Interface for a mandatory position manager, which replaces the default position manager -/// authorization on position actions of a permissioned Spoke. -interface IMandatoryPositionManager { +/// @notice Interface for a gate, which replaces the default position manager authorization on +/// position actions of a permissioned Spoke. +interface ISpokeGate { /// @notice Returns whether a position action on the Spoke is allowed. /// @dev It can preserve the default authorization by calling back `ISpoke.isPositionManager`. /// @param caller The transaction initiator on the Spoke. diff --git a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol index de784d350..09457346f 100644 --- a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol +++ b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.0; import 'tests/setup/PermissionedSpokeBase.sol'; contract PermissionedSpokeTest is PermissionedSpokeBase { - function test_defaultBehavior_withoutMandatoryPositionManager() public { + function test_defaultBehavior_withoutGate() public { _supplyCollateralAndBorrow(alice, 100e6); // an unapproved caller still cannot act on behalf of alice @@ -18,26 +18,21 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { }); } - function test_updateMandatoryPositionManager() public { + function test_updateGate() public { vm.expectEmit(address(spoke)); - emit IPermissionedSpoke.UpdateMandatoryPositionManager(address(mandatoryPositionManager)); + emit IPermissionedSpoke.UpdateGate(address(gate)); vm.prank(ADMIN); - PermissionedSpokeInstance(address(spoke)).updateMandatoryPositionManager( - address(mandatoryPositionManager) - ); + PermissionedSpokeInstance(address(spoke)).updateGate(address(gate)); - assertEq( - PermissionedSpokeInstance(address(spoke)).getMandatoryPositionManager(), - address(mandatoryPositionManager) - ); + assertEq(PermissionedSpokeInstance(address(spoke)).getGate(), address(gate)); } - function test_updateMandatoryPositionManager_removal() public { - _setMandatoryPositionManager(address(mandatoryPositionManager)); - _setMandatoryPositionManager(address(0)); + function test_updateGate_removal() public { + _setGate(address(gate)); + _setGate(address(0)); - assertEq(PermissionedSpokeInstance(address(spoke)).getMandatoryPositionManager(), address(0)); + assertEq(PermissionedSpokeInstance(address(spoke)).getGate(), address(0)); // default authorization is restored SpokeActions.supply({ @@ -49,19 +44,17 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { }); } - function test_updateMandatoryPositionManager_revertsIfUnauthorized() public { + function test_updateGate_revertsIfUnauthorized() public { vm.expectRevert( abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) ); vm.prank(alice); - PermissionedSpokeInstance(address(spoke)).updateMandatoryPositionManager( - address(mandatoryPositionManager) - ); + PermissionedSpokeInstance(address(spoke)).updateGate(address(gate)); } function test_permissionedBorrow() public { - mandatoryPositionManager.setGated(ISpoke.borrow.selector, true); - _setMandatoryPositionManager(address(mandatoryPositionManager)); + gate.setGated(ISpoke.borrow.selector, true); + _setGate(address(gate)); // supply is not gated for ineligible users SpokeActions.supplyCollateral({ @@ -81,7 +74,7 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { onBehalfOf: alice }); - mandatoryPositionManager.setEligible(alice, true); + gate.setEligible(alice, true); SpokeActions.borrow({ spoke: spoke, reserveId: usdxReserveId, @@ -94,7 +87,7 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { } function test_defaultApprovalsPreservedViaCallback() public { - _setMandatoryPositionManager(address(mandatoryPositionManager)); + _setGate(address(gate)); SpokeActions.supply({ spoke: spoke, @@ -134,8 +127,8 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { /// @dev Horizon-style forced transfer: the RWA manager moves alice's position to bob by /// withdrawing on her behalf and re-supplying to bob, without any user approval. function test_forcedTransfer_viaGlobalManager() public { - mandatoryPositionManager.setGlobalManager(RWA_MANAGER, true); - _setMandatoryPositionManager(address(mandatoryPositionManager)); + gate.setGlobalManager(RWA_MANAGER, true); + _setGate(address(gate)); uint256 amount = 100e6; SpokeActions.supply({ @@ -169,8 +162,8 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { } function test_forcedWithdraw_stillValidatesHealthFactor() public { - mandatoryPositionManager.setGlobalManager(RWA_MANAGER, true); - _setMandatoryPositionManager(address(mandatoryPositionManager)); + gate.setGlobalManager(RWA_MANAGER, true); + _setGate(address(gate)); _supplyCollateralAndBorrow(alice, 100e6); @@ -185,7 +178,7 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { } function test_liquidationCallUnaffected() public { - _setMandatoryPositionManager(address(mandatoryPositionManager)); + _setGate(address(gate)); SpokeActions.supply({ spoke: spoke, @@ -227,8 +220,8 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { } function test_cannotBeBypassedWithMulticall() public { - mandatoryPositionManager.setGated(ISpoke.borrow.selector, true); - _setMandatoryPositionManager(address(mandatoryPositionManager)); + gate.setGated(ISpoke.borrow.selector, true); + _setGate(address(gate)); bytes[] memory calls = new bytes[](1); calls[0] = abi.encodeCall(ISpoke.borrow, (usdxReserveId, 100e6, alice)); diff --git a/tests/gas/PermissionedSpoke.Operations.gas.t.sol b/tests/gas/PermissionedSpoke.Operations.gas.t.sol index d41e911ea..5c5dcf571 100644 --- a/tests/gas/PermissionedSpoke.Operations.gas.t.sol +++ b/tests/gas/PermissionedSpoke.Operations.gas.t.sol @@ -20,28 +20,26 @@ contract PermissionedSpokeOperations_Gas_Tests is PermissionedSpokeBase { }); } - function test_updateMandatoryPositionManager() public { + function test_updateGate() public { vm.prank(ADMIN); - PermissionedSpokeInstance(address(spoke)).updateMandatoryPositionManager( - address(mandatoryPositionManager) - ); - vm.snapshotGasLastCall(NAMESPACE, 'updateMandatoryPositionManager: set'); + PermissionedSpokeInstance(address(spoke)).updateGate(address(gate)); + vm.snapshotGasLastCall(NAMESPACE, 'updateGate: set'); } - function test_operations_mandatoryPositionManagerUnset() public { - _snapshotOperations('mpm unset'); + function test_operations_gateUnset() public { + _snapshotOperations('gate unset'); } - function test_operations_mandatoryPositionManagerSet() public { - mandatoryPositionManager.setGated(ISpoke.supply.selector, true); - mandatoryPositionManager.setGated(ISpoke.withdraw.selector, true); - mandatoryPositionManager.setGated(ISpoke.borrow.selector, true); - mandatoryPositionManager.setGated(ISpoke.repay.selector, true); - mandatoryPositionManager.setGated(ISpoke.setUsingAsCollateral.selector, true); - mandatoryPositionManager.setEligible(alice, true); - _setMandatoryPositionManager(address(mandatoryPositionManager)); + function test_operations_gateSet() public { + gate.setGated(ISpoke.supply.selector, true); + gate.setGated(ISpoke.withdraw.selector, true); + gate.setGated(ISpoke.borrow.selector, true); + gate.setGated(ISpoke.repay.selector, true); + gate.setGated(ISpoke.setUsingAsCollateral.selector, true); + gate.setEligible(alice, true); + _setGate(address(gate)); - _snapshotOperations('mpm set'); + _snapshotOperations('gate set'); } function _snapshotOperations(string memory label) internal { diff --git a/tests/helpers/mocks/MockMandatoryPositionManager.sol b/tests/helpers/mocks/MockSpokeGate.sol similarity index 85% rename from tests/helpers/mocks/MockMandatoryPositionManager.sol rename to tests/helpers/mocks/MockSpokeGate.sol index ed3614598..6666bea64 100644 --- a/tests/helpers/mocks/MockMandatoryPositionManager.sol +++ b/tests/helpers/mocks/MockSpokeGate.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -import {IMandatoryPositionManager} from 'src/spoke/interfaces/IMandatoryPositionManager.sol'; +import {ISpokeGate} from 'src/spoke/interfaces/ISpokeGate.sol'; import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; -/// @dev Mandatory position manager mock: +/// @dev Gate mock: /// - `globalManager`s are allowed to act on behalf of any user (e.g. an RWA manager) /// - `gated` selectors additionally require the position owner to be `eligible` /// - otherwise falls back to the Spoke's default position manager authorization -contract MockMandatoryPositionManager is IMandatoryPositionManager { +contract MockSpokeGate is ISpokeGate { ISpoke public immutable SPOKE; mapping(address caller => bool) public globalManager; diff --git a/tests/setup/PermissionedSpokeBase.sol b/tests/setup/PermissionedSpokeBase.sol index 2219fb50c..6c157521c 100644 --- a/tests/setup/PermissionedSpokeBase.sol +++ b/tests/setup/PermissionedSpokeBase.sol @@ -5,13 +5,13 @@ import 'tests/setup/Base.t.sol'; import {PermissionedSpokeInstance} from 'src/spoke/instances/PermissionedSpokeInstance.sol'; import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; -import {MockMandatoryPositionManager} from 'tests/helpers/mocks/MockMandatoryPositionManager.sol'; +import {MockSpokeGate} from 'tests/helpers/mocks/MockSpokeGate.sol'; /// @dev Deploys a spoke with the `PermissionedSpokeInstance` implementation, two reserves on hub1 /// (weth as collateral, usdx as borrowable) and a mock mandatory position manager. abstract contract PermissionedSpokeBase is Base { ISpoke internal spoke; - MockMandatoryPositionManager internal mandatoryPositionManager; + MockSpokeGate internal gate; address internal RWA_MANAGER = makeAddr('RWA_MANAGER'); uint256 internal wethReserveId; @@ -35,7 +35,7 @@ abstract contract PermissionedSpokeBase is Base { }); _setupFixturesRoles(report); spoke = ISpoke(report.spokeReports[0].spoke); - mandatoryPositionManager = new MockMandatoryPositionManager(spoke); + gate = new MockSpokeGate(spoke); IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, @@ -98,10 +98,8 @@ abstract contract PermissionedSpokeBase is Base { }); } - function _setMandatoryPositionManager(address newMandatoryPositionManager) internal { + function _setGate(address newGate) internal { vm.prank(ADMIN); - PermissionedSpokeInstance(address(spoke)).updateMandatoryPositionManager( - newMandatoryPositionManager - ); + PermissionedSpokeInstance(address(spoke)).updateGate(newGate); } } From 29fc856ddcbf86bc7835570c77cffb7cf273e9e7 Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:23:14 +0300 Subject: [PATCH 05/10] test: gas snapshots with matched permissioning policies --- snapshots/PermissionedSpoke.Operations.json | 22 ++++-- .../PermissionedSpoke.Operations.gas.t.sol | 33 ++++++--- tests/helpers/mocks/PolicyGates.sol | 74 +++++++++++++++++++ 3 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 tests/helpers/mocks/PolicyGates.sol diff --git a/snapshots/PermissionedSpoke.Operations.json b/snapshots/PermissionedSpoke.Operations.json index d7cc7f211..8466b6f65 100644 --- a/snapshots/PermissionedSpoke.Operations.json +++ b/snapshots/PermissionedSpoke.Operations.json @@ -1,13 +1,23 @@ { - "borrow: gate set": "306868", + "borrow: borrow-allowlist policy": "305681", "borrow: gate unset": "295329", - "repay: partial, gate set": "159421", + "borrow: global-manager policy": "300105", + "borrow: position-manager policy": "300011", + "repay: partial, borrow-allowlist policy": "152816", "repay: partial, gate unset": "147882", - "supply: gate set": "141024", + "repay: partial, global-manager policy": "152658", + "repay: partial, position-manager policy": "152564", + "supply: borrow-allowlist policy": "134419", "supply: gate unset": "129470", + "supply: global-manager policy": "134261", + "supply: position-manager policy": "134167", "updateGate: set": "64794", - "usingAsCollateral: enable, gate set": "73297", + "usingAsCollateral: enable, borrow-allowlist policy": "66692", "usingAsCollateral: enable, gate unset": "61737", - "withdraw: partial, gate set": "194256", - "withdraw: partial, gate unset": "182716" + "usingAsCollateral: enable, global-manager policy": "66534", + "usingAsCollateral: enable, position-manager policy": "66440", + "withdraw: partial, borrow-allowlist policy": "187651", + "withdraw: partial, gate unset": "182716", + "withdraw: partial, global-manager policy": "187493", + "withdraw: partial, position-manager policy": "187399" } \ No newline at end of file diff --git a/tests/gas/PermissionedSpoke.Operations.gas.t.sol b/tests/gas/PermissionedSpoke.Operations.gas.t.sol index 5c5dcf571..39a3bbb5a 100644 --- a/tests/gas/PermissionedSpoke.Operations.gas.t.sol +++ b/tests/gas/PermissionedSpoke.Operations.gas.t.sol @@ -3,6 +3,13 @@ pragma solidity ^0.8.0; import 'tests/setup/PermissionedSpokeBase.sol'; +import { + PositionManagerPolicyGate, + GlobalManagerPolicyGate, + BorrowAllowlistPolicyGate, + MockAllowlist +} from 'tests/helpers/mocks/PolicyGates.sol'; + /// forge-config: default.isolate = true contract PermissionedSpokeOperations_Gas_Tests is PermissionedSpokeBase { string internal NAMESPACE = 'PermissionedSpoke.Operations'; @@ -30,16 +37,24 @@ contract PermissionedSpokeOperations_Gas_Tests is PermissionedSpokeBase { _snapshotOperations('gate unset'); } - function test_operations_gateSet() public { - gate.setGated(ISpoke.supply.selector, true); - gate.setGated(ISpoke.withdraw.selector, true); - gate.setGated(ISpoke.borrow.selector, true); - gate.setGated(ISpoke.repay.selector, true); - gate.setGated(ISpoke.setUsingAsCollateral.selector, true); - gate.setEligible(alice, true); - _setGate(address(gate)); + /// @dev Same authorization as the standard spoke, routed through the gate. + function test_operations_positionManagerPolicy() public { + _setGate(address(new PositionManagerPolicyGate(spoke))); + _snapshotOperations('position-manager policy'); + } + + /// @dev Horizon-style policy: a fixed global manager may act for any user. + function test_operations_globalManagerPolicy() public { + _setGate(address(new GlobalManagerPolicyGate(spoke, RWA_MANAGER))); + _snapshotOperations('global-manager policy'); + } - _snapshotOperations('gate set'); + /// @dev EtherFi-style policy: borrowing restricted to an external allowlist. + function test_operations_borrowAllowlistPolicy() public { + MockAllowlist allowlist = new MockAllowlist(); + allowlist.setAllowed(alice, true); + _setGate(address(new BorrowAllowlistPolicyGate(spoke, allowlist))); + _snapshotOperations('borrow-allowlist policy'); } function _snapshotOperations(string memory label) internal { diff --git a/tests/helpers/mocks/PolicyGates.sol b/tests/helpers/mocks/PolicyGates.sol new file mode 100644 index 000000000..47292fd7e --- /dev/null +++ b/tests/helpers/mocks/PolicyGates.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {ISpokeGate} from 'src/spoke/interfaces/ISpokeGate.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +interface IAllowlist { + function isAllowed(address account) external view returns (bool); +} + +/// @dev Gate replicating the default position-manager authorization. +contract PositionManagerPolicyGate is ISpokeGate { + ISpoke public immutable SPOKE; + + constructor(ISpoke spoke) { + SPOKE = spoke; + } + + function isCallAllowed( + address caller, + address onBehalfOf, + bytes calldata + ) external view returns (bool) { + return SPOKE.isPositionManager(onBehalfOf, caller); + } +} + +/// @dev Gate allowing a fixed global manager to act on behalf of any user (e.g. an RWA manager). +contract GlobalManagerPolicyGate is ISpokeGate { + ISpoke public immutable SPOKE; + address public immutable GLOBAL_MANAGER; + + constructor(ISpoke spoke, address globalManager) { + SPOKE = spoke; + GLOBAL_MANAGER = globalManager; + } + + function isCallAllowed( + address caller, + address onBehalfOf, + bytes calldata + ) external view returns (bool) { + if (caller == GLOBAL_MANAGER) return true; + return SPOKE.isPositionManager(onBehalfOf, caller); + } +} + +/// @dev Gate restricting borrowing to allowlisted position owners. +contract BorrowAllowlistPolicyGate is ISpokeGate { + ISpoke public immutable SPOKE; + IAllowlist public immutable ALLOWLIST; + + constructor(ISpoke spoke, IAllowlist allowlist) { + SPOKE = spoke; + ALLOWLIST = allowlist; + } + + function isCallAllowed( + address caller, + address onBehalfOf, + bytes calldata data + ) external view returns (bool) { + if (bytes4(data) == ISpoke.borrow.selector && !ALLOWLIST.isAllowed(onBehalfOf)) return false; + return SPOKE.isPositionManager(onBehalfOf, caller); + } +} + +contract MockAllowlist is IAllowlist { + mapping(address account => bool) public isAllowed; + + function setAllowed(address account, bool value) external { + isAllowed[account] = value; + } +} From afd74f34f04eac87ac8ccd0b31fc10484ffbca33 Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:50:49 +0300 Subject: [PATCH 06/10] feat: require the gate at initialization --- snapshots/PermissionedSpoke.Operations.json | 36 +++----- .../instances/PermissionedSpokeInstance.sol | 51 ++++++++--- src/spoke/instances/SpokeInstanceBase.sol | 2 +- src/spoke/interfaces/IPermissionedSpoke.sol | 6 +- src/spoke/interfaces/ISpokeGate.sol | 3 +- .../spoke/misc/PermissionedSpoke.t.sol | 91 +++++++++++-------- .../PermissionedSpoke.Operations.gas.t.sol | 56 +++++------- tests/helpers/mocks/MockSpokeGate.sol | 10 +- tests/helpers/mocks/PolicyGates.sol | 20 +--- tests/setup/PermissionedSpokeBase.sol | 64 +++++++------ 10 files changed, 175 insertions(+), 164 deletions(-) diff --git a/snapshots/PermissionedSpoke.Operations.json b/snapshots/PermissionedSpoke.Operations.json index 8466b6f65..978b3c5f6 100644 --- a/snapshots/PermissionedSpoke.Operations.json +++ b/snapshots/PermissionedSpoke.Operations.json @@ -1,23 +1,17 @@ { - "borrow: borrow-allowlist policy": "305681", - "borrow: gate unset": "295329", - "borrow: global-manager policy": "300105", - "borrow: position-manager policy": "300011", - "repay: partial, borrow-allowlist policy": "152816", - "repay: partial, gate unset": "147882", - "repay: partial, global-manager policy": "152658", - "repay: partial, position-manager policy": "152564", - "supply: borrow-allowlist policy": "134419", - "supply: gate unset": "129470", - "supply: global-manager policy": "134261", - "supply: position-manager policy": "134167", - "updateGate: set": "64794", - "usingAsCollateral: enable, borrow-allowlist policy": "66692", - "usingAsCollateral: enable, gate unset": "61737", - "usingAsCollateral: enable, global-manager policy": "66534", - "usingAsCollateral: enable, position-manager policy": "66440", - "withdraw: partial, borrow-allowlist policy": "187651", - "withdraw: partial, gate unset": "182716", - "withdraw: partial, global-manager policy": "187493", - "withdraw: partial, position-manager policy": "187399" + "borrow: borrow-allowlist policy": "305671", + "borrow: global-manager policy": "300095", + "borrow: position-manager policy": "299971", + "repay: partial, borrow-allowlist policy": "152806", + "repay: partial, global-manager policy": "152648", + "repay: partial, position-manager policy": "152524", + "supply: borrow-allowlist policy": "134409", + "supply: global-manager policy": "134251", + "supply: position-manager policy": "134127", + "usingAsCollateral: enable, borrow-allowlist policy": "66682", + "usingAsCollateral: enable, global-manager policy": "66524", + "usingAsCollateral: enable, position-manager policy": "66400", + "withdraw: partial, borrow-allowlist policy": "187619", + "withdraw: partial, global-manager policy": "187461", + "withdraw: partial, position-manager policy": "187337" } \ No newline at end of file diff --git a/src/spoke/instances/PermissionedSpokeInstance.sol b/src/spoke/instances/PermissionedSpokeInstance.sol index 9bbe56219..ff1e7b552 100644 --- a/src/spoke/instances/PermissionedSpokeInstance.sol +++ b/src/spoke/instances/PermissionedSpokeInstance.sol @@ -7,8 +7,8 @@ import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; /// @title PermissionedSpokeInstance /// @author Aave Labs -/// @notice Spoke implementation with a configurable gate, which replaces the default position -/// manager authorization on position actions. +/// @notice Spoke implementation where a gate, settable by governance, replaces the default +/// position manager authorization on position actions. contract PermissionedSpokeInstance is SpokeInstanceBase, IPermissionedSpoke { /// @custom:storage-location erc7201:aave.storage.PermissionedSpoke struct PermissionedSpokeStorage { @@ -33,10 +33,31 @@ contract PermissionedSpokeInstance is SpokeInstanceBase, IPermissionedSpoke { uint16 maxUserReservesLimit_ ) SpokeInstanceBase(oracle_, maxUserReservesLimit_) {} + /// @dev Disabled in favor of `initialize(address,address)`. + function initialize(address) external pure override { + revert InvalidInitialization(); + } + + /// @notice Initializer. + /// @dev The authority contract must implement the `AccessManaged` interface for access control. + /// @param authority The address of the authority contract which manages permissions. + /// @param gate The address of the gate. + function initialize(address authority, address gate) external reinitializer(SPOKE_REVISION) { + emit SetSpokeImmutables(ORACLE, MAX_USER_RESERVES_LIMIT); + + require(authority != address(0), InvalidAddress()); + __AccessManaged_init(authority); + if (_liquidationConfig.targetHealthFactor == 0) { + _liquidationConfig.targetHealthFactor = HEALTH_FACTOR_LIQUIDATION_THRESHOLD; + emit UpdateLiquidationConfig(_liquidationConfig); + } + + _updateGate(gate); + } + /// @inheritdoc IPermissionedSpoke function updateGate(address gate) external restricted { - _permissionedSpokeStorage().gate = gate; - emit UpdateGate(gate); + _updateGate(gate); } /// @inheritdoc IPermissionedSpoke @@ -44,17 +65,25 @@ contract PermissionedSpokeInstance is SpokeInstanceBase, IPermissionedSpoke { return _permissionedSpokeStorage().gate; } - /// @dev When a gate is set, it replaces the default authorization and fully decides whether the - /// call is allowed, based on the caller, the position owner and the calldata. + function _updateGate(address gate) internal { + require(gate != address(0), InvalidAddress()); + _permissionedSpokeStorage().gate = gate; + emit UpdateGate(gate); + } + + /// @dev The gate fully decides whether the call is allowed, based on the caller, the position + /// owner and the calldata. It can preserve the default authorization by calling back + /// `isPositionManager`. function _isAuthorizedPositionManagerCall( address caller, address user, bytes calldata data ) internal view override returns (bool) { - address gate = _permissionedSpokeStorage().gate; - if (gate == address(0)) { - return super._isAuthorizedPositionManagerCall(caller, user, data); - } - return ISpokeGate(gate).isCallAllowed({caller: caller, onBehalfOf: user, data: data}); + return + ISpokeGate(_permissionedSpokeStorage().gate).isCallAllowed({ + caller: caller, + onBehalfOf: user, + data: data + }); } } diff --git a/src/spoke/instances/SpokeInstanceBase.sol b/src/spoke/instances/SpokeInstanceBase.sol index 5f4a5c109..1e213d4e9 100644 --- a/src/spoke/instances/SpokeInstanceBase.sol +++ b/src/spoke/instances/SpokeInstanceBase.sol @@ -20,7 +20,7 @@ abstract contract SpokeInstanceBase is Spoke { /// @notice Initializer. /// @dev The authority contract must implement the `AccessManaged` interface for access control. /// @param authority The address of the authority contract which manages permissions. - function initialize(address authority) external override reinitializer(SPOKE_REVISION) { + function initialize(address authority) external virtual override reinitializer(SPOKE_REVISION) { emit SetSpokeImmutables(ORACLE, MAX_USER_RESERVES_LIMIT); require(authority != address(0), InvalidAddress()); diff --git a/src/spoke/interfaces/IPermissionedSpoke.sol b/src/spoke/interfaces/IPermissionedSpoke.sol index 818cc2664..b7bd4be95 100644 --- a/src/spoke/interfaces/IPermissionedSpoke.sol +++ b/src/spoke/interfaces/IPermissionedSpoke.sol @@ -10,11 +10,11 @@ interface IPermissionedSpoke { event UpdateGate(address indexed gate); /// @notice Updates the gate. - /// @dev When set, it replaces the default position manager authorization on position actions. - /// @dev Setting the zero address removes it, restoring the default authorization. + /// @dev The gate replaces the default position manager authorization on position actions. + /// @dev It reverts on the zero address; a gate is required from initialization onwards. /// @param gate The address of the gate. function updateGate(address gate) external; - /// @notice Returns the address of the gate, or the zero address if unset. + /// @notice Returns the address of the gate. function getGate() external view returns (address); } diff --git a/src/spoke/interfaces/ISpokeGate.sol b/src/spoke/interfaces/ISpokeGate.sol index e68617528..075c68f7a 100644 --- a/src/spoke/interfaces/ISpokeGate.sol +++ b/src/spoke/interfaces/ISpokeGate.sol @@ -7,7 +7,8 @@ pragma solidity ^0.8.0; /// position actions of a permissioned Spoke. interface ISpokeGate { /// @notice Returns whether a position action on the Spoke is allowed. - /// @dev It can preserve the default authorization by calling back `ISpoke.isPositionManager`. + /// @dev Called by the Spoke, so it can preserve the default authorization by calling back + /// `ISpoke(msg.sender).isPositionManager`. /// @param caller The transaction initiator on the Spoke. /// @param onBehalfOf The owner of the position being modified. /// @param data The full calldata of the Spoke call, allowing per-action decoding. diff --git a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol index 09457346f..e22049bfc 100644 --- a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol +++ b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol @@ -4,44 +4,44 @@ pragma solidity ^0.8.0; import 'tests/setup/PermissionedSpokeBase.sol'; contract PermissionedSpokeTest is PermissionedSpokeBase { - function test_defaultBehavior_withoutGate() public { - _supplyCollateralAndBorrow(alice, 100e6); + function test_initialize() public { + assertEq(PermissionedSpokeInstance(address(spoke)).getGate(), address(gate)); - // an unapproved caller still cannot act on behalf of alice - vm.expectRevert(ISpoke.Unauthorized.selector); - SpokeActions.withdraw({ - spoke: spoke, - reserveId: usdxReserveId, - caller: bob, - amount: 1e6, - onBehalfOf: alice + // the single-argument initializer is disabled + PermissionedSpokeInstance implementation = new PermissionedSpokeInstance({ + oracle_: address(oracle1), + maxUserReservesLimit_: DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT }); + vm.expectRevert(Initializable.InvalidInitialization.selector); + new TransparentUpgradeableProxy( + address(implementation), + PROXY_ADMIN_OWNER, + abi.encodeCall(ISpokeInstance.initialize, (address(accessManager))) + ); + + // the gate is required at initialization + vm.expectRevert(ISpoke.InvalidAddress.selector); + new TransparentUpgradeableProxy( + address(implementation), + PROXY_ADMIN_OWNER, + abi.encodeWithSignature('initialize(address,address)', address(accessManager), address(0)) + ); } function test_updateGate() public { - vm.expectEmit(address(spoke)); - emit IPermissionedSpoke.UpdateGate(address(gate)); + address newGate = address(new MockSpokeGate()); + vm.expectEmit(address(spoke)); + emit IPermissionedSpoke.UpdateGate(newGate); vm.prank(ADMIN); - PermissionedSpokeInstance(address(spoke)).updateGate(address(gate)); + PermissionedSpokeInstance(address(spoke)).updateGate(newGate); - assertEq(PermissionedSpokeInstance(address(spoke)).getGate(), address(gate)); - } + assertEq(PermissionedSpokeInstance(address(spoke)).getGate(), newGate); - function test_updateGate_removal() public { - _setGate(address(gate)); - _setGate(address(0)); - - assertEq(PermissionedSpokeInstance(address(spoke)).getGate(), address(0)); - - // default authorization is restored - SpokeActions.supply({ - spoke: spoke, - reserveId: usdxReserveId, - caller: alice, - amount: 100e6, - onBehalfOf: alice - }); + // the gate cannot be unset + vm.expectRevert(ISpoke.InvalidAddress.selector); + vm.prank(ADMIN); + PermissionedSpokeInstance(address(spoke)).updateGate(address(0)); } function test_updateGate_revertsIfUnauthorized() public { @@ -52,9 +52,22 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { PermissionedSpokeInstance(address(spoke)).updateGate(address(gate)); } + function test_defaultBehaviorViaCallback() public { + _supplyCollateralAndBorrow(alice, 100e6); + + // an unapproved caller still cannot act on behalf of alice + vm.expectRevert(ISpoke.Unauthorized.selector); + SpokeActions.withdraw({ + spoke: spoke, + reserveId: usdxReserveId, + caller: bob, + amount: 1e6, + onBehalfOf: alice + }); + } + function test_permissionedBorrow() public { gate.setGated(ISpoke.borrow.selector, true); - _setGate(address(gate)); // supply is not gated for ineligible users SpokeActions.supplyCollateral({ @@ -86,9 +99,7 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { assertEq(spoke.getUserTotalDebt(usdxReserveId, alice), 100e6); } - function test_defaultApprovalsPreservedViaCallback() public { - _setGate(address(gate)); - + function test_approvedPositionManagersPreservedViaCallback() public { SpokeActions.supply({ spoke: spoke, reserveId: usdxReserveId, @@ -128,7 +139,6 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { /// withdrawing on her behalf and re-supplying to bob, without any user approval. function test_forcedTransfer_viaGlobalManager() public { gate.setGlobalManager(RWA_MANAGER, true); - _setGate(address(gate)); uint256 amount = 100e6; SpokeActions.supply({ @@ -163,7 +173,6 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { function test_forcedWithdraw_stillValidatesHealthFactor() public { gate.setGlobalManager(RWA_MANAGER, true); - _setGate(address(gate)); _supplyCollateralAndBorrow(alice, 100e6); @@ -178,8 +187,6 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { } function test_liquidationCallUnaffected() public { - _setGate(address(gate)); - SpokeActions.supply({ spoke: spoke, reserveId: usdxReserveId, @@ -203,9 +210,16 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { pricePercentage: 90_00 }); + // gate everything; neither alice nor bob are eligible + gate.setGated(ISpoke.supply.selector, true); + gate.setGated(ISpoke.withdraw.selector, true); + gate.setGated(ISpoke.borrow.selector, true); + gate.setGated(ISpoke.repay.selector, true); + gate.setGated(ISpoke.setUsingAsCollateral.selector, true); + gate.setGated(ISpoke.liquidationCall.selector, true); + uint256 debtBefore = spoke.getUserTotalDebt(usdxReserveId, alice); - // bob is neither eligible nor a global manager; liquidations are not gated SpokeActions.liquidationCall({ spoke: spoke, collateralReserveId: wethReserveId, @@ -221,7 +235,6 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { function test_cannotBeBypassedWithMulticall() public { gate.setGated(ISpoke.borrow.selector, true); - _setGate(address(gate)); bytes[] memory calls = new bytes[](1); calls[0] = abi.encodeCall(ISpoke.borrow, (usdxReserveId, 100e6, alice)); diff --git a/tests/gas/PermissionedSpoke.Operations.gas.t.sol b/tests/gas/PermissionedSpoke.Operations.gas.t.sol index 39a3bbb5a..77c0b296b 100644 --- a/tests/gas/PermissionedSpoke.Operations.gas.t.sol +++ b/tests/gas/PermissionedSpoke.Operations.gas.t.sol @@ -14,66 +14,52 @@ import { contract PermissionedSpokeOperations_Gas_Tests is PermissionedSpokeBase { string internal NAMESPACE = 'PermissionedSpoke.Operations'; - function setUp() public virtual override { - super.setUp(); - - // seed borrowable liquidity - SpokeActions.supply({ - spoke: spoke, - reserveId: usdxReserveId, - caller: bob, - amount: 100_000e6, - onBehalfOf: bob - }); - } - - function test_updateGate() public { - vm.prank(ADMIN); - PermissionedSpokeInstance(address(spoke)).updateGate(address(gate)); - vm.snapshotGasLastCall(NAMESPACE, 'updateGate: set'); - } - - function test_operations_gateUnset() public { - _snapshotOperations('gate unset'); - } - /// @dev Same authorization as the standard spoke, routed through the gate. function test_operations_positionManagerPolicy() public { - _setGate(address(new PositionManagerPolicyGate(spoke))); - _snapshotOperations('position-manager policy'); + ISpoke target = _deployPermissionedSpoke(address(new PositionManagerPolicyGate())); + _snapshotOperations(target, 'position-manager policy'); } /// @dev Horizon-style policy: a fixed global manager may act for any user. function test_operations_globalManagerPolicy() public { - _setGate(address(new GlobalManagerPolicyGate(spoke, RWA_MANAGER))); - _snapshotOperations('global-manager policy'); + ISpoke target = _deployPermissionedSpoke(address(new GlobalManagerPolicyGate(RWA_MANAGER))); + _snapshotOperations(target, 'global-manager policy'); } /// @dev EtherFi-style policy: borrowing restricted to an external allowlist. function test_operations_borrowAllowlistPolicy() public { MockAllowlist allowlist = new MockAllowlist(); allowlist.setAllowed(alice, true); - _setGate(address(new BorrowAllowlistPolicyGate(spoke, allowlist))); - _snapshotOperations('borrow-allowlist policy'); + ISpoke target = _deployPermissionedSpoke(address(new BorrowAllowlistPolicyGate(allowlist))); + _snapshotOperations(target, 'borrow-allowlist policy'); } - function _snapshotOperations(string memory label) internal { + function _snapshotOperations(ISpoke target, string memory label) internal { + // seed borrowable liquidity + SpokeActions.supply({ + spoke: target, + reserveId: usdxReserveId, + caller: bob, + amount: 100_000e6, + onBehalfOf: bob + }); + vm.startPrank(alice); - spoke.supply(usdxReserveId, 1000e6, alice); + target.supply(usdxReserveId, 1000e6, alice); vm.snapshotGasLastCall(NAMESPACE, string.concat('supply: ', label)); - spoke.setUsingAsCollateral(usdxReserveId, true, alice); + target.setUsingAsCollateral(usdxReserveId, true, alice); vm.snapshotGasLastCall(NAMESPACE, string.concat('usingAsCollateral: enable, ', label)); - spoke.borrow(usdxReserveId, 100e6, alice); + target.borrow(usdxReserveId, 100e6, alice); vm.snapshotGasLastCall(NAMESPACE, string.concat('borrow: ', label)); skip(100); - spoke.repay(usdxReserveId, 50e6, alice); + target.repay(usdxReserveId, 50e6, alice); vm.snapshotGasLastCall(NAMESPACE, string.concat('repay: partial, ', label)); - spoke.withdraw(usdxReserveId, 100e6, alice); + target.withdraw(usdxReserveId, 100e6, alice); vm.snapshotGasLastCall(NAMESPACE, string.concat('withdraw: partial, ', label)); vm.stopPrank(); } diff --git a/tests/helpers/mocks/MockSpokeGate.sol b/tests/helpers/mocks/MockSpokeGate.sol index 6666bea64..062a01ce4 100644 --- a/tests/helpers/mocks/MockSpokeGate.sol +++ b/tests/helpers/mocks/MockSpokeGate.sol @@ -7,18 +7,12 @@ import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; /// @dev Gate mock: /// - `globalManager`s are allowed to act on behalf of any user (e.g. an RWA manager) /// - `gated` selectors additionally require the position owner to be `eligible` -/// - otherwise falls back to the Spoke's default position manager authorization +/// - otherwise falls back to the calling Spoke's default position manager authorization contract MockSpokeGate is ISpokeGate { - ISpoke public immutable SPOKE; - mapping(address caller => bool) public globalManager; mapping(bytes4 selector => bool) public gated; mapping(address user => bool) public eligible; - constructor(ISpoke spoke) { - SPOKE = spoke; - } - function setGlobalManager(address caller, bool value) external { globalManager[caller] = value; } @@ -38,6 +32,6 @@ contract MockSpokeGate is ISpokeGate { ) external view returns (bool) { if (globalManager[caller]) return true; if (gated[bytes4(data)] && !eligible[onBehalfOf]) return false; - return SPOKE.isPositionManager(onBehalfOf, caller); + return ISpoke(msg.sender).isPositionManager(onBehalfOf, caller); } } diff --git a/tests/helpers/mocks/PolicyGates.sol b/tests/helpers/mocks/PolicyGates.sol index 47292fd7e..f488b27ce 100644 --- a/tests/helpers/mocks/PolicyGates.sol +++ b/tests/helpers/mocks/PolicyGates.sol @@ -10,28 +10,20 @@ interface IAllowlist { /// @dev Gate replicating the default position-manager authorization. contract PositionManagerPolicyGate is ISpokeGate { - ISpoke public immutable SPOKE; - - constructor(ISpoke spoke) { - SPOKE = spoke; - } - function isCallAllowed( address caller, address onBehalfOf, bytes calldata ) external view returns (bool) { - return SPOKE.isPositionManager(onBehalfOf, caller); + return ISpoke(msg.sender).isPositionManager(onBehalfOf, caller); } } /// @dev Gate allowing a fixed global manager to act on behalf of any user (e.g. an RWA manager). contract GlobalManagerPolicyGate is ISpokeGate { - ISpoke public immutable SPOKE; address public immutable GLOBAL_MANAGER; - constructor(ISpoke spoke, address globalManager) { - SPOKE = spoke; + constructor(address globalManager) { GLOBAL_MANAGER = globalManager; } @@ -41,17 +33,15 @@ contract GlobalManagerPolicyGate is ISpokeGate { bytes calldata ) external view returns (bool) { if (caller == GLOBAL_MANAGER) return true; - return SPOKE.isPositionManager(onBehalfOf, caller); + return ISpoke(msg.sender).isPositionManager(onBehalfOf, caller); } } /// @dev Gate restricting borrowing to allowlisted position owners. contract BorrowAllowlistPolicyGate is ISpokeGate { - ISpoke public immutable SPOKE; IAllowlist public immutable ALLOWLIST; - constructor(ISpoke spoke, IAllowlist allowlist) { - SPOKE = spoke; + constructor(IAllowlist allowlist) { ALLOWLIST = allowlist; } @@ -61,7 +51,7 @@ contract BorrowAllowlistPolicyGate is ISpokeGate { bytes calldata data ) external view returns (bool) { if (bytes4(data) == ISpoke.borrow.selector && !ALLOWLIST.isAllowed(onBehalfOf)) return false; - return SPOKE.isPositionManager(onBehalfOf, caller); + return ISpoke(msg.sender).isPositionManager(onBehalfOf, caller); } } diff --git a/tests/setup/PermissionedSpokeBase.sol b/tests/setup/PermissionedSpokeBase.sol index 6c157521c..429ae3854 100644 --- a/tests/setup/PermissionedSpokeBase.sol +++ b/tests/setup/PermissionedSpokeBase.sol @@ -3,16 +3,19 @@ pragma solidity ^0.8.0; import 'tests/setup/Base.t.sol'; +import {TransparentUpgradeableProxy} from 'src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol'; +import {DeployConstants} from 'src/deployments/utils/libraries/DeployConstants.sol'; import {PermissionedSpokeInstance} from 'src/spoke/instances/PermissionedSpokeInstance.sol'; import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; import {MockSpokeGate} from 'tests/helpers/mocks/MockSpokeGate.sol'; -/// @dev Deploys a spoke with the `PermissionedSpokeInstance` implementation, two reserves on hub1 -/// (weth as collateral, usdx as borrowable) and a mock mandatory position manager. +/// @dev Deploys a spoke with the `PermissionedSpokeInstance` implementation gated by a mock gate, +/// with two reserves on hub1 (weth as collateral, usdx as borrowable). abstract contract PermissionedSpokeBase is Base { ISpoke internal spoke; MockSpokeGate internal gate; address internal RWA_MANAGER = makeAddr('RWA_MANAGER'); + address internal PROXY_ADMIN_OWNER = makeAddr('PROXY_ADMIN_OWNER'); uint256 internal wethReserveId; uint256 internal usdxReserveId; @@ -20,22 +23,28 @@ abstract contract PermissionedSpokeBase is Base { function setUp() public virtual override { super.setUp(); - // Deploy a fresh spoke with the PermissionedSpokeInstance implementation - TestTypes.TestEnvReport memory report = AaveV4TestOrchestration.deployTestEnv({ - admin: ADMIN, - treasuryAdmin: TREASURY_ADMIN, - hubCount: 0, - spokeCount: 1, - nativeWrapper: address(tokenList.weth), - hubBytecode: BytecodeHelper.getHubBytecode(), - spokeBytecode: vm.getCode( - 'src/spoke/instances/PermissionedSpokeInstance.sol:PermissionedSpokeInstance' - ), - salt: bytes32(vm.randomBytes(32)) + gate = new MockSpokeGate(); + spoke = _deployPermissionedSpoke(address(gate)); + } + + /// @dev Deploys a permissioned spoke with the given gate, mirroring the standard fixture config. + function _deployPermissionedSpoke(address newGate) internal returns (ISpoke newSpoke) { + AaveOracle oracle = new AaveOracle(8); + PermissionedSpokeInstance implementation = new PermissionedSpokeInstance({ + oracle_: address(oracle), + maxUserReservesLimit_: DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT }); - _setupFixturesRoles(report); - spoke = ISpoke(report.spokeReports[0].spoke); - gate = new MockSpokeGate(spoke); + newSpoke = ISpoke( + address( + new TransparentUpgradeableProxy( + address(implementation), + PROXY_ADMIN_OWNER, + abi.encodeWithSignature('initialize(address,address)', address(accessManager), newGate) + ) + ) + ); + oracle.setSpoke(address(newSpoke)); + setUpRoles(hub1, newSpoke, accessManager); IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ active: true, @@ -46,10 +55,10 @@ abstract contract PermissionedSpokeBase is Base { }); vm.startPrank(ADMIN); - wethReserveId = spoke.addReserve( + wethReserveId = newSpoke.addReserve( address(hub1), wethAssetId, - _deployMockPriceFeed(spoke, 2000e8), + _deployMockPriceFeed(newSpoke, 2000e8), _getDefaultReserveConfig(15_00), ISpoke.DynamicReserveConfig({ collateralFactor: 80_00, @@ -57,10 +66,10 @@ abstract contract PermissionedSpokeBase is Base { liquidationFee: 10_00 }) ); - usdxReserveId = spoke.addReserve( + usdxReserveId = newSpoke.addReserve( address(hub1), usdxAssetId, - _deployMockPriceFeed(spoke, 1e8), + _deployMockPriceFeed(newSpoke, 1e8), _getDefaultReserveConfig(20_00), ISpoke.DynamicReserveConfig({ collateralFactor: 78_00, @@ -68,15 +77,15 @@ abstract contract PermissionedSpokeBase is Base { liquidationFee: 12_00 }) ); - hub1.addSpoke(wethAssetId, address(spoke), spokeConfig); - hub1.addSpoke(usdxAssetId, address(spoke), spokeConfig); + hub1.addSpoke(wethAssetId, address(newSpoke), spokeConfig); + hub1.addSpoke(usdxAssetId, address(newSpoke), spokeConfig); vm.stopPrank(); address[3] memory users = [alice, bob, RWA_MANAGER]; 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); + tokenList.weth.approve(address(newSpoke), type(uint256).max); + tokenList.usdx.approve(address(newSpoke), type(uint256).max); vm.stopPrank(); } } @@ -97,9 +106,4 @@ abstract contract PermissionedSpokeBase is Base { onBehalfOf: user }); } - - function _setGate(address newGate) internal { - vm.prank(ADMIN); - PermissionedSpokeInstance(address(spoke)).updateGate(newGate); - } } From b8a7d2f0e5d020a6c2a509613b37cd40d03bac65 Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:52:44 +0300 Subject: [PATCH 07/10] feat: make the gate immutable --- foundry.toml | 3 +- snapshots/PermissionedSpoke.Operations.json | 30 ++++---- .../instances/PermissionedSpokeInstance.sol | 75 +++---------------- src/spoke/interfaces/IPermissionedSpoke.sol | 20 ----- .../spoke/misc/PermissionedSpoke.t.sol | 49 ++---------- tests/setup/PermissionedSpokeBase.sol | 6 +- 6 files changed, 37 insertions(+), 146 deletions(-) delete mode 100644 src/spoke/interfaces/IPermissionedSpoke.sol diff --git a/foundry.toml b/foundry.toml index 0606b3c3d..72f9ab48a 100644 --- a/foundry.toml +++ b/foundry.toml @@ -22,13 +22,12 @@ dynamic_test_linking = true additional_compiler_profiles = [ { name = "hub", optimizer = true, via_ir = true, optimizer_runs = 22_300 }, { name = "spoke", optimizer = true, via_ir = true, optimizer_runs = 750 }, - { name = "permissioned-spoke", optimizer = true, via_ir = true, optimizer_runs = 600 }, ] compilation_restrictions = [ { paths = "src/hub/instances/HubInstance.sol", via_ir = true, optimizer_runs = 22_300 }, { paths = "src/spoke/instances/SpokeInstance.sol", via_ir = true, optimizer_runs = 750 }, - { paths = "src/spoke/instances/PermissionedSpokeInstance.sol", via_ir = true, optimizer_runs = 600 }, + { paths = "src/spoke/instances/PermissionedSpokeInstance.sol", via_ir = true, optimizer_runs = 750 }, ] [bind_json] diff --git a/snapshots/PermissionedSpoke.Operations.json b/snapshots/PermissionedSpoke.Operations.json index 978b3c5f6..7f5bed503 100644 --- a/snapshots/PermissionedSpoke.Operations.json +++ b/snapshots/PermissionedSpoke.Operations.json @@ -1,17 +1,17 @@ { - "borrow: borrow-allowlist policy": "305671", - "borrow: global-manager policy": "300095", - "borrow: position-manager policy": "299971", - "repay: partial, borrow-allowlist policy": "152806", - "repay: partial, global-manager policy": "152648", - "repay: partial, position-manager policy": "152524", - "supply: borrow-allowlist policy": "134409", - "supply: global-manager policy": "134251", - "supply: position-manager policy": "134127", - "usingAsCollateral: enable, borrow-allowlist policy": "66682", - "usingAsCollateral: enable, global-manager policy": "66524", - "usingAsCollateral: enable, position-manager policy": "66400", - "withdraw: partial, borrow-allowlist policy": "187619", - "withdraw: partial, global-manager policy": "187461", - "withdraw: partial, position-manager policy": "187337" + "borrow: borrow-allowlist policy": "303427", + "borrow: global-manager policy": "297890", + "borrow: position-manager policy": "297766", + "repay: partial, borrow-allowlist policy": "150680", + "repay: partial, global-manager policy": "150537", + "repay: partial, position-manager policy": "150413", + "supply: borrow-allowlist policy": "132289", + "supply: global-manager policy": "132146", + "supply: position-manager policy": "132022", + "usingAsCollateral: enable, borrow-allowlist policy": "64562", + "usingAsCollateral: enable, global-manager policy": "64419", + "usingAsCollateral: enable, position-manager policy": "64295", + "withdraw: partial, borrow-allowlist policy": "185421", + "withdraw: partial, global-manager policy": "185278", + "withdraw: partial, position-manager policy": "185154" } \ No newline at end of file diff --git a/src/spoke/instances/PermissionedSpokeInstance.sol b/src/spoke/instances/PermissionedSpokeInstance.sol index ff1e7b552..adab2a7f0 100644 --- a/src/spoke/instances/PermissionedSpokeInstance.sol +++ b/src/spoke/instances/PermissionedSpokeInstance.sol @@ -3,72 +3,26 @@ pragma solidity 0.8.28; import {SpokeInstanceBase} from 'src/spoke/instances/SpokeInstanceBase.sol'; import {ISpokeGate} from 'src/spoke/interfaces/ISpokeGate.sol'; -import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; /// @title PermissionedSpokeInstance /// @author Aave Labs -/// @notice Spoke implementation where a gate, settable by governance, replaces the default -/// position manager authorization on position actions. -contract PermissionedSpokeInstance is SpokeInstanceBase, IPermissionedSpoke { - /// @custom:storage-location erc7201:aave.storage.PermissionedSpoke - struct PermissionedSpokeStorage { - address gate; - } - - // keccak256(abi.encode(uint256(keccak256('aave.storage.PermissionedSpoke')) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant PermissionedSpokeStorageLocation = - 0xad19adda25bc112a506d1eb6b62266ed84c7e8969fba16c536d63fc20c4fda00; - - function _permissionedSpokeStorage() private pure returns (PermissionedSpokeStorage storage $) { - assembly { - $.slot := PermissionedSpokeStorageLocation - } - } +/// @notice Spoke implementation where a gate replaces the default position manager authorization +/// on position actions. +contract PermissionedSpokeInstance is SpokeInstanceBase { + /// @notice The gate deciding whether position actions are allowed. + address public immutable GATE; /// @dev Constructor. /// @param oracle_ The address of the oracle. /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. + /// @param gate_ The address of the gate. constructor( address oracle_, - uint16 maxUserReservesLimit_ - ) SpokeInstanceBase(oracle_, maxUserReservesLimit_) {} - - /// @dev Disabled in favor of `initialize(address,address)`. - function initialize(address) external pure override { - revert InvalidInitialization(); - } - - /// @notice Initializer. - /// @dev The authority contract must implement the `AccessManaged` interface for access control. - /// @param authority The address of the authority contract which manages permissions. - /// @param gate The address of the gate. - function initialize(address authority, address gate) external reinitializer(SPOKE_REVISION) { - emit SetSpokeImmutables(ORACLE, MAX_USER_RESERVES_LIMIT); - - require(authority != address(0), InvalidAddress()); - __AccessManaged_init(authority); - if (_liquidationConfig.targetHealthFactor == 0) { - _liquidationConfig.targetHealthFactor = HEALTH_FACTOR_LIQUIDATION_THRESHOLD; - emit UpdateLiquidationConfig(_liquidationConfig); - } - - _updateGate(gate); - } - - /// @inheritdoc IPermissionedSpoke - function updateGate(address gate) external restricted { - _updateGate(gate); - } - - /// @inheritdoc IPermissionedSpoke - function getGate() external view returns (address) { - return _permissionedSpokeStorage().gate; - } - - function _updateGate(address gate) internal { - require(gate != address(0), InvalidAddress()); - _permissionedSpokeStorage().gate = gate; - emit UpdateGate(gate); + uint16 maxUserReservesLimit_, + address gate_ + ) SpokeInstanceBase(oracle_, maxUserReservesLimit_) { + require(gate_ != address(0), InvalidAddress()); + GATE = gate_; } /// @dev The gate fully decides whether the call is allowed, based on the caller, the position @@ -79,11 +33,6 @@ contract PermissionedSpokeInstance is SpokeInstanceBase, IPermissionedSpoke { address user, bytes calldata data ) internal view override returns (bool) { - return - ISpokeGate(_permissionedSpokeStorage().gate).isCallAllowed({ - caller: caller, - onBehalfOf: user, - data: data - }); + return ISpokeGate(GATE).isCallAllowed({caller: caller, onBehalfOf: user, data: data}); } } diff --git a/src/spoke/interfaces/IPermissionedSpoke.sol b/src/spoke/interfaces/IPermissionedSpoke.sol deleted file mode 100644 index b7bd4be95..000000000 --- a/src/spoke/interfaces/IPermissionedSpoke.sol +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-BUSL -pragma solidity ^0.8.0; - -/// @title IPermissionedSpoke -/// @author Aave Labs -/// @notice Interface for the permissioned functionality of a Spoke. -interface IPermissionedSpoke { - /// @notice Emitted when the gate is updated. - /// @param gate The address of the gate, or the zero address if removed. - event UpdateGate(address indexed gate); - - /// @notice Updates the gate. - /// @dev The gate replaces the default position manager authorization on position actions. - /// @dev It reverts on the zero address; a gate is required from initialization onwards. - /// @param gate The address of the gate. - function updateGate(address gate) external; - - /// @notice Returns the address of the gate. - function getGate() external view returns (address); -} diff --git a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol index e22049bfc..8d4bdc919 100644 --- a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol +++ b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol @@ -4,52 +4,15 @@ pragma solidity ^0.8.0; import 'tests/setup/PermissionedSpokeBase.sol'; contract PermissionedSpokeTest is PermissionedSpokeBase { - function test_initialize() public { - assertEq(PermissionedSpokeInstance(address(spoke)).getGate(), address(gate)); + function test_constructor() public { + assertEq(PermissionedSpokeInstance(address(spoke)).GATE(), address(gate)); - // the single-argument initializer is disabled - PermissionedSpokeInstance implementation = new PermissionedSpokeInstance({ + vm.expectRevert(ISpoke.InvalidAddress.selector); + new PermissionedSpokeInstance({ oracle_: address(oracle1), - maxUserReservesLimit_: DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT + maxUserReservesLimit_: DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT, + gate_: address(0) }); - vm.expectRevert(Initializable.InvalidInitialization.selector); - new TransparentUpgradeableProxy( - address(implementation), - PROXY_ADMIN_OWNER, - abi.encodeCall(ISpokeInstance.initialize, (address(accessManager))) - ); - - // the gate is required at initialization - vm.expectRevert(ISpoke.InvalidAddress.selector); - new TransparentUpgradeableProxy( - address(implementation), - PROXY_ADMIN_OWNER, - abi.encodeWithSignature('initialize(address,address)', address(accessManager), address(0)) - ); - } - - function test_updateGate() public { - address newGate = address(new MockSpokeGate()); - - vm.expectEmit(address(spoke)); - emit IPermissionedSpoke.UpdateGate(newGate); - vm.prank(ADMIN); - PermissionedSpokeInstance(address(spoke)).updateGate(newGate); - - assertEq(PermissionedSpokeInstance(address(spoke)).getGate(), newGate); - - // the gate cannot be unset - vm.expectRevert(ISpoke.InvalidAddress.selector); - vm.prank(ADMIN); - PermissionedSpokeInstance(address(spoke)).updateGate(address(0)); - } - - function test_updateGate_revertsIfUnauthorized() public { - vm.expectRevert( - abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) - ); - vm.prank(alice); - PermissionedSpokeInstance(address(spoke)).updateGate(address(gate)); } function test_defaultBehaviorViaCallback() public { diff --git a/tests/setup/PermissionedSpokeBase.sol b/tests/setup/PermissionedSpokeBase.sol index 429ae3854..50746ff31 100644 --- a/tests/setup/PermissionedSpokeBase.sol +++ b/tests/setup/PermissionedSpokeBase.sol @@ -6,7 +6,6 @@ import 'tests/setup/Base.t.sol'; import {TransparentUpgradeableProxy} from 'src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol'; import {DeployConstants} from 'src/deployments/utils/libraries/DeployConstants.sol'; import {PermissionedSpokeInstance} from 'src/spoke/instances/PermissionedSpokeInstance.sol'; -import {IPermissionedSpoke} from 'src/spoke/interfaces/IPermissionedSpoke.sol'; import {MockSpokeGate} from 'tests/helpers/mocks/MockSpokeGate.sol'; /// @dev Deploys a spoke with the `PermissionedSpokeInstance` implementation gated by a mock gate, @@ -32,14 +31,15 @@ abstract contract PermissionedSpokeBase is Base { AaveOracle oracle = new AaveOracle(8); PermissionedSpokeInstance implementation = new PermissionedSpokeInstance({ oracle_: address(oracle), - maxUserReservesLimit_: DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT + maxUserReservesLimit_: DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT, + gate_: newGate }); newSpoke = ISpoke( address( new TransparentUpgradeableProxy( address(implementation), PROXY_ADMIN_OWNER, - abi.encodeWithSignature('initialize(address,address)', address(accessManager), newGate) + abi.encodeCall(ISpokeInstance.initialize, (address(accessManager))) ) ) ); From 38ac1f2b87d14534d4766f86d64f8fbe4d9b4cc1 Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:29:09 +0300 Subject: [PATCH 08/10] refactor: override onlyPositionManager modifier instead of internal hook Marking the modifier virtual is the only change to Spoke, which keeps SpokeInstance deployed bytecode byte-identical to main. The gate check lives in an internal function so the override is not inlined per entry point (24,527 bytes at 750 runs). --- snapshots/PermissionedSpoke.Operations.json | 30 +++++++++---------- src/spoke/Spoke.sol | 17 +++-------- .../instances/PermissionedSpokeInstance.sol | 17 +++++++---- 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/snapshots/PermissionedSpoke.Operations.json b/snapshots/PermissionedSpoke.Operations.json index 7f5bed503..07b7537b4 100644 --- a/snapshots/PermissionedSpoke.Operations.json +++ b/snapshots/PermissionedSpoke.Operations.json @@ -1,17 +1,17 @@ { - "borrow: borrow-allowlist policy": "303427", - "borrow: global-manager policy": "297890", - "borrow: position-manager policy": "297766", - "repay: partial, borrow-allowlist policy": "150680", - "repay: partial, global-manager policy": "150537", - "repay: partial, position-manager policy": "150413", - "supply: borrow-allowlist policy": "132289", - "supply: global-manager policy": "132146", - "supply: position-manager policy": "132022", - "usingAsCollateral: enable, borrow-allowlist policy": "64562", - "usingAsCollateral: enable, global-manager policy": "64419", - "usingAsCollateral: enable, position-manager policy": "64295", - "withdraw: partial, borrow-allowlist policy": "185421", - "withdraw: partial, global-manager policy": "185278", - "withdraw: partial, position-manager policy": "185154" + "borrow: borrow-allowlist policy": "303410", + "borrow: global-manager policy": "297873", + "borrow: position-manager policy": "297749", + "repay: partial, borrow-allowlist policy": "150630", + "repay: partial, global-manager policy": "150487", + "repay: partial, position-manager policy": "150363", + "supply: borrow-allowlist policy": "132272", + "supply: global-manager policy": "132129", + "supply: position-manager policy": "132005", + "usingAsCollateral: enable, borrow-allowlist policy": "64545", + "usingAsCollateral: enable, global-manager policy": "64402", + "usingAsCollateral: enable, position-manager policy": "64278", + "withdraw: partial, borrow-allowlist policy": "185404", + "withdraw: partial, global-manager policy": "185261", + "withdraw: partial, position-manager policy": "185137" } \ No newline at end of file diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index 959206ac5..a94ced311 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -86,9 +86,10 @@ abstract contract Spoke is uint256 internal constant DUST_LIQUIDATION_THRESHOLD = LiquidationLogic.DUST_LIQUIDATION_THRESHOLD; - /// @notice Modifier that checks if the caller is authorized to act on the position of `onBehalfOf`. - modifier onlyPositionManager(address onBehalfOf) { - require(_isAuthorizedPositionManagerCall(msg.sender, onBehalfOf, msg.data), Unauthorized()); + /// @notice Modifier that checks if the caller is an approved positionManager for `onBehalfOf`. + /// @dev Virtual to allow instances to replace the authorization scheme of position actions. + modifier onlyPositionManager(address onBehalfOf) virtual { + require(_isPositionManager({user: onBehalfOf, manager: msg.sender}), Unauthorized()); _; } @@ -912,16 +913,6 @@ abstract contract Spoke is return config.active && config.approval[user]; } - /// @notice Returns whether `caller` is authorized to act on the position of `user` for the given calldata. - /// @dev The default implementation requires the caller to be `user` or an approved position manager for `user`. - function _isAuthorizedPositionManagerCall( - address caller, - address user, - bytes calldata - ) internal view virtual returns (bool) { - return _isPositionManager({user: user, manager: caller}); - } - function _validateReserveConfig(ReserveConfig calldata config) internal pure { require(config.collateralRisk <= MAX_ALLOWED_COLLATERAL_RISK, InvalidCollateralRisk()); } diff --git a/src/spoke/instances/PermissionedSpokeInstance.sol b/src/spoke/instances/PermissionedSpokeInstance.sol index adab2a7f0..1e7fae3fa 100644 --- a/src/spoke/instances/PermissionedSpokeInstance.sol +++ b/src/spoke/instances/PermissionedSpokeInstance.sol @@ -28,11 +28,16 @@ contract PermissionedSpokeInstance is SpokeInstanceBase { /// @dev The gate fully decides whether the call is allowed, based on the caller, the position /// owner and the calldata. It can preserve the default authorization by calling back /// `isPositionManager`. - function _isAuthorizedPositionManagerCall( - address caller, - address user, - bytes calldata data - ) internal view override returns (bool) { - return ISpokeGate(GATE).isCallAllowed({caller: caller, onBehalfOf: user, data: data}); + modifier onlyPositionManager(address onBehalfOf) override { + _checkCallAllowed(onBehalfOf); + _; + } + + /// @dev Reverts if the gate disallows the current call on the position of `onBehalfOf`. + function _checkCallAllowed(address onBehalfOf) internal view { + require( + ISpokeGate(GATE).isCallAllowed({caller: msg.sender, onBehalfOf: onBehalfOf, data: msg.data}), + Unauthorized() + ); } } From 0ea8e3021951273c9649f576f3dd8863185bf9f9 Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:26:25 +0300 Subject: [PATCH 09/10] refactor: drop SpokeInstanceBase in favor of a PermissionedSpoke core SpokeInstance is back to exactly the main version. The gate authorization lives in an abstract PermissionedSpoke core, and the instance inherits SpokeInstance and PermissionedSpoke (diamond over Spoke), re-declaring the modifier override as required by the compiler. The modifier passes msg.sender and msg.data down instead of reading them in the internal check. SpokeInstance deployed bytecode stays byte-identical to main; PermissionedSpokeInstance is 24,535 bytes at 750 runs. --- snapshots/PermissionedSpoke.Operations.json | 30 +++++++------- src/spoke/PermissionedSpoke.sol | 41 +++++++++++++++++++ .../instances/PermissionedSpokeInstance.sol | 38 +++++------------ src/spoke/instances/SpokeInstance.sol | 28 ++++++++++--- src/spoke/instances/SpokeInstanceBase.sol | 33 --------------- 5 files changed, 89 insertions(+), 81 deletions(-) create mode 100644 src/spoke/PermissionedSpoke.sol delete mode 100644 src/spoke/instances/SpokeInstanceBase.sol diff --git a/snapshots/PermissionedSpoke.Operations.json b/snapshots/PermissionedSpoke.Operations.json index 07b7537b4..35dad200e 100644 --- a/snapshots/PermissionedSpoke.Operations.json +++ b/snapshots/PermissionedSpoke.Operations.json @@ -1,17 +1,17 @@ { - "borrow: borrow-allowlist policy": "303410", - "borrow: global-manager policy": "297873", - "borrow: position-manager policy": "297749", - "repay: partial, borrow-allowlist policy": "150630", - "repay: partial, global-manager policy": "150487", - "repay: partial, position-manager policy": "150363", - "supply: borrow-allowlist policy": "132272", - "supply: global-manager policy": "132129", - "supply: position-manager policy": "132005", - "usingAsCollateral: enable, borrow-allowlist policy": "64545", - "usingAsCollateral: enable, global-manager policy": "64402", - "usingAsCollateral: enable, position-manager policy": "64278", - "withdraw: partial, borrow-allowlist policy": "185404", - "withdraw: partial, global-manager policy": "185261", - "withdraw: partial, position-manager policy": "185137" + "borrow: borrow-allowlist policy": "303440", + "borrow: global-manager policy": "297903", + "borrow: position-manager policy": "297779", + "repay: partial, borrow-allowlist policy": "150693", + "repay: partial, global-manager policy": "150550", + "repay: partial, position-manager policy": "150426", + "supply: borrow-allowlist policy": "132302", + "supply: global-manager policy": "132159", + "supply: position-manager policy": "132035", + "usingAsCollateral: enable, borrow-allowlist policy": "64575", + "usingAsCollateral: enable, global-manager policy": "64432", + "usingAsCollateral: enable, position-manager policy": "64308", + "withdraw: partial, borrow-allowlist policy": "185434", + "withdraw: partial, global-manager policy": "185291", + "withdraw: partial, position-manager policy": "185167" } \ No newline at end of file diff --git a/src/spoke/PermissionedSpoke.sol b/src/spoke/PermissionedSpoke.sol new file mode 100644 index 000000000..cbf78c0a1 --- /dev/null +++ b/src/spoke/PermissionedSpoke.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity 0.8.28; + +import {Spoke} from 'src/spoke/Spoke.sol'; +import {ISpokeGate} from 'src/spoke/interfaces/ISpokeGate.sol'; + +/// @title PermissionedSpoke +/// @author Aave Labs +/// @notice Spoke where a gate replaces the default position manager authorization on position +/// actions. +abstract contract PermissionedSpoke is Spoke { + /// @notice The gate deciding whether position actions are allowed. + address public immutable GATE; + + /// @dev The gate fully decides whether the call is allowed, based on the caller, the position + /// owner and the calldata. It can preserve the default authorization by calling back + /// `isPositionManager`. + modifier onlyPositionManager(address onBehalfOf) virtual override { + _checkCallAllowed(msg.sender, onBehalfOf, msg.data); + _; + } + + /// @dev Constructor. + /// @param gate_ The address of the gate. + constructor(address gate_) { + require(gate_ != address(0), InvalidAddress()); + GATE = gate_; + } + + /// @dev Reverts if the gate disallows `caller` performing the call `data` on the position of `onBehalfOf`. + function _checkCallAllowed( + address caller, + address onBehalfOf, + bytes calldata data + ) internal view { + require( + ISpokeGate(GATE).isCallAllowed({caller: caller, onBehalfOf: onBehalfOf, data: data}), + Unauthorized() + ); + } +} diff --git a/src/spoke/instances/PermissionedSpokeInstance.sol b/src/spoke/instances/PermissionedSpokeInstance.sol index 1e7fae3fa..0d06c0468 100644 --- a/src/spoke/instances/PermissionedSpokeInstance.sol +++ b/src/spoke/instances/PermissionedSpokeInstance.sol @@ -1,16 +1,19 @@ // SPDX-License-Identifier: LicenseRef-BUSL pragma solidity 0.8.28; -import {SpokeInstanceBase} from 'src/spoke/instances/SpokeInstanceBase.sol'; -import {ISpokeGate} from 'src/spoke/interfaces/ISpokeGate.sol'; +import {Spoke} from 'src/spoke/Spoke.sol'; +import {PermissionedSpoke} from 'src/spoke/PermissionedSpoke.sol'; +import {SpokeInstance} from 'src/spoke/instances/SpokeInstance.sol'; /// @title PermissionedSpokeInstance /// @author Aave Labs -/// @notice Spoke implementation where a gate replaces the default position manager authorization -/// on position actions. -contract PermissionedSpokeInstance is SpokeInstanceBase { - /// @notice The gate deciding whether position actions are allowed. - address public immutable GATE; +/// @notice Implementation contract for the PermissionedSpoke. +contract PermissionedSpokeInstance is SpokeInstance, PermissionedSpoke { + /// @dev Applies the gate authorization of the PermissionedSpoke. + modifier onlyPositionManager(address onBehalfOf) override(Spoke, PermissionedSpoke) { + _checkCallAllowed(msg.sender, onBehalfOf, msg.data); + _; + } /// @dev Constructor. /// @param oracle_ The address of the oracle. @@ -20,24 +23,5 @@ contract PermissionedSpokeInstance is SpokeInstanceBase { address oracle_, uint16 maxUserReservesLimit_, address gate_ - ) SpokeInstanceBase(oracle_, maxUserReservesLimit_) { - require(gate_ != address(0), InvalidAddress()); - GATE = gate_; - } - - /// @dev The gate fully decides whether the call is allowed, based on the caller, the position - /// owner and the calldata. It can preserve the default authorization by calling back - /// `isPositionManager`. - modifier onlyPositionManager(address onBehalfOf) override { - _checkCallAllowed(onBehalfOf); - _; - } - - /// @dev Reverts if the gate disallows the current call on the position of `onBehalfOf`. - function _checkCallAllowed(address onBehalfOf) internal view { - require( - ISpokeGate(GATE).isCallAllowed({caller: msg.sender, onBehalfOf: onBehalfOf, data: msg.data}), - Unauthorized() - ); - } + ) SpokeInstance(oracle_, maxUserReservesLimit_) PermissionedSpoke(gate_) {} } diff --git a/src/spoke/instances/SpokeInstance.sol b/src/spoke/instances/SpokeInstance.sol index 7da310634..2b8d06252 100644 --- a/src/spoke/instances/SpokeInstance.sol +++ b/src/spoke/instances/SpokeInstance.sol @@ -1,17 +1,33 @@ // SPDX-License-Identifier: LicenseRef-BUSL pragma solidity 0.8.28; -import {SpokeInstanceBase} from 'src/spoke/instances/SpokeInstanceBase.sol'; +import {Spoke} from 'src/spoke/Spoke.sol'; /// @title SpokeInstance /// @author Aave Labs /// @notice Implementation contract for the Spoke. -contract SpokeInstance is SpokeInstanceBase { +contract SpokeInstance is Spoke { + uint64 public constant SPOKE_REVISION = 1; + /// @dev Constructor. + /// @dev During upgrade, must ensure that the new oracle is supporting existing assets on the Spoke and the replaced oracle. /// @param oracle_ The address of the oracle. /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. - constructor( - address oracle_, - uint16 maxUserReservesLimit_ - ) SpokeInstanceBase(oracle_, maxUserReservesLimit_) {} + constructor(address oracle_, uint16 maxUserReservesLimit_) Spoke(oracle_, maxUserReservesLimit_) { + _disableInitializers(); + } + + /// @notice Initializer. + /// @dev The authority contract must implement the `AccessManaged` interface for access control. + /// @param authority The address of the authority contract which manages permissions. + function initialize(address authority) external override reinitializer(SPOKE_REVISION) { + emit SetSpokeImmutables(ORACLE, MAX_USER_RESERVES_LIMIT); + + require(authority != address(0), InvalidAddress()); + __AccessManaged_init(authority); + if (_liquidationConfig.targetHealthFactor == 0) { + _liquidationConfig.targetHealthFactor = HEALTH_FACTOR_LIQUIDATION_THRESHOLD; + emit UpdateLiquidationConfig(_liquidationConfig); + } + } } diff --git a/src/spoke/instances/SpokeInstanceBase.sol b/src/spoke/instances/SpokeInstanceBase.sol deleted file mode 100644 index 1e213d4e9..000000000 --- a/src/spoke/instances/SpokeInstanceBase.sol +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-BUSL -pragma solidity 0.8.28; - -import {Spoke} from 'src/spoke/Spoke.sol'; - -/// @title SpokeInstanceBase -/// @author Aave Labs -/// @notice Base implementation contract for Spoke instances. -abstract contract SpokeInstanceBase is Spoke { - uint64 public constant SPOKE_REVISION = 1; - - /// @dev Constructor. - /// @dev During upgrade, must ensure that the new oracle is supporting existing assets on the Spoke and the replaced oracle. - /// @param oracle_ The address of the oracle. - /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. - constructor(address oracle_, uint16 maxUserReservesLimit_) Spoke(oracle_, maxUserReservesLimit_) { - _disableInitializers(); - } - - /// @notice Initializer. - /// @dev The authority contract must implement the `AccessManaged` interface for access control. - /// @param authority The address of the authority contract which manages permissions. - function initialize(address authority) external virtual override reinitializer(SPOKE_REVISION) { - emit SetSpokeImmutables(ORACLE, MAX_USER_RESERVES_LIMIT); - - require(authority != address(0), InvalidAddress()); - __AccessManaged_init(authority); - if (_liquidationConfig.targetHealthFactor == 0) { - _liquidationConfig.targetHealthFactor = HEALTH_FACTOR_LIQUIDATION_THRESHOLD; - emit UpdateLiquidationConfig(_liquidationConfig); - } - } -} From 1084c2f95e71f666e108c7df2935db1cfd65cd9e Mon Sep 17 00:00:00 2001 From: AlbertoCentonze <11707683+AlbertoCentonze@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:10:24 +0100 Subject: [PATCH 10/10] refactor: authorize permissioned spoke via position manager --- src/spoke/PermissionedSpoke.sol | 38 ++++++++++--------- src/spoke/Spoke.sol | 13 +++++-- .../instances/PermissionedSpokeInstance.sol | 22 ++++++++--- .../spoke/misc/PermissionedSpoke.t.sol | 36 ++++++++++++++++++ 4 files changed, 81 insertions(+), 28 deletions(-) diff --git a/src/spoke/PermissionedSpoke.sol b/src/spoke/PermissionedSpoke.sol index cbf78c0a1..7fe8bb6b3 100644 --- a/src/spoke/PermissionedSpoke.sol +++ b/src/spoke/PermissionedSpoke.sol @@ -12,14 +12,6 @@ abstract contract PermissionedSpoke is Spoke { /// @notice The gate deciding whether position actions are allowed. address public immutable GATE; - /// @dev The gate fully decides whether the call is allowed, based on the caller, the position - /// owner and the calldata. It can preserve the default authorization by calling back - /// `isPositionManager`. - modifier onlyPositionManager(address onBehalfOf) virtual override { - _checkCallAllowed(msg.sender, onBehalfOf, msg.data); - _; - } - /// @dev Constructor. /// @param gate_ The address of the gate. constructor(address gate_) { @@ -27,15 +19,25 @@ abstract contract PermissionedSpoke is Spoke { GATE = gate_; } - /// @dev Reverts if the gate disallows `caller` performing the call `data` on the position of `onBehalfOf`. - function _checkCallAllowed( - address caller, - address onBehalfOf, - bytes calldata data - ) internal view { - require( - ISpokeGate(GATE).isCallAllowed({caller: caller, onBehalfOf: onBehalfOf, data: data}), - Unauthorized() - ); + /// @dev The gate fully decides whether the current call is allowed. The external + /// `isPositionManager` function preserves the underlying position manager semantics. + function _isPositionManager( + address user, + address manager + ) internal view virtual override returns (bool) { + return + ISpokeGate(GATE).isCallAllowed({ + caller: manager, + onBehalfOf: user, + data: msg.data + }); + } + + /// @dev Returns the underlying position manager relationship without consulting the gate. + function isPositionManager( + address user, + address positionManager + ) external view virtual override returns (bool) { + return Spoke._isPositionManager(user, positionManager); } } diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index a94ced311..41af01a62 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -87,8 +87,7 @@ abstract contract Spoke is LiquidationLogic.DUST_LIQUIDATION_THRESHOLD; /// @notice Modifier that checks if the caller is an approved positionManager for `onBehalfOf`. - /// @dev Virtual to allow instances to replace the authorization scheme of position actions. - modifier onlyPositionManager(address onBehalfOf) virtual { + modifier onlyPositionManager(address onBehalfOf) { require(_isPositionManager({user: onBehalfOf, manager: msg.sender}), Unauthorized()); _; } @@ -660,7 +659,10 @@ abstract contract Spoke is } /// @inheritdoc ISpoke - function isPositionManager(address user, address positionManager) external view returns (bool) { + function isPositionManager( + address user, + address positionManager + ) external view virtual returns (bool) { return _isPositionManager(user, positionManager); } @@ -907,7 +909,10 @@ abstract contract Spoke is } /// @notice Returns whether `manager` is active and approved positionManager for `user`. - function _isPositionManager(address user, address manager) internal view returns (bool) { + function _isPositionManager( + address user, + address manager + ) internal view virtual returns (bool) { if (user == manager) return true; PositionManagerConfig storage config = _positionManager[manager]; return config.active && config.approval[user]; diff --git a/src/spoke/instances/PermissionedSpokeInstance.sol b/src/spoke/instances/PermissionedSpokeInstance.sol index 0d06c0468..39b725050 100644 --- a/src/spoke/instances/PermissionedSpokeInstance.sol +++ b/src/spoke/instances/PermissionedSpokeInstance.sol @@ -9,12 +9,6 @@ import {SpokeInstance} from 'src/spoke/instances/SpokeInstance.sol'; /// @author Aave Labs /// @notice Implementation contract for the PermissionedSpoke. contract PermissionedSpokeInstance is SpokeInstance, PermissionedSpoke { - /// @dev Applies the gate authorization of the PermissionedSpoke. - modifier onlyPositionManager(address onBehalfOf) override(Spoke, PermissionedSpoke) { - _checkCallAllowed(msg.sender, onBehalfOf, msg.data); - _; - } - /// @dev Constructor. /// @param oracle_ The address of the oracle. /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. @@ -24,4 +18,20 @@ contract PermissionedSpokeInstance is SpokeInstance, PermissionedSpoke { uint16 maxUserReservesLimit_, address gate_ ) SpokeInstance(oracle_, maxUserReservesLimit_) PermissionedSpoke(gate_) {} + + /// @dev Resolves the diamond inheritance to the gate authorization of the PermissionedSpoke. + function _isPositionManager( + address user, + address manager + ) internal view override(Spoke, PermissionedSpoke) returns (bool) { + return PermissionedSpoke._isPositionManager(user, manager); + } + + /// @dev Resolves the diamond inheritance while preserving position manager query semantics. + function isPositionManager( + address user, + address positionManager + ) external view override(Spoke, PermissionedSpoke) returns (bool) { + return Spoke._isPositionManager(user, positionManager); + } } diff --git a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol index 8d4bdc919..bf5dc085f 100644 --- a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol +++ b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol @@ -98,6 +98,12 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { assertEq(spoke.getUserSuppliedAssets(usdxReserveId, alice), 50e6); } + function test_isPositionManager_preservesUnderlyingSemantics() public { + gate.setGlobalManager(RWA_MANAGER, true); + + assertFalse(spoke.isPositionManager(alice, RWA_MANAGER)); + } + /// @dev Horizon-style forced transfer: the RWA manager moves alice's position to bob by /// withdrawing on her behalf and re-supplying to bob, without any user approval. function test_forcedTransfer_viaGlobalManager() public { @@ -206,4 +212,34 @@ contract PermissionedSpokeTest is PermissionedSpokeBase { vm.prank(alice); spoke.multicall(calls); } + + function test_updateUserRiskPremium_gated() public { + _supplyCollateralAndBorrow(alice, 100e6); + + gate.setGated(ISpoke.updateUserRiskPremium.selector, true); + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); + vm.prank(alice); + spoke.updateUserRiskPremium(alice); + + gate.setGlobalManager(RWA_MANAGER, true); + vm.prank(RWA_MANAGER); + spoke.updateUserRiskPremium(alice); + } + + function test_updateUserDynamicConfig_gated() public { + _supplyCollateralAndBorrow(alice, 100e6); + + gate.setGated(ISpoke.updateUserDynamicConfig.selector, true); + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); + vm.prank(alice); + spoke.updateUserDynamicConfig(alice); + + gate.setGlobalManager(RWA_MANAGER, true); + vm.prank(RWA_MANAGER); + spoke.updateUserDynamicConfig(alice); + } }