Skip to content

Commit

Permalink
feat: add a staking template
Browse files Browse the repository at this point in the history
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
  • Loading branch information
smol-ninja committed Jul 16, 2024
1 parent 0da4afe commit 5d37aae
Show file tree
Hide file tree
Showing 14 changed files with 917 additions and 2 deletions.
3 changes: 2 additions & 1 deletion .solhint.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Binary file modified bun.lockb
Binary file not shown.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
347 changes: 347 additions & 0 deletions src/StakeSablierNFT.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity >=0.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);
}
}
Loading

0 comments on commit 5d37aae

Please sign in to comment.