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/snapshots/PermissionedSpoke.Operations.json b/snapshots/PermissionedSpoke.Operations.json new file mode 100644 index 000000000..35dad200e --- /dev/null +++ b/snapshots/PermissionedSpoke.Operations.json @@ -0,0 +1,17 @@ +{ + "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..7fe8bb6b3 --- /dev/null +++ b/src/spoke/PermissionedSpoke.sol @@ -0,0 +1,43 @@ +// 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 Constructor. + /// @param gate_ The address of the gate. + constructor(address gate_) { + require(gate_ != address(0), InvalidAddress()); + GATE = gate_; + } + + /// @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 9dd7beab9..41af01a62 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -659,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); } @@ -906,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 new file mode 100644 index 000000000..39b725050 --- /dev/null +++ b/src/spoke/instances/PermissionedSpokeInstance.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity 0.8.28; + +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 Implementation contract for the PermissionedSpoke. +contract PermissionedSpokeInstance is SpokeInstance, PermissionedSpoke { + /// @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_, + 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/src/spoke/interfaces/ISpokeGate.sol b/src/spoke/interfaces/ISpokeGate.sol new file mode 100644 index 000000000..075c68f7a --- /dev/null +++ b/src/spoke/interfaces/ISpokeGate.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +/// @title ISpokeGate +/// @author Aave Labs +/// @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 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. + /// @return True if the call is allowed. + function isCallAllowed( + address caller, + address onBehalfOf, + bytes calldata data + ) external view returns (bool); +} diff --git a/tests/contracts/spoke/misc/PermissionedSpoke.t.sol b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol new file mode 100644 index 000000000..bf5dc085f --- /dev/null +++ b/tests/contracts/spoke/misc/PermissionedSpoke.t.sol @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/setup/PermissionedSpokeBase.sol'; + +contract PermissionedSpokeTest is PermissionedSpokeBase { + function test_constructor() public { + assertEq(PermissionedSpokeInstance(address(spoke)).GATE(), address(gate)); + + vm.expectRevert(ISpoke.InvalidAddress.selector); + new PermissionedSpokeInstance({ + oracle_: address(oracle1), + maxUserReservesLimit_: DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT, + gate_: address(0) + }); + } + + 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); + + // 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 + }); + + gate.setEligible(alice, true); + SpokeActions.borrow({ + spoke: spoke, + reserveId: usdxReserveId, + caller: alice, + amount: 100e6, + onBehalfOf: alice + }); + + assertEq(spoke.getUserTotalDebt(usdxReserveId, alice), 100e6); + } + + function test_approvedPositionManagersPreservedViaCallback() public { + 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); + } + + 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 { + gate.setGlobalManager(RWA_MANAGER, true); + + 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 { + gate.setGlobalManager(RWA_MANAGER, true); + + _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 { + 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 + }); + + // 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); + + 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 { + gate.setGated(ISpoke.borrow.selector, true); + + 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 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); + } +} diff --git a/tests/gas/PermissionedSpoke.Operations.gas.t.sol b/tests/gas/PermissionedSpoke.Operations.gas.t.sol new file mode 100644 index 000000000..77c0b296b --- /dev/null +++ b/tests/gas/PermissionedSpoke.Operations.gas.t.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +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'; + + /// @dev Same authorization as the standard spoke, routed through the gate. + function test_operations_positionManagerPolicy() public { + 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 { + 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); + ISpoke target = _deployPermissionedSpoke(address(new BorrowAllowlistPolicyGate(allowlist))); + _snapshotOperations(target, 'borrow-allowlist policy'); + } + + 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); + target.supply(usdxReserveId, 1000e6, alice); + vm.snapshotGasLastCall(NAMESPACE, string.concat('supply: ', label)); + + target.setUsingAsCollateral(usdxReserveId, true, alice); + vm.snapshotGasLastCall(NAMESPACE, string.concat('usingAsCollateral: enable, ', label)); + + target.borrow(usdxReserveId, 100e6, alice); + vm.snapshotGasLastCall(NAMESPACE, string.concat('borrow: ', label)); + + skip(100); + + target.repay(usdxReserveId, 50e6, alice); + vm.snapshotGasLastCall(NAMESPACE, string.concat('repay: partial, ', label)); + + 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 new file mode 100644 index 000000000..062a01ce4 --- /dev/null +++ b/tests/helpers/mocks/MockSpokeGate.sol @@ -0,0 +1,37 @@ +// 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'; + +/// @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 calling Spoke's default position manager authorization +contract MockSpokeGate is ISpokeGate { + mapping(address caller => bool) public globalManager; + mapping(bytes4 selector => bool) public gated; + mapping(address user => bool) public eligible; + + 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 ISpoke(msg.sender).isPositionManager(onBehalfOf, caller); + } +} diff --git a/tests/helpers/mocks/PolicyGates.sol b/tests/helpers/mocks/PolicyGates.sol new file mode 100644 index 000000000..f488b27ce --- /dev/null +++ b/tests/helpers/mocks/PolicyGates.sol @@ -0,0 +1,64 @@ +// 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 { + function isCallAllowed( + address caller, + address onBehalfOf, + bytes calldata + ) external view returns (bool) { + 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 { + address public immutable GLOBAL_MANAGER; + + constructor(address globalManager) { + GLOBAL_MANAGER = globalManager; + } + + function isCallAllowed( + address caller, + address onBehalfOf, + bytes calldata + ) external view returns (bool) { + if (caller == GLOBAL_MANAGER) return true; + return ISpoke(msg.sender).isPositionManager(onBehalfOf, caller); + } +} + +/// @dev Gate restricting borrowing to allowlisted position owners. +contract BorrowAllowlistPolicyGate is ISpokeGate { + IAllowlist public immutable ALLOWLIST; + + constructor(IAllowlist allowlist) { + 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 ISpoke(msg.sender).isPositionManager(onBehalfOf, caller); + } +} + +contract MockAllowlist is IAllowlist { + mapping(address account => bool) public isAllowed; + + function setAllowed(address account, bool value) external { + isAllowed[account] = value; + } +} diff --git a/tests/setup/PermissionedSpokeBase.sol b/tests/setup/PermissionedSpokeBase.sol new file mode 100644 index 000000000..50746ff31 --- /dev/null +++ b/tests/setup/PermissionedSpokeBase.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +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 {MockSpokeGate} from 'tests/helpers/mocks/MockSpokeGate.sol'; + +/// @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; + + function setUp() public virtual override { + super.setUp(); + + 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, + gate_: newGate + }); + newSpoke = ISpoke( + address( + new TransparentUpgradeableProxy( + address(implementation), + PROXY_ADMIN_OWNER, + abi.encodeCall(ISpokeInstance.initialize, (address(accessManager))) + ) + ) + ); + oracle.setSpoke(address(newSpoke)); + setUpRoles(hub1, newSpoke, accessManager); + + 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 = newSpoke.addReserve( + address(hub1), + wethAssetId, + _deployMockPriceFeed(newSpoke, 2000e8), + _getDefaultReserveConfig(15_00), + ISpoke.DynamicReserveConfig({ + collateralFactor: 80_00, + maxLiquidationBonus: 105_00, + liquidationFee: 10_00 + }) + ); + usdxReserveId = newSpoke.addReserve( + address(hub1), + usdxAssetId, + _deployMockPriceFeed(newSpoke, 1e8), + _getDefaultReserveConfig(20_00), + ISpoke.DynamicReserveConfig({ + collateralFactor: 78_00, + maxLiquidationBonus: 101_00, + liquidationFee: 12_00 + }) + ); + 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(newSpoke), type(uint256).max); + tokenList.usdx.approve(address(newSpoke), 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 + }); + } +}