Platform: Code4rena
Start Date: 16/01/2024
Pot Size: $80,000 USDC
Total HM: 37
Participants: 178
Period: 14 days
Judge: Picodes
Total Solo HM: 4
Id: 320
League: ETH
Rank: 8/178
Findings: 5
Award: $1,723.82
🌟 Selected for report: 1
🚀 Solo Findings: 0
8.7582 USDC - $8.76
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/ManagedWallet.sol#L42 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/ManagedWallet.sol#L59
The managedWallet contract implements the following wallet update process:
The problem is in the scenario where the current ConfirmationWallet rejects the proposal. In this case the only action taken is that activeTimelock is set to type(uint256).max. The MainWallet and ConfirmationWallets are not reset, which means new proposals can not be submitted (because of the following check:
require( proposedMainWallet == address(0), "Cannot overwrite non-zero proposed mainWallet." );
)
in proposeWallets.
This leaves the system is a state where the current wallets can not be replaced unless the original proposal is accepted. The only way out of this deadlock is if both the current ConfirmationWallet owner and the new (main and confirmation) wallets owners all agree to facilitate a transition to an alternative wallet pair by having the current ConfirmationWallet accept the proposed pair, and the proposed pair agreeing to propose and accept an alternative pair. This of course entails trusting the proposed pair which is unacceptable (given that the current confirmationWallet rejected them).
managedTeamWallet is an immutable in the ExchangeConfig which means there is no other way to change it. Inability to replace it may put funds at risk in situations such as: There's a risk that private key for the managedTeamWallet were exposed, or the managedTeamWallet address is a multisig wallet whose security may be compromised (for example following an upgrade) etc.
The following POC shows how after a proposal is rejected all new proposals revert because of the issue described is this finding.
How to Run:
function testNoNewProposalAfterRejection() public { ManagedWallet managedWallet = new ManagedWallet(alice, address(this)); // Propose new wallets address newMainWallet = address(0x3333); address newConfirmationWallet = address(0x4444); vm.prank(alice); managedWallet.proposeWallets(newMainWallet, newConfirmationWallet); //fast forward a bit for realistic simulation vm.warp(block.timestamp + TIMELOCK_DURATION / 30); // Confirmation wallet sends less than 0.05 ether to reject uint256 amountToSend = 0.000001 ether; vm.prank(address(this)); vm.deal(address(this), amountToSend); (bool success,) = address(managedWallet).call{value: amountToSend}(""); assertTrue(success, "Rejection of proposal failed"); //fast forward some more (doesn't matter how much) vm.warp(block.timestamp + TIMELOCK_DURATION ); //Try to propose a different pair of main/confirmation wallets. newMainWallet = address(0x6666); newConfirmationWallet = address(0x7777); vm.prank(alice); vm.expectRevert("Cannot overwrite non-zero proposed mainWallet."); managedWallet.proposeWallets(newMainWallet, newConfirmationWallet); //fast forward for a long time vm.warp(block.timestamp + TIMELOCK_DURATION*1000 ); //still cant propose new wallets. We're stuck until current confirmation wallet accepts vm.prank(alice); vm.expectRevert("Cannot overwrite non-zero proposed mainWallet."); managedWallet.proposeWallets(newMainWallet, newConfirmationWallet); }
Foundry
When a proposal is rejected, reset the proposal mechanism the same way it is done in the changeWallets function:
// Reset activeTimelock = type(uint256).max; proposedMainWallet = address(0); proposedConfirmationWallet = address(0);
Other
#0 - c4-judge
2024-02-02T10:41:58Z
Picodes marked the issue as duplicate of #838
#1 - c4-judge
2024-02-17T18:21:38Z
Picodes marked the issue as satisfactory
1196.6399 USDC - $1,196.64
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/PriceAggregator.sol#L142 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/PriceAggregator.sol#L183 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/PriceAggregator.sol#L195
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:
Foundry
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.
Other
#0 - c4-judge
2024-02-03T09:34:53Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2024-02-12T21:08:03Z
othernet-global (sponsor) acknowledged
#2 - c4-judge
2024-02-20T15:38:16Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2024-02-20T15:38:19Z
Picodes marked the issue as selected for report
#4 - othernet-global
2024-02-23T03:26:20Z
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
🌟 Selected for report: juancito
Also found by: 0x3b, 0xBinChook, 0xCiphky, 0xHelium, 0xMango, 0xOmer, 0xRobocop, 0xSmartContractSamurai, 0xWaitress, 0xbepresent, 0xpiken, 7ashraf, Arz, Audinarey, Banditx0x, Bauchibred, Draiakoo, IceBear, J4X, Jorgect, Kaysoft, KingNFT, Rhaydden, Rolezn, The-Seraphs, Tigerfrake, Topmark, Tripathi, Udsen, ZanyBonzy, a3yip6, b0g0, chaduke, codeslide, csanuragjain, dharma09, djxploit, eta, ether_sky, grearlake, inzinko, jasonxiale, jesjupyter, josephdara, lanrebayode77, linmiaomiao, lsaudit, niroh, oakcobalt, peanuts, pina, sivanesh_808, slvDev, t0x1c, vnavascues, wangxx2026
72.1303 USDC - $72.13
Remove the max approvals from the Dao constructor and add an exact amount approval in
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L249C11-L249C31 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L102
string memory ballotName = string.concat("setContract:", contractName );
where contractName is either priceFeed1,2,3 or accessManager.
string memory ballotName = string.concat("setURL:", newWebsiteURL );
Meaning only WebsiteUpdate ballots who propose the same URL are blocked from being opened in parallel. Additional WebsiteUpdate proposals can be opened with different URLs while previous proposals are still open.
Change the ballot name of proposeWebsiteUpdate to a fixed string such as "setURL" so that only one updateWebsite proposal can be live simultaneously.
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/Upkeep.sol#L129 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/Upkeep.sol#L166
instead of trading equal amounts in _formPOL, get the current reserve ratio from the pool of the relevant tokens and trade the pro-rata amount of WETH for each.
The current Emissions mechanism is activated from the Upkeep function and calculates the current emission as a percentage of the remaining funds to emit. The percentage is calculated as the pro-rata of a constant (default: 0.5% per week) based on the time since last call. When emissions are calculated this way, the more frequent the Upkeep calls, the slower the emission rate. For example: If Upkeep is called once a week over a month, the total emissions in that month are: If upkeep is called every hour in the same time period, total emissions are: The effect is the reverse of compounded interest where the more frequent the emissions the less funds are emitted in a given period. (The effect is accentuated by the double aplication of emissions rate using this calculation method: once in Emissions.sol and once in RewardsEmitter.sol)
The rate of Upkeep calls depends on market conditions. For example: in high volatility periods the arbitrage profit rate is likely to be higher than normal. This will create a more frequent incentive to call upkeep (the reward for which is 5% of arbitrage profits since the last call).
This creates an undesired uncertainty for both LPs and stakers regarding the rate at which they'll receive emissions, contrary to the common vesting schedules on most DeFi protocols. This in turn creates a disincentive to become a Salty LP/staker.
A similar emissions pattern can be achieved without dependency on Upkeep call rate, by using a function of the form (this is the integral between x1,x2 of $f(x) = a \cdot e^{-bx}$ - a function whose form is similar to the one used in the current mechanism)
Either apply the cooldown separately per feed, or remove the cooldown altogether (keeping in mind that price feed changes might be required with urgency for example if a price feed is permanently down or compromised.)
Add a bootstrap function to PriceAggregator, callable by BootstrapBallot only, that will initiate the priceFeedModificationCooldownExpiration variable. BootstrapBallot can call this function during finalizeBallot.
The comment reads: "Deposit the arbitrage profit for the DAO - later to be divided between the DAO, SALT stakers and liquidity providers in DAO.performUpkeep"
however there is no DAO.performUpkeep function and the distribution happens in other contracts
Change comment to "Deposit the arbitrage profit for the DAO - later to be divided between the DAO, SALT stakers and liquidity providers at part of the Upkeep process"
#0 - c4-judge
2024-02-03T13:24:01Z
Picodes marked the issue as grade-a
#1 - c4-sponsor
2024-02-12T21:02:04Z
othernet-global (sponsor) disputed
#2 - othernet-global
2024-02-12T21:02:17Z
All of these reported issues are acceptable.
🌟 Selected for report: 0xVolcano
Also found by: 0x11singh99, 0xAnah, Beepidibop, JCK, JcFichtner, K42, Kaysoft, Pechenite, Raihan, Rolezn, dharma09, hunter_w3b, lsaudit, n0kto, naman1778, niroh, sivanesh_808, slvDev, unique
20.7932 USDC - $20.79
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/arbitrage/ArbitrageSearch.sol#L63 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/arbitrage/ArbitrageSearch.sol#L120
Since the Parameters contract is only used as a parent contract for Dao it would save gas to define the state variables mentioned above in Parameters. This would save the added gas cost of pushing these 6 addresses to the stack (roughly 60x6 = 360 gas units) with each call to _executeParameterChange.
#0 - c4-judge
2024-02-03T14:22:27Z
Picodes marked the issue as grade-b
🌟 Selected for report: peanuts
Also found by: 0xAsen, 0xHelium, 0xSmartContract, 0xepley, DedOhWale, K42, LinKenji, Sathish9098, ZanyBonzy, catellatech, fouzantanveer, foxb868, hassanshakeel13, hunter_w3b, jauvany, kaveyjoe, kinda_very_good, klau5, niroh, rspadi, yongskiws
425.4976 USDC - $425.50
Salty is a decentralized exchange with a unique value proposition of zero fees trading. Zero fees are made possible by an Automatic Atomic Arbitrage (AAA) mechanism whose generated profits are used to incentivise liquidity providers. In addition to the exchange, Salty also offers a dollar pegged over collateralized stable token (USDS) that uses the liquidity of the Salty WBTC/WETH pool as collateral. Salty also includes an exchange token (Salt) that is the main form of rewards on the platform, and whose staked token (XSalt) grants voting rights on the Salty DAO.
Salty is unique in the DeFi exchange landscape in its no-compromises full decentralization policy that applies from day one of the platforms launch. All settings and parameters underlying the protocol can only be changed through a DAO vote. There are no trusted admins or team safety switches. The only exception is an offchain external verifier in charge of authorizing exchange access based on a geolocation whitelist, however this function is also under the control of the DAO in that the DAO can vote to replace the AccessControl contract and with it the entire ACL mechanism.
Implements an internal arbitrage search for Salty. For each pool trade, defines a fixed potential path (always with three legs that start and end with Weth) and runs an 8 step iterative binary search for the optimal input amount (if any) to maximize arbitrage profit on the path.
Represents the Dao controlling Salty. Holds DAO funds and in charge with finalizing voted actions.
Holds values of core system parameters such as voting quorum, staking rewards etc and enables the DAO to change these parameters.
Manages Dao proposals/Ballots: enables opening ballots, voting on ballots and reporting if a ballot can be finalized or not
Defines the different system parameters that can be tweaked by the dao and channels change requests to the right config contracts
Implements the airdrop mechanism activated when the bootstrap ballot is accepted. Allows BootstrapBallot to authorize wallets that voted and are eligible for the airdrop, enables claiming when bootstrap ballot is accepted. Claiming users receive an equal share of XSalt of the airdrop allocation (5M Salt)
Manages the initial vote to launch the system/DAO
Defines and performs the initial allocation of Salt then the system is launched.
Helper functions for the pools contract
Parameters (configurable by the DAO) related to Salty pools
Registry of arbitrage profits per pool for arbitrage rewards distribution
Implements Salty exchange with pools that implement swaps with atomic internal arbitrage
Helper functions for pools related math
Feed implementation encaplusationg Chainlink price feeds (BTC/USD, ETH/USD) used by PriceAggregator
Feed implementation encaplusationg UniswapV3 TWAP prices of last 30 minutes (BTC/USD, ETH/USD) used by PriceAggregator
PriceFeed implementation aggregating results from three sources (initially UniswapV3 TWAP, Chainlink and Salty but can be replaced by DAO). Used for collateral evaluation in USDS borrowing and liquidations
Feed implementation encapsulating Salty spot prices(BTC/USD, ETH/USD) used by PriceAggregator
Parameters (configurable by the DAO) related to reward distributions
A contract that receives an initial amount of Salt at launch and releases the funds gradually over time (exponential decay pattern) to further emitters and eventually to staking and LP rewards contracts.
Emmits Salt rewards acording to a configurable schedule (exponential decay pattern) to a StakingRewards target (either CollateralAndLiquidity or Staking)
Receives emissions from Emissions.sol and distributed between the staking RewardsEmitter and the liquidity RewardsEmitter according to a configurable ratio parameter.
A stable token that can be borrowed against Weth/WBTC pool liquidity. Initial collateralization ratio of 200% and liquidation (min) ratio of 110%
Parameters (configurable by the DAO) related to the stable token USDS
Derives from Liquidity, manages collateral balances for USDS borrowing and liquidity providing balances for Salty pools.
Manages liquidations of USDS CDPs
Parameters (configurable by the DAO) related to the Salt staking mechanism in Salty
Derives from StakingRewards (base of CollateralAndLiquidity). Implements the part related to pools liquidity providing.
Base contract used for both pools LP rewards and staking rewards. Registers user rewards shares and enables rewards claiming.
Derives from StakingRewards, implements Salt staking and rewards distribution.
A contract that represents a wallet that can be changed subject to the approval of a confirmation wallet. The main wallet can propose a change (of both itself and the confirmation wallet). The confirmation wallet can either accept the change (in which case the proposed new main wallet can finalize the change after a wait period of 30 days form acceptance) or reject the change.
Grants wallet access to add liquidity/collateral and borrow USDS. Based on an offchain verifier that checks for whitelisted geo location. Salt.sol 16 @openzeppelin/* SigningTools.sol 20 - Upkeep.sol 169 @openzeppelin/*
Serves as a registry of core protocol addresses such as tokens (salt, dai, wbtc, weth etc.) accessManager, DAO, upkeep, airdrop and teamWallet. DAO contrtolled. Of these, only the accessManager can be updated after the first initialization.
In analysing the codebase we took the following steps:
1. Architecture Review
Reading the provided documentation to understand the architecture and main functionality of Salty.
2. Compiling code and running provided tests
3. Detailed Code Review
A thorough code review of the contracts in scope.
4. Security Analysis
Defining the main attack surfaces of the codebase i.e. DAO voting manipulation, pool liquidity funds exfiltration, Rewards Distribution Attacks, external manipulation (front/back running trader and LPs), price manipulation of the Salty price feed etc.
5. Additional Testing
Augmenting the existing test scheme with new tests derived from the potential attack surfaces outlined in the security analysis conducted in the previous step.
Below is a description of the main mechanisms at the core of Salty's protocol:
Salty's launch is orchestrated using a set of contracts (Airdrop, BootsrapBallot and InitialDistribution) with full decentralization in mind. The process starts with a vote on weather or not to launch the protocol, the duration of which is set by the platform deployer. Voters are authorized by an offchain entity that provides them with a signature to be verified before voting (authorization is based on geolocation and having twitted about the airdrop). An authorized voter gets approved for an airdrop of Salt that takes place at launch. If the vote is successful, the system is kickstarted with an initial distribution of Salt that cascade to different actors through various scheduling and reward distribution mechanisms. The diagram below shows the flow of rewards in Salty at launch and during its ongoing operation.
As mentioned above, Salty is able to offer zero-fee trade by capruting arbitrage opportunities atomically and using the gains to reward liquidity providers. Automatic Atomic Arbitrage (AAA) is implemented based on the following components: A. New tokens on Salty can only be added to two predefined pools: NewToken/Weth and NewToken/WBTC. This, coupled with the core pools that are pre-added to Salty at the time of deployment enables the system to map a single arbitrage path for any trade, that start and ends with Weth. B. As part of each trade transaction, the exchange attempts to find a profitable arbitrage on the mapped path by running an 8 step binary search for an optimal input amount that generates maximum profit. If such a trade exists it is carried out, and the profit is accredited to the pools involved in the path for the purpose of AAA reward distribution.
As mentioned, Salty imposes strict DAO control on all system configurable components. The DAO voting system is based on XSalt (staked Salt) voting power and enable several predefined types of proposals, namely parameter changes, token whitelisting and approval proposals (such as replacing price feed sources, adding/removing approved countries etc). The voting mechanism includes several mechanisms to prevent hasty/malicious changes:
Access control to add liquidity and to stake Salt is restricted through an external offchain verifier that verifies an address meets the geo restrictions set in the DAO. Once the verification signature is validated the address is marked as whitelisted for future access. If the DAO decides to remove a previously approved country from the list, a new version of verification is formed and users need to re-verify in order to interact with the access-restricted actions.
Salty's stable token (USDS) can be minted based on provided collateral in the form of WBTC/WETH pool liquidity. The collateral value is determined through the system's price feed (see next item) and affect the borrowing limit (requires 200% collateralization) and liquidations (enabled below 110% collateralization). When a user CDP can be liquidated anyone can call a liquidate function and receive a reward of 5% of the liquidated collateral value. The remaining collateral is sent to the Liquidizer contract to be converted to USDS, and the forgone amount of USDS is marked for burning. In each Upkeep cycle the liquidizer tries to burn all USDS marked for burning, and complements missing UDSD (due to undercollateralized liquidations) from the protocol owned liquidity.
To evaluate collateral for USDS borrowing and liquidations, Salty uses a price aggregator for BTC/USD, ETH/UDS that relies on three external sources (UniswapV3 TWAP, Chainlink and Salty's own pools). The price aggregator averages the two closest prices of the three and reverts if all three reported prices diverge from each other by more than a predefined max difference (3% by default). All three price sources can be replaced by the DAO.
An upkeep transaction that can be called by anyone in the driving engine behind many of Salty's inner mechanics. It is in charge initiating liquidates USDS burns, distributing Weth AAA profits between POL, LPs, stakers and the function caller, distributing POL rewards, releasing scheduled rewards to LPs/stakers and more. Upkeep calls are incentivised by 5% of accumulated AAA Weth profits since the last call.
Salty's system architecture facilitates many of Salty's features and unique advantages. However, as with any DeFi protocol, it also entails several risks:
As mentioned above, Salty's voting system takes a cautious approach with mechanisms such as long minimal ballot periods, incremental and limited parameter changes, confirmation ballots in some cases etc. This approah however also poses a risk in cases where a more swift response is required. For example:
While rogue tokens (fee-on-transfer/reentrancy attacks and other variants) are a problem that affects most dexes, Salty's exposure to this risk is slightly elevated for two reasons:
While tokens need to pass a DAO vote to be whitelisted, some rogue tokens are hard to identify even for experts and the task of filtering them out might prove to be beyond the capabilities of a DAO vote.
Salty's intricate incentive system is at the core of the protocol, especially given that no initial liquidity is provided through investors or founders. In this context it is important to note that all rewards in Salty are awarded in the exchange token SALT (while many dexes incentivize LPs with fees paid in the provided liquidity tokens). This creates a strong dependency on the value of SALT, raising the risk of death-by-lack-of-incentive in the case of an extreme devaluation of Salt.
Many of Salty's inner mechanics are intertwined in a way that can cause a cascading effect from a disruption in one service to the others. Some examples:
In the event that a critical vulnerability is discovered after launch, the protocol currently does not include any mechanism to upgrade contracts (other than the limited option to replace the price-feeds and AccessControl with a DAO vote). This creates the risk of future exploits necessitating a full system relaunch as a remedy.
Due to its fully decentralized approach, Salty does not present many centralization risks. The only potential centralization risk is around the external access control authority
Salty's system is deployed with a trusted external validator that is in charge of verifying users geolocation and provides signatures that enable users to add liquidity/stake of Salty. While the AccessControl contract can be replaced by the DAO, the process for doing takes a minimum of 20 days which gives ample time for a malicious verifier to perform various exploits such as DOSing the system by blocking everyone.
Below is a list of recommendations, some of which are general architecture/structure recommendations and some are meant to address one or more of the risks mentioned above.
Given the relative slow reaction time of the dao to different situations, salty could add a fast-track for changes, limited to specific situations that call for immediate reaction. This can be achieved with a trusted multisig address that is allowed to open fast-response ballots (i.e. shorter duration, smaller quorum) or even perform certain actions directly (such as unwhitelist tokens or change upkeep reward ratio). Alternatively (and keeping in line with Salty's decentralization), certain emergency actions can be defined that can be voted on with a much shorter minimum duration coupled with a larger quorum to enable the DAO to act on emergencies
To address the current lack of upgradability mechanics, it is recommended to include such a mechanism, controlled by a new type of DAO proposal (possibly requiring a very high quorum as a security measure) to enable remediation of potential exploits or bugs that might be discovered in the future.
Currently Salty relies on hard-coded checks to limit access to protected functionality. This might prove to be too limited a mechanism, when in the future access control requirements might become more elaborate (for example if implementing the above recommendations). A standard ACL mechanism such as OpenZeppelin can be more flexible (i.e. enabling the DAO to dynamically grant certain privileges to specific actors) and more robust, and help prevent future ACL vulnerabilities.
50 hours
#0 - c4-judge
2024-02-03T14:51:55Z
Picodes marked the issue as grade-a
#1 - c4-judge
2024-02-07T18:16:26Z
Picodes marked the issue as selected for report
#2 - c4-judge
2024-02-21T17:27:50Z
Picodes marked the issue as not selected for report