Part II - DeFi with Vyper: Building a Complete Vault from scratch

A course to develop professional applications in DeFi using Vyper
From Philosophy to Mathematics
In Chapter 1, we established why Vyper's constraints make sense for DeFi:
- Complexity is not neutral—it's attack surface
- "Boring code" beats clever abstractions
- Explicitness makes invariants visible
- DeFi is accounting before it is code
We argued that if you cannot explain your contract's behavior with equations, you should not deploy it.
Now we face the critical question: What equations define a Vault?
This chapter strips a DeFi Vault down to its mathematical core. No ERC-20 compliance. No fees. No strategies. No governance. Just the pure accounting relationship that must hold for a Vault to be secure.
By the end of this chapter, you will understand: - The three state variables that define every Vault - The single equation that governs deposits and withdrawals - The fundamental invariant that must never break - Why most Vault exploits are violations of basic math
This is not about Vyper yet. This is about understanding what we're building before we write a single line of code.
If you internalize this chapter, you will be able to read any Vault—in any language—and immediately identify: - Whether the accounting is correct - Where rounding attacks can occur - Which state transitions can break invariants
Let's begin.
What a DeFi Vault Really Is: Assets, Shares, and State Transitions
TL;DR
- A Vault is not a smart contract—it is a state machine implementing an accounting relationship
- The entire system reduces to three numbers:
totalAssets,totalShares,balances - One equation governs everything: proportional ownership must be preserved
- If you cannot write the invariants mathematically, you should not deploy the Vault
- Every historical Vault exploit violated one of three failure modes: accounting, custody, or access
1. The Vault as a Black Box
Let's start with the simplest possible mental model:
Questions we must answer:
- If I deposit 100 USDC, how many vUSDC do I receive?
- If I burn 50 vUSDC, how much USDC can I withdraw?
- If someone else deposits after me, does my claim change?
- What happens when the Vault earns yield?
- Can the relationship between USDC and vUSDC ever break?
These are not implementation details.
They are the core design of every DeFi Vault.
If you cannot answer these questions with equations, you cannot secure a Vault.
2. The Mutual Fund Analogy: How Traditional Finance Solves This
Before DeFi, traditional finance already solved the "shared custody" problem. Let's understand how.
2.1 The Investment Fund Problem
You have an investment fund that manages $1,000,000 in assets.
Three investors contribute to this fund:
- Investor A deposits $400,000
- Investor B deposits $300,000
- Investor C deposits $300,000
Each investor receives fund shares representing their ownership proportion.
The fundamental accounting rule:
Now, the fund earns returns—let's say it grows to $1,200,000 through successful investments.
Question: How do we distribute this gain fairly?
Answer: Proportional distribution
- Investor A: 40% of shares → 40% of $1.2M = $480,000 claimable
- Investor B: 30% of shares → 30% of $1.2M = $360,000 claimable
- Investor C: 30% of shares → 30% of $1.2M = $360,000 claimable
Each investor's claim grew by 20% because they owned a fixed proportion of the fund.
Critical insight: The investors don't own "fixed dollars." They own "fixed percentages."
2.2 Why Shares, Not Direct Balances?
Consider the alternative: tracking each investor's dollar balance directly.
Naive approach:
When the fund earns $200,000 in yield, how do you distribute it?
You'd need to:
- Calculate each investor's proportion
- Apply that proportion to the yield
- Update each balance individually
# For each investor:
proportion = balance / total
yield_share = total_yield * proportion
balance += yield_share
This requires iterating over all investors every time yield is earned.
With shares, yield is automatic:
# No iteration needed. Yield is implicit in the exchange rate.
share_price = total_assets / total_shares
Or in mathematical notation:
When total_assets increases (from yield), the share price increases. Every shareholder's claim increases proportionally, without any code execution.
2.3 Translation to DeFi
Investment Fund → Vault Contract
Dollars under mgmt → Assets (USDC)
Fund shares → Shares (vUSDC)
Investment returns → Protocol yield
Share price (NAV) → totalAssets / totalShares
A DeFi Vault is the programmable, trustless, transparent version of a mutual fund.
The key insight:
Your claim is not a fixed number of assets. It is a fixed proportion of the total.
This is why Vaults use shares, not direct asset accounting.
2.4 Why This Design Matters
Gas efficiency: Yield distribution requires zero transactions. The share price simply updates.
Scalability: Works for 10 users or 10,000 users with identical efficiency.
Composability: Shares are ERC-20 tokens. They can be transferred, used as collateral, or deposited into other Vaults.
Predictability: The math is simple and auditable. Every participant can independently verify their claim.
3. The Minimal Vault State
A Vault is defined by exactly three pieces of state:
# State variable 1: Total underlying assets held by the Vault
totalAssets: uint256
# State variable 2: Total share tokens issued
totalShares: uint256
# State variable 3: Who owns which shares
balances: HashMap[address, uint256]
That's it.
Every other piece of information is derived from these three numbers:
- Your claimable assets?
balances[you] × (totalAssets / totalShares) - The share price?
totalAssets / totalShares - The Vault's solvency?
sum(all balances) == totalShares
3.1 Why This Matters (Minimalism as Security)
Every additional state variable is a potential inconsistency point.
Consider a "naive" Vault that tracks:
totalAssets: uint256
totalShares: uint256
balances: HashMap[address, uint256]
assetBalances: HashMap[address, uint256] # ← DANGER
Now you have two sources of truth for user claims:
- Their share balance
- Their asset balance
What happens if these become inconsistent?
- User deposits 100 assets, receives 100 shares
- A bug causes
assetBalances[user]to update to 110 - User can now claim 110 assets by burning 100 shares
- Vault is insolvent
This is not a hypothetical. Multiple early DeFi protocols failed this way.
Design principle:
The fewer state variables, the fewer invariants to maintain, the fewer ways to break.
4. The Core Equation: Deposits
Now we formalize the deposit operation.
4.1 The Question
A user wants to deposit A assets into a Vault that currently has:
T_assetstotal assetsT_sharestotal shares
How many shares S should they receive?
4.2 The Requirement (Proportional Ownership)
The user should receive a number of shares such that their ownership proportion equals their contribution proportion.
Mathematically:
This states:
- Left side: User's shares / New total shares
- Right side: User's assets / New total assets
These must be equal for proportional ownership.
4.3 Solving for S (Formal Derivation)
Starting from:
Cross-multiply:
Therefore:
This is the fundamental deposit formula.
4.4 Intuition Check
Let's verify this makes sense with concrete numbers:
Scenario:
- Vault has 1,000 USDC (T_assets)
- Vault has issued 1,000 vUSDC (T_shares)
- Alice deposits 100 USDC (A)
Calculation:
After deposit: - Total assets: 1,100 - Total shares: 1,100 - Alice owns: 100 shares
Alice's ownership:
4.5 The Bootstrap Case (First Deposit)
What if \(T_{assets} = 0\) and \(T_{shares} = 0\)?
The formula becomes:
This is a special case. The first depositor defines the initial exchange rate.
Convention: First deposit receives shares 1:1 with assets.
This is not arbitrary. The first depositor bears the risk of setting the initial price, so they get a simple 1:1 rate as compensation.
4.6 Proof of Conservation
Theorem: After a deposit, the sum of all user claims equals total assets.
Proof:
Before deposit:
After deposit of \(A\) assets for \(S\) shares:
Substituting \(S = A \times \frac{T_{shares}}{T_{assets}}\):
The user can claim exactly what they deposited. Conservation holds. \(\blacksquare\)
4.7 Visual State Transition
The below picture summarizes the process depicted above:

