Sofa Protocol
Findings & Analysis Report
2024-06-06
Table of contents
- Summary
- Scope
- Severity Criteria
-
- M-01 Aave Vaults lack support for claiming of reward tokens
- M-02 Use of ETH
transfer()
could cause burning of ETH products to fail - M-03 Ineffective swap deadline for
swapRCH()
- M-04 Failure to set
settlePrices[]
will prevent redemption of product - M-05 Potential Underflow in
getMinterPayoff
Function Causing Minter Asset Loss and Stuck Funds - M-06 Chainlink oracle can record stale prices
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 Pro League Audit is an event where elite tier Code4rena contributors, commonly referred to as wardens, reviews, audits and analyzes smart contract logic in exchange for a bounty provided by sponsoring projects.
During the Pro League audit outlined in this document, C4 conducted an analysis of the Sofa Protocol smart contract system written in Solidity. The audit took place between May 13 - May 28, 2024.
Wardens
3 Wardens contributed to Sofa Protocol:
Final report assembled by bytes032 and thebrittfactor.
Summary
The C4 Pro League analysis yielded an aggregated total of 9 unique vulnerabilities. Of these vulnerabilities, 3 received a risk rating in the category of HIGH severity and 6 received a risk rating in the category of MEDIUM severity.
Additionally, C4 Pro League analysis included 3 findings with a risk rating of LOW severity or non-critical and 1 Informational finding.
Scope
The source code was delivered to Code4rena in a Git repository at https://github.com/sofa-org/sofa-protocol. The audit was performed against commit 4fe95404fbb87023374af424cd35ebe4bcce747b.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (3)
[H-01] Aave Vaults are vulnerable to share inflation attacks
Lines of Code
Description
In the Aave vaults, SHARE_MULTIPLIER
is used to prevent share inflation attack by increasing share precision. This is to prevent attacks that cause an user’s share to be rounded down to zero or a small number, thus receiving incorrect share.
However, the _mint()
actually reduces the share precision again with a division by SHARE_MULTIPLIER
, causing user to be minted incorrect shares amount when there is an share inflation attack.
_mint(_msgSender(), productId, aTokenShare / SHARE_MULTIPLIER, "");
_mint(params.maker, makerProductId, aTokenShare / SHARE_MULTIPLIER, "");
Recommendation
Remove the division by SHARE_MULTIPLIER
so that the minted share amount are in SHARE_MULTIPLIER
precision. Note that this will also require changes to other share computation (e.g. in burn(), harvest()) that had assumed collateral precision for shares amount. And decimals()
should reflect the increased precision as well.
Sofa Protocol
Fixed in commit #7aab.
Code4rena Pro League
Verified, the share precision is now correctly increased based on
SHARE_MULTIPLIER
during mint and other share computations.
[H-02] Incorrect Expiry Used in getHlPrices
Function When Burning A Product ID
Allow Double Withdrawal Exploit in DNT Vaults
Lines of Code
Description
- The
getHlPrices
function may include prices after the intended expiry time when theburn()
function is called inDNTVault
contract after the expiry date (more than 1 day atleast). This can lead to incorrect settlement calculations and allow an attacker to double withdraw his bet. -
The expiry passed to
getHlPrices()
might be later than the product’s actual expiry, that’s because we always passlatestExpiry
, which is basically current time rounded togetMakerPayoff
andgetMinterPayoff
functions:// some code ... >> uint256 latestExpiry = (block.timestamp - 28800) / 86400 * 86400 + 28800; if (isMaker == 1) { >> payoff = getMakerPayoff(latestTerm, latestExpiry, anchorPrices, amount); } else { >> (payoff, settlementFee) = getMinterPayoff(latestTerm, latestExpiry, anchorPrices, amount); }
- Note that the expiry passed to
getHlPrices()
might be later than the product’s actual expiry, causing the function to iterate over and include prices that should not be considered and exclude some that should be included. This results in settling the payoff incorrectly, as prices beyond the product’s expiry can skew the high/low price determination.
>> payoff = STRATEGY.getMakerPayoff(anchorPrices, ORACLE.getHlPrices(term, expiry), amount);
- Since the minter and the maker call this function separately, they might use different
latestExpiry
values if they burn their tokens at different times. This discrepancy can lead to inconsistent settlement values for the two parties.
Attack Example:
- An attacker could take advantage of that by opening a position with themselves and monitoring the prices after the expiry. When prices are favorable to the minter, the attacker burns the minter’s token id.
- Conversely, when prices favor the maker, the attacker closes the maker’s position. This allows the attacker to double withdraw their bet, effectively draining the protocol with a large position.
Note
: This issue is present in all DNT Vaults.
Recommendation
For DNT
vaults, always ensure that the time used for settlement is equal to or less than the product’s real expiry. Implement checks to prevent using a later expiry time:
function _burn(uint256 term, uint256 expiry, uint256[2] memory anchorPrices, uint256 isMaker) internal nonReentrant returns (uint256 payoff) {
uint256 productId = getProductId(term, expiry, anchorPrices, isMaker);
(uint256 latestTerm, bool _isBurnable) = isBurnable(term, expiry, anchorPrices);
require(_isBurnable, "Vault: not burnable");
// check if settled
- uint256 latestExpiry = (block.timestamp - 28800) / 86400 * 86400 + 28800;
+ uint256 current = (block.timestamp - 28800) / 86400 * 86400 + 28800;
+ uint256 latestExpiry = current > expiry ? expiry : current
require(ORACLE.settlePrices(latestExpiry, 1) > 0, "Vault: not settled");
// more code ...
}
function _burnBatch(Product[] calldata products) internal nonReentrant returns (uint256 totalPayoff) {
//some code ..
for (uint256 i = 0; i < products.length; i++) {
// check if settled
- uint256 latestExpiry = (block.timestamp - 28800) / 86400 * 86400 + 28800;
+ Product memory product = products[i];
+ uint256 current = (block.timestamp - 28800) / 86400 * 86400 + 28800;
+ uint256 latestExpiry = current > product.expiry ? product.expiry : current
+ require(ORACLE.settlePrices(latestExpiry, 1) > 0, "Vault: not settled");
- Product memory product = products[i];
}
// more code ....
}
Sofa Protocol
Fixed in commit #a83b.
Code4rena Pro League
Verified. The DNT vaults now ensure that settlement calculation will not include prices after product expiry.
[H-03] Signatures from makers can be re-used due to malleability
Lines of Code
- AAVEDNTVault.sol#L195-L197
- AAVESmartTrendVault.sol#L194-L196
- DNTVault.sol#L175-L177
- LeverageDNTVault.sol#L190-L192
- LeverageSmartTrendVault.sol#L188-L190
- SmartTrendVault.sol#L173-L175
Description
Maker signatures used are malleable. The contract uses ecrecover
to recover the signer of the signatures, and then stores the hash of v,r,s
to denote a used signature.
The issue is that if (v,r,s)
is a valid signature, so is (v,r, -s mod n)
. This is a well known feature of the elliptic curve cryptography. The hash of this manipulated signature is different from the original one, so it allows the same signature to be used twice.
More details about the signature malleability can be found here.
This vulnerability allows maker signatures to be used twice. So makers can be signed up to be exposed to positions twice the size of the position they were anticipating.
Recommendation
Either use the latest openzeppelin ECDSA library, or implement a nonce system for maker signatures to prevent re-use. Openzeppelin ECDSA library already makes sure that the passed s
value is only in the lower range.
Sofa Protocol
Fixed in commit #e4d8. It is still considered acceptable regardless of the fix, as the market makers allow users to re-use the signature twice.
Code4rena Pro League
Verified to have adopted OpenZeppelin ECDSA library for signature verification to prevent signature re-use.
Medium Risk Findings (6)
[M-01] Aave Vaults lack support for claiming of reward tokens
Lines of Code
Description
The Aave vaults are designed to supply the collaterals into Aave Pools to receive interest-bearing aTokens. When redeeming the product on expiry using burn()
/ethBurn()
, the users will be able to receive additional returns from the interests earned in Aave Pools.
However, the Aave Vaults do not support claiming of reward tokens, which are emitted to holders of certain aTokens. For example, aTokens like aUSDC, has 1% APY ARB reward on Arbitrum (see here). That means these reward tokens will be lost as they cannot be claimed.
function burn(uint256 term, uint256 expiry, uint256[2] calldata anchorPrices, uint256 collateralAtRiskPercentage, uint256 isMaker) external {
uint256 payoff = _burn(term, expiry, anchorPrices, collateralAtRiskPercentage, isMaker);
if (payoff > 0) {
require(POOL.withdraw(address(COLLATERAL), payoff, _msgSender()) > 0, "Vault: withdraw failed");
}
}
function ethBurn(uint256 term, uint256 expiry, uint256[2] calldata anchorPrices, uint256 collateralAtRiskPercentage, uint256 isMaker) external onlyETHVault {
uint256 payoff = _burn(term, expiry, anchorPrices, collateralAtRiskPercentage, isMaker);
if (payoff > 0) {
require(POOL.withdraw(address(COLLATERAL), payoff, address(this)) > 0, "Vault: withdraw failed");
WETH.withdraw(payoff);
payable(_msgSender()).transfer(payoff);
}
}
Recommendation
For applicable aTokens, implement the reward claim mechanism so that users can get their share amount of the reward before withdrawing from the Aave pool. Reference here.
Sofa Protocol
Acknowledged.
- Supporting reward claiming and distribution will significantly increase the complexity > of the contract.
- We currently have no plans to support Arbitrum USDC, and there are no rewards for other tokens at this time.
Code4rena Pro League
vAs the supported Aave pools do not have reward tokens currently, the issue is not applicable at this time.
[M-02] Use of ETH transfer()
could cause burning of ETH products to fail
Lines of Code
- AAVEDNTVault.sol#L247
- AAVEDNTVault.sol#L300
- AAVESmartTrendVault.sol#L244
- AAVESmartTrendVault.sol#L294
- DNTVault.sol#L320
- DNTVault.sol#L368
- LeverageDNTVault.sol#L235
- LeverageDNTVault.sol#L280
- LeverageSmartTrendVault.sol#L230
- LeverageSmartTrendVault.sol#L270
- SmartTrendVault.sol#L310
- SmartTrendVault.sol#L354
Description
The vault uses transfer()
for sending ETH to the caller after determining the payout amount.
payable(_msgSender()).transfer(payoff);
However, use of transfer()
has the risk that it will not work if the gas cost increases/decrease. The assumption that gas cost stays fixed no longer holds true since Istanbul hard fork. see https://swcregistry.io/docs/SWC-134/. When that happens, it will prevent the caller from receiving ETH payout.
In the past transfer()
was recommended to prevent re-entrancy attacks (i.e. via receive/fallback) as it limits forwarded gas to 2300. Now the security recommendation to prevent re-entrancy is to ensure Check-Effects-Interactions Pattern or use a Reentrancy guard.
Recommendation
Recommend to change to the following:
(bool success, ) = _msgSender().call{value: payoff, gas: 100_000}("");
require(success, "Failed to send ETH");
Sofa Protocol
Fixed in commit #da49.
Code4rena Pro League
Verified to have adopted recommendation to use
.call()
for ETH transfer.
[M-03] Ineffective swap deadline for swapRCH()
Lines of Code
Description
swapRCH()
uses block.timestamp
, which renders the swap deadline ineffective. That is because the value of block.timestamp
is only determined during execution of the tx, causing the deadline check to always pass.
function swapRCH(
address token,
uint256 minPrice,
address[] calldata path
) external onlyOwner {
// last element of path should be rch
require(path.length <= 4, "Collector: path too long");
require(path[path.length - 1] == rch, "Collector: invalid path");
uint256 amountIn = IERC20(token).balanceOf(address(this));
IUniswapV2Router(routerV2).swapExactTokensForTokens(
amountIn,
amountIn * minPrice / 1e18,
path,
address(this),
block.timestamp + 10 minutes
);
}
Recommendation
This can be fixed by passing in the swap deadline as a parameter in swapRCH()
for both routerV2 and routerV3 to ensure an absolute deadline.
Sofa Protocol
Fixed in commit #d874.
Code4rena Pro League
Verified to have adopted recommendation, which is to make
deadline
an user input parameter.
[M-04] Failure to set settlePrices[]
will prevent redemption of product
Lines of Code
Description
Both SpotOracle
and HlOracle
are responsible for recording the settlePrices[]
via settle()
.
function settle() public {
uint256 expiry = block.timestamp - block.timestamp % 86400 + 28800;
require(settlePrices[expiry] == 0, "Oracle: already settled");
settlePrices[expiry] = uint256(getLatestPrice());
emit Settled(expiry, settlePrices[expiry]);
}
However, if settle()
is not executed on the day of settlement, the settlePrices[expiry]
will remains zero, causing all expiring product to be un-burnable/un-redeemable for SmartTrend vaults as they require a non-zero settle price. And in case of DNT vaults, the issue will cause an incorrect payout as maker will get 100% of it.
This issue could occur for L2 like Arbitrum, where the sequencer downtime will prevent any transaction to be included in the block. The impact is high though probability is low as it requires sequencer to be down for >=
24 hours.
Recommendation
It is recommended to set a latestExpiryUpdated
variable to track the last day it get updated. Upon updating the next expiry prices, verify if any days were skipped. For any missed days (expiry), assign prices based on the average of the latestExpiryUpdated
and the current prices.
Sofa Protocol
Fixed in commit #f9109f.
Code4rena Pro League
Verified to have adopted recommendation to update the missed days since
latestExpiryUpdated
with linearly extrapolated values.
[M-05] Potential Underflow in getMinterPayoff
Function Causing Minter Asset Loss and Stuck Funds
Lines of Code
- AAVEDNTVault.sol#L205
- AAVESmartTrendVault.sol#L204
- LeverageDNTVault.sol#L212-L213
- LeverageSmartTrendVault.sol#L206-L207
Description
The collateralAtRiskPercentage
can exceed 1e18
in certain cases, leading to potential underflow issues in the getMinterPayoff
function. This occurs when params.collateralAtRisk
is greater than (totalCollateral - tradingFee)
, which is possible since it can be equal to totalCollateral
(or slightly less).
require(params.collateralAtRisk <= totalCollateral, "Vault: invalid collateral");
The current check allows params.collateralAtRisk
to be equal to totalCollateral
. When calculating collateralAtRiskPercentage
, subtracting the tradingFee
from the denominator can make collateralAtRiskPercentage
exceed 1e18
.
uint256 collateralAtRiskPercentage = params.collateralAtRisk * 1e18 / (totalCollateral - tradingFee);
In this case if the minter wins, they cannot withdraw their assets due to an underflow when they try to burn thier product ID, since amount * 1e18
will always be less than amount * collateralAtRiskPercentage
here:
payoffShare = payoffShareWithFee - fee + (amount * 1e18 - amount * collateralAtRiskPercentage) / 1e18;
This leads to a loss of assets for the minter and stuck funds in the contract.
Note
: This issue is present in all vaults mentioned in Lines of Code section.
Recommendation
Implement the check to prevent params.collateralAtRisk
from exceeding (totalCollateral - tradingFee)
.
Sofa Protocol
Fixed in commit #6fd7.
Code4rena Pro League
Verified. The implemented check ensures that
collateralAtRiskPercentage > 0 && collateralAtRiskPercentage <= 1e18
.
[M-06] Chainlink oracle can record stale prices
Lines of Code
Description
The SpotOracle
uses chainlink price feeds to record spot prices of assets. This is then used to close valut positions of smart vaults later down the line. The price is read off the latestRoundData
function.
(
/* uint80 roundID */,
int price,
/*uint startedAt*/,
/*uint timeStamp*/,
/*uint80 answeredInRound*/
) = PRICEFEED.latestRoundData();
require(price > 0, "Oracle: invalid price");
This is then stored in the oracle.
settlePrices[expiry] = uint256(getLatestPrice());
The issue is that chainlink recommends certain checks before using their price feed. The most important one is that of staleness. Prices on chainlink contracts can be outdated due to issues with the oracle network, or high network congestion. To prevent using outdated/stale prices, the recommended fix is to have a hardcoded time threshold and to check the time since the timeStamp
of the answer against this threshold. If found to be higher, the price can be categorized as stale.
Using stale prices can lead to a number of issues. Minter or maker position can be settled at incorrect prices and can be denied payouts.
Recommendation
Recommend adding a STALENESS_THRESHOLD
value in the contract. Generally this can be set to 1.1-2x the heartbeat value of the pricefeed. Then, record the timestamp of the data and check the time against this threshold.
require((block.timestamp - timeStamp) < STALENESS_THRESHOLD);
Sofa Protocol
Acknowledged.
Our rule is to fetch the Chainlink price every day at 8 AM UTC for settlement. It doesn’t matter much if the Chainlink price is not updated exactly on time. We cannot fetch the price again a few minutes later just because the 8 AM price was not updated within the STALENESS_THRESHOLD. Doing so might result in fetching a price updated after 8 AM, which would still be problematic.
Code4rena Pro League
Customer wishes to maintain a consistent settlement process by settling with Chainlink prices at 8 AM UTC daily regardless of the staleness.
Low Risk Findings (2)
[L-01] Lack of Enforcement on spreadAPR
Exceeding borrowAPR
in LeverageSmartTrendVault
Contract
Lines of Code
Description
In the LeverageSmartTrendVault
contract, if spreadAPR
exceeds borrowAPR
, the mint function will always revert due to an underflow here:
uint256 borrowFee = minterCollateral * LEVERAGE_RATIO * borrowAPR * (params.expiry - block.timestamp) / SECONDS_IN_YEAR / 1e18;
uint256 spreadFee = minterCollateral * LEVERAGE_RATIO * spreadAPR * (params.expiry - block.timestamp) / SECONDS_IN_YEAR / 1e18;
>> require(borrowFee - spreadFee >= params.collateralAtRisk - params.makerCollateral, "Vault: invalid collateral at risk");
This will cause a Dos for the contract, making minting a new product impossible.
Recommendation
Add a check to ensure spreadAPR
is always less than or equal to borrowAPR
in both the initialize
function and the updateSpreadAPR
function:
+ require(spreadAPR_ <= borrowAPR, "Vault: spreadAPR must be less or equal then borrowAPR");
Sofa Protocol
Acknowledged. We will make sure that borrowAPR is always greater than spreadAPR when they are initialized.
Code4rena Pro League
Customer will enforce the check manually during deployment.
[L-02] Incorrect Assumptions Could Lead to Stuck ETH
in the Contract
Lines of Code
Description
The comment above the receive
function incorrectly assumes that depositing ETH
will help with withdrawals if the balance is insufficient. In reality, ETH
should be wrapped to WETH
since the vault settles with WETH
. Additionally, there is no direct way to withdraw ETH
from the contract, making deposited ETH
inaccessible unless upgraded.
The withdrawal process relies on WETH
availability. If there isn’t enough WETH
, the transaction will revert, even if ETH
is deposited to cover the needed amount, because the contract tries to withdraw the amount of WETH
without checking its availability.
if (payoff > 0) {
require(POOL.withdraw(address(COLLATERAL), payoff, address(this)) > 0, "Vault: withdraw failed");
>> WETH.withdraw(payoff);
payable(_msgSender()).transfer(payoff);
}
Note
: This issue is present in all DNT Vaults, making it a critical vulnerability that needs to be addressed promptly.
Recommendation
I recommend to Implement checks to ensure WETH
availability before attempting withdrawals. If there is not enough WETH for withdrawals, use the deposited ETH for that purpose if any is available.
if (payoff > 0) {
require(POOL.withdraw(address(COLLATERAL), payoff, address(this)) > 0, "Vault: withdraw failed");
+ uint wethBalance = WETH.balanceOf(address(this)) ;
+ uint toWithdraw = wethBalance > payoff ? payoff : wethBalance
- WETH.withdraw(payoff);
+ WETH.withdraw( toWithdraw);
- payable(_msgSender()).transfer(payoff);
+ require(address(this).balance >= payoff);
+ (bool success, ) = _msgSender().call{value: payoff, gas: 100_000}("");
+ require(success, "Failed to send ETH");
}
Sofa Protocol
Acknowledged. We should make sure users can withdraw the correct payoff. We can transfer some WETH into the vault when it is not enough to withdraw.
Code4rena Pro League
Customer will manually transfer WETH into the vault if required.
Informational Findings (1)
[I-01] Consider adding merkleRoot != 0
check
Lines of Code
Description
The claim()
fails to verify that merkleRoot != 0
, which could result in an inaccurate revert error.
function claim(uint256 index, uint256 amount, bytes32[] calldata merkleProof) external {
require(!isClaimed(index), "MerkleAirdrop: Drop already claimed.");
bytes32 node = keccak256(abi.encodePacked(_msgSender(), amount));
require(MerkleProof.verify(merkleProof, merkleRoots[index], node), "MerkleAirdrop: Invalid proof.");
_setClaimed(index);
token.mint(_msgSender(), amount);
emit Claimed(index, _msgSender(), amount);
}
Recommendation
Recommend to add a check for merkleRoot != 0
, same as what is done for claimMultiple()
.
Sofa Protocol
Fixed in commit #2260.
Code4rena Pro League
Verified that the recommendation has been adopted to check for
'merkleRoot != 0
.
Disclosures
C4 is an open organization governed by participants in the community.
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.