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..ade3647 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,18 @@ 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: https://docs.sablier.com/contracts/v2/guides/local-environment +## Disclaimer + +The templates provided in this repo have 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. These templates should NOT be used in a production +environment. It makes specific assumptions that may not apply to your particular needs. + ## License This repo is licensed under GPL 3.0 or later. diff --git a/bun.lockb b/bun.lockb index 1ff1a62..c7dbc82 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 9d977f9..993296e 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "lint": "bun run lint:sol && bun run prettier:check", "lint:sol": "forge fmt --check && bun solhint \"{script,src,test}/**/*.sol\"", "prettier:check": "prettier --check \"**/*.{json,md,yml}\"", - "prettier:write": "prettier --write \"**/*.{json,md,yml}\"" + "prettier:write": "prettier --write \"**/*.{json,md,yml}\"", + "test": "forge test" } } diff --git a/src/StakeSablierNFT.sol b/src/StakeSablierNFT.sol new file mode 100644 index 0000000..a258e7c --- /dev/null +++ b/src/StakeSablierNFT.sol @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.22; + +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 { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { Adminable } from "@sablier/v2-core/src/abstracts/Adminable.sol"; +import { ISablierLockupRecipient } from "@sablier/v2-core/src/interfaces/ISablierLockupRecipient.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, ISablierLockupRecipient { + 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; + } + + // {IERC165-supportsInterface} implementation as required by `ISablierLockupRecipient` interface. + function supportsInterface(bytes4 interfaceId) public pure override(IERC165) returns (bool) { + return interfaceId == 0xf8ee98d3; + } + + /*////////////////////////////////////////////////////////////////////////// + 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 cancelation events. This will be called by Sablier contract when a stream + /// is canceled by the sender. + /// @dev This function subtracts the amount refunded to the sender from `totalERC20StakedSupply`. + /// - This function also updates the rewards for the staker. + function onSablierLockupCancel( + uint256 streamId, + address, /* sender */ + uint128 senderAmount, + uint128 /* recipientAmount */ + ) + external + updateReward(stakedAssets[streamId]) + returns (bytes4 selector) + { + // 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; + + return ISablierLockupRecipient.onSablierLockupCancel.selector; + } + + /// @notice Implements the hook to handle withdraw events. This will be called by Sablier contract when withdraw is + /// called on a stream. + /// @dev This function transfers `amount` to the original staker. + function onSablierLockupWithdraw( + uint256 streamId, + address, /* caller */ + address, /* recipient */ + uint128 amount + ) + external + updateReward(stakedAssets[streamId]) + returns (bytes4 selector) + { + // 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); + + return ISablierLockupRecipient.onSablierLockupWithdraw.selector; + } + + /// @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..afd8a9b --- /dev/null +++ b/test/stake-sablier-nft/StakeSablierNFT.t.sol @@ -0,0 +1,159 @@ +// 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"; + +struct StreamOwner { + address addr; + uint256 streamId; +} + +struct Users { + // Creator of the NFT staking contract. + address admin; + // Alice has already staked her NFT. + StreamOwner alice; + // Bob is unauthorized to stake. + StreamOwner bob; + // Joe wants to stake his NFT. + StreamOwner joe; +} + +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(0x776b6fC2eD15D6Bb5Fc32e0c89DE68683118c62A); + IERC20 public constant USDC = IERC20(0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238); + + // Get the latest deployment address from the docs: https://docs.sablier.com/contracts/v2/deployments. + ISablierV2LockupLinear internal constant SABLIER = + ISablierV2LockupLinear(0x3E435560fd0a03ddF70694b35b673C25c65aBB6C); + + // 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 constant AMOUNT_IN_STREAM = 1000e18; + + Users internal users; + + function setUp() public { + // Fork Ethereum Mainnet. + vm.createSelectFork({ blockNumber: 6_239_031, urlOrAlias: "sepolia" }); + + // Create users. + users.admin = makeAddr("admin"); + users.alice.addr = makeAddr("alice"); + users.bob.addr = makeAddr("bob"); + users.joe.addr = makeAddr("joe"); + + // 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 }); + + // Set expected reward rate. + rewardRate = 10_000e18 / uint256(1 weeks); + + // Fund the staking contract with some reward tokens. + rewardToken.transfer(address(stakingContract), 10_000e18); + + // Start the staking period. + stakingContract.startStakingPeriod(10_000e18, 1 weeks); + + // Stake some streams. + _createAndStakeStreamBy({ recipient: users.alice, asset: DAI, stake: true }); + _createAndStakeStreamBy({ recipient: users.bob, asset: USDC, stake: false }); + _createAndStakeStreamBy({ recipient: users.joe, asset: DAI, stake: false }); + + // Make the stream owner the `msg.sender` in all the subsequent calls. + resetPrank({ msgSender: users.joe.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: AMOUNT_IN_STREAM }); + + 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(AMOUNT_IN_STREAM); // 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-rewards/claimRewards.t.sol b/test/stake-sablier-nft/claim-rewards/claimRewards.t.sol new file mode 100644 index 0000000..5508bc4 --- /dev/null +++ b/test/stake-sablier-nft/claim-rewards/claimRewards.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.19; + +import { StakeSablierNFT_Fork_Test } from "../StakeSablierNFT.t.sol"; + +contract ClaimRewards_Test is StakeSablierNFT_Fork_Test { + function test_ClaimRewards_WhenNonStaker() external { + // Change the caller to a staker. + resetPrank({ msgSender: users.joe.addr }); + + // Expect no transfer. + vm.expectCall({ + callee: address(rewardToken), + data: abi.encodeCall(rewardToken.transfer, (users.joe.addr, 0)), + count: 0 + }); + + // Claim rewards. + stakingContract.claimRewards(); + } + + modifier givenStaked() { + // Change the caller to a staker. + resetPrank({ msgSender: users.alice.addr }); + + vm.warp(block.timestamp + 1 days); + _; + } + + function test_ClaimRewards() external givenStaked { + uint256 expectedReward = 1 days * rewardRate; + uint256 initialBalance = rewardToken.balanceOf(users.alice.addr); + + // Claim the rewards. + stakingContract.claimRewards(); + + // Assert balance increased by the expected reward. + uint256 finalBalance = rewardToken.balanceOf(users.alice.addr); + assertApproxEqAbs(finalBalance - initialBalance, expectedReward, 0.0001e18); + + // Assert rewards has been set to 0. + assertEq(stakingContract.rewards(users.alice.addr), 0); + } +} diff --git a/test/stake-sablier-nft/claim-rewards/claimRewards.tree b/test/stake-sablier-nft/claim-rewards/claimRewards.tree new file mode 100644 index 0000000..c552483 --- /dev/null +++ b/test/stake-sablier-nft/claim-rewards/claimRewards.tree @@ -0,0 +1,5 @@ +claimRewards.t.sol +├── given the caller is not a staker +│ └── it should not transfer the rewards +└── given the caller is a staker + └── it should transfer the rewards 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..3570cf9 --- /dev/null +++ b/test/stake-sablier-nft/stake/stake.t.sol @@ -0,0 +1,53 @@ +// 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 { + resetPrank({ msgSender: users.bob.addr }); + + vm.expectRevert(abi.encodeWithSelector(DifferentStreamingAsset.selector, users.bob.streamId, DAI)); + stakingContract.stake(users.bob.streamId); + } + + modifier whenStreamingAssetIsRewardAsset() { + _; + } + + function test_RevertWhen_AlreadyStaking() external whenStreamingAssetIsRewardAsset { + resetPrank({ msgSender: users.alice.addr }); + + vm.expectRevert(abi.encodeWithSelector(AlreadyStaking.selector, users.alice.addr, users.alice.streamId)); + stakingContract.stake(users.alice.streamId); + } + + modifier notAlreadyStaking() { + resetPrank({ msgSender: users.joe.addr }); + _; + } + + function test_Stake() external whenStreamingAssetIsRewardAsset notAlreadyStaking { + // Expect {Staked} event to be emitted. + vm.expectEmit({ emitter: address(stakingContract) }); + emit Staked(users.joe.addr, users.joe.streamId); + + // Stake the NFT. + stakingContract.stake(users.joe.streamId); + + // Assertions: NFT has been transferred to the staking contract. + assertEq(SABLIER.ownerOf(users.joe.streamId), address(stakingContract)); + + // Assertions: storage variables. + assertEq(stakingContract.stakedAssets(users.joe.streamId), users.joe.addr); + assertEq(stakingContract.stakedTokenId(users.joe.addr), users.joe.streamId); + + assertEq(stakingContract.totalERC20StakedSupply(), AMOUNT_IN_STREAM * 2); + + // Assert: `updateReward` has correctly updated the storage variables. + assertApproxEqAbs(stakingContract.rewards(users.joe.addr), 0, 0); + assertEq(stakingContract.lastUpdateTime(), block.timestamp); + assertEq(stakingContract.totalRewardPaidPerERC20Token(), 0); + assertEq(stakingContract.userRewardPerERC20Token(users.joe.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..df9e607 --- /dev/null +++ b/test/stake-sablier-nft/unstake/unstake.t.sol @@ -0,0 +1,53 @@ +// 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 { + // Change the caller to a non staker. + resetPrank({ msgSender: users.bob.addr }); + + vm.expectRevert(abi.encodeWithSelector(UnauthorizedCaller.selector, users.bob.addr, users.bob.streamId)); + stakingContract.unstake(users.bob.streamId); + } + + modifier whenCallerIsAuthorized() { + _; + } + + modifier givenStaked() { + // Change the caller to a non staker and stake a stream. + resetPrank({ msgSender: users.joe.addr }); + stakingContract.stake(users.joe.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.joe.addr, users.joe.streamId); + + // Unstake the NFT. + stakingContract.unstake(users.joe.streamId); + + // Assert: NFT has been transferred. + assertEq(SABLIER.ownerOf(users.joe.streamId), users.joe.addr); + + // Assert: `stakedAssets` and `stakedTokenId` have been deleted from storage. + assertEq(stakingContract.stakedAssets(users.joe.streamId), address(0)); + assertEq(stakingContract.stakedTokenId(users.joe.addr), 0); + + // Assert: `totalERC20StakedSupply` has been updated. + assertEq(stakingContract.totalERC20StakedSupply(), AMOUNT_IN_STREAM); + + // Assert: `updateReward` has correctly updated the storage variables. + uint256 expectedReward = 1 days * rewardRate / 2; + assertApproxEqAbs(stakingContract.rewards(users.joe.addr), expectedReward, 0.0001e18); + assertEq(stakingContract.lastUpdateTime(), block.timestamp); + assertEq(stakingContract.totalRewardPaidPerERC20Token(), (expectedReward * 1e18) / AMOUNT_IN_STREAM); + assertEq(stakingContract.userRewardPerERC20Token(users.joe.addr), (expectedReward * 1e18) / AMOUNT_IN_STREAM); + } +} 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