Platform: Code4rena
Start Date: 05/08/2021
Pot Size: $50,000 USDC
Total HM: 9
Participants: 16
Period: 7 days
Judge: 0xean
Total Solo HM: 4
Id: 22
League: ETH
Rank: 3/16
Findings: 3
Award: $7,177.16
🌟 Selected for report: 13
🚀 Solo Findings: 1
🌟 Selected for report: hickuphh3
2340.7472 USDC - $2,340.75
hickuphh3
In _calculateFloatPerSecond()
, the edge cases where full rewards go to either the long or short token returns
return (1e18 * k * longPrice, 0);
and
return (0, 1e18 * k * shortPrice);
respectively.
This is however 1e18
times too large. We can verify this by checking the equivalent calculation in the 'normal case', where we assume all the rewards go to the short token, ie. longRewardUnscaled = 0
and shortRewardUnscaled = 1e18
. Plugging this into the calculation below,
return ((longRewardUnscaled * k * longPrice) / 1e18, (shortRewardUnscaled * k * shortPrice) / 1e18);
results in
(0, 1e18 * k * shortPrice / 1e18)
or (0, k * shortPrice)
.
As we can see, this would result in an extremely large float token issuance rate, which would be disastrous.
The edge cases should return (k * longPrice, 0)
and (0, k * shortPrice)
in the cases where rewards should go fully to long and short token holders respectively.
#0 - JasoonS
2021-08-10T12:43:27Z
Fix:
-return (1e18 * k * longPrice, 0); +return (k * longPrice, 0);
and
-return (0, 1e18 * k * shortPrice); +return (0, k * shortPrice);
#1 - DenhamPreen
2021-08-12T09:17:34Z
351.1121 USDC - $351.11
hickuphh3
// With an exponent of 5, the largest total liquidity possible in a market (to avoid integer overflow on exponentiation) is ~10^31 or 10 Trillion (10^13) // NOTE: this also means if the total market value is less than 2^52 there will be a division by zero error uint256 public constant safeExponentBitShifting = 52;
The comment in _changeBalanceIncentiveExponent()
says that the exponent has to be less than 5 // The exponent has to be less than 5 in these versions of the contracts.
, but it allows the value 5 to be set.
_balanceIncentiveCurve_exponent > 0 && _balanceIncentiveCurve_exponent < 6,
Perhaps saying that it has to be strictly less than 6, or at most 5, would provide better clarity.
// With an exponent of 52... uint256 public constant SAFE_EXPONENT_BIT_SHIFTING = 52
require( // The exponent has to be at most 5 in these versions of the contracts. _balanceIncentiveCurve_exponent > 0 && _balanceIncentiveCurve_exponent < 6, "balanceIncentiveCurve_exponent out of bounds" );
#0 - JasoonS
2021-08-12T08:13:54Z
Bit shifting is different to the exponent.
No vulnerability.
Typo in comment, thanks for pointing out.
#1 - 0xean
2021-08-25T01:30:18Z
duplicate of #89
351.1121 USDC - $351.11
hickuphh3
For critical user actions like minting, redeeming, shifting and staking synthetic assets, consider adding re-entrancy protections.
#0 - DenhamPreen
2021-08-12T07:45:47Z
Could the issue please be expanded on in possible vulnerabilities?
#1 - JasoonS
2021-08-12T07:56:31Z
Re-entrancy not possible since we don't use erc20 tokens with hooks as payment tokens.
Additionally our accounting is optimistic and therefore not re-entrancy prone.
#2 - 0xean
2021-08-25T15:53:02Z
duplicate of #13
🌟 Selected for report: hickuphh3
780.2491 USDC - $780.25
hickuphh3
Updating a kValue
of a market requires interpolation against the initial timestamp, which can be a hassle and might lead to a wrong value set from what is expected.
Consider the following scenario:
kValue = 2e18
, kPeriod = 2592000
(30 days)kValue = 2e18
), lasting another 30 days.In the current implementation, the admin would call _changeMarketLaunchIncentiveParameters()
with the following inputs:
period = 3888000
(45 days)
kValue
needs to be worked backwards from the formula
kInitialMultiplier - (((kInitialMultiplier - 1e18) * (block.timestamp - initialTimestamp)) / kPeriod)
. To achieve the desired effect, we would get kValue = 25e17
(formula returns 2e18 after 15 days with kPeriod = 45 days).
This isn't immediately intuitive and could lead to mistakes.
Instead of calculating from initialTimestamp
(when addNewStakingFund()
was called), calculate from when the market incentives were last updated. This would require a new mapping to store last updated timestamps of market incentives.
For example, using the scenario above, refreshing the market incentive would mean using inputs period = 2592000
(30 days) with kValue = 2e18
.
// marketIndex => timestamp of updated market launch incentive params mapping(uint32 => uint256) public marketLaunchIncentive_update_timestamps; function _changeMarketLaunchIncentiveParameters( uint32 marketIndex, uint256 period, uint256 initialMultiplier ) internal virtual { require(initialMultiplier >= 1e18, "marketLaunchIncentiveMultiplier must be >= 1e18"); marketLaunchIncentive_period[marketIndex] = period; marketLaunchIncentive_multipliers[marketIndex] = initialMultiplier; marketLaunchIncentive_update_timestamps[marketIndex] = block.timestamp; }; function _getKValue(uint32 marketIndex) internal view virtual returns (uint256) { // Parameters controlling the float issuance multiplier. (uint256 kPeriod, uint256 kInitialMultiplier) = _getMarketLaunchIncentiveParameters(marketIndex); // Sanity check - under normal circumstances, the multipliers should // *never* be set to a value < 1e18, as there are guards against this. assert(kInitialMultiplier >= 1e18); // currently: uint256 initialTimestamp = accumulativeFloatPerSyntheticTokenSnapshots[marketIndex][0].timestamp; // changed to take from last updated timestamp instead of initial timestamp uint256 initialTimestamp = marketLaunchIncentive_update_timestamps[marketIndex]; if (block.timestamp - initialTimestamp <= kPeriod) { return kInitialMultiplier - (((kInitialMultiplier - 1e18) * (block.timestamp - initialTimestamp)) / kPeriod); } else { return 1e18; } }
#0 - JasoonS
2021-08-12T08:23:38Z
You are right. we should probably just delete the external changeMarketLaunchIncentiveParameters
function so that it can only be set once.
🌟 Selected for report: hickuphh3
780.2491 USDC - $780.25
hickuphh3
TokenFactory.sol
defines DEFAULT_ADMIN_ROLE = keccak256("DEFAULT_ADMIN_ROLE");
, but OpenZeppelin's AccessControl.sol
defines DEFAULT_ADMIN_ROLE = 0x00
, so that by default, all other roles defined will have their admin role to be DEFAULT_ADMIN_ROLE
.
This makes the following lines erroneous:
// Give minter roles SyntheticToken(syntheticToken).grantRole(DEFAULT_ADMIN_ROLE, longShort); SyntheticToken(syntheticToken).grantRole(MINTER_ROLE, longShort); SyntheticToken(syntheticToken).grantRole(PAUSER_ROLE, longShort); // Revoke roles SyntheticToken(syntheticToken).revokeRole(DEFAULT_ADMIN_ROLE, address(this)); SyntheticToken(syntheticToken).revokeRole(MINTER_ROLE, address(this)); SyntheticToken(syntheticToken).revokeRole(PAUSER_ROLE, address(this));
Due to how grantRole()
and revokeRole()
works, the lines above will not revert. However, note that TokenFactory
will have DEFAULT_ADMIN_ROLE (0x00)
instead of LongShort
. This by itself doesn't seem to have any adverse effects, since TokenFactory
doesn't do anything else apart from creating new synthetic tokens.
Nonetheless, I believe that DEFAULT_ADMIN_ROLE
was unintentionally defined as keccak256("DEFAULT_ADMIN_ROLE")
, and should be amended.
The revoking role order will also have to be swapped so that DEFAULT_ADMIN_ROLE is revoked last.
bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; function createSyntheticToken( string calldata syntheticName, string calldata syntheticSymbol, address staker, uint32 marketIndex, bool isLong ) external override onlyLongShort returns (ISyntheticToken syntheticToken) { ... // Revoke roles _syntheticToken.revokeRole(MINTER_ROLE, address(this)); _syntheticToken.revokeRole(PAUSER_ROLE, address(this)); _syntheticToken.revokeRole(DEFAULT_ADMIN_ROLE, address(this)); }
#0 - JasoonS
2021-08-12T08:52:27Z
Thanks they changed that more recently (or bad copy pasting...)
🌟 Selected for report: hickuphh3
780.2491 USDC - $780.25
hickuphh3
In the unlikely event amountReservedInCaseOfInsufficientAaveLiquidity == amount
, the else
case will be executed, which means lendingPool.deposit()
is called with a value of zero. It would therefore be better to change the condition so that the if
case is executed instead.
function depositPaymentToken(uint256 amount) external override longShortOnly { // If amountReservedInCaseOfInsufficientAaveLiquidity isn't zero, then efficiently net the difference between the amount // It basically always be zero besides extreme and unlikely situations with aave. if (amountReservedInCaseOfInsufficientAaveLiquidity != 0) { // instead of strictly greater than if (amountReservedInCaseOfInsufficientAaveLiquidity >= amount) { amountReservedInCaseOfInsufficientAaveLiquidity -= amount; // Return early, nothing to deposit into the lending pool return; } ... }
#0 - JasoonS
2021-08-12T08:56:25Z
Hmm, yes, the deposit function in aave will revert if zero right?
Thanks for reporting.
24.5798 USDC - $24.58
hickuphh3
The following variables can be made immutable to save gas.
TokenFactory.sol
longShort
SyntheticToken.sol
longShort
staker
marketIndex
isLong
YieldManagerAave.sol
longShort
treasury
paymentToken
aToken
lendingPool
aaveIncentivesController
referralCode
#0 - DenhamPreen
2021-08-12T07:25:26Z
Duplicate #7
112.3905 USDC - $112.39
hickuphh3
The README states that "the synthetic token is written to never return false." as it inherits from OpenZeppelin's ERC20PresetMinterPauser.
It is also claimed that "We only check the return boolean (success) for erc20 methods on the payment token not for the synthetic token", but this is not true. LongShort.sol
does in fact check that the transfer()
and transferFrom()
methods returns true (L855 - 857, 900-906, 961-963, 1015-1020).
Since synthetic tokens have been engineered to always return true, consider dropping the require checks to save gas.
#0 - JasoonS
2021-08-12T06:43:12Z
Thanks
🌟 Selected for report: hickuphh3
249.7566 USDC - $249.76
hickuphh3
The number of solc runs used for contract compilation is 200. This number can be bumped significantly to produce more gas efficient code (max value of 2**32-1
).
More information can be found in the solidity docs.
In hardhat.config.js
, increase solc runs significantly. Contract sizes and thus deployment costs will increase, but functions will cost less gas to execute.
#0 - DenhamPreen
2021-08-12T07:29:39Z
Somewhat of a gas trade-off but very applicable 👍
#1 - JasoonS
2021-08-13T18:48:48Z
Code had 200 runs optimization which is high already. Increasing past this point has negligible impact if any at all.
#2 - 0xean
2021-08-25T17:03:49Z
200 runs is not high.
The number of runs (--optimize-runs) specifies roughly how often each opcode of the deployed code will be executed across the life-time of the contract.
I would expect that you hope most of your functions are called way more than 200 times.
🌟 Selected for report: hickuphh3
249.7566 USDC - $249.76
hickuphh3
By storing marketUpdateIndex[marketIndex];
locally in _updateSystemStateInternal()
, multiple sLOADs can be avoided.
Gas savings of about 500-600 is achieved.
function _updateSystemStateInternal(uint32 marketIndex) internal virtual requireMarketExists(marketIndex) { ... // cache marketUpdateIndex[marketIndex] uint256 currentMarketIndex = marketUpdateIndex[marketIndex]; bool assetPriceHasChanged = oldAssetPrice != newAssetPrice; if (assetPriceHasChanged || msg.sender == staker) { uint256 syntheticTokenPrice_inPaymentTokens_long = syntheticToken_priceSnapshot[marketIndex][true][ currentMarketIndex ]; uint256 syntheticTokenPrice_inPaymentTokens_short = syntheticToken_priceSnapshot[marketIndex][false][ currentMarketIndex ]; if ( userNextPrice_currentUpdateIndex[marketIndex][staker] == currentMarketIndex + 1 && assetPriceHasChanged ) { IStaker(staker).pushUpdatedMarketPricesToUpdateFloatIssuanceCalculations( marketIndex, syntheticTokenPrice_inPaymentTokens_long, syntheticTokenPrice_inPaymentTokens_short, marketSideValueInPaymentToken[marketIndex][true], marketSideValueInPaymentToken[marketIndex][false], // This variable could allow users to do any next price actions in the future (not just synthetic side shifts) currentMarketIndex + 1 ); } else { IStaker(staker).pushUpdatedMarketPricesToUpdateFloatIssuanceCalculations( marketIndex, syntheticTokenPrice_inPaymentTokens_long, syntheticTokenPrice_inPaymentTokens_short, marketSideValueInPaymentToken[marketIndex][true], marketSideValueInPaymentToken[marketIndex][false], 0 ); } ... // increment currentMarketIndex currentMarketIndex ++; marketUpdateIndex[marketIndex] = currentMarketIndex; syntheticToken_priceSnapshot[marketIndex][true][ currentMarketIndex ] = syntheticTokenPrice_inPaymentTokens_long; syntheticToken_priceSnapshot[marketIndex][false][ currentMarketIndex ] = syntheticTokenPrice_inPaymentTokens_short; ... emit SystemStateUpdated( marketIndex, currentMarketIndex, ... ); } }
#0 - JasoonS
2021-08-12T07:57:05Z
Thanks!
#1 - JasoonS
2021-08-12T08:04:17Z
Thanks
#2 - DenhamPreen
2021-08-13T12:30:37Z
CompilerError: Stack too deep, try removing local variables. --> contracts/LongShort.sol:733:39: | 733 | marketSideValueInPaymentToken[marketIndex][false] | ^^^^^^^^^^^
😬 Unable to implement this optimization
#3 - JasoonS
2021-08-13T18:40:29Z
Denham was right, stack to deep issues. However, we removed the previousAssetPrice
which was only used twice, and marketIndex
is used 6 times. So rather let that be the local variable.
🌟 Selected for report: hickuphh3
249.7566 USDC - $249.76
hickuphh3
In getYieldSplit()
,
longValue
and shortValue
is already compared and stored as a boolean in isLongSideUnderbalanced
, calculating their difference can be unchecked.treasuryYieldPercent_e18
, since marketPercent_e18
is capped to 1e18
isLongSideUnderbalanced = longValue < shortValue; uint256 imbalance; unchecked { imbalance = isLongSideUnderbalanced ? shortValue - longValue : longValue - shortValue; }
unchecked { treasuryYieldPercent_e18 = 1e18 - marketPercent_e18; }
#0 - JasoonS
2021-08-12T08:04:03Z
Good spot, we are unlikely to change this, the tiny extra gas used isn't worth having unchecked code.
But definitely an option, I agree.
🌟 Selected for report: hickuphh3
249.7566 USDC - $249.76
hickuphh3
The user's userNextPrice_amountStakedSyntheticToken_toShiftAwayFrom_long
and userNextPrice_amountStakedSyntheticToken_toShiftAwayFrom_short
are retrieved a number of times in _calculateAccumulatedFloat()
. Caching these values would help save gas.
Note that block scoping is needed to avoid the stack too deep problem.
function _calculateAccumulatedFloat() { // block scope for shiftAmount variable to avoid stack too deep { // Update the users balances uint256 shiftAmount = userNextPrice_amountStakedSyntheticToken_toShiftAwayFrom_long[marketIndex][user]; if (shiftAmount > 0) { amountStakedShort += ILongShort(longShort).getAmountSyntheticTokenToMintOnTargetSide( marketIndex, shiftAmount, true, stakerTokenShiftIndex_to_longShortMarketPriceSnapshotIndex_mapping[usersShiftIndex] ); amountStakedLong -= shiftAmount; userNextPrice_amountStakedSyntheticToken_toShiftAwayFrom_long[marketIndex][user] = 0; } shiftAmount = userNextPrice_amountStakedSyntheticToken_toShiftAwayFrom_short[marketIndex][user] if (shiftAmount > 0) { amountStakedLong += ILongShort(longShort).getAmountSyntheticTokenToMintOnTargetSide( marketIndex, shiftAmount, false, stakerTokenShiftIndex_to_longShortMarketPriceSnapshotIndex_mapping[usersShiftIndex] ); amountStakedShort -= shiftAmount; userNextPrice_amountStakedSyntheticToken_toShiftAwayFrom_short[marketIndex][user] = 0; } } // end of block scoping // Save the users updated staked amounts ... }
#0 - JasoonS
2021-08-12T09:02:54Z
Cool, makes sense!
🌟 Selected for report: hickuphh3
249.7566 USDC - $249.76
hickuphh3
The lines
accumulativeFloatPerSyntheticTokenSnapshots[marketIndex][0].accumulativeFloatPerSyntheticToken_long = 0; accumulativeFloatPerSyntheticTokenSnapshots[marketIndex][0].accumulativeFloatPerSyntheticToken_short = 0;
are redundant since their default value is zero.
Remove L355 and 356.
#0 - JasoonS
2021-08-12T08:17:35Z
Thanks. Not much of an optimization, this function is only called on market initialization.
🌟 Selected for report: hickuphh3
249.7566 USDC - $249.76
hickuphh3
In createSyntheticToken()
, syntheticToken
is casted numerous to call the grantRole()
and revokeRole()
functions. This can be avoided by declaring a SyntheticToken type in memory, utilising that variable, then casting it to the return argument.
It would also be more appropriate to return ISyntheticToken
as the output to further reduce the need for casting in LongShort.sol
.
function createSyntheticToken( string calldata syntheticName, string calldata syntheticSymbol, address staker, uint32 marketIndex, bool isLong ) external override onlyLongShort returns (ISyntheticToken syntheticToken) { SyntheticToken _syntheticToken = new SyntheticToken(syntheticName, syntheticSymbol, longShort, staker, marketIndex, isLong); // Give minter roles _syntheticToken.grantRole(DEFAULT_ADMIN_ROLE, longShort); _syntheticToken.grantRole(MINTER_ROLE, longShort); _syntheticToken.grantRole(PAUSER_ROLE, longShort); // Revoke roles _syntheticToken.revokeRole(DEFAULT_ADMIN_ROLE, address(this)); _syntheticToken.revokeRole(MINTER_ROLE, address(this)); _syntheticToken.revokeRole(PAUSER_ROLE, address(this)); syntheticToken = ISyntheticToken(address(_syntheticToken)); }
#0 - JasoonS
2021-08-12T08:47:27Z
Thanks not a serious issue gas wise, only called once.
#1 - JasoonS
2021-08-13T19:21:48Z
Now the role granting/setting stuff happens inside the syntheticToken constructor instead.
112.3905 USDC - $112.39
hickuphh3
By declaring appropriate variable types to storage variables, unnecessary / fewer type casting can be avoided.
LongShort.sol
mapping(uint32 => int256) public assetPrice;
mapping(uint32 => IYieldManager) public yieldManagers;
mapping(uint32 => IERC20) public paymentTokens;
mapping(uint32 => IOracleManager) public oracleManagers;
mapping(uint32 => mapping(bool => ISyntheticToken)) public syntheticTokens;
SyntheticToken.sol
IStaker public staker
;Staker.sol
IFloatToken public floatToken;
ILongShort public longShort;
#0 - JasoonS
2021-08-12T06:46:58Z
This breaks hardhat stack traces :( We had all our code like this originally, and I DO prefer it like that!
No effect on gas though (casting that is).
#1 - 0xean
2021-08-25T17:55:55Z
There are gas costs associated with casting int256 -> uint256.