diff --git a/contracts/src/interfaces/IGauge.sol b/contracts/src/interfaces/IGauge.sol new file mode 100644 index 0000000..63d1338 --- /dev/null +++ b/contracts/src/interfaces/IGauge.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IGauge { + error NotAlive(); + error NotAuthorized(); + error NotVoter(); + error RewardRateTooHigh(); + error ZeroAmount(); + error ZeroRewardRate(); + + event Deposit(address indexed from, address indexed to, uint256 amount); + event Withdraw(address indexed from, uint256 amount); + event NotifyReward(address indexed from, uint256 amount); + event ClaimFees(address indexed from, uint256 claimed0, uint256 claimed1); + event ClaimRewards(address indexed from, uint256 amount); + + /// @notice Address of the pool LP token which is deposited (staked) for rewards + function stakingToken() external view returns (address); + + /// @notice Address of the token (VELO v2) rewarded to stakers + function rewardToken() external view returns (address); + + /// @notice Address of the FeesVotingReward contract linked to the gauge + function feesVotingReward() external view returns (address); + + /// @notice Address of Velodrome v2 Voter + function voter() external view returns (address); + + /// @notice Returns if gauge is linked to a legitimate Velodrome pool + function isPool() external view returns (bool); + + /// @notice Timestamp end of current rewards period + function periodFinish() external view returns (uint256); + + /// @notice Current reward rate of rewardToken to distribute per second + function rewardRate() external view returns (uint256); + + /// @notice Most recent timestamp contract has updated state + function lastUpdateTime() external view returns (uint256); + + /// @notice Most recent stored value of rewardPerToken + function rewardPerTokenStored() external view returns (uint256); + + /// @notice Amount of stakingToken deposited for rewards + function totalSupply() external view returns (uint256); + + /// @notice Get the amount of stakingToken deposited by an account + function balanceOf(address) external view returns (uint256); + + /// @notice Cached rewardPerTokenStored for an account based on their most recent action + function userRewardPerTokenPaid(address) external view returns (uint256); + + /// @notice Cached amount of rewardToken earned for an account + function rewards(address) external view returns (uint256); + + /// @notice View to see the rewardRate given the timestamp of the start of the epoch + function rewardRateByEpoch(uint256) external view returns (uint256); + + /// @notice Cached amount of fees generated from the Pool linked to the Gauge of token0 + function fees0() external view returns (uint256); + + /// @notice Cached amount of fees generated from the Pool linked to the Gauge of token1 + function fees1() external view returns (uint256); + + /// @notice Get the current reward rate per unit of stakingToken deposited + function rewardPerToken() external view returns (uint256 _rewardPerToken); + + /// @notice Returns the last time the reward was modified or periodFinish if the reward has ended + function lastTimeRewardApplicable() external view returns (uint256 _time); + + /// @notice Returns accrued balance to date from last claim / first deposit. + function earned(address _account) external view returns (uint256 _earned); + + /// @notice Total amount of rewardToken to distribute for the current rewards period + function left() external view returns (uint256 _left); + + /// @notice Retrieve rewards for an address. + /// @dev Throws if not called by same address or voter. + /// @param _account . + function getReward(address _account) external; + + /// @notice Deposit LP tokens into gauge for msg.sender + /// @param _amount . + function deposit(uint256 _amount) external; + + /// @notice Deposit LP tokens into gauge for any user + /// @param _amount . + /// @param _recipient Recipient to give balance to + function deposit(uint256 _amount, address _recipient) external; + + /// @notice Withdraw LP tokens for user + /// @param _amount . + function withdraw(uint256 _amount) external; + + /// @dev Notifies gauge of gauge rewards. Assumes gauge reward tokens is 18 decimals. + /// If not 18 decimals, rewardRate may have rounding issues. + function notifyRewardAmount(uint256 amount) external; +} diff --git a/contracts/src/modules/DummyModule.sol b/contracts/src/modules/DummyModule.sol index 05b2089..97f35fa 100644 --- a/contracts/src/modules/DummyModule.sol +++ b/contracts/src/modules/DummyModule.sol @@ -2,10 +2,13 @@ pragma solidity ^0.8.20; import {ISafe} from "safe-protocol/interfaces/Accounts.sol"; -import {ISafeProtocolManager, SafeRootAccess} from "safe-protocol/interfaces/Manager.sol"; +import {ISafeProtocolManager} from "safe-protocol/interfaces/Manager.sol"; +import {SafeTransaction, SafeProtocolAction} from "safe-protocol/DataTypes.sol"; import {BaseModule, PluginMetadata} from "./BaseModule.sol"; +import {IGauge} from "../interfaces/IGauge.sol"; + contract DummyModule is BaseModule { //////////////////////////////////////////////////////////////////////////// // STRUCT @@ -24,11 +27,43 @@ contract DummyModule is BaseModule { // address (SafeProtocolManager) address manager; + // address (relayer: keeper, gelato, AA) + address public relayer; + // address (Safe address) => DummyConfig mapping(address => DummyConfig) public safeConfigs; - constructor(address _manager, PluginMetadata memory _data) BaseModule(_data) { + //////////////////////////////////////////////////////////////////////////// + // ERRORS + //////////////////////////////////////////////////////////////////////////// + + error UntrustedRelayer(address origin); + + error TooSoon(uint256 currentTime, uint256 updateTime, uint256 minDuration); + + //////////////////////////////////////////////////////////////////////////// + // MODIFIERS + //////////////////////////////////////////////////////////////////////////// + + modifier onlyRelayer() { + if (msg.sender != relayer) revert UntrustedRelayer(msg.sender); + _; + } + + //////////////////////////////////////////////////////////////////////////// + // EVENTS + //////////////////////////////////////////////////////////////////////////// + + /// @dev use for subgraph to display basic info in ui as per `safe` basis + event PluginTransactionExec(address safe, address gauge, uint256 timestamp); + + constructor( + address _manager, + address _relayer, + PluginMetadata memory _data + ) BaseModule(_data) { manager = _manager; + relayer = _relayer; } //////////////////////////////////////////////////////////////////////////// @@ -39,15 +74,34 @@ contract DummyModule is BaseModule { safeConfigs[_safe] = _config; } - /// @notice Executes a Safe transaction - /// @param _manager Address of the Safe{Core} Protocol Manager. - /// @param _safe Safe account - /// @param _rootAccess Contains the set of actions to be done in the Safe transaction - function executeFromPlugin( - ISafeProtocolManager _manager, - ISafe _safe, - SafeRootAccess calldata _rootAccess - ) external { - _manager.executeRootAccess(_safe, _rootAccess); + /// @notice Executes a Safe transaction. Only executable by trusted relayer + /// @param _safe Safe account target address + function executeFromPlugin(ISafe _safe) external onlyRelayer { + DummyConfig storage config = safeConfigs[address(_safe)]; + + uint256 lastCallTimestampCached = config.lastCall; + uint256 cadenceCached = config.cadenceSec; + + if ((block.timestamp - lastCallTimestampCached) < cadenceCached) { + revert TooSoon(block.timestamp, lastCallTimestampCached, cadenceCached); + } + + SafeProtocolAction[] memory transactions = new SafeProtocolAction[](1); + transactions[0] = SafeProtocolAction({ + to: payable(config.vault), + value: 0, + data: abi.encodeWithSelector(IGauge.getReward.selector, address(_safe)) + }); + + SafeTransaction memory transaction = SafeTransaction({ + actions: transactions, + nonce: 0, + metadataHash: bytes32(0) + }); + + ISafeProtocolManager(manager).executeTransaction(_safe, transaction); + config.lastCall = uint64(block.timestamp); + + emit PluginTransactionExec(address(_safe), config.vault, block.timestamp); } } diff --git a/contracts/src/test/MockGauge.sol b/contracts/src/test/MockGauge.sol new file mode 100644 index 0000000..f9d7c76 --- /dev/null +++ b/contracts/src/test/MockGauge.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract MockGauge is ERC20 { + address public lpToken; + + address[] public rewardTokens; + + event RewardClaimed(address acccount, uint256 amount, uint256 timestamp); + + constructor( + string memory _name, + string memory _symbol, + address _lpToken, + address[] memory _rewardTokens + ) ERC20(_name, _symbol) { + lpToken = _lpToken; + rewardTokens = _rewardTokens; + rewardTokens.push(address(0)); + } + + function deposit(uint256 amount) external { + _mint(msg.sender, amount); + IERC20(lpToken).transferFrom(msg.sender, address(this), amount); + } + + function withdraw(uint256 amount) external { + _burn(msg.sender, amount); + IERC20(lpToken).transfer(msg.sender, amount); + } + + function getReward(address _account) external { + uint256 amount = balanceOf(msg.sender); + + for (uint256 i = 0; i < rewardTokens.length; i++) { + if (rewardTokens[i] == address(0)) break; + IERC20(rewardTokens[i]).transfer(_account, amount); + emit RewardClaimed(_account, amount, block.timestamp); + } + } +} diff --git a/contracts/test/SmartGarden.t.sol b/contracts/test/SmartGarden.t.sol index ed67a3a..398ad23 100644 --- a/contracts/test/SmartGarden.t.sol +++ b/contracts/test/SmartGarden.t.sol @@ -4,37 +4,180 @@ pragma solidity ^0.8.20; /* Testing utilities */ import {Test} from "forge-std/Test.sol"; +import {SafeProxyFactory} from "safe-contracts/proxies/SafeProxyFactory.sol"; +import {SafeProxy} from "safe-contracts/proxies/SafeProxy.sol"; import {Safe} from "safe-contracts/Safe.sol"; + +import {ISafe} from "safe-protocol/interfaces/Accounts.sol"; import {SafeProtocolRegistry} from "safe-protocol/SafeProtocolRegistry.sol"; +import {SafeTransaction, SafeProtocolAction} from "safe-protocol/DataTypes.sol"; import {Enum} from "safe-protocol/common/Enum.sol"; + +import {MockGauge} from "../src/test/MockGauge.sol"; + import {SmartGardenManager} from "../src/SmartGardenManager.sol"; import {PluginMetadata} from "../src/modules/BaseModule.sol"; import {DummyModule} from "../src/modules/DummyModule.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20PresetFixedSupply} from "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; + +import {IPlugin} from "../src/interfaces/IPlugin.sol"; + +// NOTE: safe error codes: https://github.com/safe-global/safe-contracts/blob/main/docs/error_codes.md + contract SmartGardenTest is Test { + uint256 constant DUMMY_REWARD_AMT = 15e18; + + // dummy tokens for payload play + ERC20PresetFixedSupply tkn; + ERC20PresetFixedSupply rewardsToken; + // ecosystem agents address owner = address(14); + address safeOwner = address(3); + address relayer = address(5); + address rewardsDepositor = address(15); - // gnosis safe - Safe safe = new Safe(); + // gnosis-safe sc + SafeProxyFactory safeFactory = new SafeProxyFactory(); + SafeProxy s; + Safe safe = new Safe(); // singleton implementation + Safe safeProxy; // proxied safe pointer // safe-protocol sc SafeProtocolRegistry registry = new SafeProtocolRegistry(owner); SmartGardenManager manager = new SmartGardenManager(owner, address(registry)); + // mock gauge sc + MockGauge gauge; + // dummy plugin PluginMetadata data = PluginMetadata({ name: "dummy", version: "v0.0.1", - requiresRootAccess: true, + requiresRootAccess: false, iconUrl: "", appUrl: "" }); - DummyModule module = new DummyModule(address(manager), data); + DummyModule plugin = new DummyModule(address(manager), relayer, data); - function test_basic_integration() public { + function setUp() public { vm.prank(owner); - registry.addIntegration(address(module), Enum.IntegrationType.Plugin); + registry.addIntegration(address(plugin), Enum.IntegrationType.Plugin); + + address[] memory safeOwners = new address[](1); + safeOwners[0] = safeOwner; + + bytes memory initializer = abi.encodeWithSelector( + Safe.setup.selector, + safeOwners, // owners + 1, // threshold + address(0), // to + abi.encode(0), // data + address(0), // fallbackHandler + address(0), // paymentToken + 0, // payment + payable(address(0)) // paymentReceiver + ); + s = safeFactory.createProxyWithNonce(address(safe), initializer, 50); + + safeProxy = Safe(payable(address(s))); + + // mint tokens to safe + tkn = new ERC20PresetFixedSupply( + "TestToken", + "TT", + 12e18, + address(safeProxy) + ); + + assertEq(tkn.balanceOf(address(safeProxy)), 12e18); + + rewardsToken = new ERC20PresetFixedSupply( + "RewardTokens", + "RT", + 100e18, + rewardsDepositor + ); + + assertEq(rewardsToken.balanceOf(rewardsDepositor), 100e18); + + // deploy mock gauge + address[] memory rewards = new address[](1); + rewards[0] = address(rewardsToken); + gauge = new MockGauge("GaugeTestToken", "GTT", address(tkn), rewards); + + vm.prank(rewardsDepositor); + rewardsToken.transfer(address(gauge), DUMMY_REWARD_AMT); + + // manipulate `block.timestamp`. otherwise locally will be ~1 + vm.warp(1691763307); + } + + function test_basic_safe_flow() public { + // deposit in gauge + vm.prank(address(safeProxy)); + tkn.approve(address(gauge), 5e18); + vm.prank(address(safeProxy)); + gauge.deposit(5e18); + + // verify receipt + assertEq(gauge.balanceOf(address(safeProxy)), 5e18); + + // enable "manager" + vm.prank(address(safeProxy)); + safeProxy.enableModule(address(manager)); + + assertEq(safeProxy.isModuleEnabled(address(manager)), true); + + vm.prank(address(safeProxy)); + IPlugin.DummyConfig memory config = IPlugin.DummyConfig({ + vault: address(gauge), + cadenceSec: 86400, + lastCall: 0 + }); + manager.enablePluginWithConfig(address(plugin), false, config); + + (address vault, uint64 cadenceSec, uint64 lastCall) = plugin.safeConfigs( + address(safeProxy) + ); + + assertEq(vault, address(gauge)); + assertEq(lastCall, 0); + assertEq(cadenceSec, 86400); + + uint256 rewardsBalBefore = rewardsToken.balanceOf(address(safeProxy)); + + // abstraction of exec via relayer service + vm.prank(address(relayer)); + plugin.executeFromPlugin(ISafe(address(safeProxy))); + + // ensure rewards in safe increased + assertGt(rewardsToken.balanceOf(address(safeProxy)), rewardsBalBefore); + + (, , uint64 lastCallAfter) = plugin.safeConfigs(address(safeProxy)); + + // ensure that ts was writen in storage and greater + assertGt(lastCallAfter, lastCall); + + // revert if we try to trigger right after + vm.prank(address(relayer)); + vm.expectRevert( + abi.encodeWithSelector( + DummyModule.TooSoon.selector, + block.timestamp, + lastCallAfter, + cadenceSec + ) + ); + plugin.executeFromPlugin(ISafe(address(safeProxy))); + + // ok to trigger after ts >= cadence + skip(1 days); + + vm.prank(address(relayer)); + plugin.executeFromPlugin(ISafe(address(safeProxy))); } }