4.8 Pseudo-code Implementation
@external
def deposit(assets: uint256) -> uint256:
"""
Deposit assets into the Vault and receive shares.
Formula: shares = assets × (totalShares / totalAssets)
Edge case: First deposit receives shares 1:1
"""
# Input validation
assert assets > 0, "Cannot deposit zero"
# Calculate shares
shares: uint256 = 0
if self.totalAssets == 0:
# Bootstrap case: first depositor
shares = assets
else:
# Standard case: proportional to existing ratio
shares = assets * self.totalShares / self.totalAssets
# Ensure shares are non-zero (rounding protection)
assert shares > 0, "Deposit too small, rounds to zero shares"
# Update state BEFORE external call (Checks-Effects-Interactions)
self.totalAssets += assets
self.totalShares += shares
self.balances[msg.sender] += shares
# Transfer assets from user to Vault
# (Assume ERC20.transferFrom interface)
success: bool = ERC20(ASSET_TOKEN).transferFrom(
msg.sender,
self,
assets
)
assert success, "Asset transfer failed"
# Emit event
log Deposit(msg.sender, assets, shares)
return shares
Key observations:
- State updates before external calls (reentrancy protection)
- Explicit zero-share check (prevents rounding attacks)
- Bootstrap case handled explicitly (no division by zero)
- Events for off-chain tracking
5. The Core Equation: Withdrawals
Now we derive the inverse operation: converting shares back to assets.
5.1 The Question
A user wants to withdraw by burning \(S\) shares from a Vault that currently has:
- \(T_{\text{assets}}\) total assets
- \(T_{\text{shares}}\) total shares
How many assets \(A\) should they receive?
5.2 The Requirement (Proportional Claim)
The user should receive assets proportional to their share of the total supply.
Mathematically:
Which gives us:
5.3 Solving for A
From the proportion:
Cross-multiply:
Therefore:
This is the fundamental withdrawal formula.
5.4 Symmetry with Deposits
Notice the beautiful symmetry:
Deposit: $$ S = A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}} $$
Withdraw: $$ A = S \cdot \frac{T_{\text{assets}}}{T_{\text{shares}}} $$
The withdrawal formula is the algebraic inverse of the deposit formula.
5.5 Proof: Deposit → Withdraw = Identity
Theorem: If you deposit \(A\) assets and immediately withdraw all received shares, you get back exactly \(A\) assets (ignoring rounding).
Proof:
Starting with \(A\) assets deposited:
After deposit, the Vault state becomes:
- New total assets: \(T_{\text{assets}}' = T_{\text{assets}} + A\)
- New total shares: \(T_{\text{shares}}' = T_{\text{shares}} + S_{\text{received}}\)
Now withdraw \(S_{\text{received}}\) shares:
Substitute the new totals:
Substitute \(S_{\text{received}} = A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}}\):
Simplify the denominator:
Substitute back:
Result: The user gets back exactly what they deposited. The operations are perfect inverses.
5.6 Intuition Check
Let's verify with concrete numbers:
Scenario:
- Vault has 1,100 USDC (\(T_{\text{assets}}\))
- Vault has issued 1,100 vUSDC (\(T_{\text{shares}}\))
- Alice owns 100 vUSDC and wants to withdraw
Calculation:
After withdrawal:
- Total assets: 1,000 USDC
- Total shares: 1,000 vUSDC
- Alice owns: 0 vUSDC
Verification: Alice withdrew exactly what her shares entitled her to claim. ✓
5.7 Visual State Transition
The following image summarizes the withdrawl process:

