← Back to Reports
Sofa Protocol
Sofa Review
Review Resources:
Auditors:
- Drastic Watermelon
- adriro
Table of Contents
Review Summary
Automator
The crvUSD Automator is a type of vault in which users deposit crvUSD, which is re-deposited into scrvUSD for passive yield. Strategists, called Optivisors, use these funds to purchase structured products from SOFA Vaults to maximize capital.
Dual Currency Vault
Dual Currency Vault is a structured product designed by inheriting the SOFA Vault's design principles, using the ERC1155 standard, and implementing it on-chain. Users set an expiration date, anchor price, and the amount of funds they wish to deposit. Market makers will provide quotes for the product, and the user's funds and the premium provided by the market maker will be transferred to the vault. The market maker can exchange the asset for the specified anchor price from the vault before 2 hours after the expiration date (American Option). After the expiration + 2 hours, users can burn their position to receive their funds and earnings.
The contracts of the SOFA repository were reviewed over 5 days. Two auditors performed the code review between 20th January and 24th January 2024. The repository was under active development during the review, but the review was limited to the latest commit 2b45e2bd01141cb6dd8302f8ea8579de0fbc6c9e of the SOFA repo.
Scope
The scope of the review consisted of the following contracts at the specific commit:
contracts/
├── automators
│ ├── bases
│ │ └── CrvUSDAutomatorBase.sol
│ └── vaults
│ └── SimpleSmartTrendVault.sol
├── mocks
│ └── automators
│ └── CrvUSDAutomatorFactory.sol
└── vaults
└── CrvUSDDualVault.sol
After the findings were presented to the SOFA team, fixes were made and included in several PRs.
This review is a code review to identify potential vulnerabilities in the code. The reviewers did not investigate security practices or operational security and assumed that privileged accounts could be trusted. The reviewers did not evaluate the security of the code relative to a standard or specification. The review may not have identified all potential attack vectors or areas of vulnerability.
yAudit and the auditors make no warranties regarding the security of the code and do not warrant that the code is free from defects. yAudit and the auditors do not represent nor imply to third parties that the code has been audited nor that the code is free from defects. By deploying or using the code, SOFA and users of the contracts agree to use the code at their own risk.
Code Evaluation Matrix
Category | Mark | Description |
---|---|---|
Access Control | Good | Correct access control usage is made within the contracts. |
Mathematics | Low | Although simple math relations are used within the in-scope contracts, operations with incorrect rounding directions were identified. |
Complexity | Average | The contracts integrate with Curve's scrvUSD vault to generate yield for the protocol and its users. |
Libraries | Average | The protocol relies heavily on OpenZeppelin's libraries, which were found to be utilized correctly. Additionally, the libraries may be utilized for tasks executed by the protocol's contracts. |
Decentralization | Good | The in-scope contracts require little intervention from privileged actors that aren't necessarily tied to the protocol's team. |
Code stability | Good | The code repository wasn't actively developed during the review. |
Documentation | Low | The methods within the in-scope contracts seldom present in-line comments or NATSPEC documentation, which would improve the contracts' readability. |
Monitoring | Average | The log coverage is acceptable except for one incorrect parameter. |
Testing and verification | Low | The provided contracts have no unit tests. Developing a thorough testing suite for the in-scope contracts is strongly advised. |
Findings Explanation
Findings are broken down into sections by their respective impact:
- Critical, High, Medium, Low impact
- These are findings that range from attacks that may cause loss of funds, impact control/ownership of the contracts, or cause any unintended consequences/actions that are outside the scope of the requirements.
- Gas savings
- Findings that can improve the gas efficiency of the contracts.
- Informational
- Findings including recommendations and best practices.
Critical Findings
1. Critical - Token ID collision enables vault drainage
A collision in how token IDs are generated can be maliciously used by the maker to remove the minter's assets.
Technical Details
The implementation of CrvUSDDualVault.sol uses two different ways of generating the corresponding tokens IDs to minter and maker.
342: function getProductId(uint256 expiry, uint256 anchorPrice, uint256 isMaker) public pure returns (uint256) {
343: return uint256(keccak256(abi.encodePacked(expiry, anchorPrice, isMaker)));
344: }
345:
346: function getMinterProductId(uint256 expiry, uint256 anchorPrice, uint256 premiumPercentage) public pure returns (uint256) {
347: return uint256(keccak256(abi.encodePacked(expiry, anchorPrice, premiumPercentage)));
348: }
349:
The minter gets a token with ID=keccak(expiry, anchorPrice, premiumPercentage)
, and the maker gets one with ID=keccak(expiry, anchorPrice, 1)
. By setting a premiumPercentage
value of 1, their hashes collide, leading to both IDs being the same.
The burn()
function takes the premium percentage as a user-supplied argument. A malicious maker can call this function with premiumPercentage = 1
pretending to be the minter, an action that would be allowed since the token ID will result in the same value as the maker token ID.
This way, an attacker can steal the minter's collateral and quote assets by calling burn()
after maturity. The attacker can also create a fake agreement between a minter and a maker they control to withdraw the provided assets twice, draining the vault.
Proof of Concept
function test_dualVault_collision() public {
uint256 totalCollateral = 1_000e18;
uint256 expiry = block.timestamp + 14 days;
uint256 makerCollateral = 500e18;
uint256 anchorPrice = 3_000e8;
uint256 premiumPercentage = makerCollateral * 1e18 / totalCollateral;
deal(crvUSD, user, totalCollateral - makerCollateral);
deal(crvUSD, maker, makerCollateral);
CrvUSDDualVault.MintParams memory mintParams = CrvUSDDualVault.MintParams({
expiry: expiry,
anchorPrice: anchorPrice,
makerCollateral: makerCollateral,
deadline: expiry,
maker: maker,
makerSignature: ""
});
vm.prank(user);
dualVault.mint(totalCollateral, mintParams, address(0xdead));
vm.warp(expiry + 2 hours);
// Maker steals collateral by calling burn
(bool success,) = Maker(maker).execute(
address(dualVault),
abi.encodeWithSelector(
CrvUSDDualVault.burn.selector,
expiry,
mintParams.anchorPrice,
1
)
);
assertTrue(success, "failed to call burn");
// maker has all the collateral
assertEq(IERC20(crvUSD).balanceOf(maker), totalCollateral);
// burn will fail here, vault is insolvent
vm.expectRevert("insufficient shares to redeem");
vm.prank(user);
dualVault.burn(expiry, anchorPrice, premiumPercentage);
}
Impact
Critical. The attacker can drain assets from existing agreements.
Recommendation
The CrvUSDDualVault
contract ought to differentiate positions assigned to minters or market makers by using an isMaker
flag within the product id calculation, similarly to how SimpleSmartTrendVault.getProductId
does.
To achieve this, the contract could modify its getProductId
method as follows:
- function getProductId(uint256 expiry, uint256 anchorPrice, uint256 isMaker) public pure returns (uint256) {
- return uint256(keccak256(abi.encodePacked(expiry, anchorPrice, isMaker)));
+ function getProductId(uint256 expiry, uint256 anchorPrice, uint256 premiumPercentage, uint256 isMaker) public pure returns (uint256) {
+ return uint256(keccak256(abi.encodePacked(expiry, anchorPrice, premiumPercentage, isMaker)));
This way, usages of the method can distinguish between minter and maker positions, preventing the shown collision from occurring.
Developer Response
Fixed in the commit 2d72ad1abd940d46675921cad07454f85c7e03ab.
High Findings
1. High - CrvUSDDualVault.harvest()
calculates the amount of crvUSD
to be withdrawn incorrectly
CrvUSDDualVault.harvest()
calculates fee = scrvUSD.balanceOf(address(this)) - totalDeposit
, which is incorrect as it's subtracting amounts denominated in different tokens.
Technical Details
scrvUSDBalanceOf(address(this))
is an amount of scrvUSD
tokens, while totalDeposit
is an amount of crvUSD
tokens as amounts of collateral
are added to it within the _mint()
and _mintBatch()
.
Given that YearnV3 vault shares, like scrvUSD
, accrue value regarding the vault's asset, the contract's scrvUSD
balance will not increase to reflect the yield accrued by such a position. As a result, the contract fails to account and withdraw its generated fees.
Impact
High. The CrvUSDDualVault
fee withdrawal flow will malfunction.
Recommendation
The highlighted method should consider the fee accrued as the difference between the value of its scrvUSD
shares in crvUSD
and totalDeposit
, to denominate the calculation entirely in crvUSD
:
interface IScrvUSD {
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
function approve(address spender, uint256 amount) external returns (bool);
+ function previewRedeem(uint256 shares) external view returns(uint256 assets);
}
function harvest() external {
- uint256 fee = scrvUSD.balanceOf(address(this)) - totalDeposit;
+ uint256 balance = scrvUSD.balanceOf(address(this));
+ uint256 fee = scrvUSD.previewRedeem(balance) - totalDeposit;
require(fee > 0, "Vault: zero fee");
scrvUSD.withdraw(fee, feeCollector, address(this));
function totalFee() external view returns (uint256) {
- return scrvUSD.balanceOf(address(this)) - totalDeposit;
+ uint256 balance = scrvUSD.balanceOf(address(this));
+ return scrvUSD.previewRedeem(balance) - totalDeposit;
}
Developer Response
Fixed in the commit c013a2d734565d61def89aa8d728420a082246de.
Medium Findings
1. Medium - Signature replay protection is ineffective against contract signatures
The current signature replay protection could lead to different issues when combined with EIP-1271 signatures.
Technical Details
Signature replay protection is given the SignatureBitMap.sol mixin. Hashed signatures are stored in a mapping and later recovered to check if the signature has been previously used.
While EOA signature malleability is covered by OpenZeppelin's ECDSA library, the implementation also supports EIP-1271 signatures as it uses the SignatureChecker library, which can deal with both EOA and contract signatures.
In the case of contract signatures, malleability protection would depend on the implementation of EIP-1271 for each specific smart account. For example, the Safe wallet implementation checks the signature payload has at least the required length to match the number of signatures but doesn't impose any other restriction over the length (see checkNSignatures()
implementation). A malicious user can reuse the signature by padding the payload with dummy data to generate a different hash.
Contract signatures enable an additional vector attack by maliciously consuming a signature before it is rightfully used. An attacker can deploy a contract whose isValidSignature()
returns ok for any payload and then submit a transaction to the vault using this contract as the maker and the signature they want to invalidate.
Impact
Medium. Although not frequently used, contract signatures could be replayed using the current design. Additionally, an attacker can intentionally grieve other valid EOA signatures by front-running the transaction and consuming the signature before it is used.
Recommendation
Use nonces for replay protection or disable EIP-1271 signatures.
Developer Response
As long as the maker agrees, it is not unacceptable for users to reuse their signatures. Neither the protocol nor the users suffer any loss. The scenario of "reuse the signature by padding the payload with dummy data to generate a different hash" falls strictly under this case.
For "maliciously consuming a signature before it is rightfully used," the attacker gains no real benefit. Moreover, the user can request a new signature to proceed with the transaction.
We support EIP-1271 to accommodate contract users (e.g., multisig wallets) who wish to act as makers. We avoid using a nonce mechanism similar to blockchain transactions because the maker cannot effectively determine which quotes have been submitted on-chain and which have not, making it difficult to assign a meaningful nonce value.
2. Medium - Rounding errors can lead to minor insolvency issues
The collateral and quote payoff calculations in CrvUSDDualVault.sol use an incorrect rounding, leading to small amount differences that could ultimately prevent redeeming the full minted amounts.
Technical Details
When the position is redeemed, the implementation of _burn()
calculates the corresponding amount of collateral and quote assets that should be transferred to the user.
281: collateralPayoff = amount - amount * makerPosition / totalPosition;
282: quoteAssetPayoff = (amount - collateralPayoff) * anchorPrice * quoteAsset.decimals() / collateral.decimals() / PRICE_DECIMALS;
collateralPayoff
is the amount minus the user's share of exchanged collateral, while quoteAssetPayoff
is the user's share of exchanged collateral projected in the quote asset domain.
Note that collateralPayoff
is rounded up. Since amount * makerPosition / totalPosition
rounds down, the subtraction amount - amount * makerPosition / totalPosition
will be rounded up.
Additionally, note that if collateralPayoff
is fixed, this will also impact the calculation of quoteAssetPayoff
, which would incorrectly round up due to the subtraction of amount - collateralPayoff
.
The same issue is present in the batch variant of the burn functionality.
Impact
Medium. Incorrect rounding direction could generate small token insolvency issues.
Recommendation
The calculation of collateralPayoff
should round up the term amount * makerPosition / totalPosition
.
collateralPayoff = amount - Math.mulDiv(amount, makerPosition, totalPosition, Math.Rounding.UP);
The calculation of quoteAssetPayoff
can be simplified as:
quoteAssetPayoff = (amount * makerPosition / totalPosition) * anchorPrice * quoteAsset.decimals() / collateral.decimals() / PRICE_DECIMALS;
Developer Response
Fixed in the commit b31276758289715a1c367bd459cf6be9b33117d0.
Low Findings
1. Low - Missing reentrancy guard in CrvUSDDualVault.sol
Some functions in the contract don't implement reentrancy protection.
Technical Details
Impact
Low.
Recommendation
Add the nonReentrant
modifier.
Developer Response
Fixed in the commit c917dd5675cf95dcc84e1737bdbd9155f44dcff3.
2. Low - EIP712 domain separator isn’t protected from chain forks
SimpleSmartTrendVault
and CrvUSDDualVault
both store their EIP712 domain separators as constants: if the deployment chain were to fork into two separate chains, signatures from the original chain would be valid on the child chain as well.
Technical Details
Both contracts calculate their DOMAIN_SEPARATOR
field during a call to initialize()
. Because such field cannot be modified, the calculated separator will be stored in the proxy's storage as a constant: 1 and 2
In the event of a fork to the chain on which the contracts are deployed, if no action is taken to modify such value via an implementation upgrade, both versions of the contracts will consider signatures signed for the original chain as valid.
Impact
Low.
Recommendation
Use OpenZeppelin's EIP712 library to execute all EIP712-related logic.
Developer Response
Acknowledged.
3. Low - Missing vault whitelist check in CrvUSDAutomatorBase.burnProducts()
CrvUSDAutomatorBase.burnProducts()
fails to verify that each vault it interacts with has been whitelisted within the associated AutomatorFactory
.
Technical Details
While CrvUSDAutomatorBase.mintProducts()
correctly verifies that all vaults are whitelisted, the CrvUSDAutomatorBase.burnProducts()
method fails to do so.
Impact
Low.
Recommendation
Implement the whitelist check used in mintProducts()
also within burnProducts()
.
Developer Response
burnProducts()
does not need to check vaults; positions in non-whitelisted vaults can also be burned, and the proceeds will be counted as revenue.
Gas Saving Findings
1. Gas - Optimize for loops
For loops across all contracts in scope can be optimized to reduce their gas consumption.
Technical Details
For loop, gas consumption can be reduced by:
- Avoiding to initialize the iteration variable to its default value:
for (uint i = 0; ..; ..)
should be replaced withfor (uint i; ..; ..)
- Caching an array's length in a variable:
for(uint i; i < array.length; i++)
should be replaced with
uint256 len = array.length;
for(uint i; i < len; i++)
Impact
Gas optimization.
Recommendation
Implement the suggested modifications to reduce the contracts' gas consumption.
Developer Response
Refactor for loop to save gas in the commit 10dab5df316a696f2fdb4b83c5ce8821f6838658.
Some parts were not optimized due to stack too deep issues after optimization. The existing deployed contracts will not be optimized for now to maintain code consistency.
Informational Findings
1. Informational - Incorrect arguments in FeeCollected
event
The fee
and protocolFee
arguments are always set to zero.
Technical Details
The harvest()
function emits the FeeCollected
event after clearing the totalFee
and totalProtocolFee
variables.
283: function harvest() external nonReentrant {
284: require(totalFee > 0 || totalProtocolFee > 0, "Automator: zero fee");
285: uint256 feeAmount = 0;
286: uint256 protocolFeeAmount = 0;
287: if (totalFee > 0) {
288: feeAmount = scrvUSD.redeem(uint256(totalFee), owner(), address(this));
289: totalFee = 0;
290: }
291: if (totalProtocolFee > 0) {
292: protocolFeeAmount = scrvUSD.redeem(totalProtocolFee, IAutomatorFactory(factory).feeCollector(), address(this));
293: totalProtocolFee = 0;
294: }
295: emit FeeCollected(_msgSender(), feeAmount, totalFee, protocolFeeAmount, totalProtocolFee);
296: }
Impact
Informational.
Recommendation
Store these values in a local variable. This will also improve gas usage.
function harvest() external nonReentrant {
int256 totalFeeCached = totalFee;
uint256 totalProtocolFeeCached = totalProtocolFee;
require(totalFeeCached > 0 || totalProtocolFeeCached > 0, "Automator: zero fee");
uint256 feeAmount = 0;
uint256 protocolFeeAmount = 0;
if (totalFeeCached > 0) {
feeAmount = scrvUSD.redeem(uint256(totalFeeCached), owner(), address(this));
totalFee = 0;
}
if (totalProtocolFeeCached > 0) {
protocolFeeAmount = scrvUSD.redeem(totalProtocolFeeCached, IAutomatorFactory(factory).feeCollector(), address(this));
totalProtocolFee = 0;
}
emit FeeCollected(_msgSender(), feeAmount, totalFeeCached, protocolFeeAmount, totalProtocolFeeCached);
}
Developer Response
Fixed in the commit b95f8188353e7dae0d656c49096e78284926aa84.
2. Informational - Unnecessary native payments implementation
The SimpleSmartTrendVault.sol and CrvUSDDualVault.sol contracts implement the receive()
function despite not requiring native payments.
Technical Details
Impact
Informational.
Recommendation
Remove the receive()
function.
Developer Response
Fixed in the commit 3bb5fdf85cb8a50a9e758cfd86a58c097fa94afe.
Only the Dual Vault has been updated. The SimpleSmartTrendVault is already running on-chain. To maintain code consistency and facilitate the reuse of deployed implementations for similar products, it will not be modified for now.
3. Informational - Use ERC20Upgradeable in CrvUSDAutomatorBase.sol
The contract follows a cloneable pattern, making the upgradeable variant of the library a better fit.
Technical Details
Using the ERC20Upgradeable library instead of ERC20 would allow to properly initialize the name and symbol properties of the token without needing to redefine these.
Impact
Informational.
Recommendation
Use ERC20Upgradeable as the base contract in CrvUSDAutomatorBase.sol.
Developer Response
Acknowledged.
4. Informational - Usage of outdated OpenZeppelin libraries
The protocol makes use of outdated version of the OpenZeppelin libraries.
Technical Details
The protocol uses the following versions of OpenZeppelin's libraries:
"@openzeppelin/contracts": "^4.7.3",
"@openzeppelin/contracts-upgradeable": "^4.8.0",
Which belong to outdated major and minor releases.
Impact
Informational.
Recommendation
The protocol should upgrade the libraries to the latest minor release within the v4
major release: v4.9.6
.
Developer Response
Acknowledged.
Final Remarks
Sofa's crvUSD
automators and vaults allow users to purchase, sell and execute different financial options utilizing the crvUSD
stablecoin. The contracts then rely on scrvUSD
to generate a yield while the options reach maturity, granting rewards to the protocol and its users.
While the audited contracts are relatively similar to the protocol's automators and vaults, which utilize Aave's aTokens, a core difference in how such tokens and scrvUSD
function generated an incorrect accounting issue in the protocol's fee redemption flow.
Additionally, within CrvUSDDualVault
, calculating a product's ID without clear distinction between minter and maker tokens allows a malicious user to drain the vault of its crvUSD
holdings entirely.
The team promptly fixed critical issues, while others were acknowledged but left unchanged to minimize code and contract structure modifications. Given the severity of the findings and the lack of tests, auditors strongly recommend building a testing suite that covers these contracts, particularly the new Dual Vault system.