I spent last weekend tearing apart a fork of Yearn's vault that had been quietly deployed on Base. The team claimed it was a straight clone of the tried-and-true yVault pattern. The transaction logs from its first hundred deposits told a different story.
Compile the silence, let the logs speak. The stack is honest, the operator is not.
## Hook The anomaly hit me at block 15,237,842. A deposit of 100 ETH into 0x9f2e returned 99.987 share tokens. That 0.013% slippage flagged my inner heuristic. I traced the binary decay in the precision loss to find a single masked operand in the convertToShares function.
## Context The protocol, 'VaultX', marketed itself as a permissionless yield optimizer. It wrapped ERC-4626 standard vaults into a single, auto-compounding pool. The architecture was simple: users deposit an asset, receive shares, and the VaultX controller rebalances across underlying strategies.
The standard is well-audited. OpenZeppelin’s implementation has been battle-tested for two years. But VaultX didn’t use OpenZeppelin’s library. They rewrote it from scratch, claiming it was for optimization.
My index finger itched the moment I read that line in their docs.
## Core ### Code dissection
The vulnerability lives in the _convertToShares function in /contracts/WrappedVault.sol:
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256 shares) {
uint256 _totalSupply = totalSupply();
if (_totalSupply == 0) {
return assets;
}
uint256 _totalAssets = _getTotalAssets();
if (_totalAssets == 0) {
return assets;
}
shares = assets.mulDiv(_totalSupply, _totalAssets, rounding);
return shares;
}
At line 58, assets.mulDiv calls a custom MulDiv library which has a specific rounding mode passed as the third parameter. The bug is deceptively simple: the WrappedVault's constructor initializes an internal variable roundingDirection as Math.Rounding.Down. But the _convertToShares function here does NOT read that direction; it takes an explicit parameter.
the calling code:
function deposit(uint256 assets, address receiver) public virtual returns (uint256 shares) {
require((shares = _convertToShares(assets, Math.Rounding.Down)) != 0, "ZERO_SHARES");
// ...
}
```
```solidity
function withdraw(uint256 assets, address receiver, address owner) public virtual returns (uint256 shares) {
shares = _convertToShares(assets, Math.Rounding.Up);
// ...
}
The withdraw function rounds UP shares. This is the inverse of what is expected. In a standard vault, withdraw takes an asset amount and burns shares to release that asset. You want to round DOWN here to prevent the protocol from issuing more shares than assets held. Rounding UP inflates the share count needed for a given asset amount, effectively minting more shares to the user during a withdrawal.
Wait—doesn't rounding up in withdraw hurt the user? It over-issues shares. Let's examine the share balance.
If the user has 100 shares and wants to withdraw 100 ETH, with convertToShares(100 ETH) rounding up, say 100 shares becomes 101 shares. The contract burns 101 shares from the user but only sends 100 ETH. The user loses 1 share’s worth of value.
Now run the attack scenario: an early depositor dumps 1 ETH in. _totalSupply = 1,000,000 (initial 1 million shares minted to the deployer). _totalAssets = 1 ETH. Convert 0.001 ETH to shares: shares = 0.001 * 1,000,000 / 1 = 1000 shares. Then they call withdraw(0.001 ETH, ...). Shares burned = 1001 (with rounding up). They receive 0.001 ETH but the share pool loses 1001 shares for only 0.001 ETH. They've burned more shares than they should. But they got their ETH back. The attacker loses 1 share of value? No—the attacker already has those shares. The rounding up in withdraw actually over-estimates the shares needed, so the protocol underestimates the asset amount. The user gets the correct asset but burns extra shares, which then reduces totalSupply, increasing the share price for remaining holders. This is not an exploit that drains the vault. It creates a tiny deflationary pressure.
But the real problem is that the withdraw function is supposed to be ROUND DOWN, not up. The standard spec from ERC-4626 says:
withdraw: execute conversion of assets to shares with rounding down. redeem: execute conversion of shares to assets with rounding up.
VaultX swapped them. The deposit function rounds down (correct), but withdraw rounds up (should be down). This means a user withdrawing a non-zero asset amount will always get slightly fewer assets than if the rounding were correct—except in the case where the share price is exactly 1.
This isn't a liquidity drain. It's a rounding error that accumulates against the withdrawer. Holders who withdraw lose a tiny fraction each time. Over thousands of withdrawals, the vault's total assets artificially increase relative to its intended share price. The vault becomes more efficient at claiming yield, but at the cost of users' precision loss.
Contrarian angle
Everyone looks at the withdraw rounding up as a user-hostile bug. The community’s default response would be to demand a fix and re-audit. But governance is a myth; the bypass reveals the truth.
If the deployer wanted to silently extract value, this rounding direction swap yields a fraction of a basis point per withdrawal. Over $100M in TVL with 100 withdrawals per hour, that's roughly $0.10 per hour in value extracted. In a year, $876. Not exactly a rug pull.
However, what if the attacker is not the deployer, but a sophisticated MEV bot? The discrepancy between deposit and withdraw rounding creates a profitable arbitrage path: frontrun a large user's deposit, then execute your own deposit (which rounds down), wait for their deposit to inflate totalAssets, then withdraw with rounding up, burning slightly more shares than you should at a tiny profit. It’s repeatable at scale.
A bot could run this loop: flash loan 1000 ETH, deposit (1000 ETH -> 1000 shares, rounding down), wait for a victim deposit to push totalAssets up 0.01%, withdraw (1000 shares -> 1000.1 ETH, rounding up). Profit = 0.1 ETH per cycle, minus gas. On L2 Base, gas is cheap. At block times of 2 seconds, that's 12,600 ETH profit per month.
Immutable metadata doesn't lie. The withdraw rounding direction is locked in the immutable bytecode. There is no upgrade path unless the contract’s proxy admin key (0xdead...beef) rotates it.
Takeaway
The vault itself remains solvent. But the rounding asymmetry introduces an exploitable market inefficiency that will be extracted by bots within days of public audit. If the deployer doesn't patch the rounding direction in withdraw, the vault will hemorrhage value slowly, like a slow puncture. The devs will claim it's a feature—"optimizing for the withdrawer's convenience."
Heads buried in the hex, eyes on the horizon. I'd bet the deployer's address 0x9f2e... has already executed this exact MEV bot on another chain. The pattern of swapping rounding directions in convertToShares appears in at least three unverified contracts across Arbitrum and Avalanche.
You can't patch the human greed.