From 81bf4e23f0492ec4140c73fe4834121c5d08aa97 Mon Sep 17 00:00:00 2001 From: smol-ninja Date: Wed, 20 Nov 2024 14:11:59 +0000 Subject: [PATCH] feat: staking contract guide (#195) * feat: staking contract guide * refactor: polish staking guide * refactor: polish staking docs * refactor: rename tokenId to streamid * refactor: minor rewordings --------- Co-authored-by: Paul Razvan Berg --- docs/contracts/v2/guides/07-frontend.md | 2 +- .../contracts/v2/guides/08-snapshot-voting.md | 2 +- docs/contracts/v2/guides/09-etherscan.md | 2 +- .../v2/guides/10-custom-deployments.mdx | 2 +- docs/contracts/v2/guides/staking/01-setup.md | 71 +++++++++ .../v2/guides/staking/02-full-code.md | 13 ++ docs/contracts/v2/guides/staking/03-hooks.md | 98 ++++++++++++ docs/contracts/v2/guides/staking/04-stake.md | 62 ++++++++ .../contracts/v2/guides/staking/05-rewards.md | 141 ++++++++++++++++++ docs/contracts/v2/guides/staking/06-claim.md | 21 +++ .../contracts/v2/guides/staking/07-unstake.md | 38 +++++ .../v2/guides/staking/_category_.json | 5 + 12 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 docs/contracts/v2/guides/staking/01-setup.md create mode 100644 docs/contracts/v2/guides/staking/02-full-code.md create mode 100644 docs/contracts/v2/guides/staking/03-hooks.md create mode 100644 docs/contracts/v2/guides/staking/04-stake.md create mode 100644 docs/contracts/v2/guides/staking/05-rewards.md create mode 100644 docs/contracts/v2/guides/staking/06-claim.md create mode 100644 docs/contracts/v2/guides/staking/07-unstake.md create mode 100644 docs/contracts/v2/guides/staking/_category_.json diff --git a/docs/contracts/v2/guides/07-frontend.md b/docs/contracts/v2/guides/07-frontend.md index 8ae33e22..1c93e9c4 100644 --- a/docs/contracts/v2/guides/07-frontend.md +++ b/docs/contracts/v2/guides/07-frontend.md @@ -1,6 +1,6 @@ --- draft: true -sidebar_position: 7 +sidebar_position: 8 id: "frontend" title: "Frontend Integrations" --- diff --git a/docs/contracts/v2/guides/08-snapshot-voting.md b/docs/contracts/v2/guides/08-snapshot-voting.md index b3500067..ae48cc00 100644 --- a/docs/contracts/v2/guides/08-snapshot-voting.md +++ b/docs/contracts/v2/guides/08-snapshot-voting.md @@ -1,6 +1,6 @@ --- id: "snapshot-voting" -sidebar_position: 8 +sidebar_position: 9 title: "Snapshot Strategies" --- diff --git a/docs/contracts/v2/guides/09-etherscan.md b/docs/contracts/v2/guides/09-etherscan.md index 35416380..63694674 100644 --- a/docs/contracts/v2/guides/09-etherscan.md +++ b/docs/contracts/v2/guides/09-etherscan.md @@ -1,6 +1,6 @@ --- id: "etherscan" -sidebar_position: 9 +sidebar_position: 10 title: "Etherscan" --- diff --git a/docs/contracts/v2/guides/10-custom-deployments.mdx b/docs/contracts/v2/guides/10-custom-deployments.mdx index deb81885..eb17b119 100644 --- a/docs/contracts/v2/guides/10-custom-deployments.mdx +++ b/docs/contracts/v2/guides/10-custom-deployments.mdx @@ -1,6 +1,6 @@ --- id: "custom-deployments" -sidebar_position: 10 +sidebar_position: 11 title: "Custom Deployments" --- diff --git a/docs/contracts/v2/guides/staking/01-setup.md b/docs/contracts/v2/guides/staking/01-setup.md new file mode 100644 index 00000000..36ce1ab5 --- /dev/null +++ b/docs/contracts/v2/guides/staking/01-setup.md @@ -0,0 +1,71 @@ +--- +id: "stake-setup" +sidebar_position: 1 +title: "Introduction" +--- + +# Staking in Sablier + +Staking is a popular concept in DeFi. A major advantage of using Sablier is that you can set up staking of tokens being +vested through Sablier streams. This is enabled by [hooks](/concepts/protocol/hooks), about which you can read more in +the [hooks guide](/contracts/v2/guides/hooks). + +This series will guide you through an example of a staking contract with the following features: + +1. Allowing users to stake Sablier streams for a particular token. +2. Earning and claiming rewards in the same token as the vested token. +3. Allowing users to unstake their staked Sablier streams. + +:::warning + +The code provided in this guide 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. + +::: + +## Assumptions + +Before diving in, please note that we will make the following assumptions: + +1. The guide demonstrates staking of only one type of stream at a time, either Lockup Dynamic or Lockup Linear. This is + because each type is a different contract. You can build your own contract to stake all types of streams. +1. Since staking requires transferring the Sablier NFT from users' wallet to the staking contract, the Sablier stream + must be transferable at the time of creation. +1. The staking contract allows staking of one stream per user. So, if a user has already staked a stream, he will not be + able to stake another stream from the same address. This is assumed for simplicity's sake. +1. Rewards are distributed at a fixed rate, for a fixed duration, and are bound by an end time. + +## First steps + +Let's begin with the constructor. + +Create a contract called `StakeSablierNFT` and write the `constructor` as follows: + +```solidity +contract StakeSablierNFT is + Adminable, + ERC721Holder, + ISablierLockupRecipient // Required to implement hooks +{ + constructor(address initialAdmin, IERC20 rewardERC20Token_, ISablierV2Lockup sablierLockup_) { + admin = initialAdmin; + rewardERC20Token = rewardERC20Token_; + sablierLockup = sablierLockup_; + } +} +``` + +As mentioned above, a user will only be able to stake a stream that is vesting tokens specified by `rewardERC20Token_` +in the constructor. The rewards will also be distributed in the same token. + +:::info + +`ISablierV2Lockup` is a shared interface between `ISablierV2LockupLinear`, `ISablierV2LockupDynamic` and +`ISablierV2LockupTranched`, allowing users to interact with either contract type using a single interface. + +::: + +To focus on specific functionalities that enable staking support for streams, obvious functions such as +`startStakingPeriod` have been omitted from this guide. However, for completeness, the full code can be found on the +next page as well as in the +[examples repo](https://github.com/sablier-labs/examples/blob/main/v2/core/StakeSablierNFT.sol). diff --git a/docs/contracts/v2/guides/staking/02-full-code.md b/docs/contracts/v2/guides/staking/02-full-code.md new file mode 100644 index 00000000..0f4473c6 --- /dev/null +++ b/docs/contracts/v2/guides/staking/02-full-code.md @@ -0,0 +1,13 @@ +--- +id: "stake-full-code" +sidebar_position: 2 +title: "Full code" +--- + +The guide in the following pages will cover each of the essential functionalities of the `StakeSablierNFT` contract. For +those who want to start hacking right away, here is the full code, which can also be found on +[GitHub](https://github.com/sablier-labs/examples/blob/main/v2/core/StakeSablierNFT.sol): + +```solidity reference title="StakeSablierNFT contract" +https://github.com/sablier-labs/examples/blob/main/v2/core/StakeSablierNFT.sol +``` diff --git a/docs/contracts/v2/guides/staking/03-hooks.md b/docs/contracts/v2/guides/staking/03-hooks.md new file mode 100644 index 00000000..e31c1b00 --- /dev/null +++ b/docs/contracts/v2/guides/staking/03-hooks.md @@ -0,0 +1,98 @@ +--- +id: "stake-hooks" +sidebar_position: 3 +title: "Hooks" +--- + +As explained in the [access control](/contracts/v2/reference/access-control#overview) section, the Sablier Protocol +allows anyone to trigger withdrawals from a stream. For the staking contract, we want to make sure that any call to +`withdraw` also updates the states of the staking contract. So in this section, we will discuss how we can create such +control flows with Sablier hooks. + +Hooks enable callbacks to the staking contract in the following scenario: + +1. A call to `cancel` or `withdraw` function is made. +2. The staking contract is the recipient of the Sablier stream. + +Depending on your requirement, you can implement custom logic to be executed when the sender cancels or a user withdraws +from a staked stream. For example, you might want to automatically unstake the stream if `cancel` is called, or you +might want to update the internal accounting if `withdraw` is called. Hooks make that happen. + +For this example, we will implement the following logic: + +1. When a stream is canceled, update the user's staked balance in the staking contract. +2. When a withdrawal is made, update the user's staked balance in the staking contract and transfer the withdrawn amount + to the user's address. + +:::note + +A dedicated guide for hooks is available [here](/contracts/v2/guides/hooks). + +::: + +### Cancel hook + +```solidity +/// @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(stakedUsers[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; +} +``` + +### Withdraw hook + +```solidity +/// @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(stakedUsers[streamId]) + returns (bytes4 selector) +{ + // Check: the caller is the Lockup contract + if (msg.sender != address(sablierLockup)) { + revert UnauthorizedCaller(msg.sender, streamId); + } + + address staker = stakedUsers[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; +} +``` diff --git a/docs/contracts/v2/guides/staking/04-stake.md b/docs/contracts/v2/guides/staking/04-stake.md new file mode 100644 index 00000000..a4645d07 --- /dev/null +++ b/docs/contracts/v2/guides/staking/04-stake.md @@ -0,0 +1,62 @@ +--- +id: "stake-stake" +sidebar_position: 4 +title: "Stake" +--- + +The `stake` function takes the `streamId` as the input, and stakes the stream by transferring the Sablier NFT from the +user's wallet to the staking contract. + +```solidity +function stake(uint256 streamId) external { + // code goes here. +} +``` + +As the first step, we will check if the underlying token being vested through the stream is same as the reward token. +Since the Sablier protocol can be used to stream any ERC-20 token, this check ensures that the staking contract does not +accept unknown tokens. + +```solidity +if (sablierLockup.getAsset(streamId) != rewardERC20Token) { + revert DifferentStreamingToken(streamId, rewardERC20Token); +} +``` + +As mentioned in the assumptions, the contract only allows staking one NFT at a time. So, we will now check if the user +is already staking. + +```solidity +if (stakedStreams[msg.sender] != 0) { + revert AlreadyStaking(msg.sender, stakedStreams[msg.sender]); +} +``` + +Finally, we will set some storage variables and transfer the NFT: + +```solidity +stakedUsers[streamId] = msg.sender; + +stakedStreams[msg.sender] = streamId; + +totalERC20StakedSupply += _getAmountInStream(streamId); + +sablierLockup.safeTransferFrom({ from: msg.sender, to: address(this), tokenId: streamId }); +``` + +The `_getAmountInStream` function retrieves the amount of tokens being vested through the stream. + +```math +\text{amount in a stream} = (\text{amount deposited} - \text{amount withdrawn} - \text{amount refunded}) +``` + +The implementation is as follows: + +```solidity +/// @dev The following function determines the amounts of tokens in a stream irrespective of its cancelable status. +function _getAmountInStream(uint256 streamId) private view returns (uint256 amount) { + // The tokens in the stream = amount deposited - amount withdrawn - amount refunded. + return sablierLockup.getDepositedAmount(streamId) - sablierLockup.getWithdrawnAmount(streamId) + - sablierLockup.getRefundedAmount(streamId); +} +``` diff --git a/docs/contracts/v2/guides/staking/05-rewards.md b/docs/contracts/v2/guides/staking/05-rewards.md new file mode 100644 index 00000000..203ad069 --- /dev/null +++ b/docs/contracts/v2/guides/staking/05-rewards.md @@ -0,0 +1,141 @@ +--- +id: "stake-rewards" +sidebar_position: 5 +title: "Update rewards" +--- + +In this section, we will define a modifier to update the rewards earned by users each time one of following functions is +called: + +- `claimRewards` +- `onSablierLockupCancel` +- `onSablierLockupWithdraw` +- `stake` +- `unstake` + +First, define the modifier with `account` as an input parameter: + +```solidity +modifier updateReward(address account) { + // code goes here + _; +} +``` + +### Total rewards paid per ERC-20 token + +Inside the modifier, we will update the total rewards earned per ERC-20 token. + +```solidity +totalRewardPaidPerERC20Token = rewardPaidPerERC20Token(); +``` + +The implementation of `rewardPaidPerERC20Token` goes as follows: + +```solidity +/// @notice Calculates the total rewards distributed per ERC-20 token. +/// @dev This is called by `updateReward`, which also updates 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 ERC-20. + if (totalERC20StakedSupply == 0 || block.timestamp >= stakingEndTime) { + return totalRewardPaidPerERC20Token; + } + + uint256 totalRewardsPerERC20InCurrentPeriod = + ((lastTimeRewardsApplicable() - lastUpdateTime) * rewardRate * 1e18) / totalERC20StakedSupply; + + return totalRewardPaidPerERC20Token + totalRewardsPerERC20InCurrentPeriod; +} +``` + +The function calculates the rewards earned by all the streams since the last snapshot, and divides the result by the +total amount of ERC-20 tokens being vested through all staked streams. Finally, it adds the value to +`totalRewardPaidPerERC20Token`. So, the `totalRewardPaidPerERC20Token` variable tracks the cumulative rewards earned per +ERC-20 token. + +This is also helpful in calculating the rewards earned by each user: + +```math +\text{rewards} = \text{amount in stream} \times \text{rewardPaidPerERC20Token} - \text{rewards already paid} +``` + +### Last time update + +Now let's move onto the second line of the modifier: + +```solidity + lastUpdateTime = lastTimeRewardsApplicable(); +``` + +The implementation of `lastTimeRewardsApplicable` goes as follows: + +```solidity +function lastTimeRewardsApplicable() public view returns (uint256) { + return block.timestamp < stakingEndTime ? block.timestamp : stakingEndTime; +} +``` + +which is just the block timestamp if the staking period has not ended. + +### Rewards earned by each user + +The third line of the modifier calculates and stores rewards earned by each user: + +```solidity + rewards[account] = calculateUserRewards(account); +``` + +The implementation of `calculateUserRewards` goes as follows: + +```solidity +/// @return earned The amount available as rewards for the account. +function calculateUserRewards(address account) public view returns (uint256 earned) { + // Return if no tokens are staked. + if (stakedStreams[account] == 0) { + return rewards[account]; + } + + uint256 amountInStream = _getAmountInStream(stakedStreams[account]); + + // Get the rewards already paid to the user per ERC-20 token. + uint256 userRewardPerERC20Token_ = userRewardPerERC20Token[account]; + + uint256 rewardsSinceLastTime = (amountInStream * (rewardPaidPerERC20Token() - userRewardPerERC20Token_)) / 1e18; + + return rewardsSinceLastTime + rewards[account]; +} +``` + +### Update user rewards per ERC-20 token + +The final step is to set the cumulative reward per token for the user: + +```solidity + userRewardPerERC20Token[account] = totalRewardPaidPerERC20Token; +``` + +Each time this modifier is called, it updates the value of `totalRewardPaidPerERC20Token` based on the total staked +supply at that moment. This ensures that the cumulative rewards earned per ERC-20 token are tracked accurately, so users +do not lose out on their rewards even if they do not interact with the system for an extended period. + +:::info + +Let us understand this with a simple example. Let's say the reward rate is 100 tokens per hour. + +1. A user stakes 100 tokens. +2. After one hour, a second user stakes 100 tokens. The `totalRewardPaidPerERC20Token` will be updated to 1. +3. After two hours, a third user stakes 200 tokens, which makes total tokens staked to be 400. The + `totalRewardPaidPerERC20Token` will be updated to 1.5. Note that the modifier is called at the start of the function. +4. After three hours, the first user claims their rewards. The `totalRewardPaidPerERC20Token` will be updated to 1.75. + `rewards[account]` will be set to 175, and the `userRewardPerERC20Token[account]` will be set to 1.75. The first user + ends up with a reward of 175 tokens. + +Let us check if this is indeed correct. + +1. First hour: Because the first user is the only staker, they should earn 100 tokens. +2. 2nd hour: Now because of the second user, the first user should earn 50 tokens. +3. 3rd hour: The first user should earn 25 tokens. + +QED. + +::: diff --git a/docs/contracts/v2/guides/staking/06-claim.md b/docs/contracts/v2/guides/staking/06-claim.md new file mode 100644 index 00000000..fa98e791 --- /dev/null +++ b/docs/contracts/v2/guides/staking/06-claim.md @@ -0,0 +1,21 @@ +--- +id: "stake-claim" +sidebar_position: 6 +title: "Claim Rewards" +--- + +This function transfers the rewards earned by `msg.sender` and then resets the value of `rewards` storage variable to +zero. + +```solidity +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); + } +} +``` diff --git a/docs/contracts/v2/guides/staking/07-unstake.md b/docs/contracts/v2/guides/staking/07-unstake.md new file mode 100644 index 00000000..9ecf56ad --- /dev/null +++ b/docs/contracts/v2/guides/staking/07-unstake.md @@ -0,0 +1,38 @@ +--- +id: "stake-unstake" +sidebar_position: 7 +title: "Unstake" +--- + +The `unstake` function takes `streamId` as input, and unstakes the stream by transferring the Sablier NFT back to the +user. + +```solidity +function unstake(uint256 streamId) public updateReward(msg.sender) { + // code goes here. +} +``` + +As the first step, we will check if the user is a staker. + +```solidity +if (stakedUsers[streamId] != msg.sender) { + revert UnauthorizedCaller(msg.sender, streamId); +} +``` + +As the second step, we will reduce the total amount of underlying ERC20 token staked. + +```solidity +totalERC20StakedSupply -= _getAmountInStream(streamId); +``` + +As the final step, we will update some storage variables and transfer the NFT: + +```solidity +delete stakedUsers[streamId]; + +delete stakedStreams[account]; + +sablierLockup.safeTransferFrom({ from: address(this), to: account, tokenId: streamId }); +``` diff --git a/docs/contracts/v2/guides/staking/_category_.json b/docs/contracts/v2/guides/staking/_category_.json new file mode 100644 index 00000000..7cb71ddb --- /dev/null +++ b/docs/contracts/v2/guides/staking/_category_.json @@ -0,0 +1,5 @@ +{ + "collapsed": true, + "position": 7, + "label": "Staking" +}