Platform: Code4rena
Start Date: 13/11/2023
Pot Size: $24,500 USDC
Total HM: 3
Participants: 120
Period: 4 days
Judge: 0xTheC0der
Id: 306
League: ETH
Rank: 21/120
Findings: 3
Award: $271.43
π Selected for report: 0
π Solo Findings: 0
π Selected for report: rvierdiiev
Also found by: 0x175, 0x3b, 0xMango, 0xarno, 0xpiken, Bauchibred, DarkTower, ElCid, Giorgio, HChang26, Kose, KupiaSec, Madalad, PENGUN, Pheonix, RaoulSchaffranek, SpicyMeatball, T1MOH, Tricko, Udsen, Yanchuan, aslanbek, ast3ros, bart1e, bin2chen, chaduke, d3e4, deepkin, developerjordy, glcanvas, inzinko, jasonxiale, jnforja, mahyar, max10afternoon, mojito_auditor, neocrao, nmirchev8, openwide, osmanozdemir1, peanuts, pep7siup, peritoflores, pontifex, rice_cooker, rouhsamad, t0x1c, tnquanghuy0512, turvy_fuzz, twcctop, ustas, vangrim, zhaojie, zhaojohnson
1.3743 USDC - $1.37
Take a look at Market.sol#L171-L189
function sell(uint256 _id, uint256 _amount) external { (uint256 price, uint256 fee) = getSellPrice(_id, _amount); // Split the fee among holder, creator and platform _splitFees(_id, fee, shareData[_id].tokensInCirculation); // The user also gets the rewards of his own sale (which is not the case for buys) uint256 rewardsSinceLastClaim = _getRewardsSinceLastClaim(_id); rewardsLastClaimedValue[_id][msg.sender] = shareData[_id].shareHolderRewardsPerTokenScaled; shareData[_id].tokenCount -= _amount; shareData[_id].tokensInCirculation -= _amount; tokensByAddress[_id][msg.sender] -= _amount; // Would underflow if user did not have enough tokens // Send the funds to the user SafeERC20.safeTransfer(token, msg.sender, rewardsSinceLastClaim + price - fee); emit SharesSold(_id, msg.sender, _amount, price, fee); }
As seen, this function is used whenever a user wants to sell share amounts of a given share ID, issue is that this function does not implement any slippage protection, this allows attackers to use sandwich attacks to manipulate prices and profit at the honest user's expense.
This could lead to users suffering heavy financial losses, where an attacker manipulates the price before the users tx go through, there is also a potential of this to be two sided, i.e when the attacker front runs and then back run the tx.
Manual Review
A popular fix to sandwiching attacks is to implement a minimum withdrawal amount, in this case that would be to allow the user to specify the smallest value of price
they'd accept and revert the tx if their condition is not met.
Token-Transfer
#0 - c4-pre-sort
2023-11-18T09:59:29Z
minhquanym marked the issue as duplicate of #12
#1 - c4-judge
2023-11-28T23:14:14Z
MarioPoneder changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-11-28T23:28:29Z
MarioPoneder marked the issue as satisfactory
π Selected for report: chaduke
Also found by: 0xpiken, Bauchibred, Matin, MohammedRizwan, MrPotatoMagic, OMEN, Pheonix, SandNallani, T1MOH, Topmark, ZanyBonzy, adriro, aslanbek, ayden, bareli, bart1e, bin2chen, btk, cheatc0d3, codynhat, critical-or-high, d3e4, erebus, firmanregar, hunter_w3b, jasonxiale, kaveyjoe, ksk2345, lsaudit, max10afternoon, merlinboii, nailkhalimov, osmanozdemir1, peanuts, pep7siup, pontifex, sbaudh6, shenwilly, sl1, tourist, wisdomn_, young, zhaojie
47.8152 USDC - $47.82
Issue ID | Description |
---|---|
QA-01 | The return value of the turnstile.register() should be checked |
QA-02 | asD::withdrawCarry() should direct to Compound's redeemUnderlying() instead |
QA-03 | A limit should be attached to shareCount |
QA-04 | getNFTMintingPrice() should align more with its documentation |
QA-05 | Assembly code should always be accompanied with comments due to their complex nature |
QA-06 | Introduce emergency functionalities |
QA-07 | Fee calculation should be better documented |
turnstile.register()
should be checkedIn the constructor for some contracts in scope, e.g aSD.sol
, the code attempts to register the Turnstile contract if the chain ID is the Canto
mainnet or testnet, i.e 7700 / 7701. However, it doesn't check the return value of the turnstile.register()
function call. Failing to check the return value can lead to unexpected behaviour if the registration fails or if the function reverts under certain conditions.
Using aSD.sol
as a case syidy
Take a look at
constructor( string memory _name, string memory _symbol, address _owner, address _cNote, address _csrRecipient ) ERC20(_name, _symbol) { _transferOwnership(_owner); cNote = _cNote; if (block.chainid == 7700 || block.chainid == 7701) { // Register CSR on Canto main- and testnet Turnstile turnstile = Turnstile(0xEcf044C5B4b867CFda001101c617eCd347095B44); //@audit turnstile.register(_csrRecipient); } }
Use this search command: https://github.com/search?q=repo%3Acode-423n4%2F2023-11-canto%20turnstile.register(&type=code
in order to find all instances of this in scope
Create a custom error and revert with this if the attempt to register does not go through.
Additionally, consider storing the Turnstile contract address in a configuration file that can be updated externally.
asD::withdrawCarry()
should direct to Compound's redeeemUnderlying()
insteadInfo, incorrect integration of external codebase
Take a look at withdrawCarry()
function withdrawCarry(uint256 _amount) external onlyOwner { uint256 exchangeRate = CTokenInterface(cNote).exchangeRateCurrent(); // Scaled by 1 * 10^(18 - 8 + Underlying Token Decimals), i.e. 10^(28) in our case // The amount of cNOTE the contract has to hold (based on the current exchange rate which is always increasing) such that it is always possible to receive 1 NOTE when burning 1 asD uint256 maximumWithdrawable = (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) / 1e28 - totalSupply(); if (_amount == 0) { _amount = maximumWithdrawable; } else { require(_amount <= maximumWithdrawable, "Too many tokens requested"); } // Technically, _amount can still be 0 at this point, which would make the following two calls unnecessary. // But we do not handle this case specifically, as the only consequence is that the owner wastes a bit of gas when there is nothing to withdraw uint256 returnCode = CErc20Interface(cNote).redeemUnderlying(_amount); //@audit require(returnCode == 0, "Error when redeeming"); // 0 on success: https://docs.compound.finance/v2/ctokens/#redeem IERC20 note = IERC20(CErc20Interface(cNote).underlying()); SafeERC20.safeTransfer(note, msg.sender, _amount); emit CarryWithdrawal(_amount); } }
As seen the function directs to the Compound docs for #redeem
where as it should direct it to #redeemUnderlying
just like the burn()
function
Correctly integrate external codebase and also make this changes to the withdrawCarry()
function
function withdrawCarry(uint256 _amount) external onlyOwner { uint256 exchangeRate = CTokenInterface(cNote).exchangeRateCurrent(); // Scaled by 1 * 10^(18 - 8 + Underlying Token Decimals), i.e. 10^(28) in our case // The amount of cNOTE the contract has to hold (based on the current exchange rate which is always increasing) such that it is always possible to receive 1 NOTE when burning 1 asD uint256 maximumWithdrawable = (CTokenInterface(cNote).balanceOf(address(this)) * exchangeRate) / 1e28 - totalSupply(); if (_amount == 0) { _amount = maximumWithdrawable; } else { require(_amount <= maximumWithdrawable, "Too many tokens requested"); } // Technically, _amount can still be 0 at this point, which would make the following two calls unnecessary. // But we do not handle this case specifically, as the only consequence is that the owner wastes a bit of gas when there is nothing to withdraw uint256 returnCode = CErc20Interface(cNote).redeemUnderlying(_amount); - require(returnCode == 0, "Error when redeeming"); // 0 on success: https://docs.compound.finance/v2/ctokens/#redeem + require(returnCode == 0, "Error when redeeming"); // 0 on success: https://docs.compound.finance/v2/ctokens/#redeemUnderlying IERC20 note = IERC20(CErc20Interface(cNote).underlying()); SafeERC20.safeTransfer(note, msg.sender, _amount); emit CarryWithdrawal(_amount); } }
shareCount
Low, info on better code structure
Take a look at getPriceAndFee()
function getPriceAndFee(uint256 shareCount, uint256 amount) external view override returns (uint256 price, uint256 fee) { for (uint256 i = shareCount; i < shareCount + amount; i++) { uint256 tokenPrice = priceIncrease * i; price += tokenPrice; fee += (getFee(i) * tokenPrice) / 1e18; } }
As seen this function queries and returns the price and fee, one thing to note is that the function does not limit the amount of shareCount
it could work with which could lead to it getting error'd due to an OOG.
Introduce a max share count to protocol
getNFTMintingPrice()
should align more with it's documentationLow/info... confusing codes.
Take a look at getNFTMintingPrice()
/// @notice Returns the price and fee for minting a given number of NFTs. @audit this should return both the price and fee as suggested by the comments, but it instead returns only the fee /// @param _id The ID of the share /// @param _amount The number of NFTs to mint. function getNFTMintingPrice(uint256 _id, uint256 _amount) public view returns (uint256 fee) { address bondingCurve = shareData[_id].bondingCurve; (uint256 priceForOne, ) = IBondingCurve(bondingCurve).getPriceAndFee(shareData[_id].tokenCount, 1); fee = (priceForOne * _amount * NFT_FEE_BPS) / 10_000; }
As seen, the function's name and its comments project this to return both the price to mint an NFT and it's fee, but the function only returns the fee even in all the instances where it's been queried
Either make the function more aligned to the documentation or better still correct the documentation if this is intended.
Info
Take a look at LinearBondingCurve::log2()
function log2(uint256 x) internal pure returns (uint256 r) { /// @solidity memory-safe-assembly assembly { r := shl(7, lt(0xffffffffffffffffffffffffffffffff, x)) r := or(r, shl(6, lt(0xffffffffffffffff, shr(r, x)))) r := or(r, shl(5, lt(0xffffffff, shr(r, x)))) r := or(r, shl(4, lt(0xffff, shr(r, x)))) r := or(r, shl(3, lt(0xff, shr(r, x)))) // forgefmt: disable-next-item r := or( r, byte( and(0x1f, shr(shr(r, x), 0x8421084210842108cc6318c6db6d54be)), 0x0706060506020504060203020504030106050205030304010505030400000000 ) ) } } }
As seen, this function is used to get the log2 of any value x, issue is that this code block uses assembly code, which is a way lower level than normal solidity code and is highly error prone
As popularly recommended in the web 3 space, whenever assembly code is used, it should always be accompanied with comments, this should also be implemented with Canto
too.
Info
Using this search command: https://github.com/search?q=repo%3Acode-423n4%2F2023-11-canto%20pause%20unpause&type=code
we can see that all in-scope contracts lack any sort of emergency functionality, whereas adding this could lead to additional centralization, it's advisable cause in the case of a black swan event protocol can easily protect itself from too much harm
Add emergency functionalities to protocol.
Info, non-understandable piece of code
In Market.sol
it can be seen that the process for fee calculation is not transparently documented in the contract. Clear comments or documentation regarding how fees are calculated and distributed would be beneficial for both users/developers and auditors.
Improve fee calculation documentation.
#0 - c4-pre-sort
2023-11-24T06:51:38Z
minhquanym marked the issue as high quality report
#1 - c4-sponsor
2023-11-27T09:24:05Z
OpenCoreCH (sponsor) acknowledged
#2 - c4-judge
2023-11-29T23:11:50Z
MarioPoneder marked the issue as grade-a
π Selected for report: 0xSmartContract
Also found by: 0xbrett8571, 0xepley, Bauchibred, K42, Kose, MrPotatoMagic, Myd, Sathish9098, aariiif, cats, clara, emerald7017, fouzantanveer, hunter_w3b, invitedtea, unique
222.2364 USDC - $222.24
The first few hours after starting the security review was dedicated to examining the smart contracts in scope, after this quick review, a few hours was spent on researching some of the external integrations, this was then followed by a meticulous, line-by-line security review of the sLOC in scope, beginning from the contracts small in size like asD.sol
ascending to the bigger contracts like the Market.sol
. The concluding phase of this process entailed interactive sessions with the developers on Discord. This fostered an understanding of unique implementations and facilitated discussions about potential vulnerabilities and observations that I deemed significant, requiring validation from the team.
The Market.createNewShare
function is for generating new shares. The process of share creation is flexible, allowing either unrestricted access or limiting it to pre-approved (whitelisted) addresses. Importantly, no fee is levied for establishing new shares.
To purchase tokens, users employ the buy
function, specifying the desired share ID. This transaction automatically triggers the claiming of any accumulated rewards for the token holder, applicable both during purchase and sale.
Tokens are sold using the sell
function. The applicable fees are subtracted from the selling price. It's important to note that theoretically, fees could exceed the sale price, potentially rendering shares unsellable. This scenario is improbable with a linear bonding curve but could occur with other, more complex curves. In such cases, reverting the transaction is considered acceptable, as there would be no incentive for users to sell under these conditions.
Three distinct functions β claimPlatformFee
, claimHolderFee
, and claimCreatorFee
β are available for different stakeholders (platform administrators, shareholders, and share creators, respectively) to claim their respective portions of the accrued transaction fees.
LinearBondingCurve
contract, uses the IBondingCurve
interface, it implements a bonding curve mechanism where the price per share increases based on a pre-set rate (priceIncrease
), calculating the cumulative price and fee for a specified amount of shares, with a fee structure that doesn't increase as fast as the share count, i.e logarithmically relative to the share count, and includes a custom implementation of the log2()
function, sourced from Solady's FixedPointMathLib.sol
, to support its fee calculation logic.asDFactory
asDFactory
serves the purpose of generating new asD tokens. Its primary function, create
, accepts the proposed name and symbol for the new token, which are not required to be unique. The factory maintains a record of all tokens it creates in the isAsD
mapping, enabling other contracts to verify the authenticity of an asD token. This is particularly useful for those wanting to recognize all asD tokens, not just specific ones.asD
To mint asD tokens, the mint
function is utilized. An equivalent amount of NOTE must be provided by the user, which is then allocated to the Canto Lending Market (CLM), effectively converting it into cNOTE.
Utilizing the burn
function, users can destroy their asD tokens. In exchange, they receive an equal amount of NOTE, which is initially retrieved from the CLM.
The withdrawCarry
function allows the asD contract owner (typically the creator) to withdraw accrued interest. It's crucial that this function is designed to prevent the owner from withdrawing an excessive amount of tokens, ensuring the ability to redeem all asD tokens at their established 1:1 rate with NOTE.
One thing to give a great feedback on is the fact that contract uses whitelisting a lot, which is very commendable since this means that protocol only work with vetted parties, be it underlying tokens, share creators or what have you.
https://github.com/search?q=org%3Acode-423n4%20Canto-findings&type=code
Copied from Solady: https://github.com/Vectorized/solady/blob/main/src/utils/FixedPointMathLib.sol
The system's testability is commendable being that is has a wide coverage, one thing to note would be the occasionally intricate lengthy tests that are a bit hard to follow, this shouldn't necessarily be considered a flaw though, cause once familiarized with the setup, devising tests and creating PoCs becomes a smoother endeavour. In that sense I'd commend the team for having provided a high-quality testing sandbox for implementing, testing, and expanding ideas.
As usual, like many other blockchain protocols, this one also relies significantly on the admin's integrity and non-malicious behaviour, any deviation from this could result in a range of issues, such as:
changeShareCreatorWhitelist()
front running the user's execution, effectively barring the user from participating.During my security review, I identified several issues impacting the smart contract functionalities, each presenting unique challenges and potential risks to the system's overall integrity, one of which is the fact that in Market.sol
, the lack of slippage protection in the sell()
function exposes users to potential sandwich attacks. Attackers can exploit this by manipulating the market price before and after a user's transaction, leading to significant financial losses for the user. A recommended fix is to allow users to set a minimum acceptable sale price, thus ensuring their transaction is reverted if the market price is manipulated unfavourably.
The sell()
function in Market.sol
currently operates without a deadline, creating a risk of financial loss for users. If a transaction is executed later than intended, despite meeting the minimum price requirements, users might face unfavourable US dollar valuations. To mitigate this, it's recommended to introduce a user-defined deadline for each transaction within the sell
function, providing users control over the timing and enhancing transactional security.
In LinearBondingCurve.sol
, the fee calculation mechanism, which relies on the logarithm of share count, presents a significant flaw. This method leads to a disproportionately slow increase in fees compared to the growth in share count, as evidenced by provided graphical analyses, to put this in perspective a share count increase of 10,000%
would only lead to a ~78%
increase in fees. Such a fee structure risks becoming almost stagnant and suffers from precision loss, potentially resulting in insufficient fee allocation to holders, creators, and the platform, particularly as the protocol scales. Revising the fee calculation formula or addressing the precision loss is recommended to ensure fair and adequate fee distribution.
In multiple contracts in scope, like aSD.sol
, there's an oversight in not checking the return value of the turnstile.register()
call, particularly on the Canto mainnet or testnet (chain IDs 7700/7701). This omission could lead to unanticipated behaviours if the registration fails or the function reverts, potentially impacting the contract's operations. Enhancing this with a check and appropriate error handling would ensure more robust and predictable functionality.
Market.sol
faces a denial-of-service risk for some users due to the use of hardcoded recipient addresses in its sell()
function. If a user is blacklisted by the underlying ERC20 token, they become unable to sell their shares, leading to potential asset lock-up and financial losses in US$ value if market is very turbulent. To address this, it's advisable to modify the function to allow users to specify an alternative address for receiving sale proceeds, circumventing possible blacklisting issues.
The asD::withdrawCarry()
function's integration with Compound's redeemUnderlying()
is incorrectly referenced, pointing to #redeem
in the documentation. This misalignment may lead to confusion or improper use of the Compound protocol. Adjusting the reference to correctly point to #redeemUnderlying
, and ensuring the function aligns with the expected behaviour, will improve clarity and functionality.
Currently, the shareCount
in the getPriceAndFee()
function lacks a maximum limit, which could lead to out-of-gas errors during execution. Imposing a cap on shareCount
will prevent such issues, ensuring smoother and more efficient operation, especially when dealing with large amounts of data or iterations.
The getNFTMintingPrice()
function's documentation suggests it returns both price and fee, but the actual implementation returns only the fee. This discrepancy between the documentation and functionality can lead to confusion. Aligning the function with its documentation or revising the documentation to accurately reflect the functionβs purpose will enhance understanding and usability.
The use of assembly code in functions like LinearBondingCurve::log2()
without adequate commenting poses a risk due to the complex nature of such code. Detailed comments explaining the assembly logic would greatly aid in understanding and maintaining the code, reducing the likelihood of errors.
The lack of emergency functionalities across various in-scope contracts could leave the system vulnerable during unforeseen events or crises. Introducing mechanisms to pause or modify operations during emergencies would add a layer of security and control, albeit at the cost of some decentralization.
The fee calculation process within Market.sol
is currently under-documented, leading to potential misunderstandings. Improved documentation detailing how fees are calculated and distributed will provide clarity for users and developers, ensuring a more transparent and user-friendly experience.
During the course of my security review, I had to research some stuffs to understand the context in which they are currently being used, some of which led to me learning new things and having refreshers on already acquainted topics. Some of these topics are listed below:
There are currently some gaps concerning what's within the scope. It's important to remember that code comments also count as documentation, and there have been some inconsistencies in this area. Comprehensive documentation not only provides clarity but also facilitates the onboarding process for new developers. For instance, while withdrawCarrying
the code documentation point to a completely different function than what's been queried from Compound.
Having multiple eyes scrutinizing a protocol is always valuable. More contributors can significantly reduce potential risks and oversights. Evidence of where this could come in handy can be gleaned from codebase typos. Drawing from the broken windows theory, such lapses may signify underlying code imperfections.
Many security experts prefer using Visual Studio Code augmented with specific plugins. While the popular Solidity Visual Developer has already been integrated with the protocol, there's room for incorporating other beneficial tools.
Where as the previous comment on testing was heavily commending it, for any good thing it could definitely get better, and as such team should be trying to hit the 100% coverage.
There's a need to improve the naming conventions for contracts, functions, and variables. In several instances, the names don't resonate with their respective functionalities, leading to potential confusion, an example of this can be seen with the getNFTMintingPrice()
function, unlike it's name and the documentation attached to it, this function only returns the fee attached to minting the NFT.
My attempt on reviewing the Codebase spanned around 12 hours distributed around 2 days:
The codebase was a very great learning experience, though it was a pretty hard nut to crack, being that it has a little amount of nSLOC and as usual the lesser the lines of code the lesser the bug ideas , nonetheless it was a great ride seeing the unique implementations employed in the codebase.
12 hours
#0 - c4-pre-sort
2023-11-24T06:53:14Z
minhquanym marked the issue as high quality report
#1 - c4-sponsor
2023-11-27T09:29:16Z
OpenCoreCH (sponsor) acknowledged
#2 - c4-judge
2023-11-29T20:42:20Z
MarioPoneder marked the issue as grade-a