Strata engaged Guardian to review the security of their Strata Tranches Contracts. From the 22nd of September to the 29th of September, a team of 4 auditors reviewed the source code in scope. Note: Fixes to the findings uncovered in remediations (included starting on page 42) have not been reviewed by Guardian.
- Published
- Review window
- September 22 to 29, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum
- Sector
- Derivatives
- 1 Critical
- 5 High
- 14 Medium
- 5 Low
- 8 Informational
Scope
Overview
Strata engaged Guardian to review the security of their Strata Tranches Contracts. From the 22nd of September to the 29th of September, a team of 4 auditors reviewed the source code in scope. Note: Fixes to the findings uncovered in remediations (included starting on page 42) have not been reviewed by Guardian.
Findings 33
Main Review
29 findings-
C-01 Critical Reserve Withdrawal Unit Mismatch Logical Error Resolved
Description
Proof of concept: PoC
StrataCDO.reduceReservedebits accounting by the requested base-asset amount, then asks the strategy to send that many tokens to the treasury. In the USDe path,sUSDeStrategy.reduceReserveforwards the same value tounstakeCooldown.transferas if it were sUSDe shares:// StrataCDO.sol function reduceReserve (address token, uint256 tokenAmount) external onlyRole(RESERVE_MANAGER_ROLE) { if (treasury = address(0)) { revert ZeroAddress(); } // Reverts if the token is not supported uint256 baseAssets = strategy.convertToAssets(token, tokenAmount, Math.Rounding.Floor); // Reverts if not enough reserve accounting.reduceReserve(baseAssets); // Transfers tokens out instantly if possible, or through the cooldown process strategy.reduceReserve(token, tokenAmount, treasury); emit ReserveReduced(token, tokenAmount); } // sUSDeStrategy.sol function reduceReserve (address token, uint256 tokenAmount, address receiver) external onlyCDO { if (token = address(sUSDe)) { erc20Cooldown.transfer(sUSDe, receiver, tokenAmount, 0); return; } if (token = address(USDe)) { unstakeCooldown.transfer(sUSDe, receiver, tokenAmount); return; } revert UnsupportedToken(token); }When the share price exceeds 1 (normal after yield accrues), the helper only needs
previewWithdraw(baseAssets)shares, but the strategy is forced to deliver the full base-asset amount as shares. In the reproduced run, withdrawing 1 USDe from the reserve led the strategy to transfer 1 full share even though the correct amount was ~0.53 shares at the prevailing exchange rate. After cooldown the treasury would receive ~1.889 USDe while accounting deducted only 1 USDe. This totally breaks the internal accounting.Recommendation
Before calling
unstakeCooldown.transferfortoken = USDe, compute the share amount withuint256 shares =sUSDe.previewWithdraw(tokenAmount);(orconvertToShares) and supply shares instead oftokenAmount, keeping the accounting units consistent.Resolution
Strata Team: Resolved.
-
H-01 High Withdraw Griefing DoS DoS Resolved
Description
Proof of concept: PoC
- Anyone is able to use the
transferfunction of theERC20CooldownorUnstakeCooldowncontract to
create new proxies for an arbitrary recipient and lock up tokens
- If this arbitrary recipient later on calls
finalizethe system will loop through all of these proxies.
This enables a permanent DoS griefing attack:
- Bob performs a normal withdraw flow in the system to redeem $100k
- Eve creates a lot of proxies with Bob as recipient locking up 1 wei each
- After a week bob wants to claim his $100k and calls
finalizebut the transaction runs out of gas as
the loop is too big
Recommendation
Add access control to these functions.
Resolution
Strata Team: Resolved.
- Anyone is able to use the
-
H-02 High Withdrawals Flip Token And Base Assets Logical Error Resolved
Description
The call
cdo.withdraw(address(this), token, baseAssets, tokenAssets, receiver);passesbaseAssetsandtokenAssetsin the wrong order toStrataCDO.withdrawwhich expect the following parameter order:function withdraw(address tranche, address token, uint256 tokenAmount, uint256 baseAssets,
address receiver)Consequently, the strategy interprets USDe-denominated value as the specified token amounts, which will ultimately lead to users receiving less funds than they are owed within the withdrawal flow.
Recommendation
Replace the existing call with
cdo.withdraw(address(this), token, tokenAssets, baseAssets, receiver);instead.Resolution
Strata Team: Resolved.
-
H-03 High Juniors Do Not Profit On Loss Logical Error Resolved
Description
Within function
calculateNAVSplit, if the SRT reports a loss (caseif (srtGainTarget < 0)), the loss should be transferred to the Juniors as profit according to the comment.However, instead of adding the loss to
jrtNavT1which represents the updated Junior NAV, the loss is added to the old Junior NAV:jrtNavT0 + srtLoss;.Consequently, the loss by the Seniors is not deducted from the Senior NAV and it is also not added as profit to the Junior NAV, violating the spec for each Tranche.
Recommendation
Use
srtNavT1 - srtLoss;andjrtNavT1 + srtLoss;instead.Resolution
Strata Team: Resolved.
-
H-04 High JRT totalSupply Will Grow Constantly DoS Resolved
Description
Proof of concept: PoC
The senior‑first reallocation policy, as applied in
Accounting.calculateNAVSplit, can drive junior NAV (JRT) down to a hard floor while leaving JRTtotalSupplyunchanged whenever the senior target jumps because a feed update or normal parameter changes. The code path is:AprPairFeed.updateRoundData →Accounting.onAprChanged →updateAprs/updateIndexes →Tranche.deposit →cdo.updateAccounting →Accounting.updateAccounting/calculateNAVSplit.Inside
calculateNAVSplit, the senior gain target derived from the index jump is pulled from the junior side first and clamped only by a 1‑token floor:uint256 srtGainTargetAbs = Math.min( uint256(srtGainTarget), Math.saturatingSub(jrtNavT1, 1e18) // hard floor ); jrtNavT1 = jrtNavT1 - srtGainTargetAbs; // JRT can collapse to 1e18 srtNavT1 = srtNavT0 + srtGainTargetAbs;After this,
JRT.totalAssets ≈ 1e18(the floor) whileJRT.totalSupplyis unchanged. The price per JRT share becomes smaller. On the very next deposit,OpenZeppelin'sERC‑4626share conversion:shares = floor(assets * (totalSupply + 1) / (totalAssets + 1));multiplies assets by a very high ratio (S{+}1)/(A{+}1), minting a very high amount of shares. Repeated deposits at tiny price cause runaway growth of
totalSupply. For sufficiently large deposits and, sufficiently enough APR increases, the product assets * (S+1) exceeds 256 bits andMath.mulDivreverts, DoS’ing deposits. Even before hard reverts, the system inflates supply massively.Importantly, this is reachable under normal operations, not just extreme spikes. For example, mild APR oscillations (e.g., 1.0% ↔ 1.5%) can ratchet junior down over time: each uptick to 1.5% transfers incremental value from JRT to SRT (bounded only by the 1‑token floor), while the downtick back to 1.0% does not claw anything back from SRT.
With continued deposits during these cycles, JRT NAV trends toward its floor, price per share shrinks and eventually deposits at ordinary sizes mint huge share amounts and can overflow during conversion. Thus, even “small” changes accumulated over days/weeks can lead to a runaway‑supply / deposit‑overflow state. The main impact of this issue, JRT price collapse leads to unbounded share minting and, at realistic sizes,
ERC‑4626deposit conversion overflow/reverts, threatening protocol liveness.Recommendation
Consider mitigating the junior-runaway condition by enforcing hard and soft Junior/Senior TVL ratio checks within the
Accountingcontract. On the other hand, consider adding an automatic junior-price guard in theStrataCDOcontract that re-computes the JRT share price after every flow and pauses deposits into both tranches whenever it slips below a configurablejrtShortfallPausePrice, preventing the low-price/high-supply regime that previously led to overflowsResolution
Strata Team: Resolved.
-
H-05 High MEV APR Front-Run Via onAprChanged MEV Resolved
Description
Proof of concept: PoC
When the APR feed is updated, keepers call
Accounting.onAprChanged(), which immediately fetches the new pair (aprTarget,aprBase) and re-computes the senior APR (aprSrt) and rolls the accrual index/clock based on the current tranche balances stored injrtNav/srtNav.An attacker can front‑run this keeper transaction with a large, temporary
ERC‑4626deposit into theTranchethat favors their objective (typically Senior), skewing the ratiosrtNav / (srtNav + jrtNav)at the instant the keeper’s call lands, and then back‑run a withdrawal to fully exit.The manipulated
aprSrtand newly resetindexTimestamppersist for the next accrual segment, even though the attacker did not keep capital in the system.Because
onAprChanged()is a public mempool transaction (role‑gated, but visible), the attacker can reliably insert adepositbefore it and a withdraw after it to obtain a long‑lived APR haircut favorable to their position, while only tying up capital for the block (or for the cooldown window, if configured).This can be abused so the senior APR and accrual clock can be repeatedly biased at each feed update, shifting future period gains between tranches without the manipulator retaining the skewing capital within the protocol.
Recommendation
Do not recompute
aprSrtor roll the index inonAprChanged(). Treat the feed update as a pure data refresh and move APR/clock updates to a checkpoint that operates on durable post‑flow balances:- Modify
onAprChanged()/updateAprs()to only cacheaprTarget/aprBase:
function onAprChanged() external onlyRole(UPDATER_FEED_ROLE) { IAprPairFeed.TRound memory r = aprPairFeed.latestRoundData(); aprTarget = normalizeAprFromFeed(r.aprTarget); aprBase = normalizeAprFromFeed(r.aprBase); // do NOT call updateIndexes here }- At accounting checkpoints, first settle the elapsed period by rolling the index with the previous
aprSrt, then apply any
balance flows, then compute the new
aprSrtfrom the updatedjrtNav/srtNavwithout rolling the index again (the newaprSrtapplies going forward).Resolution
Strata Team: Resolved.
- Modify
-
M-01 Medium setMinimumJrtSrtRatio Mutates Logical Error Resolved
Description
In the Accounting contract, the configuration routine intended to update the junior/senior TVL safeguard writes the incoming value to
reserveBpsand emits aReservePercentageChanged(reserveBps)event.The actual guardrail variable,
minimumJrtSrtRatiois left untouched. Downstream logic that enforces the floor, for exampleuint256 minJrt = srtNav * minimumJrtSrtRatio / 1e18;anduint256 maxSrt =jrtNav * 1e18 / minimumJrtSrtRatio;, now continues to use the stale default.Meanwhile, the reserve split is silently overwritten with the requested ratio, because
setReserveBpsandsetMinimumJrtSrtRatioboth mutatereserveBpsand emit the sameReservePercentageChangedevent.Because of this, risk managers can not raise or lower the tranche. Tvl floor and reserve accounting is mutated unexpectedly.
Recommendation
Assign the provided value to
minimumJrtSrtRatio, emit a dedicated event (for exampleMinimumJrtSrtRatioChanged(bps)) and leavereserveBpsunchanged so reserve configuration remains isolated from the ratio guardrail.Resolution
Strata Team: Resolved.
-
M-02 Medium grantCall Gives DEFAULT_ADMIN Role Validation Resolved
Description
Proof of concept: PoC
AccessControlManager.grantCalltakes a contract address and selector, derives a role withroleForand forwards the request toOpenZeppelin'sgrantRole. The helper packs the address in the high 20 bytes and the selector in the low 4 bytes:role = (bytes32(uint256(uint160(contractAddress))) << 96) | bytes32(uint256(uint32(sel)));When an administrator tries to grant a global permission by supplying
contractAddress = address(0)andsel = bytes4(0), the computed role collapses tobytes32(0), whichOpenZeppelindefines asDEFAULT_ADMIN_ROLE.The
NatSpecexplicitly advertises this pattern: “ifcontractAddressis zero address, the account can access the specified function on any contract managed by this ACL”, andisAllowedToCalllooks uproleFor(address(0), sel)as the fallback namespace.Consequently, the intuitive call path
grantCall(address(0), bytes4(0), user)silently elevates user to default admin, giving them total control over every role and contract managed by the ACL. Any mistaken global fallback grant therefore becomes a full protocol takeover vector.Recommendation
Reject or special case the all-zero pair before delegating to
grantRole. For example, revert whencontractAddress = address(0)andsel = bytes4(0)or require admins to callgrantRole(DEFAULT_ADMIN_ROLE, …)explicitly.Resolution
Strata Team: Resolved.
-
M-03 Medium JRT maxWithdraw Redemption DoS DoS Resolved
Description
The
maxWithdrawfunction can be abused to DoS junior tranche redemptions as a malicious actor could deposit the maximum possible into the senior tranche every time someone else withdraws from it or enters the junior tranche.This could of course also happen accidentally in case the senior tranche is favored over the junior tranche by users.
Recommendation
Consider to either acknowledge this behavior or the introduce a deposit and withdraw queue to protect junior tranche depositors from this DoS vector.
Resolution
Strata Team: Resolved.
-
M-04 Medium Missing updateAccounting Call Rewards Resolved
Description
Proof of concept: PoC
The system does not call
updateAccountingbefore updating parameters which will influence its outcome.For example, the
setReserveBpsfunction will update thereserveBpsparameter which influences the gain of the tranches.As
updateAccountingis not called in the beginning of the function this will not only influence future gains but also past ones and therefore suddenly influence yield some users may have already calculated with.Recommendation
Always call
updateAccountingbefore updating any parameter that will influence its outcome.Resolution
Strata Team: Resolved.
-
M-05 Medium Redeem Mistreats Shares As Assets Logical Error Resolved
Description
Function
redeemroutes the redemption of shares through the Tranche’swithdrawfunction:uint256assets = super.withdraw(shares, receiver, owner);However, function
withdrawinERC-4626expects an asset amount rather than the currently supplied share amount.Consequently, users will receive less assets than they are owed for the shares they are redeeming and will have to complete multiple redemptions to redeem their shares.
Recommendation
Use
super.redeem(shares, receiver, owner)within functionredeem.Resolution
Strata Team: Resolved.
-
M-06 Medium Old Data Can Overwrite Newer Data Validation Resolved
Description
The
updateRoundDatapushes APR values by providingaprTargetandaprBasevalues. It saves them together with the currentblock.timestamp.However, the values must not be from this
block.timestampthey could also be laying around in the mempool for a while as not enough gas was provided.If the needed amount of gas is reduced later on, the transaction might go through and overwrite newer data with stale one.
Recommendation
Consider supplying the
block.timestampas param and make sure that a older transaction does not overwrite a newer one.Resolution
Strata Team: Resolved.
-
M-07 Medium JRT maxMint Reverts When Share Price < 1 Logical Error Resolved
Description
Proof of concept: PoC
StrataCDO.maxDeposit(address(this))returnstype(uint256).maxfor the junior tranche.Tranche.maxMintpasses that value to_convertToShares, which multiplies it bytotalSupply() + 1and then divides bytotalAssets() + 1.Whenever the junior share price dips below 1 (i.e.
totalAssets < totalSupplyafter juniors absorb a loss), the 512-bit multiplication produces a high word greater than thedenominator.OpenZeppelin'sMath.mulDivdetects this overflow and reverts with apanic: arithmeticErrorinstead of returning a finite cap.Because
ERC4626.mintalways callsmaxMint(receiver)prior to minting, this revert bricks every mint call even if the user requests a tiny number of shares.Recommendation
Consider returning
type(uint256).maxdirectly frommaxMintinstead, when “no deposit limit” is required.Resolution
Strata Team: Resolved.
-
M-08 Medium Cooldown Removal Ignores Pending Requests Validation Resolved
Description
The
transferfunction in theUnstakeCooldowncontract manages unstaking requests with cooldown periods by transferring tokens to a proxy contract, initiating unstaking, and either immediately processing withdrawals (if no cooldown is required) or storing requests for later withdrawal.Users then call
finalizeto complete unstaking once the cooldown expires. The issue arises whencooldownDurationis later set to zero. In this case, new unstaking requests can be withdrawn immediately, but existing pending requests remain locked.This is because
finalizevalidates requests using the originally storedunlockAttimestamp, preventing withdrawals until the original cooldown elapses, even though the cooldown is no longer active.As a result, users who initiated unstaking requests before the cooldown was removed are unfairly stuck waiting. This could also cause problems if Ethena implements emergency measures that allow immediate withdrawals, since affected users would still be unable to access their funds.
Recommendation
Modify the cooldown validation in the finalize function to allow withdrawals if either the original unlock time has passed or the cooldown has since been disabled.
Resolution
Strata Team: Resolved.
-
M-09 Medium Old Implementations Remain Active Logical Error Resolved
Description
The
setImplementationsfunction in theUnstakeCooldowncontract allows the owner to update the implementation for a given token. This implementation is used as the proxy implementation contract that holds and processes user tokens during the cooldown period.However, because the transfer and finalize functions are designed to reuse existing proxies, an outdated implementation remains valid even after a new implementation has been set.
This creates a potential issue if the old implementation contained a bug, became incompatible, or required deprecation. In such cases, funds could remain stuck or even be at risk if users continue interacting with the outdated proxy.
Recommendation
Add a version check mechanism to the proxy reuse logic in the transfer function, ensuring that only proxies created with the current implementation are reused from the pool, while outdated proxies are discarded and new ones are created instead.
Resolution
Strata Team: Resolved.
-
M-10 Medium Risk > 100% Freezes Senior APR Math DoS Resolved
Description
Proof of concept: PoC
Accounting.calculateRiskPremium()derives a “risk” factor asriskX + riskY * pow(tvlRatio, riskK), wheretvlRatio = srtNav / (srtNav + jrtNav)and the three coefficients are admin-configurable. WhenupdateIndexesruns, it applies that factor inaprSrt1 = mul(aprBase_, UD60x18.wrap(1e18) - risk).The math uses the unsigned
UD60x18type, so 1e18 represents 100%. If runtime risk ever reaches or exceeds 1e18, the subtraction underflows and the transaction reverts.The existing guard in
setRiskParametersonly checks the current risk given the current tranche split; it does not bound future values as the TVL mix evolves.Consequently, choosing parameters where
riskX + riskY > 100%looks safe while the senior pool is small, but the next deposit wave that drivestvlRatioclose to 1 can push risk above 100%.From that point on, every call to
updateIndexesand, therefore every deposit, withdrawal, or explicit accounting update, reverts, freezing the protocol until an admin dials the parameters back down.Recommendation
Consider adding two layers of protection. First, enforce a configuration invariant that keeps the theoretical maximum risk below 100%, e.g.
require riskX.unwrap() + riskY.unwrap() < 0.95e18(with optional headroom) when setting parameters and boundriskKto a very sensible range.Second, inside
updateIndexes, checkif (risk.unwrap() > 1e18) revert RiskTooHigh(risk.unwrap());or clamp the factor before subtracting; this turns the generic underflow into a clear failure mode and prevents the unsigned arithmetic panic from bricking every call.Resolution
Strata Team: Resolved.
-
M-11 Medium APR Updates Rewrite The Previous Accrual Window Logical Error Resolved
Description
Proof of concept: PoC
getSrtTargetIndexT1()expands the index over the elapsed intervaldt = block.timestamp -indexTimestampusing the newly storedaprSrt.Because the old APR was overwritten first, that dt window is retroactively capitalized at the new rate, not the one that was actually in force during the elapsed time.
In other words, the moment an updater supplies APR “B”, the entire period since the last accounting update is recomputed as if “B” had been active all along.
By choosing whether to report a higher or lower replacement APR, whoever controls the feed (or any account with
UPDATER_FEED_ROLE) can push value between the Senior and Junior tranches for the just-finished interval.That retroactive rewrite affects the next
calculateNAVSplitoutput, so deposits, withdrawals, and accounting updates that follow inherit the new distribution.Recommendation
Apply the old APR to the completed interval before committing the new one. For example:
- Advance
srtTargetIndexusing the currentaprSrt(as of the previous update). - Only after the index and
indexTimestampare updated, assign the new APR parameters that
should take effect going forward.
Resolution
Strata Team: Resolved.
- Advance
-
M-12 Medium Negative APRs Break Accounting Updates Validation Resolved
Description
The APR feed explicitly checks that pushed values stay between -50% and +200%:
require(APR_BOUNDARY_MIN < answer answer < APR_BOUNDARY_MAX, "INVALID_APR");with
APR_BOUNDARY_MIN = -0.5e12.Accounting, however, normalizes the same feed output with a tighter guard:require(APR_BOUNDARY_MIN < apr apr < APR_BOUNDARY_MAX, "invalid apr");where
APR_BOUNDARY_MIN = 0.When an operator publishes a negative APR (still within the feed's advertised bounds),
normalizeAprFromFeedreverts andonAprChangedfails, so the new value is never applied and the system keeps using stale APRs.This operational mismatch makes the live APR updates fragile and can leave the protocol out of sync with real market conditions when negative rates occur.
Recommendation
Align the allowed ranges across the two components. Either clamp negative APRs to zero (or otherwise sanitize them) inside
Accountingbefore normalization, or narrowAprPairFeed's bounds to matchAccountingso that impossible inputs are rejected at the feed boundary.Resolution
Strata Team: Resolved.
-
M-13 Medium reduceReserve Uses Stale Accounting Logical Error Resolved
Description
The
reduceReservefunction can be called by an address with theRESERVE_MANAGER_ROLEto reduce the reserve and transfer tokens to the treasury. However, the function does not callupdateAccountingbeforehand, which meansreserveNavmay not reflect the latest state.If
reserveNavis understated,reduceReservewon't be able to withdraw up to the actual available amount. More critically, ifreserveNavis overstated, losses have not yet been allocated (JRT → Reserve → SRT).In this case,
reduceReservemay withdraw more than it should. Later, whenupdateAccountingruns, the remaining reserve is smaller, and additional losses are pushed to the SRT tranche or can’t be fully covered.Recommendation
Modify the
reduceReservefunction to callupdateAccountingbefore reducing reserves to ensurereserveNavreflects the latest state.Resolution
Strata Team: Resolved.
-
L-01 Low Incorrect Validation In setProvider Function Validation Resolved
Description
AprPairFeed.setProviderattempts to verify the replacement APR provider before wiring it into the feed, but the check is pointed at the wrong address. The function still callsprovider.getAprPair(). It queries whatever implementation was already set—before assigningprovider = provider_:function setProvider(IStrategyAprPairProvider provider_) external onlyOwner { // compatibility check (int64 aprTarget, int64 aprBase, ) = provider.getAprPair(); // ← old provider ensureValid(aprTarget); ensureValid(aprBase); provider = provider_; emit ProviderSet(address(provider_)); }Because the compatibility probe never hits
provider_, any address passes the guard: the zero address, an EOA, or a misconfigured contract that returns invalid data.Recommendation
Validate the new provider before assignment: ensure that
provider_.getAprPair()succeeds with APRs inside [APR_BOUNDARY_MIN,APR_BOUNDARY_MAX]. Only after the checks pass should the code writeprovider = provider_.If
getAprPair()throws or returns out-of-range values, revert the transaction to keep the existing, working oracle.Resolution
Strata Team: Resolved.
-
L-02 Low Feed Updates Skip Accounting Refresh Configuration Resolved
Description
AprPairFeed.updateRoundDataonly writeslatestRound/latestRoundIdand emits anAnswerUpdatedevent, but never informs consumers that fresh APRs arrived.The accounting module depends on an explicit
Accounting.onAprChanged()call to pull the new feed data and recomputeaprTarget,aprBaseand the derived indexes.If the operator who updates the feed forgets this step, the feed and accounting drift: fresh APRs sit in the oracle while tranche math stays on stale rates until the second transaction happens, delaying the intended risk adjustments and yield targets.
Recommendation
Add an observer mechanism or helper that bundles the feed write with the accounting refresh. For example, have the feed call registered listeners’
onAprChanged()after a successful update, or expose an orchestrator function that performs both steps atomically so operators can’t leave the system desynchronized.Resolution
Strata Team: Resolved.
-
L-03 Low Max Limits Ignore Min Shares Guard Compatibility Resolved
Description
Tranche.maxWithdrawandTranche.maxRedeemreturn values that take tranche caps into account but do not consider_onAfterWithdrawalChecks, which is invoked at the end of everywithdraw/redeemcall and reverts withMinSharesViolationwhenever the post-withdraw total supply would fall belowMIN_SHARES.Because the advertised maxima omit this floor, integrators can see a non-zero limit, attempt a withdraw/redeem within that bound and still hit a revert.
ERC-4626explicitly requires each max helper to “MUST NOT be higher than the actual maximum that would be accepted,” so the current behavior violates theERC-4626standard and breaks downstream slippage/limit checks.Recommendation
Incorporate the minimum-supply guard into
maxWithdraw,maxRedeemand any preview helpers that rely on those values. One approach is to compute the hypothetical post-withdraw supply and, if it would drop belowMIN_SHARES, reduce the advertised limit accordingly.Resolution
Strata Team: Resolved.
-
L-04 Low Missing Event In Critical Function Best Practices Resolved
Description
The
setRiskParametersfunctions performs a critical state change but does not emit an event.Recommendation
Consider emitting events in all functions which perform critical state changes to follow best practices.
Resolution
Strata Team: Resolved.
-
L-05 Low ERC20Cooldown Accepts Any Token Validation Resolved
Description
The transfer function in the
ERC20Cooldowncontract allows users to create a cooldown request. Currently, there are no restrictions on which tokens can be used, allowing users to create requests for spam or potentially malicious tokens.While this does not directly impact funds, it can pollute the system. Additionally, the transfer and finalize functions do not specify the token in their emitted events, which can lead to corrupted event logs.
Recommendation
Consider restricting it to approved tokens, similar to the
UnstakeCooldowncontract.Resolution
Strata Team: Resolved.
-
I-01 Informational STR APR Can Be Stale Warning Resolved
Description
Senior tranche APR changes must be applied manually with the
onAprChangedfunction. If this function is called too late, the yield is distributed in a unfair manner.Recommendation
Make sure to call
onAprChangedandupdateAccountingvery regularly especially at the 8h markers when funding fees are settled for Ethena's open positions. Otherwise the senior tranche APR will be stale and yield distribution will be unfair.Resolution
Strata Team: Resolved.
-
I-02 Informational Compound VS Linear Comment Mismatch Warning Resolved
Description
calculateTargetIndexis documented as “using compound interest formula,” yet the implementation multiplies the prior index by1 + apr * dt / YEAR, which is simple interest.Readers expecting compounding logic will misinterpret how the target index grows, making it harder to reason about the accrual model.
Recommendation
Adjust the comment to describe simple interest accurately, or switch the implementation to a true compound interest calculation if that was the intended behavior.
Resolution
Strata Team: Resolved.
-
I-03 Informational Unused Code Best Practices Resolved
Description
There is unused code in multiple parts of the system. The
MathExtlibrary is not used at all.Unused Imports:
MathExtincontracts/tranches/Accounting.solIERC4626 contracts/tranches/StrataCDO.solIERC20,IERC4626,SafeERC20,IErrors,AccessControlledincontracts/tranches/Strategy.sol
Recommendation
Consider removing unused code.
Resolution
Strata Team: Resolved.
-
I-04 Informational Typos Informational Resolved
Description
event NewAccessControlManager(address accessControllManager);- replace
accessControllManagerwithaccessControlManagerRecommendation
Fix the typos.
Resolution
Strata Team: Resolved.
-
I-05 Informational SRT Yield Is Not Guaranteed Warning Acknowledged
Description
The docs state out that the senior tranches yield is guaranteed to be at minimum the SSR. However, this requires enough funds in the junior tranche to take the yield from. In case the NAV of the junior tranche approaches zero the yield is no longer guaranteed.
As the SSR and the yield from
sUSDeare not related it is possible that the SSR yield will become bigger than thesUSDeyield in the future. In this case the JRT will shrink up to the point of the SRT yield no longer being guaranteed.This risk is especially given in a bear market as Ethena is a very bullish protocol. The yield of
sUSDeis mostly given by funding fees from the hedging short positions.In a bear market where more perp's liquidity is on the short side than the long side these hedging positions actually need to pay funding fees instead of gaining them. This can drastically reduce the yield of
sUSDeor even set it to 0 (or in the worst-case lead to a collapse of Ethena).In such a bearish scenario it is likely that the SSR will outperform the
sUSDeyield as its yield source is less reliant on general crypto market conditions.Recommendation
Be aware of this worst case scenario, think about steps to take in that case and continuously analyze market conditions to be able to react quickly.
Resolution
Strata Team: Acknowledged.
Remediation Review
4 findings-
M-01 Medium Treasury Payouts Can Be DoS DoS Resolved
Description
ERC20Cooldown.transferstores each pending cooldown underactiveRequests[address(token)][to]and refuses new cross-user requests once the array length reachesPUBLIC_REQUEST_SLOTS_CAP.Any shareholder can withdraw through the tranche to an arbitrary receiver, so a malicious actor can enqueue 40 dust-sized cooldowns for a shared address by calling:
if (initialFrom = to requestsCount > PUBLIC_REQUEST_SLOTS_CAP) { revert ExternalReceiverRequestLimitRiched(token, initialFrom, to, amount); }On the other hand,
StrataCDO.reduceReservesends senior withdrawals throughsUSDeStrategy.reduceReserve, which enqueues the payout inUnstakeCooldown.transferunderactiveRequests[address(token)][treasury].The cooldown engine enforces
PUBLIC_REQUEST_SLOTS_CAP = 40for cross-user requests. An attacker can repeatedly withdraw a dust amount to the treasury address, filling the 40-slot queue with pending entries that mature only after the seven-day cooldown.Once saturated, every subsequent
reduceReservecall reverts withExternalReceiverRequestLimitRiched, preventing the protocol from delivering reserve funds—even though the attacker only sacrificed trivial amounts they later recover. In practice this allows a single account to freeze the treasury payout pipeline for the entire cooldown period.Because finalize only pops fully matured entries, the victim must wait a full cooldown duration (7 days by default) before capacity frees up. Until then, all further cross-user withdrawals to that
receiverrevert. The attacker sacrifices only minimal capital that the target eventually receives, so the DoS is cheap and repeatable.Recommendation
Consider exposing an owner/operator function that lets the receiver prune third-party requests without waiting out the entire cooldown.
Resolution
Strata Team: Resolved.
-
I-01 Informational MIN_SHARES Blocks Final Tranche Withdrawals Configuration Acknowledged
Description
Tranche._onAfterWithdrawalChecks()reverts whenevertotalSupply()drops belowMIN_SHARES:function _onAfterWithdrawalChecks () internal view { if (totalSupply() < MIN_SHARES) { revert MinSharesViolation(); } }The contract never seed-mints those 0.1 ether shares during
initialize, nor anywhere else, so the supply floor is enforced on ordinary user balances.As liquidity is drained (or even on first deposits if supply stays under 0.1 ether), the next withdrawal burns shares, sees
totalSupply() < MIN_SHARESand reverts, leaving the remaining assets permanently locked.Recommendation
Consider performing an initial deposit of exactly
MIN_SHARESand setting the receiver to an irrecoverable burn address (e.g.,address(1)), so those shares remain outside user balances while satisfying the floor.Resolution
Strata Team: Acknowledged.
-
I-02 Informational Global Selector Fallback In ACL Reverts Compatibility Acknowledged
Description
AccessControlManager.isAllowedToCallfirst checks a role scoped to the calling contract and then attempts a global fallback by invokingroleFor(address(0), sel).The helper enforces
require(contractAddress = address(0) sel = bytes4(0), "StrictPermissionOnly"), so the fallback always reverts, makinggrantCall(address(0), sel, …)unusable.Any guard that relies on
_checkAccessAllowedwill revert for globally granted selectors, preventing operators from using the policy documented ingrantCall.Recommendation
Merely informative, as this restriction was added as a fix to the M-02: Global Fallback
grantCallgivesDEFAULT_ADMINrole issueResolution
Strata Team: Acknowledged.
-
I-03 Informational IStrategy.withdraw Parameter Typo Best Practices Resolved
Description
The interface
IStrategy.withdrawdeclares the fourth argument asbseAssets, omitting the “a”, while every implementation and call site expectsbaseAssets, for examplesUSDeStrategy.withdraw.Recommendation
Rename the interface argument to
baseAssets.Resolution
Strata Team: Resolved.
No findings match.
Invariants 14
The review's fuzzing suite asserted 14 invariants. 13 held and 1 did not.
Every invariant tested
| ID | Invariant | Result |
|---|---|---|
GLOB-01 | Total NAV should equal sum of tranche NAVs and reserves | Held |
GLOB-02 | Strategy total assets should equal sum of tranche assets | Held |
GLOB-03 | ERC20 cooldown SUSDe balance should equal or greater than total ERC20 | Held |
TRANCHE-01 | cooldown amount After mint or deposit, the total assets should increase and the total supply | Held |
TRANCHE-02 | should increase After withdraw or redeem, the total assets should decrease and the total | Held |
COOLDOWN-01 | supply should decrease After cooldown request, the strategy total assets should decrease and the erc20CooldownSUSDe balance should | Held |
COOLDOWN-02 | increase After cooldown finalize, the erc20CooldownSUSDe balance should | Held |
COOLDOWN-03 | decrease After cooldown finalize, the unstakeCooldownTotalAmount should decrease | Held |
COOLDOWN-04 | After cooldown request, the strategy total assets should decrease and the unstakeCooldownTotalAmount should | Held |
STRATEGY-01 | increase srtTargetIndex must strictly increase when dt>0 and aprSrt>0 | Held |
STRATEGY-02 | Junior tranche NAV should be within 2 wei of jrtTotalAssets | Held |
STRATEGY-03 | Senior tranche NAV should be within 2 wei of srtTotalAssets | Held |
STRATEGY-04 | Total NAV should be within 2 wei of strategy total assets | Held |
STRATEGY-05 | Total assets should decrease after reduce reserve | Broken |
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.
