metastreet-usdai-contracts
Cantina Security Report
Organization
- @metastreetxyz
Engagement Type
Cantina Reviews
Period
-
Repositories
N/A
Researchers
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
8 findings
6 fixed
2 acknowledged
Informational
10 findings
8 fixed
2 acknowledged
Gas Optimizations
1 findings
1 fixed
0 acknowledged
Medium Risk1 finding
Inflation Attack
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
Anurag Jain
Description
Metastreet has a provision for
LOCKED_SHARES
on first mint (totalSupply==0) to prevent Inflation attacks. But it seems that contract currently misses to mint theseLOCKED_SHARES
to say0xdead
address.Proof of Concept
- User deposits
LOCKED_SHARES+1
convertToShares
is called internally. Since this is first mint (totalSupply==0) soinitialDeposit
is true anddepositSharePrice
will beFIXED_POINT_SCALE
makingshares=assets
.
function convertToShares( uint256 assets ) public view nonZeroUint(assets) returns (uint256) { /* Check if initial deposit */ bool initialDeposit = totalShares() < LOCKED_SHARES; /* Compute shares */ uint256 shares = ((assets * FIXED_POINT_SCALE) / depositSharePrice()); /* Check if initial deposit and shares is less than locked shares */ if (initialDeposit && shares <= LOCKED_SHARES) revert InvalidAmount(); /* Compute shares. If initial deposit, lock subset of shares */ return shares - (initialDeposit ? LOCKED_SHARES : 0); }
- So value returned would be
(LOCKED_SHARES+1)-LOCKED_SHARES = 1
. Thus1
share get minted to User - So,
1
share is now worthLOCKED_SHARES+1
assets - Attacker can now front run any victim deposit by large donation, causing victim to get lower than expected shares especially if deposit was made with min amount
0
Note
POC only shows deposit flow, but same is also true for mint flow. In both cases
LOCKED_SHARES
should get minted to0xdead
address on first mint (totalSupply==0)Impact Explanation
Impact is High since Attacker can perform this attack to cause loss of victim funds
Likelihood Explanation
The likelihood is Low since this can only happen when
totalSupply=0
Recommendation
Mint
LOCKED_SHARES
to say0xdead
address on first mint which makes this attack expensive
Low Risk8 findings
Controller-based DoS vulnerability in redemption requests
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
phaze
Summary
The StakedUSDai contract is vulnerable to a denial-of-service attack where malicious users can prevent specific controllers from creating new redemption requests by exploiting the per-controller limit on active redemptions. This vulnerability could temporarily block legitimate users from redeeming their shares when they attempt to use a targeted controller address.
Description
The StakedUSDai contract's redemption system is vulnerable to a denial-of-service attack where malicious users can block legitimate controllers from creating new redemption requests. The vulnerability stems from the way redemption requests are tracked by controller address rather than the owner of the shares.
In the
_requestRedeem()
function of theRedemptionLogic
library, there's a limit enforced on the number of active redemptions per controller:/* Validate active redemptions count is less than max allowed */if (redemptionState_.redemptionIds[controller].length() == MAX_ACTIVE_REDEMPTIONS_COUNT) { revert IStakedUSDai.InvalidRedemptionState();}
This limit is set to a constant value of 50:
/** * @notice Max active redemptions per controller */uint256 internal constant MAX_ACTIVE_REDEMPTIONS_COUNT = 50;
An attacker can exploit this by creating multiple small redemption requests (e.g., 1 share each) targeting a specific controller address until the maximum limit is reached. Once this happens, any legitimate attempt to create a new redemption request with that controller will fail, effectively blocking the controller from processing further redemptions.
Proof of Concept
- An attacker identifies a controller address (potentially a major protocol integration or a significant user)
- The attacker creates 50 separate redemption requests with 1 share each using the targeted controller address
- When the legitimate controller tries to create a new redemption request, the transaction will revert with
InvalidRedemptionState
- The controller is effectively blocked from creating new redemption requests until some of the existing ones are fully processed and removed
Impact Explanation
The impact of this vulnerability is medium. While it doesn't directly lead to the loss of funds, it can significantly disrupt the normal operation of the protocol by preventing legitimate users from initiating redemption requests through specific controllers. This could result in users being temporarily unable to redeem their shares, affecting the liquidity and usability of the protocol. In scenarios where timely redemptions are critical (such as during market volatility), this denial of service could lead to indirect financial losses for affected users.
Likelihood Explanation
The likelihood is low. The attack requires an attacker to pay gas for multiple transactions, and provides no direct financial gain. Additionally, users can choose different controllers, and the attack effect is temporary as redemptions are eventually serviced.
Recommendation
Consider refactoring the code to track redemption requests by the caller rather than the controller address. This would isolate redemption requests from different users and prevent targeted DoS attacks.
function _requestRedeem( StakedUSDaiStorage.RedemptionState storage redemptionState_, uint64 timelock_, uint256 shares, address controller) external returns (uint256) { /* Validate active redemptions count is less than max allowed */- if (redemptionState_.redemptionIds[controller].length() == MAX_ACTIVE_REDEMPTIONS_COUNT) {+ if (redemptionState_.redemptionIds[msg.sender].length() == MAX_ACTIVE_REDEMPTIONS_COUNT) { revert IStakedUSDai.InvalidRedemptionState(); } /* Rest of the function... */ /* Add redemption ID */- redemptionState_.redemptionIds[controller].add(redemptionId);+ redemptionState_.redemptionIds[msg.sender].add(redemptionId); return redemptionId;}
The rest of the redemption logic needs to be adapted accordingly as well.
Redemption queue stalling through small requests
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
phaze
Summary
The StakedUSDai contract is vulnerable to a queue stalling attack where malicious users can create multiple small redemption requests to delay the processing of legitimate redemptions, forcing admins to waste gas on processing these low-value requests.
Description
The redemption system in StakedUSDai processes redemption requests in a first-in-first-out (FIFO) queue manner. However, there is no minimum threshold for the size of a redemption request. This allows an attacker to create many small redemption requests (e.g., 1 share each) that must be processed before later, legitimate redemption requests.
When admins call the
serviceRedemptions()
function to process pending redemptions, they have to process the queue in order. Small redemption requests at the front of the queue force admins to spend gas processing these low-value transactions before getting to larger, legitimate redemption requests further back in the queue.The relevant code in the
_processRedemptions()
function shows how redemptions are processed in FIFO order:/* Process redemptions */uint256 remainingShares = shares;uint256 amountProcessed;while (remainingShares > 0 && head != 0) { /* Get redemption */ IStakedUSDai.Redemption storage redemption_ = redemptionState_.redemptions[head]; /* Compute shares to fulfill */ uint256 fullfilledShares = Math.min(redemption_.pendingShares, remainingShares); /* ... processing logic ... */ /* If redemption is completely fulfilled, update head */ if (redemption_.pendingShares == 0) head = redemption_.next;}
Impact Explanation
The impact is low to medium. This vulnerability cannot permanently block redemptions, but it can delay the processing of legitimate redemption requests, causing inconvenience to users and increasing gas costs for admins. The attack only delays rather than prevents redemptions, as the queue will eventually be processed in full.
Likelihood Explanation
The likelihood is low. The attack requires creating multiple transactions that provide no direct financial gain to the attacker. The gas costs of creating numerous small redemption requests are substantial, making this attack economically unattractive except as a griefing attack.
Recommendation
Implement a minimum threshold for redemption requests to prevent excessively small requests from entering the queue:
function _requestRedeem( StakedUSDaiStorage.RedemptionState storage redemptionState_, uint64 timelock_, uint256 shares, address controller) external returns (uint256) { /* Validate active redemptions count is less than max allowed */ if (redemptionState_.redemptionIds[controller].length() == MAX_ACTIVE_REDEMPTIONS_COUNT) { revert IStakedUSDai.InvalidRedemptionState(); } + /* Enforce minimum share amount for redemption requests */+ if (shares < 1e18) {+ revert IStakedUSDai.InvalidAmount();+ } /* Rest of the function... */}
Deviations from ERC-7540 specification
Severity
- Severity: Low
Submitted by
phaze
Description
The StakedUSDai contract implements many aspects of the ERC-7540 asynchronous ERC-4626 standard, but contains several deviations from the specification which could affect interoperability with other systems expecting full compliance. These deviations are as follows:
-
Missing ERC-7575 support: The ERC-7540 specification mandates support for ERC-7575, particularly the
share()
method. The current implementation does not include this method or explicitly inherit from an ERC-7575 interface. -
Incomplete ERC-165 implementation: While the contract implements
supportsInterface()
, it does not check for the ERC-7575 interface ID (0x2f0a18c5) as required by ERC-7540. -
Non-standard request ID system: The contract uses a redemption queue with a linked-list data structure instead of the simpler request ID system described in the specification. Additionally, the redemption service mechanism doesn't explicitly ensure that all requests with the same ID become claimable at the same pro-rata rate.
-
Incomplete requestRedeem approval logic: The ERC-7540 specification states that redemption request approval may come either from ERC-20 approval over the shares of the owner or from operator approval. The current implementation checks for operator approval but does not utilize standard ERC-20 allowances when the caller is neither the owner nor an approved operator.
-
Incorrect Withdraw event parameter ordering: The spec requires that when the Withdraw event is emitted, the first parameter should be the controller and the second parameter should be the receiver. The current implementation emits
Withdraw(msg.sender, controller, receiver, amount, shares)
, which places the controller in the third position rather than the first.
Recommendation
Consider the following changes to ensure full compliance with the ERC-7540 specification:
-
Implement the ERC-7575 interface, particularly the
share()
method. -
Update the
supportsInterface()
method to check for the ERC-7575 interface ID (0x2f0a18c5). -
Consider aligning the request ID implementation with the ERC-7540 specification, ensuring requests with the same ID become claimable at the same pro-rata rate.
-
Consider adapting the
requestRedeem()
approval logic to check ERC-20 allowances when the caller is not the owner or an approved operator. -
Adjust the Withdraw event parameters to follow the ordering specified in ERC-7540, with controller as the first parameter and receiver as the second.
Rounding in minimum amount calculation can lead to higher slippage than expected
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
phaze
Summary
In the USDai contract, the
_deposit()
function uses_unscale()
to convert the minimum USDai amount to base token amount when performing swaps. Since_unscale()
rounds down by using integer division, users may receive less USDai than their specified minimum amount, allowing for higher slippage than intended.Description
The issue occurs in the
_deposit()
function when handling deposits of non-base tokens:if (depositToken != address(_baseToken)) { /* Approve the adapter to spend the token in */ IERC20(depositToken).approve(address(_swapAdapter), depositAmount); /* Swap in deposit token for base token */ usdaiAmount = _scale(_swapAdapter.swapIn(depositToken, depositAmount, _unscale(usdaiAmountMinimum), data));}
The
_unscale()
function performs integer division which always rounds down:function _unscale(uint256 value) public view returns (uint256) { return value / _scaleFactor();}
For example, if:
- A user sets
usdaiAmountMinimum = 1.111111555555555555e18
_scaleFactor() = 1e12
(for a 6 decimal base token)_unscale(usdaiAmountMinimum) = 1111111
(rounded down from 1.111111555555)
This means the actual minimum amount used in the swap will be lower than intended, potentially allowing for higher slippage than the user specified.
Impact Explanation
The impact is low as this only results in minor increases in potential slippage and does not lead to significant loss of funds. The maximum additional slippage is bounded by the scaling factor (e.g., 1e-12 for 6 decimal tokens).
Likelihood Explanation
The likelihood is medium as this will affect any deposit transaction where the minimum amount is not cleanly divisible by the scaling factor, which is common with calculated minimum amounts that include slippage tolerances.
Recommendation
Modify the
_unscale()
function to round up when used for minimum amounts:+ /// @notice Helper function to scale down a value, rounding up+ /// @param value Value+ /// @return Unscaled value rounded up+ function _unscaleUp(uint256 value) public view returns (uint256) {+ return (value + _scaleFactor() - 1) / _scaleFactor();+ }function _deposit( address depositToken, uint256 depositAmount, uint256 usdaiAmountMinimum, address recipient, bytes calldata data) internal nonZeroUint(depositAmount) nonZeroAddress(recipient) returns (uint256) { // ... if (depositToken != address(_baseToken)) { IERC20(depositToken).approve(address(_swapAdapter), depositAmount);- usdaiAmount = _scale(_swapAdapter.swapIn(depositToken, depositAmount, _unscale(usdaiAmountMinimum), data));+ usdaiAmount = _scale(_swapAdapter.swapIn(depositToken, depositAmount, _unscaleUp(usdaiAmountMinimum), data)); } // ...}
This ensures that the minimum amount used in swaps is always rounded up, providing at least the slippage protection requested by users.
Blacklisted User redemption cannot be skipped
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Anurag Jain
Description
If a user request redeem tokens and Metastreet figures it to be a malicious request, then blacklisting this user wont help and project will be forced to allocate funds for the blacklisted request
This is because, all redemptions are processed from head to tail and there is no provision to skip any redemption id. This means that all redemption made post Attacker redemption can only be completed once Attacker redemption is completed
Proof of Concept
- User
A
request redemption of its100
staked token - User
B
request redemption of its50
staked token - Metastreet figures
A
to be a malicious user and blacklist it - Startegy Admin calls
serviceRedemptions
to process redemption request - Both User
A
&B
transaction are in redemption queue. - Redemption starts from head to tail and currently there is no way to skip any redemption request
- Thus User
A
(who is blacklisted) request need to be redeemed before UserB
redemption can happen
while (remainingShares > 0 && head != 0) { /* Get redemption */ IStakedUSDai.Redemption storage redemption_ = redemptionState_.redemptions[head]; /* Compute shares to fulfill */ uint256 fullfilledShares = Math.min(redemption_.pendingShares, remainingShares); /* Compute amount to fulfill */ uint256 fullfilledAmount = Math.mulDiv(fullfilledShares, redemptionSharePrice_, FIXED_POINT_SCALE); /* Update redemption pending, redeemable shares, and withdrawable amount */ redemption_.pendingShares -= fullfilledShares; redemption_.redeemableShares += fullfilledShares; redemption_.withdrawableAmount += fullfilledAmount; /* Update remaining shares and amount processed */ remainingShares -= fullfilledShares; amountProcessed += fullfilledAmount; /* Emit RedemptionProcessed */ emit IStakedUSDai.RedemptionProcessed( head, redemption_.controller, fullfilledShares, fullfilledAmount, redemption_.pendingShares ); /* If redemption is completely fulfilled, update head */ if (redemption_.pendingShares == 0) head = redemption_.next; }
Impact Explanation
Impact will be Medium since:
- If Admin decides to not allocate funds for Blacklisted user redemption then any future redemption request post Blacklisted user request will DOS since redemption always happen from head to tail
- If Admin allocates USDai funds for Blacklisted user redemption then these funds will remain locked in contract with no way of recovery
Likelihood Explanation
The likelihood is Low as Blacklisting feature is still on its early implementation phase.
Recommendation
Admin should be able to provide blacklisted id(s) which are meant to be skipped while performing
serviceRedemptions
Blacklisting can be bypassed
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Anurag Jain
Description
Blacklisting can be bypassed by simply transferring/bridging shares from blacklisted to non-blacklisted user. Contract currently misses any restrictions on :
- Mint/Burn by Bridge: Blacklisted user can simply bridge there tokens to another chain. Since
mint
andburn
does not have blacklist checks, thus bridging is completed without any restrictions. Once bridging is complete, the bridged asset has no restrictions - Transfer/TransferFrom: Blacklisted user can simply use
transfer/transferFrom
for transferring its token to another whitelisted address
Proof of Concept
- Observe that both
mint
andburn
function misses blacklisting checks
function mint(address to, uint256 amount) external onlyRole(BRIDGE_ADMIN_ROLE) { /* Mint supply */ _mint(to, amount); /* Update bridged supply */ _getBridgedSupplyStorage().bridgedSupply -= amount; } /** * @inheritdoc IMintable */ function burn(address from, uint256 amount) external onlyRole(BRIDGE_ADMIN_ROLE) { /* Burn supply */ _burn(from, amount); /* Update bridged supply */ _getBridgedSupplyStorage().bridgedSupply += amount; }
- Similarly, observe that
_transfer
or_update
function is not overridden and no restriction is placed on transfer/transferFrom
Impact Explanation
Impact is Medium. User can bypass blacklisting by transferring or bridging funds from blacklisted account to whitelisted account.
Likelihood Explanation
The likelihood is Low as Blacklisting feature is still on its early implementation phase.
Recommendation
Override
_update
function to check blacklisting onmsg.sender
,from
,to
address, if any is blacklisted then revertStale price check is missing in Oracle
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Anurag Jain
Description
The contract uses
tokenPriceFeed.latestRoundData()
to get latest price, but does not validate theupdatedAt
timestamp. This can allow stale prices to be used in worst scenario/* Get token price */ (, int256 tokenPrice,,,) = tokenPriceFeed.latestRoundData(); uint8 tokenDecimals = tokenPriceFeed.decimals(); tokenPrice = _scalePrice(tokenPrice, tokenDecimals); /* Get M NAV price with [1](https://cantina.xyz/code/97d21db4-66af-4775-aac0-9d9ee4f0da0c/src/oracles/ChainlinkPriceOracle.sol?comment_id=25a2e93a-eb98-4452-a4a9-c5517d6b91b9&lines=235,240)0 ** _mNavDecimals as ceiling */ (, int256 mNavPrice,,,) = _mNavPriceFeed.latestRoundData(); mNavPrice = _scalePrice(mNavPrice < M_PRICE_CEILING ? mNavPrice : M_PRICE_CEILING, _mNavDecimals); return (tokenPrice * USDAI_SCALING_FACTOR) / mNavPrice;
Recommendation
Check for price staleness using below logic
require(updatedAt >= block.timestamp - MAX_DELAY, "Stale price");
Asset amounts should be rounded in vault's favor for deposits
Severity
- Severity: Low
Submitted by
phaze
Description
When converting shares to assets during the mint process, the current implementation rounds down which slightly favors depositors. While the impact is minimal due to share price calculations and zero amount checks, it's safer to consistently round in the vault's favor.
While rounding down slightly favors the depositor (they could get a maximum discount of 1 asset unit), this is generally acceptable because:
- The deposit share price calculation already favors the vault
- The magnitude of potential value capture is limited to 1 asset unit per deposit
- The check for
amount == 0
prevents extreme rounding cases - This will become even less significant when proper minimum share requirements are implemented
However, for consistency and maximum safety, it is recommended to always round in the vault's favor.
Recommendation
Modify the share to asset conversion to round up for deposits:
- uint256 amount = convertToAssets(shares);+ // Round up required assets for deposits to favor the vault+ uint256 amount = _convertToAssets(shares, Math.Rounding.Up);/* If amount is 0 or more than max amount, revert */if (amount == 0 || amount > maxAmount) revert InvalidAmount(); /* Mint shares */_mint(receiver, shares); /* Deposit assets */IERC20(asset()).safeTransferFrom(msg.sender, address(this), amount);
This change ensures the vault always receives at least the required assets to back issued shares, maintaining a more conservative approach to rounding in the vault's favor.
Informational10 findings
SafeERC20 methods should be used consistently for token operations
Severity
- Severity: Informational
Submitted by
phaze
Description
The contracts have inconsistent usage of SafeERC20 methods for token operations:
- In USDai's
_deposit()
function:
IERC20(depositToken).transferFrom(msg.sender, address(this), depositAmount);IERC20(depositToken).approve(address(_swapAdapter), depositAmount);
- In USDai's
_withdraw()
function:
_baseToken.approve(address(_swapAdapter), baseTokenAmount);IERC20(withdrawToken).transfer(recipient, withdrawAmount);
- In UniswapV3SwapAdapter's
swapIn()
function:
IERC20(inputToken).transferFrom(msg.sender, address(this), inputAmount);IERC20(inputToken).approve(address(_swapRouter), inputAmount);
- In UniswapV3SwapAdapter's
swapOut()
function:
_baseToken.transferFrom(msg.sender, address(this), baseAmount);_baseToken.approve(address(_swapRouter), baseAmount);
Some tokens like USDT require approvals to be set to 0 first before setting a new non-zero value. Additionally, some tokens don't return a boolean value on transfers as required by the ERC20 standard, or may revert silently on failure. While these issues are not currently problems with the specific tokens used, using OpenZeppelin's SafeERC20 methods (
safeTransfer()
,safeTransferFrom()
, andforceApprove()
) would make the contracts more robust against potential future changes or non-standard tokens.Recommendation
Use SafeERC20's methods consistently throughout the contracts:
- For USDai, update all token operations:
contract USDai is ... { using SafeERC20 for IERC20; function _deposit(...) internal ... { // ...- IERC20(depositToken).transferFrom(msg.sender, address(this), depositAmount);+ IERC20(depositToken).safeTransferFrom(msg.sender, address(this), depositAmount); // ...- IERC20(depositToken).approve(address(_swapAdapter), depositAmount);+ IERC20(depositToken).forceApprove(address(_swapAdapter), depositAmount); // ... } function _withdraw(...) internal ... { // ...- _baseToken.approve(address(_swapAdapter), baseTokenAmount);+ _baseToken.forceApprove(address(_swapAdapter), baseTokenAmount); // ...- IERC20(withdrawToken).transfer(recipient, withdrawAmount);+ IERC20(withdrawToken).safeTransfer(recipient, withdrawAmount); // ... }}
- For UniswapV3SwapAdapter, update all token operations:
contract UniswapV3SwapAdapter is ISwapAdapter, AccessControl { using EnumerableSet for EnumerableSet.AddressSet; using SafeERC20 for IERC20; function swapIn(...) external ... { // ...- IERC20(inputToken).transferFrom(msg.sender, address(this), inputAmount);+ IERC20(inputToken).safeTransferFrom(msg.sender, address(this), inputAmount); // ...- IERC20(inputToken).approve(address(_swapRouter), inputAmount);+ IERC20(inputToken).forceApprove(address(_swapRouter), inputAmount); // ... } function swapOut(...) external ... { // ...- _baseToken.transferFrom(msg.sender, address(this), baseAmount);+ _baseToken.safeTransferFrom(msg.sender, address(this), baseAmount); // ...- _baseToken.approve(address(_swapRouter), baseAmount);+ _baseToken.forceApprove(address(_swapRouter), baseAmount); // ... }}
- Similar changes (
approve
->forceApprove
) should also be applied to BasePositionManager and PoolPositionManager contracts.
These changes ensure compatibility with non-standard ERC20 tokens and improve the overall safety of token operations, making the contracts more robust for future integrations.
Improve yield harvesting function separation and accuracy
Severity
- Severity: Informational
Submitted by
phaze
Description
The
harvestBaseYield()
function in BasePositionManager combines two distinct operations:- Claiming accrued yield from both the contract and USDai addresses
- Converting wrapped mTokens to USDai
Additionally, there are several minor issues with the current implementation:
- The event emission is misleading as base yield can be harvested outside this function by anyone
- The function name doesn't accurately reflect its primary purpose of depositing/converting tokens
- The emitted amount may be inaccurate due to scaling operations and minimum amount differences
Recommendation
Split the functionality into separate functions and improve accuracy:
- function harvestBaseYield(uint256 usdaiAmount) external onlyRole(STRATEGY_ADMIN_ROLE) nonReentrant {+ /// @notice Claims accrued yield from both addresses+ function claimBaseYield() external { /* Claim yield */ _wrappedMToken.claimFor(address(_usdai)); _wrappedMToken.claimFor(address(this));+ emit BaseYieldClaimed();+ }+ /// @notice Converts wrapped mTokens to USDai+ /// @param usdaiAmount Amount of USDai to receive (will be scaled)+ /// @return Amount of USDai actually received+ function depositBaseToken(uint256 usdaiAmount) external onlyRole(STRATEGY_ADMIN_ROLE) nonReentrant returns (uint256) { /* Scale down the USDai amount */ uint256 wrappedMAmount = _unscale(usdaiAmount); /* Validate balance */ if (wrappedMAmount > _wrappedMToken.balanceOf(address(this))) { revert InsufficientBalance(); } /* Approve wrapped M token to spend USDai */ _wrappedMToken.approve(address(_usdai), wrappedMAmount); /* Swap wrapped M token to USDai */- _usdai.deposit(address(_wrappedMToken), wrappedMAmount, 0, address(this));+ uint256 receivedAmount = _usdai.deposit(address(_wrappedMToken), wrappedMAmount, 0, address(this));- /* Emit BaseYieldHarvested */- emit BaseYieldHarvested(usdaiAmount);+ /* Emit BaseTokenDeposited with actual received amount */+ emit BaseTokenDeposited(receivedAmount);+ return receivedAmount;} event BaseYieldClaimed();- event BaseYieldHarvested(uint256 amount);+ event BaseTokenDeposited(uint256 receivedAmount);
DOS on removing supportedToken under certain scenarios
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Medium Submitted by
Anurag Jain
Description
If a token used in an active pool with pending shares is removed then any attempt to retrieve its price via
_priceOracle.price
will revert. This happens since_priceOracle.price
reverts if providedtoken
is not supportedfunction price( address token_ ) external view returns (uint256) { /* Validate token is supported */ if (!supportedToken(token_)) revert UnsupportedToken(token_);
Proof of Concept
- Strategy Admin deposits amount
5
in PoolP1
with currency tokenCurr1
- Admin observe issue with the pool and calls
setTokenPriceFeeds
withpriceFeeds_
forCurr1
asaddress(0)
function _setTokenPriceFeed(address[] memory tokens_, address[] memory priceFeeds_) internal { /* Validate tokens and price feeds */ if (tokens_.length != priceFeeds_.length) { revert InvalidLength(); } /* Set token price feeds */ for (uint256 i = 0; i < tokens_.length; i++) { /* Validate token */ if (tokens_[i] == address(0)) revert InvalidAddress(); if (priceFeeds_[i] != address(0)) { /* Add token */ _tokens.add(tokens_[i]); } else { /* Remove token */ _tokens.remove(tokens_[i]); } /* Set token price feed which can be zero address if it is for unsetting */ _priceFeeds[tokens_[i]] = AggregatorV3Interface(priceFeeds_[i]); /* Emit event */ emit TokenPriceFeedSet(tokens_[i], priceFeeds_[i]); } }
- This removes
Curr1
from supported token - Strategy Admin calls
poolRedeem
to redeem all5
amount from the impacted pool - This schedules a pending request which is meant to be fulfilled at future time
X
function poolRedeem( address pool, uint128 tick, uint256 shares ) external onlyRole(STRATEGY_ADMIN_ROLE) nonReentrant returns (uint128) { /* Redeem */ uint128 redemptionId = IPool(pool).redeem(tick, shares); /* Add redemption ID */ _getPoolsStorage().position[pool].redemptionIds[tick].add(redemptionId); /* Emit PoolRedeemed */ emit PoolRedeemed(pool, tick, shares, redemptionId); return redemptionId; }
- From now till
X
, all staked USDai operation fails since_assets
function fails. Below flow shows the issue:
_assets -> PoolPositionManager._assets(valuationType) -> Navigate pools -> _getTickPosition -> Get shares for pool linked with `Curr1` -> Calculate NAV -> _value(Curr1,...) -> _priceOracle.price(Curr1) -> if (!supportedToken(token_)) revert UnsupportedToken(token_) -> Reverts
Impact Explanation
Impact is Medium since this will cause multiple Staked USDai operations to fail. Severity is marked Informational since this requires Admin to perform error
Likelihood Explanation
The likelihood is Low since this would require Strategy Admin to remove a token used in pool with pending shares
Recommendation
Add a check on
setTokenPriceFeeds
to ensure that token removed is not used in any active pool with pending sharesIntroduce Separate Role for Unpausing the Contract
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Anurag Jain
Description
Currently both
pause
andunpause
operation can be performed byPAUSE_ADMIN_ROLE
. For improved access control and clearer separation of responsibilities, it would be beneficial to introduce a new roleUNPAUSER_ADMIN_ROLE
which would be solely responsible for unpausing the contract.Recommendation
Introduce a dedicated
UNPAUSER_ADMIN_ROLE
and restrict theunpause
operation to this role only.Unrequired approval for USDai
Severity
- Severity: Informational
Submitted by
Anurag Jain
Description
When Strategy Admin deposit on Pool then
USDai
is withdrawn in exchange for the required pool currency. Thewithdraw
function burns providedUSDai
token and never transfers theUSDai
token. This means no approval is required for spendingUSDai
However,
poolDeposit
gives this unrequired approvalfunction poolDeposit( address pool, uint128 tick, uint256 usdaiAmount, uint256 poolCurrencyAmountMinimum, uint256 minShares, bytes calldata data ) external onlyRole(STRATEGY_ADMIN_ROLE) nonReentrant returns (uint256) {.../* Approve USDai */ _usdai.approve(address(_usdai), usdaiAmount); /* Swap USDai to pool currency token */ uint256 poolCurrencyAmount = _usdai.withdraw(poolCurrency, usdaiAmount, poolCurrencyAmountMinimum, address(this), data);...
Recommendation
Make below changes
-- _usdai.approve(address(_usdai), usdaiAmount);
Unrequired check placed on convertToAssets
Severity
- Severity: Informational
Submitted by
Anurag Jain
Description
Below check is added on both
convertToShares
andconvertToAssets
/* Check if initial deposit and shares is less than locked shares */ if (initialDeposit && shares <= LOCKED_SHARES) revert InvalidAmount();
Now,
-
convertToShares
is used indeposit
function where User defines a amount and get back shares worth that amount. If its initial mint then user gets shares worthamount-LOCKED_SHARES
. In this case it is important that(amount=shares) <= LOCKED_SHARES
else it would underflow -
convertToAssets
is used inmint
function where User defines a share to receive . If its initial mint then user gets the shares specified but has to pay extra amount of(amount=shares)+LOCKED_SHARES
. In this case it does not matter to checkshares <= LOCKED_SHARES
Thus in case of deposit, if amount was
LOCKED_SHARES+1
, user gets 1 share (assuming inflation issue is fixed) However in case of mint, if share asked was1
, he would be asked to payLOCKED_SHARES+1
which is correct. But this fails since1<=LOCKED_SHARES
Recommendation
Remove below condition from
convertToAssets
/* Check if initial deposit and shares is less than locked shares */ if (initialDeposit && shares <= LOCKED_SHARES) revert InvalidAmount();
Bridge Minting and Burning Should Be Disabled When Contract Is Paused
Severity
- Severity: Informational
Submitted by
Anurag Jain
Description
If Staked USDai is paused then
mint
andburn
via Bridge should also be paused. In case if this restriction is meant to be placed on Metastreet UI then this can be skippedRecommendation
Add
whenNotPaused
to functions below in Staked USDai contract:function mint(address to, uint256 amount) external onlyRole(BRIDGE_ADMIN_ROLE) { ... } function burn(address from, uint256 amount) external onlyRole(BRIDGE_ADMIN_ROLE) { ... }
Note
Regular token transfer will not be paused as confirmed by team
Ensure Price feed decimals are not greater than 18
Severity
- Severity: Informational
Submitted by
Anurag Jain
Description
Any token whose oracle price feed decimals is more than 18, if added by Admin will fail while retrieving price. This happens since all scaling is done till USDai decimals and any decimals above that will cause underflow error as shown in below function
function _scalePrice(int256 price_, uint8 priceDecimals) internal pure returns (int256) { return price_ * int256(10 ** uint256(USDAI_DECIMALS - priceDecimals)); }
Recommendation
While adding new token's price feed, check that
priceFeeds_[i].decimals()<=18
Missing minimum amount validation for base token withdrawals
Severity
- Severity: Informational
Submitted by
phaze
Description
In the USDai contract's
_withdraw()
function, the minimum amount check is only performed when withdrawing non-base tokens through the swap adapter. When withdrawing the base token directly, thewithdrawAmountMinimum
parameter is ignored:if (withdrawToken != address(_baseToken)) { uint256 baseTokenAmount = _unscale(usdaiAmount); _baseToken.approve(address(_swapAdapter), baseTokenAmount); withdrawAmount = _swapAdapter.swapOut(withdrawToken, baseTokenAmount, withdrawAmountMinimum, data);} else { withdrawAmount = _unscale(usdaiAmount); // withdrawAmountMinimum not checked}
While the conversion from USDai to base token is deterministic due to the fixed scaling factor, for consistency and explicit behavior documentation, this should either be validated or documented.
Recommendation
Either add the validation:
} else { withdrawAmount = _unscale(usdaiAmount);+ if (withdrawAmount < withdrawAmountMinimum) revert SlippageExceeded();}
Or add a notice in the function documentation:
/** * @notice Withdraw * @param withdrawToken Withdraw token * @param usdaiAmount USD.ai amount * @param withdrawAmountMinimum Minimum withdraw amount+ * @dev withdrawAmountMinimum is only checked for non-base token withdrawals as base token + * conversion is exact based on the scaling factor */function _withdraw(
The recommendation depends on the desired behavior:
- Adding the check provides stronger guarantees and consistency
- Adding documentation makes the behavior explicit while avoiding gas costs for an almost-always-passing check
Missing validation for token removal in ChainlinkPriceOracle
Severity
- Severity: Informational
Submitted by
phaze
Description
In the ChainlinkPriceOracle's
_setTokenPriceFeed()
function, when removing a token (setting its price feed to address(0)), the code does not validate whether the token exists in the set:if (priceFeeds_[i] != address(0)) { /* Add token */ _tokens.add(tokens_[i]);} else { /* Remove token */ _tokens.remove(tokens_[i]); // Returns false if token doesn't exist}
Attempting to remove a non-existent token fails silently as
_tokens.remove()
returns false without reverting. This could mask configuration errors where an admin attempts to remove a token that isn't actually registered.Recommendation
Add explicit validation for token removal:
if (priceFeeds_[i] != address(0)) { /* Add token */ _tokens.add(tokens_[i]);} else { /* Remove token */- _tokens.remove(tokens_[i]);+ bool removed = _tokens.remove(tokens_[i]);+ if (!removed) revert TokenNotFound(tokens_[i]);}
Also add the error:
error TokenNotFound(address token);
This change ensures that attempting to remove non-existent tokens results in a clear error rather than failing silently.
Gas Optimizations1 finding
Cache decimals scaling factor as immutable
Severity
- Severity: Gas optimization
Submitted by
phaze
Description
The
_scaleFactor()
function in USDai makes an external call to get the base token's decimals every time scaling is needed:function _scaleFactor() internal view returns (uint256) { return 10 ** (18 - IERC20Metadata(address(_baseToken)).decimals());}
Since ERC20 decimals are (generally) immutable, this value can be calculated once during construction and stored as an immutable variable to save gas on repeated external calls.
Recommendation
Cache the scale factor during construction:
contract USDai is ... {+ uint256 private immutable _SCALE_FACTOR; constructor(address swapAdapter_) { _disableInitializers(); _swapAdapter = ISwapAdapter(swapAdapter_); _baseToken = IERC20(_swapAdapter.baseToken());+ _SCALE_FACTOR = 10 ** (18 - IERC20Metadata(address(_baseToken)).decimals()); } - function _scaleFactor() internal view returns (uint256) {- return 10 ** (18 - IERC20Metadata(address(_baseToken)).decimals());- }+ function _scaleFactor() internal view returns (uint256) {+ return _SCALE_FACTOR;+ }}
This change eliminates repeated external calls to
decimals()
, reducing gas costs for operations.