Singularity_Fi’s dynBaseUSDCv3 vault on Base was exploited in transaction 0x00b949bc3ed3edb58b04faedfbd8eb1db2edceae761382e80fe012919f8d3732, mined at block 45183967 on 2026-04-25 22:48:01 UTC (2026-04-26 in Asia/Shanghai). The root cause was an oracle configuration error: the vault’s Uniswap V3 oracle accepted and used fee tier 42, which is not an enabled Uniswap V3 fee tier, so direct getPool(token, USDC, 42) lookups returned address(0) and the oracle returned zero prices instead of reverting. Because the WETH fallback pools for the affected yield tokens also had zero liquidity, VaultTokensLib.totalAssets() counted only about 100 idle USDC and ignored the vault’s yield-token reserves. The attacker used a 100,000 USDC Morpho flash loan to mint 420,300,912.285322153666116992 vault shares, redeem those shares proportionally for almost all actual reserves, and realize 413,132.022315 USDC plus residual yield tokens.
UniswapV3Oracle at 0x73b8c192bfc323c3ea224c88219d55dfc319e89f.analysis_0x00b949bc3ed3edb58b04faedfbd8eb1db2edceae761382e80fe012919f8d3732/0x73b8c192bfc323c3ea224c88219d55dfc319e89f/contracts/dynavaults/oracles/UniswapV3Oracle.sol.dynBaseUSDCv3 vault proxy 0x67b93f6676bd1911c5fae7ffa90fff5f35e14dcd, minimal-proxy implementation 0xea7975c2fec1ae9e3058bb5f99d8e26dbc816811.0x478675aa4121c07825167bbb25a44aadd22bef7f, implementation 0x95cf606f7e499549d83bd3c8a1e5d97fdf36688b, using VaultTokensLib.totalAssets().setUniV3fee(address base, address quote, uint24 fee), selector 0x087e5606.getPrice(address base, uint24 fee, address quote, uint256 amount), selector 0x03029d4e.tokenReferenceValue(address,uint256) / getPrice(address,address) selectors 0xacf7f5a9 and 0xac41865a, called from vault accounting.function getPrice(address base, address quote, uint256 amount) public view returns (uint256, uint256) {
uint24 baseFee = (uniV3fee[base][quote] > 0) ? uniV3fee[base][quote] : 3000; // <-- VULNERABILITY: trusts any configured non-zero fee, including invalid fee 42
(uint256 directValue, uint256 directTimestamp) = getPrice(base, baseFee, quote, amount);
if (directTimestamp == 0 && base != WETH && quote != WETH) {
baseFee = (uniV3fee[base][WETH] > 0) ? uniV3fee[base][WETH] : 3000;
(uint256 wethValue, uint256 baseTimestamp) = getPrice(base, baseFee, WETH, amount);
if (baseTimestamp == 0) return (0, 0); // <-- VULNERABILITY: zero price is treated as a valid missing-price result
uint24 wethFee = (uniV3fee[WETH][quote] > 0) ? uniV3fee[WETH][quote] : 3000;
(uint256 indirectValue, uint256 indirectTimestamp) = getPrice(WETH, wethFee, quote, wethValue);
uint256 oldestTimestamp = (indirectTimestamp < baseTimestamp) ? indirectTimestamp : baseTimestamp;
return (indirectValue, oldestTimestamp);
}
return (directValue, directTimestamp);
}
function getPrice(address base, uint24 fee, address quote, uint256 amount) public view returns (uint256 price, uint256 oldestObservation) {
uint32 secondsAgo = uint32(observationPeriod);
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = secondsAgo;
secondsAgos[1] = 0;
address pool = IUniswapV3Factory(uniswapV3Factory).getPool(base, quote, fee); // <-- VULNERABILITY: invalid fee 42 returns address(0)
if (pool != address(0)) {
if (IUniswapV3Pool(pool).liquidity() < minLiquidityThreshold) return (0, 0); // <-- VULNERABILITY: zero-liquidity fallback also returns zero instead of failing closed
(int56[] memory tickCumulatives, ) = IUniswapV3Pool(pool).observe(secondsAgos);
int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
int24 tick = int24(tickCumulativesDelta / int56(uint56(secondsAgo)));
uint256 amountOut = OracleLibrary.getQuoteAtTick(tick, uint128(amount), base, quote);
(, , uint16 observationIndex, , , , ) = IUniswapV3Pool(pool).slot0();
(uint32 observationTimestamp, , , bool initialized) = IUniswapV3Pool(pool).observations(observationIndex);
if (initialized) oldestObservation = observationTimestamp;
return (amountOut, oldestObservation);
}
// <-- VULNERABILITY: no revert; Solidity returns (0, 0), so the vault treats the reserve as valueless
}
function setUniV3fee(address base, address quote, uint24 fee) external onlyRole(ORACLE_ADMIN) {
uniV3fee[base][quote] = fee; // <-- VULNERABILITY: no validation against factory.feeAmountTickSpacing(fee) or known fee tiers
uniV3fee[quote][base] = fee;
}
The zero price is then consumed by vault accounting:
function totalAssets() external view returns (uint256 total) {
address referenceAssetOracle = IDynaVaultAPI(VaultGovernanceLib.vault()).referenceAssetOracle();
TokenStorage storage _storage = tokenStorage();
address depositToken = _storage.tokens[0];
uint256 _nrOfTokens = _storage.tokens.length;
for (uint256 i = 0; i < _nrOfTokens; ++i) {
address tokenAddress = _storage.tokens[i];
uint256 tokenAmount = _storage.tokenStats[tokenAddress].tokenIdle + _storage.tokenStats[tokenAddress].tokenDebt;
if (i == 0) {
total += tokenAmount;
} else if (tokenAmount != 0) {
(uint256 price, ) = IReferenceAssetOracle(referenceAssetOracle).getPrice(tokenAddress, depositToken);
total += FixedPointMathLib.fullMulDiv(price, tokenAmount, (10 ** IERC20Metadata(tokenAddress).decimals())); // <-- VULNERABILITY: price==0 silently removes this reserve from totalAssets
}
}
}
And the inflated share issuance and proportional redemption path is:
function deposit(uint256 assetsIncludingFees, address receiver) public virtual override returns (uint256 sharesNotIncludingFees) {
receiver.requireNonZeroAddress();
before_nonReentrant();
uint256 reportedFreeFunds = DynaVaultLib.reportAllReserves();
DynaVaultLib.checkMaxDeposit(assetsIncludingFees);
DynaVaultLib.checkMinDeposit(assetsIncludingFees);
sharesNotIncludingFees = DynaVaultLib.previewDeposit(assetsIncludingFees, reportedFreeFunds); // <-- uses under-reported free funds
_deposit(msg.sender, receiver, assetsIncludingFees, sharesNotIncludingFees);
after_nonReentrant();
}
function redeemProportional(uint256 sharesIncludingFees, address receiver, address owner) public virtual override returns (uint256[] memory) {
owner.requireNonZeroAddress();
receiver.requireNonZeroAddress();
before_nonReentrant();
if (msg.sender != owner) _spendAllowance(owner, msg.sender, sharesIncludingFees);
uint256 reportedFreeFunds = DynaVaultLib.reportAllReserves();
DynaVaultLib.checkRedeem(sharesIncludingFees, owner, reportedFreeFunds);
uint256[] memory toRedeem = DynaVaultLib.calcRedeemProportional(sharesIncludingFees); // <-- proportional payout uses actual token balances, not oracle value
_burn(owner, sharesIncludingFees);
DynaVaultLib.transferProportional(receiver, toRedeem);
after_nonReentrant();
return toRedeem;
}
Expected behavior: the oracle should only accept enabled Uniswap V3 fee tiers or should verify that factory.getPool(base, quote, fee) is nonzero and liquid enough before persisting the route. If no trustworthy price exists, the oracle/vault should fail closed so deposits and redemptions cannot proceed on an incomplete asset valuation.
Actual behavior: setUniV3fee() accepts arbitrary uint24 values. The admin configured fee = 42 for the USDC/yield-token routes on 2026-01-19 06:37:03 UTC in tx 0x2df0be7a17bd69a2f732c1396796690240aecdfaf13b0a8f60f49f95a8dbe150. At the exploit block, uniV3fee[USDC][token] == 42 for PUSDCHY, CPT48, maxUSD, ysUSDC, REN-USDC-B, and tUSDC.
Uniswap V3 factory lookups for getPool(USDC, token, 42) returned address(0). The oracle then tried WETH fallback pools for five nonzero reserve tokens, but each token/WETH 3000 pool had liquidity() == 0; for tUSDC, no WETH fallback pool existed. The oracle returned (0, 0), and VaultTokensLib.totalAssets() added 0 for those reserves. The vault therefore reported totalAssets() == 100000000 raw USDC (100 USDC) immediately before the flash-loan deposit, despite holding economically meaningful yield-token reserves.
This matters because share minting used the under-reported reportedFreeFunds, while redeemProportional() paid out a ratio of real token balances. The attacker deposited 100,000 USDC into a vault priced as if it had only 100 USDC of assets, minted 420,300,912.285322153666116992 shares, then burned those shares for roughly 99.9001518113% of every reserve token balance.
0x5c2cbe53f2ce1b58532d4985a9b9d3db87d3af4c.100 USDC in total assets.100,000 USDC flash loan from Morpho Blue.100,000 USDC into dynBaseUSDCv3.420,300,912.285322153666116992 shares to the helper.redeemProportional() with all newly minted shares and received the vault’s actual USDC/yield-token balances pro rata.100,000 USDC flash loan and transferred 413,132.022315 USDC plus residual maxUSD and ysUSDC to receiver 0x25c08505b6c5eba2d6c5d97c9e9a7f5f58d9a079.Key trace path, derived from trace_callTracer.json and decoded_calls.json:
0x5c2cbe53... -> 0x9ad48257... selector 0xc765f2d2 (attacker helper entrypoint, unverified).0x67b93f66... totalSupply() returned 420082.292765729913584723 vault shares.0x67b93f66... totalAssets() returned 100000000 raw USDC (100 USDC).0xbbbbbbbb... flashLoan(address,uint256,bytes) for USDC amount 100000000000 (100,000 USDC).transfer(address,uint256) sent 100,000 USDC to the helper.0x31f57072 (flash-loan callback).approve(address,uint256) approved the vault.deposit(uint256,address) with assets=100000000000, receiver=0x9ad48257...; output shares were 420300912285322153666116992 raw shares (420,300,912.285322153666116992).deposit(), vault -> reserve manager reportAllReservesFromVault() / totalAssetsCached() called the oracle repeatedly. For affected reserves, oracle -> Uniswap V3 factory getPool(token,USDC,42) returned address(0), and token/WETH fallback pools returned liquidity() == 0.redeemProportional(uint256,address,address) with shares 420300912285322153666116992, receiver and owner both the helper.[100000000000, 0, 1906705673147924829529, 3838361764166304302973, 103063238187773015912541, 1347499541, 308710203688], corresponding to 100,000 USDC, 0 tUSDC, 1,906.705673147924829529 PUSDCHY, 3,838.361764166304302973 CPT48, 103,063.238187773015912541 maxUSD, 1,347.499541 ysUSDC, and 308,710.203688 REN-USDC-B.REN-USDC-B, maxUSD, CPT48, and PUSDCHY, causing Morpho/underlying vault calls including redeem(uint256,address,address), accrueInterest(...), and withdraw(...).100,000 USDC via transferFrom(helper, Morpho, 100000000000).0x25c08505b6c5eba2d6c5d97c9e9a7f5f58d9a079: 413,132.022315 USDC, 31,174.2923534301231755 maxUSD, dust PUSDCHY/CPT48, and 1,347.499541 ysUSDC.The vault’s direct token outflows to the attacker helper were:
| Token | Amount transferred from vault to helper | Notes |
|---|---|---|
| USDC | 100,000 | This was the attacker’s flash-loaned deposit returned during proportional redemption. |
| PUSDCHY | 1,906.705673147924829529 | Later redeemed for USDC. |
| CPT48 | 3,838.361764166304302973 | Later redeemed for USDC. |
| maxUSD | 103,063.238187773015912541 | Partly redeemed for USDC; residual transferred to profit receiver. |
| ysUSDC | 1,347.499541 | Transferred as residual token balance. |
| REN-USDC-B | 308,710.203688 | Redeemed for USDC. |
| tUSDC | 0 | Included in reserve list, but no amount transferred in this exploit. |
Realized proceeds in the transaction were 413,132.022315 USDC sent to profit receiver 0x25c08505b6c5eba2d6c5d97c9e9a7f5f58d9a079, after the helper repaid the 100,000 USDC flash loan. The same receiver also received residual 31,174.2923534301231755 maxUSD and 1,347.499541 ysUSDC, plus negligible PUSDCHY/CPT48 dust. Using USDC as the reference, the realized loss is approximately $413.1K; including residual yield-token face amounts, the gross token drain is higher.
The economic loss falls on dynBaseUSDCv3 vault share holders because the vault’s reserves were redeemed by an attacker who minted shares at a broken, oracle-understated ratio. Morpho Blue was used both as the flash-loan source and as the underlying redemption venue for some yield tokens, but the exploited accounting flaw was in the Singularity_Fi vault/oracle path.
45183967; receipt contained 48 ERC-20 Transfer events.totalSupply() == 420082292765729913584723 and totalAssets() == 100000000 before the flash loan.deposit(100000000000, helper) trace output was 420300912285322153666116992 shares. Attacker ownership after mint was 420300912.285322153666116992 / (420300912.285322153666116992 + 420082.292765729913584723) = 99.9001518113%.oracle_fee42_evidence.json records that uniV3fee[USDC][token] == 42 for the affected yield tokens at the exploit block, each factory.getPool(USDC, token, 42) returned 0x0000000000000000000000000000000000000000, and each existing token/WETH fallback pool had zero liquidity.0x2df0be7a17bd69a2f732c1396796690240aecdfaf13b0a8f60f49f95a8dbe150 called oracle selector 0x087e5606 (setUniV3fee(address,address,uint24)) with base=USDC, quote=PUSDCHY, and fee=42 at block 41007638 on 2026-01-19 06:37:03 UTC. The on-chain oracle mapping confirms analogous fee-42 routes for the other affected tokens by the exploit block.funds_flow.json confirms the vault’s negative deltas for PUSDCHY, CPT48, maxUSD, ysUSDC, and REN-USDC-B, and the final receiver’s positive 413,132.022315 USDC balance change. The script did not auto-classify these as attacker_gains because its default attacker address was the top-level EOA, while the proceeds moved through the helper and final receiver.