From da25de8b2a54b8d8e671db6372baaf0352bd5b95 Mon Sep 17 00:00:00 2001 From: 0xchin Date: Tue, 1 Oct 2024 16:28:29 -0300 Subject: [PATCH] feat: add extend and cancel subscription --- package.json | 1 + script/Deploy.sol | 16 +++--- src/contracts/Grateful.sol | 77 ++++++++++++++++++++-------- src/interfaces/IGrateful.sol | 66 ++++++++++++++++++------ test/integration/Grateful.t.sol | 64 ++++++++++++++++++++++- test/integration/IntegrationBase.sol | 1 + 6 files changed, 176 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index 3602fa7..eaa7b3d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "coverage": "forge coverage --report summary --report lcov --match-path 'test/unit/*'", "deploy:arbitrum-sepolia": "bash -c 'source .env && forge script Deploy -vvvvv --rpc-url $ARBITRUM_SEPOLIA_RPC --broadcast --chain arbitrum-sepolia --private-key $ARBITRUM_SEPOLIA_DEPLOYER_PK --verify --verifier blockscout --verifier-url https://arbitrum-sepolia.blockscout.com/api/'", "deploy:mainnet": "bash -c 'source .env && forge script Deploy -vvvvv --rpc-url $MAINNET_RPC --broadcast --chain mainnet --private-key $MAINNET_DEPLOYER_PK'", + "deploy:optimism": "bash -c 'source .env && forge script Deploy -vvvvv --rpc-url $OPTIMISM_RPC --broadcast --chain optimism --private-key $OPTIMISM_DEPLOYER_PK --verify'", "deploy:optimism-sepolia": "bash -c 'source .env && forge script Deploy -vvvvv --rpc-url $OPTIMISM_SEPOLIA_RPC --broadcast --chain optimism-sepolia --private-key $OPTIMISM_SEPOLIA_DEPLOYER_PK --verify --verifier blockscout --verifier-url https://optimism-sepolia.blockscout.com/api/'", "deploy:sepolia": "bash -c 'source .env && forge script Deploy -vvvvv --rpc-url $SEPOLIA_RPC --broadcast --chain sepolia --private-key $SEPOLIA_DEPLOYER_PK'", "deploy:v-optimism": "bash -c 'source .env && forge script Deploy -vvvvv --rpc-url $VIRTUAL_OPTIMISM_RPC --broadcast --private-key $OPTIMISM_DEPLOYER_PK --verify'", diff --git a/script/Deploy.sol b/script/Deploy.sol index 4ef68fe..34fdd77 100644 --- a/script/Deploy.sol +++ b/script/Deploy.sol @@ -23,6 +23,10 @@ contract Deploy is Script { address[] memory _tokens = new address[](1); _tokens[0] = address(0x5fd84259d66Cd46123540766Be93DFE6D43130D7); + address[] memory _tokensOptimism = new address[](2); + _tokensOptimism[0] = address(0x94b008aA00579c1307B0EF2c499aD98a8ce58e58); + _tokensOptimism[1] = address(0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1); + address[] memory _tokensOptimismSepolia = new address[](2); _tokensOptimismSepolia[0] = address(0x7F5c764cBc14f9669B88837ca1490cCa17c31607); _tokensOptimismSepolia[1] = address(0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1); @@ -35,6 +39,9 @@ contract Deploy is Script { // Mainnet _deploymentParams[1] = DeploymentParams(_tokens, IPool(0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2), 100); + // Optimism + _deploymentParams[10] = DeploymentParams(_tokensOptimism, IPool(0x794a61358D6845594F94dc1DB02A252b5b4814aD), 100); + // Optimism Sepolia _deploymentParams[11_155_420] = DeploymentParams(_tokens, IPool(0xb50201558B00496A145fE76f7424749556E326D8), 100); @@ -52,15 +59,6 @@ contract Deploy is Script { vm.startBroadcast(); Grateful _grateful = new Grateful(_params.tokens, _params.aavePool, _params.initialFee); - AaveV3Vault _vault = new AaveV3Vault( - ERC20(_params.tokens[0]), - ERC20(0x460b97BD498E1157530AEb3086301d5225b91216), - _params.aavePool, - address(0), - IRewardsController(0x3A203B14CF8749a1e3b7314c6c49004B77Ee667A), - address(_grateful) - ); - _grateful.addVault(_params.tokens[0], address(_vault)); // Deploy TestToken TestToken _testToken = new TestToken("Test Token", "TEST", 18); diff --git a/src/contracts/Grateful.sol b/src/contracts/Grateful.sol index df202ce..55d3d0a 100644 --- a/src/contracts/Grateful.sol +++ b/src/contracts/Grateful.sol @@ -52,7 +52,9 @@ contract Grateful is IGrateful, Ownable2Step { MODIFIERS //////////////////////////////////////////////////////////////*/ - modifier onlyWhenTokenWhitelisted(address _token) { + modifier onlyWhenTokenWhitelisted( + address _token + ) { if (!tokensWhitelisted[_token]) { revert Grateful_TokenNotWhitelisted(); } @@ -93,7 +95,9 @@ contract Grateful is IGrateful, Ownable2Step { } /// @inheritdoc IGrateful - function applyFee(uint256 amount) public view returns (uint256) { + function applyFee( + uint256 amount + ) public view returns (uint256) { uint256 feeAmount = (amount * fee) / 10_000; return amount - feeAmount; } @@ -103,7 +107,9 @@ contract Grateful is IGrateful, Ownable2Step { //////////////////////////////////////////////////////////////*/ /// @inheritdoc IGrateful - function addToken(address _token) external onlyOwner { + function addToken( + address _token + ) external onlyOwner { tokensWhitelisted[_token] = true; IERC20(_token).approve(address(aavePool), type(uint256).max); } @@ -159,6 +165,7 @@ contract Grateful is IGrateful, Ownable2Step { address _token, address _receiver, uint256 _amount, + uint256 _subscriptionPlanId, uint40 _interval, uint16 _paymentsAmount, address[] memory _recipients, @@ -175,6 +182,7 @@ contract Grateful is IGrateful, Ownable2Step { subscription.token = _token; subscription.sender = msg.sender; subscription.amount = _amount; + subscription.subscriptionPlanId = _subscriptionPlanId; subscription.receiver = _receiver; subscription.interval = _interval; subscription.paymentsAmount = _paymentsAmount - 1; @@ -185,6 +193,8 @@ contract Grateful is IGrateful, Ownable2Step { // Precompute paymentId uint256 paymentId = calculateId(msg.sender, _receiver, _token, _amount); + emit SubscriptionCreated(subscriptionId, msg.sender, _receiver, _amount, _subscriptionPlanId); + // Call _processPayment _processPayment(msg.sender, _receiver, _token, _amount, paymentId, subscriptionId, _recipients, _percentages); } @@ -194,14 +204,19 @@ contract Grateful is IGrateful, Ownable2Step { address _token, address _receiver, uint256 _amount, + uint256 _subscriptionPlanId, uint40 _interval, uint16 _paymentsAmount ) external onlyWhenTokenWhitelisted(_token) returns (uint256 subscriptionId) { - return subscribe(_token, _receiver, _amount, _interval, _paymentsAmount, new address[](0), new uint256[](0)); + return subscribe( + _token, _receiver, _amount, _subscriptionPlanId, _interval, _paymentsAmount, new address[](0), new uint256[](0) + ); } /// @inheritdoc IGrateful - function processSubscription(uint256 subscriptionId) external { + function processSubscription( + uint256 subscriptionId + ) external { Subscription storage subscription = subscriptions[subscriptionId]; if (subscription.amount == 0) { @@ -228,6 +243,36 @@ contract Grateful is IGrateful, Ownable2Step { subscription.paymentsAmount--; } + /// @inheritdoc IGrateful + function extendSubscription(uint256 subscriptionId, uint16 additionalPayments) external { + Subscription storage subscription = subscriptions[subscriptionId]; + + if (subscription.amount == 0) { + revert Grateful_SubscriptionDoesNotExist(); + } + if (subscription.sender != msg.sender) { + revert Grateful_OnlySenderCanExtendSubscription(); + } + + subscription.paymentsAmount += additionalPayments; + } + + /// @inheritdoc IGrateful + function cancelSubscription( + uint256 subscriptionId + ) external { + Subscription storage subscription = subscriptions[subscriptionId]; + + if (subscription.amount == 0) { + revert Grateful_SubscriptionDoesNotExist(); + } + if (subscription.sender != msg.sender && subscription.receiver != msg.sender) { + revert Grateful_OnlySenderOrReceiverCanCancelSubscription(); + } + + delete subscriptions[subscriptionId]; + } + /// @inheritdoc IGrateful function createOneTimePayment( address _merchant, @@ -324,7 +369,9 @@ contract Grateful is IGrateful, Ownable2Step { } /// @inheritdoc IGrateful - function withdraw(address _token) external onlyWhenTokenWhitelisted(_token) { + function withdraw( + address _token + ) external onlyWhenTokenWhitelisted(_token) { AaveV3ERC4626 vault = vaults[_token]; if (address(vault) == address(0)) { revert Grateful_VaultNotSet(); @@ -334,27 +381,15 @@ 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]; } /// @inheritdoc IGrateful - function setFee(uint256 _newFee) external onlyOwner { + function setFee( + uint256 _newFee + ) external onlyOwner { fee = _newFee; } diff --git a/src/interfaces/IGrateful.sol b/src/interfaces/IGrateful.sol index 10bb6a4..a6bfc55 100644 --- a/src/interfaces/IGrateful.sol +++ b/src/interfaces/IGrateful.sol @@ -17,6 +17,7 @@ interface IGrateful { address token; address sender; uint256 amount; + uint256 subscriptionPlanId; address receiver; uint40 interval; uint16 paymentsAmount; @@ -66,6 +67,14 @@ interface IGrateful { */ event OneTimePaymentCreated(address indexed merchant, address indexed token, uint256 amount); + event SubscriptionCreated( + uint256 indexed subscriptionId, + address indexed sender, + address indexed receiver, + uint256 amount, + uint256 subscriptionPlanId + ); + /*/////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ @@ -97,8 +106,11 @@ interface IGrateful { /// @notice Thrown when the one-time payment is not found. error Grateful_OneTimeNotFound(); - /// @notice Thrown when only the sender can cancel the subscription. - error Grateful_OnlySenderCanCancelSubscription(); + /// @notice Thrown when only the sender or receiver can cancel the subscription. + error Grateful_OnlySenderOrReceiverCanCancelSubscription(); + + /// @notice Thrown when only the sender can extend subscription. + error Grateful_OnlySenderCanExtendSubscription(); /*/////////////////////////////////////////////////////////////// VARIABLES @@ -110,17 +122,23 @@ interface IGrateful { /// @notice Checks if a token is whitelisted. /// @param _token Address of the token. /// @return True if the token is whitelisted, false otherwise. - function tokensWhitelisted(address _token) external view returns (bool); + function tokensWhitelisted( + address _token + ) external view returns (bool); /// @notice Returns the yielding preference of a merchant. /// @param _merchant Address of the merchant. /// @return True if the merchant prefers yielding funds, false otherwise. - function yieldingFunds(address _merchant) external view returns (bool); + function yieldingFunds( + address _merchant + ) external view returns (bool); /// @notice Returns the vault associated with a token. /// @param _token Address of the token. /// @return Address of the vault contract. - function vaults(address _token) external view returns (AaveV3ERC4626); + function vaults( + address _token + ) external view returns (AaveV3ERC4626); /// @notice Returns the amount of shares for a merchant. /// @param _merchant Address of the merchant. @@ -131,7 +149,9 @@ interface IGrateful { /// @notice Checks if an address is a registered one-time payment. /// @param _address Address to check. /// @return True if it's a registered one-time payment, false otherwise. - function oneTimePayments(address _address) external view returns (bool); + function oneTimePayments( + address _address + ) external view returns (bool); /// @notice Returns the total number of subscriptions. /// @return Number of subscriptions. @@ -149,7 +169,9 @@ interface IGrateful { * @notice Adds a token to the whitelist. * @param _token Address of the token to be added. */ - function addToken(address _token) external; + function addToken( + address _token + ) external; /** * @notice Adds a vault for a specific token. @@ -200,6 +222,7 @@ interface IGrateful { address _token, address _receiver, uint256 _amount, + uint256 _subscriptionPlanId, uint40 _interval, uint16 _paymentsAmount, address[] calldata _recipients, @@ -219,15 +242,24 @@ interface IGrateful { address _token, address _receiver, uint256 _amount, + uint256 _subscriptionPlanId, uint40 _interval, uint16 _paymentsAmount ) external returns (uint256 subscriptionId); + function cancelSubscription( + uint256 subscriptionId + ) external; + + function extendSubscription(uint256 subscriptionId, uint16 additionalPayments) external; + /** * @notice Processes a subscription payment. * @param subscriptionId ID of the subscription. */ - function processSubscription(uint256 subscriptionId) external; + function processSubscription( + uint256 subscriptionId + ) external; /** * @notice Creates a one-time payment. @@ -338,13 +370,9 @@ interface IGrateful { * @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; + function withdraw( + address _token + ) external; /** * @notice Toggles the merchant's preference to yield funds. @@ -371,11 +399,15 @@ interface IGrateful { * @param amount Amount before fee. * @return amountWithFee Amount after fee is applied. */ - function applyFee(uint256 amount) external view returns (uint256 amountWithFee); + function applyFee( + uint256 amount + ) external view returns (uint256 amountWithFee); /** * @notice Sets a new fee. * @param _newFee New fee to be applied (in basis points, 10000 = 100%). */ - function setFee(uint256 _newFee) external; + function setFee( + uint256 _newFee + ) external; } diff --git a/test/integration/Grateful.t.sol b/test/integration/Grateful.t.sol index 319bd8a..4179464 100644 --- a/test/integration/Grateful.t.sol +++ b/test/integration/Grateful.t.sol @@ -41,7 +41,18 @@ contract IntegrationGreeter is IntegrationBase { function test_Subscription() public { vm.startPrank(_usdcWhale); _usdc.approve(address(_grateful), _amount * 2); - uint256 subscriptionId = _grateful.subscribe(address(_usdc), _merchant, _amount, 30 days, 2); + + vm.expectEmit(address(_grateful)); + + emit IGrateful.SubscriptionCreated( + 0, // Because it is the first subscription + _usdcWhale, + _merchant, + _amount, + _subscriptionPlanId + ); + + uint256 subscriptionId = _grateful.subscribe(address(_usdc), _merchant, _amount, _subscriptionPlanId, 30 days, 2); vm.stopPrank(); // When subscription is created, a initial payment is made @@ -65,6 +76,54 @@ contract IntegrationGreeter is IntegrationBase { vm.expectRevert(IGrateful.Grateful_PaymentsAmountReached.selector); _grateful.processSubscription(subscriptionId); + + // Now, the sender extends the subscription + vm.startPrank(_usdcWhale); + + // Approve additional funds for the extended payments + _usdc.approve(address(_grateful), _amount * 2); + + // Extend the subscription by 2 additional payments + _grateful.extendSubscription(subscriptionId, 2); + + vm.stopPrank(); + + // Process the extended payments + + // Fast forward 30 days + vm.warp(block.timestamp + 30 days); + + _grateful.processSubscription(subscriptionId); + + // The merchant should have received three payments in total + assertEq(_usdc.balanceOf(_merchant), _grateful.applyFee(_amount) * 3); + + // Fast forward another 30 days + vm.warp(block.timestamp + 30 days); + + _grateful.processSubscription(subscriptionId); + + // The merchant should have received four payments in total + assertEq(_usdc.balanceOf(_merchant), _grateful.applyFee(_amount) * 4); + + // Should revert if the payments amount has been reached again + + // Fast forward 30 days + vm.warp(block.timestamp + 30 days); + + vm.expectRevert(IGrateful.Grateful_PaymentsAmountReached.selector); + _grateful.processSubscription(subscriptionId); + + // Now, test cancellation by the sender + + // Sender cancels the subscription + vm.prank(_usdcWhale); + _grateful.cancelSubscription(subscriptionId); + + // Attempt to process the subscription after cancellation + vm.warp(block.timestamp + 30 days); + vm.expectRevert(IGrateful.Grateful_SubscriptionDoesNotExist.selector); + _grateful.processSubscription(subscriptionId); } function test_OneTimePayment() public { @@ -207,8 +266,9 @@ contract IntegrationGreeter is IntegrationBase { // 2. Subscribe to a plan vm.startPrank(_usdcWhale); _usdc.approve(address(_grateful), _amount * 2); + uint256 subscriptionId = - _grateful.subscribe(address(_usdc), _merchant, _amount, 30 days, 2, recipients, percentages); + _grateful.subscribe(address(_usdc), _merchant, _amount, _subscriptionPlanId, 30 days, 2, recipients, percentages); vm.stopPrank(); // 3. Calculate expected amounts after fee diff --git a/test/integration/IntegrationBase.sol b/test/integration/IntegrationBase.sol index 0b71bc4..f1977c8 100644 --- a/test/integration/IntegrationBase.sol +++ b/test/integration/IntegrationBase.sol @@ -27,6 +27,7 @@ contract IntegrationBase is Test { IGrateful internal _grateful; AaveV3Vault internal _vault; uint256 internal _amount = 10 * 10 ** 6; // 10 DAI + uint256 internal _subscriptionPlanId = 0; uint256 internal _fee = 100; function setUp() public {