Platform: Code4rena
Start Date: 16/01/2024
Pot Size: $80,000 USDC
Total HM: 37
Participants: 178
Period: 14 days
Judge: Picodes
Total Solo HM: 4
Id: 320
League: ETH
Rank: 12/178
Findings: 6
Award: $1,158.39
🌟 Selected for report: 1
🚀 Solo Findings: 0
326.1249 USDC - $326.12
Users cannot earn rewards from the pool. Also, users cannot withdraw their liquidity once they deposit since _decreaseUserShare
will always fail as claimableRewards
will be too big.
When a user deposits liquidity into a pool, _increaseUserShare()
will be called and the totalRewards
, totalShares
, virtualRewards
and userShares
will be updated.
uint256 existingTotalShares = totalShares[poolID]; // Determine the amount of virtualRewards to add based on the current ratio of rewards/shares. // The ratio of virtualRewards/increaseShareAmount is the same as totalRewards/totalShares for the pool. // The virtual rewards will be deducted later when calculating the user's owed rewards. > if ( existingTotalShares != 0 ) // prevent / 0 { // Round up in favor of the protocol. uint256 virtualRewardsToAdd = Math.ceilDiv( totalRewards[poolID] * increaseShareAmount, existingTotalShares ); > user.virtualRewards += uint128(virtualRewardsToAdd); > totalRewards[poolID] += uint128(virtualRewardsToAdd); } // Update the deposit balances > user.userShare += uint128(increaseShareAmount); > totalShares[poolID] = existingTotalShares + increaseShareAmount;
Take note that only if existingTotalShares
is not zero, then the virtual rewards will be calculated.
When a pool is first whitelisted and created, there is no default liquidity deposited into the pool.
The first depositor can deposit 1 wei of share (or rather, a small amount of share, will be explained later) so that the existingTotalShare
becomes a small number. Then, he can deposit a huge liquidity so that the totalRewards
of the pool will become a huge amount. Subsequently, anyone who deposits in the same pool will also have more rewards than intended.
This assumes that the pool has some salt reward added to it in the first place, or if the depositor adds the reward himself.
1 wei of added share is not possible because of the check of dust amount when adding liquidity. The minimum added share would then be 200 (100 wei each)
function addLiquidity( IERC20 tokenA, IERC20 tokenB, uint256 maxAmountA, uint256 maxAmountB, uint256 minLiquidityReceived, uint256 totalLiquidity ) external nonReentrant returns (uint256 addedAmountA, uint256 addedAmountB, uint256 addedLiquidity) { require( msg.sender == address(collateralAndLiquidity), "Pools.addLiquidity is only callable from the CollateralAndLiquidity contract" ); require( exchangeIsLive, "The exchange is not yet live" ); require( address(tokenA) != address(tokenB), "Cannot add liquidity for duplicate tokens" ); > require( maxAmountA > PoolUtils.DUST, "The amount of tokenA to add is too small" ); > require( maxAmountB > PoolUtils.DUST, "The amount of tokenB to add is too small" );
totalRewards
becomes 10000e18 * 10e18 / 100 which is 1000e36. The totalRewards
become 1000e36.totalRewards
variable is now manipulated alreadyThe pool is now unable to earn rewards because totalRewards
is too big.
Also, users will find themselves adding liquidity into the pool but unable to withdraw any liquidity since the value of totalRewards
is too big. As a result, the value of claimableRewards
will be too big as well, which will lead to a revert when transferring salt tokens.
// In the event that virtualRewards are greater than actual rewards - claimableRewards will stay zero. if ( virtualRewardsToRemove < rewardsForAmount ) claimableRewards = rewardsForAmount - virtualRewardsToRemove; // Send the claimable rewards > if ( claimableRewards != 0 ) > salt.safeTransfer( wallet, claimableRewards );
VSCode
Recommend the protocol start out with an initial liquidity for all whitelisted pool to prevent users from manipulating the totalRewards
variable. 1e18 worth of pool tokens will be enough to start, and the DAO can pool the liquidity from the Protocol-owned liquidity.
DoS
#0 - c4-judge
2024-02-02T11:42:45Z
Picodes marked the issue as duplicate of #341
#1 - c4-judge
2024-02-21T16:19:23Z
Picodes marked the issue as satisfactory
🌟 Selected for report: PENGUN
Also found by: 0xanmol, Draiakoo, J4X, Matue, ReadyPlayer2, cu5t0mpeo, dutra, falconhoof, grearlake, peanuts, piyushshukla, vnavascues, zhanmingjing, zhaojie
31.1969 USDC - $31.20
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L259-L283 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/staking/StakingRewards.sol#L97-L118
To cast a vote (Proposals.sol), the voter has to have some SALT staked in the pool. This staked SALT, or xSALT, refers to their voting power.
uint256 userVotingPower = staking.userShareForPool( msg.sender, PoolUtils.STAKED_SALT ); require( userVotingPower > 0, "Staked SALT required to vote" );
Users can cast their votes and then unstake their SALT. The process of unstaking SALT will not affect their current voting power, which should not be the case.
This is a potential problem for the protocol. Whales that decides to not participate in the protocol anymore can vote negatively for all the current proposals and then unstake their position.
They will not be affected in any way but it will grief all the incoming proposals.
The whales action will also affect the quorum percentage since staking.totalShares is part of the calculation for the required quorum. This means that the quorum assumes that the whale's shares are not there already but they still have their votes counted.
function requiredQuorumForBallotType( BallotType ballotType ) public view returns (uint256 requiredQuorum) { // The quorum will be specified as a percentage of the total amount of SALT staked uint256 totalStaked = staking.totalShares( PoolUtils.STAKED_SALT ); require( totalStaked != 0, "SALT staked cannot be zero to determine quorum" );
To give a concrete example: Say there are 5 million total SALT staked. Alice the whale has 1 million of that total SALT staked.
The current ballot percentage is 10%, so to reach quorum, the total number of votes must be 500k.
Alice wants to leave the protocol, so she decides to grief all the proposals by voting for the negative. Afterwards, she unstakes her 1 million SALT.
The total number of SALT staked is now 4 million. The current quorum votes needed is now 400k. Alice still has 1 million votes for the negative, which is unfair because the quorum is lowered due to her unstaking but she still has a say in voting.
The balloting procedure can be potentially griefed by unstakers.
Manual Review
Recommend deleting all votes when a user unstakes their SALT.
The unstake function has to check all the ballotID that the user participated in and make sure he does not have votes in any of them.
Context
#0 - c4-judge
2024-02-02T11:13:42Z
Picodes marked the issue as duplicate of #746
#1 - c4-judge
2024-02-14T08:06:57Z
Picodes marked the issue as satisfactory
53.4926 USDC - $53.49
Judge has assessed an item in Issue #907 as 2 risk. The relevant finding follows:
[L-08] CoreChainlinkFeed uses the BTC_USD address when it should be using the WBTC-BTC one as well CoreChainlinkFeed assumes that the price of BTC is equal to the price of WBTC.
This is the BTC/USD feed:
https://data.chain.link/feeds/ethereum/mainnet/btc-usd
This is the WBTC/BTC feed:
https://data.chain.link/feeds/ethereum/mainnet/wbtc-btc
Note that the WBTC/BTC is not 1:1. Users may not get the actual WBTC price (since protocol is using WBTC instead of BTC) which will affect collaterization ratio and future liquidations.
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/CoreChainlinkFeed.sol#L32-L39
#0 - c4-judge
2024-02-27T17:23:55Z
Picodes marked the issue as duplicate of #60
#1 - c4-judge
2024-02-27T17:23:59Z
Picodes marked the issue as satisfactory
122.2968 USDC - $122.30
Judge has assessed an item in Issue #907 as 2 risk. The relevant finding follows:
[L-02] In ManagedWallet.sol, the activeTimelock can be set even before proposeWallets is called, so the activeTimelock can be bypassed. The confirmation wallet can send 0.05 ether even before proposeWallets() function is called to skip the TIMELOCK_DURATION.
receive() external payable { require( msg.sender == confirmationWallet, "Invalid sender" ); // Confirm if .05 or more ether is sent and otherwise reject. // Done this way in case custodial wallets are used as the confirmationWallet - which sometimes won't allow for smart contract calls. if ( msg.value >= .05 ether ) //@audit - Skip this part by depositing 0.05 ether before calling `proposeWallets()` activeTimelock = block.timestamp + TIMELOCK_DURATION; // establish the timelock else activeTimelock = type(uint256).max; // effectively never }
Make sure the activeTimelock is only set once the proposeWallet() function is called.
#0 - c4-judge
2024-02-03T13:20:51Z
Picodes marked the issue as duplicate of #637
#1 - c4-judge
2024-02-19T16:25:52Z
Picodes marked the issue as satisfactory
🌟 Selected for report: juancito
Also found by: 0x3b, 0xBinChook, 0xCiphky, 0xHelium, 0xMango, 0xOmer, 0xRobocop, 0xSmartContractSamurai, 0xWaitress, 0xbepresent, 0xpiken, 7ashraf, Arz, Audinarey, Banditx0x, Bauchibred, Draiakoo, IceBear, J4X, Jorgect, Kaysoft, KingNFT, Rhaydden, Rolezn, The-Seraphs, Tigerfrake, Topmark, Tripathi, Udsen, ZanyBonzy, a3yip6, b0g0, chaduke, codeslide, csanuragjain, dharma09, djxploit, eta, ether_sky, grearlake, inzinko, jasonxiale, jesjupyter, josephdara, lanrebayode77, linmiaomiao, lsaudit, niroh, oakcobalt, peanuts, pina, sivanesh_808, slvDev, t0x1c, vnavascues, wangxx2026
72.1303 USDC - $72.13
This is the calculation for emission:
uint256 saltToSend = ( saltBalance * timeSinceLastUpkeep * rewardsConfig.emissionsWeeklyPercentTimes1000() ) / ( 100 * 1000 weeks );
Assume emissionsWeeklyPercentTimes1000 is 500, since timeSinceLastUpkeep is not controllable (max of one week), the saltBalance
will eventually be a dust amount in the contract. Then, the saltToSend
will truncate to zero.
uint256 saltToSend = ( saltBalance * timeSinceLastUpkeep * rewardsConfig.emissionsWeeklyPercentTimes1000() ) / ( 100 * 1000 weeks ); // Left 100 wei saltBalance uint256 saltToSend = 100 * 604800 * 500 / 100 * 1000 * 604800 = 0
At the very end, make sure the salt amount can be withdrawn.
The confirmation wallet can send 0.05 ether even before proposeWallets()
function is called to skip the TIMELOCK_DURATION.
receive() external payable { require( msg.sender == confirmationWallet, "Invalid sender" ); // Confirm if .05 or more ether is sent and otherwise reject. // Done this way in case custodial wallets are used as the confirmationWallet - which sometimes won't allow for smart contract calls. if ( msg.value >= .05 ether ) //@audit - Skip this part by depositing 0.05 ether before calling `proposeWallets()` activeTimelock = block.timestamp + TIMELOCK_DURATION; // establish the timelock else activeTimelock = type(uint256).max; // effectively never }
Make sure the activeTimelock
is only set once the proposeWallet()
function is called.
If the confirmation wallet user changes his mind, he can send 1 wei of ether to the contract and reset the activeTimelock
.
if ( msg.value >= .05 ether ) > activeTimelock = block.timestamp + TIMELOCK_DURATION; // establish the timelock else > activeTimelock = type(uint256).max; // effectively never
This should not be the case, as once the activeTimelock is set, it should not be able to be nullified.
In setInitialFeeds, the owner can set _priceFeed1 as the zero address. He can then call setInitialFeeds()
again
function setInitialFeeds( IPriceFeed _priceFeed1, IPriceFeed _priceFeed2, IPriceFeed _priceFeed3 ) public onlyOwner { require( address(priceFeed1) == address(0), "setInitialFeeds() can only be called once" ); //@audit - set _priceFeed1 as address(0) priceFeed1 = _priceFeed1; priceFeed2 = _priceFeed2; priceFeed3 = _priceFeed3; }
Have some checks in the setInitialFeeds()
function like require(_priceFeed1 != address(0))
to prevent the owner from re-setting the initial feeds.
Document states:
Expedited unstaking is possible with a penalty - with the fastest default unstaking period being two weeks with a default penalty of 80%.
The fastest default unstaking period is actually 1 week, with a default penalty of 80%.
function changeMinUnstakeWeeks(bool increase) external onlyOwner { if (increase) { if (minUnstakeWeeks < 12) minUnstakeWeeks += 1; } else { > if (minUnstakeWeeks > 1) minUnstakeWeeks -= 1; } emit MinUnstakeWeeksChanged(minUnstakeWeeks); }
In my opinion, I think 1 week is better than 2 weeks because the penalty is already very heavy.
Users who have a signature (on the whitelist) can vote to start the exchange for the Salty protocol. Honestly, I don't see a reason why a vote is needed to start an exchange because if the vote is a resounding no, then will the whole contract be redeployed?
Also, voters that vote no are not penalized. They will still receive the same amount of airdrop as those who vote yes. So, there is not really a difference between a yes and no vote for the users perspective and the some griefers will vote no for fun because they will get the airdrop either way.
function vote( bool voteStartExchangeYes, bytes calldata signature ) external nonReentrant { require( ! hasVoted[msg.sender], "User already voted" ); // Verify the signature to confirm the user is authorized to vote bytes32 messageHash = keccak256(abi.encodePacked(block.chainid, msg.sender)); require(SigningTools._verifySignature(messageHash, signature), "Incorrect BootstrapBallot.vote signatory" ); if ( voteStartExchangeYes ) > startExchangeYes++; else > startExchangeNo++; hasVoted[msg.sender] = true; // As the whitelisted user has retweeted the launch message and voted, they are authorized to the receive the airdrop. > airdrop.authorizeWallet(msg.sender); } Consider implementing a small penalty for those that vote No. The current system is designed to assume the best of people, but I don't know if it's actually the ideal choice. https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/launch/BootstrapBallot.sol#L48-L65
When collecting data from Chainlink, the data returned is not sufficiently checked. Specifically, roundID is not checked, as well as the min-max threshold.
try chainlinkFeed.latestRoundData() returns ( uint80, // _roundID int256 _price, uint256, // _startedAt uint256 _answerTimestamp, uint80 // _answeredInRound )
Implement the roundId check as well
require(answeredInRound >= roundID, "round not complete");
CoreChainlinkFeed assumes that the price of BTC is equal to the price of WBTC.
This is the BTC/USD feed:
https://data.chain.link/feeds/ethereum/mainnet/btc-usd
This is the WBTC/BTC feed:
https://data.chain.link/feeds/ethereum/mainnet/wbtc-btc
Note that the WBTC/BTC is not 1:1. Users may not get the actual WBTC price (since protocol is using WBTC instead of BTC) which will affect collaterization ratio and future liquidations.
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/CoreChainlinkFeed.sol#L32-L39
The protocol uses a price aggregator that aggregates prices from Chainlink, Salty and Uniswap.
The protocol intends to get the price of WBTC and WETH in terms of USD.
In CoreChainlinkFeed, the protocol gets the price of BTC in terms of USD.
// https://docs.chain.link/data-feeds/price-feeds/addresses AggregatorV3Interface immutable public CHAINLINK_BTC_USD; AggregatorV3Interface immutable public CHAINLINK_ETH_USD;
In CoreSaltyFeed, the protocol gets the price of WBTC in terms of USDS.
function getPriceBTC() external view returns (uint256) { (uint256 reservesWBTC, uint256 reservesUSDS) = pools.getPoolReserves(wbtc, usds); if ( ( reservesWBTC < PoolUtils.DUST ) || ( reservesUSDS < PoolUtils.DUST ) ) return 0; // reservesWBTC has 8 decimals, keep the 18 decimals of reservesUSDS return ( reservesUSDS * 10**8 ) / reservesWBTC; }
In CoreUniswapFeed, the protocol gets the price of WBTC in terms of USDC. (WBTC-WETH-USDC)
constructor( IERC20 _wbtc, IERC20 _weth, IERC20 _usdc, address _UNISWAP_V3_WBTC_WETH, address _UNISWAP_V3_WETH_USDC ) { UNISWAP_V3_WBTC_WETH = IUniswapV3Pool(_UNISWAP_V3_WBTC_WETH); UNISWAP_V3_WETH_USDC = IUniswapV3Pool(_UNISWAP_V3_WETH_USDC);
By doing so, the protocol assumes that USD,USDC and USDS all have the same price, and WBTC and BTC have the same price.
This is slightly inaccurate since they don't have the same price. WBTC has a different price from BTC.
Also, USDC has a different price from USD, that is why there is a Chainlink oracle for that:
https://data.chain.link/feeds/ethereum/mainnet/usdc-usd
Lastly, and most importantly, USDS may not be at $1 all the time. There are instances of a collateralized stablecoin depegging (USDR, DOLA, even DAI).
The most accurate feed would be the WBTC-USDC price from Uniswap TWAP. If the protocol wants to be more accurate in the pricing, consider adding a WBTC/BTC price for Chainlink and a USDC/USDS pool for SALT.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/CoreChainlinkFeed.sol#L14-L17 https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/CoreSaltyFeed.sol https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/CoreUniswapFeed.sol
When minting USDS and checking for liquidation, USDS will always be assumed to be $1.
This is my guess for the assumption: When the price of USDS is more than $1, users will deposit more collateral, mint USDS and swap them for USDC because they will earn money. This swap will eventually bring the price back down to a dollar.
When the price of USDS is less than $1, users will buy more USDS because it is cheaper to pay back their debt now, which will bring the price back up to a dollar.
While this assumption is true, it does not count for flash crashes. If WBTC and WETH happens to drop until most positions becomes undercollaterized, then the protocol will incur a lot of bad debt. In this sense, bad debt doesn't actually refer to a monetary amount, but it refers to USDS being depegged. When positions becomes undercollaterized and before liquidation, users will start selling their USDS instead of repaying their USDS. This will create a downward pressure and since the position is undercollaterized, even if WBTC and WETH is swapped for USDS and burned, it will not help to bring the price of USDS up much. Eventually, the price of USDS will fall and it's extremely hard to bring it back up to its peg.
The last option is to burn the Protocol owned liquidity in order to bring the price of USDS back up, but that might still not be sufficient.
Not sure about the actual recommendation, but one thing the protocol could do is use the USDC value for USDS instead of its own value. Also, it makes sense since the value of WETH and WBTC is calculated in USD/USDC and not USDS.
#0 - c4-judge
2024-02-03T13:20:40Z
Picodes marked the issue as grade-a
#1 - c4-sponsor
2024-02-12T22:29:05Z
othernet-global (sponsor) acknowledged
#2 - cryptostaker2
2024-02-22T14:06:45Z
Hi Picodes,
I believe [L-08] is a duplicate of #632, which talks about how WBTC is not 1:1 to BTC.
[L-09] is a duplicate of #938, which talks about how the different price oracles does not show the precise price feed, leading to complications.
[L-10] is a duplicate of #905, which talks about how USDS may depeg due to extreme market volatility.
Thanks for reviewing!
#3 - Picodes
2024-02-27T17:27:16Z
@cryptostaker2 thanks for your comment.
I do agree with you about L-08 and have corrected it accordingly.
For L-09 the main issue is that one of the 3 prices is manipulatable which isn't discussed here.
For L-10 I don't see how it's a duplicate. This report discusses the risk of the protocol while #905 is about the fact that the DAO holds a temporary change risk that is left unhedged.
🌟 Selected for report: peanuts
Also found by: 0xAsen, 0xHelium, 0xSmartContract, 0xepley, DedOhWale, K42, LinKenji, Sathish9098, ZanyBonzy, catellatech, fouzantanveer, foxb868, hassanshakeel13, hunter_w3b, jauvany, kaveyjoe, kinda_very_good, klau5, niroh, rspadi, yongskiws
553.1469 USDC - $553.15
This is a pretty large codebase, so I started with trying to understand the protocol first, before reading the code and getting the general idea of the protocol.
User's POV:
User's POV:
borrowUSDS()
to mint USDS.repayUSDS()
.Liquidator's POV:
liquidateUser()
in CollateralAndLiquidity.solUser's POV:
Liquidity.depositLiquidityAndIncreaseShare()
. Take note that users can only deposit into whitelisted pools and that the tokens are whitelisted.Liquidity.withdrawLiquidityAndClaim()
. Note that there is a buffer time between depositing and withdrawing liquidity to prevent any flashloan rewards exploit.User's POV:
stakeSALT()
in the Staking.sol contract. The user's wallet must have access firstunstake()
at any point in time.Contract | Function | Access | Comments |
---|---|---|---|
PoolsConfig | whitelistPool | onlyOwner, DAO | From 10-100 max pools, calls updateArbitrageIndicies |
PoolsConfig | unwhitelistPool | onlyOwner, DAO | unwhitelist the pools, what if still have liquidity inside? can it be taken out? |
PoolsConfig | changeMaximumWhitelistedPools | onlyOwner, DAO | from 20-100 with a default of 50 |
PoolsConfig | changeMaximumInternalSwapPercentTimes1000 | onlyOwner, DAO | from 0.25% to 2% with a default of 1% |
Contract | Function | Access | Comments |
---|---|---|---|
CoreChainlinkFeed | latestChainlinkPrice | view function | Called when trying to get a loan, will get WBTC and WETH price. Note that WBTC is returned as 18 decimals |
latestRoundData
is not sufficiently checked, may return stale prices.Contract | Function | Access | Comments |
---|---|---|---|
DAOConfig | changeBootstrappingRewards | onlyOwner, probably DAO | Comments and code match up, 50k-500k |
DAOConfig | changePercentPolRewardsBurned | onlyOwner, probably DAO | Comments and code match up, 25-75 |
DAOConfig | changeBaseBallotQuorumPercent | onlyOwner, probably DAO | Comments and code match up, 5000-20000 |
DAOConfig | changeBallotDuration | onlyOwner, probably DAO | Comments and code match up, 3-14 |
DAOConfig | changeRequiredProposalPercentStake | onlyOwner, probably DAO | Comments and code match up, 100-2000 |
DAOConfig | changeMaxPendingTokensForWhitelisting | onlyOwner, probably DAO | Comments and code match up, 3-12 |
DAOConfig | changeArbitrageProfitsPercentPOL | onlyOwner, probably DAO | Comments and code match up, 5-45 |
DAOConfig | changeUpkeepRewardPercent | onlyOwner, probably DAO | Comments and code match up, 1-10 |
Contract | Function | Access | Comments |
---|---|---|---|
Proposals | _possiblyCreateProposal | internal (DAO and public can call) | Users must have a percentage of salt stake to call a proposal, one user can propose only one ballot |
Proposals | createConfirmationProposal | called by DAO | DAO doesn't need any staked SALT to call a proposal |
Proposals | markBallotAsFinalized | called by DAO | This should be internal? If called this accidentally, then proposal will be voided |
Proposals | proposeParameterBallot | anyone can call | Only one proposal per address, to prevent spam |
Proposals | proposeTokenWhitelisting | anyone can call | Maximum of 12, default 5 |
Proposals | proposeTokenUnwhitelisting | anyone can call | DAO doesn't need any staked SALT to call a proposal |
Proposals | proposeSendSALT | anyone can call | Would be quite funny to propose sending salt to yourself |
Proposals | proposeCallContract | anyone can call | This is quite dangerous as no one knows what the arbitrary contract code holds |
Proposals | proposeCountryInclusion | anyone can call | Interesting requirement of 2 bytes ISO 3166 Alpha-2 Code, can this be bypassed? |
Proposals | proposeCountryExclusion | anyone can call | Excludes a country, is there any country where it must be always excluded? Like tokenunwhitelisting? |
Proposals | proposeSetContractAddress | anyone can call | Also can be quite dangerous |
Proposals | proposeWebsiteUpdate | anyone can call | website might not even be a proper one |
Proposals | castVote | anyone with staked salt | I like how there is recasting of votes. Votes can only be called once |
Proposals | requiredQuorumForBallotType | anyone with staked salt | whitelist token needs 2x the base quorum, more important things like changing website need 3x |
Contract | Function | Access | Comments |
---|---|---|---|
Parameters | _executeParameterChange | internal call | Abstract contract |
Contract | Function | Access | Comments |
---|---|---|---|
Airdrop | authorizeWallet | only BootstrapBallot can call | allowClaiming must not be called first |
Airdrop | allowClaiming | only InitialDistribution can call | users who are authorized can claim, but that means that there must be salt balance in the contract first |
Airdrop | claimAirdrop | only authorized wallet | after allowclaiming and is authorized, only can claim once |
Airdrop | isAuthorized | view function | Uses Enumerable Set, added from authorizeWallet function |
Airdrop | numberAuthorized | view function | Simply checks the length |
The first thing that happens is that the bootstrap Ballot must authorize all the wallet it wants to authorize
Once allow claiming is called (can only be called once), then there is no more authorized wallet.
This means even before the salt is sent to the initial distribution contract, some wallets must already be authorized.
Users must vote in order to be authorized in BootstrapBallot.sol
For users, they have to vote first, then wait until initial distribution is called, then call claimAirdrop(). They can only claim once. Their tokens will pass through the staking contract and then be transferred to the user (must check this interaction)
Is this authorization independent from the entire protocol? In other words, does it affect anything else?
All functions are checked to be written correctly. Users that are not authorized cannot claim. Also, once initial distribution starts, no one can be authorized anymore.
Contract | Function | Access | Comments |
---|---|---|---|
BootstrapBallot | constructor | - | Take note of the ballotDuration, most important part of this contract |
BootstrapBallot | vote | only users with the signature | signature check done offchain. Message hash includes the msg.sender |
BootstrapBallot | finalizeBallot | public | If votes are equal, also assume fail. Function can only be called once. |
BootstrapBallot | authorizeWallet | only BootstrapBallot can call | allowClaiming must not be called first |
Contract | Function | Access | Comments |
---|---|---|---|
InitialDistribution | distributionApproved | Only BootstrapBallot can call | The 100M salt token must already be in the contract |
Contract | Function | Access | Comments |
---|---|---|---|
PriceAggregator | setInitialFeeds | onlyOwner, probably DAO | The require statement is a little wrong, more comments below |
PriceAggregator | setPriceFeed | onlyOwner, probably DAO | Call once every 35 days, priceFeedModificationCooldown can be changed, 30-45 days |
PriceAggregator | changeMaximumPriceFeedPercentDifferenceTimes1000 | onlyOwner, probably DAO | Same as other Config files, comments and code match up, from 1000 - 7000 |
PriceAggregator | changePriceFeedModificationCooldown | onlyOwner, probably DAO | comments and code match up, from 30-45 days |
PriceAggregator | _aggregatePrices | internal | gets the average of the two closest price |
priceFeedModificationCooldown
since it's only changed if something serious is going to happen / has happened. It should be able to be changed immediately.Contract | Function | Access | Comments |
---|---|---|---|
CoreSaltyFeed | getPriceBTC | view function, called by PriceAggregator, no change of state | Check that reservesWBTC is in 8 decimals when pools.getPoolReserves is called |
CoreSaltyFeed | getPriceETH | view function, called by PriceAggregator, no change of state | Check that pools.getPoolReserves returns the proper reservesWETH and reservesUSDS amount |
Contract | Function | Access | Comments |
---|---|---|---|
RewardsConfig | changeRewardsEmitterDailyPercent | OnlyOwner, probably DAO | Comments and Code match up, from 250 - 2500 (0.25% to 2.5%) |
RewardsConfig | changeEmissionsWeeklyPercent | OnlyOwner, probably DAO | Comments and Code match up, from 250 - 1000 (0.25% to 1%) |
RewardsConfig | changeStakingRewardsPercent | OnlyOwner, probably DAO | Comments and Code match up, from 25 - 75 (25% to 75%) |
RewardsConfig | changePercentRewardsSaltUSDS | OnlyOwner, probably DAO | Comments and Code match up, from 5 - 25 (5% to 25%) |
Contract | Function | Access | Comments |
---|---|---|---|
Emissions | performUpkeep | Only Upkeep contract can call | Calls rewardsConfig.emissionsWeeklyPercentTimes1000() |
uint256 saltToSend = ( saltBalance * timeSinceLastUpkeep * rewardsConfig.emissionsWeeklyPercentTimes1000() ) / ( 100 * 1000 weeks ); Assume 100e18 as saltBalance, 0.5% should be 5e17. (1 second) 100e18 * 1 * 500 / (100 * 1000 * 604800) = 826719576720 (1 week) 100e18 * 604800 * 500 / (100 * 1000 * 604800) = 5e17 (0.5%)
Contract | Function | Access | Comments |
---|---|---|---|
USDS | setCollateralAndLiquidity | onlyOwner, inherited Ownable | renounceOwnership() can be dangerous is the collateralAndLiquidity contract address is changed |
USDS | mintTo | only collateralAndLiquidityCanCall | check how collateralAndLiquidity calls mintTo , check whether amount can be manipulated |
USDS | burnTokensInContract | public | will only be an error if DAO mistakenly deposits USDS into contract |
Contract | Function | Access | Comments |
---|---|---|---|
StakingConfig | changeMinUnstakeWeeks | onlyOwner, is DAO | Comments and code match up, range only from 1-12 |
StakingConfig | changeMaxUnstakeWeeks | onlyOwner, is DAO | Comments and code match up, range only from 20-108 |
StakingConfig | changeMinUnstakePercent | onlyOwner, is DAO | Comments and code match up, range only from 10-50 |
StakingConfig | changeModificationCooldown | onlyOwner, is DAO | Comments and code match up, range only from 15-600 |
Contract | Function | Access | Comments |
---|---|---|---|
ManagedWallet | proposeWallets | called by mainWallet | 2-step transfer to change the mainwallet and confirmation wallet |
ManagedWallet | receive() | called by confirmationWallet | Ether is stuck in contract, and the confirmationWallet can alter the activeTimelock duration anytime |
ManagedWallet | changeWallets | called by proposedMainWallet | Must wait for activeTimelock to set and reset the variables |
Contract | Function | Access | Comments |
---|---|---|---|
AccessManager | excludedCountriesUpdated | called by DAO | can be inconvenient because all verified wallets have to be verified again, does it affect anything? |
AccessManager | grantAccess | public, users must have the signature | geoVersion can be changed |
AccessManager | _verifyAccess | internal, called by grantAccess | Check whether the _verifySignature function is written correctly |
Contract | Function | Access | Comments |
---|---|---|---|
Salt | constructor | one time | mints 100M salt to msg.sender, and doesn't have any mint, consider hard cap? |
Salt | burnTokensInContract | public | same as USDS, error will be on the user side |
Design Patterns and Best Practices:
Code Readability and Maintainability:
Error Handling and Input Validation:
Interoperability and Standards Compliance:
Testing and Documentation:
Upgradeability:
Dependency Management:
Protocol relies on external libraries like OpenZeppelin. Protocol should keep an eye on vulnerabilities that affects those external integrations, and make changes where necessary.
Overall, great architecture from the protocol, slight changes would be to the written code itself (using modifiers for repeated code, checking zero values, checking overflows etc) and more real life scenarios in the documentation.
Centralization Risks
Not much centralization risks as the protocol is almost run fully on the DAO and votes. The only thing the DAO can do is control which proposals to be passed by finalizing the Ballot. Otherwise, most of the protocol functions like clockwork.
40 hours
#0 - c4-judge
2024-02-03T14:51:08Z
Picodes marked the issue as grade-a
#1 - c4-judge
2024-02-07T18:16:08Z
Picodes marked the issue as selected for report