5.8 Pseudo-code Implementation
@external
def withdraw(shares: uint256) -> uint256:
"""
Burn shares and withdraw proportional assets.
Formula: assets = shares × (totalAssets / totalShares)
"""
# Input validation
assert shares > 0, "Cannot withdraw zero"
assert self.balances[msg.sender] >= shares, "Insufficient shares"
# Calculate assets to return
assets: uint256 = shares * self.totalAssets / self.totalShares
# Ensure assets are non-zero (rounding protection)
assert assets > 0, "Withdrawal too small, rounds to zero assets"
# Update state BEFORE external call (Checks-Effects-Interactions)
self.totalAssets -= assets
self.totalShares -= shares
self.balances[msg.sender] -= shares
# Transfer assets from Vault to user
success: bool = ERC20(ASSET_TOKEN).transfer(
msg.sender,
assets
)
assert success, "Asset transfer failed"
# Emit event
log Withdraw(msg.sender, assets, shares)
return assets
5.9 Key Observations
- State updates before external calls (prevents reentrancy)
- Balance check before calculation (prevents underflow)
- Zero-asset check (prevents rounding to zero)
- Explicit error messages (helps with debugging)
6. The Fundamental Invariant
Now we formalize the single most important property of a Vault.
6.1 Informal Statement
At any point in time, the sum of all user claims must equal the total assets held by the Vault.
No more, no less.
6.2 Formal Statement
Let \(B_i\) denote the share balance of user \(i\), and let \(n\) be the total number of users.
The Fundamental Invariant:
Or equivalently:
Interpretation: - Left side: Sum of all individual claims (in assets) - Right side: Total assets held by Vault - These must always be equal
6.3 Proof That Invariant Holds After Deposit
Given: Invariant holds before deposit:
Action: User \(j\) deposits \(A\) assets, receives \(S\) shares where:
New state: - \(T_{\text{assets}}' = T_{\text{assets}} + A\) - \(T_{\text{shares}}' = T_{\text{shares}} + S\) - \(B_j' = B_j + S\)
Need to prove:
Proof:
By assumption (invariant held before):
Conclusion: Deposits preserve the invariant.
6.4 Proof That Invariant Holds After Withdrawal
Given: Invariant holds before withdrawal:
Action: User \(j\) withdraws \(S\) shares, receives \(A\) assets where:
New state: - \(T_{\text{assets}}' = T_{\text{assets}} - A\) - \(T_{\text{shares}}' = T_{\text{shares}} - S\) - \(B_j' = B_j - S\)
Need to prove:
Proof:
By assumption:
Conclusion: Withdrawals preserve the invariant.
6.5 When Does the Invariant Break?
The invariant can only break if:
- State updates are inconsistent
- Example: Update
totalAssetsbut nottotalShares -
Example: Update
balances[user]but nottotalShares -
External state changes bypass the Vault
- Example: Someone sends tokens directly to the Vault address
-
Example: Token implements transfer fees (balance != transfer amount)
-
Integer overflow/underflow
- Example:
totalShareswraps around from overflow -
Example: Subtraction causes underflow
-
Reentrancy exploits
- Example: Attacker calls
withdraw()from within adeposit()callback -
State becomes inconsistent mid-execution
-
Rounding errors accumulate
- Example: Multiple small deposits round shares to zero
- Assets increase, but shares don't
6.6 Defensive Measures
To protect the invariant:
# 1. Use SafeMath or Vyper's built-in overflow protection
totalShares += shares # Will revert on overflow
# 2. Checks-Effects-Interactions pattern
# Update state BEFORE external calls
self.totalAssets += assets
self.totalShares += shares
# Now safe to call external token
# 3. Minimum deposit/withdrawal amounts
assert shares >= MIN_SHARES, "Too small"
assert assets >= MIN_ASSETS, "Too small"
# 4. Reentrancy guards
@nonreentrant("lock")
def deposit(...):
...
# 5. Regular invariant checks (in tests)
def check_invariant():
sum_balances = sum(balances[user] for user in all_users)
assert sum_balances == totalShares, "INVARIANT BROKEN"
6.7 The Invariant as a Security Primitive
Think of the invariant as a conservation law like energy conservation in physics.
If total energy changes without explanation, your physics is wrong.
If total claims ≠ total assets, your Vault is wrong.
Every test should verify this invariant.
Every audit should check if this invariant can break.
7. The Rounding Problem (Deep Dive)
Now we confront the most subtle and dangerous aspect of Vault accounting: integer division.
7.1 The Source of the Problem
In the EVM, all arithmetic is integer arithmetic. There are no floating-point numbers.
When you compute:
The division \(\frac{T_{\text{shares}}}{T_{\text{assets}}}\) rounds down to the nearest integer.
Example:
The user deposits 100 assets but receives only 99 shares. Where did the 1 share go?
It vanishes. This is precision loss.
7.2 The Donation Attack (Share Price Inflation)
This is the most famous Vault exploit pattern.
Setup:
- Empty Vault (totalAssets = 0, totalShares = 0)
- Attacker is the first depositor
Attack steps:
-
Attacker deposits 1 wei
-
Attacker directly transfers 1,000,000 tokens to Vault
-
Victim deposits 1,000 tokens
Result: Victim deposited 1,000 tokens and received 0 shares.
The attacker now withdraws their 1 share:
Attacker profits 1,000 tokens (minus the initial 1 wei deposit).
7.3 Mathematical Analysis
The attack works because:
If \(\frac{\text{assets}}{T_{\text{assets}}} < \frac{1}{T_{\text{shares}}}\), then:
Condition for zero shares:
The attacker maximizes \(\frac{T_{\text{assets}}}{T_{\text{shares}}}\) by:
- Keeping \(T_{\text{shares}}\) small (e.g., 1)
- Inflating \(T_{\text{assets}}\) large (via direct transfer)
7.4 The Dust Attack (Balance Drain)
A variation where attacker deposits many tiny amounts:
Setup:
- Vault has totalAssets = 1,000,000, totalShares = 1,000,000
Attack steps:
Attacker deposits 1 wei repeatedly:
shares = 1 * 1_000_000 / 1_000_000 = 1 # First deposit: OK
# After 1 wei deposit:
totalAssets = 1_000_001
totalShares = 1_000_001
shares = 1 * 1_000_001 / 1_000_001 = 1 # Second deposit: OK
But if done strategically with withdrawals in between, rounding errors can accumulate:
# Deposit tiny amount
shares = 1 * T_shares / T_assets = 0 # Rounds to zero
# Assets increased, shares didn't
# Repeat many times
# Eventually: totalAssets >> totalShares
Now the attacker withdraws at inflated price.
7.5 Real-World Examples
Yearn Finance v1 (2020)
- Early versions vulnerable to share price manipulation
- Mitigated by requiring minimum initial deposit
ERC-4626 "Inflation Attack" (2022)
- Security researchers demonstrated the donation attack on multiple implementations
- Led to widespread adoption of defenses
Various forks (2021-2023)
- Many Vault forks copied code without understanding rounding implications
- Repeated the same vulnerabilities
7.6 Defense 1: Minimum Shares
Require that every deposit must mint at least MIN_SHARES:
MIN_SHARES: constant(uint256) = 1000
@external
def deposit(assets: uint256) -> uint256:
shares: uint256 = assets * self.totalShares / self.totalAssets
# Enforce minimum
assert shares >= MIN_SHARES, "Deposit too small"
# ... rest of logic
Why this works:
To force shares to round to zero, attacker needs:
With MIN_SHARES = 1000, the attacker must:
If attacker inflates \(T_{\text{assets}}\) to \(10^{18}\), victim needs to deposit:
That's 1,000 tokens (at 18 decimals). Most victims deposit more than this.
7.7 Defense 2: Virtual Shares (ERC-4626 Approach)
Use virtual offsets to prevent the first-depositor advantage:
OFFSET: constant(uint256) = 10**9 # Virtual offset
@external
def deposit(assets: uint256) -> uint256:
# Add virtual offset to both numerator and denominator
shares: uint256 = assets * (self.totalShares + OFFSET) / (self.totalAssets + OFFSET)
# ... rest of logic
Why this works:
Even when \(T_{\text{shares}} = 0\), we compute:
First depositor gets 1:1 ratio, no inflation possible.
7.8 Defense 3: Dead Shares (Uniswap V2 Approach)
Burn some shares on first deposit to prevent manipulation:
MINIMUM_LIQUIDITY: constant(uint256) = 1000
@external
def deposit(assets: uint256) -> uint256:
if self.totalShares == 0:
# First deposit: mint shares, but burn some
shares = assets
dead_shares = MINIMUM_LIQUIDITY
self.totalShares = shares
self.balances[DEAD_ADDRESS] = dead_shares
self.balances[msg.sender] = shares - dead_shares
else:
# Normal deposit
shares = assets * self.totalShares / self.totalAssets
# ... rest
Why this works:
By burning shares, we ensure \(T_{\text{shares}} \geq 1000\) always, making inflation attacks expensive.
7.9 Comparison of Defenses
| Defense | Pros | Cons | Gas Cost |
|---|---|---|---|
| Minimum shares | Simple, clear | May exclude small depositors | Low |
| Virtual shares | Elegant, no user impact | More complex logic | Medium |
| Dead shares | Battle-tested (Uniswap) | Permanently locks value | Low |
Recommendation for this course:
We will use Minimum Shares because:
- Explicit (Vyper philosophy)
- Easy to understand
- Low complexity
- Adequate security for most use cases
7.10 The Rounding Invariant
We can formalize rounding safety as an invariant:
Rounding Invariant:
For any deposit of \(A\) assets:
Testing strategy:
def test_rounding_invariant():
# For all possible Vault states
for total_assets in [1, 1000, 10**18]:
for total_shares in [1, 1000, 10**18]:
# Minimum deposit that satisfies invariant
min_deposit = (MIN_SHARES * total_assets) / total_shares
# Verify deposits >= min_deposit succeed
assert vault.deposit(min_deposit) >= MIN_SHARES
# Verify deposits < min_deposit fail
with pytest.raises(Exception):
vault.deposit(min_deposit - 1)
This is adversarial testing—we search for the boundary conditions where the math breaks.
8. State Transitions as a Diagram
A Vault is a finite state machine. Every action moves the Vault from one state to another in a predictable way.
8.1 The State Machine View
flowchart TB
State["VAULT STATE<br/>─────────────────────<br/>totalAssets: uint256<br/>totalShares: uint256<br/>balances: mapping"]
State --> Deposit[DEPOSIT]
State --> Withdraw[WITHDRAW]
Deposit --> DepositInput["Input: assets<br/>Output: shares"]
Withdraw --> WithdrawInput["Input: shares<br/>Output: assets"]
DepositInput --> DepositSteps["1. Calculate shares<br/>S = A × Ts/Ta<br/>2. Update totalAssets += A<br/>3. Update totalShares += S<br/>4. Update balances[user]<br/>5. Transfer assets IN<br/>6. Emit Deposit event"]
WithdrawInput --> WithdrawSteps["1. Calculate assets<br/>A = S × Ta/Ts<br/>2. Update totalAssets -= A<br/>3. Update totalShares -= S<br/>4. Update balances[user]<br/>5. Transfer assets OUT<br/>6. Emit Withdraw event"]
style State fill:#2d3748,stroke:#4a5568,color:#fff
style Deposit fill:#2563eb,stroke:#1e40af,color:#fff
style Withdraw fill:#dc2626,stroke:#991b1b,color:#fff
style DepositSteps fill:#1e3a5f,stroke:#2563eb,color:#fff
style WithdrawSteps fill:#4a1d1d,stroke:#dc2626,color:#fff
8.2 The Critical Ordering: Checks-Effects-Interactions
Every state transition must follow this pattern:
flowchart TD
Checks["`**CHECKS**
─────────────────────────────
• Validate inputs (amount > 0)
• Verify permissions (user has shares)
• Check invariants (no overflow)`"]
Effects["`**EFFECTS**
─────────────────────────────
• Update totalAssets
• Update totalShares
• Update balances
• All state changes complete`"]
Interactions["`**INTERACTIONS**
─────────────────────────────
• Call external contracts (token transfer)
• Emit events
• No more state changes`"]
Checks ==> Effects
Effects ==> Interactions
style Checks fill:#1e3a5f,stroke:#2563eb,color:#fff,stroke-width:3px
style Effects fill:#065f46,stroke:#10b981,color:#fff,stroke-width:3px
style Interactions fill:#7c2d12,stroke:#f97316,color:#fff,stroke-width:3px
Why this order matters:
If you call external contracts before updating state, you open a reentrancy vulnerability:
# VULNERABLE CODE (DON'T DO THIS):
@external
def withdraw(shares: uint256):
assets = shares * totalAssets / totalShares
# ❌ External call BEFORE state update
ERC20(token).transfer(msg.sender, assets)
# Attacker can re-enter here and withdraw again!
self.totalAssets -= assets
self.totalShares -= shares
Correct version:
# SAFE CODE:
@external
def withdraw(shares: uint256):
assets = shares * totalAssets / totalShares
# ✅ Update state FIRST
self.totalAssets -= assets
self.totalShares -= shares
self.balances[msg.sender] -= shares
# Now safe to interact
ERC20(token).transfer(msg.sender, assets)
8.3 State Transition Table
Here's how the Vault state evolves across operations:
| Operation | totalAssets | totalShares | balances[user] | External Effect |
|---|---|---|---|---|
| Initial | 0 | 0 | 0 | - |
| deposit(100) | +100 | +100 | +100 | Transfer 100 tokens IN |
| deposit(50) | +50 | +50 | +50 | Transfer 50 tokens IN |
| Yield accrues | +30 | (unchanged) | (unchanged) | - |
| withdraw(75) | -90 | -75 | -75 | Transfer 90 tokens OUT |
| Final | 90 | 75 | 75 | - |
Key observation: Yield doesn't change shares. It only increases totalAssets, which automatically increases the claim value of all shares.
9. What a Vault Is NOT
To understand what a Vault is, we must be clear about what it is not.
9.1 A Vault Is Not a Token
Common confusion:
- "I'm deploying a Vault token"
- "Users hold Vault tokens"
Correction:
- A Vault is a contract that manages tokens
- Users receive share tokens (e.g., vUSDC)
- The share token is separate from the Vault logic
Analogy:
- Vault = The bank building
- Shares = Your account number and balance
- Assets = The dollars in the vault
You don't "hold the bank." You hold a claim on funds stored in the bank.
9.2 A Vault Is Not a Lending Protocol
What lending protocols do:
- Users deposit assets
- Other users borrow against collateral
- Interest rates depend on utilization
- Fractional reserve (total borrows < total deposits)
What Vaults do:
- Users deposit assets
- No one borrows (assets stay in Vault or strategies)
- Full reserve (every share is backed by proportional assets)
- Yield comes from external sources (strategies), not borrowers
Key difference:
9.3 A Vault Is Not a DEX
What DEXs do:
- Hold multiple token types (e.g., ETH + USDC)
- Users swap between tokens
- Price determined by pool ratios
- Liquidity providers earn from trading fees
What Vaults do:
- Hold one token type (the underlying asset)
- No swapping between tokens
- Share price determined by
totalAssets / totalShares - Users earn from strategy yield, not trading fees
9.4 A Vault Is Not a Bank
What banks do:
- Fractional reserve (lend out 90% of deposits)
- Create money through lending
- Regulated and insured
- Can freeze accounts
What Vaults do:
- Full reserve (or close to it, minus active strategy allocations)
- No money creation
- Permissionless (anyone can deposit/withdraw)
- Code is law (no account freezing, unless explicitly programmed)
9.5 What a Vault Actually Is
A Vault is:
A custody system with programmable yield allocation, where users hold proportional claims on a shared asset pool.
That's it.
Everything else—governance, fees, strategies, multi-asset support—is built on top of this core accounting primitive.
10. The Three Failure Modes
Every Vault exploit in history falls into one of three categories.
10.1 Failure Mode 1: Accounting Breaks
Definition: The relationship between assets, shares, and claims becomes inconsistent.
Symptoms:
- \(\sum \text{balances} \neq \text{totalShares}\)
- User claims exceed available assets
- Rounding errors accumulate
- Share price calculation is wrong
Example vulnerabilities:
# Bug 1: State updated in wrong order
self.balances[user] += shares
# ← If this reverts, totalShares is inconsistent
self.totalShares += shares
# Bug 2: Calculation uses stale values
assets = shares * self.totalAssets / self.totalShares
self.totalAssets -= assets # ← But used old value!
# Bug 3: Missing update
self.totalAssets += deposit_amount
# ← Forgot to update totalShares!
Real example: Pickle Finance (2021)
Pickle's "jar" contract had a bug in the getRatio() function:
function getRatio() public view returns (uint256) {
// Intended: (balance + strategy) / totalSupply
// Actual bug: Used wrong balance variable
return balance() / totalSupply(); // ← Missing strategy balance!
}
Users could deposit, the contract would invest in a strategy, but the ratio calculation didn't account for strategy holdings. This allowed attackers to drain funds.
Lesson: Every state variable in your accounting must be part of a consistent update.
10.2 Failure Mode 2: Custody Breaks
Definition: Assets disappear from the Vault without proper accounting.
Symptoms:
ERC20(token).balanceOf(vault)<totalAssets- Tokens stolen via external call
- Assets sent to wrong address
- Tokens stuck in failed transactions
Example vulnerabilities:
# Bug 1: Transfer to wrong address
ERC20(token).transfer(attacker, assets) # ← Copy-paste error
# Bug 2: Unchecked transfer result
ERC20(token).transfer(user, assets)
# ← If this returns false, assets are gone but state updated
# Bug 3: Selfdestruct recipient
send(STRATEGY, assets) # ← If STRATEGY selfdestructs, funds lost
Real example: Rari Capital Fuse (2022)
Rari's Fuse pools had a reentrancy vulnerability in the withdrawal flow:
function withdraw(uint256 amount) external {
// ❌ Called external contract before updating state
token.transfer(msg.sender, amount);
// ← Attacker re-enters here
balance[msg.sender] -= amount;
}
Attacker could withdraw multiple times before balance updated, draining the pool.
Lesson: Follow Checks-Effects-Interactions religiously. Update state before external calls.
10.3 Failure Mode 3: Access Breaks
Definition: Wrong users can execute privileged operations.
Symptoms:
- User withdraws someone else's shares
- Unauthorized strategy changes
- Non-owner can pause/unpause
- Missing access control checks
Example vulnerabilities:
# Bug 1: No ownership check
@external
def withdraw(user: address, shares: uint256):
# ❌ Anyone can withdraw anyone's shares!
assets = shares * self.totalAssets / self.totalShares
self.balances[user] -= shares
ERC20(token).transfer(user, assets)
# Bug 2: Wrong sender check
@external
def withdraw(shares: uint256):
# ❌ Uses tx.origin instead of msg.sender
assert tx.origin == owner # Vulnerable to phishing
# Bug 3: Missing modifier
@external
def emergencyWithdraw():
# ❌ Forgot to add @onlyOwner
send(msg.sender, self.balance)
Real example: Harvest Finance (2020)
Harvest had an economic exploit (flash loan manipulation), but it was enabled by a missing access control:
function rebalance() external {
// ❌ Anyone could call this!
// Attacker manipulated price oracle, then called rebalance
_convertAndDeposit();
}
Combined with oracle manipulation, attacker stole $33M.
Lesson: Every state-changing function needs explicit permission checks.
10.4 Defensive Checklist
For every Vault function, verify:
Accounting:
- [ ] All state variables updated together
- [ ] Updates happen in consistent order
- [ ] No division-by-zero edge cases
- [ ] Rounding favors the Vault
Custody:
- [ ] External calls follow Checks-Effects-Interactions
- [ ] Transfer results are checked
- [ ] Reentrancy guards in place
- [ ] Destination addresses validated
Access:
- [ ]
msg.senderverified (nottx.origin) - [ ] Owner/admin checks present
- [ ] User balance checks before operations
- [ ] No missing modifiers
11. Minimal Viable Vault (Pseudo-code)
Now we can write the complete minimal Vault in ~80 lines of pseudo-code.
This is the simplest possible Vault that is mathematically correct.
# ============================================================================
# MINIMAL VIABLE VAULT
# ============================================================================
# State variables
totalAssets: uint256
totalShares: uint256
balances: HashMap[address, uint256]
# Constants
ASSET: constant(address) = 0x... # Underlying token (e.g., USDC)
MIN_SHARES: constant(uint256) = 1000 # Rounding protection
# ============================================================================
# DEPOSIT
# ============================================================================
@external
def deposit(assets: uint256) -> uint256:
"""
Deposit assets, receive proportional shares.
Formula: shares = assets × (totalShares / totalAssets)
Edge case: First deposit receives 1:1
"""
# ─────────────────────────────────────────────────────────────────────
# CHECKS
# ─────────────────────────────────────────────────────────────────────
assert assets > 0, "Cannot deposit zero"
# Calculate shares
shares: uint256 = 0
if self.totalAssets == 0:
# Bootstrap: first depositor gets 1:1
shares = assets
else:
# Standard: proportional to existing ratio
shares = assets * self.totalShares / self.totalAssets
# Rounding protection
assert shares >= MIN_SHARES, "Deposit too small"
# ─────────────────────────────────────────────────────────────────────
# EFFECTS
# ─────────────────────────────────────────────────────────────────────
self.totalAssets += assets
self.totalShares += shares
self.balances[msg.sender] += shares
# ─────────────────────────────────────────────────────────────────────
# INTERACTIONS
# ─────────────────────────────────────────────────────────────────────
success: bool = ERC20(ASSET).transferFrom(msg.sender, self, assets)
assert success, "Transfer failed"
log Deposit(msg.sender, assets, shares)
return shares
# ============================================================================
# WITHDRAW
# ============================================================================
@external
def withdraw(shares: uint256) -> uint256:
"""
Burn shares, receive proportional assets.
Formula: assets = shares × (totalAssets / totalShares)
"""
# ─────────────────────────────────────────────────────────────────────
# CHECKS
# ─────────────────────────────────────────────────────────────────────
assert shares > 0, "Cannot withdraw zero"
assert self.balances[msg.sender] >= shares, "Insufficient shares"
# Calculate assets to return
assets: uint256 = shares * self.totalAssets / self.totalShares
# Ensure non-zero (rounding protection)
assert assets > 0, "Withdrawal too small"
# ─────────────────────────────────────────────────────────────────────
# EFFECTS
# ─────────────────────────────────────────────────────────────────────
self.totalAssets -= assets
self.totalShares -= shares
self.balances[msg.sender] -= shares
# ─────────────────────────────────────────────────────────────────────
# INTERACTIONS
# ─────────────────────────────────────────────────────────────────────
success: bool = ERC20(ASSET).transfer(msg.sender, assets)
assert success, "Transfer failed"
log Withdraw(msg.sender, assets, shares)
return assets
# ============================================================================
# VIEW FUNCTIONS
# ============================================================================
@view
@external
def balanceOf(user: address) -> uint256:
"""Return share balance of user."""
return self.balances[user]
@view
@external
def convertToShares(assets: uint256) -> uint256:
"""Calculate shares for a given asset amount."""
if self.totalAssets == 0:
return assets
return assets * self.totalShares / self.totalAssets
@view
@external
def convertToAssets(shares: uint256) -> uint256:
"""Calculate assets for a given share amount."""
if self.totalShares == 0:
return 0
return shares * self.totalAssets / self.totalShares
@view
@external
def sharePrice() -> uint256:
"""Return the current price per share (in assets)."""
if self.totalShares == 0:
return 1 * 10**18 # Initial price
return (self.totalAssets * 10**18) / self.totalShares
# ============================================================================
# INVARIANT CHECK (for testing)
# ============================================================================
@view
@internal
def checkInvariant() -> bool:
"""
Verify the fundamental invariant:
sum(all balances) == totalShares
"""
# In production, this would iterate over all users
# For testing, we track this explicitly
return True # Simplified for pseudo-code
That's it.
This is a complete, working Vault. Everything else is extensions: - ERC-20 compliance (for shares) - ERC-4626 compliance (standard interface) - Strategies (yield generation) - Fees (performance, management) - Governance (owner controls) - Pausing (emergency stops)
But the mathematical core is these ~80 lines.
12. Historical Vault Exploits (Pattern Recognition)
Let's analyze real exploits and identify the patterns.
12.1 Case Study 1: Pickle Finance (November 2021)
Loss: $19.7 million
Root cause: Accounting failure (incorrect ratio calculation)
The bug:
function getRatio() public view returns (uint256) {
uint256 balance = want.balanceOf(address(this));
return balance.mul(1e18).div(totalSupply());
}
Problem: want.balanceOf(address(this)) only counted tokens in the contract, not tokens deployed to the strategy.
Exploitation:
- Attacker deposited funds
- Vault invested funds into strategy (reducing
balanceOf) - Ratio calculation undervalued shares
- Attacker withdrew at inflated rate
Pattern: State variable doesn't reflect true position
Fix:
function getRatio() public view returns (uint256) {
uint256 balance = want.balanceOf(address(this));
uint256 strategyBalance = strategy.balanceOf();
uint256 total = balance.add(strategyBalance); // ← Include all positions
return total.mul(1e18).div(totalSupply());
}
12.2 Case Study 2: Yearn v1 (Multiple incidents, 2020)
Loss: Various small amounts
Root cause: Rounding attacks (share inflation)
The bug:
Early Yearn Vaults didn't enforce minimum shares. First depositor could:
- Deposit 1 wei → receive 1 share
- Donate 1,000,000 tokens to Vault
- sharePrice = 1,000,000 / 1 = 1,000,000
- Next depositor needs > 1,000,000 tokens to get 1 share
Pattern: Rounding down to zero shares
Fix:
uint256 constant MIN_SHARES = 1e9; // 1 billion shares minimum
function deposit(uint256 assets) external {
uint256 shares = calculateShares(assets);
require(shares >= MIN_SHARES, "Deposit too small");
// ...
}
Yearn also introduced "dead shares" (permanently lock 1,000 shares on first deposit).
12.3 Case Study 3: Rari Capital Fuse (April 2022)
Loss: $80 million
Root cause: Reentrancy (custody failure)
The bug:
function borrow(uint256 amount) external {
// ❌ External call before state update
underlyingToken.transfer(msg.sender, amount);
// ← Attacker re-enters here through a malicious token
accountBorrows[msg.sender] += amount;
}
Exploitation:
- Attacker created malicious ERC-20 token
- Token's
transferfunction called back into Fuse - Re-entered
borrow()before state updated - Borrowed multiple times from same collateral
Pattern: Checks-Effects-Interactions violated
Fix:
function borrow(uint256 amount) external nonReentrant {
// ✅ State update FIRST
accountBorrows[msg.sender] += amount;
// Then external call
underlyingToken.transfer(msg.sender, amount);
}
12.4 Case Study 4: Harvest Finance (October 2020)
Loss: $33.8 million
Root cause: Economic exploit + missing access control
The bug:
function rebalance() external {
// ❌ No access control!
uint256 price = oracle.getPrice(); // ← Manipulable
_rebalanceBasedOnPrice(price);
}
Exploitation:
- Attacker took flash loan
- Manipulated oracle price via large Curve swap
- Called
rebalance()(which anyone could call) - Vault rebalanced at bad price
- Attacker profited from the manipulation
Pattern: Missing access control + external dependency
Fix:
modifier onlyOwner() {
require(msg.sender == owner, "Not authorized");
_;
}
function rebalance() external onlyOwner {
// Also: Use TWAP oracle instead of spot price
uint256 price = oracle.getTWAP(3600); // 1-hour average
_rebalanceBasedOnPrice(price);
}
12.5 Pattern Summary Table
| Exploit | Failure Mode | Root Cause | Defense |
|---|---|---|---|
| Pickle Finance | Accounting | Incomplete state tracking | Include all positions in calculations |
| Yearn v1 | Accounting | Rounding to zero | Minimum shares enforcement |
| Rari Capital | Custody | Reentrancy | CEI pattern + nonReentrant |
| Harvest Finance | Access | Missing permission | Access control + TWAP oracles |
12.6 The Meta-Pattern
Every exploit exploited a violation of simplicity.
- Pickle: Complex strategy accounting → missed a term
- Yearn: Clever 1:1 initial ratio → exploitable edge case
- Rari: Flexible callback system → reentrancy surface
- Harvest: Open rebalance function → economic attack vector
The lesson:
Complexity doesn't just make code harder to read. It creates holes in your invariants.
This is why Vyper's constraints matter. By removing inheritance, modifiers, and assembly, you're forced to write code where every state change is visible.
13. The Path Forward
We've completed the mathematical foundation. Let's recap and look ahead.
13.1 What We've Established
The Three Core Numbers: $$ \begin{align} &\text{totalAssets} \ &\text{totalShares} \ &\text{balances}[\text{user}] \end{align} $$
The Two Core Equations:
The One Core Invariant:
The Three Failure Modes:
- Accounting breaks
- Custody breaks
- Access breaks
The Defense Strategy:
- Checks-Effects-Interactions
- Minimum shares
- Explicit state updates
- No cleverness
13.2 What We Haven't Covered Yet
We still need to address:
- Tooling: How to set up Vyper development environment
- Implementation: Translating pseudo-code to actual Vyper
- Standards: ERC-20 and ERC-4626 compliance
- Testing: Unit tests, scenario tests, adversarial tests
- Strategies: How Vaults generate yield
- Fees: Management and performance fee mechanisms
- Governance: Owner controls and upgradeability
- Production: Gas optimization and deployment
13.3 The Next Chapters
Chapter 3: Setting Up a Professional DeFi Development Environment
- Vyper installation and tooling
- Testing frameworks (pytest, Brownie, Foundry)
- Repository structure
- Continuous integration
Chapter 4: Vyper from the Inside Out
- Storage layout and gas costs
- Data types and their constraints
- Interfaces and external calls
- Events and logging
Chapter 5: Designing the Vault Accounting Model
- Formalizing the invariants from this chapter
- Writing invariant checks in code
- Adversarial scenario planning
Chapter 6: Implementing the Minimal Vault
- Converting our pseudo-code to Vyper
- Line-by-line explanation
- First working contract
This is where we finally write code.
13.4 How to Use This Chapter
As a reference:
When implementing any Vault feature, return to the equations:
- Adding a fee? How does it affect
totalAssets? - Adding a strategy? How does it change the accounting?
- Adding a withdrawal queue? Does the invariant still hold?
As a testing framework:
Every test should verify:
def test_invariant_after_operation(vault, operation):
# Record initial state
initial_sum = sum(vault.balances(user) for user in users)
initial_total = vault.totalShares()
assert initial_sum == initial_total
# Perform operation
operation()
# Verify invariant still holds
final_sum = sum(vault.balances(user) for user in users)
final_total = vault.totalShares()
assert final_sum == final_total # ← Must always pass
As an audit checklist:
When reviewing Vault code:
- ✓ Are state updates atomic?
- ✓ Is CEI pattern followed?
- ✓ Are shares ≥ MIN_SHARES enforced?
- ✓ Can the invariant break under any conditions?
- ✓ Is every external call checked?
- ✓ Does every function have access control?
13.5 The Philosophy Recap
From Chapter 1, we learned:
"Vyper forces you to write boring code."
From this chapter, we learned:
"A Vault is boring math that must never break."
These align perfectly.
The most secure Vault is one where:
- The math is obvious
- The state is minimal
- The invariants are explicit
- The failure modes are known
Vyper won't make your math correct. But it will make your math visible.
And in DeFi, visibility is security.
Final Exercise Before Chapter 3
Before moving on, test your understanding:
1. Write the invariant check function:
@view
@internal
def checkInvariant() -> bool:
"""
Return True if fundamental invariant holds.
How would you implement this?
"""
# Your code here
2. Identify the bug:
@external
def deposit(assets: uint256) -> uint256:
shares = assets * self.totalShares / self.totalAssets
ERC20(ASSET).transferFrom(msg.sender, self, assets)
self.totalAssets += assets
self.totalShares += shares
return shares
What's wrong? What attack does this enable?
3. Fix the rounding exploit:
# First depositor deposits 1 wei
vault.deposit(1) # Receives 1 share
# Attacker donates 1M tokens directly to Vault
token.transfer(vault.address, 1_000_000)
# Victim deposits 500 tokens
vault.deposit(500) # Receives ??? shares
How many shares does the victim receive? How do you fix this?
If you can answer these three questions, you're ready for Chapter 3.
If not, re-read sections 5-7. The math must be internalized before we write code.
Summary
A DeFi Vault is:
- Mathematically: Three variables and two equations
- Operationally: Deposits, withdrawals, and invariant preservation
- Practically: 80 lines of logic that must be bulletproof
Everything else is commentary.
In Chapter 3, we'll set up the tools to implement this properly.
Next: Chapter 3 - Setting Up a Professional DeFi Development Environment
"If you can't write down the invariant, you can't secure the contract."
Series: The Complete Vyper Vault Implementation Guide
Questions? Want to discuss your specific use case? Reach out—I'm always interested in learning from other builders' experiences.
Juan José Expósito González
Freelance AI Engineer | Blockchain Developer | Python Coach
22 years in oil & gas engineering → AI systems → DeFi protocols
Published: 2026-01-16
Last Updated: 2026-01-16
Reading Time: ~2-2.5 hours (deep reading)
Tags: #Vyper #Ethereum #SmartContracts #DeFi #Mindset