F-2026-0009·stale-derived-state

finalizeLiquidate leaves actualNotionalUsdcUnits stale post-liquidation, creating a latent fee-computation bug and inconsistent off-chain reads

Fixedvaultleveragedprediction-marketgithub.com/bloom-art/dripster-lend
TL;DR

finalizeLiquidate updates borrow and fee fields but never refreshes actualNotionalUsdcUnits, leaving the stored notional pre-liquidation. The contract is currently safe by accident; off-chain consumers reading the public getter receive incorrect values.

Severity
LOW
Impact
MEDIUM
Likelihood
LOW
Method
MManual review
CAT.
Complexity
MEDIUM
Exploitability
LOW
02Section · Description

Description

finalizeLiquidate defensively writes borrowedUsdcUnits, pendingLifetimeFeeUsdcUnits, and feeAccrualStartAt after the liquidation completes, so that a delayed finalizeForceUnwind arriving from Liquidated state (the concurrent unwind plus liquidate race) can correctly compute its remaining work. However, it does NOT update actualNotionalUsdcUnits. The stored notional retains its pre-liquidation value even though the position's effective post-liquidation notional is now collateralUsdcUnits + borrowedUsdcUnits (which is smaller after the residual borrow reduction at L1248).

solidity
// LeveragedPredictionVaultV1.sol:L1248-1254
position.borrowedUsdcUnits -= dist.protocolCapitalReturnUsdcUnits;
position.pendingLifetimeFeeUsdcUnits = collectedLifetimeFeeUsdcUnits <= totalLifetimeFeesOwedUsdcUnits
? totalLifetimeFeesOwedUsdcUnits - collectedLifetimeFeeUsdcUnits
: 0;
position.feeAccrualStartAt = uint64(block.timestamp);
// Missing: position.actualNotionalUsdcUnits = position.collateralUsdcUnits + position.borrowedUsdcUnits;

The contract is currently safe on-chain only by accident: the only consumer that would use the stale notional for fee math (finalizeForceUnwind at L1407 to L1410) short-circuits to pendingLifetimeFeeUsdcUnits via the isTerminal branch at L1402 before reaching the stale-read path. The safety property is not "the value is correct" (it isn't) but rather "we currently never look at it in the wrong state." Any future refactor of the isTerminal branch in finalizeForceUnwind, or any new on-chain consumer of actualNotionalUsdcUnits on a Liquidated position, would manifest the latent bug as incorrect fee accrual against the user.

The off-chain impact is more immediate. actualNotionalUsdcUnits is a public storage variable with an auto-generated getter, almost certainly consumed by partner frontends, indexers, dashboards, monitoring, and protocol-TVL attestation. Those consumers have no way to know the field silently goes stale the moment a position transitions to Liquidated, and would display or aggregate pre-liquidation notionals for liquidated positions, inflating per-position figures and any aggregates derived from them. The same staleness would also propagate into a future V1-to-V2 storage migration if the field is copied verbatim into the new schema.

03Section · Recommendation

Recommendation

Add one line in finalizeLiquidate after L1248 so the stored notional stays consistent with the position's post-liquidation state:

diff
position.borrowedUsdcUnits -= dist.protocolCapitalReturnUsdcUnits;
+position.actualNotionalUsdcUnits = position.collateralUsdcUnits + position.borrowedUsdcUnits;

This eliminates the latent on-chain fee-computation bug, removes the implicit dependency on finalizeForceUnwind's isTerminal branch, and ensures all on-chain and off-chain readers of actualNotionalUsdcUnits see a value that reflects the position's current state.

04Section · Resolution

Resolution

Fixed. _resyncDerivedFields recomputes actualNotionalUsdcUnits and leverageBps after the borrowed decrement; identity locked in by _assertPositionInvariants.

Status
Fixed
F-2026-0009

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx