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

Improve coverage #40

Merged
merged 17 commits into from
Aug 11, 2024
80 changes: 39 additions & 41 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,15 @@ jobs:

# https://twitter.com/PaulRBerg/status/1611116650664796166
- name: Generate fuzz seed with 1 day TTL
run: >
echo "FOUNDRY_FUZZ_SEED=$(
echo $(($EPOCHSECONDS - $EPOCHSECONDS % 86400))
)" >> $GITHUB_ENV
run: |
echo "FOUNDRY_FUZZ_SEED=$(( $(date +%s) - $(date +%s) % 86400 ))" >> $GITHUB_ENV

- name: Run coverage
run: forge coverage --report summary --report lcov --ir-minimum
run: |
forge coverage --report summary --report lcov --nmc IntegrationTest --ir-minimum

# To ignore coverage for certain directories modify the paths in this step as needed. The
# below default ignores coverage results for the test and script directories. Alternatively,
# To ignore coverage for certain directories modify the paths in this step as needed.
# The below default ignores coverage results for the test and script directories. Alternatively,
# to include coverage in all directories, comment out this step. Note that because this
# filtering applies to the lcov file, the summary table generated in the previous step will
# still include all files and directories.
Expand All @@ -72,8 +71,7 @@ jobs:
- name: Filter directories
run: |
sudo apt update && sudo apt install -y lcov
lcov --remove lcov.info 'test/*' 'script/*' 'src/libraries/*' \
--output-file lcov.info --rc lcov_branch_coverage=1
lcov --remove lcov.info 'test/*' 'script/*' 'src/libraries/*' --output-file lcov.info --rc lcov_branch_coverage=0

# This step posts a detailed coverage report as a comment and deletes previous comments on
# each push. The below step is used to fail coverage if the specified coverage threshold is
Expand Down Expand Up @@ -126,35 +124,35 @@ jobs:
echo "✅ Passed or warnings found" >> $GITHUB_STEP_SUMMARY
fi

slither-analyze:
runs-on: "ubuntu-latest"
permissions:
actions: "read"
contents: "read"
security-events: "write"
steps:
- name: "Check out the repo"
uses: "actions/checkout@v4"

- name: "Install Bun"
uses: "oven-sh/setup-bun@v1"

- name: "Install the Node.js dependencies"
run: "bun install"

- name: "Run Slither analysis"
uses: "crytic/[email protected]"
id: "slither"
with:
fail-on: "none"
sarif: "results.sarif"

- name: "Upload SARIF file to GitHub code scanning"
uses: "github/codeql-action/upload-sarif@v2"
with:
sarif_file: ${{ steps.slither.outputs.sarif }}

- name: "Add summary"
run: |
echo "## Slither result" >> $GITHUB_STEP_SUMMARY
echo "✅ Uploaded to GitHub code scanning" >> $GITHUB_STEP_SUMMARY
# slither-analyze:
# runs-on: "ubuntu-latest"
# permissions:
# actions: "read"
# contents: "read"
# security-events: "write"
# steps:
# - name: "Check out the repo"
# uses: "actions/checkout@v4"

# - name: "Install Bun"
# uses: "oven-sh/setup-bun@v1"

# - name: "Install the Node.js dependencies"
# run: "bun install"

# - name: "Run Slither analysis"
# uses: "crytic/[email protected]"
# id: "slither"
# with:
# fail-on: "none"
# sarif: "results.sarif"

# - name: "Upload SARIF file to GitHub code scanning"
# uses: "github/codeql-action/upload-sarif@v2"
# with:
# sarif_file: ${{ steps.slither.outputs.sarif }}

