Singularity_Fi dynBaseUSDCv3 Oracle Misconfiguration / Share Inflation
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.
Root Cause
Vulnerable Contract
- Primary vulnerable contract:
UniswapV3Oracleat0x73b8c192bfc323c3ea224c88219d55dfc319e89f. - Source type: verified Solidity,
analysis_0x00b949bc3ed3edb58b04faedfbd8eb1db2edceae761382e80fe012919f8d3732/0x73b8c192bfc323c3ea224c88219d55dfc319e89f/contracts/dynavaults/oracles/UniswapV3Oracle.sol. - Affected consumer:
dynBaseUSDCv3vault proxy0x67b93f6676bd1911c5fae7ffa90fff5f35e14dcd, minimal-proxy implementation0xea7975c2fec1ae9e3058bb5f99d8e26dbc816811. - Vault accounting path: reserve manager proxy
0x478675aa4121c07825167bbb25a44aadd22bef7f, implementation0x95cf606f7e499549d83bd3c8a1e5d97fdf36688b, usingVaultTokensLib.totalAssets().
Vulnerable Function
- Primary configuration function:
setUniV3fee(address base, address quote, uint24 fee), selector0x087e5606. - Primary price function:
getPrice(address base, uint24 fee, address quote, uint256 amount), selector0x03029d4e. - Impact path:
tokenReferenceValue(address,uint256)/getPrice(address,address)selectors0xacf7f5a9and0xac41865a, called from vault accounting.
Vulnerable Code
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;
}
Why It’s Vulnerable
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.
Attack Execution
High-Level Flow
- The attacker called an unverified helper contract from EOA
0x5c2cbe53f2ce1b58532d4985a9b9d3db87d3af4c. - The helper checked the vault’s supply and assets; the vault reported only
100USDC in total assets. - The helper took a
100,000USDC flash loan from Morpho Blue. - Inside the Morpho callback, the helper approved USDC and deposited
100,000USDC intodynBaseUSDCv3. - Because oracle-priced free funds were under-reported, the vault minted
420,300,912.285322153666116992shares to the helper. - The helper called
redeemProportional()with all newly minted shares and received the vault’s actual USDC/yield-token balances pro rata. - The helper redeemed/withdrew several yield tokens into USDC through their underlying Morpho-backed mechanisms.
- The helper repaid the
100,000USDC flash loan and transferred413,132.022315USDC plus residualmaxUSDandysUSDCto receiver0x25c08505b6c5eba2d6c5d97c9e9a7f5f58d9a079.
Detailed Call Trace
Key trace path, derived from trace_callTracer.json and decoded_calls.json:
0x5c2cbe53...->0x9ad48257...selector0xc765f2d2(attacker helper entrypoint, unverified).- Helper -> vault
0x67b93f66...totalSupply()returned420082.292765729913584723vault shares. - Helper -> vault
0x67b93f66...totalAssets()returned100000000raw USDC (100USDC). - Helper -> Morpho Blue
0xbbbbbbbb...flashLoan(address,uint256,bytes)for USDC amount100000000000(100,000USDC). - Morpho -> USDC
transfer(address,uint256)sent100,000USDC to the helper. - Morpho -> helper selector
0x31f57072(flash-loan callback). - Helper -> USDC
approve(address,uint256)approved the vault. - Helper -> vault
deposit(uint256,address)withassets=100000000000,receiver=0x9ad48257...; output shares were420300912285322153666116992raw shares (420,300,912.285322153666116992). - During
deposit(), vault -> reserve managerreportAllReservesFromVault()/totalAssetsCached()called the oracle repeatedly. For affected reserves, oracle -> Uniswap V3 factorygetPool(token,USDC,42)returnedaddress(0), and token/WETH fallback pools returnedliquidity() == 0. - Helper -> vault
redeemProportional(uint256,address,address)with shares420300912285322153666116992, receiver and owner both the helper. - Vault -> helper transferred proportional reserves. The ABI output array was
[100000000000, 0, 1906705673147924829529, 3838361764166304302973, 103063238187773015912541, 1347499541, 308710203688], corresponding to100,000USDC,0tUSDC,1,906.705673147924829529PUSDCHY,3,838.361764166304302973CPT48,103,063.238187773015912541maxUSD,1,347.499541ysUSDC, and308,710.203688REN-USDC-B. - Helper redeemed/withdrew
REN-USDC-B,maxUSD,CPT48, andPUSDCHY, causing Morpho/underlying vault calls includingredeem(uint256,address,address),accrueInterest(...), andwithdraw(...). - Morpho pulled back
100,000USDC viatransferFrom(helper, Morpho, 100000000000). - Helper transferred final proceeds to
0x25c08505b6c5eba2d6c5d97c9e9a7f5f58d9a079:413,132.022315USDC,31,174.2923534301231755maxUSD, dust PUSDCHY/CPT48, and1,347.499541ysUSDC.
Financial Impact
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.
Evidence
- Exploit transaction status was successful at Base block
45183967; receipt contained48ERC-20Transferevents. - Initial trace calls show
totalSupply() == 420082292765729913584723andtotalAssets() == 100000000before the flash loan. - The
deposit(100000000000, helper)trace output was420300912285322153666116992shares. Attacker ownership after mint was420300912.285322153666116992 / (420300912.285322153666116992 + 420082.292765729913584723) = 99.9001518113%. oracle_fee42_evidence.jsonrecords thatuniV3fee[USDC][token] == 42for the affected yield tokens at the exploit block, eachfactory.getPool(USDC, token, 42)returned0x0000000000000000000000000000000000000000, and each existing token/WETH fallback pool had zero liquidity.- The setup transaction
0x2df0be7a17bd69a2f732c1396796690240aecdfaf13b0a8f60f49f95a8dbe150called oracle selector0x087e5606(setUniV3fee(address,address,uint24)) withbase=USDC,quote=PUSDCHY, andfee=42at block41007638on 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.jsonconfirms the vault’s negative deltas for PUSDCHY, CPT48, maxUSD, ysUSDC, and REN-USDC-B, and the final receiver’s positive413,132.022315USDC balance change. The script did not auto-classify these asattacker_gainsbecause its default attacker address was the top-level EOA, while the proceeds moved through the helper and final receiver.
Related URLs
- Exploit transaction: https://basescan.org/tx/0x00b949bc3ed3edb58b04faedfbd8eb1db2edceae761382e80fe012919f8d3732
- Victim vault: https://basescan.org/address/0x67b93f6676bd1911c5fae7ffa90fff5f35e14dcd
- Oracle: https://basescan.org/address/0x73b8c192bfc323c3ea224c88219d55dfc319e89f
- Fee-42 setup transaction: https://basescan.org/tx/0x2df0be7a17bd69a2f732c1396796690240aecdfaf13b0a8f60f49f95a8dbe150