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: 17/22
Findings: 1
Award: $44.92
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Sathish9098
Also found by: 0xSmartContract, 0xepley, LinKenji, Myd, ZanyBonzy, albahaca, alexbabits, clara, foxb868, invitedtea, oakcobalt, peanuts
44.915 USDC - $44.92
Within the context of this audit, the goal of Shell protocol is to make external protocols compatible with the Ocean through adapter primitives. These adapters take in a financial primitive with respect to the Ocean, and handle standardizing the computation of inputs and outputs so that they can be used seamlessly in the context of the Ocean. Financial primitives are things like AMMs, lending pools, NFT markets, and so on. The Shell protocol is on Arbitrum.
Traditionally, primitives do the business logic of mathematical calculation of inputs and outputs and the actual accounting or movement of tokens. This causes users who wanted to participate in DeFi to execute multiple transactions across multiple primitives for a single intended end result or action.
The Ocean solves the problem of scattered and non-standardized primitives by handling all the accounting movement (burns, mints, transfers) of tokens via its internal ledger, letting the primitives focus solely on the mathematical business logic of calculating amount B for token B, given token A, amount A, and token B. By having standardized compute functions for primitives, and by putting all the tokens into a single ERC1155 ledger, Shell protocol has boiled down the commonality between any primitive and has separated the concerns of accounting and business logic.
There have been multiple Code4rena audits of the Shell Protocol. This one is focused on the Ocean and the Adapters.
Scoped Files:
Ocean.sol
- Handles the accounting or "movement" of tokens.OceanAdapter.sol
- Helper file for the Curve pool adapters. The adapters force Curve to share the same exact accounting logic of the Ocean as any other primitive that we want to adapt into the Ocean.Curve2PoolAdapter.sol
- Used to access Curve's output amount for deposits, withdraws, and swaps. The pool can be found at: https://curve.fi/#/arbitrum/pools/2pool/depositCurveTricryptoAdapter.sol
- Used to access Curve's output amount for deposits, withdraws, and swaps. The pool can be found at: https://curve.fi/#/arbitrum/pools/tricrypto/depositRelevant out of scope files:
BalanceDelta.sol
- Handles the temporary balances that are stored in memory during a set of multiple interactions. Useful for understanding how the balances in memory are handled.Interactions.sol
- Contains the Interaction struct and InteractionType enum, as well as the interface for the Ocean's do*
and forwardDo*
functions. Useful for understanding interactions and their types in detail.OceanERC1155.sol
- The ERC1155 ledger for the Ocean that handles the internal accounting. It also handles registering new primitive tokens, calculating the ocean ID, doing mints, burns, and safe transfers to modify the balances in storage.MaliciousPrimitive.sol
- Example of a malicious primitive that sets a small output amount to be given to a user and a large input amount to be taken from a user.MockPrimitive.sol
- Example primitive that uses interactions (on behalf of the malicious primitive) to determine input/output amounts.The codebase is well documented and structured logically. For incoming developers, consider having more strict language and background education on the adapters and other things unique to the protocol, as those were the hardest to grasp as a novice. Below is my audit approach.
Notable questions asked during the audit:
What malicious things can the primitives or adapters do to poison the accounting of the ocean, if anything? They just return a uint256 output amount, which could be malicious but this doesn't seem to affect any other aspect of the ocean's ledger.
What if the userAddress gets blacklisted during or after a set of interactions? Cannot be blacklisted during a set of interactions since it is a single atomic transaction. If blacklisted afterwards, then they cannot receive their balance via an unwrapping because safeTransfer will fail, but I think this will not damage Ocean's internal accounting.
What if we send ETH value and tokens at the same time in a doInteraction()
or doMultipleInteraction()
? In the former, because there is an if-else statement, the interaction specifics doesn't actually matter, so if a value of ETH is sent, then it doesn't even bother to look at the interaction part. For multiple interactions, it can handle an ETH value sent and token interactions.
Can anything bad happen if passing in invalid interactionType or address for the bytes32 interactionTypeAndAddress? An interactionType beyond the enum will revert in the _getSpecifiedToken()
check. Any address can be passed for the address parameter, but this just tells which token contract or primitive to interact with. So unless the primitive or token itself can somehow poison ocean, this parameter is also safe.
Can anything malicious happen via the bytes32 metadata for an interaction? In the case of the curve pool adapters, the metadata is minimumOutputAmount, which gets cast from bytes32 to uint256, and is used as a slippage metric. Notably, if this is set to 0 and the output amount is 0, the check will pass. It is technically arbitrary but handled by the primitives which ultimately just return amounts.
Is calculating the ocean ID for an ERC721 based on the metadata on the fly during doInteraction()
a problem? If an invalid bytes32 value that is supposed to be cast to the token ID of the NFT is invalid, then it should simply return an invalid ocean ID and revert during _executeInteraction()
.
What if there are duplicate tokenId's in the BalanceDelta struct? Answered directly in the code comments in BalanceDelta.sol
. Any duplicates will always have a delta of 0 because the iteration will only ever look/grab the first tokenID.
What if we provide two identical tokenIds and somehow get both to have a different delta, knowing that only the first token will be operated on? This could cause something bad that may be worth taking a look, but I didn't understand how that would be possible.
Are flash mints and flash loans possible? If so, what kinds of malicious things can happen during a flash mint or loan? Yes they are, but flash loans aren't traditional flash loan I think. In detail answer later.
How can we mess up the balance deltas in memory? We can technically do any whacky things we want in the temporary balance deltas, but "judgement day" comes after a set of interactions via mints and burns, and should always revert if the actual balances in storage attempt to become less than 0.
Can we make the _balances mapping in storage have any value of less than 0? It appears not, sanitized through uint256 and with checks for Balance >= amount before subtractions. (Where amount is always positive).
Can we make the calculated ocean ID incorrect or duplicate with different input values via the abi.encodePacked? No, because an address is always 20 bytes and the tokenId uint should always be unique. Unless there is a way for the tokenAddress for two different tokens to be identical, then this could pose an issue. We cannot mix and match or swap the tokenAddress or tokenId in any capacity.
There is a nice separation of concerns between the Ocean files and the primitive adapters. The primitives are tasked with computing output/input amounts, while the ocean handles all the transfers, mints, and burns.
doMultipleInteractions()
which can have positive and negative deltas during interactions).do*
call, the Ocean accurately tracks balances of the tokens involved throughout the transaction._getSpecifiedtoken()
._executeInteraction()
. This will execute whatever specified interaction type was passed, and we expect a wrap, unwrap, or computation of an input or output amount to occur.
createMintAndBurnArrays()
, and populated with proper amounts via _copyDeltasToMintAndBurnArrays()
doMultipleInteraction()
with 3 interactions.Alice prepares an interaction of interactionType computeOutputAmount, specifying an AMM primitive as the external contract with shUSDC as the input token and shDAI as the output token for a specifiedAmount of 1e24 (1e6 * 1e18). This makes the temporary balances of Alice -1 million shUSDC and +1 million shDAI. Notably, in future interactions she could only unwrap however much the Ocean has backing the shUSDC if she wanted to also do a flash loan. If her starting balance of shUSDC in the Ocean ledger storage is 0, then the ending temporary balance would have to be at least 0, otherwise the whole transaction would revert.
Compute*
interactionType so the primitive that does the flashloan is still invoked.doMultipleInteractions()
to successfully happen.Because doMultipleInteractions()
happens atomically, everything must be done within the context of interactions, and the ending balances in storage must be properly rectified for a successful flash loan.
Centralization risk in Ocean.sol
via onlyOwner modifier with access to changing the fees arbitrarily. There is also no delay on changing fees. The code documentation properly states that the governance structure must handle time lock or mechanism for managing fee changes.
External risk via Curve. The adapters compute the rawOutputAmount based on the curve pool. If they ever change how decimals are handled or their protocol fails, the adapter will also be rendered useless. This external risk is a peripheral concern but should be noted. Also, Curve uses Vyper instead of Solidity in their contracts. Regarding the Curve tricrypto pool for Arbitrum, https://curve.fi/#/arbitrum/pools/tricrypto/deposit, the website states that the pool may be at risk of being exploited, and they recommend exiting this pool. Tweet here: https://twitter.com/CurveFinance/status/1685925429041917952. Therefore, this may not be the best optics to display how adapters work, to have a connection with the tricrypto pool, even if the chance of tragedy is low, the sentiment could scare people.
Systemic risk via putting all of the eggs in one basket, or all of the shells in one Ocean ledger. If something critical fails with the ocean or ocean ledger, then all the primitives connected are effected. There is no upgradability or pausability, which is a tradeoff between absolute immutability and potential centralized safety backdoors.
Compiler risk via solidity compiler 0.8.20. I am unsure if this is an issue but it is worth noting. The PUSH0 opcode introduced in 0.8.20 is not supported on Arbitrum. (This is a more gas-efficient way to push 0 on top of the stack). This may create compatibility issues, where contracts compiled with these versions may deploy but fail to function or return incorrect values. A solution if it is of concern is to downgrade all contracts to 0.8.19.
Dependency Note: OpenZeppelin version 4.8.1 appears to be a solid choice, especially because in version 5.0.0 they removed .isContract()
, which is used in the OceanERC1155.sol
while doing TransferAcceptance checks. It appears isContract()
is used appropriately.
Primitive Security Concerns: The user experience and specific security concerns are passed onto the primitives rather than the Ocean itself. While this keeps the Ocean pristine, if primitives integrating with Ocean do so poorly it can lead to a bad taste for the user for the Ocean in general. The primitives are the ones that need to be selective in which tokens they can accept, and make sure to properly understand how the Ocean works. Because a lot of the security concerns are passed along to the adapters/primitives, make sure to have an overbearing amount of education and developer docs for building on top of Ocean.
Current security approach: They have had past audits with Trail of Bits, a bug bounty program on Immunefi, have participated in multiple Code4rena audits, and they have a Forta bot that can monitor wraps/unwraps/swaps/deposits/withdraws over a threshold amount.
Ideally, the test suite would make sure to include all the invariants and exploration of flash mints and flash loans along with Echidna fuzzing. And generally mix and matching longer strings of doMultipleInteractions()
interactions during tests could be useful.
35 hours
#0 - c4-pre-sort
2023-12-10T16:53:33Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-12-17T11:43:49Z
0xA5DF marked the issue as grade-b