Guardian's review of Strategy Vault Updates for Orderly, published September 2025. The report records 30 findings across 2 review rounds, including 1 high and 5 medium.
- Published
- Review window
- August 19 to September 5, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum, Arbitrum, Optimism, Base, Solana
- Sector
- Perpetuals
- 0 Critical
- 1 High
- 5 Medium
- 16 Low
- 8 Informational
Scope
Findings 30
Main Review
21 findings · August 19 to 22, 2025-
M-01 Medium Claiming Can Bypass Required Cross‑chain Fee Configuration Resolved
Description
ProtocolVault.updateUnClaimedcredits users with claimable assets and increments a per‑user fee accumulator:// ProtocolVault.updateUnClaimed(...) userClaimedById[userId][USDC_HASH].unClaimedAssets += userClaimInfos[i].assets; crossChainFee[userId] += ccFee;However, the non‑payable
claim()path does not consultcrossChainFeeat all, it simply transfers the full amount and never clears any pending fee:function claim(ClaimParams memory claimParams) external whenNotPaused { // no check on crossChainFee[id] uint256 amount = userClaimedById[id][USDC_HASH].unClaimedAssets; userClaimedById[id][USDC_HASH].unClaimedAssets = 0; delete userClaimedById[id][USDC_HASH].requestIds; SafeTransferLib.safeTransfer(ERC20(claimParams.token), msg.sender, amount); }By contrast,
claimWithFee()does enforce and clear the fee:function claimWithFee(ClaimParams memory claimParams) external payable whenNotPaused { require(msg.value == crossChainFee[id], "NotEnoughCCFee()"); ... delete crossChainFee[id]; }This allows a user with
crossChainFee[id] > 0to callclaim()and receive their assets without paying the intended cross‑chain fee. The fee then “dangles” in storage and can continue to accumulate across updates, creating an inconsistent state and a systematic loss of expected fee revenue. If a UI later usesclaimWithFee(), exact‑equality on the accumulatedcrossChainFee[id]may also cause confusing reverts when there is no remainingunClaimedAssets.Therefore, users can repeatedly avoid paying cross‑chain fees while still receiving their assets, causing protocol fee leakage and stale fee balances.
Recommendation
Enforce fee settlement whenever a fee is pending. The simplest hardening is to block the free path when a fee exists:
// In claim(): require(crossChainFee[id] == 0, "Fee pending — use claimWithFee");Alternatively, merge both flows into a single payable
claim()that conditionally requires and clears the fee:if (crossChainFee[id] > 0) { require(msg.value == crossChainFee[id], "fee mismatch"); delete crossChainFee[id]; }Also consider clearing
crossChainFee[id]only upon successful claims and documenting whether fees are per‑claim or per‑user to avoid future ambiguity. -
M-02 Medium DEX Requests Are Marked “handled” Even When They Fail, Blocking Retries Logical Error Acknowledged
Description
In
handleDexRequests, each item is flagged as handled before the contract knows whether the state change succeeded. The function setsisDexRequestHandled[requestId] = trueand then calls_handleRequest. When_handleRequestreturnsfalse(typical for withdrawals if there aren’t enoughpendingSharesorpendingStrategyProviderSharesto freeze), the function only emitsDexWithdrawNotEnough(requestId)but leaves the “handled” flag set. Because the request is now considered consumed, resubmitting the samedexRequestIdlater, after balances are updated by the period pipeline, will be rejected with anAlreadyCallederror and the withdrawal can never progress.Minimal excerpt showing the ordering problem:
isDexRequestHandled[requestId] = true; // set too early if (_handleRequest(request.dexRequestData.payloadType, request.id, tokenHash, request.dexRequestData.amount) { emit DexRequestHandled(request); } else { emit DexWithdrawNotEnough(requestId); // but requestId already consumed }This affects
LP_WITHDRAWandSP_WITHDRAWflows, where_handleRequestchecks_checkWithdrawagainst pending share balances and returnsfalsewhen the operator has not yet staged enough pending shares for the id.The main impact of this issue is that valid withdrawals can be permanently dropped due to timing, causing operational DoS and cross‑chain accounting mismatches.
Recommendation
Consider only marking a DEX request as handled after a successful state change, e.g.:
if (_handleRequest(...)) { isDexRequestHandled[requestId] = true; } -
M-03 Medium New Strategies Never Receive LP Deposits Configuration Resolved
Description
In
LedgerCoreImpl.allocateToFunds, the multi‑strategy deposit path distributespendingLpDepositAssetspro‑rata to existing main vault exposure only, computed from each strategy’smainSharesconverted to assets. When a strategy is added later (e.g., at period N) it starts withmainShares == 0. The allocation loop first sums the current main exposure:totalMainAssetsInFund += LedgerUtils._convertToAssets( strategyFundToken.mainShares, strategyFundToken.fundAssetsAfterFee, strategyFundToken.totalShares, Math.Rounding.Floor );and then assigns each strategy’s share:
uint256 mainAssetsInFund = LedgerUtils._convertToAssets( strategyFundToken.mainShares, strategyFundToken.fundAssetsAfterFee, strategyFundToken.totalShares, Math.Rounding.Floor ); uint256 distributeDepositAssets = depositAssets.mulDiv(mainAssetsInFund, totalMainAssetsInFund, Math.Rounding.Floor);A newly added strategy therefore receives zero from LP deposits because
mainAssetsInFund == 0. Self‑funding viaSP_DEPOSITdoes not help:_handleSPDepositmints strategy‑provider shares (pendingStrategyProviderShares) and updatespendingTotalShares/Assets, but does not increasependingMainShares, so the strategy’s weight in future LP splits remains zero. SinceallocateToFundsis allowed only once per period, operators cannot do a “seed then allocate” pattern in the same period.Recommendation
Incorporate a method for the operator to allocate a desired seed amount of main vault capital to a newly added strategy (e.g., “rebalance from existing strategies” to ensure the newcomer starts with a non-zero main share).
-
M-04 Medium
allocateToFundsPartial DOS Logical Error AcknowledgedDescription
LedgerCoreImpl.allocateToFunds()distributes all processed deposits and withdrawals to specified strategies. In case there is more than one strategy, funds will be split proportionally. The problem arises when there aren't any funds in the strategies, either because it's the start of the vault or because they have been naturally depleted in a previous period.In that case,
totalMainAssetsInFundwill be computed as 0 and the following division will revert:uint256 distributeDepositAssets = depositAssets.mulDiv(mainAssetsInFund, totalMainAssetsInFund, Math.Rounding.Floor);In result, allocating the funds to multiple strategies won't be possible and the Orderly operator will be forced to choose only one strategy for the given period.
Recommendation
Split all the funds evenly between the strategies if
totalMainAssetsInFund == 0 -
L-01 Low Lack Of Deadline For Signatures Signatures Acknowledged
Description
Signatures in the Ledger contract don't have deadline parameter which means they may be executed in any point in time. This can lead to unfavorable actions being executed, especially for dex requests.
Recommendation
Consider adding deadline checks.
-
L-02 Low Missing Source Chain Validation For Ledger‑Originated Messages In _lzReceive Validation Acknowledged
Description
VaultCrossChainManager._lzReceiveaccepts two ledger‑originated message types,ASSETS_DISTRIBUTIONandUPDATE_USER_CLAIMand immediately decodes and acts on them without asserting that they actually came from the ledger chain.In the
ASSETS_DISTRIBUTIONbranch the code decodes the payload and then calls intoProtocolVault.depositToStrategy. Similarly, inUPDATE_USER_CLAIMthe code decodes and updates claims.Neither branch validates that
strategyVaultCCmessage.srcChainIdcorresponds to the configured ledger chain (nor cross‑checks it against the LayerZeroOrigin.srcEid). While the inherited receiver already enforces that the message comes from a configured peer for a given EID, the contract may still be configured with multiple peers/EIDs. Without an explicit “ledger chain only” check here, any other configured peer could send these payload types and the handler would accept them, and the handler trusts the chain IDs embedded inside the message for decimal conversion. Impact: a misconfigured or compromised non‑ledger peer could trigger unintended strategy deposits or mutate claim balances, and incorrectsrc/dstIDs inside the message could skew amount conversions.Recommendation
Defensively assert that these branches only execute for messages whose source is the ledger chain and, optionally, that the embedded
dstChainIdmatchesblock.chainid. A minimal fix is to add a source‑chain check that uses either a storedledgerChainIdor the EID mapping together with the LayerZeroOrigin:require(chainIdToEid[strategyVaultCCmessage.srcChainId] == _origin.srcEid, "invalid ledger source");Apply the same check in both
ASSETS_DISTRIBUTIONandUPDATE_USER_CLAIMbranches. Optionally also assertstrategyVaultCCmessage.dstChainId == block.chainidto prevent forged destination IDs affecting decimal conversion. -
L-03 Low Solana DEX ID Validation Ignores Payload Type Validation Acknowledged
Description
In
LedgerExtension._verifySolRequest, Solana DEX requests always validate the identifier (request.id) as an LP account ID, irrespective of thepayloadType. The code path is:function _verifySolRequest(DexRequest calldata request) internal view { DexRequestData calldata data = request.dexRequestData; // Verify id (always as Account ID) if (!VaultUtils.validateAccountId(data.receiver, vaultBroker[data.vaultId], request.id)) { revert InvalidId(); } Signature.verifySOLSig(data, request.r, request.s, request.chainId, data.receiver); }For
SP_DEPOSITorSP_WITHDRAW, this means a legitimate strategy provider ID will be rejected because it is checked as an account ID. Conversely, a caller can submit an SP request using a valid account ID and pass_verifySolRequest. Execution then proceeds to_handleRequest; for example, the SP‑withdraw branch:} else if (payloadType == PayloadType.SP_WITHDRAW) { if (_checkWithdraw(amount, strategyFundToken.frozenShares, strategyFundToken.pendingState.pendingStrategyProviderShares)) { strategyFundToken.frozenShares += amount; } else { return false; } }Because the identifier that passed validation is an account ID (not a strategy provider ID), the SP share bucket is zero and
_checkWithdrawfails; however, inhandleDexRequeststhe request is marked as handled before invoking_handleRequest:isDexRequestHandled[requestId] = true; // set before attempting the state change if (_handleRequest(...)) { ... } else { ... } // failure does not unset the flagThis combination produces two failures: first, legitimate SP operations on Solana are blocked by incorrect on‑chain ID validation; second, a mismatched SP request that slips through the validator burns the
dexRequestId(since it is flagged as handled even when_handleRequestreturnsfalse), preventing a corrected re‑submission with the same ID and forcing off‑chain remediation.Recommendation
Validate the identifier according to
payloadTypeand only markisDexRequestHandledafter a successful state transition. Concretely, in_verifySolRequestbranch on the payload:if (data.payloadType == PayloadType.LP_DEPOSIT || data.payloadType == PayloadType.LP_WITHDRAW) { require(VaultUtils.validateAccountId(data.receiver, vaultBroker[data.vaultId], request.id), "InvalidId"); } else if (data.payloadType == PayloadType.SP_DEPOSIT || data.payloadType == PayloadType.SP_WITHDRAW) { require(VaultUtils.validateSPId(protocolVault /* or SP namespace */, vaultBroker[data.vaultId], request.id), "InvalidId"); } else { revert InvalidType(); }Then, in
handleDexRequests, assignisDexRequestHandled[requestId] = trueonly if_handleRequestreturnstrue; if it returnsfalse, revert (or store a separate “failed” status) so the DEX can correct and resubmit without losing the request ID. -
L-04 Low Rounding In Multi‑fund Allocation Causes Systematic Under/over Accounting Rounding Resolved
Description
In the multi‑fund branch of
LedgerCoreImpl.allocateToFunds, the contract splits deposits and withdrawals proportionally across strategies using different rounding directions per fund and does not perform any balancing step afterward. For deposits, the per‑fund split usesMath.Rounding.Floor:uint256 distributeDepositAssets = depositAssets.mulDiv(mainAssetsInFund, totalMainAssetsInFund, Math.Rounding.Floor);Every slice is rounded down, so the sum of all
distributeDepositAssetsis ≤pendingLpDepositAssets, leaving unassigned “dust” that never reaches any strategy’spendingTotalAssetsin that period. For withdrawals, the split usesMath.Rounding.Ceil:uint256 distributeWithdrawAssets = withdrawAssets.mulDiv(mainAssetsInFund, totalMainAssetsInFund, Math.Rounding.Ceil);Every slice is rounded up, so the sum of all
distributeWithdrawAssetsis ≥pendingLpWithdrawAssetsand the contract subtracts more from strategies’pendingTotalAssetsthan the requested withdrawal. Over time, repeated floors on deposits and ceils on withdrawals create a systematic downward drift in per‑fund asset totals, which can bias NAV/share pricing. As a simple illustration with three equal funds and 100 units: floor on deposits yields 33+33+33=99 (1 unit not allocated), while ceil on withdrawals yields 34+34+34=102 (2 units over‑removed). In edge cases with many funds and very small amounts, the per‑fund over‑subtractions can even trigger underflow reverts.Recommendation
Use an exact‑sum allocation scheme so that per‑fund amounts add up exactly to the total for both deposits and withdrawals. Two robust approaches are:
- Largest‑remainder method: compute exact rationals, take floors for all, then assign the remaining “dust” to funds with largest fractional remainders.
- Balance‑on‑last: floor the first N‑1 allocations and set the last allocation to
total − sumFloors. For example, for the last fund:distributeAssets_last = totalAssets - sumAllocatedSoFar;
-
L-05 Low Claim Fee Is Not Chain Specific Unexpected Behavior Acknowledged
Description
LedgerCoreImpl.updateUnclaimed()quotes LayerZero for the native fee that must be paid on the Orderly chain. It will then encode the amount in the crosschain message sent to the Vault side where it will be recorded incrossChainFeeand will be paid by the user upon claim. However, if the vault chain has a different native token than the Orderly chain, this action will result in accounting mismatch. For example, if the contracts are deployed on Polygon PoS, the fee recorded will be in MATIC instead of ETH.Recommendation
Add additional logic that handles these cases if you decide to deploy to such chains.
-
L-06 Low
handleDexRequests()Doesn't Verify Token Validation AcknowledgedDescription
LedgerExtension.handleDexRequests()doesn't check if the token provided is USDC, which makes it possible to use the system with other tokens even if they are not supported.Recommendation
Skip any requests if the token they are working with is not
USDC. -
L-07 Low No Only‑delegatecall Guard In LedgerExtension Contract Validation Acknowledged
Description
LedgerExtensionis designed to be used only viadelegatecallfromProtocolVaultLedger, but it exposes public/external entry points without enforcing that call context. If someone calls the implementation address ofLedgerExtensiondirectly, its functions execute against the implementation’s own storage, not the ledger’s storage, and can still emit “successful” events (e.g.,OperationHandled,DexRequestHandled) and update mappings likeisDexRequestHandledwithin the implementation contract. This does not mutate the real ledger state but can pollute on‑chain telemetry and generally widens the surface for future mistakes. The risk is currently limited to observability and monitoring confusion because state changes remain isolated to the implementation contract.Recommendation
Add an only‑delegatecall guard to every external entry point in
LedgerExtension(and mirror it in other implementation modules) so that calls revert unless executed via the proxy. A minimal pattern is to record the implementation’s own address once and require thataddress(this)differs when invoked (i.e., reject direct calls):require(address(this) != IMPLEMENTATION_SELF, "impl: only delegatecall");. -
L-08 Low Integer Division Truncation in Cross-Chain Fee Calculation Rounding Acknowledged
Description
In the
LedgerCoreImplcontract'supdateUnclaimedfunction, the cross-chain feeccFeeobtained from theIVaultCrossChainManager'squoteClaimfunction is divided by the number of valid unclaimed requestslenbefore encoding it into theStrategyVaultCCMessagepayload asabi.encode(periodId, ccFee / len, userClaimInfos). Solidity performs integer division on uint256 types by default flooring the result and discarding any remainder, meaning that for non-divisible values the computed per-claim fee will be lower than intended. For instance, ifccFeeequals 10 and len equals 3, thenccFee / lenevaluates to 3 with a remainder of 1 lost, resulting in the payload implying a total fee of 3 multiplied by 3 equaling 9 which is less than the originally quoted 10. This truncation happens because the division operator in Solidity does not round up or preserve fractions, leading to a systematic underestimation when the fee is not perfectly divisible by the batch size. The code snippet illustrating this is:(ccFee,) = IVaultCrossChainManager(crossChainManager).quoteClaim(chainId, message); message = _createCCMessage( PayloadType.UPDATE_USER_CLAIM, chainId, abi.encode(periodId, ccFee / len, userClaimInfos) );Here, the quoted
ccFeeis for the entire batch message, but the encoded per-claim value may cause the destination chain's processing logic to reconstruct a total that is insufficient if it multiplies back bylen. The impact is potential underfunding of cross-chain messages leading to delivery failures, reverts.Recommendation
Replace the division with a ceiling calculation using
(ccFee + len - 1) / lento ensure the total implied fee when multiplied back meets or exceeds the quoted amount, and add a check to revert if the per-claim fee is zero for non-empty batches. -
L-09 Low Duplicated Requests Dilute The ccFee Logical Error Acknowledged
Description
LedgerCoreImpl.updateUnclaimed()is designed to handle duplicates in therequestIdspassed by the BE. This becomes evident from the second check for a valid request.// Ignore if handled if (_isValidRequestId(requestIds[i])) { userClaimInfos[index] = userClaimInfo[requestIds[i]]; isUserClaimHandled[requestIds[i]] = true; index++; delete userClaimInfo[requestIds[i]]; }Even though these requests won't be included in the
userClaimInfosarray, its length has already been set tolenwhich includes all the duplicates.In result,
userClaimInfoswill contain empty slots instead of duplicates, but there is a bigger problem - the fee calculation:ccFee / lenSince
lenincludes the empty slots, the individual fee paid for each request will be diluted. Nobody will pay the fees for the empty slots which means they have to be covered by the Orderly protocol.In addition, on the EVM side the
userClaimedByIdandcrossChainFeemappings will be populated foraddress(0)because of these empty slots.Recommendation
The simplest solution is to compute the individual fee to be paid as
ccFee / indexinstead ofccFee / len. While this won't stop the mappings being incorrectly populated, the fee dilution problem will be solved.If you want to fix both issues, you should redesign the function.
-
L-10 Low Incorrect
feeRateCheck Validation AcknowledgedDescription
Additional check that ensures fee cannot be changed before NAV is updated was added to the
setFeeRate()function in response to the following finding from a previous audit round -However, in the latest commit, the check was modified to work for the previous period, not the current one
if (latestPeriodId != 0 && !isUpdateStrategyFundAssets[latestPeriodId - 1]) { revert NotAllowedTime(); }For the vault to work correctly, once
updatePeriodId()is called, it meansupdateStrategyFundAssets()has already been called for the previous period. This means the check insetFeeRate()will be always passing, reintroducing the issue from the original report. An exception is ifsetFeeRate()is called in period 1 becauseupdateStrategyFundAssets()may have not been called in period 0. In that case, the fee can't be changed until period 1 ends.Recommendation
Check for the current period, not the previous one.
- if (latestPeriodId != 0 && !isUpdateStrategyFundAssets[latestPeriodId - 1]) { + if (latestPeriodId != 0 && !isUpdateStrategyFundAssets[latestPeriodId]) { revert NotAllowedTime(); } -
L-11 Low Dex Requests Can Use Invalid Accounts Validation Acknowledged
Description
LedgerExtension.handleDexRequests()performs validation to confirm the account id to be modified is derived by the user address and thebrokerHash. ThebrokerHashis fetched fromvaultBroker[data.vaultId]using thedata.vaultIdfield. However, there is no further validation applied tobrokerHash. For example, it may be empty. This will yield invalid account id which is still controlled by the user and can be claimed by them, but is not derived from a valid broker. In result, users are able to bypass the accounts system and operate with invalid accounts.It's also possible that
vaultBrokerfor thatvaultIdwas changed toaddress(0)by callingsetVaultBroker(). One would expect accounts with that broker won't be used anymore, but the issue shows the opposite.Recommendation
Ensure
brokerHashis not empty after fetching it in_verifyEVMRequestand_verifySolRequest() -
I-01 Informational Ledger Layout Must Match Upgradeability Resolved
Description
All of the three contracts -
ProtocolVaultLedger,LedgerCoreImplandLedgerExtension- must have the same storage layout because the first one delegate calls the other two. The current layout is fine because none of the other contractsProtocolVaultLedgerinherits from has any state.Recommendation
Be careful with future updates since they can corrupt the Ledger storage layout.
-
I-02 Informational Redundant uint128 Cast In _processDeposit Best Practices Resolved
Description
In
VaultAdapter._processDeposit, thetokenAmountfield ofVaultDepositFEis assigned using an explicit cast:VaultDepositFE memory depositData = VaultDepositFE({ accountId: id, brokerHash: brokerHash, tokenHash: adapterDeposit.tokenHash, tokenAmount: uint128(adapterDeposit.amount) // amount is already uint128 });If
adapterDeposit.amountis indeeduint128, this cast is redundant and slightly reduces readability while adding a negligible cost.Recommendation
Remove the cast as
adapterDeposit.amountis alreadyuint128. -
I-03 Informational Unnecessary Mapping Slot Derivations In _handleRequest Gas Optimization Resolved
Description
In
_handleRequest, the function pre‑derives both storage pointers:AccountToken storage accountToken = accountTokenInfo[id][tokenHash]; StrategyFundToken storage strategyFundToken = strategyFundTokenInfo[id][tokenHash];irrespective of
payloadType. In theLP_*branches onlyaccountTokenis used; in theSP_*branches onlystrategyFundTokenis used. While this does not perform an SLOAD by itself, deriving a nested‑mapping storage pointer requires one or morekeccak256computations to obtain the slot. Computing the unused pointer on every call therefore incurs avoidable gas for each request (e.g., ~two extrakeccak256operations per call in typical paths), with no functional benefit.Recommendation
Compute the storage pointer lazily inside the relevant branch only. For example, declare
AccountToken storage a = accountTokenInfo[id][tokenHash];only inLP_*branches andStrategyFundToken storage s = strategyFundTokenInfo[id][tokenHash];only inSP_*branches. This avoids deriving unused mapping slots and saves gas on every request. -
I-04 Informational depositNative Function Is Not payable Configuration Acknowledged
Description
VaultAdapter.depositNativeforwards native currency to the DEX vault:function depositNative(AdapterDeposit memory adapterDeposit, bytes calldata signature) external onlyOperator { (uint256 fee, VaultDepositFE memory depositData) = _processDeposit(adapterDeposit, signature); uint256 totalValue = adapterDeposit.amount + fee; IDexVault(dexVault).depositTo{value: totalValue}(adapterDeposit.receiver, depositData); emit DepositFromCeffu(adapterDeposit, true); }However, the function is not marked
payable. As a result, the caller cannot attach ETH to this transaction to fundtotalValue. The call todepositTo{value: totalValue}will revert if the adapter contract’s balance is insufficient, effectively causing a denial of service for native deposits unless the contract is pre-funded out-of-band viareceive(). This contradicts the function’s intent (“Deposit native token (ETH) to the specified receiver”) and makes operational flows brittle, since success depends on prior top‑ups and exact balance management.Moreover, this forces the top-ups and deposits to be executed atomically otherwise a different operator could make use through another deposit call of the native assets that were sent but not yet consumed by the contracts.
Recommendation
Consider declaring the function
payableand validate the provided value against the required total:function depositNative(AdapterDeposit memory adapterDeposit, bytes calldata signature) external payable onlyOperator { (uint256 fee, VaultDepositFE memory depositData) = _processDeposit(adapterDeposit, signature); uint256 totalValue = adapterDeposit.amount + fee; require(msg.value == totalValue, "Incorrect msg.value"); IDexVault(dexVault).depositTo{value: msg.value}(adapterDeposit.receiver, depositData); }If you prefer to allow overpayment, require
msg.value >= totalValueand refund the excess before callingdepositTo. -
I-05 Informational Double Withdraw Risk Informational Acknowledged
Description
After internal DEX withdraw happens,
handleDexRequests()will increase the user'sfrozenShares, thenupdateLPAndStrategyFundwill finalize the withdrawal, but it will also populateuserClaimInfofor thatrequestId.The BE should differentiate between normal requests and DEX requests and not call
updateUnclaimed()for the DEX requests, but it's still technically possible. If this happens users will receive their funds twice - once by internal transfer and once by claiming from the ProtocolVault on the EVM chain.Recommendation
To avoid the risk completely add a check to
updateLPAndStrategyFund()that skips writing touserClaimInfoif the current request is a DEX one. -
I-06 Informational ProtocolVaultLedger Relies On Off‑chain Calculations And Operator Actions Warning Acknowledged
Description
The ledger’s critical state transitions depend on values computed and orchestrated off‑chain by privileged actors (the engine, operator, DEX signers and the cross‑chain manager). On‑chain code primarily verifies signatures and roles, not the economic correctness of the supplied data. For example, in
LedgerCoreImpl.updateStrategyFundAssetsthe engine signs the per‑strategytotalAssets(NAV) array that drives performance‑fee minting and share‑price updates; the contract only callsSignature.verifyUpdateFundAssets(...)and then computes fees andfundAssetsAfterFeefrom those off‑chain NAVs. If the NAVs are stale or manipulated, performance fees and main/share accounting are affected with no on‑chain way to self‑correct. InLedgerCoreImpl.updateLPAndStrategyFundthe engine submits the list of operations (LP/SP deposits/withdrawals, amounts and requestIds). The contract verifies the signature and applies conversions and freezes, assuming the off‑chain stream correctly reflects real deposits/withdrawals observed on other chains; misuse or bugs can mis‑creditunAllocatedAssetsor freeze the wrong amounts until later settlement. InLedgerCoreImpl.allocateToFundsthe operator chooses which strategy IDs participate in the split for the period; the on‑chain logic does proportional math but does not enforce any fairness or inclusion invariants, so a strategy can be starved or over‑favored by off‑chain selection. InLedgerCoreImpl.distributeAssetsthe engine dictates the cross‑chain distribution tuple(chainId, assets)per strategy and the ledger blindly constructs and sends cross‑chain messages through the configured manager; there is no on‑chain reconciliation that the distribution matches pending balances. Finally, inLedgerCoreImpl.updateUnclaimedthe engine provides the set ofrequestIdsto finalize and the contract clears and forwards them; omission or mis‑ordering off‑chain leaves claims pending indefinitely. Similar dependence exists inLedgerExtension.handleDexRequests, where the contract trusts DEX‑provided signatures and request formatting to mutateunAllocatedAssetsorfrozenShares. These flows are role‑ and signature‑gated, but economic truth (correct NAVs, operation completeness, fair inclusion, and accurate distributions) is externalized to off‑chain processes.Recommendation
Merely informative. Note that any error in the offchain components of the protocol can have a severe impact in the protocol.
Remediation Review
9 findings · September 5, 2025-
H-01 High Rebalance Updates Are Undone By Next Settle Logical Error Acknowledged
Description
LedgerExtension.rebalancedirectly edits the final per‑fund fields (totalAssets,totalShares,mainShares) but does not updatependingState.*orfundAssetsAfterFee. Later, the period pipeline callssettleMainAndStrategyFunds, which commits final fields from the pending state, thereby clobbering the earlier rebalance edits. During the window betweenrebalanceandsettleMainAndStrategyFunds, other flows (notablyallocateToFunds) read a mixed source of truth. UpdatedmainShares/totalSharespaired with stalefundAssetsAfterFee, which can misweight allocations and make conversions inconsistent with the intended post‑rebalance basis.Settle overwrites final fields from pending:
// LedgerCoreImpl.settleMainAndStrategyFunds(...) strategyFundToken.totalShares = pendingState.pendingTotalShares; strategyFundToken.totalAssets = pendingState.pendingTotalAssets; strategyFundToken.mainShares = pendingState.pendingMainShares;Allocation uses an inconsistent triplet because
rebalancedoesn’t touchfundAssetsAfterFeeorpendingState:// LedgerCoreImpl.allocateToFunds(...) mainAssetsInFund = LedgerUtils._convertToAssets( strategyFundToken.mainShares, // updated by rebalance() strategyFundToken.fundAssetsAfterFee, // NOT updated by rebalance() strategyFundToken.totalShares, // updated by rebalance() ... );Because of this, the intended cross‑fund redistribution performed by
rebalancedoes not persist across settlement and before settlement it can skew per‑fund weights and conversions.Recommendation
Consider applying rebalance deltas to
pendingState.pendingTotalAssets,pendingState.pendingTotalSharesandpendingState.pendingMainShares(and, if your pricing depends on it, reconcilefundAssetsAfterFee). -
M-01 Medium
SPprice discrepancy between rebalance and deposit Logical Error AcknowledgedDescription
During a
rebalance(), thefund.lastSharePricevariable is used to calculate the main shares to be added or removed.uint256 sharePrice = fund.lastSharePrice; ... deltaShares = deltaAssets * 10 ** USDC_DECIMAL / sharePrice;This price is modified at the beginning of the period when uploading the NAV via
updateStrategyFundAssets()strategyFundToken.lastSharePrice = (fundAssets - performanceFee) * 10 ** USDC_DECIMAL / fundShares;However,
_handleSPDepositconverts the deposited assets to shares using the current price of the SP fund, not the cached one.uint256 depositShares = LedgerUtils._convertToShares( amount, strategyFundToken.fundAssetsAfterFee, strategyFundToken.totalShares, Math.Rounding.Floor );In case
fundAssetsAfterFeeandtotalSharesare 0, shares and assets are priced1:1. This discrepancy between the SP share price used in rebalance and the SP share price in_handleSPDepositwill cause accounting mismatch and lead to unfair funds distribution. For example:- LP deposits 100 USDC and receives 100 shares
- SP deposits 100 USDC to
SP_Band receives 100 shares - The LP funds are allocated to
SP_B(mainShares = 100, totalShares = 200, assets = 200) - At the beginning of the next period the NAV is updated and
SP_Breports a double increase, i.eassets = 400andlastSharePrice = 2 - The SP withdraws all their shares from
SP_Band receives 200 USDC (mainShares = 100, totalShares = 100, assets = 200) - Rebalance moves all of the LP’s funds to
SP_A. This leavesSP_Bempty - In the next period the SP deposits again 100 USDC to
SP_B. Since it’s empty, they receive 100 shares. - Rebalance moves all of the LP’s funds to
SP_B. It useslastSharePrice = 2and converts 200 USDC to 100 shares. - Now the LP and SP have both 100 shares, or 150 USDC to withdraw.
- In the end, the SP stole 50 USDC from the LP.
Recommendation
Consider if caching the
lastSharePriceis needed instead of using the same ratio asspDepositandspWithdraw -
L-01 Low Decimal Conversion Applied To Withdraw Shares Warning Resolved
Description
In
_lzReceiveofVaultCrossChainManager, the incomingOperationData.amountis always passed through decimal conversion wheneverisSpecialDecimal[tokenHash][srcChainId]is set, regardless of the operation type. This is correct for deposits (whereamountis in asset units) but wrong for withdrawals, where amount represents shares, a dimensionless value that must not be scaled by token decimals.Relevant code (current behavior):
// _lzReceive(...) OperationData memory operationData = abi.decode(payload, (OperationData)); uint256 srcChainId = strategyVaultCCmessage.srcChainId; if (isSpecialDecimal[operationData.tokenHash][srcChainId]) { operationData.amount = _convertAmount( operationData.amount, tokenDecimals[operationData.tokenHash][srcChainId], ledgerDecimal ); } IProtocolVaultLedger(ledger).handleOpFromVault(payloadType, srcChainId, operationData);For
LP_WITHDRAW/SP_WITHDRAW, this scaled value is then treated as shares by the ledger:- Intake freezes that (now wrong) amount of shares:
// LedgerExtension._handleRequest if (payloadType == PayloadType.LP_WITHDRAW) { if (_checkWithdraw(amount, accountToken.frozenShares, accountToken.pendingShares)) { accountToken.frozenShares += amount; // amount is assumed to be shares } else { return false; } }- Settlement later requires the amount to be
<= frozenSharesand uses it to compute assets:
// LedgerCoreImpl._handleLpWithdraw LedgerUtils.requireEnoughFrozenShares(amount, accountToken.frozenShares); uint256 withdrawAssets = LedgerUtils._convertToAssets(amount, mainAssetsAfterFee, mainShares, Math.Rounding.Floor);Because
_lzReceivehas down‑ or up‑scaled shares, the ledger freezes the wrong number of shares. In practice, operators will typically settle an amount that matches what the ledger froze to avoid reverts, which produces silent under‑withdrawals (e.g., user intended 600 shares; only 6 were frozen and settled). If the operator instead tries to settle the original amount, settlement reverts withNotEnoughFrozenShare, creating a DoS for that request. In short, the unit mismatch corrupts withdraw flows and can either underpay users or block settlement.Recommendation
Only perform decimal conversion for deposits (asset units). Do not convert
operationData.amountforLP_WITHDRAW/SP_WITHDRAW, since it represents shares. A minimal fix is to gate the conversion:// Pseudocode inside _lzReceive before calling handleOpFromVault if ((payloadType == LP_DEPOSIT || payloadType == SP_DEPOSIT) && isSpecialDecimal[operationData.tokenHash][srcChainId]) { operationData.amount = _convertAmount(operationData.amount, tokenDecimals[operationData.tokenHash][srcChainId], ledgerDecimal); } // For withdraws, forward amount unchanged. -
L-02 Low Previous Rebalance Request Is Not Cleared Unexpected Behavior Acknowledged
Description
When the owner requests a rebalance, the function is expected to clear the previous request and store the new one.
bytes32 rebalanceId = keccak256(abi.encode(vaultId, targetRatios, block.timestamp)); // Clear and push rebalance request delete rebalanceRequest[rebalanceId];However, the
rebalanceIdof a request is computed askeccak256(abi.encode(vaultId, targetRatios, block.timestamp)), meaning each request will have a different ID unless all parameters are exactly the same. As a result, the same vault and strategy providers can have multiple valid rebalance requests concurrently, since the previous request is not actually cleared.Additionally, the rebalance request creation timestamp is neither emitted in an event nor stored in
storedRatios, and therebalancefunction does not take it into account. As a result, an earlier request can be executed after a later one, effectively overriding the most recent rebalance request.For example:
- The owner creates a rebalance request with a 30:70 ratio for 2 SPs in the vault.
- Later, the owner creates a new rebalance request with a 40:60 ratio for the same 2 SPs, assuming it would clear the previous request.
- The previous request has not been cleared, so both requests remain valid.
- The operator executes the second request first, then executes the first request.
- As a result, the funds are rebalanced based on the 30:70 ratio.
Recommendation
Consider having
requestIds based only onvaultIdand SP IDs, without including the ratios orblock.timestamp. This way, there will be only one active request for the same vault–SP pair, even if the intended ratios change. -
L-03 Low Disabling strategy providers may DOS the Vault Configuration Acknowledged
Description
To disable a
strategyProvider, firstisAllowedStrategyProvidermust be disabled in theProtocolVaultcontract, then all in-flight messages must be executed and finally disable theSPin theProtocolVaultLedgercontract.Otherwise the in-flight messages will be executed and the
OperationHandledevent will be emitted with a disabledSPwhich will lead to the reverting ofupdateLPAndStrategyFund()and DOS the strategy vault, since there theSPis checked against the allowed ones. -
L-04 Low Rebalance can assign assets to a 0-ratio
SPUnexpected Behavior AcknowledgedDescription
The
rebalance()function treats the lastSPas a fund that receives all of the assets leftovers after the previousSPshave been allocated to.for (uint256 i = 0; i < len; i++) { if (i < len - 1) { targetAssets[i] = totalMainAssets.mulDiv(targetRatios[i].ratio, REBALANCE_BASE, Math.Rounding.Floor); allocatedAssets += targetAssets[i]; } else { targetAssets[i] = totalMainAssets - allocatedAssets; } }Since the
targetAssetsare rounded down for each fund, the leftover assets will be assigned to the lastSPeven if it’stargetRatio = 0Recommendation
To avoid assigning funds to an
SPwithtargetRatio = 0, make sure the lastSPin therebalanceRequestarray has a positivetargetRatio. This can also be enforced in the ProtocolVaultLedger.requestRebalance() function. -
L-05 Low Rebalanced
SPcan end up having shares, but no assets Unexpected Behavior AcknowledgedDescription
When
LedgerExtensionrebalances the assets in theSPfunds, it modifies thetotalAssets,mainSharesandtotalShares.deltaAssets = currentMainAssets - targetAssets[i]; deltaShares = deltaAssets * 10 ** USDC_DECIMAL / sharePrice; fund.totalAssets -= deltaAssets; fund.totalShares -= deltaShares; fund.mainShares -= deltaShares;Since
deltaSharesare rounded down,mainSharescan end up being slightly larger than expected. This can become problematic if there are noSPshares because it will result in the fund not having any assets - because they were fully subtracted, but still having dust shares. This will also impact thehwmbecause it won’t enter the zero case during settlementif (totalShares == 0) { return 10 ** decimal; }Recommendation
If
targetAssets[i] == 0, execute the following logic insteadfund.totalAssets -= currentMainAssets; fund.totalShares -= fund.mainShares; fund.mainShares = 0; -
I-01 Informational quoteClaim Uses Param ChainId While Send Path Uses message.dstChainId Validation Acknowledged
Description
In the
VaultCrossChainManagercontract,quoteClaim(uint256 chainId, StrategyVaultCCMessage message)derives the destination endpoint ID (EID) from the function parameter chainId, whereas the actual send pathsendMessagederives the destination frommessage.dstChainId. If a caller supplies achainIdthat differs frommessage.dstChainId, the quoted LayerZero fee will be computed for one destination but the send will target another, producing incorrect fees.Illustrative code:
// quoting path function quoteClaim(uint256 chainId, StrategyVaultCCMessage memory message) public view returns (MessagingFee memory) { bytes memory lzMessage = abi.encode(message); uint32 dstEid = chainIdToEid[chainId]; // uses param MessagingFee memory fee = _quote(dstEid, lzMessage, _getOptions(message.payloadType), false); return fee; } // send path function sendMessage(StrategyVaultCCMessage memory message) external onlyLedger { bytes memory lzMessage = abi.encode(message); uint32 dstEid = chainIdToEid[message.dstChainId]; // uses message field MessagingFee memory fee = _quote(dstEid, lzMessage, _getOptions(message.payloadType), false); _lzSend(dstEid, lzMessage, _getOptions(message.payloadType), fee, payable(address(this))); }When the two values diverge, operators may underfund the send (leading to
NotEnoughNativeand failed delivery) or overfund. While current usage appears to keep them equal, the API remains a footgun that can surface as intermittent delivery failures or unnecessary balance management overhead.Recommendation
Consider removing the
chainIdparameter fromquoteClaimand derive the destination solely frommessage.dstChainId. -
I-02 Informational Unenforced Operation Order Enables Mis-sequenced Period Actions Configuration Acknowledged
Description
ProtocolVaultLedgerexposes several operator entry points that must be run in a specific per‑period order for accounting and custody to stay consistent. The contract only enforces “once per period” flags (e.g.,isAllocatedToFunds,isAssetDistributed) and period equality (_check(periodId)) on some functions, but it does not enforce the required sequencing between them. Several calls accept anyperiodIdand can be executed out of order without reverting.Examples that compile and run but produce inconsistent state:
- allocate before NAV: Calling
allocateToFundsbeforeupdateStrategyFundAssetsuses stale fundAssetsAfterFee/lastSharePrice for per‑fund conversions, mispricing shares. - distribute without period gating:
distributeAssetshas no_check(periodId)and can be called for any period (including “after”updatePeriodId), decoupling custody movement from the accounting window. - …
Because of this, operators can unintentionally execute valid transactions in the wrong order, causing mispricing, inconsistent books vs custody and operational foot‑guns that are hard to detect and fix.
Recommendation
Enforce an explicit per‑period state machine and add ordering preconditions:
- Add a stage enum per period (e.g.,
None→NavUploaded→OpsProcessed→Allocated→Distributed→FundsSettled→AccountsSettled→Closed). - Gate each function with required stage(s).
- allocate before NAV: Calling
No findings match.
More from Orderly
All 8 reports-
Solana Vault, Sol-CC and EVM Updates
53 findings1 critical · 6 high 53 findings: 1 critical, 6 high, 5 medium, 22 low, 19 informational -
Solana Vault
41 findings1 high 41 findings: 1 high, 3 medium, 18 low, 19 informational -
Solana Staking
35 findings1 critical · 1 high 35 findings: 1 critical, 1 high, 4 medium, 29 low -
Cross-Chain Yield Vault
64 findings7 critical · 6 high 64 findings: 7 critical, 6 high, 16 medium, 35 low
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
