Skip to content

Commit

Permalink
feat: staking contract guide (#195)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
smol-ninja and PaulRBerg authored Nov 20, 2024
1 parent 75da54c commit 81bf4e2
Show file tree
Hide file tree
Showing 12 changed files with 453 additions and 4 deletions.
2 changes: 1 addition & 1 deletion docs/contracts/v2/guides/07-frontend.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
draft: true
sidebar_position: 7
sidebar_position: 8
id: "frontend"
title: "Frontend Integrations"
---
Expand Down
2 changes: 1 addition & 1 deletion docs/contracts/v2/guides/08-snapshot-voting.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: "snapshot-voting"
sidebar_position: 8
sidebar_position: 9
title: "Snapshot Strategies"
---

Expand Down
2 changes: 1 addition & 1 deletion docs/contracts/v2/guides/09-etherscan.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: "etherscan"
sidebar_position: 9
sidebar_position: 10
title: "Etherscan"
---

Expand Down
2 changes: 1 addition & 1 deletion docs/contracts/v2/guides/10-custom-deployments.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
id: "custom-deployments"
sidebar_position: 10
sidebar_position: 11
title: "Custom Deployments"
---

Expand Down
71 changes: 71 additions & 0 deletions docs/contracts/v2/guides/staking/01-setup.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 13 additions & 0 deletions docs/contracts/v2/guides/staking/02-full-code.md
Original file line number Diff line number Diff line change
@@ -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
```
98 changes: 98 additions & 0 deletions docs/contracts/v2/guides/staking/03-hooks.md
Original file line number Diff line number Diff line change
@@ -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;
}
```
62 changes: 62 additions & 0 deletions docs/contracts/v2/guides/staking/04-stake.md
Original file line number Diff line number Diff line change
@@ -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);
}
```
Loading

0 comments on commit 81bf4e2

Please sign in to comment.