Guardian's review of Protocol Review for Ethereal, published August 2026. The report records 42 findings, including 1 high and 4 medium.
- Published
- Review window
- July 20 to August 6, 2026
- Language
- Solidity, Rust
- Chains
- Ethereum, Converge, Offchain
- Sector
- DEXs and AMMs, Perpetuals
- 0 Critical
- 1 High
- 4 Medium
- 15 Low
- 22 Informational
Scope
-
GuardianOrg/merkl-contracts-team1-1784749189571
b7853b931e - meridianxyz/contracts
1 file in scope · 106 nSLOC
| File | nSLOC | Lines |
|---|---|---|
contracts/partners/tokenWrappers/MeridianDepositWrapper.sol | 106 | 180 |
Findings 42
-
H-01 High Short EIP1271 Return Halts processActions Queue DoS Resolved
Description
The
isValidSignaturefunction of theVerifierLibcontract must never revert, since theprocessActionsloop relies on it returning a boolean when handling_handleLinkSignerand_handleRevokeLinkedSigneractions. Both handlers invoke it withshouldRevertset to false and treat afalseresult as a clean rejection rather than a failure. Despite this,VerifierLib._verifyEip1271Signaturecan be pushed into a revert by a contract that exposes anisValidSignaturefunction crafted to answer with a short payload. Once the staticcall returns, the checksuccess := and(success, gt(returndatasize(), 0))filters out only empty responses, so a reply of 1 to 3 bytes survives and proceeds to the copy step. At that point the code runsreturndatacopy(add(returnData, 0x20), 0, bytesToCopy)withbytesToCopyfixed at 4. Wheneverreturndatasizeis 1, 2, or 3, the read satisfiesoffset + size > returndatasizeand goes out of bounds, causing an exceptional halt Since this halt fires within theVerifierLibframe itself and not inside the wallet's staticcall, theshouldRevertlogic never sees it and the error bubbles up unhandled. That breaks the no-revert guarantee ofisValidSignatureand forces the wholeActionHandler.processActionsbatch to revert.Recommendation
Only proceed to copy when the response carries the full 4 magic bytes, so any shorter reply is classified as an invalid signature and continues through the existing graceful path. Replace
success := and(success, gt(returndatasize(), 0))withsuccess := and(success, gt(returndatasize(), 3)). -
M-01 Medium Pyth Freshness Check Uses Wrong Units Oracle Resolved
Description
LiquidationSharedLib.parsePythPriceFeedcompares Pyth’s signed update timestamp directly withblock.timestamp.if (block.timestamp - maxAge > data.timestamp) { revert PriceTooOld(id, data.timestamp, uint64(block.timestamp)); }However, Pyth encodes this timestamp in microseconds, while
block.timestampandpythMaxAgeare denominated in seconds. Consequently, the comparison in LiquidationSharedLib cannot reject realistically encoded stale payloads.pythMaxAgetherefore imposes no effective age limit while the payload’s signer remains authorized. A stale price can incorrectly determine whether an account is below maintenance margin or whether the liquidator is bankrupt.Recommendation
Normalize both values to the same unit before comparing them. Prefer converting
block.timestampandpythMaxAgeto microseconds to avoid discarding timestamp precision. -
M-02 Medium Fresh Payloads Can Contain Stale Prices Oracle Acknowledged
Description
Pyth Lazer’s payload header contains
timestampUs, while each feed exposes a separatefeedUpdateTimestampidentifying when its price was generated. Pyth’s Payload Reference explains that prices may be carried forward during market closures. Its price-data documentation also explains that prices are carried forward when insufficient publishers contribute. Consumers must therefore validatefeedUpdateTimestamprather than relying solely on the header timestamp. Meridian’s parser does not supportfeedUpdateTimestamp. Consequently, even after correcting the header timestamp’s unit, a recently signed payload can contain a price generated hours or days earlier and still pass validation. For example, when a market reopens after a weekend, a fresh XAU payload may continue carrying Friday’s price until Pyth generates a new aggregate. If the market has moved favorably for a position, the carried price can make an otherwise healthy account appear below maintenance margin and permit wrongful liquidation. It can likewise make a solvent liquidator appear bankrupt and incorrectly authorize ADL.Recommendation
Update PythLazerLib to support
FeedUpdateTimestampand modifyparsePythPriceFeedto require and validate it. Ensure submitted liquidation and ADL payloads include the property. Reject a payload when the property is absent, later than the header timestamp, or older thanpythMaxAgeafter normalizing all timestamps to the same unit. -
M-03 Medium Manipulable Delist Settlement Price Oracle Acknowledged
Description
When a perp is delisted,
PerpEngine.initiateDelistreads a single Pyth Lazer price viaLiquidationSharedLib.parsePythPriceFeedand stores it asproduct.delistExpiryPrice, the price at which every position is force-closed inPerpEngine.settleDelistPosition. Since settlement uses one instantaneous snapshot instead of a time-weighted average, that single price can be manipulated to lock in PnL on the attacker's own position, forcing every counterparty to be settled at that price. Attack path:- The attacker learns the time an asset will be delisted from a public announcement
- They build a large long position on the perp.
- Just before the delist time, they buy heavily on spot exchanges to sharply inflate the spot price for a short period.
- The delist is called and the inflated oracle price is stored as
delistExpiryPrice. - They sell back the spot they bought, while their perp profit stays locked in at the inflated settlement price. Note that this attack is only realistic for lower-liquidity tokens where the spot price can be moved significantly cheaply, but those are also the assets most likely to be delisted in the first place.
Recommendation
Settle delists on a time-weighted average of the oracle price over a fixed window, as Hyperliquid and other exchanges do to mitigate this issue.
-
M-04 Medium Delisting Can Block the Relayer Queue Logical Error Resolved
Description
Product delisting, funding updates, and position-fee updates are submitted as separate sequencer actions.
PerpEngine.finalizeDelistpermanently changes the product status toDELISTED, while bothPerpEngine.updateFundingandPerpEngine.updatePositionFeeonly accept active products.if (product.status != ProductStatus.ACTIVE) { revert ProductStatusViolation(id, productId, EngineType.PERP, product.status); }A funding request submitted before engine expiry can be confirmed after the expiry message has already generated
FinalizePerpProductDelist. FundingHandler still applies this late confirmation and enqueues anUpdateFundingaction without checking that the product has been delisted. The funding action therefore receives a higher action ID than the finalization action and can no longer execute once finalization is processed. The same applies to position fees. After engine expiry but before the sequencer processes the expiry message, the position-fee worker can submit a charge while the database still considers the product active. Nekuti accepts the charge, and PositionFeeHandler subsequently enqueuesUpdatePositionFeewithout checking the product status. The position-fee action therefore also receives a higher action ID than the finalization action. Consequently, if the sequencer submitsFinalizePerpProductDelistbefore an already-generatedUpdateFundingorUpdatePositionFeeaction, the update can no longer execute. If both actions are included in one transaction, the entire batch reverts atomically and delist finalization is rolled back. If finalization is submitted separately first, the subsequent update reverts without changing state. ActionHandler requires action IDs to be strictly increasing but not contiguous, meaning the sequencer can omit the obsolete action and continue with a higher action ID. However, the current off-chain relayer always selects the oldest pending action and provides no rejected or discarded state. Once the obsolete update reaches the head of the queue, the relayer worker terminates on every attempt and selects the same action again after restarting. Consequently, all later actions (including order settlement, deposit finalization, withdrawal initiation, liquidation, and ADL) remain pending, causing off-chain/on-chain divergence and allowing unhealthy positions to accumulate further losses.Recommendation
Mark the product as
DELISTINGbefore requesting engine expiry, block new funding and position-fee charges, and reconcile all in-flight charges. -
L-01 Low Default Switch Exposes New Backing Unexpected Behavior Acknowledged
Description
MeridianUSD supports multiple approved backing tokens, but
wrapandunwrapaccept onlydefaultWrappedToken. Meanwhile,setDefaultWrappedTokenverifies only that the new token is enabled - it neither migrates reserves nor requires the contract’s balance of the new default to cover the outstanding merUSD supply. The documented migration procedure requires wrapping the new asset and unwrapping the old asset before changing the default. However, this cannot execute becausewrap(newToken)reverts while the old token remains the default. Consequently, the owner can legitimately switch from A to an empty B reserve while A still backs all existing merUSD. Subsequent B deposits cannot repair this deficit because each deposit increases the B reserve and merUSD supply by the same amount. Any existing merUSD holder can redeem the newly deposited B before the new depositor. The depositor remains with merUSD backed by A, but cannot redeem A while B is the default. If A is depegged or frozen, legacy holders can escape its loss using new depositors’ B. Even when A remains sound, withdrawals fail after B is exhausted until the owner intervenes.Recommendation
Consider adding an atomic owner-only migration function that pulls sufficient new backing, verifies actual receipt, switches the default, and then recovers the old reserve without changing merUSD supply.
-
L-02 Low Maximum ID Disables Claimer Batches Unexpected Behavior Acknowledged
Description
Claimer.finalizeWithdrawalsaccepts caller-supplied uint64 request IDs and requires only that each ID is greater than the previously processed value. The ID is advanced regardless of whether the underlying withdrawal succeeds. An authorized withdrawal claimer can accidentally, or if compromised, submittype(uint64).maxwith an invalid account or withdrawal digest. The underlying finalization fails, but the outer batch succeeds and stores the maximum ID. No future request can provide a larger uint64, so every subsequentfinalizeWithdrawalscall on that Claimer instance reverts. Individual withdrawals remain directly finalizable through ExchangeGateway, but automated batching requires upgrading or replacing the Claimer contract.Recommendation
Consider requiring request IDs to be consecutive rather than accepting any larger value, deriving the expected ID from the stored cursor.
-
L-03 Low Claimer Traps OFT Fee Refunds Logical Error Acknowledged
Description
Claimer.finalizeWithdrawalsforwards each caller-fundedoftSendFeebudget to ExchangeGateway. For a successful cross-chain withdrawal,CollateralManager._completeWithdrawalobtains the current LayerZero fee, spends only that amount, and refunds the unused budget to msg.sender. Because the call originated from Claimer, the refund is returned to the Claimer contract. However,finalizeWithdrawalsonly refunds theoftSendFeeallocated to unsuccessful finalization attempts and any value supplied above the total declared budgets.if (amountToRefund > 0) { SafeTransferLib.safeTransferETH(msg.sender, amountToRefund); }ETH returned by successful calls is not included. If a request declares budget B but the current fee is
F, the operator paysBwhileB - Fremains in Claimer. This can occur when the LayerZero fee decreases between the off-chain quote and transaction execution. This balance cannot fund future batches because each call requires sufficient new msg.value. Since Claimer has no ETH recovery function, accumulated overpayments remain inaccessible in the contract.Recommendation
Consider snapshotting Claimer’s balance excluding the current msg.value, then refund the complete balance increase after processing the batch, or adding an owner-only recovery function for ETH already trapped in the contract.
-
L-04 Low Inconsistent zero-price guard checks Validation Resolved
Description
The
parsePythPriceFeedfunction from theLiquidationSharedLibcontract can return a price of zero. Thedata.pricefield is zero-initialized and assigned only inside thePriceproperty branch, so a payload omitting that property returns zero rather than reverting. TheinitiateDelistfunction from thePerpEnginecontract rejects this withif (expiryPrice == 0) revert PriceZero(id). The four other consumers of the parsed price apply no such check:liquidateSubaccountand_processLiquidateSubaccountWithCompensationProductfrom theLiquidationcontract, and_prepareDeleverageProductfrom both theDeleverageandDeleverageWithCompensationcontracts.Recommendation
Apply the same
if (markPrice == 0) revert PriceZero(id)check at each of the four sites, matching the existing guard ininitiateDelist. -
L-05 Low Realized PnL Mismatch Events Are Never Emitted Events Resolved
Description
The
_verifyRealizedPnl,_verifyLiquidatorRealizedPnl, and user-side settlement checks in thePerpEngine,Liquidation,Deleverage, andDeleverageWithCompensationcontracts are intended to emit a mismatch event (e.g.Deleverage.DeleverageUserRealizedPnlMismatch) when the sequencer's reported realized PnL drifts from the on-chain reference by a notable-but-tolerable amount, feeding off-chain drift monitoring. Each site reverts whenabsDiff > toleranceand otherwise emits whenabsDiff > 1 gwei, but everytolerancepassed is at mostWORST_CASE_REALIZED_PNL_ERROR(2_220_448wei ≈0.00222 gwei), so reaching theelsebranch guaranteesabsDiffis already far below1 gweiand the emit branch is dead code. Consequently these mismatch events are never emitted, leaving any off-chain monitoring that relies on them blind to matching-engine PnL drift. Affected sites:DeleverageL814, L842, L916 ·DeleverageWithCompensationL909, L937, L1007 ·PerpEngineL589 ·LiquidationL813Recommendation
Replace the
absDiff > 1 gweiguard at each of the eight sites with a threshold below the tolerance, such asabsDiff > 0. -
L-06 Low Zero Fee Recipient Blocks New Campaigns DoS Resolved
Description
The
MeridianDepositWrapperstores Merkl's fee recipient, readingDistributionCreator.feeRecipientin its constructor and insetFeeRecipient. Campaigns using the wrapper are funded through theDistributionCreator, which charges a fee in the reward token at creation. Merkl can setDistributionCreator.feeRecipienttoaddress(0), a supported state where theDistributionCreatorcontract keeps the fee. TheDistributionCreatorremaps a zero recipient toaddress(this)before charging the fee, but the wrapper rejects zero: its constructor andsetFeeRecipientboth revert whenfeeRecipientisaddress(0), so it keeps a stale recipient. Because_beforeTokenTransferonly mints whentois thedistributoror the cachedfeeRecipient, the fee transfer to theDistributionCreatormints nothing and reverts on the creator's zero wrapper balance. As a result no new Merkl campaigns can be created whilefeeRecipientis zero. Existing campaigns with a non-zerofeeRecipientare unaffected.Recommendation
In the constructor and
setFeeRecipient, setfeeRecipientto thedistributionCreatoraddress whenDistributionCreator.feeRecipientreturnsaddress(0). -
L-07 Low Zero Leverage Creates Unusable Products Validation Resolved
Description
The owner can accidentally register an active perpetual product with
maxLeverageequal to zero because_verifyMaxLeverageonly enforces the upper bound.if (maxLeverage > PRODUCT_MAX_LEVERAGE) { revert InvalidParameter("maxLeverage", P_ERR_AMT_GT_MAX); }The registration succeeds on-chain, but the off-chain database requires
max_leverage > 0and rejects the corresponding indexer insertion. The database rejection also prevents the indexer checkpoint from being saved, so the indexer remains stuck on the registration block. The product therefore exists on-chain but not off-chain, while subsequent events cannot be indexed. A positive leverage update in a later block does not automatically resolve the issue because the registration handler continues reading the product’s zero leverage at the original registration block. Additionally, the owner can setmaxLeverageto zero for an active product that already has open positions. Both liquidation variants divide bymaxLeverageand consequently revert, preventing those positions from being liquidated until a positive value is restored.Recommendation
Require
maxLeverageto be greater than zero during product registration, activation, and every leverage update. -
L-08 Low Expiry Updates Extend Existing Signatures Unexpected Behavior Acknowledged
Description
User signed actions commit only to
signedAt, while their deadline is calculated at execution using the current owner-configured buffer:if (block.timestamp > signedAt + self.actionExpiryBuffers[action]) { revert ActionExpired(id, block.timestamp, signedAt, self.actionExpiryBuffers[action]); }Because the buffer is not included in the signed message, changing it changes the validity of outstanding signatures. Increasing the buffer can revive signatures that users considered permanently expired. If an expired action is submitted, execution reverts before consuming its nonce or action ID, so the same action can succeed later if the owner increases the buffer. Consequently, an authorized submitter holding an old withdrawal, conversion, or other signed request may execute it after its original validity period, causing unexpected asset movements based on stale user intent.
Recommendation
Consider including an explicit deadline or configuration version in every signed message so later buffer updates cannot change existing signatures’ validity.
-
L-09 Low Updates During Delisting Mint Unbacked Balance Best Practices Acknowledged
Description
updatePositionFeeandupdateFundingon the PerpEngine contract both assume a balanced book, where total long size equals total short size.updatePositionFeecredits the liquidatorpositionFeeDeltaUsd * (openInterest * 2), treatingopenInterest(longs only) as exactly half of total open size.updateFundingadvancescumulativeFundingUsd, a transfer that nets to zero because what longs pay, shorts receive. A delist temporarily breaks that assumption:settleDelistPositioncloses positions one at a time, so partway through a wind-down the open longs and shorts are unequal, and the product stays ACTIVE with nodelistInvokedguard untilfinalizeDelist. On the contract in isolation, an update landing mid-wind-down would credit the liquidator on anopenInterest * 2that no longer matches the paying size, or realizefundingDeltaUsd * (remainingLongs - remainingShorts)with no counterparty, minting withdrawable balance backed by nothing. In the current system this state is not reachable due to the current sequencer implementation. Only the sequencer can submit action batches (processActionsisonlySequencer), and it emits the whole delist under one engine message with funding and position-fee updates ordered before the settlement block, so any updates apply while the book is still full and balanced. Updates from an earlier message land before delisting starts; updates from a later message revert because the product is DELISTED by then (updateFunding/updatePositionFeerequirestatus == ACTIVE). The contract does not enforce this ordering itself. Both functions gate only onstatus == ACTIVE, not on whether settlement has started, so the guarantee rests entirely on the sequencer's action ordering.Recommendation
As a hardening measure, add a dedicated
delistSettlementStartedflag, set by the firstsettleDelistPosition, and reject funding/fee updates only when that flag is true. -
L-10 Low Partial Deleverage Blocks All Liquidations DoS Acknowledged
Description
Auto-deleveraging unwinds a bankrupt singleton liquidator one quote-token book at a time. A sequence locks to a single quote token via
deleverageQuoteTokenand is split into batches that share onemessageIdx. WhiledeleverageMessageIdxis non-zero the sequence is in progress, and it is cleared in only one place: the_finalizeDeleverageLiquidatorbranch reached when the liquidator's last position closes. There is no abort or reset path. Every batch, including that completion branch, re-proves the liquidator is bankrupt at the supplied price and reverts withDeleverageLiquidatorNotBankruptotherwise. So if the liquidator's equity turns positive after a batch has closed part of the book but left a position open, every remaining batch and the completion branch revert, leavingdeleverageMessageIdxset indefinitely. Two things can lift the liquidator's equity mid-sequence. The mark price can recover between batches; or the newMarginTransferaction writes directly to the destination balance with nodeleverageMessageIdxguard, so anyone can push margin into the liquidator subaccount and raise itsliqBalancePrior. Because the liquidator is a singleton, one stuck sequence revertsLiquidation.liquidateSubaccountfor every book viaLiquidatorIsDeleveraging, freezing all liquidations across all quote tokens. Un-liquidatable accounts keep sliding, and any that reach negative equity become bad debt. This is currently not reachable through the Nekuti engine or sequencer. The engine unwinds the liquidator in a single message at one frozen price snapshot, and every on-chain batch is a slice of that message embedding the same Pyth update, so the re-proof sees an identical price across batches and the mark cannot recover between them. The sequencer also relays actions strictly in creation order, so aMarginTransfer(not yet handled by the sequencer) from a later message is only submitted after the deleverage's batches complete and will not interleave. It is still submitted because the contract enforces non-interleaving as an on-chain invariant.Liquidation.liquidateSubaccountrevertsLiquidatorIsDeleveragingwith the comment "that a deleverage must complete as a batch and other actions may not interleave", andExchangeConfig.updateLiquidatoris guarded identically.Recommendation
Two fixes are needed. First, guard any action that can credit the liquidator's balance in deleverageQuoteToken while a sequence is in progress. Revert with LiquidatorIsDeleveraging whenever deleverageMessageIdx is non-zero and the destination is the liquidator subaccount, Second, consider adding a sequencer or owner gated
abortDeleverageaction so a stuck sequence is recoverable without an upgrade. It should settledeleverageBalanceDeltaunder the same accounting and solvency invariants as a normal completion, reset the delta to zero, and cleardeleverageMessageIdxanddeleverageQuoteToken. -
L-11 Low Timestamp Skew Causes Wrongful Liquidation Unexpected Behavior Acknowledged
Description
Liquidation and deleveraging value every product using a separate signed Pyth payload. Each price and timestamp can be authentic and accurate for when it was recorded. However, the protocol treats prices recorded at different times as they formed one coherent portfolio snapshot. The parser extracts the timestamp and channel from each payload but requires the payload to contain only one feed:
(data.timestamp, data.channel, feedsLength, pos) = PythLazerLib.parsePayloadHeader(payload); if (feedsLength != 1) { revert PriceFeedsTooMany(id, feedsLength); }It then performs an individual stale price check and returns only the normalized price. The timestamp and channel are not returned to the liquidation or deleverage caller:
if (block.timestamp - maxAge > data.timestamp) { revert PriceTooOld(id, data.timestamp, uint64(block.timestamp)); } return uint128(data.price) * (10 ** exponent).toUint128();Consequently, the caller cannot check whether the submitted prices have sufficiently close timestamps before aggregating them into one portfolio-equity calculation. Consider an account holding a long position of 100 units in Product A and a short position of 100 units in correlated Product B, both opened at $100. The account has $10,000 of margin. At a coherent $100/$100 snapshot, neither position has PnL and the account retains $10,000 of equity. At a coherent $200/$200 snapshot, the long gains $10,000 while the short loses $10,000, so the account still retains $10,000 of equity. Suppose Product A’s signed payload reports $100 at
t-59, while Product B’s payload reports $200 att. With a configured 60-second maximum age, both payloads are individually recent enough. Combining them, however, gives the long zero PnL and the short a $10,000 loss. The resulting synthetic portfolio has zero equity even though the account had $10,000 of equity at both coherent snapshots. The liquidation consequently succeeds and transfers the positions to the designated liquidator at the mixed prices. When the liquidator closes both positions at the coherent $200/$200 state, its inherited long realizes a $10,000 gain equal to the victim’s entire margin. The same inconsistent valuation can falsely classify the system liquidator as bankrupt in both deleverage variants, causing solvent accounts to be wrongfully liquidated.Recommendation
Check the timestamp difference between every Pyth price used in the same liquidation or deleverage action and revert if it exceeds a configured limit.
-
L-12 Low Stale ADL Tolerance Can Lock Liquidations Logical Error Pending
Description
A complete ADL sequence is intended to close all of the liquidator’s positions. However, the sequence may be divided into multiple on-chain actions because of match or transaction limits. Intermediate actions can therefore close some positions while leaving the remaining positions for subsequent actions. Both deleverage implementations calculate bankruptcy tolerance from every liquidator position present at the beginning of each action. When an intermediate action closes some positions, its final validation still uses the original tolerance, including contributions from positions that were already closed. This allows the intermediate action to commit when the liquidator’s ending equity exceeds the tolerance calculated from its remaining positions but remains within the original tolerance. The next action recalculates tolerance from only the remaining positions and reverts because the liquidator is now considered solvent. Engine-generated ADLs are expected to settle the liquidator at its bankruptcy price, and tested engine sequences did not produce positive residual equity. Triggering the issue therefore requires an accepted engine/on-chain discrepancy that leaves positive equity within the stale-tolerance range. If this occurs, the retained deleverage marker prevents the sequence from completing, blocks subsequent liquidations, and prevents the owner from rotating the liquidator.
Recommendation
After processing each intermediate action, recalculate bankruptcy tolerance using only the liquidator positions that remain open. Reject the action if its ending equity exceeds the recalculated tolerance.
-
L-13 Low Deferred PnL Drift Can Freeze ADL Unexpected Behavior Pending
Description
ADL accepts differences between the engine-provided realized PnL and the value recomputed on-chain when each difference is within the configured tolerance. For incomplete chunks, these values are stored in
deleverageBalanceDelta, while the post-chunk bankruptcy check uses mark price deltas that may omit discrepancies from closed positions. The same divergence can occur without any realized PnL discrepancy. Preparation floors the whole position mark notional once, while each match independently floors its mark-notional delta. Fragmented reductions can therefore cause the cumulative mark-PnL delta to understate the equity represented by the deferred realized PnL and the remaining position cost. If either type of discrepancy accumulates, a chunk can commit even though the liquidator’s ending equity exceeds the tolerance supported by its remaining positions. The next continuation includes the deferred balance delta and recalculates equity and tolerance from the remaining positions, causing it to revert because the liquidator appears solvent. The sequencer verifies each PnL difference separately, so multiple allowed differences can accumulate. If a later ADL chunk fails during simulation, the relayer can still submit the earlier chunks that passed, leaving an incomplete sequence on-chain. A failed continuation leaves the deleverage marker set, blocking subsequent liquidations and liquidator replacement.Recommendation
For incomplete chunks, calculate post-chunk bankruptcy equity from the exact realized PnL, funding, and fees that will be persisted, plus the mark PnL of the remaining positions. Alternatively, explicitly carry every accepted discrepancy into the post-chunk equity calculation. As defense in depth, the sequencer should reproduce the contract’s accounting across the complete ADL sequence and reject the sequence if cumulative discrepancies would cause any continuation to fail
-
L-14 Low Token Removal Ignores Pending Withdrawals Logical Error Acknowledged
Description
Token removal is prevented when a token has an active balance or pending deposit, but pending withdrawals are not included in this validation. Because withdrawal initiation removes the amount from totalBalance and totalBackedBalance, the owner can remove a token after its final active balance has been converted into a pending withdrawal. The removal check only considers active balances and pending deposits. Removing the token deletes its address mapping and configuration, including the OFT address required by cross-chain withdrawal finalization. Pending withdrawals may consequently remain unclaimable until the token configuration is restored.
Recommendation
Consider tracking the aggregate pending withdrawal amount for each token and require it to be zero before allowing token removal.
-
L-15 Low Missed intra-user equity peaks can indefinitely disable all liquidations while the backstop is solvent Informational Pending
Description
Each match computes a liquidator unrealized-mark-PnL delta, but both deleverage modules aggregate all matches for one user/chunk before updating the maximum cumulative delta. Consequently, an early match in a normal canonical product traversal can restore positive equity while a later match erases that peak before the explicitly documented between-match bankruptcy invariant samples it. The PoC starts at -$20, processes +$50 and then -$30, and commits at zero even though intermediate equity reached +$30; splitting identical matches across user entries correctly reverts. Impact: The invalid partial action persists deleverageMessageIdx. If a remaining position becomes profitable, continuations reject the now-solvent liquidator, while all unrelated liquidations and liquidator replacement remain blocked. Fairly closing the position merely realizes the surplus into cash and still cannot clear the lock. The global liquidation halt can persist indefinitely unless markets reverse, the protocol sacrifices positive backstop value, or it upgrades, allowing bad debt to accumulate.
Recommendation
Update cumulative and maximum liquidator mark-PnL deltas after every individual match in both deleverage modules. Add canonical ascending-product multi-match regressions and a constrained stale-lock recovery path that explicitly accounts for deferred PnL and positive surplus.
-
I-01 Informational Liquidations Always Seize A User's Entire Margin Suggestion Acknowledged
Description
A subaccount is liquidatable once its equity drops below its maintenance margin,
MM = notional * (0.5 / maxLeverage + takerFee), roughly5%of notional at10x. WhenLiquidation.liquidateSubaccountruns it transfers both the position and the account's remaining margin to the liquidator: it creditsuserEquityAfterLiquidationto the liquidator viabalanceTransferand setsuserAccount.balance[subaccount][quoteToken] = 0. The liquidated user is left with nothing. The position could instead be matched against a resting order and closed on the book. In that case the user pays only the market-close cost (slippage plus taker fee) and keeps the rest of their margin after being liquidated.Recommendation
Consider attempting to close a liquidatable position by matching it against resting orders. If book liquidity is sufficient to fill the position, close it there and leave the residual margin to the user. Only when liquidity is insufficient should the position and its remaining margin be transferred to the liquidator as a fallback.
-
I-02 Informational Signed Oracle Prices Decode as Unsigned Unexpected Behavior Resolved
Description
Pyth Lazer encodes
Price,BestBidPrice, andBestAskPriceas signed 64-bit integers. Meridian instead decodes these properties using an unsigned reader and stores the operative price as uint64. Consequently, a negative mantissa is reinterpreted as a large unsigned integer. For example,-1becomes2^64 - 1before exponent normalization, producing an extremely large positive price. The sequencer rejects nonpositive prices before caching them, substantially mitigating the issue. However, if an authenticated negative update is unexpectedly relayed, delisting can store an incorrect expiry price, while liquidation and deleveraging can make incorrect eligibility calculations or revert.BestBidPriceandBestAskPricehave the same type mismatch but are currently discarded.Recommendation
Decode the affected properties as int64, require the operative price to be present and greater than zero, and only then cast and normalize it.
-
I-03 Informational Missing View Modifiers in Gateway Interface Informational Resolved
Description
The IExchangeGateway interface omits the view modifier from two view getter declarations, although their implementations are correctly marked view.
function getAccountGlobalTotalBalance(address token) external returns (uint256); function getAccountGlobalTotalPendingBalance(address token) external returns (uint256);Consequently, Solidity integrations cannot call them from their own view functions through this interface, and clients generated from its ABI may incorrectly expose them as transaction methods.
Recommendation
Add the view modifier to both interface declarations.
-
I-04 Informational Duplicate Using Directives In PerpEngine Informational Resolved
Description
The
PerpEnginecontract declares the same attached-library directive twice for three libraries:using Exchange for Exchange.Dataat lines 79 and 84,using PerpPosition for PerpPosition.Dataat lines 80 and 86, andusing FeeCollector for FeeCollector.Dataat lines 81 and 85.Recommendation
Delete the duplicate declarations at lines 84, 85 and 86, keeping the originals at lines 79, 80 and 81.
-
I-05 Informational Unsupported Exponents Halt Risk Actions Suggestion Acknowledged
Description
The protocol only accepts Pyth exponents between -8 and -1 and normalizes prices to D9 through multiplication.
if (data.exponent < MIN_EXPONENT || data.exponent > MAX_EXPONENT) { revert PriceInvalidExponent(id, data.exponent); } uint16 exponent = uint16(9 + data.exponent);If a configured Pyth feed is rescaled to -9 or lower under the same feed ID, its authenticated payload becomes unusable. An account holding the affected product cannot be liquidated: including it causes price validation to revert, while excluding it violates the requirement to process all positions in the same collateral pool. The unsupported price can also prevent delist initiation and ADL involving that product. The off-chain sequencer rejects out-of-range updates before caching them, so unsupported payloads do not normally reach the contract. If no valid supported price remains available, affected risk actions remain blocked until the owner changes the feed or upgrades the protocol.
Recommendation
Monitor configured Pyth feed exponents and update the affected feed ID before any announced rescaling becomes effective.
-
I-06 Informational Packed ADL Fields Limit Settlements Documentation Resolved
Description
Deleverage actions minimize calldata by encoding each match with 64-bit fields, while positions and the subsequent processing structure use 128-bit values. At D9 precision, uint64 can encode approximately $18.45 billion, while int64 can encode PnL between approximately −$9.22 billion and +$9.22 billion. Internally, significantly larger position costs remain representable. Consider a liquidator holding a one raw D9 unit long position with a cost of approximately $27.67 billion. Closing a long calculates its PnL as the submitted notional minus its stored cost. Even using the maximum uint64 notional produces expected PnL below int64.min. The closest encodable PnL differs from the expected value by 2,220,449 raw units, exceeding the 2,220,448-unit tolerance by one raw unit. Therefore, no packed ADL match can close the position. The extreme position therefore cannot be settled through the packed ADL path. However, ordinary order matching uses wider fields and can close it, and the required position is far outside current configuration limits, so this does not create a protocol lock.
Recommendation
Document the ADL format’s 64-bit per-match limits and clarify that products capable of producing unrepresentable minimum-size settlements fall outside the supported configuration range.
-
I-07 Informational Position Fee Rebates Are Not Supported Suggestion Pending
Description
The sequencer accrues position fees by calling
updatePositionFeeon thePerpEnginecontract, which advances the per-productcumulativePositionFeeUsdindex and credits the liquidator for the fee owed by both sides of the open interest. The matching engine supports negative position fees as rebates, where the beneficiary account instead pays out to position holders in proportion to their absolute position size. The contract cannot represent this.positionFeeDeltaUsdis auint128applied asproduct.cumulativePositionFeeUsd + positionFeeDeltaUsd, and the liquidator credit is applied with+=, so both the index and the beneficiary balance can only ever increase.Recommendation
Consider documenting that rebates are intentionally unsupported in this deployment. Otherwise, change
positionFeeDeltaUsdandPerpProduct.cumulativePositionFeeUsdtoint128to mirrorcumulativeFundingUsd, update the realized-fee arithmetic inPerpEngine,LiquidationandDeleverageto signed math, and apply the liquidator delta throughLiquidationSharedLib.updateLiquidatorBalance, which already reverts on a negative resulting balance and so reproduces the beneficiary margin check the engine performs before applying a rebate round. -
I-08 Informational Zero Max Quantity Renders A Product Close-Only Validation Resolved
Description
The
_verifyProductSizesfunction from thePerpEngineAdmincontract rejects a zerotickSizeand a zerolotSize, but itsmaxQuantity % lotSize != 0guard passes for a zeromaxQuantitybecause0 % lotSize == 0, so bothregisterandupdateProductaccept amaxQuantityof zero. Every order on such a product then fails theorder.quantity > product.maxQuantitycheck inPerpEngine, leaving only zero-quantity reduce-only orders valid. So existing holders can still exit against each other, but no position can be opened or increased until an owner update restores a non-zero value.Recommendation
Add
if (maxQuantity == 0) revert InvalidParameter("maxQuantity", P_ERR_AMT_ZERO);to_verifyProductSizes -
I-09 Informational Disabling The Mark Price Cap Is Dangerous Informational Acknowledged
Description
The engine's cap behaviour is supplied per mark update and the sequencer currently hardcodes
CapToFirstBankruptcyPrice, which pins every price move at the point where the first account reaches exactly zero equity. No account can therefore be pushed to negative equity by a price move, which is what guarantees a liquidated account is zeroed out withbalanceTransfer = 0and leaves no deficit for the liquidator to absorb. UnderNoCapa large price jump can leave an account at negative equity, producing a negativebalanceTransfer, and because balances are unsigned the liquidator cannot carry that as debt:updateLiquidatorBalancefrom theLiquidationSharedLiblibrary reverts withLiquidatorInsufficientBalanceif the combined realized PnL and balance transfer would take its quote token balance below zero. The account is then unliquidatable until the liquidator is funded with enough cash, leaving the position open to sink further into bad debt.Recommendation
Document that keeping
CapToFirstBankruptcyPriceenabled is critical for liquidation liveness. IfNoCapis ever adopted for a product, account balances need redesigning so they can be signed and hold a negative value where required, and the settlement path updated to carry that deficit rather than revert on it. -
I-10 Informational Emergency Pause Blocks Liquidations Warning Acknowledged
Description
The
emergencyPausefunction from theExchangeGatewaycontract sets a single global flag that gatesprocessActionsalongside the deposit and withdraw entrypoints, so a pause halts every sequencer-relayed action, including liquidations and reduce-only closes, as well as user collateral top-ups. Because mark prices keep moving while the book is frozen, a position that was safely liquidatable when the pause began can be deeply insolvent by the time trading resumes.Recommendation
Pausing the liquidation and deposit paths remains important, since pause is invoked when an exploit is suspected and those functions may themselves sit in the exploit path, so no code change is recommended here. Consider documenting that risk-reducing operations being included in the paused set of operations is an intended tradeoff
-
I-11 Informational Zero-valued withdrawal request ID conflicts with the fresh Claimer cursor Informational Pending
Description
The Claimer's sole high-water mark initializes to zero and every submitted ID must be strictly greater than it. Therefore ID zero can never be processed. Repository tests consistently start fresh streams at ID 1, but neither the interface nor the type explicitly reserves zero, creating an integration ambiguity if an external service uses zero-based numbering. Impact: If the intended external stream starts at zero, its genesis request and any batch retaining it cannot use the Claimer until the operator omits or renumbers it. The withdrawal remains directly finalizable through the Gateway, so this is avoidable integration friction rather than loss or protocol-level denial.
Recommendation
Explicitly reserve and document zero and reject it clearly, or add an initialized flag so a valid first ID of zero can be distinguished from an uninitialized cursor.
-
I-12 Informational Token configuration NatSpec incorrectly labels D9 values as native units Informational Resolved
Description
The implementation, deployment scripts, and tests consistently interpret depositCapacity, minDeposit, depositFee, and withdrawFee as normalized D9 accounting values. However, UpdateTokenParams and Token.Data NatSpec describe several or all of these fields as token-native units. The original scale-distortion claim therefore requires trusted-owner misconfiguration and is not an exploitable accounting bug, but the public API documentation is contradictory. Impact: An operator or integrator following the stale NatSpec instead of established deployment conventions could supply incorrectly scaled configuration for non-nine-decimal collateral. Because token configuration is a fully trusted owner action, this is documentation and operational ambiguity rather than a permissionless vulnerability.
Recommendation
Update IExchangeConfig.UpdateTokenParams and Token.Data NatSpec to state that all capacity, minimum, and flat-fee fields use D9 precision. Consider encoding the unit in field names or dedicated value types and adding decimal-diverse configuration tests.
-
I-13 Informational Zero-normalized deposits register collateral-free subaccounts Informational Pending
Description
For a deposit-enabled token with more than nine decimals and owner-configured minDeposit and depositFee of zero, a positive native amount below one D9 accounting unit normalizes to zero. The deposit path accepts it, stores a pending deposit identified by a nonzero token address, and transfers zero tokens. A sequencer can then finalize the zero pending amount and mark the named subaccount registered without collateral. Impact: An attacker paying transaction gas can create persistent zero-value pending deposits and, if the sequencer finalizes them, zero-balance registered subaccounts. No collateral, withdrawal capacity, or cross-user value is created, and no bounded onchain iteration is affected, so the proven impact is limited to state cleanliness and avoidable indexing/finalization overhead under a zero-minimum configuration.
Recommendation
Reject deposits when normalizedAmount or normalizedNetAmount is zero. Also consider requiring a positive minimum deposit for every deposit-enabled concrete token and defensively rejecting zero-total pending deposits during finalization.
-
I-14 Informational Expired partial-order fills are never pruned Informational Acknowledged
Description
_settleOrderFills stores product.filled[digest] for a partially filled order and deletes it only if that exact digest is later fully filled. Once the signed order expires, ActionHandler rejects any further match before the deletion branch, and the protocol exposes no cancellation or expiry-pruning path. Consequently, the partial-fill slot remains in proxy storage indefinitely. Impact: Expired partial orders leave stale proxy/L2 state that cannot be reclaimed. Under ordinary honest minimum-fill matching, however, each such slot is paired with a consumed nonce for the fully filled counter-order and does not demonstrate asymptotic storage amplification beyond normal permanent replay bookkeeping. The stronger two-slot/no-nonce construction requires trusted-sequencer underfilling and a directionally invalid reduce-only self-order, so no Low-severity unprivileged impact is established.
Recommendation
Provide authenticated cancellation or expiry-based pruning for filled[digest], with the signed expiry cryptographically bound to the digest. Consider similarly pruning expired replay state or using bounded epochs so obsolete order bookkeeping can be reclaimed safely.
-
I-15 Informational Revoked linked signer addresses cannot later become trader accounts Informational Pending
Description
Revocation permanently marks the former signer in
excludedAccounts, so the address cannot subsequently initialize an exchange account through a deposit. This behavior is deliberate and covered by upstream tests, but the irreversible account-address consequence is broader than merely preventing signer reuse and should be made explicit to users when they co-sign a link. Impact: A consenting linked-signer address that is later revoked must use a different wallet if its owner wants to become an independent Meridian trader. No third-party funds or protocol invariant are affected.Recommendation
Prominently disclose during signer linking that the address is permanently reserved from use as an exchange account, or redesign identity state if future reuse as an independent account is desired.
-
I-16 Informational Synthetic token capacity is accepted but has no defined effect Informational Pending
Description
The shared token update API accepts and reports a nonzero
depositCapacityfor synthetic tokens, even though synthetic tokens cannot enable physical deposits and signed conversions do not consult the destination token's capacity. Consequently, the configured value has no effect on balances created by conversion. The behavior is not a demonstrated risk-limit bypass because the field is documented as a deposit restriction and conversion preserves the backing family's already-counted exposure, but accepting an inapplicable value creates configuration/API ambiguity. Impact: An operator or integrator may incorrectly interpret a stored synthetic-token capacity as an enforced internal exposure cap. No collateral is minted, backing-family capacity is not bypassed, and the PoC shows no direct third-party loss.Recommendation
Either reject or ignore nonzero deposit-only settings for synthetic tokens and document them as inapplicable, or introduce a separate explicitly defined conversion/exposure capacity and enforce it when crediting synthetic destinations.
-
I-17 Informational Per-account caps cannot prevent narrow ADL recovery from freezing all liquidations Informational Pending
Description
Per-subaccount offchain caps do not bound the designated liquidator's aggregate exposure: each full int128 liquidation nets another user's position into its existing position without maxQuantity, maxPositionNotionalUsd, or an int64-compatible aggregate check. The revised public-path PoC proves three separately funded and signed int64-capped accounts aggregate through genuine liquidations into a 3 * int64.max liquidator position. Repeating the same cap-compliant operation can produce the 100,001 * int64.max exposure while remaining within the configured product OI/notional limits. Because packed ADL settlements carry only int64 size and related 64-bit fields, a complete unwind then needs 100,001 settlements and a 3.4MB payload, exceeding Nitro's transaction-data bound and failing Robinhood's 32m gas cap. A bounded partial ADL necessarily stores the singleton marker; fresh favorable pricing makes the residual solvent and continuation reverts, while no solvent-abort path clears the marker. Impact: An honest liquidation cascade across individually cap-compliant accounts can force the backstop unwind across transactions. Favorable price movement can then leave a solvent residual that cannot be further ADL'd or normally aborted, while the singleton marker blocks unrelated liquidations across all products and quote pools and prevents liquidator rotation. Underwater accounts can remain unresolved and bad debt can grow until prices reverse or governance deploys an emergency upgrade. Even without recovery, narrow recovery fields can cause a prolonged liquidation outage during a large cascade.
Recommendation
Apply aggregate quantity and notional limits to liquidator takeovers that are compatible with ADL wire and production transaction bounds, and widen ADL size/notional/PnL fields to the position/liquidation int128/uint128 domain. Add a solvent-abort transition that reconciles deferred balance state and clears the marker without force-closing counterparties, and scope non-interleaving more narrowly where possible.
-
I-18 Informational Local withdrawals silently discard low-decimal collateral remainders created by normal trading Informational Acknowledged
Description
Meridian accounts for collateral at nine decimals, but denormalization of tokens with fewer decimals uses truncating division. Withdrawal initiation debits the complete normalized payout and reduces global accounting by that amount, whereas same-chain non-USD finalization transfers only the truncated native-token amount, clears the pending request, and does not re-credit the remainder. The updated PoC demonstrates that ordinary trading fees can produce such a nonrepresentable balance for standard six-decimal USDC, then closes all positions and withdraws the full balance through the local path. Cross-chain OFT flooring and rescue behavior are excluded as already covered by the prior finding. Impact: A local withdrawal of low-decimal collateral silently loses any sub-native-unit remainder created by trading fees, PnL, funding, or similar accounting. Each loss is strictly less than one smallest native token unit, but the residual tokens accumulate untracked in the Gateway while the user's internal and pending balances are cleared.
Recommendation
Before debiting a local concrete-token withdrawal, require its net payout to be exactly representable in the token's native decimals. Alternatively, transfer the floored amount while retaining or re-crediting the normalized remainder with consistent global accounting. Where feasible, align fees and other balance-producing calculations to the quote token's native quantum.
-
I-19 Informational Deposit Capacity Behavior Is Undocumented Documentation Resolved
Description
The documentation does not clearly state that
depositCapacitylimits active backed balances rather than the exchange’s physical token custody. Initiated withdrawals are removed from active accounting immediately and therefore release deposit capacity, although their tokens remain escrowed until finalization or rescue. Consequently, physical custody may exceeddepositCapacityby the aggregate pending withdrawal amount. This does not create insolvency because each withdrawal claim remains fully backed, but the undocumented distinction may cause the parameter to be interpreted as a total custody limit.Recommendation
Document that
depositCapacityexcludes initiated withdrawals and limits only active backed collateral. -
I-20 Informational Pending withdrawals bypass the token-specific completion halt Informational Acknowledged
Description
Token.withdrawEnabled is checked only when a withdrawal is initiated. Although the README says disabling the onchain flag halts the relayer and prevents pending-withdrawal completion, finalizeWithdraw does not reload Token.Data, allowing an account to directly complete a pre-existing request while the token remains disabled. Impact: If the flag is intended as a token-scoped completion halt, operators cannot reliably stop custody transfers for one affected token: queued requests remain directly executable, while the only effective onchain alternative is the global emergency pause. No accounting or solvency loss is proven because initiation already debits the payout from backed liabilities.
Recommendation
Clarify the intended semantics. If disabling withdrawals must stop pending transfers, re-check withdrawEnabled before local and OFT completion, with any rescue override explicitly scoped. Otherwise, document the flag as initiation-only and align relayer behavior so it does not imply a token-scoped completion guarantee.
-
I-21 Informational Binary64 tolerance can admit liquidation marginally above exact maintenance Informational Pending
Description
Liquidation accepts an offchain eligibility classification within a cumulative per-position IEEE-754 tolerance. The PoC models round-to-nearest-even binary64 arithmetic for a correctly margined, production-capped three-product portfolio: the model reports slightly below maintenance while Solidity's exact calculation remains 20 raw D9 units above it, and the accumulated onchain tolerance admits liquidation. The production matching-engine source and its precise conversion order are not available to confirm this modeled discrepancy. Impact: A portfolio deliberately or coincidentally positioned within a few raw D9 units of maintenance may be liquidated despite remaining marginally above the unbuffered Solidity threshold. Because the protocol intentionally zeroes an entire isolated book after eligibility, the transition has a large cliff effect, but the discrepancy is nanodollar-scale, requires an extremely precise boundary state, and is within the documented rounding accommodation.
Recommendation
Use fixed-point offchain risk arithmetic matching the canonical onchain calculation. If binary64 remains necessary, document and formally derive a portfolio-wide error bound from the production engine's exact evaluation order, and decide explicitly which calculation is authoritative at the liquidation boundary.
-
I-22 Informational Tolerated IEEE-754 notional drift pollutes position basis Informational Pending
Description
The contract derives quantity times the maker's signed price but uses it only as a tolerance reference; a differing raw-D9 float64 matching-engine fillNotional within
(reference >> 52) + 13is stored verbatim as position cost. The passing PoC uses a $14.42m position fully collateralized for configured 10x leverage and charges fixture maker/taker fees. The raw float64 engine notional exceeds canonical Solidity arithmetic by two units. Forty-eight same-price open/close cycles leave the attacker exactly 96 raw units above its fees-only baseline and the victim 96 below, after which the resulting balance is finalized into backing USD. Impact: Accepted offchain floating-point dust becomes a real zero-sum trader-to-trader accounting transfer rather than validation-only tolerance. It pollutes signed-price position basis and downstream realized PnL even with sufficient margin and configured fees. The per-fill value is tiny and normal fees dominate it, supporting Low severity.Recommendation
Use canonical
fillQuantity.mulDecimal(makerPrice)for every position-cost mutation and downstream PnL calculation, retaining external fillNotional only as a validation input. Alternatively canonicalize the engine value to Solidity D9 arithmetic and require exact equality.
No findings match.
More from Ethereal
-
Contract Updates
59 findings5 high 59 findings: 5 high, 4 medium, 22 low, 28 informational -
Orderbook DEX, Round 2
45 findings1 critical · 5 high 45 findings: 1 critical, 5 high, 16 medium, 9 low, 14 informational -
Orderbook DEX
36 findings1 critical · 1 high 36 findings: 1 critical, 1 high, 3 medium, 16 low, 15 informational -
Pre-Deposit Vault
1 finding 1 finding: 1 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.
