Quantstamp reviewed Strata (Strata Markets Update - Isolated and Saturn Strate) on 11 Aug 2026: 22 findings, 0 critical; 17 marked fixed, 4 acknowledged, 1 open.
Report hosted on certificate.quantstamp.com.
Trustblock does not review code.
Statuses are the security firm’s own, from its report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 0 |
| Medium | 1 | 0 | 6 |
| Low | 0 | 4 | 11 |
The function `SaturnCooldownRequestImpl.finalize()` calls `sUSDat.claim()`, which claims every processed withdrawal NFT owned by the proxy rather than the specific `requestId` created for the current withdrawal. An attacker can transfer a smaller processed withdrawal NFT to the proxy, causing `finalize()` to succeed while the proxy's actual request is still pending. `UnstakeCooldown` then removes the active request and returns the proxy to its pool, leaving the real withdrawal NFT without a tracked finalization path. Once that request is eventually processed, the user's funds remain in the proxy until the proxy contract is reused within `UnstakeCooldown` and gets finalized as part of a later withdrawal.
The `IsolatedStrategy.imbalances()` function is intended to set the Junior strategy target to the greater of Junior's tranche-level accounting entitlement and the configured minimum liquid allocation. Instead, whenever the Junior strategy's physical assets are below the floor amount, the function unconditionally replaces `jrtNavT0` with that floor amount. If Junior's entitlement is already greater than the floor, this lowers the target rather than preserving the larger entitlement, this is contrary to the behaviour outlined in the SIP-06 specification. For example, consider Junior and Senior entitlements of 1,000 and 100, while their strategies physically hold 100 and 1,000. With a 30% floor, the floor amount is 330. The Junior strategy's full deficit relative to its entitlement is 900, but the function initially reports only 230. A call to `initiateRebalanceByDebt()` therefore moves only enough assets to bring the Junior strategy to the floor. Once that pending or completed rebalance is included in Junior's effective strategy balance, the floor branch stops applying and the remaining 670 deficit becomes visible, requiring a second rebalance. Consequently, a nominal full-debt rebalance may require two operations and potentially two asynchronous withdrawal requests. This increases operational complexity and can leave more of Junior's economic backing exposed to the Senior strategy for longer than intended, particularly when the Senior strategy has a lengthy withdrawal cooldown.
`SharesCooldown` escrows redemption requests during their cooldown and lets each request attach optional `strategyOptions` (custom redemption parameters), stored in `requestMeta[vault][to][metaKey]` and referenced by a per-request `metaKey`, where the sentinel `metaKey == bytes12(0)` means "no options" and a real options request gets a unique key from a per-`(vault, to)` nonce. `metaKey == bytes12(0)` is the reserved "no `strategyOptions`" sentinel. But the per-`(vault, to)` metadata nonce post-increments from zero: ```solidity uint96 nonce = _metaKeyNonce[address(vault)][to]++; metaKey = bytes12(uint96(nonce)); requestMeta[address(vault)][to][metaKey] = TRequestMeta({ strategyOptions: strategyOptions }); ``` So the first options-bearing request for a `(vault, to)` derives `metaKey = bytes12(uint96(0)) = bytes12(0)`, indistinguishable from a no-metadata request, and stores its options at the sentinel slot `requestMeta[vault][to][bytes12(0)]`. The finalize/cancel functions only delete the metadata when `metaKey != bytes12(0)`, so that first request's metadata with `metaKey = bytes12(0)` is never deleted. Because every plain (no-options) request also carries `metaKey == bytes12(0)`, all of them read that one shared slot at finalize time and pick up the options that do not belong to their request i.e. wrong route/slippage/sub-strategy, producing unfavorable terms or a revert.
Between oracle updates, `DYSAccounting` grows Senior's NAV toward its target APR (`aprSrt`) by shifting value from Junior to Senior on paper, accumulating the unearned gain in `srtPnLProjected`. At the next reconciliation it reverses that `srtPnLProjected` against the pooled Senior base and returns it to Junior (`projUndo = Math.min(srtPnLProjected, srtNavT0); srtNavT0 -= projUndo; jrtNavT0Real += projUndo`), only consistent when every Senior that received the projected gain is still present. The flaw: a Senior can leave first. Its `maxWithdraw()` includes the projected gain, but `updateBalanceFlow()` does not decrement `srtPnLProjected`, so the assets leave while the `srtPnLProjected` stays the same and the pool-wide reversal charges the remaining Senior for a gain a departed Senior already carried out. Total NAV still balances (`navT1 == jrt + srt + reserve`), so value is silently redistributed rather than reverting. Who absorbs it depends on how much Senior stayed: 1. Partial exit (cap does not bind, more likely): With `srtNavT0 > srtPnLProjected`, the full `srtPnLProjected` is reversed so Junior is made whole, but the pooled remaining Senior is over-charged by the exiter's share and pushed below its own principal. 2. Near-total exit (cap binds): With `srtNavT0 < srtPnLProjected`, the reversal is capped, drives the remaining Senior base to zero, and the residual `srtPnLProjected − srtNavT0` falls on remaining Junior instead. The leak per exit equals the projected gain since the last reconciliation, larger when reconciliations are rare, and is repeatable by depositing, waiting, and withdrawing before each one.
`calculateRiskPremium()` calculates the Senior TVL ratio from the stored base NAVs without applying the valuation-loss adjustment. When `valuationPrice` is below `1e18`, the effective NAV calculation transfers value from Junior to Senior, increasing the Senior's effective share of total TVL. Because the risk-premium formula increases with the Senior TVL ratio, using the unadjusted ratio understates the risk premium during a depeg event. This can overpay Senior share holders and under-compensate Junior share holders precisely when juniors are absorbing additional valuation risk.
A sub-threshold NAV move permanently desyncs the accounting's `nav` from the tranche totals, which eventually bricks every deposit, redemption, and share-price read on the vault. The `updateAccountingInner()` function overwrites `nav` with the strategy's reported value on every call, including the projection branch. Projection only shifts value between Senior and Junior and leaves the reserve untouched, so the committed tranche totals (`srtBaseNav + jrtBaseNav + reserveNav`) remain at the previous total. Reconciliation is the only path that rewrites those totals to match `nav`, and it is skipped whenever the realized move is below 1000 wei. That threshold exists to skip unnecessary reconciliations on small roundings. Each such move advances `nav` without touching the totals, leaving `nav` permanently above the tranches sum. The next move above the 1000 wei threshold triggers a reconciliation. The split starts from the old, lower totals and adds `pnl = navT1 - nav`, so it lands short of `navT1` by the earlier delta and reverts with `InvalidNavSplit`. Every reconciling path then reverts, including `totalAssets()`, which prices shares, so deposits and redemptions are blocked. `DiscreteAccounting` reconciles on any nonzero move and is therefore unaffected. This issue is specific to the `DYSAccounting` dust threshold.
Every other state mutating entry point reads the strategy value with `cdo.totalStrategyAssets(nav, _navAnchor())`. `setFloorRate()` passes `navTimestamp` instead. ```1117:1117:contracts/tranches/DYSAccounting.sol updateAccountingInner(cdo.totalStrategyAssets(nav, navTimestamp)); ``` When `useNavAtReconciliation` is true, `_navAnchor()` returns `lastReconciliation`, not `navTimestamp`, so this path uses the wrong time anchor and can detect or skip a reconciliation differently from every other entry point during the floor rate update. It is owner only and limited in scope, but it can misstate the NAV computed at that one update and contradicts the documented anchor semantics.
Some `SharesCooldown` finalization paths call the external tranche redemption before removing the corresponding request from storage. If the selected output token invokes a receiver callback, the request owner can reenter `cancel()` while the request still exists and recover tranche shares after those shares have already been redeemed. When the selected request is the last array entry and another request remains, the outer execution can continue and remove a different request instead of reverting. This can pay the attacker both redemption assets and pooled tranche shares belonging to other cooldown requests, although the currently configured output tokens do not appear to provide such callbacks.
The function `MultiStrategy.totalAssets(navT0, timestamp)` treats any zero returned by a sub-strategy as a stale-oracle signal and returns the entire cached aggregate `navT0`. However, zero is also a valid fresh NAV when a sub-strategy has been fully withdrawn, rounded down, or suffered a complete loss. The protocol can consequently continue pricing deposits and withdrawals using assets that no longer exist instead of reconciling the loss. Existing holders may then withdraw against newly deposited capital, shifting an already-realized loss onto new users.
`MultiStrategy` stores a separate `accounting` reference even though its allocation calculations must use the accounting contract configured in its parent `StrataCDO`. The reference is independently mutable through `setAccounting()`, allowing it to diverge from the source used for tranche pricing and balance updates. If the two values differ, `IsolatedStrategy.imbalances()` calculates deficits and surpluses from unrelated NAV data. Deposits and rebalances may consequently be routed in the wrong direction.
`liquidAllocationFloor` can be initialized or updated to a value greater than `1e18`, even though the value represents a percentage of total NAV. Such a value produces a Junior target greater than total Junior and Senior NAV combined. The subsequent calculation of `totalNav - target` underflows and causes `imbalances()` to revert, which can block deposit routing and rebalancing operations.
Sub-strategies configured under a `MultiStrategy` treat that composite strategy as their `cdo`. Several strategy implementations call `cdo.isJrt(tranche)` to select tranche-specific cooldowns or configuration, but `MultiStrategy` does not expose that function. These calls revert when reached, which can block otherwise supported withdrawals from Midas, Saturn, Ethena, Neutrl, or Figure sub-strategies. Spark's local fallback avoids a revert but can apply the Senior cooldown to a Junior withdrawal instead.
`completeRebalance()` deposits the lesser of the available token balance and `pending.baseAssets` into the destination strategy. It nevertheless emits `RebalanceCompleted` with the original `pending.baseAssets`, even when the actual deposit was smaller. Off-chain accounting, monitoring, and automation that rely on the event can therefore record a larger completed rebalance than occurred on-chain.
When `useNavAtReconciliation` is enabled, `_navAnchor()` returns `lastReconciliation`, the timestamp strategies use to detect new rewards. The `updateAccountingInner()` function advances `lastReconciliation` to `block.timestamp` whenever `navT1 != nav`, but an actual reconciliation only occurs when the move exceeds the 1000 wei threshold in `_shouldReconcile()`. A sub-threshold move therefore advances the anchor without reconciling, leaving it later than the true last reconciliation. Because the anchor now sits after the true last reconciliation, those strategies suppress detection of oracle rounds whose `updatedAt` is before the shifted anchor, delaying the next reconciliation by up to one oracle cycle and extending the projection window during which Junior funds the Senior `aprSrt` accretion with no true-up. The effect self-heals once a newer round, or all sub-strategies, update past the anchor, so a large pending round cannot be masked this way.
`IsolatedStrategy.setStrats()` can replace the configured Junior and Senior strategies while they still hold protocol assets. The function updates the strategy array and token-routing mappings but does not migrate or otherwise account for assets in the previous strategies. Those assets immediately disappear from `MultiStrategy.totalAssets()` and are no longer reachable through normal withdrawal or rebalance routing. A privileged configuration mistake can therefore create a large apparent NAV loss and leave protocol funds orphaned.
`maxDeposit()` limits how much Senior can deposit so that Junior stays at least `minimumJrtSrtRatioBuffer` (6%) of Senior. It does this by measuring Junior and Senior *before* the deposit and allowing Senior to grow up to the point where Junior would equal 6% of it. Normally this is exact. When the asset trades at par, a Senior deposit only adds to Senior and leaves Junior untouched — it moves one side of the ratio by exactly the deposited amount and nothing else. So the "before" snapshot predicts the "after" state perfectly, and depositing the maximum lands the ratio right on 6%. During a de-peg this breaks, because `calcEffectiveNav()` marks Senior back up to par by pulling value out of Junior, and the size of that transfer grows with Senior. Now a Senior deposit no longer touches just one side of the ratio, it touches both. The new Senior demands its own top-up, so Junior is drained further (the numerator drops), and the new Senior is itself marked up to par (the denominator grows by more than the deposit). The `maxDeposit()` cap does not consider both effects because it sized the deposit against the smaller, pre-deposit Senior without any effective nav markup. So it approves a deposit it believes lands at 6%, while the real result is a lower Junior over a higher Senior, pushing the effective ratio below the 6% buffer. The size of this gap scales with how deep the de-peg is, since a larger de-peg means a larger Senior top-up drawn from Junior. Under an extreme de-peg a single permitted deposit can therefore breach the ratio far more heavily. In the current implementation deposits should be paused once the valuation price is below `1e18`, however this problem becomes apparent if deposits are later reenabled without the peg returning to 1e18.
Every deferred rebalance appends an entry to `pendingRebalances`, while `Rebalancer.totalAssets()` loops over the complete array. A privileged operator can create many small pending rebalances by splitting an available deficit, causing the cost of accounting reads to grow without a fixed bound. Because `totalAssets()` is used by protocol accounting and user flows, a sufficiently large array can make those transactions exceed the block gas limit. The role restriction makes this primarily an operational and configuration risk rather than an unprivileged denial of service.
The report names a repository or files. Open the report to see its scope.
236 projects reviewed · 332 reports · latest Oct 2026
See Quantstamp’s auditsSomething wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us