Skip to content

Commit

Permalink
feat: support staking of cancelable streams
Browse files Browse the repository at this point in the history
  • Loading branch information
smol-ninja committed May 4, 2024
1 parent c5f38d7 commit b00f6da
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 75 deletions.
159 changes: 96 additions & 63 deletions src/StakeSablierNFT.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,47 +15,26 @@ import { ISablierV2Lockup } from "@sablier/v2-core/src/interfaces/ISablierV2Lock
/// not be applicable 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 based on the Synthetix staking contract:
/// in the stream. The implementation is inspired from 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 transferrable.
/// - The Sablier NFT must be non-cancelable.
/// - One user can only stake one NFT at a time.
///
/// Risks:
/// - If you want to implement the staking for CANCELABLE streams, be careful with how you calculate the amount in
/// streams.
/// If the stream is not cancelable:
/// - the tokens in the stream are the difference between the amount deposited and the amount withdrawn
/// amountInStream = sablierLockup.getDepositedAmount(tokenId) - sablierLockup.getWithdrawnAmount(tokenId);
///
/// If the stream is cancelable:
/// - If not canceled, the tokens in the stream are the sum of amount available to withdraw and the amount that
/// can be refunded to the sender:
///
/// amountInStream = sablierLockup.withdrawableAmountOf(tokenId) +
/// sablierLockup.refundableAmountOf(tokenId);
///
/// - If canceled, the tokens in the stream are the difference between the amount deposited, the amount
/// withdrawn and the amount refunded.
///
/// amountInStream = sablierLockup.getDepositedAmount(tokenId) - sablierLockup.getWithdrawnAmount(tokenId) -
/// sablierLockup.getRefundedAmount(tokenId);
/// - The Sablier NFT must be transferrable 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 ActiveStaker(address account, uint256 tokenId);
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 ZeroAddress(address account);
error ZeroAmount();
error ZeroDuration();

