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: 11/178
Findings: 3
Award: $1,266.45
π Selected for report: 1
π Solo Findings: 0
16.3165 USDC - $16.32
The Liquidizer contract will burn more USDS and SALT than it is supposed to.
In the repayUSDS function, the USDS is sent to the USDS contract and then the usdsThatShouldBeBurned
variable is increased in the Liquidizer contract.
// Have the user send the USDS to the USDS contract so that it can later be burned (on USDS.performUpkeep) usds.safeTransferFrom(msg.sender, address(usds), amountRepaid); // Have USDS remember that the USDS should be burned liquidizer.incrementBurnableUSDS( amountRepaid );
Later when liquidizer.performUpkeep() is called, it will try to burn usdsThatShouldBeBurned
that the Liquidizer contract doesn't actually have. Because of this, the contract will withdraw SALT, DAI and USDS from the liquidity owned by the DAO.
Manual review
Repaid USDS should be sent to the Liquidizer contract, not to the USDS contract.
Token-Transfer
#0 - c4-judge
2024-02-01T15:48:14Z
Picodes marked the issue as duplicate of #618
#1 - c4-judge
2024-02-17T18:38:10Z
Picodes marked the issue as satisfactory
1196.6399 USDC - $1,196.64
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:
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.
The notation used is equal to the one found in the Salty.IO smart contracts, in particular in ArbitrageSearch.sol. For convenience, is replaced with just . For example, should be read as .
Some of the math steps were omitted to simplify this submission, but I invite you to verify the derivation of the formulas.
The arbitrage function is given by , where:
bestArbAmountIn
) is the amount of WETH to be "sold" for arbToken2 in the first step of the arbitrage path weth -> arbToken2 -> arbToken3 -> weth.Note that:
So, when , we know there is an arbitrage opportunity which maximizes at:
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.
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 be a root of the arbitrage function such that and . The solution is given by . 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 ) for arbToken3. We are interested in finding a pool structure such that 1/128th of is greater than . This would mean that the protocol's bisection search will test a range of which is not profitable, when there are actually values that are profitable.
The formula we get from the mentioned assumptions is:
which means that:
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.
Manual Review
Consider replacing _bisectionSearch() with something similar to computeBestArbitrage(). Beware that computeBestArbitrage() is not overflow-proof.
Other
#0 - c4-judge
2024-02-03T15:26:45Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2024-02-09T02:17:25Z
othernet-global (sponsor) confirmed
#2 - othernet-global
2024-02-09T02:18:24Z
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;
#3 - othernet-global
2024-02-11T07:56:35Z
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;
#4 - c4-judge
2024-02-19T17:26:52Z
Picodes marked the issue as satisfactory
#5 - Picodes
2024-02-19T17:28:18Z
Considering the value-added for the sponsor and the fact that this report:
I think Med severity is appropriate.
#6 - c4-judge
2024-02-19T17:28:24Z
Picodes marked the issue as selected for report
53.4926 USDC - $53.49
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L132 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L384-L400 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L98
bootstrappingRewards
for all of them. Even if the least voted proposal reached quorum, it won't be possible to execute it until the DAO holds enough rewards again.ballotMinimumEndTime
is left in a vulnerable position. The side that first casts its votes will get to end the proposal without the other side getting the chance to react. A malicious or naΓ―ve actor could prematurely create a proposal and generate this scenario.Only the DAO can finalize proposals, as required in Proposal.sol L132
require( msg.sender == address(exchangeConfig.dao()), "Only the DAO can mark a ballot as finalized" );
canFinalizeBallot() requires that a proposal cannot finalize while the quorum has not been reached, no matter how much time has passed.
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 DAO cannot finalize whitelisting proposals that reached quorum while there are not enough bootstrappingRewards available. See DAO.sol L247:
require( msg.sender == address(exchangeConfig.dao()), "Only the DAO can mark a ballot as finalized" );
This means that xSALT holders could get stuck with a proposal they created and voters could take advantage of the voting process.
Manual Review
Consider ending proposals that didn't reach quorum and extending the voting time if a proposal was significantly voted with no or little time to go.
Also consider expiring proposals that reached quorum but were not executed.
Governance
#0 - c4-judge
2024-02-01T17:00:31Z
Picodes marked the issue as duplicate of #362
#1 - c4-judge
2024-02-20T10:46:48Z
Picodes marked the issue as satisfactory