# - name: "Add summary"
# run: |
# echo "## Slither result" >> $GITHUB_STEP_SUMMARY
# echo "✅ Uploaded to GitHub code scanning" >> $GITHUB_STEP_SUMMARY
18 changes: 15 additions & 3 deletions src/BaseStaking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,16 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable {
/// @notice Thrown when transfer/tranferFrom is called
error TransferDisabled();

/// @notice Thrown when a user has no shares
error UserHasNoShares();

/// @notice Thrown when a user try to claim rewards but has no rewards to
/// claim
error NoRewardsToClaim();

/// @notice Thrown when the argument is the zero address
error AddressZero();

/// @notice Thrown when the amount of shares is 0
error SharesMustBeGreaterThanZero();

/*//////////////////////////////////////////////////////////////
MODIFIERS
//////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -181,6 +181,7 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable {
uint256 assets
) public view virtual returns (uint256) {
// sum + 1 on both sides to prevent donation attack
// this is the same as OZ ERC4626 prevetion to inflation attack with decimal offset = 0
return assets.mulDivDown(totalSupply() + 1, _totalAssets() + 1);
}

Expand All @@ -190,6 +191,7 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable {
uint256 shares
) public view virtual returns (uint256) {
// sum + 1 on both sides to prevent donation attack
// this is the same as OZ ERC4626 prevetion to inflation attack with decimal offset = 0
return shares.mulDivDown(_totalAssets() + 1, totalSupply() + 1);
}

Expand All @@ -215,6 +217,15 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable {
// Calculate the amount of shares to mint
uint256 shares = convertToShares(amount);

// A first deposit donation attack may result in shares being 0 if the
// contract has very high assets balance but a very low total supply.
// Although this attack is not profitable for the attacker, as they will
// spend more tokens than they will receive, it can still be used to perform a DDOS attack
// against a specific user. The targeted user can still withdraw their SHU,
// but this is only guaranteed if someone mints to increase the total supply of shares,
// because previewWithdraw rounds up and their shares will be less than the burn amount.
require(shares > 0, SharesMustBeGreaterThanZero());

// Update the total locked amount
totalLocked[user] += amount;

Expand Down Expand Up @@ -253,6 +264,7 @@ abstract contract BaseStaking is OwnableUpgradeable, ERC20VotesUpgradeable {
/// @param assets The amount of assets
function _previewWithdraw(uint256 assets) internal view returns (uint256) {
// sum + 1 on both sides to prevent donation attack
// this is the same as OZ ERC4626 prevetion to inflation attack with decimal offset = 0
return assets.mulDivUp(totalSupply() + 1, _totalAssets() + 1);
}

Expand Down
23 changes: 20 additions & 3 deletions src/DelegateStaking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,15 @@ interface IStaking {
* A user's SHU balance is calculated as:
* balanceOf(user) * totalSupply() / totalShares()
*
* Staking, unstaking, and claiming rewards are based on shares, not the balance directly.
* This method ensures the balance can change over time without needing too many storage updates.
*
* When staking, you must specify a keyper address. This symbolically demonstrates your support
* for that keyper. The keyper address must be a valid keyper in the staking contract.
* Staking, unstaking, and claiming rewards are based on shares rather than the balance directly.
* This method ensures the balance can change over time without needing too many storage updates.
*
* @dev SHU tokens transferred into the contract without using the `stake` function will be included
* in the rewards distribution and shared among all stakers. This contract only supports SHU
* tokens. Any non-SHU tokens transferred into the contract will be permanently lost.
*
*/
contract DelegateStaking is BaseStaking {
Expand Down Expand Up @@ -67,6 +72,9 @@ contract DelegateStaking is BaseStaking {
/// @notice stores the metadata associated with a given stake
mapping(uint256 id => Stake _stake) public stakes;

/// @notice stores the amount delegated to a keyper
mapping(address keyper => uint256 totalDelegated) public totalDelegated;

/*//////////////////////////////////////////////////////////////
EVENTS
//////////////////////////////////////////////////////////////*/
Expand All @@ -89,6 +97,9 @@ contract DelegateStaking is BaseStaking {
ERRORS
//////////////////////////////////////////////////////////////*/

/// @notice Thrown when a user has no shares
error UserHasNoShares();

/// @notice Trown when amount is zero
error ZeroAmount();

Expand Down Expand Up @@ -160,6 +171,9 @@ contract DelegateStaking is BaseStaking {
stakes[stakeId].timestamp = block.timestamp;
stakes[stakeId].lockPeriod = lockPeriod;

// Increase the keyper total delegated amount
totalDelegated[keyper] += amount;

_deposit(user, amount);

emit Staked(user, keyper, amount, lockPeriod);
Expand All @@ -183,7 +197,7 @@ contract DelegateStaking is BaseStaking {
function unstake(
uint256 stakeId,
uint256 _amount
) external returns (uint256 amount) {
) external updateRewards returns (uint256 amount) {
address user = msg.sender;
require(userStakes[user].contains(stakeId), StakeDoesNotBelongToUser());
Stake memory userStake = stakes[stakeId];
Expand All @@ -208,6 +222,9 @@ contract DelegateStaking is BaseStaking {
// Decrease the amount from the stake
stakes[stakeId].amount -= amount;

// Decrease the total delegated amount
totalDelegated[userStake.keyper] -= amount;

// If the stake is empty, remove it
if (stakes[stakeId].amount == 0) {
// Remove the stake from the stakes mapping
Expand Down
26 changes: 13 additions & 13 deletions src/Staking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@ import {IRewardsDistributor} from "./interfaces/IRewardsDistributor.sol";
* Please be aware that the contract's Owner can change the minimum stake amount.
* If the Owner is compromised, they could set the minimum stake amount very high,
* making it impossible for keypers to unstake their SHU.
* The Owner of this contract is the Shutter DAO multisig. By staking SHU, you trust
* the Owner not to set the minimum stake amount to an unreasonably high value.
* The Owner of this contract is the Shutter DAO multisig with a Azorius module.
* By staking SHU, you trust the Owner not to set the minimum stake amount to
* an unreasonably high value.
*
* @dev SHU tokens transferred into the contract without using the `stake` function will be included
* in the rewards distribution and shared among all stakers. This contract only supports SHU
* tokens. Any non-SHU tokens transferred into the contract will be permanently lost.
*
*/
contract Staking is BaseStaking {
Expand Down Expand Up @@ -84,6 +89,8 @@ contract Staking is BaseStaking {
/*//////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////*/
/// @notice Thrown when a user has no shares
error UserHasNoShares();

/// @notice Thrown when a non-keyper attempts a call for which only keypers are allowed
error OnlyKeyper();
Expand All @@ -97,7 +104,7 @@ contract Staking is BaseStaking {

/// @notice Thrown when someone try to unstake a stake that doesn't belong
/// to the keyper in question
error StakeDoesNotBelongToKeyper();
error StakeDoesNotBelongToUser();

/// @notice Thrown when someone try to unstake a stake that doesn't exist
error StakeDoesNotExist();
Expand All @@ -115,11 +122,6 @@ contract Staking is BaseStaking {
_;
}

/// @notice Ensure logic contract is unusable
constructor() {
_disableInitializers();
}

/// @notice Initialize the contract
/// @param _owner The owner of the contract, i.e. the DAO contract address
/// @param _stakingToken The address of the staking token, i.e. SHU
Expand Down Expand Up @@ -211,10 +213,10 @@ contract Staking is BaseStaking {
address keyper,
uint256 stakeId,
uint256 _amount
) external returns (uint256 amount) {
) external updateRewards returns (uint256 amount) {
require(
userStakes[keyper].contains(stakeId),
StakeDoesNotBelongToKeyper()
StakeDoesNotBelongToUser()
);
Stake memory keyperStake = stakes[stakeId];

Expand All @@ -233,7 +235,6 @@ contract Staking is BaseStaking {
// must be locked for the lock period
// If the global lock period is greater than the stake lock period,
// the stake must be locked for the stake lock period

uint256 lock = keyperStake.lockPeriod > lockPeriod
? lockPeriod
: keyperStake.lockPeriod;
Expand Down Expand Up @@ -280,7 +281,6 @@ contract Staking is BaseStaking {
/// @param _minStake The minimum stake amount
function setMinStake(uint256 _minStake) external onlyOwner {
minStake = _minStake;

emit NewMinStake(_minStake);
}

Expand Down Expand Up @@ -320,7 +320,7 @@ contract Staking is BaseStaking {
/// - if the keyper sSHU balance is less or equal than the minimum
/// stake or the total locked amount, the function will return 0
/// @param keyper The keyper address
/// @param unlockedAmount The amount of assets to unlock
/// @param unlockedAmount The amount of unlocked assets
/// @return amount The maximum amount of assets that a keyper can withdraw after unlocking a certain amount
function _maxWithdraw(
address keyper,
Expand Down
Loading
Loading