As of 21 Oct 2026, Sturdy has 4 audit reports by 3 security firms on Trustblock; the latest is by ChainSecurity, dated 13 Oct 2023. Those reports list no open critical findings.
Record last updated . It holds audits and findings, contracts and incidents up to Oct 2026. Trustblock does not review code.
Newest first
| Audit | Security firm | Date | Scope | Critical findings | Report |
|---|---|---|---|---|---|
| Sturdy Aggregator | Security firm: ChainSecurity | Date: | Scope: Files named in the report | No open critical findings | Open · chainsecurity.com |
| Sturdy security assessment | Security firm: Zellic | Date: | Scope: Not recorded (see the report) | No open critical findings | Open · GitHub |
| Sturdy (Aura Integration And Leverage) | Security firm: Quantstamp | Date: | Scope: Files named in the report | No open critical findings | Open · certificate.quantstamp.com |
| Sturdy | Security firm: Quantstamp | Date: | Scope: Files named in the report | No open critical findings | Open · certificate.quantstamp.com |
Statuses are the security firm’s own, from each report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 1 | 0 | 1 |
| Medium | 3 | 2 | 10 |
| Low | 1 | 25 | 22 |
In this audit, we found an exploit path in which the attacker can drain the collateral of other users. The `GeneralLevSwap` contract does not use the `ReentrancyGuard` (for example, OpenZeppelin's contract: [link](https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard)) nor follows the [checks-effects-interactions pattern](https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html). Thus, the token transfer calls might trigger reentrancy attacks. However, most of the time, the code only interacts with allow-listed tokens. The tokens are either from the Sturdy platform itself or integrated platforms, such as Aave and Balancer. Those tokens do not have the hook to trigger a reentrancy attack. Unfortunately, in the `GeneralLevSwap._withdrawWithFlashloan()` function, the line `IERC20(_sAsset).safeTransferFrom(_user, address(this), withdrawalAmount)` can be a valid entry point of a reentrancy attack. The reason is that `_sAsset` is from user input when calling the `GeneralLevSwap.withdrawWithFlashloan()` function. Therefore, the attacker can pass in a malicious `_sAsset` contract address and trigger the attack. Once triggered, the attacker can call the `IBalancerVault(BALANCER_VAULT).flashLoan()` with arbitrary `userData` (see: [balancer's flashLoan code](https://github.com/balancer-labs/balancer-v2-monorepo/blob/d2794ef7d8f6d321cde36b7c536e8d51971688bd/pkg/vault/contracts/FlashLoans.sol#L42)). This allows the Balancer to call the `GeneralLevSwap.receiveFlashLoan()` function with unexpected data. The `_balancerFlashLoanLock == 2` validation on `L131` will also be bypassed since it is inside a reentrancy call. Finally, the transaction calls the `GeneralLevSwap._withdrawWithFlashloan()` function with arbitrary inputs to redeem the aTokens (`_sAsset`) of the victims.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportA vault can be registered through the `LendingPoolConfigurator.registerVault()` function but cannot currently be unregistered in any way. Registered vaults have a high level of access to the `LendingPool` contract through the `withdrawFrom()`, `getYield()`, `deposit()` and `depositYield()` functions. Thus, if a vault is compromised in any way, it is important to have the ability to unregister it quickly.
Open the reportThe `AToken` and `ATokenForCollateral` contracts implement EIP-712 to accept off-chain signatures. While the signatures must include a `chainId`, we note that the `DOMAIN_SEPARATOR` for both contracts is defined as follows within the `initialize()` function: ``` assembly { chainId := chainid() } DOMAIN_SEPARATOR = keccak256( abi.encode( EIP712_DOMAIN, keccak256(bytes(aTokenName)), keccak256(EIP712_REVISION), chainId, address(this) ) ); ``` Note that the `chainId` is only obtained during the initial computation of the `DOMAIN_SEPARATOR` during the initialization of the contract. If the chain is forked, then the signature could be replayed across both forks of the chain.
Open the reportBy design, the protocol requires users to withdraw their collateral from a vault. However, it is technically possible for users to withdraw directly from the `LendingPool` contract directly using the `LendingPool.withdraw()` function. For some tokens, such as stETH, directly withdrawing from the `LendingPool` contract does not appear to cause any issues. However, if a `SturdyInternalAsset` token is withdrawn directly from the `LendingPool` contract, there will be no way for a user to redeem the `SturdyInternalAsset` for the actual token it represents. The user will be left holding the valueless `SturdyInternalAsset` token and will not be able to withdraw their original assets.
Open the reportThe `ConvexCurveLPVault._withdraw()` function does not follow the [Check-Effects-Interactions pattern](https://docs.soliditylang.org/en/v0.8.1/security-considerations.html#re-entrancy). Specifically, `L239` should be performed after `L242` instead of the current order.
Open the reportThe `LendingPoolCollateralManager.liquidationCall()` function does not follow the [Check-Effects-Interactions pattern](https://docs.soliditylang.org/en/v0.8.1/security-considerations.html#re-entrancy). This is because the function obtains the debt reserve from the liquidator after completing the internal accounting process in regard to the repayment of debt. It may be possible for the liquidator to sell the obtained collateral into debt tokens and then reenter the contract and finish the final external call.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe `AuraBalancerLPVault.getYieldAmount()` function should return the amount of pending yield for the vault. However, it is currently returning the result of the `GeneralVault._getYieldAmount()` function, which returns the difference between the `ATokenForCollateral` supply and the amount of underlying token held within the `ATokenForCollateral` contract. This calculation does not apply to the `AuraBalancerLPVault`, which retrieves yield based on how much the AURA pool awards it.
Open the reportThe `GeneralVault.vaultYieldInPrice()` function always returns `0` and is not overridden by any vault contracts. As such, this function always returns the incorrect value. This can be seen in the function definition below: ``` function vaultYieldInPrice() external view virtual returns (uint256) { return 0; } ```
Open the reportIn the `GeneralLevSwap` contract, the `zapLeverageWithFlashloan()` and `enterPositionWithFlashloan()` functions does not validate the `_leverage` input to be less than or equal to nine. If the user calls the functions with `_leverage > 9`, it will have the effect of having a leverage `> 10x` of the user's principle. This breaks the spec from the [Sturdy documentation](https://docs.sturdy.finance/overview/what-is-sturdy), which is states that "DeFi's best yield farmers use Sturdy to access up to 10x leverage to farm with". Note that due to the liquidation threshold, the leverage is likely to fail as one cannot borrow such that their position is liquidatable.
Open the reportIn the case where `_vaultFee + _incentiveRatio == PercentageMath.PERCENTAGE_FACTOR` in the `AuraBalancerLPVault` contract, the `AuraBalancerLPVault._transferYield()` function might fail due to overflow. The reason is that the `PercentageMath.percentMul()` function rounds up when the decimal is larger than or equal to `0.5`. Consequently, in the case `_vaultFee + _incentiveRatio == PercentageMath.PERCENTAGE_FACTOR`, the `incentiveAmount` and `treasuryAmount` might be larger than the `yieldAmount`. The transfer can fail unexpectedly,
Open the reportWhen swapping from the Balancer LP token to the borrowing asset through the `_swapFrom()` function, there is a hard-coded slippage value of `10%` as seen in `L93:uint256 minAmountOut = ((_amountToSwap * fromAssetPrice) / 1e18).percentMul(9000); //10% slippage`. If a user ever wanted to allow more than `10%` slippage due to unusual market conditions, it would not be possible. In these cases, any attempt to swap through `_swapFrom()` would fail.
Open the reportThe `AuraBalancerLPVault` contract introduces another fee parameter, namely `_incentiveRatio`. It is set by the function `setIncentiveRatio()`, which properly makes sure that the sum of `_vaultFee` and `_incentiveRatio` is less than `100%`. However, while the function `GeneralVault.setTreasuryInfo()`, which sets the treasury address and the `_vaultFee`, checks that the `_vaultFee` is less than or equal to `30%`, it does not perform a sanity check that `_vaultFee` and `_incentiveRatio` (which is only introduced in `AuraBalancerLPVault.sol`) is less than `100%`. So, the contract could be erroneously set up where first the `_incentiveRatio` is set to `71%` via `setIncentiveRatio()`, followed by a setting of the `_vaultFee` of `30%` via `setTreasuryInfo()`, leading to `_incentiveRatio` + `_vaultFee` > `100%`. This would cause the `_transferYield()` function to revert, leading to the yield being stuck in the contract, as more than `100%` of the contract balance is attempted to be transferred.
Open the reportSeveral functions within the protocol lack sufficient validation of their input parameters. We have listed all missing checks below: 1. `GeneralLevSwap` 1. `executeOperation()` 1. Validate that the array parameters have the same length. 2. Validate that `amounts[0]` is greater than zero. 3. Validate that `assets[0]` is not the zero address. 2. `receiveFlashLoan()` 1. Validate that the array parameters have the same length. 2. Validate that `amounts[0]` is greater than zero. 3. Validate that `tokens[0]` is not the zero address. 3. `_executeOperation()` 1. Validate that `arg1` is greater than zero. 2. Validate that `arg2` is not the zero address. 3. If `isEnterPosition` is `false`, then validate that `arg0` is greater than zero. 4. If `isEnterPosition` is `false`, then validate that `arg3` is not the zero address. 4. `enterPositionWithFlashloan()` 1. Validate that `_borrowingAsset` is not the zero address. 5. `withdrawWithFlashloan()` 1. Validate that `_borrowingAsset` is not the zero address. 6. `zapLeverageWithFlashloan()` 1. Validate that `_borrowingAsset` is not the zero address. 2. Validate that `_zappingAsset` is not the zero address. 7. `zapDeposit()` 1. Validate that `_zappingAsset` is not the zero address. 2. `LeverageSwapManager` 1. `initialize()` 1. Validate that `_provider` is not the zero address. 2. `registerLevSwapper()` 1. Validate that `collateral` is not the zero address. 3. `AURAWSTETHWETHLevSwap` 1. `_swapTo()` 1. Validate that the `_borrowingAsset` is WETH. 2. `_swapFrom()` 1. Validate that the `_borrowingAsset` is WETH.
Open the reportThere are two calculations in which precision is lost due to division before multiplication. The first is the calculation for the withdrawal amounts from ``_withdrawWithFlashloan()` below: ``` uint256 withdrawalAmountETH = (((totalCollateralETH * currentLiquidationThreshold) / PercentageMath.PERCENTAGE_FACTOR - totalDebtETH) * PercentageMath.PERCENTAGE_FACTOR) / assetLiquidationThreshold; uint256 withdrawalAmount = Math.min( IERC20(_sAsset).balanceOf(_user), (withdrawalAmountETH * (10**DECIMALS)) / _getAssetPrice(COLLATERAL) ); ``` In the calculation for `withdrawalAmountETH` , a number is divided by `PercentageMath.PERCENTAGE_FACTOR` and then multiplied by another number. `withdrawalAmountETH` is also first divided by `assetLiquidationThreshold` and then multiplied by another number. The second example is the calculation for the amount to borrow with the flash loan in `_leverageWithFlashloan()`: ``` amounts[0] = ((((_principal * _getAssetPrice(COLLATERAL)) / 10**DECIMALS) * 10**borrowAssetDecimals) / _getAssetPrice(_borrowAsset)).percentMul(_leverage).percentMul( PercentageMath.PERCENTAGE_FACTOR + _slippage ); ``` Above, a number is divided by `10**DECIMALS` and then multiplied by another number.
Open the reportSeveral contracts within the protocol make calls to functions on other contracts with return values that indicate the result of those functions. However, many of these return values are not being validated within the code. We have listed all function calls for which return values are being ignored: 1. `GeneralLevSwap` 1. `L83:IERC20(COLLATERAL).approve(_vault, type(**uint256**).max);` 2. `L367:LENDING_POOL.repay(_borrowingAsset, _amount, USE_VARIABLE_DEBT, borrower);` 2. `AuraBalancerLPVault` 1. `L197:AURA_BOOSTER.deposit(auraPoolId, _amount, true);` 2. `L202:SturdyInternalAsset(internalAsset).mint(address(this), _amount);` 3. ``L236:IConvexBaseRewardPool(baseRewardPool).withdrawAndUnwrap(_amount, false);`
Open the reportA validation check within `_withdrawWithFlashloan()` incorrectly uses strict inequality, which may cause the function to fail for valid data: `require(withdrawalAmount > _requiredAmount, Errors.LS_SUPPLY_NOT_ALLOWED);`. If the `withdrawalAmount` equals the `_requiredAmount`, the validation check will fail, despite meeting the user's requirements.
Open the reportIn the constructor of the `GeneralLevSwap` contract, the `_vault` is set to have a maximum allowance of the `COLLATERAL`. While the `GeneralLevSwap` contract is not designed to hold any collateral assets directly, setting an unlimited allowance is a pattern that should generally be avoided.
Open the reportIt is unclear how exactly `BALWSTETHWETHOracle.sol` will be integrated with the remainder of the system. The `AURAWSTETHWETHLevSwap` contract inherits from the `GeneralLevSwap` contract, which internally uses `SturdyOracle` contract as its `ORACLE`. The `SturdyOracle` contract seems to be designed to make direct calls to chainlink oracles, but their function signatures differ compared to the `BALWSTETHWETHOracle` contract (which inherits from an `IOracle` interface, unused in the `SturyOracle` contract). Therefore, the `BALWSTETHWETHOracle` contract could not be integrated as another `assetSource` of the `SturdyOracle` contract, resulting in non-trivial setup on how it would actually be used in the protocol.
Open the reportThe `ConvexCurveLPVault.getYieldAmount()` function should return the amount of pending yield for the vault. However, it is currently returning the result of the `GeneralVault._getYieldAmount()` function, which returns the difference between the `ATokenForCollateral` supply and the amount of underlying token held within the `ATokenForCollateral` contract. This calculation does not apply to the `ConvexCurveLPVault`, which retrieves yield based on how much the Convex pool awards it.
Open the reportThe `YearnRETHWstETHVault.processYield()` function involves taking many steps to convert the yield token into WETH tokens. One of these steps is withdrawing liquidity from a Curve pool to retrieve wstETH as seen on `L125-L129`: ``` amountWstETH = ICurvePool(_poolAddress).remove_liquidity_one_coin( _amount, 1, minWstETHAmount, address(this) ); ``` If a Curve pool is imbalanced, then the liquidity provider may have to pay a withdrawal fee when removing liquidity. A malicious user may cause an imbalance in the Curve pool before calling `YearnRETHWstETHVault.processYield()` to force the function to pay the penalty by withdrawing liquidity from the Curve pool.
Open the reportMultiple areas within the code implicitly assume that assets with more decimal places than `18` may not be used. The assumption is best illustrated by `ATokenFromCollateral#L159-L160`: ``` if (decimal < 18) amount = (share * 10**(18 - decimal)).rayMul(index); else amount = share.rayMul(index); ``` We can see that the first condition accounts for when an asset token has less than `18` decimal places. The second `else` statement assumes that the asset has exactly `18` decimal places. However, no condition dealing with the case for greater than `18` decimal places exists. The same set of conditions can be found in multiple contracts, including `LendingPoolCollateralManager` and `LendingPool`. There is currently no explicit validation check to ensure that the external asset has less than or equal to `18` decimal places. An ideal place to add this check would be when a reserve is initialized within the `LendingPoolConfigurator.batchInitReserve()` function.
Open the reportThe `LendingPoolConfigurator.enableBorrowingOnReserve()` and `LendingPoolConfigurator.enableCollateralOnReserve()` functions enable borrowing and collateral on a reserve respectively. A reserve which can be borrowed should never be used as collateral and vice versa. However, it is currently possible to enable borrowing and collateral on the same reserver by calling the two functions with the same reserve `asset`.
Open the reportThe `YearnVault.processYield()` function performs multiple swaps to obtain debt assets from FTM. There is a broken validation check performed after the swap to ensure that the correct amount of debt asset has been received on `L126-L129`: ``` require( IERC20(_tokenOut).balanceOf(address(this)) >= receivedAmounts[1], Errors.VT_PROCESS_YIELD_INVALID ); ``` The current check validates that the contract's current token balance exceeds the expected amount returned by the swap. This check is insufficient if the contract had an existing balance of the token before the swap.
Open the reportThe `GeneralVault.withdrawOnLiquidation()` function is not overridden in `LidoVault`. This means that the `LidoVault.withdrawOnLiquidation()` will have the exact same code as `GeneralVault.withdrawOnLiquidation()`. It will thus be callable by anyone and will return the `_amount` passed in as a parameter without doing anything else. This could result in callers of the function being misled into believing that a liquidation has occurred successfully. Furthermore, the `withdrawOnLiquidation()` function is generally only meant to be callable by the `LendingPool` contract. However, `LidoVault.withdrawOnLiquidation()` is callable by anyone.
Open the report<br> 1. `LendingPool.initialize()`: `provider` should not be zero. 1. `LendingPoolConfigurator.initialize()`: `provider` should not be zero. 1. `AToken.sol.initialize()`: `aTokenDecimals`, `pool`, `treasury`, `underlyingAsset`, `incentivesController` should not be zero. 1. `GeneralVault.initialize()`: `_provider` should not be zero. 1. `ReserveLogic.init()`: `yieldAddress`, `stableDebtTokenAddress`, `variableDebtTokenAddress`, `interestRateStrategyAddress` should not be zero.
Open the reportThe reports name a repository or files. Open a report to see its scope.
Is this your project? Show this record on your own website. The label states what the record holds, links to this page and updates by itself. It is free and needs no sign-in.
Automatic: the three strongest facts of this project’s own record (its last audit and two more), highlighted. They update with the record. Picking a fact turns Automatic off.
<head><script src="https://widget.trustblock.run/project-widget.min.js" defer></script></head><body><divdata-tb-project-widgetdata-tb-project-slug="sturdy"></div></body>
Paste the code where the label should appear. It uses no API credit and updates by itself.
Something wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us