Organization
- @Ender13120
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Low Risk
1 findings
1 fixed
0 acknowledged
Informational
5 findings
3 fixed
2 acknowledged
Gas Optimizations
1 findings
1 fixed
0 acknowledged
Low Risk1 finding
VaultRouter is susceptible to USD0/USDS relative depeg risk due to assuming 1 USD0 = 1 USDS = 1 USD
Description
- unit of
minTokensToReceive
in deposit (usd0pp => usd0 => susds):SUSDS * (USD0 / USDS)
, while it should ideally be inSUSDS
since tokenOut isSUSDS
after paraswap swap - unit of
minTokensToReceive
in withdraw (susds => usd0 => usd0pp):SUSDS * (USDS / SUSDS)
=USDS
while it should be inUSD0
since tokenOut isUSD0
after paraswap swap
if
USD0 / USDS
:- depegs in either direction more than
max_deviation_bps + withdraw_fee
(1%) threshold - stays depegged for more than cooldown period (60 seconds)
then, vault + router system maybe exposed to some arbitrage risk.
- oracleRate = 1.05, usd0/usds = 1
- deposit 100 suds => 100 shares for 105 usd0
- oracleRate = 1.05, usd0/usds = 0.97
- computed
minTokensToReceive
= 100 * 1.05 = 105 => 103.5 (after adjusting for fees + deviation) - actual
minTokensToReceive
= 103.5 usds converted to usd0 = 103.5 * 1/0.97 = 106.7
(note: this is one of the scenarios, to cover all scenarios exhaustively, all possible combinations of
deposit/withdraw * side_of_depege * magnitude_of_depege * value_of_fee * value_of_deviation
must be considered)- some scenarios may not be profitable due to how params may be set or or direction of depeg which can max lead to reverts of user txns due to less tokens received than min computed
- in some scenarios, risk can be controlled with changing max deviation while in others it cannot because max deviation can't be set above 1
Recommendation
- use appropriate oracles to get market price for
USD0
andUSDS
and adjust by that inminTokensToReceive
calculations
- unit of
Informational5 findings
VaultRouter lacks explicit input token validation in oracle rate calculation
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
phaze
Description
The
_computeMinTokensToReceive()
function in the VaultRouter contract uses an if-else structure that assumes any token that is not USD0 should be treated as sUSDS. This implicit assumption can lead to incorrect calculations if an unexpected token is passed to the function.The current implementation:
if (tokenIn == USD0) { oracleRate = SUSDS.previewMint(1e18); minTokensToReceive = (amountIn * 1e18) / oracleRate;} else { oracleRate = SUSDS.previewRedeem(1e18); minTokensToReceive = (amountIn * oracleRate) / 1e18;}
The else clause will execute for any token that is not USD0, potentially causing incorrect oracle rate calculations for unsupported tokens.
Recommendation
Add explicit validation to ensure only supported tokens are processed:
function _computeMinTokensToReceive( IERC20 tokenIn, uint256 amountIn) internal view returns (uint256 minTokensToReceive){ uint256 oracleRate; if (tokenIn == USD0) { oracleRate = SUSDS.previewMint(1e18); minTokensToReceive = (amountIn * 1e18) / oracleRate;- } else {+ } else if (tokenIn == IERC20(address(SUSDS))) { oracleRate = SUSDS.previewRedeem(1e18); minTokensToReceive = (amountIn * oracleRate) / 1e18;+ } else {+ revert InvalidInputToken(address(tokenIn)); } uint256 maxDeviation = minTokensToReceive * MAX_EXCHANGE_RATE_DEVIATION_BPS / BPS_DIVIDER; return minTokensToReceive - maxDeviation;}
This change improves code safety by:
- Making the supported token types explicit
- Preventing incorrect calculations for unsupported tokens
- Providing clear error messages when invalid tokens are used
- Following defensive programming practices
VaultRouter oracle rate calculation can be simplified
Severity
- Severity: Informational
Submitted by
phaze
Description
The
_computeMinTokensToReceive()
function in the VaultRouter contract currently calculates conversion rates through an intermediate step that can be simplified and potentially reduce rounding errors.The current implementation:
- Retrieves an oracle rate for a fixed amount (1e18)
- Manually calculates the conversion using division and multiplication operations
This approach introduces unnecessary complexity and potential rounding errors compared to directly using the ERC4626 preview functions that are designed for these exact calculations.
Recommendation
Simplify the oracle rate calculation by using the ERC4626 preview functions directly:
function _computeMinTokensToReceive( IERC20 tokenIn, uint256 amountIn) internal view returns (uint256 minTokensToReceive){- uint256 oracleRate;- if (tokenIn == USD0) {- oracleRate = SUSDS.previewMint(1e18);- minTokensToReceive = (amountIn * 1e18) / oracleRate;+ minTokensToReceive = SUSDS.previewDeposit(amountIn); } else {- oracleRate = SUSDS.previewRedeem(1e18);- minTokensToReceive = (amountIn * oracleRate) / 1e18;+ minTokensToReceive = SUSDS.previewRedeem(amountIn); } uint256 maxDeviation = minTokensToReceive * MAX_EXCHANGE_RATE_DEVIATION_BPS / BPS_DIVIDER; return minTokensToReceive - maxDeviation;}
This change:
- Eliminates intermediate rate calculations and manual conversions
- Reduces potential rounding errors by using the vault's native preview functions
- Simplifies the code logic and improves readability
- Leverages the ERC4626 standard's designed functionality for these exact use cases
VaultRouter withdraw function deviates from ERC4626 return convention
Severity
- Severity: Informational
Submitted by
phaze
Description
The
withdraw()
function in VaultRouter returns the amount of USD0PP tokens received (amountUSD0pp
) rather than the number of vault shares burned, which deviates from the standard ERC4626 convention where withdraw functions typically return the shares amount.In the ERC4626 standard, a withdraw function usually returns the number of shares that were redeemed to provide the requested assets. However, the VaultRouter's withdraw function returns the final USD0PP amount that the user receives after the withdrawal and swap process.
This design choice makes sense from a user experience perspective, as users interacting with the router are primarily concerned with the final token amount they receive rather than the intermediate vault share operations.
Recommendation
Consider adding documentation to clarify this design choice:
/// @inheritdoc IVaultRouter+ /// @dev Returns the amount of USD0PP received rather than shares burned,+ /// as users interact with final token amounts rather than vault sharesfunction withdraw( IParaSwapAugustus augustus, uint256 assets, address receiver, uint256 minUSD0PPToReceiveUser, bytes calldata swapData) public whenNotPaused nonReentrant returns (uint256 amountUSD0pp)
This documentation helps developers understand the intentional deviation from the standard ERC4626 pattern and clarifies that the return value represents the user's final token receipt rather than intermediate vault operations.
VaultRouter should include tests for exploit prevention mechanisms
Severity
- Severity: Informational
Submitted by
phaze
Description
The VaultRouter contract has implemented protections against a specific exploit that occurred on May 27, 2025, involving dust swap attacks through manipulated Uniswap pools. While the fix has been implemented through oracle-based minimum amounts and fee mechanisms, the current test suite should include fuzz tests that validate these protections under various scenarios.
The exploit involved:
- Flash loans of large USD0++ amounts
- Creating pools with minimal sUSDS liquidity
- Converting USD0++ to USD0 at 1:1 ratio, then swapping all USD0 for minimal sUSDS
- Extracting USD0 from the pool and converting back to USD0++ at market rates for profit
The implemented fix enforces oracle-based minimum amounts using
sUSDS.previewMint()
with a maximum deviation threshold and withdrawal fees to prevent profitable exploitation.Recommendation
Include a test suite that validates the exploit prevention mechanisms:
// SPDX-License-Identifier: MITpragma solidity 0.8.26; import { BaseIntegrationTest } from "./BaseUnit.integration.t.sol";import { MockAugustus } from "./mocks/MockAugustus.sol";import { IParaSwapAugustus } from "../src/interfaces/IParaSwapAugustus.sol";import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol";import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";import { IUSD0ppMinter } from "../src/interfaces/IUSD0ppMinter.sol";import { VaultRouter } from "../src/VaultRouter.sol";import { StdStorage, stdStorage } from "forge-std/StdStorage.sol";import { console } from "forge-std/console.sol"; import { ADDRESS_AUGUSTUS_REGISTRY, BPS_DIVIDER } from "../src/constants.sol"; /** * @title VulnerabilityDemoTest * @notice Tests demonstrating that the May 27, 2025 exploit vulnerability has been fixed * @dev This test suite validates the hotfix implementation that prevents dust swap attacks */contract VulnerabilityDemoTest is BaseIntegrationTest { using Math for uint256; using stdStorage for StdStorage; MockAugustus public mockAugustus; IERC20 public USDS; function setUp() public override { super.setUp(); USDS = IERC20(IERC4626(address(SUSDS)).asset()); // Deploy and set up mock Augustus mockAugustus = new MockAugustus(); // Mock Augustus registry validation vm.mockCall( ADDRESS_AUGUSTUS_REGISTRY, abi.encodeWithSignature("isValidAugustus(address)", address(mockAugustus)), abi.encode(true) ); // Set unlimited unwrap cap for testing vm.startPrank(unwrapCapAllocator); IUSD0ppMinter(address(USD0PP)).setUnwrapCap( address(router), type(uint256).max ); vm.stopPrank(); // Label contracts for debugging vm.label(address(USD0), "USD0"); vm.label(address(USD0PP), "USD0PP"); vm.label(address(SUSDS), "sUSDS"); vm.label(address(mockAugustus), "mockAugustus"); vm.label(address(router), "router"); vm.label(address(vault), "vault"); } function setMaxExchangeRateDeviationBps(uint256 bps) public { stdstore.target(address(router)).sig( bytes4(keccak256("MAX_EXCHANGE_RATE_DEVIATION_BPS()")) ).checked_write(bps); assertEq(router.MAX_EXCHANGE_RATE_DEVIATION_BPS(), bps); } function setWithdrawFeeRateBps(uint256 bps) public { stdstore.target(address(vault)).sig( bytes4(keccak256("withdrawFeeRateBps()")) ).checked_write(bps); assertEq(vault.withdrawFeeRateBps(), bps); } // Maximum expected USD0++ price deviation uint256 MIN_USD0_TO_USD0PP_PRICE = 0.9e18; // Test the specific exploit scenario with protections enabled function testExploitWithProtectionsEnabled() public { _testExploitWithProtectionsEnabled( 1_900_000e18, // Flash loan amount from actual incident 0.995e18, // Max output deviation ratio (0.5% deviation) 0.97e18, // USD0++ price 0.005e18 // Fee override (0.5% fee) ); } // Fuzz test to validate protections across parameter ranges function testExploitWithProtectionsEnabledFuzz( uint256 L, // Flash loan amount uint256 r, // Max output deviation ratio uint256 p // USD0++ price ) public { _testExploitWithProtectionsEnabled(L, r, p, 0); } // Core test implementation that validates: // - Oracle-based minimum amount enforcement // - Proper fee calculation to prevent profitable exploitation // - Price deviation bounds function _testExploitWithProtectionsEnabled( uint256 L, uint256 r, uint256 p, uint256 feeOverride ) public { vm.startPrank(alice); // Bound parameters to realistic ranges p = bound(p, MIN_USD0_TO_USD0PP_PRICE, 1e18); // 0.9% ≤ p ≤ 100% L = bound(L, 1e18, 1e25); // 1e18 ≤ L ≤ 1e24 r = bound(r, 0.9e18, 0.999e18); // 0.9% ≤ maxDeviation ≤ 99.9% // Calculate minimum fee required to prevent profitable exploitation uint256 f = (1e18 * ((1e18 - p) * (1e18 - r)) + p * r - 1) / (p * r); if (feeOverride > 0) { f = feeOverride; } console.log("L: %18e", L); console.log("r: %18e", r); console.log("p: %18e", p); console.log("f: %18e", f); uint256 skL; uint256 u; { // Set withdrawal fees and maximum output deviation uint256 minWithdrawFeeRateBps = (f * BPS_DIVIDER + 1e18 - 1) / 1e18; uint256 maxOutputDeviationBps = (1e18 - r) * BPS_DIVIDER / 1e18; setWithdrawFeeRateBps(minWithdrawFeeRateBps); setMaxExchangeRateDeviationBps(maxOutputDeviationBps); // Calculate minimum sUSDS required based on oracle rate uint256 t = (L * 1e18 / IERC4626(address(SUSDS)).previewMint(1e18)); skL = t - t * maxOutputDeviationBps / BPS_DIVIDER; u = IERC4626(address(SUSDS)).previewMint(skL); } // Setup tokens and approvals deal(address(USDS), alice, u); deal(address(USD0PP), alice, L); IERC20(USDS).approve(address(SUSDS), u); IERC20(USD0PP).approve(address(router), L); IERC20(vault).approve(address(router), type(uint256).max); // Prepare sUSDS for mock Augustus IERC4626(address(SUSDS)).mint(skL, address(alice)); SUSDS.transfer(address(mockAugustus), skL); // Mock the manipulated pool swap result mockAugustus.setSwapResult( true, address(USD0), L, // Input: L USD0 address(SUSDS), skL, // Output: skL sUSDS address(router) ); // Execute deposit with dust swap attack attempt router.deposit( IParaSwapAugustus(address(mockAugustus)), USD0PP, L, 1, // minSharesReceived 0, // minAmountReceived address(alice), "" ); // Transfer extracted USD0 to attacker using MockAugustus transfer function mockAugustus.transfer(address(USD0), alice, L); skip(61); // Skip cooldown period // Execute withdrawal uint256 sUSDSWithdrawAmt = vault.maxWithdraw(alice); uint256 USDSWithdrawAmt = IERC4626(address(SUSDS)).previewRedeem(sUSDSWithdrawAmt); // Mock fair market rate withdrawal swap deal(address(USD0), address(mockAugustus), USDSWithdrawAmt); mockAugustus.setSwapResult( true, address(SUSDS), sUSDSWithdrawAmt, address(USD0), USDSWithdrawAmt, address(router) ); router.withdraw( IParaSwapAugustus(address(mockAugustus)), sUSDSWithdrawAmt, alice, 0, "" ); // Calculate profit and verify exploit is not profitable int256 profit = int256(USD0.balanceOf(alice)) + int256(USD0PP.balanceOf(alice) * p / 1e18) - int256(u) - int256(L * p / 1e18); assertLt(profit, 0, "Attacker made a profit - exploit protection failed"); }} // MockAugustus with transfer capability for testingcontract MockAugustus { function transfer(address token, address to, uint256 amount) external { IERC20(token).transfer(to, amount); } function setSwapResult(/* swap parameters */) external { // Mock swap implementation }}
The test suite should validate three critical assumptions:
-
USD0PP Price Bounds: Tests should cover the expected maximum price deviation range for USD0PP (e.g., 0.9 to 1.0 ratio)
-
Maximum Output Deviation: Validate that the
MAX_EXCHANGE_RATE_DEVIATION_BPS
setting (currently 0.5%) provides adequate protection across realistic market conditions -
Fee Calculation: Ensure withdrawal fees are calculated correctly using the formula
(1 - p) * (1 - r) / (p * r)
to cover maximum price and output deviation scenarios
These tests provide confidence that the implemented protections effectively prevent the exploit under various market conditions and attack parameters, while ensuring the system remains functional for legitimate users.
General considerations for deployment & upgrade checklist
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Om Parikh
Description
console.logBytes
can be used instead ofbytesToHexString
and casting to string memory to avoid introducing additional dependency for critical function.- use certain script flags/opts when running such as --slow, custom gas price or higher multiplier, --force when running first/non-failed attempt, etc. This could also be added in
foundry.toml
in a separate deployment profile - make sure to verify contracts on etherscan before executing upgrade or setting router
- since vault was paused for long time, and deposit/withdraw actions don't auto-harvest, an explict harvest must be batched with unpause (after initializeV1)
- double check if
harvestFeeRateBps
needs to be changed before unpausing as new invariant$.harvestFeeRateBps <= $.withdrawFeeRateBps
was introduced in PR 62
Gas Optimizations1 finding
VaultRouter should declare MAX_EXCHANGE_RATE_DEVIATION_BPS as immutable
Severity
- Severity: Gas optimization
Submitted by
phaze
Description
The
MAX_EXCHANGE_RATE_DEVIATION_BPS
variable in VaultRouter is set once in the constructor and never modified afterward, but is declared as a regular public state variable. This is inconsistent with its intended usage as an unchangeable configuration parameter.The variable is currently declared under the comment "Mutable constants" but behaves as an immutable value since there are no functions to update it after deployment.
Recommendation
Declare
MAX_EXCHANGE_RATE_DEVIATION_BPS
asimmutable
to make the intended behavior explicit and improve gas efficiency:///*** Mutable constants ***////// @notice The maximum deviation from the oracle rate- uint256 public MAX_EXCHANGE_RATE_DEVIATION_BPS;+ uint256 public immutable MAX_EXCHANGE_RATE_DEVIATION_BPS;
This change:
- Makes the intended immutable behavior explicit in the code
- Saves gas by storing the value in bytecode rather than contract storage
- Improves code clarity by matching the declaration with the actual usage pattern
- Follows Solidity best practices for values that are set once at deployment