Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: staking template for Sablier NFTs #9

Merged
merged 3 commits into from
Jul 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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"
}
}
362 changes: 362 additions & 0 deletions src/StakeSablierNFT.sol

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions test/stake-sablier-nft/StakeSablierNFT.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
44 changes: 44 additions & 0 deletions test/stake-sablier-nft/claim-rewards/claimRewards.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
5 changes: 5 additions & 0 deletions test/stake-sablier-nft/claim-rewards/claimRewards.tree
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions test/stake-sablier-nft/stake/stake.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
12 changes: 12 additions & 0 deletions test/stake-sablier-nft/stake/stake.tree
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions test/stake-sablier-nft/unstake/unstake.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
9 changes: 9 additions & 0 deletions test/stake-sablier-nft/unstake/unstake.tree
Original file line number Diff line number Diff line change
@@ -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