Expand Down Expand Up @@ -85,8 +64,8 @@ contract StakeSablierNFT is Adminable, ERC721Holder {
/// @dev Earned rewards for each account.
mapping(address account => uint256 earned) public rewards;

/// @dev The amount of rewards per ERC20 token already distributed.
uint256 public rewardPerERC20TokenStored;
/// @dev Keeps track of the rewards distributed divided by total staked supply.
uint256 public totalRewardPerERC20TokenPaid;

/// @dev Total rewards to be distributed per second.
uint256 public rewardRate;
Expand All @@ -109,7 +88,7 @@ contract StakeSablierNFT is Adminable, ERC721Holder {
uint256 public totalERC20StakedSupply;

/// @dev The rewards paid to each account per ERC20 token mapped by the account.
mapping(address account => uint256 paidAmount) public userRewardPerERC20TokenPaid;
mapping(address account => uint256 paidAmount) public userRewardPerERC20Token;

/*//////////////////////////////////////////////////////////////////////////
MODIFIERS
Expand All @@ -118,12 +97,10 @@ contract StakeSablierNFT is Adminable, ERC721Holder {
/// @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) {
rewardPerERC20TokenStored = rewardPerERC20Token();
lastUpdateTime = lastTimeRewardApplicable();
if (account != address(0)) {
rewards[account] = calculateRewards(account);
userRewardPerERC20TokenPaid[account] = rewardPerERC20TokenStored;
}
totalRewardPerERC20TokenPaid = rewardPerERC20Token();
lastUpdateTime = min(block.timestamp, periodFinish);
rewards[account] = calculateRewards(account);
userRewardPerERC20Token[account] = totalRewardPerERC20TokenPaid;
_;
}

Expand Down Expand Up @@ -154,7 +131,7 @@ contract StakeSablierNFT is Adminable, ERC721Holder {
return (
(
_getAmountInStream(stakedTokenId[account])
* (rewardPerERC20Token() - userRewardPerERC20TokenPaid[account])
* (rewardPerERC20Token() - userRewardPerERC20Token[account])
) / 1e18
) + rewards[account];
}
Expand All @@ -165,20 +142,17 @@ contract StakeSablierNFT is Adminable, ERC721Holder {
return rewardRate / totalERC20StakedSupply;
}

/// @return lastRewardsApplicable the last time the rewards were applicable. Returns Returns `block.timestamp` if
/// the rewards period is not ended.
function lastTimeRewardApplicable() public view returns (uint256 lastRewardsApplicable) {
return block.timestamp < periodFinish ? block.timestamp : periodFinish;
}

/// @notice calculates the rewards per ERC20 token for the current time whenever a new stake/unstake is made to keep
/// track of the correct token distribution between stakers.
/// @notice calculates the rewards distributed per ERC20 token whenever a new stake/unstake is made to keep track of
/// the correct token distribution between stakers.
function rewardPerERC20Token() public view returns (uint256) {
if (totalERC20StakedSupply == 0) {
return rewardPerERC20TokenStored;
// If the total staked supply is zero, return the stored value of reward per ERC20.
return totalRewardPerERC20TokenPaid;
} else {
// Otherwise, calculate the reward per ERC20 token.
return totalRewardPerERC20TokenPaid
+ (((min(block.timestamp, periodFinish) - lastUpdateTime) * rewardRate * 1e18) / totalERC20StakedSupply);
}
return rewardPerERC20TokenStored
+ (((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / totalERC20StakedSupply);
}

/// @notice function useful for Front End to see the staked NFT and earned rewards.
Expand All @@ -205,6 +179,25 @@ contract StakeSablierNFT is Adminable, ERC721Holder {
}
}

/// @notice Implements the hook to handle the cancelation of the stream.
/// @dev This function unstakes the NFT and transfers it back to the original staker.
/// - Similar to `unstake`, this function also updates the rewards for the staker.
function onStreamCanceled(
uint256 streamId,
address,
address,
uint128
)
external
updateReward(stakedAssets[streamId])
{
// Check: the caller is the lockup contract
if (msg.sender != address(sablierLockup)) {
revert UnauthorizedCaller(msg.sender, streamId);
}
_unstake(streamId, stakedAssets[streamId]);
}

/// @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 {
Expand All @@ -217,7 +210,7 @@ contract StakeSablierNFT is Adminable, ERC721Holder {

// Check: the staker is not the zero address
if (staker == address(0)) {
revert ZeroAddress(streamId);
revert ZeroAddress(staker);
}

// Interaction: transfer the withdrawn amount to the original staker
Expand All @@ -235,8 +228,8 @@ contract StakeSablierNFT is Adminable, ERC721Holder {
}

// Check: the user is not already staking
if (stakedAssets[tokenId] != address(0) || stakedTokenId[msg.sender] != 0) {
revert ActiveStaker(msg.sender, stakedTokenId[msg.sender]);
if (stakedTokenId[msg.sender] != 0) {
revert AlreadyStaking(msg.sender, stakedTokenId[msg.sender]);
}

// Effect: store the owner of the Sablier NFT
Expand All @@ -262,19 +255,69 @@ contract StakeSablierNFT is Adminable, ERC721Holder {
revert UnauthorizedCaller(msg.sender, 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) {
// Get the `isCancelable` value of the stream
bool isCancelable = sablierLockup.isCancelable(tokenId);

// Get the `wasCanceled` value of the stream
bool wasCanceled = sablierLockup.wasCanceled(tokenId);

// Determine whether the stream was always non-cancelable
bool isCancelableOrHasBeenCanceled = isCancelable || wasCanceled;

// If the stream is always non-cancelable:
// the tokens in the stream = amount deposited + amount withdrawn.
if (!isCancelableOrHasBeenCanceled) {
return sablierLockup.getDepositedAmount(tokenId) - sablierLockup.getWithdrawnAmount(tokenId);
} else {
// If the stream is cancelable or was cancelable, the tokens in the stream depend on whether the stream has
// been canceled or not.
if (wasCanceled) {
// If the stream has been canceled:
// the tokens in the stream = amount deposited - amount withdrawn - amount refunded.
return sablierLockup.getDepositedAmount(tokenId) - sablierLockup.getWithdrawnAmount(tokenId)
- sablierLockup.getRefundedAmount(tokenId);
} else {
// If the stream has not yet been canceled:
// the tokens in the stream = amount that can be withdrawn + amount that can be refunded.
return sablierLockup.withdrawableAmountOf(tokenId) + sablierLockup.refundableAmountOf(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[msg.sender];
delete stakedTokenId[account];

// Effect: update the total staked amount
totalERC20StakedSupply -= _getAmountInStream(tokenId);

// Interaction: transfer stream back to user
sablierLockup.safeTransferFrom(address(this), msg.sender, tokenId);
sablierLockup.safeTransferFrom(address(this), account, tokenId);

emit Unstaked(msg.sender, tokenId);
emit Unstaked(account, tokenId);
}

/// @dev Calculated the min of two values.
function min(uint256 a, uint256 b) private pure returns (uint256 v) {
return a < b ? a : b;
}

/*//////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -324,14 +367,4 @@ contract StakeSablierNFT is Adminable, ERC721Holder {

emit RewardDurationUpdated(rewardsDuration);
}

/*//////////////////////////////////////////////////////////////////////////
INTERNAL NON-CONSTANT FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/

/// @notice function to get the amount of tokens in the stream.
/// @dev The following function only applied to non-cancelable streams.
function _getAmountInStream(uint256 tokenId) internal view returns (uint256 amount) {
return sablierLockup.getDepositedAmount(tokenId) - sablierLockup.getWithdrawnAmount(tokenId);
}
}
13 changes: 10 additions & 3 deletions test/stake-sablier-nft/StakeSablierNFT.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import { StakeSablierNFT } from "src/StakeSablierNFT.sol";

abstract contract StakeSablierNFT_Fork_Test is Test {
// Errors
error AlreadyStaking(address account, uint256 tokenId);
error DifferentStreamingAsset(uint256 tokenId, IERC20 rewardToken);
error ERC721IncorrectOwner(address, uint256, address);
error ProvidedRewardTooHigh();
error StakingAlreadyActive();
error UnauthorizedCaller(address account, uint256 tokenId);
error ZeroAddress(uint256 tokenId);
error ZeroAmount();
error ZeroRewardsDuration();
error ZeroDuration();

// Events
event RewardAdded(uint256 reward);
Expand All @@ -30,6 +31,9 @@ abstract contract StakeSablierNFT_Fork_Test is Test {
// Set an existing stream ID
uint256 internal existingStreamId = 1253;

// 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 = IERC20(0x686f2404e77Ab0d9070a46cdfb0B7feCDD2318b0);

Expand Down Expand Up @@ -66,9 +70,12 @@ abstract contract StakeSablierNFT_Fork_Test is Test {
// Fund the staking contract with some reward tokens
rewardToken.transfer(address(stakingContract), 10_000e18);

//Start the staking period
// Start the staking period
stakingContract.startStakingPeriod(10_000e18, 1 weeks);

// Set expected reward rate
rewardRate = 10_000e18 / uint256(1 weeks);

// Make the stream owner the `msg.sender` in all the subsequent calls
vm.startPrank({ msgSender: staker });

Expand Down
22 changes: 21 additions & 1 deletion test/stake-sablier-nft/stake/stake.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,20 @@ contract Stake_Test is StakeSablierNFT_Fork_Test {
_;
}

function test_Stake() external whenStreamingAssetIsRewardAsset {
function test_RevertWhen_AlreadyStaking() external whenStreamingAssetIsRewardAsset {
// Stake the NFT.
stakingContract.stake(existingStreamId);

// Expect {AlreadyStaking} evenet to be emitted
vm.expectRevert(abi.encodeWithSelector(AlreadyStaking.selector, staker, stakingContract.stakedTokenId(staker)));
stakingContract.stake(existingStreamId);
}

modifier notAlreadyStaking() {
_;
}

function test_Stake() external whenStreamingAssetIsRewardAsset notAlreadyStaking {
// Expect {Staked} evenet to be emitted
vm.expectEmit({ emitter: address(stakingContract) });
emit Staked(staker, existingStreamId);
Expand All @@ -38,6 +51,13 @@ contract Stake_Test is StakeSablierNFT_Fork_Test {
// Assertions: storage variables
assertEq(stakingContract.stakedAssets(existingStreamId), staker);
assertEq(stakingContract.stakedTokenId(staker), existingStreamId);

assertEq(stakingContract.totalERC20StakedSupply(), tokenAmountsInStream);

// Assert: `updateReward` has correctly updated the storage variables
assertApproxEqAbs(stakingContract.rewards(staker), 0, 0);
assertEq(stakingContract.lastUpdateTime(), block.timestamp);
assertEq(stakingContract.totalRewardPerERC20TokenPaid(), 0);
assertEq(stakingContract.userRewardPerERC20Token(staker), 0);
}
}
11 changes: 8 additions & 3 deletions test/stake-sablier-nft/stake/stake.tree
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ 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
├── it should transfer the sablier NFT from the caller to the staking contract
├── it should update storage variables
└── it should emit a {Staked} event
├── 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
9 changes: 6 additions & 3 deletions test/stake-sablier-nft/unstake/unstake.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ contract Unstake_Test is StakeSablierNFT_Fork_Test {
// Assert: `totalERC20StakedSupply` has been updated
assertEq(stakingContract.totalERC20StakedSupply(), 0);

// Assert: reward amount equals expected amount
uint256 expectedReward = 1 days * stakingContract.rewardRate();
assertApproxEqAbs(stakingContract.calculateRewards(staker), expectedReward, 0.0001e18);
// Assert: `updateReward` has correctly updated the storage variables
uint256 expectedReward = 1 days * rewardRate;
assertApproxEqAbs(stakingContract.rewards(staker), expectedReward, 0.0001e18);
assertEq(stakingContract.lastUpdateTime(), block.timestamp);
assertEq(stakingContract.totalRewardPerERC20TokenPaid(), (expectedReward * 1e18) / tokenAmountsInStream);
assertEq(stakingContract.userRewardPerERC20Token(staker), (expectedReward * 1e18) / tokenAmountsInStream);
}
}
5 changes: 3 additions & 2 deletions test/stake-sablier-nft/unstake/unstake.tree
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ unstake.t.sol
│ └── 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 storage variables
├── it should delete {streamOwner} and {stakedTokenId}
├── it should update {totalERC20StakedSupply}
├── it should update {updateReward} storage variables
└── it should emit a {Unstaked} event

0 comments on commit b00f6da

Please sign in to comment.