Platform: Code4rena
Start Date: 20/01/2022
Pot Size: $80,000 USDC
Total HM: 5
Participants: 37
Period: 7 days
Judge: Jack the Pug
Total Solo HM: 1
Id: 76
League: ETH
Rank: 1/37
Findings: 5
Award: $11,521.89
๐ Selected for report: 5
๐ Solo Findings: 1
๐ Selected for report: GreyArt
3287.1415 USDC - $3,287.14
GreyArt
When escalating claims, the documentation states that the protocol agent is required to pay and stake a certain amount for the process. If the covered protocol is proven correct, then the amount specified by the claim will be paid out. They will also receive the stake amount back in full. If the covered protocol's escalation is not successful, then the amount specified by the claim is not paid out and the stake amount is not returned.
The protocol agent is reasonably expected to pay the following:
BOND
) andIn reality, the protocol agent will end up paying more, as we shall see in the proof of concept.
Let us assume the following:
BOND = 9600
as defined in SherlockClaimManager
umaFee = 400
(at the time of writing, this value has been updated to 1500 USDC: see [Store.computeFinalFee(usdc)](https://etherscan.io/address/0x54f44eA3D2e7aA0ac089c4d8F7C93C27844057BF#readContract)
).On invoking escalate()
, the following amounts are required:
BOND + umaFee = 9600 + 400
will be transferred to UMA when invoking requestAndProposePriceFor()
// Taken from https://github.com/UMAprotocol/protocol/blob/master/packages/core/contracts/oracle/implementation/SkinnyOptimisticOracle.sol // Only relevant lines are referenced uint256 finalFee = _getStore().computeFinalFee(address(currency)).rawValue; request.finalFee = finalFee; totalBond = request.bond.add(request.finalFee); if (totalBond > 0) currency.safeTransferFrom(msg.sender, address(this), totalBond);
BOND + umaFee = 9600 + 400
will be transfered when invoking disputePriceFor()
// Taken from https://github.com/UMAprotocol/protocol/blob/master/packages/core/contracts/oracle/implementation/SkinnyOptimisticOracle.sol#L389-L390 totalBond = request.bond.add(request.finalFee); if (totalBond > 0) request.currency.safeTransferFrom(msg.sender, address(this), totalBond);
However, whatโs important to note is that UMA will โburnโ half of the BOND collected + final fee. This will go against the claim that the protocol agent will be able to reclaim his stake in full.
StoreInterface store = _getStore(); // Avoids stack too deep compilation error. { // Along with the final fee, "burn" part of the loser's bond to ensure that a larger bond always makes it // proportionally more expensive to delay the resolution even if the proposer and disputer are the same // party. uint256 burnedBond = _computeBurnedBond(disputedRequest); // The total fee is the burned bond and the final fee added together. uint256 totalFee = request.finalFee.add(burnedBond); if (totalFee > 0) { request.currency.safeIncreaseAllowance(address(store), totalFee); _getStore().payOracleFeesErc20(address(request.currency), FixedPoint.Unsigned(totalFee)); } } function _computeBurnedBond(Request memory request) private pure returns (uint256) { // burnedBond = floor(bond / 2) return request.bond.div(2); }
We finally note that on settlement, the eventual payout is
// Winner gets: // - Their bond back. // - The unburned portion of the loser's bond: proposal bond (not including final fee) - burned bond. // - Their final fee back. // - The request reward (if not already refunded -- if refunded, it will be set to 0). payout = request.bond.add(request.bond.sub(_computeBurnedBond(settledRequest))).add(request.finalFee).add( request.reward ); request.currency.safeTransfer(disputeSuccess ? request.disputer : request.proposer, payout);
Hence, in reality, the protocol agent will only receive 9600 * 2 - 4800 + 400 = 14800
should the dispute be successful. We note that the burnt amount of 4800 / 2 + 400 = 5200
has been taken by UMA.
One can further verify this behaviour by looking at a past resolution of another protocol:
The above example has a bond is 0.0075 ETH
, with UMAโs final fee being 0.2 ETH. We see that UMA takes 0.2 + 0.5 * 0.0075 = 0.02375 ETH
.
Thus, we see that the protocol agent will be charged disproportionally to what is expected.
We suggest changing the parameters of requestAndProposePriceFor()
to
UMA.requestAndProposePriceFor( UMA_IDENTIFIER, // Sherlock ID so UMA knows the request came from Sherlock claim.timestamp, // Timestamp to identify the request claim.ancillaryData, // Ancillary data such as the coverage agreement TOKEN, // USDC BOND, // While sherlock handles rewards on its own, we use the BOND as the reward // because using it as UMA's bond would result in 0.5 * BOND charged by UMA excluding final fee 1, // Ideally 0, but 0 = final fee used. Hence, we set it to the next lowest // possible value LIVENESS, // Proposal liveness address(sherlockCore), // Sherlock core address 0 // price );
where BOND
becomes the reward and the actual bond for UMA is 1
. Ideally, it should be set to 0, but if set as such, UMA interprets it to use the final fee as the bond amount instead.
[request.bond = bond != 0 ? bond : finalFee;](https://github.com/UMAprotocol/protocol/blob/master/packages/core/contracts/oracle/implementation/SkinnyOptimisticOracle.sol#L321)
This way, the actual amount required from the protocol agent is the BOND + 2 * (USDC wei + umaFee)
for the process. He will additionally be returned his BOND + umaFee
if his dispute is successful.
#0 - Evert0x
2022-02-03T21:09:34Z
Non critical as documentation is incorrect about this
#1 - jack-the-pug
2022-03-26T08:16:52Z
Downgrading to Med
as it's mostly because the documentation is incorrect.
#2 - rcstanciu
2022-03-27T08:00:41Z
@jack-the-pug The docs have been updated to explain the correct burned amount.
#3 - jack-the-pug
2022-04-03T07:50:31Z
@jack-the-pug The docs have been updated to explain the correct burned amount.
Thank you @rcstanciu
While I agreed that the issue only impacts a small sum of users and the impact is not significant. And the root cause of this issue may not be a wrong implementation but actual wrong documentation.
However, I still tend to make this a Med
rather than a Low
for the following reasons:
Therefore, I'm keeping this as a Med
and I encourage the future wardens to continue finding the inconsistency between the documentation and the implementation.
Keep up the good work! GreyArt is a great name btw.
GreyArt
As per the documentation, โAfter 2 weeks without action on an unlocked position arbs can come in to arbRestake(id)
, 20% of the underlying USDC amount (principal + yield) is at risk for the owner of the position.โ
While shares are redeemed for the arbitrager, the ownerโs shares are not reduced in the addressShares
mapping. This will cause an incorrect value to be returned when fetching the ownerโs tokenBalanceOfAddress()
.
Issue is rated as low severity because there is no impact to funds.
Append the following lines into the Do
case of arbRestake()
in Sherlock.js
.
// to make totalStakeShares non-zero after Carol redeems NFT await meta(this.sherlock.initialStake(this.amount, 10, this.bob.address)); await timeTraveler.increaseTime((this.t2.time.add(weeks12)).toNumber()); await this.sherlock.connect(this.carol).redeemNFT(1); console.log(`expect Carol's address to have 0 token balance (fully redeemed)`); expect(await this.sherlock.tokenBalanceOfAddress(this.carol.address)).to.eq(0);
The initial state is Carol, the only party, minting 1 NFT. After arbRestake()
is done, if Carol redeems that NFT, she is expected to have 0 token balance. This is however not the case because of her shares are not decremented in arbRestake()
. Note that we mint another NFT prior to Carolโs redeem so that totalStakeShares
is non-zero. (tokenBalanceOfAddress()
returns zero if totalStakeShares
is zero).
Reduce the nftOwnerโs addressShares
in arbRestake()
.
function arbRestake(uint256 _id) ... { _arbReward = _redeemShares(_id, arbRewardShares, msg.sender); // Burn NFT owner's addressShares addressShares[nftOwner] -= arbRewardShares; ... }
#0 - CloudEllie
2022-02-21T19:19:05Z
Duplicate of #109
๐ Selected for report: hyh
Also found by: GreyArt, harleythedog, pauliax
GreyArt
If there are funds remaining in an old strategy, there is only 1 way to claim those funds which is through Sherlock.updateYieldStrategy()
. It is quite an inconvenience to do this.
Create an additional function to allow anyone to call withdraw / withdraw all from an old strategy. The withdrawn funds should be sent to SherlockCore e.g.
function oldStrategyWithdrawAll(IStrategyManager _oldStrategy) external override { _oldStrategy.withdrawAll(); }
#0 - CloudEllie
2022-02-21T19:22:08Z
Duplicate of #76
1095.7138 USDC - $1,095.71
GreyArt
There are multiple benefits to deriving the SHER
and USDC
token addresses from Sherlock.sol
/ _sherlockPosition
.
SherClaim.claimableAt()
.Expose the token
and SHER
addresses in Sherlock.sol
, and allow SherBuy
to fetch these values.
constructor( uint256 _stakeRate, uint256 _buyRate, ISherlock _sherlockPosition, address _receiver, ISherClaim _sherClaim ) { ... // TODO: ISherlock to expose token() and sher() addresses usdc = _sherlockPosition.token(); sher = _sherlockPosition.sher(); }
๐ Selected for report: GreyArt
2434.9196 USDC - $2,434.92
GreyArt
Other relevant view functions like lockupEnd()
, sherRewards()
and tokenBalanceOf()
revert for non-existent IDs, but viewRewardForArbRestake()
doesnโt.
Include the existence check in viewRewardForArbRestake()
.
if (!_exists(_tokenID)) revert NonExistent();
GreyArt
The escalate()
function performs a sanity check on _amount
:
if (_amount < BOND) revert InvalidArgument();
However, the exact _amount
required is actually 2 * (BOND + UMAโs final fee). The first instance is for calling [UMA.requestAndProposePriceFor()](https://github.com/UMAprotocol/protocol/blob/master/packages/core/contracts/oracle/implementation/SkinnyOptimisticOracle.sol#L331)
and the second for calling [UMA.disputePriceFor()](https://github.com/UMAprotocol/protocol/blob/master/packages/core/contracts/oracle/implementation/SkinnyOptimisticOracle.sol#L390)
.
UMAโs final fee can be found by referencing the finder contract in the Optimistic Oracle: StoreInterface(finder.getImplementationAddress(OracleInterfaces.Store)).computeFinalFee(address(currency)).rawValue
. Note that this value isnโt fixed, and can be updated by UMA.
For simplicity and gas savings, a stricter sanity check would be to check that _amount >= 2 * BOND
.
uint256 internal constant MIN_STAKE_AMOUNT = 2 * BOND; function escalate(uint256 _claimID, uint256 _amount) external override nonReentrant whenNotPaused { if (_amount < MIN_STAKE_AMOUNT) revert InvalidArgument(); ... }
#0 - Evert0x
2022-02-09T20:08:10Z
#112
GreyArt
As per the common issue raised, caching the array length outside a loop saves reading it on each iteration, as long as the array's length is not changed during the loop.
uint256 len = claimCallbacks.length; for (uint256 i; i < len; i++) { if (claimCallbacks[i] == _callback) revert InvalidArgument(); }
#0 - jack-the-pug
2022-03-26T06:52:39Z
Dup #231
11.8662 USDC - $11.87
GreyArt
Instead of doing if (X == true)
or if (X == false)
, it is more gas efficient to do if (X)
or if (!X)
.
// Sends any remaining ETH to the receiver address (as long as receiver address is payable) (bool success, ) = _receiver.call{ value: address(this).balance }(''); if (success == false) revert InvalidConditions();
// Only allow claim calls if claim period is active if (!active()) revert InvalidState();
if (_isCleanupState(_oldState) == false) revert InvalidState();
// Can this claim be updated (based on its current state)? If no, revert if (_isEscalateState(_oldState, updated) == false) revert InvalidState();
// Sends any remaining ETH to the receiver address (as long as receiver address is payable) (bool success, ) = _receiver.call{ value: address(this).balance }(''); if (!success) revert InvalidConditions();
// Only allow claim calls if claim period is active if (!active()) revert InvalidState();
if (!_isCleanupState(_oldState)) revert InvalidState();
// Can this claim be updated (based on its current state)? If no, revert if (!_isEscalateState(_oldState, updated)) revert InvalidState();
#0 - jack-the-pug
2022-03-26T07:34:35Z
Dup #132
GreyArt
The _sweep()
function will make a call to the _receiver regardless of any ETH balance in the contract.
// Sends any remaining ETH to the receiver address (as long as receiver address is payable) (bool success, ) = _receiver.call{ value: address(this).balance }(''); if (success == false) revert InvalidConditions();
It would be more gas efficient to first return if ETH balance is zero, then make the call otherwise.
uint256 ethBal = address(this).balance; // no balance to send, exit if (ethBal == 0) return; (bool success, ) = msg.sender.call{ value: ethBal }(''); // Note: another gas optimization is to use !success instead if (success == false) revert InvalidConditions();
๐ Selected for report: GreyArt
GreyArt
Gas optimisation.
A quick test on remix will show you that it is cheaper to assign than to add then assign so instead of doing _tvl += maxRewardsAvailable;
, it is equivalent to replace it with _tvl = maxRewardsEndTVL;