Finding Vault Precision Bugs in Bytecode
Abstract
Tokenized vaults convert between an underlying asset and transferable shares, and the safety of that conversion rests almost entirely on integer rounding. Two well-known defects follow: the first-depositor inflation attack, where an empty or near-empty vault lets an attacker manipulate the share price so later deposits round to zero shares, and rounding-direction errors, where deposit-side or withdraw-side conversions round in the user’s favor and bleed value over many cycles. Both are easy to describe in source and surprisingly slippery to detect in compiled bytecode, because the defining evidence is not what the arithmetic computes but which way it discards a remainder. This article describes how to recover that evidence from bytecode: anchoring on the ERC-4626 interface through function selectors, locating the conversion arithmetic, deciding whether a virtual-offset defense is present, and inferring rounding direction from the instruction-level shape of the division. Throughout, the distinction that matters is between what a standard requires and what a particular implementation does — a gap that source-level intuition tends to paper over and that bytecode analysis is forced to confront directly.
The property is the rounding, not the math
It is tempting to think a vault’s conversion is defined by a formula. A naive ERC-4626 implementation computes a depositor’s shares as
shares = assets * totalSupply / totalAssets
but this formula appears nowhere in EIP-4626. The standard deliberately treats vaults as black boxes and constrains only behavior, not implementation. What it does mandate is rounding: convertToShares and convertToAssets “MUST round down towards 0.” The Security Considerations section goes further, prescribing the direction per use: round down when computing the shares to issue for assets supplied or the assets to release for shares returned, and round up when computing the shares a user must supply for a desired amount of assets, or the assets a user must provide for a desired number of shares. Mapped onto the interface, deposits and redemptions round against the user on the “how much do you get” path, while mint and withdraw round against the user on the “how much must you give” path.
That single design choice — always resolve the fractional remainder in the vault’s favor — is what keeps an attacker from extracting value through repeated conversions. It is a direction, not a value. And direction is exactly the thing that survives compilation in a form you have to work to read. The compiler emits a DIV either way; whether the surrounding instructions nudge the result up or leave it truncated is the whole question, and it is encoded in the shape of the code around the division, not in the division itself.
This is why vault precision analysis cannot be a search for a known-bad expression. It is a search for a mismatch between the rounding a position demands and the rounding the code performs.
Step one: prove it is a vault
Bytecode does not announce that it implements ERC-4626. The interface has to be recognized, and the most reliable anchor is the set of four-byte function selectors the standard defines. A contract that dispatches on deposit(uint256,address) (0x6e553f65), mint(uint256,address) (0x94bf804d), withdraw(uint256,address,address) (0xb460af94), redeem(uint256,address,address) (0xba087652), and totalAssets() (0x01e1d114) is presenting the ERC-4626 surface. Requiring a quorum — say, at least two of these selectors in the dispatch table — keeps a lone collision from misclassifying an unrelated contract.
Selectors do more than classify. They orient the rest of the analysis. Each selector resolves to a code offset where that function begins, which gives the analysis named entry points to reason about: this region is the deposit path, that region is the redeem path. Without source, those labels are the difference between “a division somewhere in the contract” and “a division on the deposit-side conversion, which is required to round down.” The selector table is the map that turns anonymous arithmetic into arithmetic with a job.
When a contract is delivered behind a proxy, the dispatcher may live in one place and the logic in another, and selector recognition has to follow the delegate relationship to the implementation that actually holds the conversion code. Resolving that link is its own problem, but it is a prerequisite: analyze the proxy shell alone and the vault’s arithmetic is simply not there to see.
Step two: locate the conversion arithmetic
With the vault confirmed and its entry points labeled, the next task is to find the conversions themselves. Share math has a recognizable structure: a user-supplied amount multiplied by one accounting quantity and divided by another — (amount * supplyTerm) / assetTerm or its inverse. At the instruction level this is a MUL feeding a DIV, with the operands traceable through the stack back to a call argument on one side and storage reads on the other.
Data-flow tracking over the intermediate representation connects those dots. The amount originates as CALLDATALOAD; the supply and asset terms originate as SLOADs of the accounting slots. When a multiply-then-divide consumes one calldata-derived value and two storage-derived values inside a function reached through a vault selector, it is overwhelmingly likely to be a conversion, and it is worth reporting its location and the path that produced it.
Two refinements make this precise rather than merely suggestive. First, the order of operations matters independently of rounding: a division that happens before a multiplication on the same value path throws away precision the multiplication can never restore, and that ordering is visible directly as a DIV whose result flows into a later MUL. Second, the count and placement of conversions tell you which paths the contract actually protects — a vault that guards previewDeposit but computes deposit through a second, unguarded path has a hole that only path-sensitive tracking will find.
Step three: is the inflation defense present?
The first-depositor inflation attack works on a vault whose share price can be moved by an outsider before honest deposits arrive. The mechanism is well documented: become the first depositor for a negligible amount, then transfer a large quantity of the underlying directly to the vault address without going through deposit. The direct transfer inflates totalAssets without minting shares, so the next depositor’s assets * totalSupply / totalAssets floors to zero — they receive nothing while their assets join the pool the attacker’s single share can redeem.
The community converged on a defense, and OpenZeppelin’s ERC-4626 implementation made it the default in v4.9.0 (released May 2023): a virtual offset. The conversions are computed as if the vault always held a small phantom balance of shares and assets — concretely, by adding 10 ** _decimalsOffset() to the supply term and 1 to the asset term inside the mulDiv. The phantom shares anchor the price near 1:1 when the vault is empty, so a donation has to be enormous, relative to that offset, to round an honest deposit to zero. The attack does not become impossible; it becomes uneconomic, which for a precision exploit is the same thing.
For bytecode analysis this turns into a presence-or-absence question with a confidence consequence. A vault whose conversion adds constant offsets to both terms of the division carries the defense; one whose conversion divides the bare product does not. Recognizing the OpenZeppelin shape — the +1 and +10**offset constants threaded into the mulDiv — lets the analysis down-rank a finding to low severity, because the standard mitigation is demonstrably compiled in. The honest caveat is symmetric: a custom offset that does not match the recognized constant pattern can read as undefended, and a governance-seeded vault that opens only after a large initial deposit may be economically safe despite presenting the naive shape. Both are false positives the analysis should acknowledge rather than hide.
A note on attribution, because it matters for not overstating findings: “dead shares” (burning the first tranche of shares to a null address) and minimum-initial-deposit requirements are real, independent mitigations, but they are not what OpenZeppelin chose, and they compile to different patterns. Recognizing one defense is not the same as ruling out the others, and a vault that uses a non-offset mitigation should not be reported with the same confidence as one with no mitigation at all.
Step four: recover the rounding direction
The subtler defect is a conversion that is present, well-formed, and inflation-protected, but rounds the wrong way. If a deposit-side conversion rounds up, the depositor receives slightly more shares than they paid for; if a withdraw-side conversion rounds up, the redeemer receives slightly more assets than they should. Each event is dust. Automated across thousands of deposit/redeem cycles in a block, dust compounds into a drain.
Recovering direction is an instruction-pattern problem. Plain integer division in the EVM truncates toward zero — it rounds down. Rounding up has to be built, and it leaves fingerprints: the classic (a + b - 1) / b, or a mulDiv-with-ceiling helper that adds a correction when the modulus is non-zero. Detecting whether a given conversion’s DIV is bare or wrapped in one of these ceiling idioms recovers the direction the code actually rounds.
Direction alone is not a verdict; it has to be checked against the side of the conversion, which is where the selector map pays off again. The judgment is a comparison:
flowchart TD
classDef process fill:#1a2233,stroke:#7ea8d4,stroke-width:2px,color:#c0d8f0
classDef data fill:#332a1a,stroke:#d4b870,stroke-width:2px,color:#f0e0c0
classDef attack fill:#331a1a,stroke:#d97777,stroke-width:2px,color:#f0c0c0
classDef success fill:#1a331a,stroke:#a8c686,stroke-width:2px,color:#c8e8b0
SEL["Selector map:<br/>which side is this?"]:::process
DIR["Rounding idiom:<br/>bare DIV vs ceil-division"]:::data
Q{"direction matches<br/>the required side?"}:::process
OK["Conforms to ERC-4626"]:::success
BAD["Rounds toward the user<br/>extractable dust"]:::attack
SEL --> Q
DIR --> Q
Q -->|"yes"| OK
Q -->|"no"| BAD
A deposit-side conversion that rounds down conforms; the same conversion rounding up favors the user and is a finding. The required direction comes from the standard; the observed direction comes from the instruction pattern; the side comes from the selector that reached the code. Only when all three are in hand can the analysis say a vault rounds against itself.
What stays out of reach
Three limits are worth stating plainly, because a tool that hides them is harder to trust than one that names them.
Custom rounding through inline assembly or an unrecognized math library can implement either direction in a shape the pattern matcher does not know, producing false negatives. Cumulative precision loss that only manifests across many transactions — each individually correct — is invisible to single-execution analysis. And the economic question that ultimately decides whether an inflation finding is exploitable depends on deployment context: a vault seeded by governance before it opens to the public is the same bytecode as a vulnerable one, distinguishable only by facts that are not in the code. Each of these is a reason to report a vault precision finding as a prioritized lead for a human to confirm, not as a settled verdict.
The thread running through all four steps is the same one the introduction started with: in vault accounting, the security property is a direction, and a direction is a thing you have to reconstruct, not read off. The standard tells you which way each path must round; the bytecode tells you which way it does. The work is making those two statements comparable when one of them was compiled away.
References
- EIP-4626: Tokenized Vault Standard — Security Considerations
- OpenZeppelin docs — ERC-4626: the inflation attack and the virtual-offset defense
- OpenZeppelin Contracts —
ERC4626.sol(_decimalsOffset, virtual-offset conversions) - OpenZeppelin Contracts v4.9.0 release — inflation-attack mitigation introduced
- Solidity documentation — integer division truncates toward zero
- CWE-682: Incorrect Calculation