diff --git a/.github/codecov.yaml b/.github/codecov.yaml index 5e4016b..1213b64 100644 --- a/.github/codecov.yaml +++ b/.github/codecov.yaml @@ -9,3 +9,6 @@ coverage: status: project: off patch: off +ignore: # exclude these folders from the report + - "script" + - "test" diff --git a/script/GnosisPayInfraDeployment.s.sol b/script/GnosisPayInfraDeployment.s.sol index 16b38a2..5a6c58b 100644 --- a/script/GnosisPayInfraDeployment.s.sol +++ b/script/GnosisPayInfraDeployment.s.sol @@ -8,13 +8,15 @@ import {Delay} from "@delay-module/Delay.sol"; import {Roles} from "@roles-module/Roles.sol"; import {Bouncer} from "@gnosispay-kit/Bouncer.sol"; +import {RoboSaverVirtualModuleFactory} from "../src/RoboSaverVirtualModuleFactory.sol"; import {RoboSaverVirtualModule} from "../src/RoboSaverVirtualModule.sol"; /// @notice Deploys a setup mirroring GnosisPay infrastructure components and RoboSaverVirtualModule, in the following order: /// 1. {RolesModule} /// 2. {DelayModule} /// 3. {Bouncer} -/// 4. {RoboSaverVirtualModule} +/// 4. {RoboSaverVirtualModuleFactory} +/// 5. {RoboSaverVirtualModule} contract GnosisPayInfraDeployment is Script { // safe target address constant GNOSIS_SAFE = 0xa4A4a4879dCD3289312884e9eC74Ed37f9a92a55; @@ -43,7 +45,8 @@ contract GnosisPayInfraDeployment is Script { Bouncer bouncerContract; - // robosaver module + // robosaver module & factory + RoboSaverVirtualModuleFactory roboModuleFactory; RoboSaverVirtualModule roboModule; function run() public { @@ -61,10 +64,15 @@ contract GnosisPayInfraDeployment is Script { // 3. {Bouncer} bouncerContract = new Bouncer(GNOSIS_SAFE, address(rolesModule), SET_ALLOWANCE_SELECTOR); - // 4. {RoboSaverVirtualModule} - roboModule = new RoboSaverVirtualModule(address(delayModule), address(rolesModule), 50e18, 200); + // 4. {RoboSaverVirtualModuleFactory} + roboModuleFactory = new RoboSaverVirtualModuleFactory(); - // 5. {Allowance config} + // 5. {RoboSaverVirtualModule} + roboModule = new RoboSaverVirtualModule( + address(roboModuleFactory), address(delayModule), address(rolesModule), 50e18, 200 + ); + + // 6. {Allowance config} rolesModule.setAllowance( SET_ALLOWANCE_KEY, MIN_EURE_ALLOWANCE, diff --git a/src/RoboSaverVirtualModule.sol b/src/RoboSaverVirtualModule.sol index 2cfd8b6..f5cab6f 100644 --- a/src/RoboSaverVirtualModule.sol +++ b/src/RoboSaverVirtualModule.sol @@ -13,39 +13,14 @@ import "@balancer-v2/interfaces/contracts/pool-stable/StablePoolUserData.sol"; import {KeeperCompatibleInterface} from "@chainlink/automation/interfaces/KeeperCompatibleInterface.sol"; +import {VirtualModule} from "./types/DataTypes.sol"; + /// @title RoboSaver: turn your Gnosis Pay card into an automated savings account! /// @author onchainification.xyz /// @notice Deposit and withdraw $EURe from your Gnosis Pay card to a liquidity pool contract RoboSaverVirtualModule is KeeperCompatibleInterface // 1 inherited component { - /*////////////////////////////////////////////////////////////////////////// - DATA TYPES - //////////////////////////////////////////////////////////////////////////*/ - - /// @notice Enum representing the different types of pool actions - /// @custom:value0 WITHDRAW Withdraw $EURe from the pool to the card - /// @custom:value1 DEPOSIT Deposit $EURe from the card into the pool - /// @custom:value2 CLOSE Close the pool position by withdrawing all to $EURe - /// @custom:value3 EXEC_QUEUE_POOL_ACTION Execute the queued pool action - enum PoolAction { - WITHDRAW, - DEPOSIT, - CLOSE, - EXEC_QUEUE_POOL_ACTION - } - - /// @notice Struct representing the data needed to execute a queued transaction - /// @dev Nonce allows us to determine if the transaction queued originated from this virtual module - /// @param nonce The nonce of the queued transaction - /// @param target The address of the target contract - /// @param payload The payload of the transaction to be executed on the target contract - struct QueuedTx { - uint256 nonce; - address target; - bytes payload; - } - /*////////////////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////////////////*/ @@ -68,10 +43,11 @@ contract RoboSaverVirtualModule is IComposableStablePool immutable BPT_STEUR_EURE; + address public immutable FACTORY; + /*////////////////////////////////////////////////////////////////////////// PUBLIC STORAGE //////////////////////////////////////////////////////////////////////////*/ - IDelayModifier public delayModule; IRolesModifier public rolesModule; @@ -80,7 +56,7 @@ contract RoboSaverVirtualModule is uint16 public slippage; /// @dev Keeps track of the transaction queued up by the virtual module and allows internally to call `executeNextTx` - QueuedTx public queuedTx; + VirtualModule.QueuedTx public queuedTx; /*////////////////////////////////////////////////////////////////////////// PRIVATE STORAGE @@ -149,6 +125,7 @@ contract RoboSaverVirtualModule is error NotKeeper(address agent); error NotAdmin(address agent); + error NeitherAdminNorFactory(address agent); error ZeroAddressValue(); error ZeroUintValue(); @@ -174,15 +151,24 @@ contract RoboSaverVirtualModule is _; } + /// @notice Enforce that the function is called by the admin or the factory only + modifier onlyAdminOrFactory() { + if (msg.sender != CARD && msg.sender != FACTORY) revert NeitherAdminNorFactory(msg.sender); + _; + } + /*////////////////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////////////////*/ - constructor(address _delayModule, address _rolesModule, uint256 _buffer, uint16 _slippage) { + constructor(address _factory, address _delayModule, address _rolesModule, uint256 _buffer, uint16 _slippage) { + FACTORY = _factory; + delayModule = IDelayModifier(_delayModule); rolesModule = IRolesModifier(_rolesModule); - buffer = _buffer; - slippage = _slippage; + + _setBuffer(_buffer); + _setSlippage(_slippage); CARD = delayModule.avatar(); @@ -223,7 +209,7 @@ contract RoboSaverVirtualModule is /// @notice Assigns a new keeper address /// @param _keeper The address of the new keeper - function setKeeper(address _keeper) external onlyAdmin { + function setKeeper(address _keeper) external onlyAdminOrFactory { if (_keeper == address(0)) revert ZeroAddressValue(); address oldKeeper = keeper; @@ -235,23 +221,13 @@ contract RoboSaverVirtualModule is /// @notice Assigns a new value for the buffer responsible for deciding when there is a surplus /// @param _buffer The value of the new buffer function setBuffer(uint256 _buffer) external onlyAdmin { - if (_buffer == 0) revert ZeroUintValue(); - - uint256 oldBuffer = buffer; - buffer = _buffer; - - emit SetBuffer(msg.sender, oldBuffer, buffer); + _setBuffer(_buffer); } /// @notice Adjust the maximum slippage the user is comfortable with /// @param _slippage The value of the new slippage in bps (so 10_000 is 100%) function setSlippage(uint16 _slippage) external onlyAdmin { - if (_slippage >= MAX_BPS) revert TooHighBps(); - - uint16 oldSlippage = slippage; - slippage = _slippage; - - emit SetSlippage(msg.sender, oldSlippage, slippage); + _setSlippage(_slippage); } /// @notice Check if there is a surplus or deficit of $EURe on the card @@ -275,7 +251,7 @@ contract RoboSaverVirtualModule is if (queuedTx.nonce != 0) { /// @notice check if the transaction is still in cooldown or ready to exec if (_isInCoolDown(queuedTx.nonce)) return (false, bytes("Internal transaction in cooldown status")); - return (true, abi.encode(PoolAction.EXEC_QUEUE_POOL_ACTION, 0)); + return (true, abi.encode(VirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION, 0)); } uint256 balance = EURE.balanceOf(CARD); @@ -290,14 +266,14 @@ contract RoboSaverVirtualModule is uint256 deficit = dailyAllowance - balance + buffer; uint256 withdrawableEure = bptBalance * BPT_STEUR_EURE.getRate() * (MAX_BPS - slippage) / 1e18 / MAX_BPS; if (withdrawableEure < deficit) { - return (true, abi.encode(PoolAction.CLOSE, withdrawableEure)); + return (true, abi.encode(VirtualModule.PoolAction.CLOSE, withdrawableEure)); } else { - return (true, abi.encode(PoolAction.WITHDRAW, deficit)); + return (true, abi.encode(VirtualModule.PoolAction.WITHDRAW, deficit)); } } else if (balance > dailyAllowance + buffer) { /// @notice there is a surplus; we need to deposit into the pool uint256 surplus = balance - (dailyAllowance + buffer); - return (true, abi.encode(PoolAction.DEPOSIT, surplus)); + return (true, abi.encode(VirtualModule.PoolAction.DEPOSIT, surplus)); } /// @notice neither deficit nor surplus; no action needed @@ -306,7 +282,8 @@ contract RoboSaverVirtualModule is function performUpkeep(bytes calldata _performData) external override onlyKeeper { // decode `_performData` - (PoolAction action, uint256 amount) = abi.decode(_performData, (PoolAction, uint256)); + (VirtualModule.PoolAction action, uint256 amount) = + abi.decode(_performData, (VirtualModule.PoolAction, uint256)); _adjustPool(action, amount); } @@ -317,18 +294,18 @@ contract RoboSaverVirtualModule is /// @notice Adjust the pool by depositing or withdrawing $EURe /// @param _action The action to take: deposit or withdraw /// @param _amount The amount of $EURe to deposit or withdraw - function _adjustPool(PoolAction _action, uint256 _amount) internal { + function _adjustPool(VirtualModule.PoolAction _action, uint256 _amount) internal { if (!delayModule.isModuleEnabled(address(this))) revert VirtualModuleNotEnabled(); if (_isCleanQueueRequired()) delayModule.skipExpired(); if (_isExternalTxQueued()) revert ExternalTxIsQueued(); - if (_action == PoolAction.WITHDRAW) { + if (_action == VirtualModule.PoolAction.WITHDRAW) { _poolWithdrawal(_amount); - } else if (_action == PoolAction.DEPOSIT) { + } else if (_action == VirtualModule.PoolAction.DEPOSIT) { _poolDeposit(_amount); - } else if (_action == PoolAction.CLOSE) { + } else if (_action == VirtualModule.PoolAction.CLOSE) { _poolClose(_amount); - } else if (_action == PoolAction.EXEC_QUEUE_POOL_ACTION) { + } else if (_action == VirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION) { _executeQueuedTx(); } } @@ -449,7 +426,7 @@ contract RoboSaverVirtualModule is : IDelayModifier.DelayModuleOperation.Call; delayModule.execTransactionFromModule(_target, 0, _payload, operation); uint256 cachedQueueNonce = delayModule.queueNonce(); - queuedTx = QueuedTx(cachedQueueNonce, _target, _payload); + queuedTx = VirtualModule.QueuedTx(cachedQueueNonce, _target, _payload); emit AdjustPoolTxDataQueued(_target, _payload, cachedQueueNonce); } @@ -473,4 +450,22 @@ contract RoboSaverVirtualModule is anyExpiredTxs_ = true; } } + + function _setSlippage(uint16 _slippage) internal { + if (_slippage >= MAX_BPS) revert TooHighBps(); + + uint16 oldSlippage = slippage; + slippage = _slippage; + + emit SetSlippage(msg.sender, oldSlippage, slippage); + } + + function _setBuffer(uint256 _buffer) internal { + if (_buffer == 0) revert ZeroUintValue(); + + uint256 oldBuffer = buffer; + buffer = _buffer; + + emit SetBuffer(msg.sender, oldBuffer, buffer); + } } diff --git a/src/RoboSaverVirtualModuleFactory.sol b/src/RoboSaverVirtualModuleFactory.sol new file mode 100644 index 0000000..aa99ac6 --- /dev/null +++ b/src/RoboSaverVirtualModuleFactory.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {IERC20} from "@chainlink/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol"; +import {IKeeperRegistryMaster} from "@chainlink/automation/interfaces/v2_1/IKeeperRegistryMaster.sol"; +import {IKeeperRegistrar} from "./interfaces/chainlink/IKeeperRegistrar.sol"; + +import {IDelayModifier} from "./interfaces/delayModule/IDelayModifier.sol"; +import {IRolesModifier} from "@gnosispay-kit/interfaces/IRolesModifier.sol"; + +import {Factory} from "./types/DataTypes.sol"; + +import {RoboSaverVirtualModule} from "./RoboSaverVirtualModule.sol"; + +/// @title RoboSaverVirtualModuleFactory +/// @author onchainification.xyz +/// @notice Factory contract creates an unique {RoboSaverVirtualModule} per Gnosis Pay card, and registers it in the Chainlink Keeper Registry +contract RoboSaverVirtualModuleFactory { + /*////////////////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////////////////*/ + + IERC20 constant LINK = IERC20(0xE2e73A1c69ecF83F464EFCE6A5be353a37cA09b2); + IKeeperRegistryMaster constant CL_REGISTRY = IKeeperRegistryMaster(0x299c92a219F61a82E91d2062A262f7157F155AC1); + IKeeperRegistrar constant CL_REGISTRAR = IKeeperRegistrar(0x0F7E163446AAb41DB5375AbdeE2c3eCC56D9aA32); + + /*////////////////////////////////////////////////////////////////////////// + PUBLIC STORAGE + //////////////////////////////////////////////////////////////////////////*/ + + // card -> (module address, upkeep id) + mapping(address => Factory.VirtualModuleDetails) public virtualModules; + + /*////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////*/ + event RoboSaverVirtualModuleCreated(address virtualModule, address card, uint256 upkeepId, uint256 timestamp); + + /*////////////////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////////////////*/ + error UpkeepZero(); + + error CallerNotMatchingAvatar(string moduleName, address caller); + + /*////////////////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////////////////*/ + + constructor() { + /// @dev max approval to be able to handle smoothly upkeep creations and top-ups + LINK.approve(address(CL_REGISTRAR), type(uint256).max); + } + + /*////////////////////////////////////////////////////////////////////////// + EXTERNAL METHODS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Creates a virtual module for a card + /// @param _delayModule The address of the delay module + /// @param _rolesModule The address of the roles module + /// @param _buffer The buffer for the virtual module (configurable) + /// @param _slippage The slippage for the virtual module (configurable) + function createVirtualModule(address _delayModule, address _rolesModule, uint256 _buffer, uint16 _slippage) + external + { + /// @dev verification of `buffer` & `slippage` is done in the constructor of the virtual module + _verifyVirtualModuleCreationArgs(_delayModule, _rolesModule); + + // uses `msg.sender` as a salt to make the deployed address of the virtual module deterministic + address virtualModule = address( + new RoboSaverVirtualModule{salt: keccak256(abi.encodePacked(msg.sender))}( + address(this), _delayModule, _rolesModule, _buffer, _slippage + ) + ); + + uint256 upkeepId = _registerRoboSaverVirtualModule(virtualModule); + + // extracts forwarder address & sets keeper + address keeper = CL_REGISTRY.getForwarder(upkeepId); + RoboSaverVirtualModule(virtualModule).setKeeper(keeper); + + emit RoboSaverVirtualModuleCreated(virtualModule, msg.sender, upkeepId, block.timestamp); + } + + /*////////////////////////////////////////////////////////////////////////// + INTERNAL METHODS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Verifies the arguments passed to the virtual module creation + /// @param _delayModule The address of the delay module + /// @param _rolesModule The address of the roles module + function _verifyVirtualModuleCreationArgs(address _delayModule, address _rolesModule) internal view { + // verify that delay module avatar & `msg.sender` matches + if (IDelayModifier(_delayModule).avatar() != msg.sender) { + revert CallerNotMatchingAvatar("DelayModule", msg.sender); + } + // verify that the roles module avatar & `msg.sender` matches + if (IRolesModifier(_rolesModule).avatar() != msg.sender) { + revert CallerNotMatchingAvatar("RolesModule", msg.sender); + } + } + + /// @notice Registers the virtual module in the Chainlink Keeper Registry + /// @param _virtualModule The address of the virtual module to be registered + function _registerRoboSaverVirtualModule(address _virtualModule) internal returns (uint256 upkeepId_) { + IKeeperRegistrar.RegistrationParams memory registrationParams = IKeeperRegistrar.RegistrationParams({ + name: string.concat(RoboSaverVirtualModule(_virtualModule).name(), "-", _addressToString(msg.sender)), + encryptedEmail: "", + upkeepContract: _virtualModule, + gasLimit: 2_000_000, + adminAddress: address(this), // @note the factory is the admin + triggerType: 0, + checkData: "", + triggerConfig: "", + offchainConfig: "", + amount: 200e18 // @note dummy value for now + }); + + upkeepId_ = CL_REGISTRAR.registerUpkeep(registrationParams); + if (upkeepId_ == 0) revert UpkeepZero(); + + virtualModules[msg.sender] = + Factory.VirtualModuleDetails({virtualModuleAddress: _virtualModule, upkeepId: upkeepId_}); + } + + /// @notice Converts an address to a string + function _addressToString(address _addr) internal pure returns (string memory) { + bytes32 value = bytes32(uint256(uint160(_addr))); + bytes memory alphabet = "0123456789abcdef"; + + bytes memory str = new bytes(42); + str[0] = "0"; + str[1] = "x"; + + for (uint256 i = 0; i < 20; i++) { + str[2 + i * 2] = alphabet[uint8(value[i + 12] >> 4)]; + str[3 + i * 2] = alphabet[uint8(value[i + 12] & 0x0f)]; + } + + return string(str); + } +} diff --git a/src/types/DataTypes.sol b/src/types/DataTypes.sol new file mode 100644 index 0000000..f44f2ba --- /dev/null +++ b/src/types/DataTypes.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +/// @notice Namespace for the structs used in {RoboSaverVirtualModule}. +library VirtualModule { + /// @notice Enum representing the different types of pool actions + /// @custom:value0 WITHDRAW Withdraw $EURe from the pool to the card + /// @custom:value1 DEPOSIT Deposit $EURe from the card into the pool + /// @custom:value2 CLOSE Close the pool position by withdrawing all to $EURe + /// @custom:value3 EXEC_QUEUE_POOL_ACTION Execute the queued pool action + enum PoolAction { + WITHDRAW, + DEPOSIT, + CLOSE, + EXEC_QUEUE_POOL_ACTION + } + + /// @notice Struct representing the data needed to execute a queued transaction + /// @dev Nonce allows us to determine if the transaction queued originated from this virtual module + /// @param nonce The nonce of the queued transaction + /// @param target The address of the target contract + /// @param payload The payload of the transaction to be executed on the target contract + struct QueuedTx { + uint256 nonce; + address target; + bytes payload; + } +} + +/// @notice Namespace for the structs used in {RoboSaverVirtualModuleFactory}. +library Factory { + /// @notice Struct representing the data details of each registered virtual module in the Chainlink automation service + /// @param virtualModuleAddress The address of the virtual module + /// @param upkeepId The ID of the upkeep registered in Chainlink for the virtual module + struct VirtualModuleDetails { + address virtualModuleAddress; + uint256 upkeepId; + } +} diff --git a/test/BaseFixture.sol b/test/BaseFixture.sol index f0acf22..6013d50 100644 --- a/test/BaseFixture.sol +++ b/test/BaseFixture.sol @@ -16,6 +16,7 @@ import "@balancer-v2/interfaces/contracts/pool-stable/StablePoolUserData.sol"; import {IKeeperRegistryMaster} from "@chainlink/automation/interfaces/v2_1/IKeeperRegistryMaster.sol"; import {IKeeperRegistrar} from "../src/interfaces/chainlink/IKeeperRegistrar.sol"; +import {RoboSaverVirtualModuleFactory} from "../src/RoboSaverVirtualModuleFactory.sol"; import {RoboSaverVirtualModule} from "../src/RoboSaverVirtualModule.sol"; import {ISafeProxyFactory} from "@gnosispay-kit/interfaces/ISafeProxyFactory.sol"; @@ -80,7 +81,8 @@ contract BaseFixture is Test { ISafe safe; - // robosaver module + // robosaver module & factory + RoboSaverVirtualModuleFactory roboModuleFactory; RoboSaverVirtualModule roboModule; // Keeper address (forwarder): https://docs.chain.link/chainlink-automation/guides/forwarder#securing-your-upkeep @@ -113,40 +115,27 @@ contract BaseFixture is Test { bouncerContract = new Bouncer(address(safe), address(rolesModule), SET_ALLOWANCE_SELECTOR); - roboModule = new RoboSaverVirtualModule(address(delayModule), address(rolesModule), EURE_BUFFER, SLIPPAGE); + roboModuleFactory = new RoboSaverVirtualModuleFactory(); + // fund the factory with LINK for task top up + deal(LINK, address(roboModuleFactory), LINK_FOR_TASK_TOP_UP); // enable robo module in the delay & gnosis safe for tests flow vm.startPrank(address(safe)); + // create from factory new robo virtual module + roboModuleFactory.createVirtualModule(address(delayModule), address(rolesModule), EURE_BUFFER, SLIPPAGE); + (address roboModuleAddress, uint256 upkeepId) = roboModuleFactory.virtualModules(address(safe)); + + roboModule = RoboSaverVirtualModule(roboModuleAddress); + // deduct keeper from the registry and factory upkeep id rerieved from factory storage + keeper = CL_REGISTRY.getForwarder(upkeepId); + delayModule.enableModule(address(roboModule)); delayModule.enableModule(address(safe)); safe.enableModule(address(delayModule)); safe.enableModule(address(rolesModule)); - // registering the task in CL automation service - deal(LINK, address(safe), LINK_FOR_TASK_TOP_UP); - IERC20(LINK).approve(address(CL_REGISTRAR), LINK_FOR_TASK_TOP_UP); - - IKeeperRegistrar.RegistrationParams memory registrationParams = IKeeperRegistrar.RegistrationParams({ - name: string.concat(roboModule.name(), "-", _addressToString(address(safe))), - encryptedEmail: "", - upkeepContract: address(roboModule), - gasLimit: 2_000_000, - adminAddress: address(safe), - triggerType: 0, - checkData: "", - triggerConfig: "", - offchainConfig: "", - amount: LINK_FOR_TASK_TOP_UP - }); - - uint256 upkeepID = CL_REGISTRAR.registerUpkeep(registrationParams); - assertNotEq(upkeepID, 0); - - keeper = CL_REGISTRY.getForwarder(upkeepID); - roboModule.setKeeper(keeper); - vm.stopPrank(); vm.prank(SAFE_EOA_SIGNER); @@ -161,8 +150,6 @@ contract BaseFixture is Test { // @note is it neccesary for our setup: assign roles, scope target, scope function? - // @note pendant of wiring up a keeper service here at some point - vm.prank(SAFE_EOA_SIGNER); rolesModule.transferOwnership(address(bouncerContract)); @@ -171,15 +158,38 @@ contract BaseFixture is Test { deal(BPT_STEUR_EURE, address(safe), EURE_TO_MINT); + // assert here constructor action in the {RoboSaverVirtualModuleFactory} for a hit + assertEq(IERC20(LINK).allowance(address(roboModuleFactory), address(CL_REGISTRAR)), type(uint256).max); + + _labelKeyContracts(); + } + + /// @dev Labels key contracts for tracing + function _labelKeyContracts() internal { + vm.label(address(safe), "GNOSIS_SAFE"); + // robosaver module factory + vm.label(address(roboModuleFactory), "ROBO_MODULE_FACTORY"); + // tokens vm.label(EURE, "EURE"); vm.label(WETH, "WETH"); - vm.label(address(safe), "GNOSIS_SAFE"); + vm.label(LINK, "LINK"); + // gnosis pay modules infrastructure vm.label(address(delayModule), "DELAY_MODULE"); vm.label(address(bouncerContract), "BOUNCER_CONTRACT"); vm.label(address(rolesModule), "ROLES_MODULE"); vm.label(address(roboModule), "ROBO_MODULE"); + // balancer vm.label(BPT_STEUR_EURE, "BPT_STEUR_EURE"); vm.label(address(roboModule.BALANCER_VAULT()), "BALANCER_VAULT"); + // chainlink + vm.label(address(CL_REGISTRY), "CL_REGISTRY"); + vm.label(address(CL_REGISTRAR), "CL_REGISTRAR"); + } + + function _getDeterministicAddress(bytes memory bytecode, bytes32 _salt) internal view returns (address) { + bytes32 hash = keccak256(abi.encodePacked(bytes1(0xff), address(this), _salt, keccak256(bytecode))); + + return address(uint160(uint256(hash))); } function _addressToString(address _addr) internal pure returns (string memory) { diff --git a/test/integration/AdjustPoolTest.t.sol b/test/integration/AdjustPoolTest.t.sol index 0316069..54110aa 100644 --- a/test/integration/AdjustPoolTest.t.sol +++ b/test/integration/AdjustPoolTest.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.25; import {BaseFixture} from "../BaseFixture.sol"; +import {VirtualModule} from "../../src/types/DataTypes.sol"; import {RoboSaverVirtualModule} from "../../src/RoboSaverVirtualModule.sol"; contract AdjustPoolTest is BaseFixture { @@ -23,7 +24,7 @@ contract AdjustPoolTest is BaseFixture { // keeper trying to exec for no reason `adjustPool` while checker is false vm.prank(roboModule.keeper()); vm.expectRevert(abi.encodeWithSelector(RoboSaverVirtualModule.ExternalTxIsQueued.selector)); - roboModule.performUpkeep(abi.encode(RoboSaverVirtualModule.PoolAction.DEPOSIT, 2e18)); + roboModule.performUpkeep(abi.encode(VirtualModule.PoolAction.DEPOSIT, 2e18)); } function test_RevertWhen_VirtualModuleIsDisabled() public { @@ -33,7 +34,7 @@ contract AdjustPoolTest is BaseFixture { vm.prank(roboModule.keeper()); vm.expectRevert(abi.encodeWithSelector(RoboSaverVirtualModule.VirtualModuleNotEnabled.selector)); - roboModule.performUpkeep(abi.encode(RoboSaverVirtualModule.PoolAction.DEPOSIT, 2e18)); + roboModule.performUpkeep(abi.encode(VirtualModule.PoolAction.DEPOSIT, 2e18)); } function test_When_QueueHasExpiredTxs() public { @@ -58,7 +59,7 @@ contract AdjustPoolTest is BaseFixture { // 3. trigger a normal flow (includes cleanup + queuing of a deposit) vm.prank(keeper); - roboModule.performUpkeep(abi.encode(RoboSaverVirtualModule.PoolAction.DEPOSIT, 2e18)); + roboModule.performUpkeep(abi.encode(VirtualModule.PoolAction.DEPOSIT, 2e18)); // asserts the clean up its checked, since it triggered `txNonce++` assertGt(delayModule.txNonce(), txNonceBeforeCleanup); diff --git a/test/integration/ClosePoolTest.t.sol b/test/integration/ClosePoolTest.t.sol index 5bcbdf2..beea662 100644 --- a/test/integration/ClosePoolTest.t.sol +++ b/test/integration/ClosePoolTest.t.sol @@ -5,7 +5,7 @@ import {IERC20} from "@gnosispay-kit/interfaces/IERC20.sol"; import {BaseFixture} from "../BaseFixture.sol"; -import {RoboSaverVirtualModule} from "../../src/RoboSaverVirtualModule.sol"; +import {VirtualModule} from "../../src/types/DataTypes.sol"; contract ClosePoolTest is BaseFixture { function testClosePool() public { @@ -14,9 +14,9 @@ contract ClosePoolTest is BaseFixture { // balance=240, dailyAllowance=200, buffer=50 // deposit 100 vm.startPrank(keeper); - roboModule.performUpkeep(abi.encode(RoboSaverVirtualModule.PoolAction.DEPOSIT, 100e18)); + roboModule.performUpkeep(abi.encode(VirtualModule.PoolAction.DEPOSIT, 100e18)); vm.warp(block.timestamp + COOLDOWN_PERIOD); - roboModule.performUpkeep(abi.encode(RoboSaverVirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION, 1)); + roboModule.performUpkeep(abi.encode(VirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION, 1)); vm.stopPrank(); // // set buffer to > dailyAllowance + ~poolBalance @@ -26,15 +26,14 @@ contract ClosePoolTest is BaseFixture { // this should now trigger a pool close (bool canExec, bytes memory execPayload) = roboModule.checkUpkeep(""); assertTrue(canExec); - (RoboSaverVirtualModule.PoolAction _action,) = - abi.decode(execPayload, (RoboSaverVirtualModule.PoolAction, uint256)); - assertEq(uint8(_action), uint8(RoboSaverVirtualModule.PoolAction.CLOSE)); + (VirtualModule.PoolAction _action,) = abi.decode(execPayload, (VirtualModule.PoolAction, uint256)); + assertEq(uint8(_action), uint8(VirtualModule.PoolAction.CLOSE)); // exec it and check if pool is closed vm.startPrank(keeper); roboModule.performUpkeep(execPayload); vm.warp(block.timestamp + COOLDOWN_PERIOD); - roboModule.performUpkeep(abi.encode(RoboSaverVirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION, 0)); + roboModule.performUpkeep(abi.encode(VirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION, 0)); assertEq(IERC20(BPT_STEUR_EURE).balanceOf(address(safe)), 0); } } diff --git a/test/integration/FactoryTest.t.sol b/test/integration/FactoryTest.t.sol new file mode 100644 index 0000000..4815e7f --- /dev/null +++ b/test/integration/FactoryTest.t.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {BaseFixture} from "../BaseFixture.sol"; + +import {Delay} from "@delay-module/Delay.sol"; +import {Roles} from "@roles-module/Roles.sol"; + +import {IKeeperRegistrar} from "../../src/interfaces/chainlink/IKeeperRegistrar.sol"; + +import {RoboSaverVirtualModule} from "../../src/RoboSaverVirtualModule.sol"; +import {RoboSaverVirtualModuleFactory} from "../../src/RoboSaverVirtualModuleFactory.sol"; + +contract FactoryTest is BaseFixture { + Delay dummyDelayModule; + Roles dummyRolesModule; + + function test_ReverWhen_AvatarDoesNotMatch() public { + address randomAvatar = address(545_495); + // deploy dummy delay and roles modules + dummyRolesModule = new Roles(SAFE_EOA_SIGNER, randomAvatar, randomAvatar); + dummyDelayModule = new Delay(randomAvatar, randomAvatar, randomAvatar, COOLDOWN_PERIOD, EXPIRATION_PERIOD); + + vm.startPrank(address(safe)); + + vm.expectRevert( + abi.encodeWithSelector( + RoboSaverVirtualModuleFactory.CallerNotMatchingAvatar.selector, "DelayModule", address(safe) + ) + ); + roboModuleFactory.createVirtualModule(address(dummyRolesModule), address(rolesModule), EURE_BUFFER, SLIPPAGE); + + vm.expectRevert( + abi.encodeWithSelector( + RoboSaverVirtualModuleFactory.CallerNotMatchingAvatar.selector, "RolesModule", address(safe) + ) + ); + roboModuleFactory.createVirtualModule(address(delayModule), address(dummyRolesModule), EURE_BUFFER, SLIPPAGE); + vm.stopPrank(); + } + + function test_RevertWhen_UpkeepReturnsZero() public { + bytes memory creationCode = abi.encodePacked(type(RoboSaverVirtualModule).creationCode); + bytes32 salt = keccak256(abi.encodePacked(address(safe))); + address deterministicVirtualModuleAddress = _getDeterministicAddress(creationCode, salt); + + // seems that only `upkeepId_` could be return null in case that it is not "autoApprove" + // ref: https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/automation/v2_0/KeeperRegistrar2_0.sol#L376 + IKeeperRegistrar.RegistrationParams memory params = IKeeperRegistrar.RegistrationParams({ + name: string.concat("RoboSaverVirtualModule-EURE", "-", _addressToString(address(safe))), + encryptedEmail: "", + upkeepContract: deterministicVirtualModuleAddress, + gasLimit: 2_000_000, + adminAddress: address(roboModuleFactory), + triggerType: 0, + checkData: "", + triggerConfig: "", + offchainConfig: "", + amount: 200e18 + }); + + // @todo pendant of implementing properly in another PR ensuring proper storage manipulation + } +} diff --git a/test/integration/TopupBptTest.t.sol b/test/integration/TopupBptTest.t.sol index a050a95..4c52fc3 100644 --- a/test/integration/TopupBptTest.t.sol +++ b/test/integration/TopupBptTest.t.sol @@ -13,7 +13,7 @@ import {Enum} from "../../lib/delay-module/node_modules/@gnosis.pm/safe-contract import {IEURe} from "../../src/interfaces/eure/IEURe.sol"; -import {RoboSaverVirtualModule} from "../../src/RoboSaverVirtualModule.sol"; +import {VirtualModule} from "../../src/types/DataTypes.sol"; contract TopupBptTest is BaseFixture { function testTopupBpt() public { @@ -25,14 +25,12 @@ contract TopupBptTest is BaseFixture { uint256 initialBptBal = IERC20(BPT_STEUR_EURE).balanceOf(address(safe)); (bool canExec, bytes memory execPayload) = roboModule.checkUpkeep(""); - (RoboSaverVirtualModule.PoolAction _action, uint256 _amount) = - abi.decode(execPayload, (RoboSaverVirtualModule.PoolAction, uint256)); + (VirtualModule.PoolAction _action, uint256 _amount) = + abi.decode(execPayload, (VirtualModule.PoolAction, uint256)); // since initially it was minted 1000 it should be way above the buffer assertTrue(canExec, "CanExec: not executable"); - assertEq( - uint8(_action), uint8(RoboSaverVirtualModule.PoolAction.DEPOSIT), "PoolAction: not depositing into the pool" - ); + assertEq(uint8(_action), uint8(VirtualModule.PoolAction.DEPOSIT), "PoolAction: not depositing into the pool"); uint256 bptOutExpected = _getBptOutExpected(_amount); @@ -63,7 +61,7 @@ contract TopupBptTest is BaseFixture { // 1. eure exact appproval to `BALANCER_VAULT` // 2. join the pool single sided with the excess vm.prank(keeper); - roboModule.performUpkeep(abi.encode(RoboSaverVirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION, 0)); + roboModule.performUpkeep(abi.encode(VirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION, 0)); // ensure default values at `queuedTx` after execution _assertPostDefaultValuesNextTxExec(); diff --git a/test/integration/TopupEUReTest.t.sol b/test/integration/TopupEUReTest.t.sol index aa44a4d..281f31a 100644 --- a/test/integration/TopupEUReTest.t.sol +++ b/test/integration/TopupEUReTest.t.sol @@ -9,7 +9,7 @@ import "@balancer-v2/interfaces/contracts/vault/IVault.sol"; import {Enum} from "../../lib/delay-module/node_modules/@gnosis.pm/safe-contracts/contracts/common/Enum.sol"; -import {RoboSaverVirtualModule} from "../../src/RoboSaverVirtualModule.sol"; +import {VirtualModule} from "../../src/types/DataTypes.sol"; contract TopupTest is BaseFixture { // @note ref for error codes: https://docs.balancer.fi/reference/contracts/error-codes.html#error-codes @@ -29,13 +29,11 @@ contract TopupTest is BaseFixture { uint256 initialEureBal = IERC20(EURE).balanceOf(address(safe)); (bool canExec, bytes memory execPayload) = roboModule.checkUpkeep(""); - (RoboSaverVirtualModule.PoolAction _action, uint256 _deficit) = - abi.decode(execPayload, (RoboSaverVirtualModule.PoolAction, uint256)); + (VirtualModule.PoolAction _action, uint256 _deficit) = + abi.decode(execPayload, (VirtualModule.PoolAction, uint256)); assertTrue(canExec, "CanExec: not executable"); - assertEq( - uint8(_action), uint8(RoboSaverVirtualModule.PoolAction.WITHDRAW), "PoolAction: not withdrawal from pool" - ); + assertEq(uint8(_action), uint8(VirtualModule.PoolAction.WITHDRAW), "PoolAction: not withdrawal from pool"); // calc via Balancer Queries the max BPT amount to withdraw uint256 maxBPTAmountIn = _getMaxBptInExpected(_deficit, initialBptBal); @@ -63,7 +61,7 @@ contract TopupTest is BaseFixture { _assertPreStorageValuesNextTxExec(address(roboModule.BALANCER_VAULT()), abi.decode(entries[1].data, (bytes))); vm.prank(keeper); - roboModule.performUpkeep(abi.encode(RoboSaverVirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION, 0)); + roboModule.performUpkeep(abi.encode(VirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION, 0)); // ensure default values at `queuedTx` after execution _assertPostDefaultValuesNextTxExec(); diff --git a/test/unit/CheckerTest.t.sol b/test/unit/CheckerTest.t.sol index bdca727..e2ee987 100644 --- a/test/unit/CheckerTest.t.sol +++ b/test/unit/CheckerTest.t.sol @@ -7,7 +7,7 @@ import {IERC20} from "@gnosispay-kit/interfaces/IERC20.sol"; import {Enum} from "../../lib/delay-module/node_modules/@gnosis.pm/safe-contracts/contracts/common/Enum.sol"; -import {RoboSaverVirtualModule} from "../../src/RoboSaverVirtualModule.sol"; +import {VirtualModule} from "../../src/types/DataTypes.sol"; contract CheckerTest is BaseFixture { function testChecker_When_TopupIsRequired() public { @@ -22,14 +22,10 @@ contract CheckerTest is BaseFixture { delayModule.executeNextTx(EURE, 0, payload, Enum.Operation.Call); (bool canExec, bytes memory execPayload) = roboModule.checkUpkeep(""); - (RoboSaverVirtualModule.PoolAction _action, uint256 _amount) = - abi.decode(execPayload, (RoboSaverVirtualModule.PoolAction, uint256)); + (VirtualModule.PoolAction _action, uint256 _amount) = + abi.decode(execPayload, (VirtualModule.PoolAction, uint256)); assertTrue(canExec, "CanExec: not executable"); - assertEq( - uint8(_action), - uint8(RoboSaverVirtualModule.PoolAction.WITHDRAW), - "PoolAction: not withdrawing from the pool" - ); + assertEq(uint8(_action), uint8(VirtualModule.PoolAction.WITHDRAW), "PoolAction: not withdrawing from the pool"); assertGt(_amount, 0); } @@ -68,7 +64,7 @@ contract CheckerTest is BaseFixture { function testChecker_When_internalTxIsQueued() public { // 1. assert that internal tx is being queued and within cooldown vm.prank(keeper); - roboModule.performUpkeep(abi.encode(RoboSaverVirtualModule.PoolAction.DEPOSIT, 1000)); + roboModule.performUpkeep(abi.encode(VirtualModule.PoolAction.DEPOSIT, 1000)); (bool canExec, bytes memory execPayload) = roboModule.checkUpkeep(""); assertFalse(canExec); @@ -87,9 +83,9 @@ contract CheckerTest is BaseFixture { (canExec, execPayload) = roboModule.checkUpkeep(""); assertTrue(canExec); - (RoboSaverVirtualModule.PoolAction _action, uint256 _amount) = - abi.decode(execPayload, (RoboSaverVirtualModule.PoolAction, uint256)); - assertEq(uint8(_action), uint8(RoboSaverVirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION)); + (VirtualModule.PoolAction _action, uint256 _amount) = + abi.decode(execPayload, (VirtualModule.PoolAction, uint256)); + assertEq(uint8(_action), uint8(VirtualModule.PoolAction.EXEC_QUEUE_POOL_ACTION)); assertEq(_amount, 0); } diff --git a/test/unit/InformativeMethodsTest.t.sol b/test/unit/InformativeMethodsTest.t.sol index 3eb89d1..8f89c3b 100644 --- a/test/unit/InformativeMethodsTest.t.sol +++ b/test/unit/InformativeMethodsTest.t.sol @@ -3,8 +3,6 @@ pragma solidity ^0.8.25; import {BaseFixture} from "../BaseFixture.sol"; -import {RoboSaverVirtualModule} from "../../src/RoboSaverVirtualModule.sol"; - /// @notice Suite is focus mainly in the following methods for coverage: /// - `RoboSaverVirtualModule.name()` /// - `RoboSaverVirtualModule.version()` diff --git a/test/unit/SettersTest.t.sol b/test/unit/SettersTest.t.sol index e5b8dc8..6d04efc 100644 --- a/test/unit/SettersTest.t.sol +++ b/test/unit/SettersTest.t.sol @@ -32,7 +32,7 @@ contract SettersTest is BaseFixture { roboModule.setBuffer(1000); vm.prank(randomCaller); - vm.expectRevert(abi.encodeWithSelector(RoboSaverVirtualModule.NotAdmin.selector, randomCaller)); + vm.expectRevert(abi.encodeWithSelector(RoboSaverVirtualModule.NeitherAdminNorFactory.selector, randomCaller)); roboModule.setKeeper(address(554)); vm.prank(randomCaller);