Skip to content
$1,000,000 in security audit grants are live now, Apply here →

Security review · October 2025

Tranches

for Strata

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

30 resolved · 3 acknowledged

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
  1. C-01 Critical Reserve Withdrawal Unit Mismatch Logical Error Resolved
    Location
    sUSDeStrategy.sol
    Round
    Main Review

    Description

    Proof of concept: PoC

    StrataCDO.reduceReserve debits accounting by the requested base-asset amount, then asks the strategy to send that many tokens to the treasury. In the USDe path, sUSDeStrategy.reduceReserve forwards the same value to unstakeCooldown.transfer as 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.transfer for token = USDe, compute the share amount with uint256 shares = sUSDe.previewWithdraw(tokenAmount); (or convertToShares) and supply shares instead of tokenAmount, keeping the accounting units consistent.

    Resolution

    Strata Team: Resolved.

  2. H-01 High Withdraw Griefing DoS DoS Resolved
    Location
    UnstakeCooldown.sol: 46-105
    Round
    Main Review

    Description

    Proof of concept: PoC

    • Anyone is able to use the transfer function of the ERC20Cooldown or UnstakeCooldown contract to

    create new proxies for an arbitrary recipient and lock up tokens

    • If this arbitrary recipient later on calls finalize the 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 finalize but the transaction runs out of gas as

    the loop is too big

    Recommendation

    Add access control to these functions.

    Resolution

    Strata Team: Resolved.

  3. H-02 High Withdrawals Flip Token And Base Assets Logical Error Resolved
    Location
    Tranche.sol: 205
    Round
    Main Review

    Description

    The call cdo.withdraw(address(this), token, baseAssets, tokenAssets, receiver); passes baseAssets and tokenAssets in the wrong order to StrataCDO.withdraw which 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.

  4. H-03 High Juniors Do Not Profit On Loss Logical Error Resolved
    Location
    Accounting.sol: 256
    Round
    Main Review

    Description

    Within function calculateNAVSplit, if the SRT reports a loss (case if (srtGainTarget < 0) ), the loss should be transferred to the Juniors as profit according to the comment.

    However, instead of adding the loss to jrtNavT1 which 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; and jrtNavT1 + srtLoss; instead.

    Resolution

    Strata Team: Resolved.

  5. H-04 High JRT totalSupply Will Grow Constantly DoS Resolved
    Location
    Accounting.sol
    Round
    Main Review

    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 JRT totalSupply unchanged 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) while JRT.totalSupply is unchanged. The price per JRT share becomes smaller. On the very next deposit, OpenZeppelin's ERC‑4626 share 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 and Math.mulDiv reverts, 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‑4626 deposit 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 Accounting contract. On the other hand, consider adding an automatic junior-price guard in the StrataCDO contract that re-computes the JRT share price after every flow and pauses deposits into both tranches whenever it slips below a configurable jrtShortfallPausePrice, preventing the low-price/high-supply regime that previously led to overflows

    Resolution

    Strata Team: Resolved.

  6. H-05 High MEV APR Front-Run Via onAprChanged MEV Resolved
    Location
    Accounting.sol
    Round
    Main Review

    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 in jrtNav/srtNav.

    An attacker can front‑run this keeper transaction with a large, temporary ERC‑4626 deposit into the Tranche that favors their objective (typically Senior), skewing the ratio srtNav / (srtNav + jrtNav) at the instant the keeper’s call lands, and then back‑run a withdrawal to fully exit.

    The manipulated aprSrt and newly reset indexTimestamp persist 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 a deposit before 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 aprSrt or roll the index in onAprChanged(). 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 cache aprTarget/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 aprSrt from the updated jrtNav/srtNav without rolling the index again (the new aprSrt applies going forward).

    Resolution

    Strata Team: Resolved.

  7. M-01 Medium setMinimumJrtSrtRatio Mutates Logical Error Resolved
    Location
    Accounting.sol
    Round
    Main Review

    Description

    In the Accounting contract, the configuration routine intended to update the junior/senior TVL safeguard writes the incoming value to reserveBps and emits a ReservePercentageChanged(reserveBps) event.

    The actual guardrail variable, minimumJrtSrtRatio is left untouched. Downstream logic that enforces the floor, for example uint256 minJrt = srtNav * minimumJrtSrtRatio / 1e18; and uint256 maxSrt = jrtNav * 1e18 / minimumJrtSrtRatio;, now continues to use the stale default.

    Meanwhile, the reserve split is silently overwritten with the requested ratio, because setReserveBps and setMinimumJrtSrtRatio both mutate reserveBps and emit the same ReservePercentageChanged event.

    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 example MinimumJrtSrtRatioChanged(bps)) and leave reserveBps unchanged so reserve configuration remains isolated from the ratio guardrail.

    Resolution

    Strata Team: Resolved.

  8. M-02 Medium grantCall Gives DEFAULT_ADMIN Role Validation Resolved
    Location
    AccessControlManager.sol
    Round
    Main Review

    Description

    Proof of concept: PoC

    AccessControlManager.grantCall takes a contract address and selector, derives a role with roleFor and forwards the request to OpenZeppelin's grantRole. 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) and sel = bytes4(0), the computed role collapses to bytes32(0), which OpenZeppelin defines as DEFAULT_ADMIN_ROLE.

    The NatSpec explicitly advertises this pattern: “if contractAddress is zero address, the account can access the specified function on any contract managed by this ACL”, and isAllowedToCall looks up roleFor(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 when contractAddress = address(0) and sel = bytes4(0) or require admins to call grantRole(DEFAULT_ADMIN_ROLE, …) explicitly.

    Resolution

    Strata Team: Resolved.

  9. M-03 Medium JRT maxWithdraw Redemption DoS DoS Resolved
    Location
    Accounting.sol: 124-131
    Round
    Main Review

    Description

    The maxWithdraw function 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.

  10. M-04 Medium Missing updateAccounting Call Rewards Resolved
    Location
    Global
    Round
    Main Review

    Description

    Proof of concept: PoC

    The system does not call updateAccounting before updating parameters which will influence its outcome.

    For example, the setReserveBps function will update the reserveBps parameter which influences the gain of the tranches.

    As updateAccounting is 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 updateAccounting before updating any parameter that will influence its outcome.

    Resolution

    Strata Team: Resolved.

  11. M-05 Medium Redeem Mistreats Shares As Assets Logical Error Resolved
    Location
    Tranche.sol: 150
    Round
    Main Review

    Description

    Function redeem routes the redemption of shares through the Tranche’s withdraw function: uint256 assets = super.withdraw(shares, receiver, owner);

    However, function withdraw in ERC-4626 expects 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 function redeem.

    Resolution

    Strata Team: Resolved.

  12. M-06 Medium Old Data Can Overwrite Newer Data Validation Resolved
    Location
    AprPairFeed.sol: 91-95
    Round
    Main Review

    Description

    The updateRoundData pushes APR values by providing aprTarget and aprBase values. It saves them together with the current block.timestamp.

    However, the values must not be from this block.timestamp they 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.timestamp as param and make sure that a older transaction does not overwrite a newer one.

    Resolution

    Strata Team: Resolved.

  13. M-07 Medium JRT maxMint Reverts When Share Price < 1 Logical Error Resolved
    Location
    Tranche.sol
    Round
    Main Review

    Description

    Proof of concept: PoC

    StrataCDO.maxDeposit(address(this)) returns type(uint256).max for the junior tranche. Tranche.maxMint passes that value to _convertToShares, which multiplies it by totalSupply() + 1 and then divides by totalAssets() + 1.

    Whenever the junior share price dips below 1 (i.e. totalAssets < totalSupply after juniors absorb a loss), the 512-bit multiplication produces a high word greater than the denominator.

    OpenZeppelin's Math.mulDiv detects this overflow and reverts with a panic: arithmeticError instead of returning a finite cap.

    Because ERC4626.mint always calls maxMint(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).max directly from maxMint instead, when “no deposit limit” is required.

    Resolution

    Strata Team: Resolved.

  14. M-08 Medium Cooldown Removal Ignores Pending Requests Validation Resolved
    Location
    UnstakeCooldown.sol: 85
    Round
    Main Review

    Description

    The transfer function in the UnstakeCooldown contract 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 finalize to complete unstaking once the cooldown expires. The issue arises when cooldownDuration is later set to zero. In this case, new unstaking requests can be withdrawn immediately, but existing pending requests remain locked.

    This is because finalize validates requests using the originally stored unlockAt timestamp, 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.

  15. M-09 Medium Old Implementations Remain Active Logical Error Resolved
    Location
    UnstakeCooldown.sol: 146
    Round
    Main Review

    Description

    The setImplementations function in the UnstakeCooldown contract 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.

  16. M-10 Medium Risk > 100% Freezes Senior APR Math DoS Resolved
    Location
    Accounting.sol
    Round
    Main Review

    Description

    Proof of concept: PoC

    Accounting.calculateRiskPremium() derives a “risk” factor as riskX + riskY * pow(tvlRatio, riskK), where tvlRatio = srtNav / (srtNav + jrtNav) and the three coefficients are admin-configurable. When updateIndexes runs, it applies that factor in aprSrt1 = mul(aprBase_, UD60x18.wrap(1e18) - risk).

    The math uses the unsigned UD60x18 type, so 1e18 represents 100%. If runtime risk ever reaches or exceeds 1e18, the subtraction underflows and the transaction reverts.

    The existing guard in setRiskParameters only 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 drives tvlRatio close to 1 can push risk above 100%.

    From that point on, every call to updateIndexes and, 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 bound riskK to a very sensible range.

    Second, inside updateIndexes, check if (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.

  17. M-11 Medium APR Updates Rewrite The Previous Accrual Window Logical Error Resolved
    Location
    Accounting.sol
    Round
    Main Review

    Description

    Proof of concept: PoC

    getSrtTargetIndexT1() expands the index over the elapsed interval dt = block.timestamp - indexTimestamp using the newly stored aprSrt.

    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 calculateNAVSplit output, 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:

    1. Advance srtTargetIndex using the current aprSrt (as of the previous update).
    2. Only after the index and indexTimestamp are updated, assign the new APR parameters that

    should take effect going forward.

    Resolution

    Strata Team: Resolved.

  18. M-12 Medium Negative APRs Break Accounting Updates Validation Resolved
    Location
    Accounting.sol, AprPairFeed.sol
    Round
    Main Review

    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), normalizeAprFromFeed reverts and onAprChanged fails, 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 Accounting before normalization, or narrow AprPairFeed's bounds to match Accounting so that impossible inputs are rejected at the feed boundary.

    Resolution

    Strata Team: Resolved.

  19. M-13 Medium reduceReserve Uses Stale Accounting Logical Error Resolved
    Location
    StrataCDO.sol: 193
    Round
    Main Review

    Description

    The reduceReserve function can be called by an address with the RESERVE_MANAGER_ROLE to reduce the reserve and transfer tokens to the treasury. However, the function does not call updateAccounting beforehand, which means reserveNav may not reflect the latest state.

    If reserveNav is understated, reduceReserve won't be able to withdraw up to the actual available amount. More critically, if reserveNav is overstated, losses have not yet been allocated (JRT → Reserve → SRT).

    In this case, reduceReserve may withdraw more than it should. Later, when updateAccounting runs, the remaining reserve is smaller, and additional losses are pushed to the SRT tranche or can’t be fully covered.

    Recommendation

    Modify the reduceReserve function to call updateAccounting before reducing reserves to ensure reserveNav reflects the latest state.

    Resolution

    Strata Team: Resolved.

  20. L-01 Low Incorrect Validation In setProvider Function Validation Resolved
    Location
    AprPairFeed.sol
    Round
    Main Review

    Description

    AprPairFeed.setProvider attempts to verify the replacement APR provider before wiring it into the feed, but the check is pointed at the wrong address. The function still calls provider.getAprPair(). It queries whatever implementation was already set—before assigning provider = 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 write provider = provider_.

    If getAprPair() throws or returns out-of-range values, revert the transaction to keep the existing, working oracle.

    Resolution

    Strata Team: Resolved.

  21. L-02 Low Feed Updates Skip Accounting Refresh Configuration Resolved
    Location
    AprPairFeed.sol
    Round
    Main Review

    Description

    AprPairFeed.updateRoundData only writes latestRound/latestRoundId and emits an AnswerUpdated event, 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 recompute aprTarget, aprBase and 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.

  22. L-03 Low Max Limits Ignore Min Shares Guard Compatibility Resolved
    Location
    Tranche.sol
    Round
    Main Review

    Description

    Tranche.maxWithdraw and Tranche.maxRedeem return values that take tranche caps into account but do not consider _onAfterWithdrawalChecks, which is invoked at the end of every withdraw/redeem call and reverts with MinSharesViolation whenever the post-withdraw total supply would fall below MIN_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-4626 explicitly requires each max helper to “MUST NOT be higher than the actual maximum that would be accepted,” so the current behavior violates the ERC-4626 standard and breaks downstream slippage/limit checks.

    Recommendation

    Incorporate the minimum-supply guard into maxWithdraw, maxRedeem and any preview helpers that rely on those values. One approach is to compute the hypothetical post-withdraw supply and, if it would drop below MIN_SHARES, reduce the advertised limit accordingly.

    Resolution

    Strata Team: Resolved.

  23. L-04 Low Missing Event In Critical Function Best Practices Resolved
    Location
    Accounting.sol: 350-360
    Round
    Main Review

    Description

    The setRiskParameters functions 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.

  24. L-05 Low ERC20Cooldown Accepts Any Token Validation Resolved
    Location
    ERC20Cooldown.sol: 28
    Round
    Main Review

    Description

    The transfer function in the ERC20Cooldown contract 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 UnstakeCooldown contract.

    Resolution

    Strata Team: Resolved.

  25. I-01 Informational STR APR Can Be Stale Warning Resolved
    Location
    Global
    Round
    Main Review

    Description

    Senior tranche APR changes must be applied manually with the onAprChanged function. If this function is called too late, the yield is distributed in a unfair manner.

    Recommendation

    Make sure to call onAprChanged and updateAccounting very 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.

  26. I-02 Informational Compound VS Linear Comment Mismatch Warning Resolved
    Location
    Accounting.sol
    Round
    Main Review

    Description

    calculateTargetIndex is documented as “using compound interest formula,” yet the implementation multiplies the prior index by 1 + 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.

  27. I-03 Informational Unused Code Best Practices Resolved
    Location
    Global
    Round
    Main Review

    Description

    There is unused code in multiple parts of the system. The MathExt library is not used at all.

    Unused Imports:

    • MathExt in contracts/tranches/Accounting.sol
    • IERC4626 contracts/tranches/StrataCDO.sol
    • IERC20, IERC4626, SafeERC20, IErrors, AccessControlled in contracts/tranches/Strategy.sol

    Recommendation

    Consider removing unused code.

    Resolution

    Strata Team: Resolved.

  28. I-04 Informational Typos Informational Resolved
    Location
    Global
    Round
    Main Review

    Description

    • event NewAccessControlManager(address accessControllManager); - replace

    accessControllManager with accessControlManager

    Recommendation

    Fix the typos.

    Resolution

    Strata Team: Resolved.

  29. I-05 Informational SRT Yield Is Not Guaranteed Warning Acknowledged
    Location
    Global
    Round
    Main Review

    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 sUSDe are not related it is possible that the SSR yield will become bigger than the sUSDe yield 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 sUSDe is 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 sUSDe or 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 sUSDe yield 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
  1. M-01 Medium Treasury Payouts Can Be DoS DoS Resolved
    Location
    ERC20Cooldown.sol
    Round
    Remediation Review

    Description

    ERC20Cooldown.transfer stores each pending cooldown under activeRequests[address(token)][to] and refuses new cross-user requests once the array length reaches PUBLIC_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.reduceReserve sends senior withdrawals through sUSDeStrategy.reduceReserve, which enqueues the payout in UnstakeCooldown.transfer under activeRequests[address(token)][treasury].

    The cooldown engine enforces PUBLIC_REQUEST_SLOTS_CAP = 40 for 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 reduceReserve call reverts with ExternalReceiverRequestLimitRiched, 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 receiver revert. 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.

  2. I-01 Informational MIN_SHARES Blocks Final Tranche Withdrawals Configuration Acknowledged
    Location
    Tranche.sol
    Round
    Remediation Review

    Description

    Tranche._onAfterWithdrawalChecks() reverts whenever totalSupply() drops below MIN_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_SHARES and reverts, leaving the remaining assets permanently locked.

    Recommendation

    Consider performing an initial deposit of exactly MIN_SHARES and 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.

  3. I-02 Informational Global Selector Fallback In ACL Reverts Compatibility Acknowledged
    Location
    AccessControlManager.sol
    Round
    Remediation Review

    Description

    AccessControlManager.isAllowedToCall first checks a role scoped to the calling contract and then attempts a global fallback by invoking roleFor(address(0), sel).

    The helper enforces require(contractAddress = address(0) sel = bytes4(0), "StrictPermissionOnly"), so the fallback always reverts, making grantCall(address(0), sel, …) unusable.

    Any guard that relies on _checkAccessAllowed will revert for globally granted selectors, preventing operators from using the policy documented in grantCall.

    Recommendation

    Merely informative, as this restriction was added as a fix to the M-02: Global Fallback grantCall gives DEFAULT_ADMIN role issue

    Resolution

    Strata Team: Acknowledged.

  4. I-03 Informational IStrategy.withdraw Parameter Typo Best Practices Resolved
    Location
    IStrategy.sol
    Round
    Remediation Review

    Description

    The interface IStrategy.withdraw declares the fourth argument as bseAssets, omitting the “a”, while every implementation and call site expects baseAssets, for example sUSDeStrategy.withdraw.

    Recommendation

    Rename the interface argument to baseAssets.

    Resolution

    Strata Team: Resolved.

Invariants 14

The review's fuzzing suite asserted 14 invariants. 13 held and 1 did not.

Every invariant tested
IDInvariantResult
GLOB-01Total NAV should equal sum of tranche NAVs and reservesHeld
GLOB-02Strategy total assets should equal sum of tranche assetsHeld
GLOB-03ERC20 cooldown SUSDe balance should equal or greater than total ERC20Held
TRANCHE-01cooldown amount After mint or deposit, the total assets should increase and the total supplyHeld
TRANCHE-02should increase After withdraw or redeem, the total assets should decrease and the totalHeld
COOLDOWN-01supply should decrease After cooldown request, the strategy total assets should decrease and the erc20CooldownSUSDe balance shouldHeld
COOLDOWN-02increase After cooldown finalize, the erc20CooldownSUSDe balance shouldHeld
COOLDOWN-03decrease After cooldown finalize, the unstakeCooldownTotalAmount should decreaseHeld
COOLDOWN-04After cooldown request, the strategy total assets should decrease and the unstakeCooldownTotalAmount shouldHeld
STRATEGY-01increase srtTargetIndex must strictly increase when dt>0 and aprSrt>0Held
STRATEGY-02Junior tranche NAV should be within 2 wei of jrtTotalAssetsHeld
STRATEGY-03Senior tranche NAV should be within 2 wei of srtTotalAssetsHeld
STRATEGY-04Total NAV should be within 2 wei of strategy total assetsHeld
STRATEGY-05Total assets should decrease after reduce reserveBroken

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.

Get a quote