generated from PaulRBerg/foundry-template
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: staking template for Sablier NFTs (#9)
* feat: add a staking template 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 * refactor: update staking contract for v2.2 * docs: add disclaimer
- Loading branch information
1 parent
0da4afe
commit 144e999
Showing
12 changed files
with
708 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |