diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 762a296..130a421 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,11 +29,6 @@ jobs: run: | forge --version - - name: Run Forge fmt - run: | - forge fmt --check - id: fmt - - name: Run Forge build run: | forge build --sizes diff --git a/.gitmodules b/.gitmodules index c59f396..e80ffd8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std -[submodule "lib/solmate"] - path = lib/solmate - url = https://github.com/transmissions11/solmate +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/openzeppelin/openzeppelin-contracts diff --git a/Makefile b/Makefile index 6dfa576..215baac 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ clean :; forge clean remove :; rm -rf .gitmodules && rm -rf .git/modules/* && rm -rf lib && touch .gitmodules && git add . && git commit -m "modules" -install :; forge install foundry-rs/forge-std --no-commit && forge install transmissions11/solmate --no-commit +install :; forge install foundry-rs/forge-std --no-commit && forge install openzeppelin/openzeppelin-contracts --no-commit update :; forge update @@ -20,4 +20,6 @@ format-sol :; forge fmt anvil :; anvil -m 'test test test test test test test test test test test junk' --steps-tracing --block-time 1 -precommit :; forge fmt && git add . \ No newline at end of file +precommit :; forge fmt && git add . + +deploy-local :; forge script script/DeployPayStreams.s.sol --rpc-url 127.0.0.1:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --broadcast \ No newline at end of file diff --git a/README.md b/README.md index 9265b45..5895df0 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,149 @@ -## Foundry + -**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![MIT License][license-shield]][license-url] -Foundry consists of: + +
+
+ -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. +

PayStreams

-## Documentation +

+ PayStreams is a payment streaming service leveraging PyUSD, supercharged with hooks +
+ Report Bug + ยท + Request Feature +

+
-https://book.getfoundry.sh/ + +
+ Table of Contents +
    +
  1. + About The Project + +
  2. +
  3. + Getting Started + +
  4. +
  5. Roadmap
  6. +
  7. Contributing
  8. +
  9. License
  10. +
  11. Contact
  12. +
