From 271d0e80cfb55e9429c6b1aeb7766d982d3d5dfe Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Fri, 25 Oct 2024 22:16:19 +0530 Subject: [PATCH 01/24] chore: Switch to from solmate to openzeppelin --- .gitmodules | 6 +++--- Makefile | 2 +- foundry.toml | 1 + lib/openzeppelin-contracts | 1 + lib/solmate | 1 - 5 files changed, 6 insertions(+), 5 deletions(-) create mode 160000 lib/openzeppelin-contracts delete mode 160000 lib/solmate 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..6c132e3 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 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..69c8def --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit 69c8def5f222ff96f2b5beff05dfba996368aa79 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 From 884aaf08adae4eb19ca85f05eb425a70617e69cc Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Sat, 26 Oct 2024 21:44:41 +0530 Subject: [PATCH 02/24] chore: Remmove the formatting stage from the github workflow --- .github/workflows/test.yml | 5 ----- 1 file changed, 5 deletions(-) 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 From 19099aa90830b5e63cbe08ef3b9ff9adf5f8c9ee Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Sat, 26 Oct 2024 21:45:10 +0530 Subject: [PATCH 03/24] feat: Finish writing admin controls --- src/PayStreams.sol | 61 ++++++++++++++++++++++++++++++++++ src/interfaces/IPayStreams.sol | 22 ++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/PayStreams.sol create mode 100644 src/interfaces/IPayStreams.sol diff --git a/src/PayStreams.sol b/src/PayStreams.sol new file mode 100644 index 0000000..e7c1b33 --- /dev/null +++ b/src/PayStreams.sol @@ -0,0 +1,61 @@ +// 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 { IPayStreams } from "./interfaces/IPayStreams.sol"; + +contract PyStreams is Ownable, IPayStreams { + using SafeERC20 for IERC20; + + uint16 private constant BASIS_POINTS = 10_000; + + address private s_feeRecipient; + uint16 private s_feeInBasisPoints; + mapping(address token => bool isSupported) private s_supportedTokens; + mapping(address token => uint256 collectedFees) private s_collectedFees; + + constructor(uint16 _feeInBasisPoints) Ownable(msg.sender) { + if (_feeInBasisPoints > BASIS_POINTS) revert PayStreams__InvalidFeeInBasisPoints(_feeInBasisPoints); + s_feeInBasisPoints = _feeInBasisPoints; + } + + function setFeeRecipient(address _feeRecipient) external onlyOwner { + if (_feeRecipient == address(0)) revert PayStreams__AddressZero(); + s_feeRecipient = _feeRecipient; + + emit FeeRecipientSet(_feeRecipient); + } + + function setFeeInBasisPoints(uint16 _feeInBasisPoints) external onlyOwner { + if (_feeInBasisPoints > BASIS_POINTS) revert PayStreams__InvalidFeeInBasisPoints(_feeInBasisPoints); + s_feeInBasisPoints = _feeInBasisPoints; + + emit FeeInBasisPointsSet(_feeInBasisPoints); + } + + function collectFee(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); + } + + 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); + } +} diff --git a/src/interfaces/IPayStreams.sol b/src/interfaces/IPayStreams.sol new file mode 100644 index 0000000..bdfe4e4 --- /dev/null +++ b/src/interfaces/IPayStreams.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +interface IPayStreams { + struct StreamData { + address streamer; + address recipient; + address token; + uint256 amount; + uint256 startingTimestamp; + } + + event FeeRecipientSet(address newFeeRecipient); + event FeeInBasisPointsSet(uint16 _feeInBasisPoints); + event TokenSet(address token, bool support); + event FeesCollected(address token, uint256 amount); + + error PayStreams__AddressZero(); + error PayStreams__InvalidFeeInBasisPoints(uint16 feeInBasisPoints); + error PayStreams__UnsupportedToken(address token); + error PayStreams__InsufficientCollectedFees(); +} From 61c4cef159adeac16f2f40b1994ebec3161f0c9f Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Sat, 26 Oct 2024 23:15:36 +0530 Subject: [PATCH 04/24] feat: Write the logic to create streams, set recipient vault, and hook config --- src/PayStreams.sol | 139 +++++++++++++++++++++++++++++++++ src/interfaces/IHooks.sol | 10 +++ src/interfaces/IPayStreams.sol | 17 ++++ 3 files changed, 166 insertions(+) create mode 100644 src/interfaces/IHooks.sol diff --git a/src/PayStreams.sol b/src/PayStreams.sol index e7c1b33..3e15b1f 100644 --- a/src/PayStreams.sol +++ b/src/PayStreams.sol @@ -6,6 +6,7 @@ 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"; contract PyStreams is Ownable, IPayStreams { @@ -18,6 +19,10 @@ contract PyStreams is Ownable, IPayStreams { mapping(address token => bool isSupported) private s_supportedTokens; mapping(address token => uint256 collectedFees) private s_collectedFees; + mapping(bytes32 streamHash => StreamData streamData) private s_streamData; + mapping(address user => mapping(bytes32 streamHash => HookConfig hookConfig)) private s_hookConfig; + mapping(address streamer => string[] tags) private s_streamerToTags; + constructor(uint16 _feeInBasisPoints) Ownable(msg.sender) { if (_feeInBasisPoints > BASIS_POINTS) revert PayStreams__InvalidFeeInBasisPoints(_feeInBasisPoints); s_feeInBasisPoints = _feeInBasisPoints; @@ -58,4 +63,138 @@ contract PyStreams is Ownable, IPayStreams { emit TokenSet(_token, _support); } + + function createStream( + StreamData calldata _streamData, + HookConfig calldata _streamerHookConfig, + string memory _tag + ) + external + { + 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); + s_streamData[streamHash] = _streamData; + s_streamerToTags[msg.sender].push(_tag); + s_hookConfig[msg.sender][streamHash] = _streamerHookConfig; + + if (_streamerHookConfig.callAfterStreamCreated && _streamData.streamerVault != address(0)) { + IHooks(_streamData.streamerVault).afterStreamCreated(streamHash); + } + } + + function setRecipientVaultForStream(bytes32 _streamHash, address _vault) external { + StreamData memory streamData = s_streamData[_streamHash]; + if (msg.sender != streamData.recipient) revert PayStreams__Unauthorized(); + + s_streamData[_streamHash].recipientVault = _vault; + + emit RecipientVaultSet(msg.sender, _streamHash, _vault); + } + + function setHookConfigForStream(bytes32 _streamHash, HookConfig calldata _recipientHookConfig) external { + StreamData memory streamData = s_streamData[_streamHash]; + if (msg.sender != streamData.streamer || msg.sender != streamData.recipient) revert PayStreams__Unauthorized(); + + s_hookConfig[msg.sender][_streamHash] = _recipientHookConfig; + + emit HookConfigSet(msg.sender, _streamHash); + } + + function collectFundsFromStream(bytes32 _streamHash) external { + StreamData memory streamData = s_streamData[_streamHash]; + if (msg.sender != streamData.recipient) revert PayStreams__Unauthorized(); + + if (streamData.startingTimestamp < block.timestamp) { + revert PayStreams__StreamHasNotStartedYet(_streamHash, streamData.startingTimestamp); + } + uint256 amountToCollect = ( + streamData.amount * (block.timestamp - streamData.startingTimestamp) / streamData.duration + ) - streamData.totalStreamed; + if (amountToCollect > streamData.amount) amountToCollect = streamData.amount - streamData.totalStreamed; + + s_streamData[_streamHash].totalStreamed += amountToCollect; + + HookConfig memory streamerHookConfig = s_hookConfig[streamData.streamer][_streamHash]; + HookConfig memory recipientHookConfig = s_hookConfig[streamData.streamer][_streamHash]; + if (streamData.streamerVault != address(0)) { + if (streamerHookConfig.callBeforeFundsCollected) { + IHooks(streamData.streamerVault).beforeFundsCollected(_streamHash); + } + } + if (streamData.recipientVault != address(0)) { + if (recipientHookConfig.callBeforeFundsCollected) { + IHooks(streamData.streamerVault).beforeFundsCollected(_streamHash); + } + } + if (streamData.streamerVault != address(0)) { + streamData.recipientVault != address(0) + ? IERC20(streamData.token).safeTransferFrom( + streamData.streamerVault, streamData.recipientVault, amountToCollect + ) + : IERC20(streamData.token).safeTransferFrom(streamData.streamerVault, streamData.recipient, amountToCollect); + } else { + streamData.recipientVault != address(0) + ? IERC20(streamData.token).safeTransferFrom(streamData.streamer, streamData.recipientVault, amountToCollect) + : IERC20(streamData.token).safeTransferFrom(streamData.streamer, streamData.recipient, amountToCollect); + } + if (streamData.streamerVault != address(0)) { + if (streamerHookConfig.callAfterFundsCollected) { + IHooks(streamData.streamerVault).afterFundsCollected(_streamHash); + } + } + if (streamData.recipientVault != address(0)) { + if (recipientHookConfig.callAfterFundsCollected) { + IHooks(streamData.streamerVault).afterFundsCollected(_streamHash); + } + } + + emit FundsCollectedFromStream(_streamHash, amountToCollect); + } + + function getFeeRecipient() external view returns (address) { + return s_feeRecipient; + } + + function getFeeInBasisPoints() external view returns (uint256) { + return s_feeInBasisPoints; + } + + function isSupportedToken(address _token) external view returns (bool) { + return s_supportedTokens[_token]; + } + + function getCollectedFees(address _token) external view returns (uint256) { + return s_collectedFees[_token]; + } + + function getStreamData(bytes32 _streamHash) external view returns (StreamData memory) { + return s_streamData[_streamHash]; + } + + function getHookConfig(address _user, bytes32 _streamHash) external view returns (HookConfig memory) { + return s_hookConfig[_user][_streamHash]; + } + + function getTags(address _streamer) external view returns (string[] memory) { + return s_streamerToTags[_streamer]; + } + + function getStreamHash( + address _streamer, + address _recipient, + address _token, + string memory _tag + ) + public + pure + returns (bytes32) + { + return keccak256(abi.encode(_streamer, _recipient, _token, _tag)); + } } diff --git a/src/interfaces/IHooks.sol b/src/interfaces/IHooks.sol new file mode 100644 index 0000000..48fd865 --- /dev/null +++ b/src/interfaces/IHooks.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +interface IHooks { + function afterStreamCreated(bytes32 _streamHash) external; + + function beforeFundsCollected(bytes32 _streamHash) external; + + function afterFundsCollected(bytes32 _streamHash) external; +} diff --git a/src/interfaces/IPayStreams.sol b/src/interfaces/IPayStreams.sol index bdfe4e4..cd405b9 100644 --- a/src/interfaces/IPayStreams.sol +++ b/src/interfaces/IPayStreams.sol @@ -4,19 +4,36 @@ pragma solidity 0.8.24; interface IPayStreams { struct StreamData { address streamer; + address streamerVault; address recipient; + address recipientVault; address token; uint256 amount; uint256 startingTimestamp; + uint256 duration; + uint256 totalStreamed; + } + + struct HookConfig { + bool callAfterStreamCreated; + bool callBeforeFundsCollected; + bool callAfterFundsCollected; } event FeeRecipientSet(address newFeeRecipient); event FeeInBasisPointsSet(uint16 _feeInBasisPoints); event TokenSet(address token, bool support); event FeesCollected(address token, uint256 amount); + event StreamCreated(address by, bytes32 streamHash, string tag); + event RecipientVaultSet(address by, bytes32 streamHash, address vault); + event HookConfigSet(address by, bytes32 streamHash); + event FundsCollectedFromStream(bytes32 streamHash, uint256 amountToCollect); error PayStreams__AddressZero(); error PayStreams__InvalidFeeInBasisPoints(uint16 feeInBasisPoints); error PayStreams__UnsupportedToken(address token); error PayStreams__InsufficientCollectedFees(); + error PayStreams__InvalidStreamConfig(); + error PayStreams__Unauthorized(); + error PayStreams__StreamHasNotStartedYet(bytes32 streamHash, uint256 startingTimestamp); } From fcda6ee82109776549823f4825f6d10ad6513cea Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Sun, 27 Oct 2024 15:58:40 +0530 Subject: [PATCH 05/24] chore: Update interfaces --- src/interfaces/IHooks.sol | 20 ++++++++++++++++++-- src/interfaces/IPayStreams.sol | 23 +++++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/interfaces/IHooks.sol b/src/interfaces/IHooks.sol index 48fd865..43c910e 100644 --- a/src/interfaces/IHooks.sol +++ b/src/interfaces/IHooks.sol @@ -4,7 +4,23 @@ pragma solidity 0.8.24; interface IHooks { function afterStreamCreated(bytes32 _streamHash) external; - function beforeFundsCollected(bytes32 _streamHash) external; + function beforeFundsCollected(bytes32 _streamHash, uint256 _amount) external; - function afterFundsCollected(bytes32 _streamHash) external; + function afterFundsCollected(bytes32 _streamHash, uint256 _amount) external; + + function beforeStreamUpdated(bytes32 _streamHash) external; + + function afterStreamUpdated(bytes32 _streamHash) external; + + function beforeStreamClosed(bytes32 _streamHash) external; + + function afterStreamClosed(bytes32 _streamHash) external; + + function beforeStreamPaused(bytes32 _streamHash) external; + + function afterStreamPaused(bytes32 _streamHash) external; + + function beforeStreamUnPaused(bytes32 _streamHash) external; + + function afterStreamUnPaused(bytes32 _streamHash) external; } diff --git a/src/interfaces/IPayStreams.sol b/src/interfaces/IPayStreams.sol index cd405b9..741a54e 100644 --- a/src/interfaces/IPayStreams.sol +++ b/src/interfaces/IPayStreams.sol @@ -12,28 +12,47 @@ interface IPayStreams { uint256 startingTimestamp; uint256 duration; uint256 totalStreamed; + bool recurring; + bool isPaused; } struct HookConfig { bool callAfterStreamCreated; bool callBeforeFundsCollected; bool callAfterFundsCollected; + bool callBeforeStreamUpdated; + bool callAfterStreamUpdated; + bool callBeforeStreamClosed; + bool callAfterStreamClosed; + bool callBeforeStreamPaused; + bool callAfterStreamPaused; + bool callBeforeStreamUnPaused; + bool callAfterStreamUnPaused; } event FeeRecipientSet(address newFeeRecipient); event FeeInBasisPointsSet(uint16 _feeInBasisPoints); event TokenSet(address token, bool support); + event StreamCreated(bytes32 streamHash); event FeesCollected(address token, uint256 amount); - event StreamCreated(address by, bytes32 streamHash, string tag); - event RecipientVaultSet(address by, bytes32 streamHash, address vault); + event VaultSet(address by, bytes32 streamHash, address vault); event HookConfigSet(address by, bytes32 streamHash); event FundsCollectedFromStream(bytes32 streamHash, uint256 amountToCollect); + event StreamUpdated( + bytes32 streamHash, uint256 amount, uint256 startingTimestamp, uint256 duration, bool recurring + ); + event StreamCancelled(bytes32 streamHash); + event StreamPaused(bytes32 streamHash); + event StreamUnPaused(bytes32 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__StreamPaused(); + error PayStreams__ZeroAmountToCollect(); } From 4d800e773c3dfd97b26d1f2e5e4bfed6711455b4 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Sun, 27 Oct 2024 15:59:56 +0530 Subject: [PATCH 06/24] feat: Finish writing the chore functionalities This includes creating, collecting funds, updating, cancelling, pausing, and unpausing a stream --- src/PayStreams.sol | 327 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 278 insertions(+), 49 deletions(-) diff --git a/src/PayStreams.sol b/src/PayStreams.sol index 3e15b1f..7f649d5 100644 --- a/src/PayStreams.sol +++ b/src/PayStreams.sol @@ -14,27 +14,45 @@ contract PyStreams is Ownable, IPayStreams { uint16 private constant BASIS_POINTS = 10_000; - address private s_feeRecipient; + /** + * @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; - mapping(address streamer => string[] tags) private s_streamerToTags; - + /** + * @dev Utility storage for the stream hashes. + */ + mapping(address streamer => bytes32[] streamHashes) private s_streamerToStreamHashes; + + /** + * @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; } - function setFeeRecipient(address _feeRecipient) external onlyOwner { - if (_feeRecipient == address(0)) revert PayStreams__AddressZero(); - s_feeRecipient = _feeRecipient; - - emit FeeRecipientSet(_feeRecipient); - } - + /** + * @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; @@ -42,7 +60,12 @@ contract PyStreams is Ownable, IPayStreams { emit FeeInBasisPointsSet(_feeInBasisPoints); } - function collectFee(address _token, uint256 _amount) external onlyOwner { + /** + * @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(); @@ -52,6 +75,12 @@ contract PyStreams is Ownable, IPayStreams { 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(); @@ -64,127 +93,327 @@ contract PyStreams is Ownable, IPayStreams { emit TokenSet(_token, _support); } - function createStream( + /** + * @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 + || _streamData.totalStreamed != 0 || _streamData.isPaused == true ) 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_streamerToTags[msg.sender].push(_tag); + s_streamerToStreamHashes[msg.sender].push(streamHash); s_hookConfig[msg.sender][streamHash] = _streamerHookConfig; - if (_streamerHookConfig.callAfterStreamCreated && _streamData.streamerVault != address(0)) { + if (_streamData.streamerVault != address(0) && _streamerHookConfig.callAfterStreamCreated) { IHooks(_streamData.streamerVault).afterStreamCreated(streamHash); } + + emit StreamCreated(streamHash); + + return streamHash; } - function setRecipientVaultForStream(bytes32 _streamHash, address _vault) external { + /** + * @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.recipient) revert PayStreams__Unauthorized(); + if (msg.sender != streamData.streamer || msg.sender != streamData.recipient) revert PayStreams__Unauthorized(); - s_streamData[_streamHash].recipientVault = _vault; + msg.sender == streamData.streamer + ? s_streamData[_streamHash].streamerVault = _vault + : s_streamData[_streamHash].recipientVault = _vault; - emit RecipientVaultSet(msg.sender, _streamHash, _vault); + emit VaultSet(msg.sender, _streamHash, _vault); } - function setHookConfigForStream(bytes32 _streamHash, HookConfig calldata _recipientHookConfig) external { + /** + * @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] = _recipientHookConfig; + 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 (msg.sender != streamData.recipient) revert PayStreams__Unauthorized(); - if (streamData.startingTimestamp < block.timestamp) { + if (streamData.startingTimestamp > block.timestamp) { revert PayStreams__StreamHasNotStartedYet(_streamHash, streamData.startingTimestamp); } + if (streamData.isPaused) revert PayStreams__StreamPaused(); uint256 amountToCollect = ( streamData.amount * (block.timestamp - streamData.startingTimestamp) / streamData.duration ) - streamData.totalStreamed; - if (amountToCollect > streamData.amount) amountToCollect = streamData.amount - streamData.totalStreamed; + if (amountToCollect > streamData.amount && !streamData.recurring) { + amountToCollect = streamData.amount - streamData.totalStreamed; + } + if (amountToCollect == 0) revert PayStreams__ZeroAmountToCollect(); + uint256 feeAmount = (amountToCollect * s_feeInBasisPoints) / BASIS_POINTS; s_streamData[_streamHash].totalStreamed += amountToCollect; HookConfig memory streamerHookConfig = s_hookConfig[streamData.streamer][_streamHash]; - HookConfig memory recipientHookConfig = s_hookConfig[streamData.streamer][_streamHash]; - if (streamData.streamerVault != address(0)) { - if (streamerHookConfig.callBeforeFundsCollected) { - IHooks(streamData.streamerVault).beforeFundsCollected(_streamHash); - } + HookConfig memory recipientHookConfig = s_hookConfig[streamData.recipient][_streamHash]; + + if (streamData.streamerVault != address(0) && streamerHookConfig.callBeforeFundsCollected) { + IHooks(streamData.streamerVault).beforeFundsCollected(_streamHash, amountToCollect); } - if (streamData.recipientVault != address(0)) { - if (recipientHookConfig.callBeforeFundsCollected) { - IHooks(streamData.streamerVault).beforeFundsCollected(_streamHash); - } + if (streamData.recipientVault != address(0) && recipientHookConfig.callBeforeFundsCollected) { + IHooks(streamData.streamerVault).beforeFundsCollected(_streamHash, amountToCollect); } + if (streamData.streamerVault != address(0)) { streamData.recipientVault != address(0) ? IERC20(streamData.token).safeTransferFrom( - streamData.streamerVault, streamData.recipientVault, amountToCollect + streamData.streamerVault, streamData.recipientVault, amountToCollect - feeAmount ) - : IERC20(streamData.token).safeTransferFrom(streamData.streamerVault, streamData.recipient, amountToCollect); + : IERC20(streamData.token).safeTransferFrom( + streamData.streamerVault, streamData.recipient, amountToCollect - feeAmount + ); + + s_collectedFees[streamData.token] += feeAmount; + IERC20(streamData.token).safeTransferFrom(streamData.streamerVault, address(this), feeAmount); } else { streamData.recipientVault != address(0) - ? IERC20(streamData.token).safeTransferFrom(streamData.streamer, streamData.recipientVault, amountToCollect) - : IERC20(streamData.token).safeTransferFrom(streamData.streamer, streamData.recipient, amountToCollect); + ? IERC20(streamData.token).safeTransferFrom( + streamData.streamer, streamData.recipientVault, amountToCollect - feeAmount + ) + : IERC20(streamData.token).safeTransferFrom( + streamData.streamer, streamData.recipient, amountToCollect - feeAmount + ); + + s_collectedFees[streamData.token] += feeAmount; + IERC20(streamData.token).safeTransferFrom(streamData.streamer, address(this), feeAmount); } - if (streamData.streamerVault != address(0)) { - if (streamerHookConfig.callAfterFundsCollected) { - IHooks(streamData.streamerVault).afterFundsCollected(_streamHash); - } + + if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterFundsCollected) { + IHooks(streamData.streamerVault).afterFundsCollected(_streamHash, amountToCollect); } - if (streamData.recipientVault != address(0)) { - if (recipientHookConfig.callAfterFundsCollected) { - IHooks(streamData.streamerVault).afterFundsCollected(_streamHash); - } + if (streamData.recipientVault != address(0) && recipientHookConfig.callAfterFundsCollected) { + IHooks(streamData.streamerVault).afterFundsCollected(_streamHash, amountToCollect); } emit FundsCollectedFromStream(_streamHash, amountToCollect); } - function getFeeRecipient() external view returns (address) { - return s_feeRecipient; + /** + * @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); + } + + delete s_streamData[_streamHash]; + + 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 Allows the creator of the stream to pause the stream. + * @param _streamHash The hash of the stream. + */ + function pauseStream(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).beforeStreamPaused(_streamHash); + } + + s_streamData[_streamHash].isPaused = true; + + if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterStreamClosed) { + IHooks(streamData.streamerVault).afterStreamPaused(_streamHash); + } + if (streamData.recipientVault != address(0) && recipientHookConfig.callAfterStreamClosed) { + IHooks(streamData.recipientVault).afterStreamPaused(_streamHash); + } + + emit StreamPaused(_streamHash); + } + + /** + * @notice Allows the creator of the stream to unpause the stream. + * @param _streamHash The hash of the stream. + */ + function unPauseStream(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).beforeStreamUnPaused(_streamHash); + } + + s_streamData[_streamHash].isPaused = true; + + if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterStreamClosed) { + IHooks(streamData.streamerVault).afterStreamUnPaused(_streamHash); + } + if (streamData.recipientVault != address(0) && recipientHookConfig.callAfterStreamClosed) { + IHooks(streamData.recipientVault).afterStreamUnPaused(_streamHash); + } + + emit StreamUnPaused(_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 (uint256) { 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]; } - function getTags(address _streamer) external view returns (string[] memory) { - return s_streamerToTags[_streamer]; + /** + * @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 getStreamHashes(address _streamer) external view returns (bytes32[] memory) { + return s_streamerToStreamHashes[_streamer]; } + /** + * @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, From e437d9843743d7ce6f48ea3cfd693e5ac303c46a Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Sun, 27 Oct 2024 20:53:32 +0530 Subject: [PATCH 07/24] feat: Add a deployment script --- script/DeployPayStreams.s.sol | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 script/DeployPayStreams.s.sol diff --git a/script/DeployPayStreams.s.sol b/script/DeployPayStreams.s.sol new file mode 100644 index 0000000..5012379 --- /dev/null +++ b/script/DeployPayStreams.s.sol @@ -0,0 +1,18 @@ +// 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; + + function run() external returns (address) { + vm.startBroadcast(); + PayStreams stream = new PayStreams(feeInBasisPoints); + vm.stopBroadcast(); + + return address(stream); + } +} From 0690b3996edc40833c034eecf7e19bcf9bab3ebf Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Sun, 27 Oct 2024 20:53:52 +0530 Subject: [PATCH 08/24] chore: Add a command to deploy locally --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6c132e3..215baac 100644 --- a/Makefile +++ b/Makefile @@ -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 From 442f68373e2d5895fe27661e0e5b0d5b4372bf46 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Sun, 27 Oct 2024 21:18:54 +0530 Subject: [PATCH 09/24] fix: Fix some contract logic issues --- src/PayStreams.sol | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/PayStreams.sol b/src/PayStreams.sol index 7f649d5..7a9189e 100644 --- a/src/PayStreams.sol +++ b/src/PayStreams.sol @@ -9,7 +9,7 @@ import { SafeERC20 } from "@openzeppelin/token/ERC20/utils/SafeERC20.sol"; import { IHooks } from "./interfaces/IHooks.sol"; import { IPayStreams } from "./interfaces/IPayStreams.sol"; -contract PyStreams is Ownable, IPayStreams { +contract PayStreams is Ownable, IPayStreams { using SafeERC20 for IERC20; uint16 private constant BASIS_POINTS = 10_000; @@ -36,9 +36,13 @@ contract PyStreams is Ownable, IPayStreams { */ mapping(address user => mapping(bytes32 streamHash => HookConfig hookConfig)) private s_hookConfig; /** - * @dev Utility storage for the stream hashes. + * @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. @@ -120,6 +124,7 @@ contract PyStreams is Ownable, IPayStreams { if (s_streamData[streamHash].streamer != address(0)) revert PayStreams__StreamAlreadyExists(streamHash); s_streamData[streamHash] = _streamData; s_streamerToStreamHashes[msg.sender].push(streamHash); + s_streamerToStreamHashes[_streamData.recipient].push(streamHash); s_hookConfig[msg.sender][streamHash] = _streamerHookConfig; if (_streamData.streamerVault != address(0) && _streamerHookConfig.callAfterStreamCreated) { @@ -174,27 +179,29 @@ contract PyStreams is Ownable, IPayStreams { revert PayStreams__StreamHasNotStartedYet(_streamHash, streamData.startingTimestamp); } if (streamData.isPaused) revert PayStreams__StreamPaused(); + uint256 amountToCollect = ( streamData.amount * (block.timestamp - streamData.startingTimestamp) / streamData.duration ) - streamData.totalStreamed; + if (amountToCollect == 0) revert PayStreams__ZeroAmountToCollect(); if (amountToCollect > streamData.amount && !streamData.recurring) { amountToCollect = streamData.amount - streamData.totalStreamed; } - - if (amountToCollect == 0) revert PayStreams__ZeroAmountToCollect(); uint256 feeAmount = (amountToCollect * s_feeInBasisPoints) / BASIS_POINTS; + 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); + IHooks(streamData.streamerVault).beforeFundsCollected(_streamHash, amountToCollect, feeAmount); } if (streamData.recipientVault != address(0) && recipientHookConfig.callBeforeFundsCollected) { - IHooks(streamData.streamerVault).beforeFundsCollected(_streamHash, amountToCollect); + 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( @@ -204,7 +211,6 @@ contract PyStreams is Ownable, IPayStreams { streamData.streamerVault, streamData.recipient, amountToCollect - feeAmount ); - s_collectedFees[streamData.token] += feeAmount; IERC20(streamData.token).safeTransferFrom(streamData.streamerVault, address(this), feeAmount); } else { streamData.recipientVault != address(0) @@ -215,15 +221,14 @@ contract PyStreams is Ownable, IPayStreams { streamData.streamer, streamData.recipient, amountToCollect - feeAmount ); - s_collectedFees[streamData.token] += feeAmount; IERC20(streamData.token).safeTransferFrom(streamData.streamer, address(this), feeAmount); } if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterFundsCollected) { - IHooks(streamData.streamerVault).afterFundsCollected(_streamHash, amountToCollect); + IHooks(streamData.streamerVault).afterFundsCollected(_streamHash, amountToCollect, feeAmount); } if (streamData.recipientVault != address(0) && recipientHookConfig.callAfterFundsCollected) { - IHooks(streamData.streamerVault).afterFundsCollected(_streamHash, amountToCollect); + IHooks(streamData.streamerVault).afterFundsCollected(_streamHash, amountToCollect, feeAmount); } emit FundsCollectedFromStream(_streamHash, amountToCollect); @@ -340,7 +345,7 @@ contract PyStreams is Ownable, IPayStreams { IHooks(streamData.streamerVault).beforeStreamUnPaused(_streamHash); } - s_streamData[_streamHash].isPaused = true; + s_streamData[_streamHash].isPaused = false; if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterStreamClosed) { IHooks(streamData.streamerVault).afterStreamUnPaused(_streamHash); @@ -402,10 +407,19 @@ contract PyStreams is Ownable, IPayStreams { * @param _streamer The stream creator's address. * @return An array of stream hashes. */ - function getStreamHashes(address _streamer) external view returns (bytes32[] memory) { + 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. From 8569f1b0877a9493a33cc879e432027d7a931775 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Sun, 27 Oct 2024 21:19:34 +0530 Subject: [PATCH 10/24] refactor: Add a fee amount field to the hooks called before and after funds are collected --- src/interfaces/IHooks.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/interfaces/IHooks.sol b/src/interfaces/IHooks.sol index 43c910e..ae77ed0 100644 --- a/src/interfaces/IHooks.sol +++ b/src/interfaces/IHooks.sol @@ -4,9 +4,9 @@ pragma solidity 0.8.24; interface IHooks { function afterStreamCreated(bytes32 _streamHash) external; - function beforeFundsCollected(bytes32 _streamHash, uint256 _amount) external; + function beforeFundsCollected(bytes32 _streamHash, uint256 _amount, uint256 _feeAmount) external; - function afterFundsCollected(bytes32 _streamHash, uint256 _amount) external; + function afterFundsCollected(bytes32 _streamHash, uint256 _amount, uint256 _feeAmount) external; function beforeStreamUpdated(bytes32 _streamHash) external; From 5bd38556d804623bb4d335a6c69d346fd9f0279c Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 01:16:54 +0530 Subject: [PATCH 11/24] fix: Return a uint16 instead of a uint256 from the getFeeInBasisPoints function --- src/PayStreams.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PayStreams.sol b/src/PayStreams.sol index 7a9189e..aafd4e1 100644 --- a/src/PayStreams.sol +++ b/src/PayStreams.sol @@ -361,7 +361,7 @@ contract PayStreams is Ownable, IPayStreams { * @notice Gets the fee value for streaming in basis points. * @return The fee value for streaming in basis points. */ - function getFeeInBasisPoints() external view returns (uint256) { + function getFeeInBasisPoints() external view returns (uint16) { return s_feeInBasisPoints; } From a1deaed127a27f044e28fce29b6f0848ac5b00b1 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 11:00:12 +0530 Subject: [PATCH 12/24] fix: Remove the pause/unpause functionality Also add NatSpec comments to the StreamData and HookConfig structs --- src/PayStreams.sol | 57 +--------------------------------- src/interfaces/IHooks.sol | 8 ----- src/interfaces/IPayStreams.sol | 38 ++++++++++++++++++----- 3 files changed, 31 insertions(+), 72 deletions(-) diff --git a/src/PayStreams.sol b/src/PayStreams.sol index aafd4e1..db4e758 100644 --- a/src/PayStreams.sol +++ b/src/PayStreams.sol @@ -117,7 +117,7 @@ contract PayStreams is Ownable, IPayStreams { _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 || _streamData.isPaused == true + || _streamData.totalStreamed != 0 ) revert PayStreams__InvalidStreamConfig(); bytes32 streamHash = getStreamHash(msg.sender, _streamData.recipient, _streamData.token, _tag); @@ -178,7 +178,6 @@ contract PayStreams is Ownable, IPayStreams { if (streamData.startingTimestamp > block.timestamp) { revert PayStreams__StreamHasNotStartedYet(_streamHash, streamData.startingTimestamp); } - if (streamData.isPaused) revert PayStreams__StreamPaused(); uint256 amountToCollect = ( streamData.amount * (block.timestamp - streamData.startingTimestamp) / streamData.duration @@ -303,60 +302,6 @@ contract PayStreams is Ownable, IPayStreams { emit StreamCancelled(_streamHash); } - /** - * @notice Allows the creator of the stream to pause the stream. - * @param _streamHash The hash of the stream. - */ - function pauseStream(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).beforeStreamPaused(_streamHash); - } - - s_streamData[_streamHash].isPaused = true; - - if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterStreamClosed) { - IHooks(streamData.streamerVault).afterStreamPaused(_streamHash); - } - if (streamData.recipientVault != address(0) && recipientHookConfig.callAfterStreamClosed) { - IHooks(streamData.recipientVault).afterStreamPaused(_streamHash); - } - - emit StreamPaused(_streamHash); - } - - /** - * @notice Allows the creator of the stream to unpause the stream. - * @param _streamHash The hash of the stream. - */ - function unPauseStream(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).beforeStreamUnPaused(_streamHash); - } - - s_streamData[_streamHash].isPaused = false; - - if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterStreamClosed) { - IHooks(streamData.streamerVault).afterStreamUnPaused(_streamHash); - } - if (streamData.recipientVault != address(0) && recipientHookConfig.callAfterStreamClosed) { - IHooks(streamData.recipientVault).afterStreamUnPaused(_streamHash); - } - - emit StreamUnPaused(_streamHash); - } - /** * @notice Gets the fee value for streaming in basis points. * @return The fee value for streaming in basis points. diff --git a/src/interfaces/IHooks.sol b/src/interfaces/IHooks.sol index ae77ed0..d803a3a 100644 --- a/src/interfaces/IHooks.sol +++ b/src/interfaces/IHooks.sol @@ -15,12 +15,4 @@ interface IHooks { function beforeStreamClosed(bytes32 _streamHash) external; function afterStreamClosed(bytes32 _streamHash) external; - - function beforeStreamPaused(bytes32 _streamHash) external; - - function afterStreamPaused(bytes32 _streamHash) external; - - function beforeStreamUnPaused(bytes32 _streamHash) external; - - function afterStreamUnPaused(bytes32 _streamHash) external; } diff --git a/src/interfaces/IPayStreams.sol b/src/interfaces/IPayStreams.sol index 741a54e..dab324d 100644 --- a/src/interfaces/IPayStreams.sol +++ b/src/interfaces/IPayStreams.sol @@ -2,6 +2,19 @@ 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; @@ -13,9 +26,25 @@ interface IPayStreams { uint256 duration; uint256 totalStreamed; bool recurring; - bool isPaused; } + /** + * @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; @@ -24,10 +53,6 @@ interface IPayStreams { bool callAfterStreamUpdated; bool callBeforeStreamClosed; bool callAfterStreamClosed; - bool callBeforeStreamPaused; - bool callAfterStreamPaused; - bool callBeforeStreamUnPaused; - bool callAfterStreamUnPaused; } event FeeRecipientSet(address newFeeRecipient); @@ -42,8 +67,6 @@ interface IPayStreams { bytes32 streamHash, uint256 amount, uint256 startingTimestamp, uint256 duration, bool recurring ); event StreamCancelled(bytes32 streamHash); - event StreamPaused(bytes32 streamHash); - event StreamUnPaused(bytes32 streamHash); error PayStreams__AddressZero(); error PayStreams__InvalidFeeInBasisPoints(uint16 feeInBasisPoints); @@ -53,6 +76,5 @@ interface IPayStreams { error PayStreams__StreamAlreadyExists(bytes32 streamHash); error PayStreams__Unauthorized(); error PayStreams__StreamHasNotStartedYet(bytes32 streamHash, uint256 startingTimestamp); - error PayStreams__StreamPaused(); error PayStreams__ZeroAmountToCollect(); } From 77b42c7aa7cfe4b4e87c7c16ee2678df9d5c4091 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 11:02:45 +0530 Subject: [PATCH 13/24] chore: Whitelist PYUSD for streaming on deployment script --- script/DeployPayStreams.s.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/script/DeployPayStreams.s.sol b/script/DeployPayStreams.s.sol index 5012379..8171563 100644 --- a/script/DeployPayStreams.s.sol +++ b/script/DeployPayStreams.s.sol @@ -7,10 +7,12 @@ 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); From e5fba846a40d3721b68ceedb774a2a9f5af300c4 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 11:29:28 +0530 Subject: [PATCH 14/24] chore: Add more NatSpec to contract --- src/PayStreams.sol | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/PayStreams.sol b/src/PayStreams.sol index db4e758..3674119 100644 --- a/src/PayStreams.sol +++ b/src/PayStreams.sol @@ -9,6 +9,12 @@ 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; From e5fc66ddfdb8042682bc5ad3cb41fa247bb17ddf Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 12:01:00 +0530 Subject: [PATCH 15/24] chore: Add functions to the interface --- src/interfaces/IPayStreams.sol | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/interfaces/IPayStreams.sol b/src/interfaces/IPayStreams.sol index dab324d..96a2d0b 100644 --- a/src/interfaces/IPayStreams.sol +++ b/src/interfaces/IPayStreams.sol @@ -77,4 +77,43 @@ interface IPayStreams { 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); } From 57ed5cff4af329b44599b22ec3ab8f38fdbd786d Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 12:01:24 +0530 Subject: [PATCH 16/24] feat: Add base vault implementation --- src/utils/BaseVault.sol | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/utils/BaseVault.sol 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 { } +} From 76c72a8cb1472429ce1f8173b6d513a94be213a6 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 12:01:51 +0530 Subject: [PATCH 17/24] feat: Add a payment splitter hook example --- src/exampleHooks/PaymentSplitterVault.sol | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/exampleHooks/PaymentSplitterVault.sol diff --git a/src/exampleHooks/PaymentSplitterVault.sol b/src/exampleHooks/PaymentSplitterVault.sol new file mode 100644 index 0000000..f6559c9 --- /dev/null +++ b/src/exampleHooks/PaymentSplitterVault.sol @@ -0,0 +1,37 @@ +// 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 amount, address[] 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); + } +} From c2622564e5fc97544ea7680926cee121310a5e13 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 12:32:18 +0530 Subject: [PATCH 18/24] feat: Add a good README.md! --- README.md | 159 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 121 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 9265b45..c2b60d3 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. +

