From 009245b9f735771bfb2a2ef119228ea08174c816 Mon Sep 17 00:00:00 2001 From: 0xchin Date: Wed, 18 Sep 2024 17:15:32 -0300 Subject: [PATCH] feat: add payment splitting --- src/contracts/Grateful.sol | 351 ++++++++++++++++++++------- src/contracts/OneTime.sol | 12 +- src/interfaces/IGrateful.sol | 407 ++++++++++++++++++++------------ test/integration/Grateful.t.sol | 120 ++++++++++ 4 files changed, 664 insertions(+), 226 deletions(-) diff --git a/src/contracts/Grateful.sol b/src/contracts/Grateful.sol index cb570f6..df202ce 100644 --- a/src/contracts/Grateful.sol +++ b/src/contracts/Grateful.sol @@ -11,34 +11,47 @@ import {IGrateful} from "interfaces/IGrateful.sol"; import {Bytes32AddressLib} from "solmate/utils/Bytes32AddressLib.sol"; import {AaveV3ERC4626, IPool} from "yield-daddy/aave-v3/AaveV3ERC4626.sol"; +/** + * @title Grateful Contract + * @notice Allows payments in whitelisted tokens with optional yield via AAVE, including payment splitting functionality. + */ contract Grateful is IGrateful, Ownable2Step { using Bytes32AddressLib for bytes32; - // @inheritdoc IGrateful + /*////////////////////////////////////////////////////////////// + STATE VARIABLES + //////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IGrateful IPool public aavePool; - // @inheritdoc IGrateful + /// @inheritdoc IGrateful mapping(address => bool) public tokensWhitelisted; - // @inheritdoc IGrateful + /// @inheritdoc IGrateful mapping(address => bool) public yieldingFunds; - // @inheritdoc IGrateful + /// @inheritdoc IGrateful mapping(address => AaveV3ERC4626) public vaults; - // @inheritdoc IGrateful + /// @inheritdoc IGrateful mapping(address => mapping(address => uint256)) public shares; mapping(uint256 => Subscription) public subscriptions; + /// @inheritdoc IGrateful mapping(address => bool) public oneTimePayments; - // @inheritdoc IGrateful + /// @inheritdoc IGrateful uint256 public subscriptionCount; - // @inheritdoc IGrateful + /// @inheritdoc IGrateful uint256 public fee; + /*////////////////////////////////////////////////////////////// + MODIFIERS + //////////////////////////////////////////////////////////////*/ + modifier onlyWhenTokenWhitelisted(address _token) { if (!tokensWhitelisted[_token]) { revert Grateful_TokenNotWhitelisted(); @@ -46,6 +59,16 @@ contract Grateful is IGrateful, Ownable2Step { _; } + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Initializes the Grateful contract. + * @param _tokens Array of token addresses to whitelist. + * @param _aavePool Address of the Aave V3 pool. + * @param _initialFee Initial fee in basis points (10000 = 100%). + */ constructor(address[] memory _tokens, IPool _aavePool, uint256 _initialFee) Ownable(msg.sender) { aavePool = _aavePool; fee = _initialFee; @@ -55,7 +78,11 @@ contract Grateful is IGrateful, Ownable2Step { } } - // @inheritdoc IGrateful + /*////////////////////////////////////////////////////////////// + PUBLIC FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @inheritdoc IGrateful function calculateId( address _sender, address _merchant, @@ -65,57 +92,122 @@ contract Grateful is IGrateful, Ownable2Step { return uint256(keccak256(abi.encodePacked(_sender, _merchant, _token, _amount, block.timestamp))); } + /// @inheritdoc IGrateful + function applyFee(uint256 amount) public view returns (uint256) { + uint256 feeAmount = (amount * fee) / 10_000; + return amount - feeAmount; + } + + /*////////////////////////////////////////////////////////////// + EXTERNAL FUNCTIONS + //////////////////////////////////////////////////////////////*/ + /// @inheritdoc IGrateful function addToken(address _token) external onlyOwner { tokensWhitelisted[_token] = true; IERC20(_token).approve(address(aavePool), type(uint256).max); } - // @inheritdoc IGrateful - function addVault(address _token, address _vault) external onlyWhenTokenWhitelisted(_token) onlyOwner { + /// @inheritdoc IGrateful + function addVault(address _token, address _vault) external onlyOwner onlyWhenTokenWhitelisted(_token) { vaults[_token] = AaveV3ERC4626(_vault); IERC20(_token).approve(address(_vault), type(uint256).max); } - // @inheritdoc IGrateful - function pay(address _merchant, address _token, uint256 _amount, uint256 _id) public onlyWhenTokenWhitelisted(_token) { - _processPayment(msg.sender, _merchant, _token, _amount, _id, 0); // 0 because no subscription is involved + /// @inheritdoc IGrateful + function pay( + address _merchant, + address _token, + uint256 _amount, + uint256 _id + ) external onlyWhenTokenWhitelisted(_token) { + _processPayment( + msg.sender, + _merchant, + _token, + _amount, + _id, + 0, // 0 because no subscription is involved + new address[](0), + new uint256[](0) + ); } - // @inheritdoc IGrateful + /// @inheritdoc IGrateful + function pay( + address _merchant, + address _token, + uint256 _amount, + uint256 _id, + address[] calldata _recipients, + uint256[] calldata _percentages + ) external onlyWhenTokenWhitelisted(_token) { + _processPayment( + msg.sender, + _merchant, + _token, + _amount, + _id, + 0, // 0 because no subscription is involved + _recipients, + _percentages + ); + } + + /// @inheritdoc IGrateful function subscribe( address _token, address _receiver, uint256 _amount, uint40 _interval, - uint16 _paymentsAmount - ) external onlyWhenTokenWhitelisted(_token) returns (uint256 subscriptionId) { + uint16 _paymentsAmount, + address[] memory _recipients, + uint256[] memory _percentages + ) public onlyWhenTokenWhitelisted(_token) returns (uint256 subscriptionId) { + if (_recipients.length != _percentages.length) { + revert Grateful_MismatchedArrays(); + } + subscriptionId = subscriptionCount++; - subscriptions[subscriptionId] = Subscription({ - token: _token, - sender: msg.sender, - amount: _amount, - receiver: _receiver, - interval: _interval, - paymentsAmount: _paymentsAmount - 1, // Subtract 1 because the first payment is already processed - lastPaymentTime: uint40(block.timestamp) - }); - _processPayment( - msg.sender, _receiver, _token, _amount, calculateId(msg.sender, _receiver, _token, _amount), subscriptionId - ); + // Store the subscription details + Subscription storage subscription = subscriptions[subscriptionId]; + subscription.token = _token; + subscription.sender = msg.sender; + subscription.amount = _amount; + subscription.receiver = _receiver; + subscription.interval = _interval; + subscription.paymentsAmount = _paymentsAmount - 1; + subscription.lastPaymentTime = uint40(block.timestamp); + subscription.recipients = _recipients; + subscription.percentages = _percentages; + + // Precompute paymentId + uint256 paymentId = calculateId(msg.sender, _receiver, _token, _amount); + + // Call _processPayment + _processPayment(msg.sender, _receiver, _token, _amount, paymentId, subscriptionId, _recipients, _percentages); } - // @inheritdoc IGrateful + /// @inheritdoc IGrateful + function subscribe( + address _token, + address _receiver, + uint256 _amount, + uint40 _interval, + uint16 _paymentsAmount + ) external onlyWhenTokenWhitelisted(_token) returns (uint256 subscriptionId) { + return subscribe(_token, _receiver, _amount, _interval, _paymentsAmount, new address[](0), new uint256[](0)); + } + + /// @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 - ) { + if (block.timestamp < subscription.lastPaymentTime + subscription.interval) { revert Grateful_TooEarlyForNextPayment(); } if (subscription.paymentsAmount == 0) { @@ -128,13 +220,68 @@ contract Grateful is IGrateful, Ownable2Step { subscription.token, subscription.amount, calculateId(subscription.sender, subscription.receiver, subscription.token, subscription.amount), - subscriptionId + subscriptionId, + subscription.recipients, + subscription.percentages ); subscription.lastPaymentTime = uint40(block.timestamp); subscription.paymentsAmount--; } - // @inheritdoc IGrateful + /// @inheritdoc IGrateful + function createOneTimePayment( + address _merchant, + address _token, + uint256 _amount, + uint256 _salt, + uint256 _paymentId, + address precomputed, + address[] calldata _recipients, + uint256[] calldata _percentages + ) external onlyWhenTokenWhitelisted(_token) returns (OneTime oneTime) { + oneTimePayments[precomputed] = true; + oneTime = new OneTime{salt: bytes32(_salt)}( + IGrateful(address(this)), IERC20(_token), _merchant, _amount, _paymentId, _recipients, _percentages + ); + emit OneTimePaymentCreated(_merchant, _token, _amount); + } + + /// @inheritdoc IGrateful + function receiveOneTimePayment( + address _merchant, + address _token, + uint256 _paymentId, + uint256 _amount, + address[] calldata _recipients, + uint256[] calldata _percentages + ) external { + if (!oneTimePayments[msg.sender]) { + revert Grateful_OneTimeNotFound(); + } + _processPayment(msg.sender, _merchant, _token, _amount, _paymentId, 0, _recipients, _percentages); + } + + /// @inheritdoc IGrateful + function computeOneTimeAddress( + address _merchant, + address _token, + uint256 _amount, + uint256 _salt, + uint256 _paymentId, + address[] calldata _recipients, + uint256[] calldata _percentages + ) external view returns (OneTime oneTime) { + bytes memory bytecode = abi.encodePacked( + type(OneTime).creationCode, + abi.encode(address(this), _token, _merchant, _amount, _paymentId, _recipients, _percentages) + ); + bytes32 bytecodeHash = keccak256(bytecode); + bytes32 addressHash = keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(_salt), bytecodeHash)); + address computedAddress = address(uint160(uint256(addressHash))); + return OneTime(computedAddress); + } + + /// @inheritdoc IGrateful function createOneTimePayment( address _merchant, address _token, @@ -144,18 +291,21 @@ contract Grateful is IGrateful, Ownable2Step { address precomputed ) external onlyWhenTokenWhitelisted(_token) returns (OneTime oneTime) { oneTimePayments[precomputed] = true; - oneTime = - new OneTime{salt: bytes32(_salt)}(IGrateful(address(this)), IERC20(_token), _merchant, _amount, _paymentId); + oneTime = new OneTime{salt: bytes32(_salt)}( + IGrateful(address(this)), IERC20(_token), _merchant, _amount, _paymentId, new address[](0), new uint256[](0) + ); emit OneTimePaymentCreated(_merchant, _token, _amount); } + /// @inheritdoc IGrateful function receiveOneTimePayment(address _merchant, address _token, uint256 _paymentId, uint256 _amount) external { if (!oneTimePayments[msg.sender]) { revert Grateful_OneTimeNotFound(); } - _processPayment(msg.sender, _merchant, _token, _amount, _paymentId, 0); + _processPayment(msg.sender, _merchant, _token, _amount, _paymentId, 0, new address[](0), new uint256[](0)); } + /// @inheritdoc IGrateful function computeOneTimeAddress( address _merchant, address _token, @@ -163,23 +313,17 @@ contract Grateful is IGrateful, Ownable2Step { uint256 _salt, uint256 _paymentId ) external view returns (OneTime oneTime) { - return OneTime( - keccak256( - abi.encodePacked( - bytes1(0xFF), - address(this), - bytes32(_salt), - keccak256( - abi.encodePacked( - type(OneTime).creationCode, abi.encode(address(this), _token, _merchant, _amount, _paymentId) - ) - ) - ) - ).fromLast20Bytes() + bytes memory bytecode = abi.encodePacked( + type(OneTime).creationCode, + abi.encode(address(this), _token, _merchant, _amount, _paymentId, new address[](0), new uint256[](0)) ); + bytes32 bytecodeHash = keccak256(bytecode); + bytes32 addressHash = keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(_salt), bytecodeHash)); + address computedAddress = address(uint160(uint256(addressHash))); + return OneTime(computedAddress); } - // @inheritdoc IGrateful + /// @inheritdoc IGrateful function withdraw(address _token) external onlyWhenTokenWhitelisted(_token) { AaveV3ERC4626 vault = vaults[_token]; if (address(vault) == address(0)) { @@ -190,7 +334,7 @@ contract Grateful is IGrateful, Ownable2Step { vault.redeem(_shares, msg.sender, address(this)); } - // @inheritdoc IGrateful + /// @inheritdoc IGrateful function cancelSubscription(uint256 subscriptionId) external { Subscription storage subscription = subscriptions[subscriptionId]; @@ -204,24 +348,30 @@ contract Grateful is IGrateful, Ownable2Step { delete subscriptions[subscriptionId]; } - // @inheritdoc IGrateful + /// @inheritdoc IGrateful function switchYieldingFunds() external { yieldingFunds[msg.sender] = !yieldingFunds[msg.sender]; } - function applyFee(uint256 amount) public view returns (uint256) { - uint256 feeAmount = (amount * fee) / 10_000; - return amount - feeAmount; + /// @inheritdoc IGrateful + function setFee(uint256 _newFee) external onlyOwner { + fee = _newFee; } + /*////////////////////////////////////////////////////////////// + INTERNAL FUNCTIONS + //////////////////////////////////////////////////////////////*/ + /** - * @notice Processes a payment - * @param _sender Address of the sender - * @param _merchant Address of the merchant - * @param _token Address of the token - * @param _amount Amount of the token - * @param _paymentId Id of the payment - * @param _subscriptionId Id of the subscription, 0 if it is one-time + * @notice Processes a payment. + * @param _sender Address of the sender. + * @param _merchant Address of the merchant. + * @param _token Address of the token. + * @param _amount Amount of the token. + * @param _paymentId ID of the payment. + * @param _subscriptionId ID of the subscription, 0 if it is one-time. + * @param _recipients List of recipients for payment splitting. + * @param _percentages Corresponding percentages for each recipient. */ function _processPayment( address _sender, @@ -229,29 +379,72 @@ contract Grateful is IGrateful, Ownable2Step { address _token, uint256 _amount, uint256 _paymentId, - uint256 _subscriptionId + uint256 _subscriptionId, + address[] memory _recipients, + uint256[] memory _percentages ) internal { + // Transfer the full amount from the sender to this contract + if (!IERC20(_token).transferFrom(_sender, address(this), _amount)) { + revert Grateful_TransferFailed(); + } + + // Apply the fee uint256 amountWithFee = applyFee(_amount); - if (yieldingFunds[_merchant]) { - AaveV3ERC4626 vault = vaults[_token]; - if (address(vault) == address(0)) { - revert Grateful_VaultNotSet(); + + // Transfer fee to owner + if (!IERC20(_token).transfer(owner(), _amount - amountWithFee)) { + revert Grateful_TransferFailed(); + } + + // If payment splitting is requested + if (_recipients.length > 0) { + if (_recipients.length != _percentages.length) { + revert Grateful_MismatchedArrays(); + } + uint256 totalPercentage = 0; + for (uint256 i = 0; i < _percentages.length; i++) { + totalPercentage += _percentages[i]; + } + if (totalPercentage != 10_000) { + revert Grateful_InvalidTotalPercentage(); + } + + // Distribute amountWithFee among recipients + for (uint256 i = 0; i < _recipients.length; i++) { + address recipient = _recipients[i]; + uint256 recipientShare = (amountWithFee * _percentages[i]) / 10_000; + + if (yieldingFunds[recipient]) { + AaveV3ERC4626 vault = vaults[_token]; + if (address(vault) == address(0)) { + revert Grateful_VaultNotSet(); + } + uint256 _shares = vault.deposit(recipientShare, address(this)); + shares[recipient][_token] += _shares; + } else { + // Transfer tokens to recipient + if (!IERC20(_token).transfer(recipient, recipientShare)) { + revert Grateful_TransferFailed(); + } + } } - IERC20(_token).transferFrom(_sender, address(this), amountWithFee); - uint256 _shares = vault.deposit(amountWithFee, address(this)); - shares[_merchant][_token] += _shares; } else { - if (!IERC20(_token).transferFrom(_sender, _merchant, amountWithFee)) { - revert Grateful_TransferFailed(); + // Proceed as before, paying the merchant + if (yieldingFunds[_merchant]) { + AaveV3ERC4626 vault = vaults[_token]; + if (address(vault) == address(0)) { + revert Grateful_VaultNotSet(); + } + uint256 _shares = vault.deposit(amountWithFee, address(this)); + shares[_merchant][_token] += _shares; + } else { + // Transfer tokens to merchant + if (!IERC20(_token).transfer(_merchant, amountWithFee)) { + revert Grateful_TransferFailed(); + } } } - IERC20(_token).transferFrom(_sender, owner(), _amount - amountWithFee); - emit PaymentProcessed(_sender, _merchant, _token, _amount, yieldingFunds[_merchant], _paymentId, _subscriptionId); } - - function setFee(uint256 _newFee) external onlyOwner { - fee = _newFee; - } } diff --git a/src/contracts/OneTime.sol b/src/contracts/OneTime.sol index 2fdf116..8e1faa0 100644 --- a/src/contracts/OneTime.sol +++ b/src/contracts/OneTime.sol @@ -5,8 +5,16 @@ import {IERC20} from "forge-std/interfaces/IERC20.sol"; import {IGrateful} from "interfaces/IGrateful.sol"; contract OneTime { - constructor(IGrateful _grateful, IERC20 _token, address _merchant, uint256 _amount, uint256 _paymentId) { + constructor( + IGrateful _grateful, + IERC20 _token, + address _merchant, + uint256 _amount, + uint256 _paymentId, + address[] memory _recipients, + uint256[] memory _percentages + ) { _token.approve(address(_grateful), _amount); - _grateful.receiveOneTimePayment(_merchant, address(_token), _paymentId, _amount); + _grateful.receiveOneTimePayment(_merchant, address(_token), _paymentId, _amount, _recipients, _percentages); } } diff --git a/src/interfaces/IGrateful.sol b/src/interfaces/IGrateful.sol index 8108746..10bb6a4 100644 --- a/src/interfaces/IGrateful.sol +++ b/src/interfaces/IGrateful.sol @@ -10,7 +10,7 @@ import {AaveV3ERC4626, IPool} from "yield-daddy/aave-v3/AaveV3ERC4626.sol"; */ interface IGrateful { /*////////////////////////////////////////////////////////////// - / STRUCTS + STRUCTS //////////////////////////////////////////////////////////////*/ struct Subscription { @@ -19,155 +19,201 @@ interface IGrateful { uint256 amount; address receiver; uint40 interval; - uint40 lastPaymentTime; uint16 paymentsAmount; + uint40 lastPaymentTime; + address[] recipients; + uint256[] percentages; + } + + struct PaymentDetails { + address merchant; + address token; + uint256 amount; + uint256 id; + address[] recipients; + uint256[] percentages; } /*/////////////////////////////////////////////////////////////// - EVENTS - //////////////////////////////////////////////////////////////*/ + EVENTS + //////////////////////////////////////////////////////////////*/ /** - * @notice Emitted when a payment is processed - * @param sender Address of the sender - * @param merchant Address of the merchant - * @param token Address of the token - * @param amount Amount of the token - * @param yielded Indicates if the payment was yielded + * @notice Emitted when a payment is processed. + * @param sender Address of the sender. + * @param merchant Address of the merchant. + * @param token Address of the token. + * @param amount Amount of the token. + * @param yielded Indicates if the payment was yielded. + * @param paymentId ID of the payment. + * @param subscriptionId ID of the subscription. */ event PaymentProcessed( - address sender, - address merchant, - address token, + address indexed sender, + address indexed merchant, + address indexed token, uint256 amount, bool yielded, uint256 paymentId, uint256 subscriptionId ); - event OneTimePaymentCreated(address merchant, address token, uint256 amount); + /** + * @notice Emitted when a one-time payment is created. + * @param merchant Address of the merchant. + * @param token Address of the token. + * @param amount Amount of the token. + */ + event OneTimePaymentCreated(address indexed merchant, address indexed token, uint256 amount); /*/////////////////////////////////////////////////////////////// - ERRORS - //////////////////////////////////////////////////////////////*/ + ERRORS + //////////////////////////////////////////////////////////////*/ - /** - * @notice Throws if the token is not whitelisted - */ + /// @notice Thrown when the token is not whitelisted. error Grateful_TokenNotWhitelisted(); - /** - * @notice Throws if the transfer failed - */ - error Grateful_TransferFailed(); + /// @notice Thrown when array lengths mismatch. + error Grateful_MismatchedArrays(); - /** - * @notice Throws if the vault for a token is not set - */ + /// @notice Thrown when the total percentage is invalid. + error Grateful_InvalidTotalPercentage(); + + /// @notice Thrown when the vault for a token is not set. error Grateful_VaultNotSet(); - /** - * @notice Throws if the token is not whitelisted when adding a vault - */ - error Grateful_VaultTokenNotWhitelisted(); + /// @notice Thrown when a token transfer fails. + error Grateful_TransferFailed(); - /** - * @notice Throws if the subscription does not exist - */ + /// @notice Thrown when the subscription does not exist. error Grateful_SubscriptionDoesNotExist(); - /** - * @notice Throws if the subscription is too early for the next payment - */ + /// @notice Thrown when it's too early for the next subscription payment. error Grateful_TooEarlyForNextPayment(); - /** - * @notice Throws if the sender is not the owner of the subscription - */ - error Grateful_OnlySenderCanCancelSubscription(); - - /** - * @notice Throws if the payments amount has been reached - */ + /// @notice Thrown when the maximum number of payments has been reached. error Grateful_PaymentsAmountReached(); - /** - * @notice Throws if the one-time payment is not found - */ + /// @notice Thrown when the one-time payment is not found. error Grateful_OneTimeNotFound(); - /*/////////////////////////////////////////////////////////////// - VARIABLES - //////////////////////////////////////////////////////////////*/ - - /** - * @notice Aave pool for yielding merchants funds - * @return _aavePool Aave pool - */ - function aavePool() external view returns (IPool _aavePool); + /// @notice Thrown when only the sender can cancel the subscription. + error Grateful_OnlySenderCanCancelSubscription(); - /** - * @notice Whitelist of tokens that can be used to pay - * @return _isWhitelisted True if the token is whitelisted - */ - function tokensWhitelisted(address _token) external view returns (bool _isWhitelisted); + /*/////////////////////////////////////////////////////////////// + VARIABLES + //////////////////////////////////////////////////////////////*/ - /** - * @notice Returns the status of the merchant - * @return _isYieldingFunds True if the merchant is yielding funds - */ - function yieldingFunds(address _merchant) external view returns (bool _isYieldingFunds); + /// @notice Aave pool for yielding merchants' funds. + function aavePool() external view returns (IPool); + + /// @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); + + /// @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); + + /// @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); + + /// @notice Returns the amount of shares for a merchant. + /// @param _merchant Address of the merchant. + /// @param _token Address of the token. + /// @return Amount of shares. + function shares(address _merchant, address _token) external view returns (uint256); + + /// @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); + + /// @notice Returns the total number of subscriptions. + /// @return Number of subscriptions. + function subscriptionCount() external view returns (uint256); + + /// @notice Returns the fee applied to the payments. + /// @return Fee in basis points (10000 = 100%). + function fee() external view returns (uint256); - /** - * @notice Returns the vault associated with a token - * @return _vault Address of the vault contract - */ - function vaults(address _token) external view returns (AaveV3ERC4626 _vault); + /*/////////////////////////////////////////////////////////////// + LOGIC + //////////////////////////////////////////////////////////////*/ /** - * @notice Returns the amount of shares for a merchant - * @return _shares Amount of shares + * @notice Adds a token to the whitelist. + * @param _token Address of the token to be added. */ - function shares(address _merchant, address _token) external view returns (uint256 _shares); + function addToken(address _token) external; /** - * @notice Returns the number of subscriptions - * @return _subscriptionCount Number of subscriptions + * @notice Adds a vault for a specific token. + * @param _token Address of the token. + * @param _vault Address of the vault contract. */ - function subscriptionCount() external view returns (uint256 _subscriptionCount); + function addVault(address _token, address _vault) external; /** - * @notice Returns the fee applied to the payments - * @return _fee Fee applied to the payments + * @notice Makes a payment to a merchant. + * @param _merchant Address of the merchant receiving payment. + * @param _token Address of the token used for payment. + * @param _amount Amount of the token to be paid. + * @param _id ID of the payment. */ - function fee() external view returns (uint256); - - /*/////////////////////////////////////////////////////////////// - LOGIC - //////////////////////////////////////////////////////////////*/ + function pay(address _merchant, address _token, uint256 _amount, uint256 _id) external; /** - * @notice Adds a token to the whitelist - * @param _token Address of the token to be added to the whitelist + * @notice Makes a payment to a merchant. + * @param _merchant Address of the merchant receiving payment. + * @param _token Address of the token used for payment. + * @param _amount Amount of the token to be paid. + * @param _id ID of the payment. + * @param _recipients List of recipients for payment splitting. + * @param _percentages Corresponding percentages for each recipient (in basis points, 10000 = 100%). */ - function addToken(address _token) external; + function pay( + address _merchant, + address _token, + uint256 _amount, + uint256 _id, + address[] calldata _recipients, + uint256[] calldata _percentages + ) external; /** - * @notice Makes a payment to a merchant - * @param _merchant Address of the merchant receiving payment - * @param _token Address of the token being used for payment - * @param _amount Amount of the token to be paid - * @param _id Id of the payment + * @notice Subscribes to a service with recurring payments. + * @param _token Address of the token. + * @param _receiver Address of the payment receiver. + * @param _amount Amount per payment. + * @param _interval Interval in seconds between payments. + * @param _paymentsAmount Total number of payments. + * @param _recipients List of recipients for payment splitting. + * @param _percentages Corresponding percentages for each recipient. + * @return subscriptionId ID of the created subscription. */ - function pay(address _merchant, address _token, uint256 _amount, uint256 _id) external; + function subscribe( + address _token, + address _receiver, + uint256 _amount, + uint40 _interval, + uint16 _paymentsAmount, + address[] calldata _recipients, + uint256[] calldata _percentages + ) external returns (uint256 subscriptionId); /** - * @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 + * @notice Subscribes to a service with recurring payments. + * @param _token Address of the token. + * @param _receiver Address of the payment receiver. + * @param _amount Amount per payment. + * @param _interval Interval in seconds between payments. + * @param _paymentsAmount Total number of payments. + * @return subscriptionId ID of the created subscription. */ function subscribe( address _token, @@ -177,63 +223,109 @@ interface IGrateful { uint16 _paymentsAmount ) external returns (uint256 subscriptionId); - /// @notice Creates a one-time payment - /// @param _merchant Address of the merchant - /// @param _token Address of the token - /// @param _amount Amount of the token - /// @return oneTime Contract of the one-time payment + /** + * @notice Processes a subscription payment. + * @param subscriptionId ID of the subscription. + */ + function processSubscription(uint256 subscriptionId) external; + + /** + * @notice Creates a one-time payment. + * @param _merchant Address of the merchant. + * @param _token Address of the token. + * @param _amount Amount of the token. + * @param _salt Salt used for address computation. + * @param _paymentId ID of the payment. + * @param precomputed Precomputed address of the OneTime contract. + * @return oneTime Address of the created OneTime contract. + */ function createOneTimePayment( address _merchant, address _token, uint256 _amount, uint256 _salt, uint256 _paymentId, - address precomputed + address precomputed, + address[] calldata _recipients, + uint256[] calldata _percentages ) external returns (OneTime oneTime); - /// @notice Receives a one-time payment - /// @param _merchant Address of the merchant - /// @param _token Address of the token - /// @param _paymentId Id of the payment - /// @param _amount Amount of the token - function receiveOneTimePayment(address _merchant, address _token, uint256 _paymentId, uint256 _amount) external; - /** - * @notice Processes a subscription - * @param subscriptionId Id of the subscription to be processed + * @notice Creates a one-time payment without recipients and percentages. + * @param _merchant Address of the merchant. + * @param _token Address of the token. + * @param _amount Amount of the token. + * @param _salt Salt used for address computation. + * @param _paymentId ID of the payment. + * @param precomputed Precomputed address of the OneTime contract. + * @return oneTime Address of the created OneTime contract. */ - function processSubscription(uint256 subscriptionId) external; + function createOneTimePayment( + address _merchant, + address _token, + uint256 _amount, + uint256 _salt, + uint256 _paymentId, + address precomputed + ) external returns (OneTime oneTime); /** - * @notice Withdraws funds from the vault - * @param _token Address of the token being withdrawn + * @notice Receives a one-time payment. + * @param _merchant Address of the merchant. + * @param _token Address of the token. + * @param _paymentId ID of the payment. + * @param _amount Amount of the token. + * @param _recipients List of recipients for payment splitting. + * @param _percentages Corresponding percentages for each recipient. */ - function withdraw(address _token) external; + function receiveOneTimePayment( + address _merchant, + address _token, + uint256 _paymentId, + uint256 _amount, + address[] calldata _recipients, + uint256[] calldata _percentages + ) external; /** - * @notice Cancels a subscription - * @param subscriptionId Id of the subscription to be cancelled + * @notice Receives a one-time payment without recipients and percentages. + * @param _merchant Address of the merchant. + * @param _token Address of the token. + * @param _paymentId ID of the payment. + * @param _amount Amount of the token. */ - function cancelSubscription(uint256 subscriptionId) external; + function receiveOneTimePayment(address _merchant, address _token, uint256 _paymentId, uint256 _amount) external; /** - * @notice Switch the preference of the merchant to yield funds or not + * @notice Computes the address of a one-time payment contract. + * @param _merchant Address of the merchant. + * @param _token Address of the token. + * @param _amount Amount of the token. + * @param _salt Salt used for address computation. + * @param _paymentId ID of the payment. + * @param _recipients List of recipients for payment splitting. + * @param _percentages Corresponding percentages for each recipient. + * @return oneTime Address of the computed OneTime contract. */ - function switchYieldingFunds() external; + function computeOneTimeAddress( + address _merchant, + address _token, + uint256 _amount, + uint256 _salt, + uint256 _paymentId, + address[] calldata _recipients, + uint256[] calldata _percentages + ) external view returns (OneTime oneTime); /** - * @notice Adds a vault for a specific token - * @param _token Address of the token for which the vault is being set - * @param _vault Address of the vault contract + * @notice Computes the address of a one-time payment contract without recipients and percentages. + * @param _merchant Address of the merchant. + * @param _token Address of the token. + * @param _amount Amount of the token. + * @param _salt Salt used for address computation. + * @param _paymentId ID of the payment. + * @return oneTime Address of the computed OneTime contract. */ - function addVault(address _token, address _vault) external; - - /// @notice Computes the address of a one-time payment - /// @param _merchant Address of the merchant - /// @param _token Address of the token - /// @param _amount Amount of the token - /// @param _salt Salt used to compute the address - /// @return oneTime Address of the one-time payment function computeOneTimeAddress( address _merchant, address _token, @@ -243,22 +335,47 @@ interface IGrateful { ) external view returns (OneTime oneTime); /** - * @notice Calculates the id of a payment - * @param _sender Address of the sender - * @param _merchant Address of the merchant - * @param _token Address of the token - * @param _amount Amount of the token - * @return id Id of the payment + * @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 Toggles the merchant's preference to yield funds. + */ + function switchYieldingFunds() external; + + /** + * @notice Calculates the ID of a payment. + * @param _sender Address of the sender. + * @param _merchant Address of the merchant. + * @param _token Address of the token. + * @param _amount Amount of the token. + * @return id ID of the payment. */ function calculateId( address _sender, address _merchant, address _token, uint256 _amount - ) external view returns (uint256); + ) external view returns (uint256 id); - /// @notice Applies the fee to an amount - /// @param amount Amount of the token - /// @return amountWithFee Amount of the token with the fee applied - function applyFee(uint256 amount) external view returns (uint256); + /** + * @notice Applies the fee to an amount. + * @param amount Amount before fee. + * @return amountWithFee Amount after fee is applied. + */ + 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; } diff --git a/test/integration/Grateful.t.sol b/test/integration/Grateful.t.sol index d796759..319bd8a 100644 --- a/test/integration/Grateful.t.sol +++ b/test/integration/Grateful.t.sol @@ -118,4 +118,124 @@ contract IntegrationGreeter is IntegrationBase { uint256 feeAmount = _amount - _grateful.applyFee(_amount); assertEq(_usdc.balanceOf(_owner), feeAmount); } + + function test_PaymentSplit() public { + // 1. Define recipients and percentages + address[] memory recipients = new address[](2); + recipients[0] = makeAddr("recipient1"); // Recipient 1 + recipients[1] = makeAddr("recipient2"); // Recipient 2 + + uint256[] memory percentages = new uint256[](2); + percentages[0] = 7000; // 70% + percentages[1] = 3000; // 30% + + // 2. Approve Grateful contract to spend USDC + vm.startPrank(_usdcWhale); + _usdc.approve(address(_grateful), _amount); + + // 3. Make a payment with splitting + uint256 paymentId = _grateful.calculateId(_usdcWhale, _merchant, address(_usdc), _amount); + + _grateful.pay(_merchant, address(_usdc), _amount, paymentId, recipients, percentages); + vm.stopPrank(); + + // 4. Calculate expected amounts after fee + uint256 amountAfterFee = _grateful.applyFee(_amount); + uint256 expectedAmountRecipient0 = (amountAfterFee * percentages[0]) / 10_000; + uint256 expectedAmountRecipient1 = (amountAfterFee * percentages[1]) / 10_000; + + // 5. Check balances of recipients + assertEq(_usdc.balanceOf(recipients[0]), expectedAmountRecipient0); + assertEq(_usdc.balanceOf(recipients[1]), expectedAmountRecipient1); + + // Ensure the merchant did not receive any funds directly + assertEq(_usdc.balanceOf(_merchant), 0); + } + + function test_OneTimePaymentSplit() public { + // 1. Define recipients and percentages + address[] memory recipients = new address[](2); + recipients[0] = makeAddr("recipient1"); // Recipient 1 + recipients[1] = makeAddr("recipient2"); // Recipient 2 + + uint256[] memory percentages = new uint256[](2); + percentages[0] = 7000; // 70% + percentages[1] = 3000; // 30% + + // 2. Calculate payment id + uint256 paymentId = _grateful.calculateId(_usdcWhale, _merchant, address(_usdc), _amount); + + // 3. Precompute address + address precomputed = address( + _grateful.computeOneTimeAddress(_merchant, address(_usdc), _amount, 4, paymentId, recipients, percentages) + ); + + // 4. Once the payment address is precomputed, the client sends the payment + vm.prank(_usdcWhale); + _usdc.transfer(precomputed, _amount); + + // 5. Merchant calls api to make one time payment to his address + vm.prank(_gratefulAutomation); + _grateful.createOneTimePayment( + _merchant, address(_usdc), _amount, 4, paymentId, precomputed, recipients, percentages + ); + + // 6. Calculate expected amounts after fee + uint256 amountAfterFee = _grateful.applyFee(_amount); + uint256 expectedAmountRecipient0 = (amountAfterFee * percentages[0]) / 10_000; + uint256 expectedAmountRecipient1 = (amountAfterFee * percentages[1]) / 10_000; + + // 7. Check balances of recipients + assertEq(_usdc.balanceOf(recipients[0]), expectedAmountRecipient0); + assertEq(_usdc.balanceOf(recipients[1]), expectedAmountRecipient1); + + // Ensure owner received the fee + uint256 feeAmount = _amount - amountAfterFee; + assertEq(_usdc.balanceOf(_owner), feeAmount); + } + + function test_SubscriptionSplit() public { + // 1. Define recipients and percentages + address[] memory recipients = new address[](2); + recipients[0] = makeAddr("recipient1"); // Recipient 1 + recipients[1] = makeAddr("recipient2"); // Recipient 2 + + uint256[] memory percentages = new uint256[](2); + percentages[0] = 7000; // 70% + percentages[1] = 3000; // 30% + + // 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); + vm.stopPrank(); + + // 3. Calculate expected amounts after fee + uint256 amountAfterFee = _grateful.applyFee(_amount); + uint256 expectedAmountRecipient0 = (amountAfterFee * percentages[0]) / 10_000; + uint256 expectedAmountRecipient1 = (amountAfterFee * percentages[1]) / 10_000; + + // 4. Check balances of recipients + assertEq(_usdc.balanceOf(recipients[0]), expectedAmountRecipient0); + assertEq(_usdc.balanceOf(recipients[1]), expectedAmountRecipient1); + + // Ensure owner received the fee + uint256 feeAmount = _amount - amountAfterFee; + assertEq(_usdc.balanceOf(_owner), feeAmount); + + // 5. Fast forward time + vm.warp(block.timestamp + 30 days); + + // 6. Process subscription + vm.prank(_gratefulAutomation); + _grateful.processSubscription(subscriptionId); + + // 7. Check balances of recipients + assertEq(_usdc.balanceOf(recipients[0]), expectedAmountRecipient0 * 2); + assertEq(_usdc.balanceOf(recipients[1]), expectedAmountRecipient1 * 2); + + // Ensure owner received the fee + assertEq(_usdc.balanceOf(_owner), feeAmount * 2); + } }