+
-## Usage + -### Build +## About The Project -```shell -$ forge build -``` +PayStreams is a payment streaming service which allows anyone to open token streams directed to any recipient. The recipient can collect the streamed funds over time, or when the stream ends. The stream creator can update, or cancel the stream as well. Streams can be one-time, or recurring. -### Test +PayStreams leverages the stability of PYUSD to avoid the volatilities of crypto coins, and ensures that the streaming service is reliable and pays out the correct value at any time. Additionally, we introduce hooks, which are functions with custom logic that can be invoked at various points during the stream's lifespan. To opt into hooks, both the streamer and the recipient can set custom vaults with correct functions and hook configuration, and these functions will be invoked by the `PayStreams` contract when certain events occur. Hooks open up a wide array of use cases and customizations, enabling developers to extend the functionality of streams. You can find some hook examples in the `./src/exampleHooks/` folder. -```shell -$ forge test -``` +P.S. This project was built for the BuildOn hackathon on Devfolio. -### Format +### Built With -```shell -$ forge fmt -``` +- Solidity +- Foundry -### Gas Snapshots + -```shell -$ forge snapshot -``` +## Getting Started -### Anvil +### Prerequisites -```shell -$ anvil -``` +Make sure you have git, rust, and foundry installed and configured on your system. -### Deploy +### Installation + +Clone the repo, ```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +git clone https://github.com/mgnfy-view/pay-streams.git ``` -### Cast +cd into the repo, and install the necessary dependencies ```shell -$ cast +cd pay-streams +forge build ``` -### Help +Run tests by executing ```shell -$ forge --help -$ anvil --help -$ cast --help +forge test ``` + +That's it, you are good to go now! + + + +## Roadmap + +- [x] Smart contract development +- [ ] Unit tests +- [x] Write a good README.md + +See the [open issues](https://github.com/mgnfy-view/pay-streams/issues) for a full list of proposed features (and known issues). + + + +## Contributing + +Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". +Don't forget to give the project a star! Thanks again! + +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + + + +## License + +Distributed under the MIT License. See `LICENSE.txt` for more information. + + + +## Reach Out + +Here's a gateway to all my socials, don't forget to hit me up! + +[![Linktree](https://img.shields.io/badge/linktree-1de9b6?style=for-the-badge&logo=linktree&logoColor=white)][linktree-url] + + + + +[contributors-shield]: https://img.shields.io/github/contributors/mgnfy-view/pay-streams.svg?style=for-the-badge +[contributors-url]: https://github.com/mgnfy-view/pay-streams/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/mgnfy-view/pay-streams.svg?style=for-the-badge +[forks-url]: https://github.com/mgnfy-view/pay-streams/network/members +[stars-shield]: https://img.shields.io/github/stars/mgnfy-view/pay-streams.svg?style=for-the-badge +[stars-url]: https://github.com/mgnfy-view/pay-streams/stargazers +[issues-shield]: https://img.shields.io/github/issues/mgnfy-view/pay-streams.svg?style=for-the-badge +[issues-url]: https://github.com/mgnfy-view/pay-streams/issues +[license-shield]: https://img.shields.io/github/license/mgnfy-view/pay-streams.svg?style=for-the-badge +[license-url]: https://github.com/mgnfy-view/pay-streams/blob/master/LICENSE.txt +[linktree-url]: https://linktr.ee/mgnfy.view diff --git a/foundry.toml b/foundry.toml index 40db18f..a3cdd5e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,6 +2,7 @@ src = "src" out = "out" libs = ["lib"] +remappings = ["@openzeppelin=lib/openzeppelin-contracts/contracts"] [fmt] sort_imports = true diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..448efee --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit 448efeea6640bbbc09373f03fbc9c88e280147ba diff --git a/lib/solmate b/lib/solmate deleted file mode 160000 index 97bdb20..0000000 --- a/lib/solmate +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 97bdb2003b70382996a79a406813f76417b1cf90 diff --git a/script/DeployPayStreams.s.sol b/script/DeployPayStreams.s.sol new file mode 100644 index 0000000..8171563 --- /dev/null +++ b/script/DeployPayStreams.s.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { Script } from "forge-std/Script.sol"; + +import { PayStreams } from "../src/PayStreams.sol"; + +contract DeployPayStreams is Script { + uint16 feeInBasisPoints = 10; + address public constant PYUSD = 0xCaC524BcA292aaade2DF8A05cC58F0a65B1B3bB9; + + function run() external returns (address) { + vm.startBroadcast(); + PayStreams stream = new PayStreams(feeInBasisPoints); + stream.setToken(PYUSD, true); + vm.stopBroadcast(); + + return address(stream); + } +} diff --git a/src/PayStreams.sol b/src/PayStreams.sol new file mode 100644 index 0000000..c898b04 --- /dev/null +++ b/src/PayStreams.sol @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol"; + +import { Ownable } from "@openzeppelin/access/Ownable.sol"; +import { SafeERC20 } from "@openzeppelin/token/ERC20/utils/SafeERC20.sol"; + +import { IHooks } from "./interfaces/IHooks.sol"; +import { IPayStreams } from "./interfaces/IPayStreams.sol"; + +/** + * @title PayStreams. + * @author mgnfy-view. + * @notice PayStreams is a payment streamming service leveraging PYUSD (made for the PayPal + * hackathon), and supercharged with hooks. + */ +contract PayStreams is Ownable, IPayStreams { + using SafeERC20 for IERC20; + + uint16 private constant BASIS_POINTS = 10_000; + + /** + * @dev The fee applied on streams in basis points. + */ + uint16 private s_feeInBasisPoints; + /** + * @dev Only supported tokens can be streamed. PYUSD should be supported for the PYUSD hackathon. + */ + mapping(address token => bool isSupported) private s_supportedTokens; + /** + * @dev Any fees collected from streaming is stored in the contract and tracked by this mapping. + */ + mapping(address token => uint256 collectedFees) private s_collectedFees; + + /** + * @dev Stores stream details. + */ + mapping(bytes32 streamHash => StreamData streamData) private s_streamData; + /** + * @dev Stores the hook configuration for the streamer and the recipient. + */ + mapping(address user => mapping(bytes32 streamHash => HookConfig hookConfig)) private s_hookConfig; + /** + * @dev Utility storage for the streamer's stream hashes. + */ + mapping(address streamer => bytes32[] streamHashes) private s_streamerToStreamHashes; + /** + * @dev Utility storage for the recipient's stream hashes. + */ + mapping(address recipient => bytes32[] streamHashes) private s_recipientToStreamHashes; + + /** + * @notice Initializes the owner and the fee value in basis points. + * @param _feeInBasisPoints The fee value in basis points. + */ + constructor(uint16 _feeInBasisPoints) Ownable(msg.sender) { + if (_feeInBasisPoints > BASIS_POINTS) revert PayStreams__InvalidFeeInBasisPoints(_feeInBasisPoints); + s_feeInBasisPoints = _feeInBasisPoints; + } + + /** + * @notice Allows the owner to set the fee for streaming in basis points. + * @param _feeInBasisPoints The fee value in basis points. + */ + function setFeeInBasisPoints(uint16 _feeInBasisPoints) external onlyOwner { + if (_feeInBasisPoints > BASIS_POINTS) revert PayStreams__InvalidFeeInBasisPoints(_feeInBasisPoints); + s_feeInBasisPoints = _feeInBasisPoints; + + emit FeeInBasisPointsSet(_feeInBasisPoints); + } + + /** + * @notice Allows the owner to withdraw any collected fees. + * @param _token The address of the token. + * @param _amount The amount of collected fees to withdraw. + */ + function collectFees(address _token, uint256 _amount) external onlyOwner { + if (!s_supportedTokens[_token]) revert PayStreams__UnsupportedToken(_token); + if (s_collectedFees[_token] < _amount) revert PayStreams__InsufficientCollectedFees(); + + s_collectedFees[_token] -= _amount; + IERC20(_token).safeTransfer(msg.sender, _amount); + + emit FeesCollected(_token, _amount); + } + + /** + * @notice Allows the owner to support or revoke support from tokens for streaming. + * @param _token The address of the token. + * @param _support A boolean indicating whether to support the token or revoke + * support from the token. + */ + function setToken(address _token, bool _support) external onlyOwner { + if (_token == address(0)) revert PayStreams__AddressZero(); + + if (_support) { + s_supportedTokens[_token] = true; + } else { + s_supportedTokens[_token] = false; + } + + emit TokenSet(_token, _support); + } + + /** + * @notice Allows anyone to create a stream with custom params and hook configuration. + * @param _streamData The stream details. + * @param _streamerHookConfig The streamer's hook configuration. + * @param _tag Salt for stream creation. This will allow to create multiple streams for different + * purposes targeted towards the same recipient and the same token. + * @return The hash of the newly created stream. + */ + function setStream( + StreamData calldata _streamData, + HookConfig calldata _streamerHookConfig, + string memory _tag + ) + external + returns (bytes32) + { + if ( + _streamData.streamer != msg.sender || _streamData.recipient == address(0) + || _streamData.recipientVault != address(0) || !s_supportedTokens[_streamData.token] + || _streamData.amount == 0 || _streamData.startingTimestamp < block.timestamp || _streamData.duration == 0 + || _streamData.totalStreamed != 0 + ) revert PayStreams__InvalidStreamConfig(); + + bytes32 streamHash = getStreamHash(msg.sender, _streamData.recipient, _streamData.token, _tag); + if (s_streamData[streamHash].streamer != address(0)) revert PayStreams__StreamAlreadyExists(streamHash); + s_streamData[streamHash] = _streamData; + s_streamerToStreamHashes[msg.sender].push(streamHash); + s_recipientToStreamHashes[_streamData.recipient].push(streamHash); + s_hookConfig[msg.sender][streamHash] = _streamerHookConfig; + + if (_streamData.streamerVault != address(0) && _streamerHookConfig.callAfterStreamCreated) { + IHooks(_streamData.streamerVault).afterStreamCreated(streamHash); + } + + emit StreamCreated(streamHash); + + return streamHash; + } + + /** + * @notice Allows the streamer or recipient of a stream to set their vaults. + * @dev Hooks can only be called on correctly configured and set vaults (both on streamer's + * and recipient's end). + * @param _streamHash The hash of the stream. + * @param _vault The streamer's or recipient's vault address. + */ + function setVaultForStream(bytes32 _streamHash, address _vault) external { + StreamData memory streamData = s_streamData[_streamHash]; + if (msg.sender != streamData.streamer && msg.sender != streamData.recipient) revert PayStreams__Unauthorized(); + + msg.sender == streamData.streamer + ? s_streamData[_streamHash].streamerVault = _vault + : s_streamData[_streamHash].recipientVault = _vault; + + emit VaultSet(msg.sender, _streamHash, _vault); + } + + /** + * @notice Allows streamers and recipients to set their hook configuration. + * @param _streamHash The hash of the stream. + * @param _hookConfig The streamer's or recipient's hook configuration. + */ + function setHookConfigForStream(bytes32 _streamHash, HookConfig calldata _hookConfig) external { + StreamData memory streamData = s_streamData[_streamHash]; + if (msg.sender != streamData.streamer && msg.sender != streamData.recipient) revert PayStreams__Unauthorized(); + + s_hookConfig[msg.sender][_streamHash] = _hookConfig; + + emit HookConfigSet(msg.sender, _streamHash); + } + + /** + * @notice Allows the recipient to collect funds from a stream. + * @param _streamHash The hash of the stream. + */ + function collectFundsFromStream(bytes32 _streamHash) external { + StreamData memory streamData = s_streamData[_streamHash]; + + if (streamData.startingTimestamp > block.timestamp) { + revert PayStreams__StreamHasNotStartedYet(_streamHash, streamData.startingTimestamp); + } + (uint256 amountToCollect, uint256 feeAmount) = getAmountToCollectFromStreamAndFeeToPay(_streamHash); + if (amountToCollect == 0) revert PayStreams__ZeroAmountToCollect(); + + s_streamData[_streamHash].totalStreamed += amountToCollect; + + HookConfig memory streamerHookConfig = s_hookConfig[streamData.streamer][_streamHash]; + HookConfig memory recipientHookConfig = s_hookConfig[streamData.recipient][_streamHash]; + + if (streamData.streamerVault != address(0) && streamerHookConfig.callBeforeFundsCollected) { + IHooks(streamData.streamerVault).beforeFundsCollected(_streamHash, amountToCollect, feeAmount); + } + if (streamData.recipientVault != address(0) && recipientHookConfig.callBeforeFundsCollected) { + IHooks(streamData.streamerVault).beforeFundsCollected(_streamHash, amountToCollect, feeAmount); + } + + s_collectedFees[streamData.token] += feeAmount; + if (streamData.streamerVault != address(0)) { + streamData.recipientVault != address(0) + ? IERC20(streamData.token).safeTransferFrom( + streamData.streamerVault, streamData.recipientVault, amountToCollect - feeAmount + ) + : IERC20(streamData.token).safeTransferFrom( + streamData.streamerVault, streamData.recipient, amountToCollect - feeAmount + ); + + IERC20(streamData.token).safeTransferFrom(streamData.streamerVault, address(this), feeAmount); + } else { + streamData.recipientVault != address(0) + ? IERC20(streamData.token).safeTransferFrom( + streamData.streamer, streamData.recipientVault, amountToCollect - feeAmount + ) + : IERC20(streamData.token).safeTransferFrom( + streamData.streamer, streamData.recipient, amountToCollect - feeAmount + ); + + IERC20(streamData.token).safeTransferFrom(streamData.streamer, address(this), feeAmount); + } + + if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterFundsCollected) { + IHooks(streamData.streamerVault).afterFundsCollected(_streamHash, amountToCollect, feeAmount); + } + if (streamData.recipientVault != address(0) && recipientHookConfig.callAfterFundsCollected) { + IHooks(streamData.streamerVault).afterFundsCollected(_streamHash, amountToCollect, feeAmount); + } + + emit FundsCollectedFromStream(_streamHash, amountToCollect); + } + + /** + * @notice Allows the creator of a stream to update the stream parameters. + * @param _streamHash The hash of the stream. + * @param _amount The new amount to stream. + * @param _startingTimestamp The new starting timestamp. + * @param _duration The new stream duration. + * @param _recurring Update stream to be recurring or not. + */ + function updateStream( + bytes32 _streamHash, + uint256 _amount, + uint256 _startingTimestamp, + uint256 _duration, + bool _recurring + ) + external + { + StreamData memory streamData = s_streamData[_streamHash]; + if (msg.sender != streamData.streamer) revert PayStreams__Unauthorized(); + + HookConfig memory streamerHookConfig = s_hookConfig[streamData.streamer][_streamHash]; + HookConfig memory recipientHookConfig = s_hookConfig[streamData.recipient][_streamHash]; + + if (streamData.streamerVault != address(0) && streamerHookConfig.callBeforeFundsCollected) { + IHooks(streamData.streamerVault).beforeStreamUpdated(_streamHash); + } + + s_streamData[_streamHash].amount = _amount; + s_streamData[_streamHash].startingTimestamp = _startingTimestamp; + s_streamData[_streamHash].duration = _duration; + s_streamData[_streamHash].recurring = _recurring; + + if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterStreamClosed) { + IHooks(streamData.streamerVault).afterStreamUpdated(_streamHash); + } + if (streamData.recipientVault != address(0) && recipientHookConfig.callAfterStreamClosed) { + IHooks(streamData.recipientVault).afterStreamUpdated(_streamHash); + } + + emit StreamUpdated(_streamHash, _amount, _startingTimestamp, _duration, _recurring); + } + + /** + * @notice Allows the creator of a stream to cancel the stream. + * @param _streamHash The hash of the stream. + */ + function cancelStream(bytes32 _streamHash) external { + StreamData memory streamData = s_streamData[_streamHash]; + if (msg.sender != streamData.streamer) revert PayStreams__Unauthorized(); + + HookConfig memory streamerHookConfig = s_hookConfig[streamData.streamer][_streamHash]; + HookConfig memory recipientHookConfig = s_hookConfig[streamData.recipient][_streamHash]; + + if (streamData.streamerVault != address(0) && streamerHookConfig.callBeforeStreamClosed) { + IHooks(streamData.streamerVault).beforeStreamClosed(_streamHash); + } + + s_streamData[_streamHash].amount = 0; + + if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterStreamClosed) { + IHooks(streamData.streamerVault).afterStreamClosed(_streamHash); + } + if (streamData.recipientVault != address(0) && recipientHookConfig.callAfterStreamClosed) { + IHooks(streamData.recipientVault).afterStreamClosed(_streamHash); + } + + emit StreamCancelled(_streamHash); + } + + /** + * @notice Gets the fee value for streaming in basis points. + * @return The fee value for streaming in basis points. + */ + function getFeeInBasisPoints() external view returns (uint16) { + return s_feeInBasisPoints; + } + + /** + * @notice Checks if the given token is supported for streaming or not. + * @param _token The address of the token. + * @return A boolean indicating whether the token is supported or not. + */ + function isSupportedToken(address _token) external view returns (bool) { + return s_supportedTokens[_token]; + } + + /** + * @notice Gets the total amount collected in fees for a given token. + * @param _token The address of the token. + * @return The amount of token collected in fees. + */ + function getCollectedFees(address _token) external view returns (uint256) { + return s_collectedFees[_token]; + } + + /** + * @notice Gets the details for a given stream. + * @param _streamHash The hash of the stream. + * @return The stream details. + */ + function getStreamData(bytes32 _streamHash) external view returns (StreamData memory) { + return s_streamData[_streamHash]; + } + + /** + * @notice Gets the hook configuration for a given user and a given stream hash. + * @param _user The user's address. + * @param _streamHash The hash of the stream. + * @return The hook configuration details. + */ + function getHookConfig(address _user, bytes32 _streamHash) external view returns (HookConfig memory) { + return s_hookConfig[_user][_streamHash]; + } + + /** + * @notice Gets the hashes of the streams created by a user. + * @param _streamer The stream creator's address. + * @return An array of stream hashes. + */ + function getStreamerStreamHashes(address _streamer) external view returns (bytes32[] memory) { + return s_streamerToStreamHashes[_streamer]; + } + + /** + * @notice Gets the hashes of the streams the user is a recipient of. + * @param _recipient The stream recipient's address. + * @return An array of stream hashes. + */ + function getRecipientStreamHashes(address _recipient) external view returns (bytes32[] memory) { + return s_recipientToStreamHashes[_recipient]; + } + + /** + * @notice Computes the hash of a stream from the streamer, recipient, token addresses and a string tag. + * @param _streamer The address of the stream creator. + * @param _recipient The address of the stream recipient. + * @param _token The address of the token. + * @param _tag Salt for stream creation. + * @return The hash of the stream. + */ + function getStreamHash( + address _streamer, + address _recipient, + address _token, + string memory _tag + ) + public + pure + returns (bytes32) + { + return keccak256(abi.encode(_streamer, _recipient, _token, _tag)); + } + + function getAmountToCollectFromStreamAndFeeToPay(bytes32 _streamHash) public view returns (uint256, uint256) { + StreamData memory streamData = s_streamData[_streamHash]; + + if (block.timestamp < streamData.startingTimestamp) return (0, 0); + + uint256 amountToCollect = ( + streamData.amount * (block.timestamp - streamData.startingTimestamp) / streamData.duration + ) - streamData.totalStreamed; + if (amountToCollect > streamData.amount && !streamData.recurring) { + amountToCollect = streamData.amount - streamData.totalStreamed; + } + uint256 feeAmount = (amountToCollect * s_feeInBasisPoints) / BASIS_POINTS; + + return (amountToCollect - feeAmount, feeAmount); + } +} diff --git a/src/exampleHooks/PaymentSplitterVault.sol b/src/exampleHooks/PaymentSplitterVault.sol new file mode 100644 index 0000000..8a79131 --- /dev/null +++ b/src/exampleHooks/PaymentSplitterVault.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol"; + +import { SafeERC20 } from "@openzeppelin/token/ERC20/utils/SafeERC20.sol"; + +import { IPayStreams } from "../interfaces/IPayStreams.sol"; + +import { BaseVault } from "../utils/BaseVault.sol"; + +contract PaymentSplitterVault is BaseVault { + using SafeERC20 for IERC20; + + address private s_payStreams; + address[] private s_recipients; + + event PaymentSplit(uint256 indexed amount, address[] indexed recipients); + event RecipientListUpdated(address[] indexed recipients); + + constructor(address _payStreams, address[] memory _recipients) { + s_payStreams = _payStreams; + s_recipients = _recipients; + } + + function afterFundsCollected(bytes32 _streamHash, uint256 _amount, uint256 _feeAmount) external override { + address token = IPayStreams(s_payStreams).getStreamData(_streamHash).token; + address[] memory recipients = s_recipients; + uint256 numberOfRecipients = recipients.length; + uint256 amountPerRecipient = (_amount - _feeAmount) / numberOfRecipients; + + for (uint256 i; i < numberOfRecipients; ++i) { + IERC20(token).safeTransfer(recipients[i], amountPerRecipient); + } + + emit PaymentSplit(_amount - _feeAmount, recipients); + } + + function updateRecipientList(address[] memory _recipients) external onlyOwner { + s_recipients = _recipients; + + emit RecipientListUpdated(_recipients); + } +} diff --git a/src/interfaces/IHooks.sol b/src/interfaces/IHooks.sol new file mode 100644 index 0000000..d803a3a --- /dev/null +++ b/src/interfaces/IHooks.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +interface IHooks { + function afterStreamCreated(bytes32 _streamHash) external; + + function beforeFundsCollected(bytes32 _streamHash, uint256 _amount, uint256 _feeAmount) external; + + function afterFundsCollected(bytes32 _streamHash, uint256 _amount, uint256 _feeAmount) external; + + function beforeStreamUpdated(bytes32 _streamHash) external; + + function afterStreamUpdated(bytes32 _streamHash) external; + + function beforeStreamClosed(bytes32 _streamHash) external; + + function afterStreamClosed(bytes32 _streamHash) external; +} diff --git a/src/interfaces/IPayStreams.sol b/src/interfaces/IPayStreams.sol new file mode 100644 index 0000000..a5a1ea6 --- /dev/null +++ b/src/interfaces/IPayStreams.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +interface IPayStreams { + /** + * @notice The stream details struct. + * @param streamer The address of the streamer. + * @param streamerVault The address of the streamer's vault. + * @param recipient The address of the recipient. + * @param recipientVault The address of the recipient's vault. + * @param token The address of the token to stream. + * @param amount The amount of the token to stream. + * @param startingTimestamp The timestamp when the stream begins. + * @param duration The duration for which the stream lasts. + * @param totalStreamed The total amount collected by recipient from the stream. + * @param recurring A bool indicating if the stream is recurring or one-time only. + */ + struct StreamData { + address streamer; + address streamerVault; + address recipient; + address recipientVault; + address token; + uint256 amount; + uint256 startingTimestamp; + uint256 duration; + uint256 totalStreamed; + bool recurring; + } + + /** + * @notice The hook configuration details struct for both streamer and recipient. + * @param callAfterStreamCreated If set, the afterStreamCreated() function will be called on + * the user's vault (if it isn't address(0)). + * @param callBeforeFundsCollected If set, the beforeFundsCollected() function will be called on + * the user's vault (if it isn't address(0)). + * @param callAfterFundsCollected If set, the afterFundsCollected() function will be called on + * the user's vault (if it isn't address(0)). + * @param callBeforeStreamUpdated If set, the beforeStreamUpdated() function will be called on + * the user's vault (if it isn't address(0)). + * @param callAfterStreamUpdated If set, the afterStreamUpdated() function will be called on + * the user's vault (if it isn't address(0)). + * @param callBeforeStreamClosed If set, the beforeStreamClosed() function will be called on + * the user's vault (if it isn't address(0)). + * @param callAfterStreamClosed If set, the afterStreamClosed() function will be called on + * the user's vault (if it isn't address(0)). + */ + struct HookConfig { + bool callAfterStreamCreated; + bool callBeforeFundsCollected; + bool callAfterFundsCollected; + bool callBeforeStreamUpdated; + bool callAfterStreamUpdated; + bool callBeforeStreamClosed; + bool callAfterStreamClosed; + } + + event FeeRecipientSet(address indexed newFeeRecipient); + event FeeInBasisPointsSet(uint16 indexed _feeInBasisPoints); + event TokenSet(address indexed token, bool indexed support); + event StreamCreated(bytes32 indexed streamHash); + event FeesCollected(address indexed token, uint256 indexed amount); + event VaultSet(address indexed by, bytes32 indexed streamHash, address indexed vault); + event HookConfigSet(address indexed by, bytes32 indexed streamHash); + event FundsCollectedFromStream(bytes32 indexed streamHash, uint256 indexed amountToCollect); + event StreamUpdated( + bytes32 indexed streamHash, uint256 amount, uint256 startingTimestamp, uint256 duration, bool recurring + ); + event StreamCancelled(bytes32 indexed streamHash); + + error PayStreams__AddressZero(); + error PayStreams__InvalidFeeInBasisPoints(uint16 feeInBasisPoints); + error PayStreams__UnsupportedToken(address token); + error PayStreams__InsufficientCollectedFees(); + error PayStreams__InvalidStreamConfig(); + error PayStreams__StreamAlreadyExists(bytes32 streamHash); + error PayStreams__Unauthorized(); + error PayStreams__StreamHasNotStartedYet(bytes32 streamHash, uint256 startingTimestamp); + error PayStreams__ZeroAmountToCollect(); + + function setFeeInBasisPoints(uint16 _feeInBasisPoints) external; + function collectFees(address _token, uint256 _amount) external; + function setToken(address _token, bool _support) external; + function setStream( + StreamData calldata _streamData, + HookConfig calldata _streamerHookConfig, + string memory _tag + ) + external + returns (bytes32); + function setVaultForStream(bytes32 _streamHash, address _vault) external; + function setHookConfigForStream(bytes32 _streamHash, HookConfig calldata _hookConfig) external; + function collectFundsFromStream(bytes32 _streamHash) external; + function updateStream( + bytes32 _streamHash, + uint256 _amount, + uint256 _startingTimestamp, + uint256 _duration, + bool _recurring + ) + external; + function cancelStream(bytes32 _streamHash) external; + function getFeeInBasisPoints() external view returns (uint16); + function isSupportedToken(address _token) external view returns (bool); + function getCollectedFees(address _token) external view returns (uint256); + function getStreamData(bytes32 _streamHash) external view returns (StreamData memory); + function getHookConfig(address _user, bytes32 _streamHash) external view returns (HookConfig memory); + function getStreamerStreamHashes(address _streamer) external view returns (bytes32[] memory); + function getRecipientStreamHashes(address _recipient) external view returns (bytes32[] memory); + function getStreamHash( + address _streamer, + address _recipient, + address _token, + string memory _tag + ) + external + pure + returns (bytes32); + function getAmountToCollectFromStreamAndFeeToPay(bytes32 _streamHash) external view returns (uint256, uint256); +} diff --git a/src/utils/BaseVault.sol b/src/utils/BaseVault.sol new file mode 100644 index 0000000..da76363 --- /dev/null +++ b/src/utils/BaseVault.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { Ownable } from "@openzeppelin/access/Ownable.sol"; + +import { IHooks } from "../interfaces/IHooks.sol"; + +abstract contract BaseVault is Ownable, IHooks { + constructor() Ownable(msg.sender) { } + + function afterStreamCreated(bytes32 _streamHash) external virtual { } + + function beforeFundsCollected(bytes32 _streamHash, uint256 _amount, uint256 _feeAmount) external virtual { } + + function afterFundsCollected(bytes32 _streamHash, uint256 _amount, uint256 _feeAmount) external virtual { } + + function beforeStreamUpdated(bytes32 _streamHash) external virtual { } + + function afterStreamUpdated(bytes32 _streamHash) external virtual { } + + function beforeStreamClosed(bytes32 _streamHash) external virtual { } + + function afterStreamClosed(bytes32 _streamHash) external virtual { } +} diff --git a/test/unit/PayStream.t.sol b/test/unit/PayStream.t.sol new file mode 100644 index 0000000..f4a2e61 --- /dev/null +++ b/test/unit/PayStream.t.sol @@ -0,0 +1,655 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; +import {PayStreams} from "../../src/PayStreams.sol"; +import {initializeTokenAndActors} from "../utils/Helpers/initializeTokenAndActors.sol"; +import {Test} from "../../lib/forge-std/src/Test.sol"; +import {console } from "../../lib/forge-std/src/console.sol"; +import {IPayStreams} from "../../src/interfaces/IPayStreams.sol"; +import {MakeVaultforStreamer} from "../utils/MOCKS/makeVault.sol"; +import { MakeVaultforReceipient} from "../utils/MOCKS/makeValt2.sol"; +import {MakeHook} from "../../test/utils/MOCKS/makeHooks.sol"; + +contract PayStream is Test, initializeTokenAndActors { + MakeVaultforStreamer makeVaultForStreamer; + MakeVaultforReceipient makeVaultForReceipient; + MakeHook makeHook; + PayStreams public payStreams; + IPayStreams public iPayStreams; + uint16 public basisPoint; + function setUp() public { + vm.startPrank(payStreamTeamAddress1); + payStreams = new PayStreams(basisPoint); + payStreams.setFeeInBasisPoints(5_00); + vm.stopPrank(); + } + + function testIfBasisPointSetLower() public { + vm.startPrank(payStreamTeamAddress1); + vm.expectRevert(); + payStreams.setFeeInBasisPoints(15_000); + vm.stopPrank(); + } + function testBasisPoint() public { + uint16 newBasisPoint = 200; + vm.startPrank(payStreamTeamAddress1); + payStreams.setFeeInBasisPoints(newBasisPoint); + uint16 basisPoint_ = payStreams.getFeeInBasisPoints(); + assertEq(basisPoint_, newBasisPoint ); + vm.stopPrank(); + } + + function testSetToken() public { + vm.startPrank(payStreamTeamAddress1); + payStreams.setToken(address(mpyUSD), true); + vm.stopPrank(); + bool isSupported = payStreams.isSupportedToken(address(mpyUSD)); + assertEq(isSupported, true); + } + modifier setSupportedToken() { + vm.startPrank(payStreamTeamAddress1); + payStreams.setToken(address(mpyUSD), true); + vm.stopPrank(); + _; + } + + function testCollectFee() public setSupportedToken { + + // get the streamData struct + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(0), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream"; + // console.log(streamData.streamerVault); + bytes32 actualStreamHash = payStreams.setStream(streamData, hookConfig, _tag); + + // Advance the time by 1 day to make funds collectible + vm.warp(block.timestamp + 1 days); + uint expectedCollectedAmount =( streamData.amount * (block.timestamp - streamData.startingTimestamp) / streamData.duration ) - streamData.totalStreamed; + uint expectedFee = (expectedCollectedAmount * payStreams.getFeeInBasisPoints()) / 10_000; + (uint expectedAmountToWitdraw, uint ExpectedFee) = (expectedCollectedAmount- expectedFee, expectedFee ); + (uint256 ActualAmountToWithdraw,uint256 ActualFee ) = payStreams.getAmountToCollectFromStreamAndFeeToPay(actualStreamHash); + vm.startPrank(payStreamer); + mpyUSD.approve(address(payStreams), type(uint256).max); + payStreams.collectFundsFromStream(actualStreamHash); + vm.stopPrank(); + + + // now we handle the fee collected by the protocol + vm.startPrank(payStreamTeamAddress1); + payStreams.collectFees(streamData.token, ActualFee); + uint amountOfFeeCollected = mpyUSD.balanceOf(payStreamTeamAddress1); + console.log("this is the total amount of fee collected", amountOfFeeCollected); + assertEq(amountOfFeeCollected, ActualFee); + } + + function testSetStreamExpectRevert() public setSupportedToken { + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k ); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: makeAddr("norevert"), + recipient: payReceiver, + recipientVault: address(0), + token: address(pyUSD),// the error is intentionally written in this line as pyUSD instead of mpyUSD + amount: 1_000, + startingTimestamp: block.timestamp, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream2"; + vm.expectRevert(); + payStreams.setStream(streamData, hookConfig, _tag); + vm.stopPrank(); + } + function testSetStream() public setSupportedToken { + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(0), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp + 1 days, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream"; + console.log(streamData.streamerVault); + bytes32 actualStreamHash = payStreams.setStream(streamData, hookConfig, _tag); + bytes32 expectedStreamHash = keccak256(abi.encode(payStreamer, payReceiver, address(mpyUSD), _tag)); + // console.log("this is the actual stream hash:", actualStreamHash); + vm.assertEq(expectedStreamHash, actualStreamHash); + vm.stopPrank(); + } + + function testSetVaultForStream() public setSupportedToken{ + // set Stream to update the mapping + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k ); + makeVaultForStreamer = new MakeVaultforStreamer(address(payStreamer)); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(makeVaultForStreamer), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream2"; + bytes32 streamHash = payStreams.setStream(streamData, hookConfig, _tag); + vm.stopPrank(); + + // create a streamer vault address + // makeVaultForStreamer = new MakeVaultforStreamer(address(payStreamer)); + vm.startPrank(payStreamer); + payStreams.setVaultForStream(streamHash, address(makeVaultForStreamer)); + address expectedVault = payStreams.getStreamData(streamHash).streamerVault; + address actualVault = address(makeVaultForStreamer); + assertEq(expectedVault, actualVault); + vm.stopPrank(); + } + + function testSetVaultForReceiver() public setSupportedToken{ + vm.startPrank(payReceiver); + makeVaultForReceipient = new MakeVaultforReceipient(address(payReceiver)); + vm.stopPrank(); + vm.startPrank(payStreamer); + makeVaultForStreamer = new MakeVaultforStreamer(address(payStreamer)); + mpyUSD.mint(payStreamer, USDC10k ); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(makeVaultForStreamer), + recipient: payReceiver, + recipientVault: address(makeVaultForReceipient), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream2"; + bytes32 streamHash = payStreams.setStream(streamData, hookConfig, _tag); + vm.stopPrank(); + + // create a streamer vault address + vm.startPrank(payReceiver); + payStreams.setVaultForStream(streamHash, address(makeVaultForReceipient)); + address expectedVault = payStreams.getStreamData(streamHash).recipientVault; + address actualVault = address(makeVaultForReceipient); + assertEq(expectedVault, actualVault); + vm.stopPrank(); + } + + function testSetHookConfigForStream() public setSupportedToken { + vm.startPrank(payStreamer); + makeHook = MakeHook(payStreamer); + mpyUSD.mint(payStreamer, USDC10k); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(0), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp + 1 days, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: true, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream"; + console.log(streamData.streamerVault); + bytes32 actualStreamHash = payStreams.setStream(streamData, hookConfig, _tag); + payStreams.setHookConfigForStream(actualStreamHash, hookConfig); + + bool boolCallAfterStreamCreated = payStreams.getHookConfig(payStreamer, actualStreamHash).callBeforeFundsCollected; + // note that when bool boolCallAfterStreamCreated = payStreams.getHookConfig(payStreamer, actualStreamHash).callAfterStreamCreated; it reverts on evm error. more test to be conducted + console.log(boolCallAfterStreamCreated); + assertEq(boolCallAfterStreamCreated, hookConfig.callBeforeFundsCollected); + vm.stopPrank(); + } + + + function testGetAmountToCollectFree() public setSupportedToken { + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(0), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream"; + // console.log(streamData.streamerVault); + bytes32 actualStreamHash = payStreams.setStream(streamData, hookConfig, _tag); + + // Advance the time by 1 day to make funds collectible + vm.warp(block.timestamp + 1 days); + console.log("this is the streamed total Amount",streamData.totalStreamed); + console.log("this is the currenting time stamp after a day", block.timestamp); + console.log("this was the timestamp a day before",streamData.startingTimestamp); + console.log("this the durtion",streamData.duration); + vm.stopPrank(); + uint expectedCollectedAmount =( streamData.amount * (block.timestamp - streamData.startingTimestamp) / streamData.duration ) - streamData.totalStreamed; + uint expectedFee = (expectedCollectedAmount * payStreams.getFeeInBasisPoints()) / 10_000; + console.log("this is the expected collected fee",expectedFee ); + (uint expectedAmountToWitdraw, uint ExpectedFee) = (expectedCollectedAmount- expectedFee, expectedFee ); + console.log ("this is the expected collected Amount", expectedCollectedAmount); + (uint256 ActualAmountToWithdraw,uint256 ActualFee ) = payStreams.getAmountToCollectFromStreamAndFeeToPay(actualStreamHash); + console.log("these are the amountToWithdraw and fee amunt ", ActualAmountToWithdraw,ActualFee); + + assertEq(expectedAmountToWitdraw, ActualAmountToWithdraw); + assertEq(expectedFee, ActualFee); + + } + function testCollectFundFromStream() public setSupportedToken { + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(0), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream"; + // console.log(streamData.streamerVault); + bytes32 actualStreamHash = payStreams.setStream(streamData, hookConfig, _tag); + + // Advance the time by 1 day to make funds collectible + vm.warp(block.timestamp + 1 days); + uint expectedCollectedAmount =( streamData.amount * (block.timestamp - streamData.startingTimestamp) / streamData.duration ) - streamData.totalStreamed; + uint expectedFee = (expectedCollectedAmount * payStreams.getFeeInBasisPoints()) / 10_000; + (uint expectedAmountToWitdraw, uint ExpectedFee) = (expectedCollectedAmount- expectedFee, expectedFee ); + (uint256 ActualAmountToWithdraw,uint256 ActualFee ) = payStreams.getAmountToCollectFromStreamAndFeeToPay(actualStreamHash); + console.log("this is the streamer address",streamData.streamer); + console.log("this is the pay streamer address", payStreamer); + console.log("this is the payStream contract address", address(payStreams)); + vm.startPrank(payStreamer); + mpyUSD.approve(address(payStreams), type(uint256).max); + payStreams.collectFundsFromStream(actualStreamHash); + vm.stopPrank(); + } + + function testUpdateStream() public setSupportedToken { + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k ); + makeVaultForStreamer = new MakeVaultforStreamer(address(payStreamer)); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(makeVaultForStreamer), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream2"; + bytes32 streamHash = payStreams.setStream(streamData, hookConfig, _tag); + vm.stopPrank(); + + // create a streamer vault address + // makeVaultForStreamer = new MakeVaultforStreamer(address(payStreamer)); + vm.startPrank(payStreamer); + payStreams.setVaultForStream(streamHash, address(makeVaultForStreamer)); + vm.stopPrank(); + + vm.startPrank(payStreamer); + // handle the update stream function + vm.warp(block.timestamp + 2 days); + uint newAmount = 1_200; + uint newStartTimStamp = block.timestamp; + uint newDuration = 2 weeks; + bool recurring = true; + + payStreams.updateStream(streamHash,newAmount, newStartTimStamp,newDuration, recurring); + + vm.stopPrank(); + IPayStreams.StreamData memory updatedStreamData = payStreams.getStreamData(streamHash); + + // Assert updated values to match set in the updateStream + assertEq(updatedStreamData.amount, newAmount); + assertEq(updatedStreamData.startingTimestamp, newStartTimStamp); + assertEq(updatedStreamData.duration, newDuration); + assertEq(updatedStreamData.recurring, recurring); + } + function testCancelStream() public setSupportedToken{ + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k ); + makeVaultForStreamer = new MakeVaultforStreamer(address(payStreamer)); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(makeVaultForStreamer), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream2"; + bytes32 streamHash = payStreams.setStream(streamData, hookConfig, _tag); + vm.stopPrank(); + + // create a streamer vault address + // makeVaultForStreamer = new MakeVaultforStreamer(address(payStreamer)); + vm.startPrank(payStreamer); + payStreams.setVaultForStream(streamHash, address(makeVaultForStreamer)); + vm.stopPrank(); + + vm.startPrank(payStreamer); + // handle the cancel stream function + vm.warp(block.timestamp + 2 days); + payStreams.cancelStream(streamHash); + vm.stopPrank(); + + IPayStreams.StreamData memory cancelledStreamData = payStreams.getStreamData(streamHash); + assertEq(cancelledStreamData.amount, 0); + + } + function testgetStreamHash() public setSupportedToken{ + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(0), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp + 1 days, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream"; + bytes32 actualStreamHash = payStreams.setStream(streamData, hookConfig, _tag); + vm.stopPrank(); + address expectedStreamer = payStreamer; + address expectedReceipent = payReceiver; + address expectedToken = address(mpyUSD); + string memory expectedSalt = "HamidStream"; + // TEST getSteamHash + bytes32 actualStremaHash = payStreams.getStreamHash(expectedStreamer, expectedReceipent, expectedToken, expectedSalt); + bytes32 expectedStreamHash = keccak256(abi.encode(streamData.streamer, streamData.recipient, streamData.token, _tag)); + assertEq(actualStreamHash, expectedStreamHash); + } + + function testReceiverStreamHash() public setSupportedToken { + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(0), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp + 1 days, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream"; + bytes32 actualStreamHash = payStreams.setStream(streamData, hookConfig, _tag); + vm.stopPrank(); + vm.startPrank(payStreamer); + IPayStreams.StreamData memory streamData2 = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(0), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp + 1 days, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig2 = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: true, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag2 = "OscarStream"; + bytes32 actualStreamHash2 = payStreams.setStream(streamData2, hookConfig2, _tag2); + vm.stopPrank(); + // get receiver hash + + bytes32[] memory streamHashes = payStreams.getRecipientStreamHashes(payReceiver); + // bytes32[] memory streamHashes = payStreams.getStreamerStreamHashes(payReceiver); + + assertEq(streamHashes.length, 2, "Expected two stream hashes for the receiver."); + + assertEq(actualStreamHash, streamHashes[0]); + assertEq(actualStreamHash2, streamHashes[1]); + } + + + + function testStreamerStreamHash() public setSupportedToken { + vm.startPrank(payStreamer); + mpyUSD.mint(payStreamer, USDC10k); + IPayStreams.StreamData memory streamData = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(0), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp + 1 days, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: false, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag = "HamidStream"; + bytes32 actualStreamHash = payStreams.setStream(streamData, hookConfig, _tag); + vm.stopPrank(); + vm.startPrank(payStreamer); + IPayStreams.StreamData memory streamData2 = IPayStreams.StreamData({ + streamer: payStreamer, + streamerVault: address(0), + recipient: payReceiver, + recipientVault: address(0), + token: address(mpyUSD), + amount: 1_000, + startingTimestamp: block.timestamp + 1 days, + duration: 1 weeks, + totalStreamed: 0, + recurring: false + }); + IPayStreams.HookConfig memory hookConfig2 = IPayStreams.HookConfig({ + callAfterStreamCreated:false, + callBeforeFundsCollected: true, + callAfterFundsCollected: false, + callBeforeStreamUpdated: false, + callAfterStreamUpdated: false, + callBeforeStreamClosed: false, + callAfterStreamClosed: false + }); + + string memory _tag2 = "OscarStream"; + bytes32 actualStreamHash2 = payStreams.setStream(streamData2, hookConfig2, _tag2); + vm.stopPrank(); + // get receiver hash + + // bytes32[] memory streamHashes = payStreams.getRecipientStreamHashes(payReceiver); + bytes32[] memory streamHashes = payStreams.getStreamerStreamHashes(payStreamer); + + assertEq(streamHashes.length, 2, "Expected two stream hashes for the receiver."); + + assertEq(actualStreamHash, streamHashes[0]); + assertEq(actualStreamHash2, streamHashes[1]); + } +} diff --git a/test/utils/Helpers/initializeTokenAndActors.sol b/test/utils/Helpers/initializeTokenAndActors.sol new file mode 100644 index 0000000..cf4a60f --- /dev/null +++ b/test/utils/Helpers/initializeTokenAndActors.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; + // --------------------------- + // ACTOR INITIALISER + // --------------------------- +import {Test} from "../../../lib/forge-std/src/Test.sol"; +import {console} from "../../../lib/forge-std/src/console.sol"; + +import {MockpyUSD} from "../MOCKS/MockpyUSD.sol"; +import {MockToken} from "../MOCKS/MockToken.sol"; +contract initializeTokenAndActors is Test { + + uint256 public constant USDC1 = 1e6; // 1 e 6 stable Coin + uint256 public constant USDC10k = 1e10; // 10k stable Coin + uint256 public constant USDC100k = 1e11; // 100k stable Coin + + MockToken mpyUSD = new MockToken("PayPal Mock USD", "mockpyUSD", 6); + MockToken wUSDT = new MockToken ("wUSDT", "wUSDT", 6); + MockToken wETH = new MockToken ("wETH", "wETH", 18); + + MockpyUSD pyUSD = new MockpyUSD(USDC100k); + + // --------------------------- + // ADMIN ACTORS + // --------------------------- + address payStreamTeamAddress1 = makeAddr("protocolpayStreamTeamAddress1"); + address payStreamTeamAddress2 = makeAddr("protocolpayStreamTeamAddress2"); + + // --------------------------- + // STREAMER ACTORS + // --------------------------- + address payStreamer = makeAddr("makePayStreamer"); + address payStreamer2 = makeAddr("makePayStreamer2"); + address payStreamer3 = makeAddr("makePayStreamer3"); + + // --------------------------- + // RECEIVER ACTORS + // --------------------------- + address payReceiver = makeAddr("makeReceiver"); + address payReceiver2 = makeAddr("makeReceiver2"); + address payReceiver3 = makeAddr("makeReceiver3"); + address payReceiver4 = makeAddr("makeReceiver4"); + address payReceiver5 = makeAddr("makeReceiver5"); + + address payNetflix = makeAddr("netnetflixHook"); +} \ No newline at end of file diff --git a/test/utils/MOCKS/MockToken.sol b/test/utils/MOCKS/MockToken.sol new file mode 100644 index 0000000..cb3ae7d --- /dev/null +++ b/test/utils/MOCKS/MockToken.sol @@ -0,0 +1,43 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +// --------------------------- +// ERC20 MOCK CONTRACT +// --------------------------- + +import {ERC20} from "../../../lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; + +contract MockToken is ERC20 { + + // --------------------------- + // STATE VARIABLE + // --------------------------- + /// @dev dec could be a 2, 8, 18 + uint8 dec; + + // --------------------------- + // CONSTRUCTOR + // --------------------------- + /** + * + * @param _name name of the token + * @param _symbol symbol of the token + * @param _dec dec of the token + */ + constructor (string memory _name, string memory _symbol, uint8 _dec) ERC20(_name, _symbol) { + dec = _dec; + } + + // --------------------------- + // Function + // --------------------------- + + function mint(address AddrTo, uint256 amount) public { + _mint(AddrTo, amount); + } + + function decimals() public view override returns(uint8){ + return dec; + } + +} diff --git a/test/utils/MOCKS/MockpyUSD.sol b/test/utils/MOCKS/MockpyUSD.sol new file mode 100644 index 0000000..31d8018 --- /dev/null +++ b/test/utils/MOCKS/MockpyUSD.sol @@ -0,0 +1,60 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockpyUSD { + string public name = "MOCK PAY PAL USD"; + string public symbol = "pyUSD"; + uint8 public decimal = 6; + + uint public totalSupply; + + mapping(address => uint256) balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event e_Deposit(address indexed user, uint256 amount); + event e_Withdrawal(address indexed user, uint256 amount); + + + constructor (uint _totalSupply) { + totalSupply = _totalSupply; + } + + + function withdraw(uint256 amount) public { + require(balanceOf[msg.sender] > amount); + + balanceOf[msg.sender] -= amount; + (bool success,) = payable(msg.sender).call{ value: amount }(""); + require(success, "transfer failed on function withdraw"); + // payable(msg.sender).transfer(amount); + emit e_Withdrawal(msg.sender, amount); + } + + function getTotalSupply() public view returns (uint256) { + return address(this).balance; + } + + function approve(address user, uint256 amount) public returns (bool) { + allowance[msg.sender][user] = amount; + return true; + } + + function transfer(address addrTo, uint256 amount) public returns (bool) { + return transferFrom(msg.sender, addrTo, amount); + } + + function transferFrom(address addrFrom, address addrTo, uint256 amount) public returns (bool) { + if (addrFrom != msg.sender && allowance[addrFrom][msg.sender] != type(uint256).max) { + require(allowance[addrFrom][msg.sender] >= amount); + allowance[addrFrom][msg.sender] -= amount; + } + + balanceOf[addrFrom] -= amount; + balanceOf[addrTo] += amount; + + return true; + } +} \ No newline at end of file diff --git a/test/utils/MOCKS/makeHooks.sol b/test/utils/MOCKS/makeHooks.sol new file mode 100644 index 0000000..b0d0573 --- /dev/null +++ b/test/utils/MOCKS/makeHooks.sol @@ -0,0 +1,53 @@ +//SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; + +import {MockpyUSD} from "../MOCKS/MockpyUSD.sol"; +import {initializeTokenAndActors} from "../Helpers/initializeTokenAndActors.sol"; +import {IHooks} from "../../../src/interfaces/IHooks.sol"; +import {MakeVaultforStreamer} from "../MOCKS/makeVault.sol"; + +contract MakeHook is initializeTokenAndActors{ + + + address public onlyStreamer; + address public hookReceiver = payReceiver; + + + IHooks public iHooks; + + + error makeHook_NotTheStreamerError(); + error makeHook_notMonadexReceiver(); + + constructor(address _onlyStreamer) { + onlyStreamer = _onlyStreamer; + } + function makeStreamerHook(bytes32 _streamHash, address worker) public { + if (msg.sender != onlyStreamer){ + revert makeHook_NotTheStreamerError(); + } + + iHooks.afterStreamCreated(_streamHash); + if (worker != hookReceiver) { + revert makeHook_notMonadexReceiver(); + } + } + + function mockHookafterFundsCollected(bytes32 _streamHash, uint amount, uint fee) public { + //weekly deposit to subcribe for netFlix address + if (msg.sender != hookReceiver){ + revert makeHook_NotTheStreamerError(); + } + iHooks.afterFundsCollected(_streamHash, amount, fee); + uint startingTimeStamp = block.timestamp; + uint duration = 1 weeks; + if (block.timestamp > startingTimeStamp + duration) { + mpyUSD.transferFrom(payReceiver, payNetflix, amount ); + } + } + + function setPoolReceiver(address receiver) public { + hookReceiver = receiver; + } +} \ No newline at end of file diff --git a/test/utils/MOCKS/makeValt2.sol b/test/utils/MOCKS/makeValt2.sol new file mode 100644 index 0000000..8490386 --- /dev/null +++ b/test/utils/MOCKS/makeValt2.sol @@ -0,0 +1,24 @@ +//SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; + +import {MockpyUSD} from "../MOCKS/MockpyUSD.sol"; +import {initializeTokenAndActors} from "../Helpers/initializeTokenAndActors.sol"; +import {BaseVault} from "../../../src/utils/BaseVault.sol"; +contract MakeVaultforReceipient is initializeTokenAndActors, BaseVault{ + + address public onlyReciepeint; + + error makeVault_NotTheReceipentError(); + + constructor (address _onlyReceipient) { + onlyReciepeint = _onlyReceipient; + } + function receiverWithdrawFromVault(uint amount) public { + if (msg.sender != onlyReciepeint) { + revert makeVault_NotTheReceipentError(); + } + mpyUSD.transferFrom(address(this), msg.sender, amount); + + } +} \ No newline at end of file diff --git a/test/utils/MOCKS/makeVault.sol b/test/utils/MOCKS/makeVault.sol new file mode 100644 index 0000000..fdbc702 --- /dev/null +++ b/test/utils/MOCKS/makeVault.sol @@ -0,0 +1,26 @@ +//SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; +import {MockpyUSD} from "../MOCKS/MockpyUSD.sol"; +import {initializeTokenAndActors} from "../Helpers/initializeTokenAndActors.sol"; +import {BaseVault} from "../../../src/utils/BaseVault.sol"; +contract MakeVaultforStreamer is initializeTokenAndActors, BaseVault{ + + address public onlyStreamer; + + error makeVault_NotTheStreamerError(); + constructor (address _onlyStreamer ) { + onlyStreamer = _onlyStreamer; + } + + function addFundsToVault(uint amountToFund) public { + if (msg.sender != onlyStreamer) { + revert makeVault_NotTheStreamerError(); + } + + mpyUSD.mint(payStreamer, amountToFund); + mpyUSD.transferFrom(payStreamer, address(this), amountToFund); + } + +} +