Soul Streams

-## 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 From 5979ebcaba3dfdae5c5b0eb9d3868c0c655c248f Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 12:34:30 +0530 Subject: [PATCH 19/24] fix: README.md fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c2b60d3..5895df0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Logo --> -

Soul Streams

+

PayStreams

PayStreams is a payment streaming service leveraging PyUSD, supercharged with hooks From acd7fcd9f170977e1d599effef41b368d6faa0d5 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 19:17:23 +0530 Subject: [PATCH 20/24] fix: Fix some bugs in PayStreams contract --- src/PayStreams.sol | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/PayStreams.sol b/src/PayStreams.sol index 3674119..785be05 100644 --- a/src/PayStreams.sol +++ b/src/PayStreams.sol @@ -130,7 +130,7 @@ contract PayStreams is Ownable, IPayStreams { if (s_streamData[streamHash].streamer != address(0)) revert PayStreams__StreamAlreadyExists(streamHash); s_streamData[streamHash] = _streamData; s_streamerToStreamHashes[msg.sender].push(streamHash); - s_streamerToStreamHashes[_streamData.recipient].push(streamHash); + s_recipientToStreamHashes[_streamData.recipient].push(streamHash); s_hookConfig[msg.sender][streamHash] = _streamerHookConfig; if (_streamData.streamerVault != address(0) && _streamerHookConfig.callAfterStreamCreated) { @@ -151,7 +151,7 @@ contract PayStreams is Ownable, IPayStreams { */ 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(); + if (msg.sender != streamData.streamer && msg.sender != streamData.recipient) revert PayStreams__Unauthorized(); msg.sender == streamData.streamer ? s_streamData[_streamHash].streamerVault = _vault @@ -167,7 +167,7 @@ contract PayStreams is Ownable, IPayStreams { */ 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(); + if (msg.sender != streamData.streamer && msg.sender != streamData.recipient) revert PayStreams__Unauthorized(); s_hookConfig[msg.sender][_streamHash] = _hookConfig; @@ -184,15 +184,8 @@ contract PayStreams is Ownable, IPayStreams { if (streamData.startingTimestamp > block.timestamp) { revert PayStreams__StreamHasNotStartedYet(_streamHash, streamData.startingTimestamp); } - - uint256 amountToCollect = ( - streamData.amount * (block.timestamp - streamData.startingTimestamp) / streamData.duration - ) - streamData.totalStreamed; + (uint256 amountToCollect, uint256 feeAmount) = getAmountToCollectFromStreamAndFeeToPay(_streamHash); if (amountToCollect == 0) revert PayStreams__ZeroAmountToCollect(); - if (amountToCollect > streamData.amount && !streamData.recurring) { - amountToCollect = streamData.amount - streamData.totalStreamed; - } - uint256 feeAmount = (amountToCollect * s_feeInBasisPoints) / BASIS_POINTS; s_streamData[_streamHash].totalStreamed += amountToCollect; @@ -391,4 +384,20 @@ contract PayStreams is Ownable, IPayStreams { { 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); + } } From c91aa136fca177a08ce3e43011a7905820c225fc Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 19:21:22 +0530 Subject: [PATCH 21/24] refactor: Make event fields indexed --- src/exampleHooks/PaymentSplitterVault.sol | 9 ++++++++- src/interfaces/IPayStreams.sol | 20 ++++++++++---------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/exampleHooks/PaymentSplitterVault.sol b/src/exampleHooks/PaymentSplitterVault.sol index f6559c9..8a79131 100644 --- a/src/exampleHooks/PaymentSplitterVault.sol +++ b/src/exampleHooks/PaymentSplitterVault.sol @@ -15,7 +15,8 @@ contract PaymentSplitterVault is BaseVault { address private s_payStreams; address[] private s_recipients; - event PaymentSplit(uint256 amount, address[] recipients); + event PaymentSplit(uint256 indexed amount, address[] indexed recipients); + event RecipientListUpdated(address[] indexed recipients); constructor(address _payStreams, address[] memory _recipients) { s_payStreams = _payStreams; @@ -34,4 +35,10 @@ contract PaymentSplitterVault is BaseVault { emit PaymentSplit(_amount - _feeAmount, recipients); } + + function updateRecipientList(address[] memory _recipients) external onlyOwner { + s_recipients = _recipients; + + emit RecipientListUpdated(_recipients); + } } diff --git a/src/interfaces/IPayStreams.sol b/src/interfaces/IPayStreams.sol index 96a2d0b..6b6708a 100644 --- a/src/interfaces/IPayStreams.sol +++ b/src/interfaces/IPayStreams.sol @@ -55,18 +55,18 @@ interface IPayStreams { bool callAfterStreamClosed; } - event FeeRecipientSet(address newFeeRecipient); - event FeeInBasisPointsSet(uint16 _feeInBasisPoints); - event TokenSet(address token, bool support); - event StreamCreated(bytes32 streamHash); - event FeesCollected(address token, uint256 amount); - event VaultSet(address by, bytes32 streamHash, address vault); - event HookConfigSet(address by, bytes32 streamHash); - event FundsCollectedFromStream(bytes32 streamHash, uint256 amountToCollect); + 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 streamHash, uint256 amount, uint256 startingTimestamp, uint256 duration, bool recurring + bytes32 indexed streamHash, uint256 amount, uint256 startingTimestamp, uint256 duration, bool recurring ); - event StreamCancelled(bytes32 streamHash); + event StreamCancelled(bytes32 indexed streamHash); error PayStreams__AddressZero(); error PayStreams__InvalidFeeInBasisPoints(uint16 feeInBasisPoints); From 8f5b0c4080fd73c829ead80d8e7ac364abdfd6e0 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Mon, 28 Oct 2024 19:29:28 +0530 Subject: [PATCH 22/24] refactor: Add the getAmountToCollectFromStreamAndFeeToPay() function to the contract interface --- src/interfaces/IPayStreams.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/interfaces/IPayStreams.sol b/src/interfaces/IPayStreams.sol index 6b6708a..a5a1ea6 100644 --- a/src/interfaces/IPayStreams.sol +++ b/src/interfaces/IPayStreams.sol @@ -116,4 +116,5 @@ interface IPayStreams { external pure returns (bytes32); + function getAmountToCollectFromStreamAndFeeToPay(bytes32 _streamHash) external view returns (uint256, uint256); } From 81aa310a9e9d12379dce073568174b062872fdc5 Mon Sep 17 00:00:00 2001 From: Sahil-Gujrati Date: Tue, 29 Oct 2024 11:21:22 +0530 Subject: [PATCH 23/24] fix: Do not delete the entire stream data when caancelling the stream --- src/PayStreams.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PayStreams.sol b/src/PayStreams.sol index 785be05..c898b04 100644 --- a/src/PayStreams.sol +++ b/src/PayStreams.sol @@ -289,7 +289,7 @@ contract PayStreams is Ownable, IPayStreams { IHooks(streamData.streamerVault).beforeStreamClosed(_streamHash); } - delete s_streamData[_streamHash]; + s_streamData[_streamHash].amount = 0; if (streamData.streamerVault != address(0) && streamerHookConfig.callAfterStreamClosed) { IHooks(streamData.streamerVault).afterStreamClosed(_streamHash); From 2ddd18caa137a6f964d61f088bb3b17e00939e42 Mon Sep 17 00:00:00 2001 From: Ola Hamid Date: Wed, 30 Oct 2024 08:05:28 +0100 Subject: [PATCH 24/24] feat:new tests added and 1 failed --- lib/openzeppelin-contracts | 2 +- test/unit/PayStream.t.sol | 655 ++++++++++++++++++ .../Helpers/initializeTokenAndActors.sol | 47 ++ test/utils/MOCKS/MockToken.sol | 43 ++ test/utils/MOCKS/MockpyUSD.sol | 60 ++ test/utils/MOCKS/makeHooks.sol | 53 ++ test/utils/MOCKS/makeValt2.sol | 24 + test/utils/MOCKS/makeVault.sol | 26 + 8 files changed, 909 insertions(+), 1 deletion(-) create mode 100644 test/unit/PayStream.t.sol create mode 100644 test/utils/Helpers/initializeTokenAndActors.sol create mode 100644 test/utils/MOCKS/MockToken.sol create mode 100644 test/utils/MOCKS/MockpyUSD.sol create mode 100644 test/utils/MOCKS/makeHooks.sol create mode 100644 test/utils/MOCKS/makeValt2.sol create mode 100644 test/utils/MOCKS/makeVault.sol diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index 69c8def..448efee 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit 69c8def5f222ff96f2b5beff05dfba996368aa79 +Subproject commit 448efeea6640bbbc09373f03fbc9c88e280147ba 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); + } + +} +