diff --git a/src/contracts/Grateful.sol b/src/contracts/Grateful.sol index 1ab063c..e4d43a2 100644 --- a/src/contracts/Grateful.sol +++ b/src/contracts/Grateful.sol @@ -3,6 +3,8 @@ pragma solidity 0.8.26; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; + +import {console} from "forge-std/console.sol"; import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IGrateful} from "interfaces/IGrateful.sol"; import {AaveV3ERC4626, IPool, IRewardsController} from "yield-daddy/aave-v3/AaveV3ERC4626.sol"; @@ -23,6 +25,11 @@ contract Grateful is IGrateful, Ownable2Step { // @inheritdoc IGrateful mapping(address => mapping(address => uint256)) public shares; + mapping(uint256 => Subscription) public subscriptions; + + // @inheritdoc IGrateful + uint256 public subscriptionCount; + modifier onlyWhenTokenWhitelisted(address _token) { if (!tokensWhitelisted[_token]) { revert Grateful_TokenNotWhitelisted(); @@ -51,20 +58,45 @@ contract Grateful is IGrateful, Ownable2Step { } // @inheritdoc IGrateful - function pay(address _merchant, address _token, uint256 _amount) external onlyWhenTokenWhitelisted(_token) { - if (yieldingFunds[_merchant]) { - AaveV3ERC4626 vault = vaults[_token]; - if (address(vault) == address(0)) { - revert Grateful_VaultNotSet(); - } - IERC20(_token).transferFrom(msg.sender, address(this), _amount); - uint256 _shares = vault.deposit(_amount, address(this)); - shares[_merchant][_token] += _shares; - } else { - if (!IERC20(_token).transferFrom(msg.sender, _merchant, _amount)) { - revert Grateful_TransferFailed(); - } + function pay(address _merchant, address _token, uint256 _amount) public onlyWhenTokenWhitelisted(_token) { + _processPayment(msg.sender, _merchant, _token, _amount); + } + + // @inheritdoc IGrateful + function subscribe( + address _token, + address _receiver, + uint256 _amount, + uint256 _interval + ) external onlyWhenTokenWhitelisted(_token) returns (uint256 subscriptionId) { + subscriptionId = subscriptionCount++; + subscriptions[subscriptionId] = Subscription({ + token: _token, + sender: msg.sender, + receiver: _receiver, + amount: _amount, + interval: _interval, + lastPaymentTime: block.timestamp + }); + + _processPayment(msg.sender, _receiver, _token, _amount); + } + + // @inheritdoc IGrateful + function processSubscription(uint256 subscriptionId) external { + Subscription storage subscription = subscriptions[subscriptionId]; + + if (subscription.amount == 0) { + revert Grateful_SubscriptionDoesNotExist(); } + if ( + block.timestamp < subscription.lastPaymentTime + subscription.interval // min timestamp for next payment + ) { + revert Grateful_TooEarlyForNextPayment(); + } + + _processPayment(subscription.sender, subscription.receiver, subscription.token, subscription.amount); + subscription.lastPaymentTime = block.timestamp; } // @inheritdoc IGrateful @@ -78,8 +110,38 @@ contract Grateful is IGrateful, Ownable2Step { vault.redeem(_shares, msg.sender, address(this)); } + // @inheritdoc IGrateful + function cancelSubscription(uint256 subscriptionId) external { + Subscription storage subscription = subscriptions[subscriptionId]; + + if (subscription.amount == 0) { + revert Grateful_SubscriptionDoesNotExist(); + } + if (subscription.sender != msg.sender) { + revert Grateful_OnlySenderCanCancelSubscription(); + } + + delete subscriptions[subscriptionId]; + } + // @inheritdoc IGrateful function switchYieldingFunds() external { yieldingFunds[msg.sender] = !yieldingFunds[msg.sender]; } + + function _processPayment(address _sender, address _merchant, address _token, uint256 _amount) internal { + if (yieldingFunds[_merchant]) { + AaveV3ERC4626 vault = vaults[_token]; + if (address(vault) == address(0)) { + revert Grateful_VaultNotSet(); + } + IERC20(_token).transferFrom(_sender, address(this), _amount); + uint256 _shares = vault.deposit(_amount, address(this)); + shares[_merchant][_token] += _shares; + } else { + if (!IERC20(_token).transferFrom(_sender, _merchant, _amount)) { + revert Grateful_TransferFailed(); + } + } + } } diff --git a/src/interfaces/IGrateful.sol b/src/interfaces/IGrateful.sol index 2833b1a..18724f7 100644 --- a/src/interfaces/IGrateful.sol +++ b/src/interfaces/IGrateful.sol @@ -9,6 +9,19 @@ import {AaveV3ERC4626, IPool} from "yield-daddy/aave-v3/AaveV3ERC4626.sol"; * @notice Interface for the Grateful contract that allows payments in whitelisted tokens with optional yield via AAVE. */ interface IGrateful { + /*////////////////////////////////////////////////////////////// + / STRUCTS + //////////////////////////////////////////////////////////////*/ + + struct Subscription { + address token; + address sender; + address receiver; + uint256 amount; + uint256 interval; + uint256 lastPaymentTime; + } + /*/////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ @@ -37,6 +50,21 @@ interface IGrateful { */ error Grateful_VaultTokenNotWhitelisted(); + /** + * @notice Throws if the subscription does not exist + */ + error Grateful_SubscriptionDoesNotExist(); + + /** + * @notice Throws if the subscription is too early for the next payment + */ + error Grateful_TooEarlyForNextPayment(); + + /** + * @notice Throws if the sender is not the owner of the subscription + */ + error Grateful_OnlySenderCanCancelSubscription(); + /*/////////////////////////////////////////////////////////////// VARIABLES //////////////////////////////////////////////////////////////*/ @@ -71,6 +99,12 @@ interface IGrateful { */ function shares(address _merchant, address _token) external view returns (uint256 _shares); + /** + * @notice Returns the number of subscriptions + * @return _subscriptionCount Number of subscriptions + */ + function subscriptionCount() external view returns (uint256 _subscriptionCount); + /*/////////////////////////////////////////////////////////////// LOGIC //////////////////////////////////////////////////////////////*/ @@ -89,12 +123,39 @@ interface IGrateful { */ function pay(address _merchant, address _token, uint256 _amount) external; + /** + * @notice Subscribes to a token for a specific amount and interval + * @param _token Address of the token being subscribed + * @param _receiver Address of the receiver of the payments + * @param _amount Amount of the token to be paid + * @param _interval Interval in seconds between payments + * @return subscriptionId Id of the subscription + */ + function subscribe( + address _token, + address _receiver, + uint256 _amount, + uint256 _interval + ) external returns (uint256 subscriptionId); + + /** + * @notice Processes a subscription + * @param subscriptionId Id of the subscription to be processed + */ + function processSubscription(uint256 subscriptionId) external; + /** * @notice Withdraws funds from the vault * @param _token Address of the token being withdrawn */ function withdraw(address _token) external; + /** + * @notice Cancels a subscription + * @param subscriptionId Id of the subscription to be cancelled + */ + function cancelSubscription(uint256 subscriptionId) external; + /** * @notice Switch the preference of the merchant to yield funds or not */ diff --git a/test/integration/Grateful.t.sol b/test/integration/Grateful.t.sol index 2412ecf..bd25170 100644 --- a/test/integration/Grateful.t.sol +++ b/test/integration/Grateful.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; -import {IntegrationBase} from "test/integration/IntegrationBase.sol"; +import {IGrateful, IntegrationBase} from "test/integration/IntegrationBase.sol"; contract IntegrationGreeter is IntegrationBase { function test_Payment() public { @@ -33,4 +33,25 @@ contract IntegrationGreeter is IntegrationBase { assertGt(_dai.balanceOf(_merchant), _amount); } + + function test_Subscription() public { + vm.startPrank(_daiWhale); + _dai.approve(address(_grateful), _amount * 2); + uint256 subscriptionId = _grateful.subscribe(address(_dai), _merchant, _amount, 30 days); + vm.stopPrank(); + + // When subscription is created, a initial payment is made + assertEq(_dai.balanceOf(_merchant), _amount); + + // Shouldn't be able to process the subscription before 30 days have passed + vm.expectRevert(IGrateful.Grateful_TooEarlyForNextPayment.selector); + _grateful.processSubscription(subscriptionId); + + // Fast forward 30 days + vm.warp(block.timestamp + 30 days); + + _grateful.processSubscription(subscriptionId); + + assertEq(_dai.balanceOf(_merchant), _amount * 2); + } } diff --git a/test/integration/IntegrationBase.sol b/test/integration/IntegrationBase.sol index c863583..b25141f 100644 --- a/test/integration/IntegrationBase.sol +++ b/test/integration/IntegrationBase.sol @@ -26,7 +26,7 @@ contract IntegrationBase is Test { IPool internal _aavePool = IPool(0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2); IGrateful internal _grateful; AaveV3Vault internal _vault; - uint256 internal _amount = 1e25; + uint256 internal _amount = 10 * 10 ** 18; // 10 DAI function setUp() public { vm.startPrank(_owner);