Platform: Code4rena
Start Date: 27/11/2023
Pot Size: $36,500 USDC
Total HM: 0
Participants: 22
Period: 8 days
Judge: 0xA5DF
Id: 308
League: ETH
Rank: 1/22
Findings: 2
Award: $6,769.17
🌟 Selected for report: 1
🚀 Solo Findings: 0
6724.2519 USDC - $6,724.25
[L-01] 2 address tokens will not work properly on the Ocean ERC1155 Ledger.
ERC20 tokens with multiple entry points (known as double entry tokens or two address tokens) such as Synthetix's ProxyERC20 contract will not work properly on the ledger. In the ledger, all tokens are given a specific uint256 value, by calling _getSpecifiedToken()
, which will call _calculateOceanId()
.
uint256 specifiedToken = _getSpecifiedToken(interactionType, externalContract, interaction
function _calculateOceanId(address tokenAddress, uint256 tokenId) internal pure returns (uint256) { return uint256(keccak256(abi.encodePacked(tokenAddress, tokenId))); }
If a user uses a two address token, the two address will be considered different, and the Ocean will create 2 separate ledger instead of 1.
[L-02] Interactions is succeptible to read-only reentrancy
The interaction flow in Ocean.sol will complete the interaction first before minting/burning the input/output token.
(inputToken, inputAmount, outputToken, outputAmount) = _executeInteraction( interaction, interactionType, externalContract, specifiedToken, interaction.specifiedAmount, userAddress ); } // if _executeInteraction returned a positive value for inputAmount, // this amount must be deducted from the user's Ocean balance if (inputAmount > 0) { // since uint, same as (inputAmount != 0) _burn(userAddress, inputToken, inputAmount); } // if _executeInteraction returned a positive value for outputAmount, // this amount must be credited to the user's Ocean balance if (outputAmount > 0) { // since uint, same as (outputAmount != 0) _mint(userAddress, outputToken, outputAmount); }
Take Unwrapping ERC721 interaction as an example. The user have an shERC721 and wants to unwrap it to get his ERC721 token back. When the ERC721 token is transferred to the user, the shERC721 is not burned yet. This means that the user has both shERC721 and ERC721 in the state.
function _erc721Unwrap(address tokenAddress, uint256 tokenId, address userAddress, uint256 oceanId) private { IERC721(tokenAddress).safeTransferFrom(address(this), userAddress, tokenId); emit Erc721Unwrap(tokenAddress, tokenId, userAddress, oceanId); }
If the Ocean has more interactions in the future like lending/borrowing, the user can call ERC721Unwrap and do a cross-function reentrancy by reentering safeTransferFrom()
after getting his ERC721 back but before burning his shERC721. For example, if the lending interaction just needs user to show proof that they have a particular shERC721 on hand, user can do the following:
function onERC721Received( address _sender, address _from, uint256 _tokenId, bytes memory _data) external returns (bytes4 retval) { require(msg.sender == address(victim), "Nope"); // Call the loan function return victim.onERC721Received.selector; }
This is hypothetical because there is no loan/borrow function on Ocean. However, the existence of a read-only reentrancy and that the user has 2 tokens at a point in the execution is dangerous. Ideally, burn the sh(Tokens) first before interaction. Minting later is fine. At all times, the user should only have 1 token in the state.
[L-03] User can escape fees on ERC1155 tokens
ERC1155 tokens from external sources may have different decimals, but they are not normalized to 18 decimals. The Ocean ledger takes whatever decimals it is and sets it to the corresponding shToken. For example, if a user wants to wrap an ERC1155 token that has 4 decimals, then they will mint a 4 decimal shToken.
function _erc1155Wrap( address tokenAddress, uint256 tokenId, uint256 amount, address userAddress, uint256 oceanId ) private { //@audit Note that there is no `_convertDecimals()` call if (tokenAddress == address(this)) revert NO_RECURSIVE_WRAPS(); _ERC1155InteractionStatus = INTERACTION; IERC1155(tokenAddress).safeTransferFrom(userAddress, address(this), tokenId, amount, ""); _ERC1155InteractionStatus = NOT_INTERACTION; emit Erc1155Wrap(tokenAddress, tokenId, amount, userAddress, oceanId); }
The fee is therefore dependent on the amount of decimals that the ERC1155 token has. If a user wraps 100 tokens with 2 decimal places, eg 100e2, they can choose not to pay the fees by calling erc1155Unwrap
5 times instead of unwrapping 100e2 tokens directly. This is because the unwrapFeeDivisor has a minValue of 2000, so if the user can make the unwrapAmount less than 2000, then they will not need to pay the fees. (Instead of unwrapping 10000 tokens and paying 5 tokens as fee, user unwraps 2000 tokens 5 times).
function _calculateUnwrapFee(uint256 unwrapAmount) private view returns (uint256 feeCharged) { feeCharged = unwrapAmount / unwrapFeeDivisor; }
[L-04] Airdrops will be locked in the contract
There are some collection of ERC721 tokens that will airdrop users tokens if they have proof that they own an ERC721. There are other ERC721 collection that will airdrop tokens to every holder of the token. If the user deposits the ERC721 token which is eligible for an airdrop into the Ocean protocol, and an airdrop happens, the Ocean will receive the airdrop instead.
Since the ocean contract does not have any way to withdraw random tokens, the airdrop will be locked inside the protocol.
Not sure about the recommendation, but take note that some ERC721 collections may mint tokens for every holder or may airdrop tokens to holders once in a while. Make sure that these tokens are either not accepted into Ocean, or that the users are warned beforehand that the airdrop will be lost.
[N-01] NATSPEC on OceanAdapter._convertDecimals should be revised
The function _convertDecimals()
from OceanAdapter.sol is an exact copy on Ocean.sol. However, OceanAdapter version does not return the truncated amount.
(OceanAdapter.sol) function _convertDecimals( uint8 decimalsFrom, uint8 decimalsTo, uint256 amountToConvert ) internal pure > returns (uint256 convertedAmount)
function _convertDecimals( uint8 decimalsFrom, uint8 decimalsTo, uint256 amountToConvert ) internal pure > returns (uint256 convertedAmount, uint256 truncatedAmount)
The comments on OceanAdapter.sol should be revised accordingly to not reference the truncatedAmount.
* @dev convert a uint256 from one fixed point decimal basis to another, * returning the truncated amount if a truncation occurs.
[N-02] minimumOutputAmount in CurveAdaptor can be integrated into the curve functionalities
The Curve Adapters interacts with curve pool through three functions, exchange()
, remove_liquidity_one_coin()
and add_liquidity()
. The last parameter of these functions are set to zero, which is the slippage parameter.
ICurveTricrypto(primitive).remove_liquidity_one_coin(rawInputAmount, indexOfOutputAmount, 0);
Ocean protocol takes care of it by checking the slippage right at the end, but it can also be integrated right into the curve functions.
if (uint256(minimumOutputAmount) > outputAmount) revert SLIPPAGE_LIMIT_EXCEEDED();
[N-03] Comments on ERC20Wrap is misleading
In ERC20Wrap, the amount
parameter is supposed to be the amount of shTokens to receive, not the amount of ERC20 tokens to be wrapped.
> * @param amount amount of the ERC-20 token to be wrapped, in terms of * 18-decimal fixed point * @param userAddress the address of the user who is wrapping the token */ > function _erc20Wrap(address tokenAddress, uint256 amount, address userAddress, uint256 outputToken) private {
The outputAmount
is the specifiedAmount
, which is passed as amount
into ERC20Wrap function.
} else if (interactionType == InteractionType.WrapErc20) { inputToken = 0; inputAmount = 0; outputToken = specifiedToken; > outputAmount = specifiedAmount; > _erc20Wrap(externalContract, outputAmount, userAddress, outputToken);
This outputAmount
is then minted as the shToken, which means that the outputAmount
must be in the form of shTokens.
if (outputAmount > 0) { // since uint, same as (outputAmount != 0) _mint(userAddress, outputToken, outputAmount); }
It will be useful if amount
is more specific, and better explained in the comments. Is amount
referring to the token amount to deposit or the corresponding shToken to receive?
#0 - c4-pre-sort
2023-12-10T19:43:46Z
raymondfam marked the issue as sufficient quality report
#1 - 0xA5DF
2023-12-16T07:48:04Z
L
L
NC
NC
L
R
NC
#2 - 0xA5DF
2023-12-16T08:35:32Z
+L from #272 +L from #221
#3 - c4-judge
2023-12-16T12:03:14Z
0xA5DF marked the issue as selected for report
🌟 Selected for report: Sathish9098
Also found by: 0xSmartContract, 0xepley, LinKenji, Myd, ZanyBonzy, albahaca, alexbabits, clara, foxb868, invitedtea, oakcobalt, peanuts
44.915 USDC - $44.92
enum InteractionType { WrapErc20, UnwrapErc20, WrapErc721, UnwrapErc721, WrapErc1155, UnwrapErc1155, ComputeInputAmount, ComputeOutputAmount, UnwrapEther }
Step | Approach | Details | Comments |
---|---|---|---|
1 | Reading the Documentation | Shell Protocol Wiki | Clear and concise, purpose is easily understood |
2 | Understanding the Whitepaper | Whitepaper | Great examples, in-depth explanation, building on top of the docs |
3 | Protocol Walkthrough | Shell App | Great UI, nice to see the frontend to make sense of things |
4 | Downloading the Code | Github | Build is fine |
5 | Manual Code Review | Github | Started with the biggest nSLOC, Ocean.sol, matching docs with code |
6 | Manual Analysis | Github | Started writing the Analysis report, and invariant testing |
7 | Discord | - | Read through the discord discussions to confirm understanding |
8 | Submissions | - | Submit Medium Findings, QA report and Analysis Report |
doInteraction()
with the specific ERC20Wrap interaction.doInteraction()
with the specific ERC20Unwrap interaction.doInteraction()
with the specific ComputeOutputAmount interaction.Contract | Line | Function | Comments | Questions/Inconsistencies to Review |
---|---|---|---|---|
Ocean | 196 | changeUnwrapFee | Unwrap fee must be above MIN_UNWRAP_FEE_DIVISOR (2000) | No upper limit, its ok, just lesser fees. onlyOwner centralization |
Ocean | 1159 | calculateUnwrapFee | used in erc20, erc1155 and ether unwrap | Maximum fee is 0.05% for every unwrap |
Ocean | 864 | erc20Unwrap | unwrap ERC20 token, can be any token? | truncatedAmt goes to protocol as well |
Ocean | 820 | erc20Wrap | wrap ERC20 token, can be any token? | mirror of unwrapping, uses determineTransferAmt |
Ocean | 1123 | convertDecimals | convert shERC decimals to ERC decimals | 11 variations, checks below |
Ocean | 1123 | determineTransferAmount | used in erc20 wrap, from x decimals to 18 | uses convertDecimals as well, but convert from 18 to x? |
Ocean | 889 | erc721Wrap | wrap ERC721 tokens, no fees, specifiedAmt must be 1 | TokenId is a user input, any reentrancy? Not really applicable |
Ocean | 903 | erc721Unwrap | wrap ERC721 tokens, no fees | TokenId is a user input, can withdraw any token from collection? |
Ocean | 920 | erc1155Wrap | wrap ERC1155 tokens to shTokens, no fees | TokenId is a user input |
Ocean | 955 | erc1155Unwrap | unwrap ERC1155 tokens, pays a fee | TokenId is a user input, can withdraw any token from collection? |
Ocean | 210 | doInteraction | payable, sets msg.sender | 9 forms of interaction type |
Ocean | 229 | doMultipleInteractions | payable, sets msg.sender | Chains the interaction type, reentrancy? |
Ocean | 256 | forwardedDoInteraction | payable, sets approved forwarder | Same as doInteraction, but lets an approved forwarder do the interaction |
Ocean | 256 | increaseBalanceOfPrimitive | Increases balance of primitive temporarily | This is to make sure that external primitives can interact with Ocean |
OceanAdapter | 55 | computeOutputAmount | user provides inputAmt and primitive compute outputAmt | A external adapter primitive, inherited by Curve2PoolAdapter.sol |
OceanAdapter | 108 | calculateOceanId | Provides the tokenAddress and tokenId | tokenId == 0 for ERC20, what if two address tokens? |
Curve2PoolAdapter | 102 | wrapToken | wraps a ERC20 token, calls doIntearction | Curve2PoolAdapter becomes the msg.sender in doInteraction? |
Curve2PoolAdapter | 142 | primitiveOutputAmount | swaps/addliquidity/removeliquidity from curve | Slippage is checked, primitive helps to swap shTokens? Does it affect ledger? |
Curve2PoolAdapter | 118 | wrapToken | similar to Curve2PoolAdapter, but with ETH integration | zToken is ETH, interactionTypeAddress sets to 0 (indicate ETH Wrapping) |
Manual Invariant Analysis (Truncation/Rounding)
Check calculateUnwrapFee
Check convertDecimals (Unwrapping)
Check convertDecimals (Wrapping) via determineTransferAmount
(All examples uses Remix. A copy of the code can be found below)
pragma solidity 0.8.10; contract Shell { function _convertDecimals( uint8 decimalsFrom, uint8 decimalsTo, uint256 amountToConvert ) public returns (uint256 convertedAmount, uint256 truncatedAmount) { if (decimalsFrom == decimalsTo) { // no shift convertedAmount = amountToConvert; truncatedAmount = 0; } else if (decimalsFrom < decimalsTo) { // Decimal shift left (add precision) uint256 shift = 10**(uint256(decimalsTo - decimalsFrom)); convertedAmount = amountToConvert * shift; truncatedAmount = 0; } else { // Decimal shift right (remove precision) -> truncation uint256 shift = 10**(uint256(decimalsFrom - decimalsTo)); convertedAmount = amountToConvert / shift; truncatedAmount = amountToConvert % shift; } } function _calculateOceanId(address tokenAddress, uint256 tokenId) public pure returns (uint256) { return uint256(keccak256(abi.encodePacked(tokenAddress, tokenId))); } }
Ocean.sol
doInteraction()
from Ocean.sol is called_doInteraction()
is executed, calls _executeInteraction()
_computeOutputAmount()
is called (Line 612)_computeOutputAmount()
is called on the OceanPrimitive (Line 760)unwrapToken()
is called on OceanAdapter.sol, an abstract contract inherited by CurveAdapter (Line 67)doInteraction()
is called on CurveAdapter. Note that msg.sender has changed when doInteraction() is invoked againprimitiveOutputAmount()
is called, moving to CuveAdapterwrapToken()
is called, wrapping the USDT back to shUSDTFund flow (fees not included):
100 shUSDC -> 100 USDC
100 USDC -> 100 USDT
100 USDT -> 100 shUSDT
There seems to be two contracts holding funds, one is Ocean.sol and one is the Adapter.sol, which will result in accounting errors.
For exmaple, The CurveAdapter will not have the shToken to burn when calling unwrap, as the accounting lies in the Ocean contract and user
Truncation while calculating swaps/deposit via CurveAdapter is ignored.
Q1: Any 1 wei attacks? Eg depositing 1 wei token into Ocean to mess up the ledger A1: Don't think it can work, no truncation issues or shares being affected by contract balance, only by input and output tokens
Q2: Any vulnerabilities in accepting all types of ERC20? A2: Mostly user input issues. Two-token addresses is affected in the leger (Low), Fee on transfer tokens will be affected because of accounting error (Medium). Revert on zero-value transfer is a user input issue. Low decimals token may affect the truncation/dust amount when converting decimals (Low). High decimal tokens seem to work fine. ERC777 can be used because its ERC20 backwards compatible, don't seem to have an issue with the potential callbacks from ERC777.
Q3: Any vulnerabilities in accepting ERC721 tokens? A3: Upgradeable ERC721 tokens will not affect/pausing mechanism will not affect since the Ocean holds the ERC721 token. (ie users wrapping/unwrapping their token will not affect another user). One possible issue is protocol assumes ERC721 tokens from a collection are all the same, but some tokens are more rare than others (Medium).
Q4: Any vulnerabilities in accepting ERC1155 tokens? A4: Not so much, vulnerabilities will be similar to ERC721.
Q5: Any frontrunning issues? A5: There's a swap functionality available, but frontrunning is not an issue because of slippage check. Frontrunning is not applicable when wrapping/unwrapping.
Q6: Any gas-related issues? A6: Usage of payable.transfer when unwrapping ether. Protocol uses safeTransfer, so return value is checked. No other known usage of low-level calls. Gas griefing only affects the user himself.
Q7: Any reentrancy issues? A7: When unwrapping tokens (burning shToken to Token), there is a point in the function execution whereby the holder holds 2 tokens, the sh variation and the token, which may cause some read-only reentrancy / cross-function reentrancy (Low).
Q8: Can a user grief another person's balance ledger? A8: _doInteraction is set to msg.sender, and forwardDoInteraction must be approved, so a user cannot manipulate another user's sh balance.
Q9: Can a user manipulate his own balance?
A9: There's a onERC1155Receive callback when minting shTokens in _doSafeTransferAcceptanceCheck()
(OceanERC1155.sol, out of scope). User can reenter through the callback, but it doesn't really matter.
25 hours
#0 - c4-pre-sort
2023-12-10T16:45:06Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-12-17T11:43:55Z
0xA5DF marked the issue as grade-b