From 5d37aaedd2ecfb2051b1f8e6e5299111d38d604f Mon Sep 17 00:00:00 2001 From: smol-ninja Date: Wed, 17 Apr 2024 10:36:03 +0100 Subject: [PATCH] feat: add a staking template doc: update README.md build: update bun lockfile build: add "test" to scripts feat: staking template for Sablier NFTs feat: use custom errors feat: add onlyStreamOwner modifier style: disable camel-case check fix: claim functions test: add tests docs: add DISCLAIMER test: adding more tests refactor: remove whitespaces, order alphabetically refactor: based on Synthetix staking contract fix: lint issues doc: add assumption that only one type of stream is allowed feat: support staking of cancelable streams perf: _getAmountInStream function refactor: add period, capitalize sentences refactor: readability refactor: function names to improve clarity temp --- .solhint.json | 3 +- README.md | 1 + bun.lockb | Bin 42235 -> 42547 bytes package.json | 3 +- src/StakeSablierNFT.sol | 347 ++++++++++++++++++ test/stake-sablier-nft/StakeSablierNFT.t.sol | 160 ++++++++ test/stake-sablier-nft/claim/claim.t.sol | 158 ++++++++ test/stake-sablier-nft/claim/claim.tree | 20 + test/stake-sablier-nft/stake/stake.t.sol | 65 ++++ test/stake-sablier-nft/stake/stake.tree | 12 + test/stake-sablier-nft/unstake/unstake.t.sol | 55 +++ test/stake-sablier-nft/unstake/unstake.tree | 9 + .../updateClaimAmount.t.sol | 75 ++++ .../updateClaimAmount.tree | 11 + 14 files changed, 917 insertions(+), 2 deletions(-) create mode 100644 src/StakeSablierNFT.sol create mode 100644 test/stake-sablier-nft/StakeSablierNFT.t.sol create mode 100644 test/stake-sablier-nft/claim/claim.t.sol create mode 100644 test/stake-sablier-nft/claim/claim.tree create mode 100644 test/stake-sablier-nft/stake/stake.t.sol create mode 100644 test/stake-sablier-nft/stake/stake.tree create mode 100644 test/stake-sablier-nft/unstake/unstake.t.sol create mode 100644 test/stake-sablier-nft/unstake/unstake.tree create mode 100644 test/stake-sablier-nft/update-claim-amount/updateClaimAmount.t.sol create mode 100644 test/stake-sablier-nft/update-claim-amount/updateClaimAmount.tree diff --git a/.solhint.json b/.solhint.json index 8094261..0b9101b 100644 --- a/.solhint.json +++ b/.solhint.json @@ -3,9 +3,10 @@ "rules": { "code-complexity": ["error", 8], "compiler-version": ["error", ">=0.8.13"], + "contract-name-camelcase": "off", "func-name-mixedcase": "off", "func-visibility": ["error", { "ignoreConstructors": true }], - "max-line-length": ["error", 120], + "max-line-length": ["error", 124], "named-parameters-mapping": "warn", "no-console": "off", "not-rely-on-time": "off" diff --git a/README.md b/README.md index f6f3e90..bf78db5 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ This repository contains templates for building integrations with Sablier. - **StreamCreator**: A template for creating a Lockup Linear stream. +- **StreamStaking**: A template for writing a staking contract for Sablier streams. For more information, refer to this guide on our documentation website: diff --git a/bun.lockb b/bun.lockb index 1ff1a62e93f9ac1a29e2751123bc3bc092815313..c7dbc829c3b0758e310314d878a735270a0cd313 100755 GIT binary patch delta 5840 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 UioSCe9XOUitM;y=@Snc-Z#6)x82|tP delta 5742 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.8.19; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { ERC721Holder } from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import { Adminable } from "@sablier/v2-core/src/abstracts/Adminable.sol"; +import { ISablierV2Lockup } from "@sablier/v2-core/src/interfaces/ISablierV2Lockup.sol"; + +/// @title StakeSablierNFT +/// +/// @notice DISCLAIMER: This template has not been audited and is provided "as is" with no warranties of any kind, +/// either express or implied. It is intended solely for demonstration purposes on how to build a staking contract using +/// Sablier NFT. This template should not be used in a production environment. It makes specific assumptions that may +/// not apply to your particular needs. +/// +/// @dev This template allows users to stake Sablier NFTs and earn staking rewards based on the total amount available +/// in the stream. The implementation is inspired by the Synthetix staking contract: +/// https://github.com/Synthetixio/synthetix/blob/develop/contracts/StakingRewards.sol. +/// +/// Assumptions: +/// - The staking contract supports only one type of stream at a time, either Lockup Dynamic or Lockup Linear. +/// - The Sablier NFT must be transferable because staking requires transferring the NFT to the staking contract. +/// - This staking contract assumes that one user can only stake one NFT at a time. +contract StakeSablierNFT is Adminable, ERC721Holder { + using SafeERC20 for IERC20; + + /*////////////////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////////////////*/ + + error AlreadyStaking(address account, uint256 tokenId); + error DifferentStreamingAsset(uint256 tokenId, IERC20 rewardToken); + error ProvidedRewardTooHigh(); + error StakingAlreadyActive(); + error UnauthorizedCaller(address account, uint256 tokenId); + error ZeroAddress(address account); + error ZeroAmount(); + error ZeroDuration(); + + /*////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////*/ + + event RewardAdded(uint256 reward); + event RewardDurationUpdated(uint256 newDuration); + event RewardPaid(address indexed user, uint256 reward); + event Staked(address indexed user, uint256 tokenId); + event Unstaked(address indexed user, uint256 tokenId); + + /*////////////////////////////////////////////////////////////////////////// + USER-FACING STATE + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev The last time when rewards were updated. + uint256 public lastUpdateTime; + + /// @dev This should be your own ERC20 token in which the staking rewards will be distributed. + IERC20 public rewardERC20Token; + + /// @dev Total rewards to be distributed per second. + uint256 public rewardRate; + + /// @dev Earned rewards for each account. + mapping(address account => uint256 earned) public rewards; + + /// @dev Duration for which staking is live. + uint256 public rewardsDuration; + + /// @dev This should be the Sablier Lockup contract. + /// - If you used Lockup Linear, you should use the LockupLinear contract address. + /// - If you used Lockup Dynamic, you should use the LockupDynamic contract address. + ISablierV2Lockup public sablierLockup; + + /// @dev The owner of the streams mapped by tokenId. + mapping(uint256 tokenId => address account) public stakedAssets; + + /// @dev The staked token ID mapped by each account. + mapping(address account => uint256 tokenId) public stakedTokenId; + + /// @dev The timestamp when the staking ends. + uint256 public stakingEndTime; + + /// @dev The total amount of ERC20 tokens staked through Sablier NFTs. + uint256 public totalERC20StakedSupply; + + /// @dev Keeps track of the total rewards distributed divided by total staked supply. + uint256 public totalRewardPaidPerERC20Token; + + /// @dev The rewards paid to each account per ERC20 token mapped by the account. + mapping(address account => uint256 paidAmount) public userRewardPerERC20Token; + + /*////////////////////////////////////////////////////////////////////////// + MODIFIERS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Modifier used to keep track of the earned rewards for user each time a `stake`, `unstake` or + /// `claimRewards` is called. + modifier updateReward(address account) { + totalRewardPaidPerERC20Token = rewardPaidPerERC20Token(); + lastUpdateTime = lastTimeRewardsApplicable(); + rewards[account] = calculateUserRewards(account); + userRewardPerERC20Token[account] = totalRewardPaidPerERC20Token; + _; + } + + /*////////////////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////////////////*/ + + /// @param initialAdmin The address of the initial contract admin. + /// @param rewardERC20Token_ The address of the ERC20 token used for rewards. + /// @param sablierLockup_ The address of the ERC721 Contract. + constructor(address initialAdmin, IERC20 rewardERC20Token_, ISablierV2Lockup sablierLockup_) { + admin = initialAdmin; + rewardERC20Token = rewardERC20Token_; + sablierLockup = sablierLockup_; + } + + /*////////////////////////////////////////////////////////////////////////// + USER-FACING CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Calculate the earned rewards for an account. + /// @param account The address of the account to calculate available rewards for. + /// @return earned The amount available as rewards for the account. + function calculateUserRewards(address account) public view returns (uint256 earned) { + if (stakedTokenId[account] == 0) { + return rewards[account]; + } + + uint256 amountInStream = _getAmountInStream(stakedTokenId[account]); + uint256 userRewardPerERC20Token_ = userRewardPerERC20Token[account]; + + uint256 rewardsSinceLastTime = (amountInStream * (rewardPaidPerERC20Token() - userRewardPerERC20Token_)) / 1e18; + + return rewardsSinceLastTime + rewards[account]; + } + + /// @notice Get the last time when rewards were applicable + function lastTimeRewardsApplicable() public view returns (uint256) { + return block.timestamp < stakingEndTime ? block.timestamp : stakingEndTime; + } + + /// @notice Calculates the total rewards distributed per ERC20 token. + /// @dev This is called by `updateReward` which also update the value of `totalRewardPaidPerERC20Token`. + function rewardPaidPerERC20Token() public view returns (uint256) { + // If the total staked supply is zero or staking has ended, return the stored value of reward per ERC20. + if (totalERC20StakedSupply == 0 || block.timestamp >= stakingEndTime) { + return totalRewardPaidPerERC20Token; + } + + uint256 totalRewardsPerERC20InCurrentPeriod = + ((lastTimeRewardsApplicable() - lastUpdateTime) * rewardRate * 1e18) / totalERC20StakedSupply; + + return totalRewardPaidPerERC20Token + totalRewardsPerERC20InCurrentPeriod; + } + + /*////////////////////////////////////////////////////////////////////////// + USER-FACING NON-CONSTANT FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Function called by the user to claim his accumulated rewards. + function claimRewards() public updateReward(msg.sender) { + uint256 reward = rewards[msg.sender]; + if (reward > 0) { + delete rewards[msg.sender]; + + rewardERC20Token.safeTransfer(msg.sender, reward); + + emit RewardPaid(msg.sender, reward); + } + } + + /// @notice Implements the hook to handle the cancelation of the stream. + /// @dev This function subtracts the amount refunded to the sender from `totalERC20StakedSupply`. + /// - This function also updates the rewards for the staker. + function onStreamCanceled( + uint256 streamId, + address, + uint128 senderAmount, + uint128 + ) + external + updateReward(stakedAssets[streamId]) + { + // Check: the caller is the lockup contract. + if (msg.sender != address(sablierLockup)) { + revert UnauthorizedCaller(msg.sender, streamId); + } + + // Effect: update the total staked amount. + totalERC20StakedSupply -= senderAmount; + } + + /// @notice Implements the hook to handle the withdrawn amount if sender calls the withdraw. + /// @dev This function transfers `amount` to the original staker. + function onStreamWithdrawn( + uint256 streamId, + address, + address, + uint128 amount + ) + external + updateReward(stakedAssets[streamId]) + { + // Check: the caller is the lockup contract + if (msg.sender != address(sablierLockup)) { + revert UnauthorizedCaller(msg.sender, streamId); + } + + address staker = stakedAssets[streamId]; + + // Check: the staker is not the zero address. + if (staker == address(0)) { + revert ZeroAddress(staker); + } + + // Effect: update the total staked amount. + totalERC20StakedSupply -= amount; + + // Interaction: transfer the withdrawn amount to the original staker. + rewardERC20Token.safeTransfer(staker, amount); + } + + /// @notice Stake a Sablier NFT with specified base asset. + /// @dev The `msg.sender` must approve the staking contract to spend the Sablier NFT before calling this function. + /// One user can only stake one NFT at a time. + /// @param tokenId The tokenId of the Sablier NFT to be staked. + function stake(uint256 tokenId) external updateReward(msg.sender) { + // Check: the Sablier NFT is streaming the staking asset. + if (sablierLockup.getAsset(tokenId) != rewardERC20Token) { + revert DifferentStreamingAsset(tokenId, rewardERC20Token); + } + + // Check: the user is not already staking. + if (stakedTokenId[msg.sender] != 0) { + revert AlreadyStaking(msg.sender, stakedTokenId[msg.sender]); + } + + // Effect: store the owner of the Sablier NFT. + stakedAssets[tokenId] = msg.sender; + + // Effect: Store the new tokenId against the user address. + stakedTokenId[msg.sender] = tokenId; + + // Effect: update the total staked amount. + totalERC20StakedSupply += _getAmountInStream(tokenId); + + // Interaction: transfer NFT to the staking contract. + sablierLockup.safeTransferFrom({ from: msg.sender, to: address(this), tokenId: tokenId }); + + emit Staked(msg.sender, tokenId); + } + + /// @notice Unstaking a Sablier NFT will transfer the NFT back to the `msg.sender`. + /// @param tokenId The tokenId of the Sablier NFT to be unstaked. + function unstake(uint256 tokenId) public updateReward(msg.sender) { + // Check: the caller is the stored owner of the NFT. + if (stakedAssets[tokenId] != msg.sender) { + revert UnauthorizedCaller(msg.sender, tokenId); + } + + // Effect: update the total staked amount. + totalERC20StakedSupply -= _getAmountInStream(tokenId); + + _unstake(tokenId, msg.sender); + } + + /*////////////////////////////////////////////////////////////////////////// + PRIVATE FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Determine the amount available in the stream. + /// @dev The following function determines the amounts of tokens in a stream irrespective of its cancelable status. + function _getAmountInStream(uint256 tokenId) private view returns (uint256 amount) { + // The tokens in the stream = amount deposited - amount withdrawn - amount refunded. + return sablierLockup.getDepositedAmount(tokenId) - sablierLockup.getWithdrawnAmount(tokenId) + - sablierLockup.getRefundedAmount(tokenId); + } + + function _unstake(uint256 tokenId, address account) private { + // Check: account is not zero. + if (account == address(0)) { + revert ZeroAddress(account); + } + + // Effect: delete the owner of the staked token from the storage. + delete stakedAssets[tokenId]; + + // Effect: delete the `tokenId` from the user storage. + delete stakedTokenId[account]; + + // Interaction: transfer stream back to user. + sablierLockup.safeTransferFrom(address(this), account, tokenId); + + emit Unstaked(account, tokenId); + } + + /*////////////////////////////////////////////////////////////////////////// + ADMIN FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @notice Start a Staking period and set the amount of ERC20 tokens to be distributed as rewards in said period. + /// @dev The Staking Contract have to already own enough Rewards Tokens to distribute all the rewards, so make sure + /// to send all the tokens to the contract before calling this function. + /// @param rewardAmount The amount of Reward Tokens to be distributed. + /// @param newDuration The duration in which the rewards will be distributed. + function startStakingPeriod(uint256 rewardAmount, uint256 newDuration) external onlyAdmin { + // Check: the amount is not zero + if (rewardAmount == 0) { + revert ZeroAmount(); + } + + // Check: the duration is not zero. + if (newDuration == 0) { + revert ZeroDuration(); + } + + // Check: the staking period is not already active. + if (block.timestamp <= stakingEndTime) { + revert StakingAlreadyActive(); + } + + // Effect: update the rewards duration. + rewardsDuration = newDuration; + + // Effect: update the reward rate. + rewardRate = rewardAmount / rewardsDuration; + + // Check: the contract has enough tokens to distribute as rewards. + uint256 balance = rewardERC20Token.balanceOf(address(this)); + if (rewardRate > balance / rewardsDuration) { + revert ProvidedRewardTooHigh(); + } + + // Effect: update the `lastUpdateTime`. + lastUpdateTime = block.timestamp; + + // Effect: update the `stakingEndTime`. + stakingEndTime = block.timestamp + rewardsDuration; + + emit RewardAdded(rewardAmount); + + emit RewardDurationUpdated(rewardsDuration); + } +} diff --git a/test/stake-sablier-nft/StakeSablierNFT.t.sol b/test/stake-sablier-nft/StakeSablierNFT.t.sol new file mode 100644 index 0000000..e473123 --- /dev/null +++ b/test/stake-sablier-nft/StakeSablierNFT.t.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.19; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ud60x18 } from "@prb/math/src/UD60x18.sol"; +import { ISablierV2LockupLinear } from "@sablier/v2-core/src/interfaces/ISablierV2LockupLinear.sol"; +import { Broker, LockupLinear } from "@sablier/v2-core/src/types/DataTypes.sol"; +import { Test } from "forge-std/src/Test.sol"; + +import { StakeSablierNFT } from "src/StakeSablierNFT.sol"; +import { console2 } from "forge-std/src/console2.sol"; + +struct StreamOwner { + address addr; + uint256 streamId; +} + +struct Users { + // Creator of the NFT staking contract. + address admin; + // Alice is authorized to stake. + StreamOwner alice; + // Bob is unauthorized to stake. + StreamOwner bob; + // Staker is the user we will test the contract for. + StreamOwner staker; +} + +abstract contract StakeSablierNFT_Fork_Test is Test { + // Errors + error AlreadyStaking(address account, uint256 tokenId); + error DifferentStreamingAsset(uint256 tokenId, IERC20 rewardToken); + error ProvidedRewardTooHigh(); + error StakingAlreadyActive(); + error UnauthorizedCaller(address account, uint256 tokenId); + error ZeroAddress(uint256 tokenId); + error ZeroAmount(); + error ZeroDuration(); + + // Events + event RewardAdded(uint256 reward); + event RewardDurationUpdated(uint256 newDuration); + event RewardPaid(address indexed user, uint256 reward); + event Staked(address indexed user, uint256 tokenId); + event Unstaked(address indexed user, uint256 tokenId); + + IERC20 public constant DAI = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F); + IERC20 public constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); + + // Get the latest deployment address from the docs: https://docs.sablier.com/contracts/v2/deployments. + ISablierV2LockupLinear internal constant SABLIER = + ISablierV2LockupLinear(0xAFb979d9afAd1aD27C5eFf4E27226E3AB9e5dCC9); + + // Set a stream ID to stake. + uint256 internal stakingStreamId = 2; + + // Reward rate based on the total amount staked. + uint256 internal rewardRate; + + // Token used for creating streams as well as to distribute rewards. + IERC20 internal rewardToken = DAI; + + StakeSablierNFT internal stakingContract; + + uint256 internal tokenAmountsInStream; + + Users internal users; + + function setUp() public { + // Fork Ethereum Mainnet. + vm.createSelectFork({ blockNumber: 19_689_210, urlOrAlias: "mainnet" }); + + // Create users. + users.admin = makeAddr("admin"); + users.alice.addr = makeAddr("alice"); + users.bob.addr = makeAddr("bob"); + users.staker.addr = makeAddr("staker"); + + // Mint some reward tokens to the admin address which will be used to deposit to the staking contract. + deal({ token: address(rewardToken), to: users.admin, give: 10_000e18 }); + + // Make the admin the `msg.sender` in all following calls. + vm.startPrank({ msgSender: users.admin }); + + // Deploy the staking contract. + stakingContract = + new StakeSablierNFT({ initialAdmin: users.admin, rewardERC20Token_: rewardToken, sablierLockup_: SABLIER }); + + // Fund the staking contract with some reward tokens. + rewardToken.transfer(address(stakingContract), 10_000e18); + + // Start the staking period. + stakingContract.startStakingPeriod(10_000e18, 1 weeks); + + // Set expected reward rate. + rewardRate = 10_000e18 / uint256(1 weeks); + + // Stake some streams. + _createAndStakeStreamBy({ recipient: users.alice, asset: DAI, stake: true }); + _createAndStakeStreamBy({ recipient: users.bob, asset: USDC, stake: false }); + _createAndStakeStreamBy({ recipient: users.staker, asset: DAI, stake: false }); + + // Make the stream owner the `msg.sender` in all the subsequent calls. + resetPrank({ msgSender: users.staker.addr }); + + // Approve the staking contract to spend the NFT. + SABLIER.setApprovalForAll(address(stakingContract), true); + } + + /// @dev Stops the active prank and sets a new one. + function resetPrank(address msgSender) internal { + vm.stopPrank(); + vm.startPrank(msgSender); + } + + function _createLockupLinearStreams(address recipient, IERC20 asset) private returns (uint256 streamId) { + deal({ token: address(asset), to: users.admin, give: 1000e18 }); + + resetPrank({ msgSender: users.admin }); + + asset.approve(address(SABLIER), type(uint256).max); + + // Declare the params struct + LockupLinear.CreateWithDurations memory params; + + // Declare the function parameters + params.sender = users.admin; // The sender will be able to cancel the stream + params.recipient = recipient; // The recipient of the streamed assets + params.totalAmount = uint128(1000e18); // Total amount is the amount inclusive of all fees + params.asset = asset; // The streaming asset + params.cancelable = true; // Whether the stream will be cancelable or not + params.transferable = true; // Whether the stream will be transferable or not + params.durations = LockupLinear.Durations({ + cliff: 4 weeks, // Assets will be unlocked only after 4 weeks + total: 52 weeks // Setting a total duration of ~1 year + }); + params.broker = Broker(address(0), ud60x18(0)); // Optional parameter for charging a fee + + // Create the Sablier stream using a function that sets the start time to `block.timestamp` + streamId = SABLIER.createWithDurations(params); + } + + function _createAndStakeStreamBy(StreamOwner storage recipient, IERC20 asset, bool stake) private { + resetPrank({ msgSender: users.admin }); + + uint256 streamId = _createLockupLinearStreams(recipient.addr, asset); + recipient.streamId = streamId; + + // Make the stream owner the `msg.sender` in all the subsequent calls. + resetPrank({ msgSender: recipient.addr }); + + // Approve the staking contract to spend the NFT. + SABLIER.setApprovalForAll(address(stakingContract), true); + + // Stake a few NFTs to simulate the actual staking behavior. + if (stake) { + stakingContract.stake(streamId); + } + } +} diff --git a/test/stake-sablier-nft/claim/claim.t.sol b/test/stake-sablier-nft/claim/claim.t.sol new file mode 100644 index 0000000..7ceb47d --- /dev/null +++ b/test/stake-sablier-nft/claim/claim.t.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.19; + +// import { StakeSablierNFT_Fork_Test } from "../StakeSablierNFT.t.sol"; + +// contract Claim_Test is StakeSablierNFT_Fork_Test { +// function test_RevertWhen_CallerUnauthorized() external { +// address unauthorizedCaller = makeAddr("Unauthorized"); +// // Change the caller to an unauthorized address +// resetPrank({ msgSender: unauthorizedCaller }); + +// vm.expectRevert(abi.encodeWithSelector(NotAuthorized.selector, unauthorizedCaller, existingStreamId)); +// stakingContract.claim(existingStreamId); +// } + +// modifier whenCallerIsAuthorized() { +// _; +// } + +// modifier givenStaked() { +// stakingContract.stake(existingStreamId); + +// // Advance time in the future +// vm.warp(block.timestamp + 1 weeks); +// _; +// } + +// function test_RevertWhen_ClaimAmountZero() external whenCallerIsAuthorized givenStaked { +// // claim rewards so that claim amount becomes zero +// stakingContract.claim(existingStreamId); + +// vm.expectRevert(abi.encodeWithSelector(ZeroAmount.selector)); +// stakingContract.claim(existingStreamId); +// } + +// modifier whenClaimAmountNotZero() { +// _; +// } + +// function test_RevertWhen_ContractBalanceIsLessThanClaimAmount() +// external +// whenCallerIsAuthorized +// whenClaimAmountNotZero +// givenStaked +// { +// // Calculate expected rewards in 52 weeks +// uint256 tokensInStream; +// if (sablier.isCancelable(existingStreamId)) { +// tokensInStream = +// sablier.withdrawableAmountOf(existingStreamId) + sablier.refundableAmountOf(existingStreamId); +// } else { +// tokensInStream = sablier.getDepositedAmount(existingStreamId) - +// sablier.getWithdrawnAmount(existingStreamId); +// } +// uint256 expectedReward = (tokensInStream * 53 * 7 * 24 * 3600 * rewardRate) / 1e18; + +// // Advance time in the future +// vm.warp(block.timestamp + 52 weeks); + +// // Get the balance of the staking contract +// uint256 balance = stakingContract.REWARD_TOKEN().balanceOf(address(stakingContract)); + +// vm.expectRevert(abi.encodeWithSelector(ClaimAmountExceedsBalance.selector, expectedReward, balance)); + +// // Claim rewards +// stakingContract.claim(existingStreamId); +// } + +// modifier whenContractBalanceIsNotLessThanClaimAmount() { +// _; +// } + +// function test_Claim_GivenStaked() +// external +// whenCallerIsAuthorized +// whenClaimAmountNotZero +// whenContractBalanceIsNotLessThanClaimAmount +// givenStaked +// { +// // Calculate expected rewards in 1 week +// uint256 tokensInStream; +// if (sablier.isCancelable(existingStreamId)) { +// tokensInStream = +// sablier.withdrawableAmountOf(existingStreamId) + sablier.refundableAmountOf(existingStreamId); +// } else { +// tokensInStream = sablier.getDepositedAmount(existingStreamId) - +// sablier.getWithdrawnAmount(existingStreamId); +// } +// uint256 expectedReward = (tokensInStream * 14 * 24 * 3600 * rewardRate) / 1e18; + +// // Advance time in the future +// vm.warp(block.timestamp + 1 weeks); + +// // Expect {ClaimAmountUpdated} event to be emitted by the staking contract +// vm.expectEmit({ emitter: address(stakingContract) }); +// emit ClaimAmountUpdated(existingStreamId); + +// // Expect {Transfer} event to be emitted by the reward token contract +// vm.expectEmit({ emitter: address(stakingContract.REWARD_TOKEN()) }); +// emit Transfer(address(stakingContract), staker, expectedReward); + +// // Claim rewards +// stakingContract.claim(existingStreamId); + +// // Assert: staker received the staking rewards +// assertEq(stakingContract.stakingRewards(existingStreamId), 0); +// } + +// modifier givenUnstaked() { +// stakingContract.stake(existingStreamId); + +// // Advance time in the future +// vm.warp(block.timestamp + 1 weeks); + +// stakingContract.unstake(existingStreamId); +// _; +// } + +// function test_RevertWhen_ClaimAmountZero_GivenUnstaked() external whenCallerIsAuthorized givenUnstaked { +// // claim rewards so that claim amount becomes zero +// stakingContract.claim(existingStreamId); + +// vm.expectRevert(abi.encodeWithSelector(ZeroAmount.selector)); +// stakingContract.claim(existingStreamId); +// } + +// function test_Claim_GivenUnstaked() +// external +// whenCallerIsAuthorized +// whenClaimAmountNotZero +// whenContractBalanceIsNotLessThanClaimAmount +// givenUnstaked +// { +// // Calculate expected rewards in 1 week +// uint256 tokensInStream; +// if (sablier.isCancelable(existingStreamId)) { +// tokensInStream = +// sablier.withdrawableAmountOf(existingStreamId) + sablier.refundableAmountOf(existingStreamId); +// } else { +// tokensInStream = sablier.getDepositedAmount(existingStreamId) - +// sablier.getWithdrawnAmount(existingStreamId); +// } +// uint256 expectedReward = (tokensInStream * 7 * 24 * 3600 * rewardRate) / 1e18; + +// // Advance time in the future +// vm.warp(block.timestamp + 1 weeks); + +// // Expect {Transfer} event to be emitted by the reward token contract +// vm.expectEmit({ emitter: address(stakingContract.REWARD_TOKEN()) }); +// emit Transfer(address(stakingContract), staker, expectedReward); + +// // Claim rewards +// stakingContract.claim(existingStreamId); + +// // Assert: staker received the staking rewards +// assertEq(stakingContract.stakingRewards(existingStreamId), 0); +// } +// } diff --git a/test/stake-sablier-nft/claim/claim.tree b/test/stake-sablier-nft/claim/claim.tree new file mode 100644 index 0000000..29b17d3 --- /dev/null +++ b/test/stake-sablier-nft/claim/claim.tree @@ -0,0 +1,20 @@ +claim.t.sol +├── when caller is not authorized +│ └── it should revert +└── when caller is authorized + ├── given Sablier NFT is staked + │ ├── when the claim amount is zero + │ │ └── it should revert + │ └── when the claim amount is not zero + │ ├── given the balance of staking contracts is less than the claim amount + │ │ └── it should revert + │ └── given the balance of staking contracts is not less than the claim amount + │ ├── it should emit {ClaimAmountUpdated} event + │ ├── it should set staking rewards to zero + │ └── it should emit {Transfer} event + └── given Sablier NFT has been unstaked + ├── when the claim amount is zero + │ └── it should revert + └── when the claim amount is not zero + ├── it should set staking rewards to zero + └── it should emit {Transfer} event diff --git a/test/stake-sablier-nft/stake/stake.t.sol b/test/stake-sablier-nft/stake/stake.t.sol new file mode 100644 index 0000000..74038c8 --- /dev/null +++ b/test/stake-sablier-nft/stake/stake.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.19; + +import { StakeSablierNFT_Fork_Test } from "../StakeSablierNFT.t.sol"; + +contract Stake_Test is StakeSablierNFT_Fork_Test { + function test_RevertWhen_StreamingAssetIsNotRewardAsset() external { + uint256 streamId = users.bob.streamId; + + // Change the caller to the users.staker again. + resetPrank({ msgSender: users.bob.addr }); + + vm.expectRevert(abi.encodeWithSelector(DifferentStreamingAsset.selector, streamId, DAI)); + stakingContract.stake(streamId); + } + + modifier whenStreamingAssetIsRewardAsset() { + _; + } + + function test_RevertWhen_AlreadyStaking() external whenStreamingAssetIsRewardAsset { + uint256 streamId = users.staker.streamId; + + // Stake the NFT. + stakingContract.stake(streamId); + + // Expect {AlreadyStaking} evenet to be emitted. + vm.expectRevert( + abi.encodeWithSelector( + AlreadyStaking.selector, users.staker.addr, stakingContract.stakedTokenId(users.staker.addr) + ) + ); + stakingContract.stake(streamId); + } + + modifier notAlreadyStaking() { + _; + } + + function test_Stake() external whenStreamingAssetIsRewardAsset notAlreadyStaking { + uint256 streamId = users.staker.streamId; + + // Expect {Staked} evenet to be emitted. + vm.expectEmit({ emitter: address(stakingContract) }); + emit Staked(users.staker.addr, streamId); + + // Stake the NFT. + stakingContract.stake(streamId); + + // Assertions: NFT has been transferred to the staking contract. + assertEq(SABLIER.ownerOf(streamId), address(stakingContract)); + + // Assertions: storage variables. + assertEq(stakingContract.stakedAssets(streamId), users.staker.addr); + assertEq(stakingContract.stakedTokenId(users.staker.addr), streamId); + + assertEq(stakingContract.totalERC20StakedSupply(), tokenAmountsInStream); + + // Assert: `updateReward` has correctly updated the storage variables. + assertApproxEqAbs(stakingContract.rewards(users.staker.addr), 0, 0); + assertEq(stakingContract.lastUpdateTime(), block.timestamp); + assertEq(stakingContract.totalRewardPaidPerERC20Token(), 0); + assertEq(stakingContract.userRewardPerERC20Token(users.staker.addr), 0); + } +} diff --git a/test/stake-sablier-nft/stake/stake.tree b/test/stake-sablier-nft/stake/stake.tree new file mode 100644 index 0000000..95c868a --- /dev/null +++ b/test/stake-sablier-nft/stake/stake.tree @@ -0,0 +1,12 @@ +stake.t.sol +├── when the streaming token is not same as the reward token +│ └── it should revert +└── when the streaming token is same as the reward token + ├── when the user is already staking + │ └── it should revert + └── when the user is not already staking + ├── it should transfer the sablier NFT from the caller to the staking contract + ├── it should update {streamOwner} and {stakedTokenId} + ├── it should update {totalERC20StakedSupply} + ├── it should update {updateReward} storage variables + └── it should emit a {Staked} event diff --git a/test/stake-sablier-nft/unstake/unstake.t.sol b/test/stake-sablier-nft/unstake/unstake.t.sol new file mode 100644 index 0000000..3c4c961 --- /dev/null +++ b/test/stake-sablier-nft/unstake/unstake.t.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.19; + +import { StakeSablierNFT_Fork_Test } from "../StakeSablierNFT.t.sol"; + +contract Unstake_Test is StakeSablierNFT_Fork_Test { + function test_RevertWhen_CallerNotAuthorized() external { + uint256 streamId = users.bob.streamId; + + // Change the caller to an unauthorized address. + resetPrank({ msgSender: users.bob.addr }); + + vm.expectRevert(abi.encodeWithSelector(UnauthorizedCaller.selector, users.bob.addr, streamId)); + stakingContract.unstake(streamId); + } + + modifier whenCallerIsAuthorized() { + _; + } + + modifier givenStaked() { + uint256 streamId = users.staker.streamId; + stakingContract.stake(streamId); + vm.warp(block.timestamp + 1 days); + _; + } + + function test_Unstake() external whenCallerIsAuthorized givenStaked { + // Expect {Unstaked} event to be emitted. + vm.expectEmit({ emitter: address(stakingContract) }); + emit Unstaked(users.staker.addr, users.staker.streamId); + + // Unstake the NFT. + stakingContract.unstake(users.staker.streamId); + + // Assert: NFT has been transferred. + assertEq(SABLIER.ownerOf(users.staker.streamId), users.staker.addr); + + // Assert: `stakedAssets` and `stakedTokenId` have been deleted from storage. + assertEq(stakingContract.stakedAssets(users.staker.streamId), address(0)); + assertEq(stakingContract.stakedTokenId(users.staker.addr), 0); + + // Assert: `totalERC20StakedSupply` has been updated. + assertEq(stakingContract.totalERC20StakedSupply(), 0); + + // Assert: `updateReward` has correctly updated the storage variables. + uint256 expectedReward = 1 days * rewardRate; + assertApproxEqAbs(stakingContract.rewards(users.staker.addr), expectedReward, 0.0001e18); + assertEq(stakingContract.lastUpdateTime(), block.timestamp); + assertEq(stakingContract.totalRewardPaidPerERC20Token(), (expectedReward * 1e18) / tokenAmountsInStream); + assertEq( + stakingContract.userRewardPerERC20Token(users.staker.addr), (expectedReward * 1e18) / tokenAmountsInStream + ); + } +} diff --git a/test/stake-sablier-nft/unstake/unstake.tree b/test/stake-sablier-nft/unstake/unstake.tree new file mode 100644 index 0000000..46cd661 --- /dev/null +++ b/test/stake-sablier-nft/unstake/unstake.tree @@ -0,0 +1,9 @@ +unstake.t.sol +├── when the caller is not the staker +│ └── it should revert +└── when the caller is the staker + ├── it should transfer the sablier NFT to the caller + ├── it should delete {streamOwner} and {stakedTokenId} + ├── it should update {totalERC20StakedSupply} + ├── it should update {updateReward} storage variables + └── it should emit a {Unstaked} event diff --git a/test/stake-sablier-nft/update-claim-amount/updateClaimAmount.t.sol b/test/stake-sablier-nft/update-claim-amount/updateClaimAmount.t.sol new file mode 100644 index 0000000..96e8cbc --- /dev/null +++ b/test/stake-sablier-nft/update-claim-amount/updateClaimAmount.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.19; + +// import { StakeSablierNFT_Fork_Test } from "../StakeSablierNFT.t.sol"; + +// contract UpdateClaimAmount_Test is StakeSablierNFT_Fork_Test { +// modifier givenStaked() { +// stakingContract.stake(existingStreamId); +// _; +// } + +// function test_UpdateClaimAmount_WhenUnstaked() external givenStaked { +// // Advance time in the future +// vm.warp(block.timestamp + 1000 seconds); + +// // Unstake the NFT +// stakingContract.unstake(existingStreamId); + +// uint256 beforeStakingRewards = stakingContract.stakingRewards(existingStreamId); +// uint256 beforeTimestamp = stakingContract.lastUpdateTimestamp(existingStreamId); + +// // Advance time in the future +// vm.warp(block.timestamp + 1000 seconds); + +// // Update claim amount +// stakingContract.updateClaimAmount(existingStreamId); + +// uint256 afterStakingRewards = stakingContract.stakingRewards(existingStreamId); +// uint256 afterTimestamp = stakingContract.lastUpdateTimestamp(existingStreamId); + +// // Assert: values should be unchanged +// assertEq(beforeStakingRewards, afterStakingRewards); +// assertEq(beforeTimestamp, afterTimestamp); +// } + +// function test_UpdateClaimAmount() external givenStaked { +// // Calculate expected reward change in 1000 seconds +// uint256 tokensInStream; +// if (sablier.isCancelable(existingStreamId)) { +// tokensInStream = +// sablier.withdrawableAmountOf(existingStreamId) + sablier.refundableAmountOf(existingStreamId); +// } else { +// tokensInStream = sablier.getDepositedAmount(existingStreamId) - +// sablier.getWithdrawnAmount(existingStreamId); +// } +// uint256 expectedRewardChange = (tokensInStream * 1000 * rewardRate) / 1e18; + +// // Advance time in the future +// vm.warp(block.timestamp + 1000 seconds); + +// // Update claim amount +// vm.expectEmit({ emitter: address(stakingContract) }); +// emit ClaimAmountUpdated(existingStreamId); +// stakingContract.updateClaimAmount(existingStreamId); + +// uint256 beforeStakingRewards = stakingContract.stakingRewards(existingStreamId); +// uint256 beforeTimestamp = stakingContract.lastUpdateTimestamp(existingStreamId); + +// // Advance time in the future +// vm.warp(block.timestamp + 1000 seconds); + +// // Update claim amount +// vm.expectEmit({ emitter: address(stakingContract) }); +// emit ClaimAmountUpdated(existingStreamId); +// uint256 newClaimAmount = stakingContract.updateClaimAmount(existingStreamId); + +// uint256 afterStakingRewards = stakingContract.stakingRewards(existingStreamId); +// uint256 afterTimestamp = stakingContract.lastUpdateTimestamp(existingStreamId); + +// // Assert: values should be changed +// assertEq(afterStakingRewards, beforeStakingRewards + expectedRewardChange); +// assertEq(afterTimestamp, beforeTimestamp + 1000 seconds); +// assertEq(newClaimAmount, afterStakingRewards); +// } +// } diff --git a/test/stake-sablier-nft/update-claim-amount/updateClaimAmount.tree b/test/stake-sablier-nft/update-claim-amount/updateClaimAmount.tree new file mode 100644 index 0000000..6d84db2 --- /dev/null +++ b/test/stake-sablier-nft/update-claim-amount/updateClaimAmount.tree @@ -0,0 +1,11 @@ +updateClaimAmount.t.sol +├── when the NFT is not staked +│ └── when time is in the future +│ ├── it should not change the staking rewards +│ └── it should not update the last update timestamp +└── when the NFT is staked + └── when time is in the future + ├── it should increase change the staking rewards + ├── it should set the last update timestamp to current time + ├── it should emit {ClaimAmountUpdated} event + └── it should return the new claim amount