Bracket engaged Guardian to do a 2nd review of their LST management system. From the 19th of February to the 25th of February, a team of 6 auditors reviewed the source code in scope.
- Published
- Review window
- February 19 to 25, 2025
- Language
- Solidity
- Chains
- Ethereum
- Sector
- Yield and vaults
- 0 Critical
- 2 High
- 1 Medium
- 23 Low
- 0 Informational
Scope
Overview
Bracket engaged Guardian to do a 2nd review of their LST management system. From the 19th of February to the 25th of February, a team of 6 auditors reviewed the source code in scope.
Findings 26
-
H-01 High Incorrect Calculation Of totalFees In updateNav Logical Error Resolved
Description
The
totalFeesvariable is intended to represent the total fees affecting the asset flow between the vault and the manager during an NAV update. However, the calculation is inconsistent:accruedManagerPerformanceFeesandaccruedManagerTvlFeesare cumulative (including fees
from all previous epochs plus the current epoch's
managerPerformanceFeeandmanagerTvlFee).brktTvlFeeis the new fee for the current epoch only, not the cumulativeaccruedBrktTvlFees.
This inconsistency leads to an incorrect
totalFeesvalue. SincetotalFeesis used intotalDebit =withdrawalAssets + totalFeeswithin_processDepositsWithdrawals, it determines how much the manager must transfer to the vault (or receive from it). Using cumulative fees for manager fees is incorrect.Expected Behavior: As the fees are now "locked on the vault's balance" the vault should retain all accrued fees and the manager should only need to cover the net asset flow (
withdrawalAssets -depositAssets) plus the new fees for the current epoch. The previously accrued fees are already in the vault and can be used to pay withdrawals or claimed fees.Example:
- Epoch 1:
brktTvlFee = 5, soaccruedBrktTvlFees = 5. - Epoch 2:
brktTvlFee = 3, soaccruedBrktTvlFees = 8. Current code computestotalFees =
accruedManagerFees + accruedManagerTvlFees + 3, ignoring the prior 5 inaccruedBrktTvlFees. It should use only new fees (managerPerformanceFee + managerTvlFee + brktTvlFee).Recommendation
Use only the new fees for consistency and logical correctness:
uint256 totalFees = managerPerformanceFee + managerTvlFee + brktTvlFee;This ensures the manager transfers funds to cover withdrawals and new fees, while the vault retains previously accrued fees, aligning with the locking mechanism.
Resolution
Bracket Team: The issue was resolved in commit 4d9c92f.
-
H-02 High Incorrect Subtraction Of totalFees Logical Error Resolved
Description
The
_getManagerAvailableBalancefunction calculates the manager's available balance for transferring funds to the vault whendepositAssets < totalDebit. It subtractstotalFees(all accrued fees) from the minimum of the manager's balance and allowance.However, this is illogical because:
- The accrued fees are locked in the vault's balance, not the manager's. The manager's
token.balanceOf(manager)does not include these fees.- Subtracting
totalFeesunderestimates the manager's ability to transfer funds, potentially causing
unnecessary reverts with
InsufficientManagerFunds.The manager's available balance should be the full amount it can transfer to the vault, limited only by its balance and allowance. Since fees are held in the vault and claimed separately (
claimManagerFees,claimBrktTvlFees), they do not reduce the manager's available balance.Example:
Manager balance = 100,allowance = 100,accruedFees = 10in the vault.- Current code:
available = min(100, 100) - 10 = 90. - Actual: Manager can transfer up to 100, as the 10 in fees is in the vault.
Recommendation
Remove the fee subtraction from the
_getManagerAvailableBalancefunction. This accurately reflects the manager's capacity to support the vault.Resolution
Bracket Team: The issue was resolved in commit a5c9e2f.
-
M-01 Medium Front-Running Risk In startVault Function Validation Resolved
Description
The
startVaultfunction in theBracketVaultcontract is susceptible to front-running. This function is designed to move the vault from its initial state (Epoch 0) to an active state (Epoch 1), transferring all deposited assets to the manager to kick off operations.During Epoch 0, users can deposit tokens and withdraw them instantly via the
_withdrawEpoch0function, which does not impose delays or penalties.An attacker can exploit this setup by depositing a large quantity of tokens right before the
startVaulttransaction, ensuring their deposit is recorded, and then withdrawing those tokens using_withdrawEpoch0front-running thestartVaultcall.As a result, when
startVaultexecutes, it transfers only the remaining deposits, excluding the attacker’s withdrawn amount, to the manager. This reduces the capital available to the manager, disrupting the vault’s intended starting liquidity.Recommendation
Consider updating the
startVaultfunction to include an additional parameter,minTotalDeposits, which specifies the minimum amount of deposits required to proceed with starting the vault. The function should check the current total deposits against this threshold and revert if the amount is insufficient.This ensures that the vault only transitions to Epoch 1 when a predefined level of funding is secured, limiting the impact of last second withdrawals.
Resolution
Bracket Team: The issue was resolved in commit 8c1e877.
-
L-01 Low Missing Input Validation In deployVault Function Code Best Practices Acknowledged
Description
The
deployVaultfunction acceptsname,symbol, andwithdrawDelayparameters but lacks validation for reasonable ranges or formats. For example, an excessively long name or symbol could cause issues in downstream systems.Recommendation
Add input validation for
nameandsymbol(e.g., maximum length).Resolution
Bracket Team: Acknowledged.
-
L-02 Low Inefficient Removal Logic In removeCollateral Gas Optimization Acknowledged
Description
Inside the
removeCollateralfunction, the code always does a “swap and pop” technique to remove the collateral from the array. However, if the item being removed (token) is already the last element in the collaterals array (index = collaterals.length - 1), the swap is unnecessary: you can simplypop(). The swap in that scenario wastes gas and can be avoided.Recommendation
Add a condition to check whether index is already the last element. If so, simply
pop()the last element without performing the swap. Otherwise, do the usual “swap and pop” logic.Resolution
Bracket Team: Acknowledged.
-
L-03 Low Variable Duration Of Locked And Unlocked Periods Code Best Practices Acknowledged
Description
In the
BracketVaultcontract, each epoch aligns with a nominal 7-day cycle defined by theLOCK_FREQUENCYconstant, featuring alternating unlocked and locked periods dictated by thenextLocktimestamp.The unlocked period begins when
nextLockis set and lasts untilblock.timestampexceedsnextLock, at which point the vault becomes locked as determined by theisVaultLockedfunction (block.timestamp > nextLock epoch = 0).The locked period then persists until
updateNavis called by an address with theNAV_UPDATER_ROLE, which advances nextLock by 7 days and transitions the vault into the next epoch’s unlocked period.However, the exact durations of these periods within the 7-day cycle are not fixed and depend heavily on when
updateNavis executed relative tonextLock.- If
updateNavis called as soon asblock.timestampsurpassesnextLock(e.g., atnextLock + 1
second), the unlocked period lasts nearly the full 7 days (e.g., 6 days, 23 hours, 59 minutes, and 59 seconds), and the locked period is minimal (e.g., 1 second), though it cannot be zero due to theonlyLockedmodifier requiringblock.timestamp > nextLock.- If
updateNavis delayed by 3 days afternextLock, the unlocked period shortens to approximately 4
days, and the locked period extends to 3 days.
- If
updateNavis never called, the vault remains locked indefinitely beyondnextLock, halting critical
operations such as withdrawals (restricted by the
onlyUnlockedmodifier) and delaying the processing of queued deposits.This variability in period lengths introduces significant unpredictability into user interactions, such as deposit and withdrawal timing, and vault management, as the duration of access to key functions depends on the timing of
updateNavcalls by theNAV_UPDATER_ROLE.Recommendation
Consider introducing a stricter scheduling mechanism for epoch transitions by enforcing a maximum locked period duration.
Resolution
Bracket Team: Acknowledged.
- If
-
L-04 Low Incompatibility With Fee-On-Transfer Tokens Code Best Practices Acknowledged
Description
The
BracketVaultcontract does not explicitly support fee-on-transfer tokens, which are ERC20 tokens that deduct a fee from the transferred amount during operations like transfer or transferFrom, resulting in the recipient receiving less than the specified amount.In the current implementation, the contract uses
SafeERC20.safeTransferFromandSafeERC20.safeTransferfor asset movements in functions such asdeposit,withdraw,startVault,updateNavandclaimWithdrawal.These operations assume that the full specified amount is transferred to the intended recipient (e.g., the vault, manager, or user). However, with fee-on-transfer tokens, the actual amount received is reduced by the fee (e.g., if a 1% fee is applied to a 100-token transfer, only 99 tokens arrive).
The contract does not account for this discrepancy, as it records the full assets amount in state variables like
totalDeposits,totalQueuedDepositsorlastDeposit[user].assetswithout verifying the actual received balance.For example, in the
depositfunction,token.safeTransferFrom(msg.sender, address(this), assets)is called andassetsis directly added tototalDepositsortotalQueuedDeposits, but if a fee reduces the received amount, the vault’s internal accounting overstates the assets held, leading to inconsistencies.Recommendation
Merely informative, avoid using Fee-On-Transfer tokens.
Resolution
Bracket Team: Acknowledged.
-
L-05 Low Potential Reentrancy In claimBrktTvlFees And claimManagerFees Code Best Practices Acknowledged
Description
The
claimBrktTvlFeesandclaimManagerFeesfunctions transfers tokens tomsg.senderbefore updating the contract’s internal accounting.If a token with on-transfer hooks was used, it could invoke a callback (reentrancy) that call the respective function again, leading to multiple payouts before the state is reset.
In the current order, the manager could potentially re-enter and claim fees repeatedly if the token’s
transfercall trigger external code.While this is unlikely with standard ERC20 tokens, it still poses a theoretical risk given the absence of reentrancy protections.
Recommendation
Consider applying the the Check‐Effects‐Interactions (CEI) pattern or add the
nonReentrantmodifier to theclaimBrktTvlFeesandclaimManagerFeesfunctions.Resolution
Bracket Team: Acknowledged.
-
L-06 Low Undesirable Delay When Manager Has Insufficient Funds Logical Error Acknowledged
Description
The
updateNavfunction is critical for updating the NAV and advancing the epoch. However, it currently reverts when withdrawals exceed deposits and the manager lacks sufficient funds.Insufficient funds can occur if assets are time-locked in a yield strategy or being bridged across chains. However, this should not prevent updateNav from executing.
Failing to call
updateNavcan delay the start of a new epoch and potentially exceednextLock, causing overlaps with subsequent epochs. This could disrupt accounting and lead to delays in deposits and withdrawals.Since a mechanism for partial withdrawals already exists,
updateNavshould be allowed to proceed even if the manager temporarily lacks sufficient funds.Recommendation
Modify
updateNavto proceed even when the manager has insufficient funds to transfer to the vaultResolution
Bracket Team: Acknowledged.
-
L-07 Low Zero Share Withdrawals Possible Code Best Practices Resolved
Description
If a small
assetsvalue is passed into thewithdrawfunction while nav is greater than1e18, the calculated number of shares to be burned may round down to zero.As a result,
claimWithdrawcan still be called with zero shares, and the transaction will succeed. Although no direct risk to funds has been identified, this behavior is unexpected and should be prevented.Recommendation
In the
withdrawfunction, ensure that if the calculated shares amount is zero, the transaction reverts to prevent unintended withdrawals.Resolution
Bracket Team: The issue was resolved in commit d54f629.
-
L-08 Low Unnecessary Check And Casting Gas Optimization Resolved
Description
In the latest commit,
accruedManagerPerformanceFeeswas changed from anint256touint256. Therefore, it's no longer necessary to check that it is> 0and re-cast it to auint256.Recommendation
Update the
accruedManagerPerformanceFeesfunction removing the redundant check and type cast.Resolution
Bracket Team: The issue was resolved in commit a5c9e2f.
-
L-09 Low Missing Validation In setNextLock Validation Resolved
Description
The function
setNextLockshould check that the newnextLockis not less than the current timestamp. An erroneous update could disrupt vault accounting.Recommendation
Validate that
_nextLock > block.timestamp.Resolution
Bracket Team: The issue was resolved in commit e8fa0d3.
-
L-10 Low _clearDeposit Return Values Never Used Gas Optimization Resolved
Description
The internal function
_clearDepositreturnsbool, Deposit memory newLastDeposit. However, these values are never used.Recommendation
Consider removing the return values if there is no need for them.
Resolution
Bracket Team: The issue was resolved in commit dbe079c.
-
L-11 Low Mismatch Between Documentation And Code Code Best Practices Resolved
Description
The protocol documentation states that “In the case of a fund closing, every user will be processed automatically for a full withdrawal.” However, the current code only allows users to process their own withdrawals.
Consequently, during a fund closing, the protocol will not be able to execute the intended automatic full withdrawal process for all users.
Recommendation
If the documentation is outdated, update it to reflect the actual behaviour of the code. Otherwise, modify the code to implement the automatic full withdrawal functionality as described in the documentation.
Resolution
Bracket Team: Resolved.
-
L-12 Low Vault Lacking Pause Mechanism Code Best Practices Resolved
Description
After a strategy vault is shut down, users may still unknowingly deposit into it. Since the vault is no longer active, their funds remain stuck until an admin manually calls
updateNavto trigger withdrawals.Additionally, without a mechanism to halt operations, users could continue interacting with the vault during an exploit
Recommendation
Consider adding a pausing mechanism and implement the admin functions
pauseandunpause.Resolution
Bracket Team: The issue was resolved in commit a5b662a.
-
L-13 Low Multisig Admin Role Can Be Maliciously Revoked Logical Error Resolved
Description
In the
initializefunction, the contract grantsDEFAULT_ADMIN_ROLEto themultisigparameter. However, there is no validation preventingDEFAULT_ADMIN_ROLEfrom being included in therolesarray during initialization.This means the vault creator could include
DEFAULT_ADMIN_ROLEin therolesarray and assign it to themselves or another address.Since
DEFAULT_ADMIN_ROLEis its own admin (as perOpenZeppelin'sAccessControl), any address with this role can revoke it from other addresses. This creates a vulnerability where:- Vault creator includes
DEFAULT_ADMIN_ROLEinrolesarray during initialization - Both multisig and creator now have
DEFAULT_ADMIN_ROLE - Creator can call
revokeRole(DEFAULT_ADMIN_ROLE, multisig)to remove multisig's admin access - Creator now has sole control of admin functions
Recommendation
Add validation in the
initializefunction to preventDEFAULT_ADMIN_ROLEfrom being included in therolesarray.Resolution
Bracket Team: The issue was resolved in commit d584be9.
- Vault creator includes
-
L-14 Low Asset Imbalance Through Cross-Token Actions Validation Acknowledged
Description
The
burnfunction allows users to withdraw a different collateral token than the one they initially deposited, which can lead to asset imbalance issues in the protocol. Here's how this can impact the system:- A user deposits Token A into the protocol using the
mintfunction - Next, they call
burnto withdraw Token B instead of Token A - This cross-token withdrawal can:
- Create imbalances in individual token reserves
- Potentially prevent
swapCollateraloperations due to insufficient balances - Impact yield generation if certain assets become depleted and the current ratio between assets is far off
from the optimal ratio that is typically maintained.
While the 5-day withdrawal delay (
WITHDRAWAL_DELAY) provides some protection by giving time for rebalancing, the core issue remains that users can systematically drain specific collateral tokens, affecting the protocol's ability to maintain balanced reserves and optimal yield generation.The impact is particularly concerning because:
- It affects the protocol's yield generation capabilities
- It can create scenarios where other users cannot withdraw their preferred tokens
- The
swapCollateralfunction may fail due to insufficient balances
Recommendation
Consider implementing one of these solutions:
- Require users to withdraw the same token type they deposited by tracking individual deposits
- Add a fee mechanism for cross-token withdrawals to discourage imbalancing behavior
At minimum, document this behavior in the protocol specifications so users understand the associated risks.
Resolution
Bracket Team: Acknowledged.
- A user deposits Token A into the protocol using the
-
L-15 Low Incomplete NatSpec Code Best Practices Resolved
Description
The
NatSpecfor theIBracketVault.Depositstruct lacks description of thequeuedAssetsparameter.Recommendation
Describe the
queuedAssetsparameter as well.Resolution
Bracket Team: The issue was resolved in commit fc72b46.
-
L-16 Low Wrong Event Emission Code Best Practices Resolved
Description
The data used for the
NavUpdatedevent inupdateNav()is the data for the updated epoch, but theepochparam is passed after it has been increased. This will lead to the event being emitted withepoch n + 1and the nav data forepoch n.Recommendation
Consider correcting the
NavUpdated event.Resolution
Bracket Team: The issue was resolved in commit e24e55f.
-
L-17 Low Users Can Claim Withdrawals When The Contract Is Paused Code Best Practices Acknowledged
Description
BrktETH.claimWithdrawal()can be called when the contract is paused.Recommendation
Be aware of this behavior.
Resolution
Bracket Team: Acknowledged.
-
L-18 Low Slight totalSupply() Discrepancy Code Best Practices Acknowledged
Description
totalPendingSharesinBracketVaultare increased with the total deposited assets converted to shares in_processDepositsWithdrawals. They are then reduced by the amount of shares being minted in_clearDeposit().Because of rounding issues, the sum of the shares minted to each user may not be equal to the sum of the shares added in
_processDepositsWithdrawals.Because of that,
BracketVault.totalNonMintedShares()will return slightly inflated value, thereforeRebalancingToken.totalShares(),RebalancingToken.activeSupply()andRebalancingToken.totalSupply()as well.Recommendation
Document this discrepancy so integrators can avoid issues related to it.
Resolution
Bracket Team: Acknowledged.
-
L-19 Low The nextLock Variable Is Not Properly Updated Logical Error Acknowledged
Description
Whenever
updateNav()is called,LOCK_FREQUENCYis being added tonextLockinstead of settingnextLocktoblock.timestamp + LOCK_FREQUENCY.This will result in new epoch being shorter than they should be, depending on the delay between the expiry of the previous lock and the update of the nav.
The update of the nav can be delayed due to several different factors - offchain calculation is not so straightforward, network congestion, the
updateNav()reverting because ofInsufficientManagerFunds(), etc…Let's take a look at a simplified example:
nextLock= 100LOCK_DURATION= 100updateNav()is called at 150nextLock = 100 + 100 = 200- The new epoch will be locked after
LOCK_DURATION / 2.
If the
updateNav()was called after 200, the new epoch would have been instantly locked.Recommendation
Update
nextLockas follows:nextLock = LOCK_FREQUENCY; + nextLock = block.timestamp + LOCK_FREQUENCY;Resolution
Bracket Team: Acknowledged.
-
L-20 Low Changing Multisig Won't Affect The Vault Code Best Practices Acknowledged
Description
In the
VaultFactorythere is asetMultisig()function which allows theDEFAULT_ADMIN_ROLEto change the multisig. However, all the previously deployed vaults will still have the old multisig set.Recommendation
Document this to avoid unexpected behaviors.
Resolution
Bracket Team: Acknowledged.
-
L-21 Low claimWithdrawal Overestimates Available Balance Validation Acknowledged
Description
In the
BracketVaultcontract, theclaimWithdrawalfunction checks the vault’s entire token balance viatoken.balanceOf(address(this))to determine how much can be paid out for a user withdrawal.This raw balance includes:
- Queued deposits: User funds transferred during locked epochs, recorded as
totalQueuedDeposits,
which are slated to be converted to shares in the next
updateNavcall.- Unclaimed fees: Manager or bracket fees stored in the vault’s balance
(
accruedManagerPerformanceFees,accruedManagerTvlFees,accruedBrktTvlFees) that remain physically in the contract but are not intended to fund user withdrawals.Because
claimWithdrawaldoes not subtract these queued deposits or outstanding fees from the vault’s “available” funds, a user can withdraw more than the true liquid balance the vault should be able to commit.Once
updateNavprocesses queued deposits (or fees get claimed), the vault may find itself short of tokens to fulfill its obligations.Recommendation
In
claimWithdrawal, adjust the availability check so it excludes bothtotalQueuedDepositsand any unclaimed fees that should not fund user withdrawals.Resolution
Bracket Team: Acknowledged.
- Queued deposits: User funds transferred during locked epochs, recorded as
-
L-22 Low Oracle Does Not Validate Fetched Price Validation Acknowledged
Description
In
BracketOracle.sol, thegetRatefunction retrieves price from each LST/LRT oracle but does not validate whether the fetched price is zero.If an oracle returns a zero price,
BrktETH'sgetTotalValuewould calculate an incorrect total collateral value, potentially enabling opportunistic attacks that exploit the mispriced collateral.Recommendation
For all functions used to fetch the LST/LRT price (e.g.
_getWstethRate), validate that the fetched price is not zero.Resolution
Bracket Team: Acknowledged.
-
L-23 Low Inactive Balance Doesn't Include Claimable Withdrawals Logical Error Acknowledged
Description
BracketVault.balanceOf(address)aims to return the underlying token's value of the provided address parameter. It currently returns theactiveBalance + inactiveBalance, where the active balance is the amount of minted shares + the amount of pending shares that we know the NAV for.The inactive balance are the rest of the assets (deposited and queued) which we don't know the NAV for. This calculation doesn't include the current claimable assets that can be withdrawn. In result, the
balanceOf()will report lower value in this cases.Recommendation
Consider tracking individual withdrawals per epoch and account for them in the
balanceOf()function.Resolution
Bracket Team: Acknowledged.
No findings match.
Invariants 13
The review's fuzzing suite asserted 13 invariants. 13 held.
Every invariant tested
| ID | Invariant | Result |
|---|---|---|
GLOB_01 | Contract balance should never be less than total accrued fees | Held |
GLOB_02 | If epoch = 0 then totalNonMintedShares = 0 | Held |
GLOB_03 | If epoch = 0 then totalMintedShares = 0 | Held |
GLOB_04 | If epoch = 0 then totalShares = 0 | Held |
GLOB_05 | If epoch = 0 then activeSupply = 0 | Held |
GLOB_06 | If epoch = 0 then inactiveSupply = totalDeposits | Held |
GLOB_07 | Token balance should never be less than inactiveSupply | Held |
GLOB_08 | totalShares should equal totalMintedShares + totalNonMintedShares | Held |
GLOB_09 | If epoch = 0 then the vault is always unlocked | Held |
GLOB_10 | After startVault() the vault should be immediately unlocked | Held |
GLOB_11 | If lastDeposit[user].epoch = epoch, lastDeposit[user].pendingAssets after calling | Held |
GLOB_12 | deposit() or withdraw() should be updated Withdrawals must never be executed when the vault is locked | Held |
ERR_01 | Unexpected error | Held |
More from Bracket
-
Contract Updates
37 findings2 critical · 8 high 37 findings: 2 critical, 8 high, 9 medium, 14 low, 4 informational -
KYC Whitelist
10 findings1 high 10 findings: 1 high, 2 medium, 1 low, 6 informational -
BracketFi, Round 1
34 findings3 critical · 2 high 34 findings: 3 critical, 2 high, 9 medium, 20 low
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
