F-2026-0004·missing-fund-recovery

USDC sent directly to the vault is permanently irrecoverable

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

rescueERC20 explicitly blocks USDC recovery to protect operational balances, but no alternative mechanism exists, so USDC sent outside the tracked accounting paths is permanently stuck.

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

Description

The rescueERC20 function explicitly blocks USDC recovery with CannotRescueOperationalToken at L1620. This is intentional, the contract holds operational USDC and a naive rescue function could drain protocol balances. However, no alternative rescue mechanism exists, meaning any USDC that enters the contract outside the tracked accounting paths is permanently stuck.

This can happen through a user accidentally sending USDC to the contract address instead of calling createPosition, or a frontend/integration bug routing funds to the wrong address.

The contract tracks USDC across five buckets: capitalPoolBalanceUsdcUnits, accumulatedFeesUsdcUnits, pendingOriginationFeesUsdcUnits, per-position collateralBalanceUsdcUnits (Created state only), and per-position pendingRefundUsdcUnits. Any USDC outside these buckets is inaccessible, but two of these buckets (collateralBalanceUsdcUnits and pendingRefundUsdcUnits) exist only at the position level with no global accumulator, so the contract cannot compute its own tracked total on-chain to distinguish stuck funds from operational funds.

03Section · Recommendation

Recommendation

Add two global accumulator state variables to track the per-position USDC buckets:

solidity
uint256 public totalCollateralBalanceUsdcUnits;
uint256 public totalPendingRefundUsdcUnits;

Maintain them at all mutation points:

  • totalCollateralBalanceUsdcUnits (3 sites): increment at createPosition, decrement at openPosition (L575), decrement at cancelPosition.
  • totalPendingRefundUsdcUnits (3 sites): increment at finalizeOpen (partial fill), increment at finalizeForceUnwind (non-terminal path), decrement at CloseLib.finalizePositionClose (when pendingRefundUsdcUnits is zeroed).

Then add a timelock-gated rescue that can only withdraw the provably untracked excess:

solidity
function rescueUSDC(uint256 amountUsdcUnits) external nonReentrant onlyTimelock {
uint256 trackedTotal = capitalPoolBalanceUsdcUnits
+ accumulatedFeesUsdcUnits
+ pendingOriginationFeesUsdcUnits
+ totalCollateralBalanceUsdcUnits
+ totalPendingRefundUsdcUnits;
uint256 actualBalance = IERC20(usdcToken).balanceOf(address(this));
uint256 excess = actualBalance - trackedTotal; // reverts on underflow
if (amountUsdcUnits > excess) revert InsufficientExcess();
IERC20(usdcToken).safeTransfer(globalAdmin, amountUsdcUnits);
}

This preserves the original safety concern (admin cannot touch operational USDC) while making accidentally transferred USDC recoverable.

04Section · Resolution

Resolution

Acknowledged. USDC rescue not added on contract-size grounds; existing rescue paths hardened to onlyTimelock.

F-2026-0004

oog
zealynx

Smart Contract Security Digest

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

© 2026 Zealynx