Skip to content

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

Vyper Course Banner

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:

Vault Black Box

Questions we must answer:

  1. If I deposit 100 USDC, how many vUSDC do I receive?
  2. If I burn 50 vUSDC, how much USDC can I withdraw?
  3. If someone else deposits after me, does my claim change?
  4. What happens when the Vault earns yield?
  5. 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:

\[ \text{Total share value} = \text{Total assets under management} \]
\[ \$400k + \$300k + \$300k = \$1{,}000{,}000 \quad \checkmark \]

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:

balances = {
    'A': 400_000,
    'B': 300_000,
    'C': 300_000
}

When the fund earns $200,000 in yield, how do you distribute it?

You'd need to:

  1. Calculate each investor's proportion
  2. Apply that proportion to the yield
  3. 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:

\[ \text{sharePrice} = \frac{\text{totalAssets}}{\text{totalShares}} \]

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_assets total assets
  • T_shares total 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:

\[ \text{User's new ownership proportion} = \text{User's contribution proportion} \]
\[ \frac{S}{T_{shares} + S} = \frac{A}{T_{assets} + A} \]

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:

\[ \frac{S}{T_{shares} + S} = \frac{A}{T_{assets} + A} \]

Cross-multiply:

\[ S \times (T_{assets} + A) = A \times (T_{shares} + S) \]
\[ S \times T_{assets} + S \times A = A \times T_{shares} + A \times S \]
\[ S \times T_{assets} = A \times T_{shares} \]

Therefore:

\[ S = A \times \frac{T_{shares}}{T_{assets}} \]

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:

\[ S = 100 \times \frac{1{,}000}{1{,}000} = 100 \times 1 = 100 \text{ shares} \]

After deposit: - Total assets: 1,100 - Total shares: 1,100 - Alice owns: 100 shares

Alice's ownership:

\[ \frac{100}{1{,}100} = 9.09\% \text{ of shares} \]
\[ \frac{100}{1{,}100} = 9.09\% \text{ of assets} \quad \checkmark \]
\[ \text{Proportions match} \quad \checkmark \]

4.5 The Bootstrap Case (First Deposit)

What if \(T_{assets} = 0\) and \(T_{shares} = 0\)?

The formula becomes:

\[ S = A \times \frac{0}{0} \quad \leftarrow \text{undefined!} \]

This is a special case. The first depositor defines the initial exchange rate.

Convention: First deposit receives shares 1:1 with assets.

if totalAssets == 0:
    shares = assets  # Bootstrap
else:
    shares = assets * totalShares / totalAssets

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:

\[ \sum_i(\text{user claim})_{i} = \sum_i\left(\text{balance}_i \times \frac{T_{assets}}{T_{shares}}\right) \]
\[ = \left(\sum_i \text{balance}_i\right) \times \frac{T_{assets}}{T_{shares}} \]
\[ = T_{shares} \times \frac{T_{assets}}{T_{shares}} = T_{assets} \quad \checkmark \]

After deposit of \(A\) assets for \(S\) shares:

\[ T'_{assets} = T_{assets} + A \]
\[ T'_{shares} = T_{shares} + S \]
\[ \text{User's new claim} = S \times \frac{T'_{assets}}{T'_{shares}} = S \times \frac{T_{assets} + A}{T_{shares} + S} \]

Substituting \(S = A \times \frac{T_{shares}}{T_{assets}}\):

\[ = \frac{A \times T_{shares}}{T_{assets}} \times \frac{T_{assets} + A}{T_{shares} + \frac{A \times T_{shares}}{T_{assets}}} \]
\[ = \frac{A \times T_{shares} \times (T_{assets} + A)}{T_{assets} \times T_{shares} + A \times T_{shares}} \]
\[ = \frac{A \times T_{shares} \times (T_{assets} + A)}{T_{shares} \times (T_{assets} + A)} = A \quad \checkmark \]

The user can claim exactly what they deposited. Conservation holds. \(\blacksquare\)

4.7 Visual State Transition

The below picture summarizes the process depicted above:

Visual State Transition

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:

  1. State updates before external calls (reentrancy protection)
  2. Explicit zero-share check (prevents rounding attacks)
  3. Bootstrap case handled explicitly (no division by zero)
  4. 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:

\[ \frac{\text{Assets received}}{\text{Total assets}} = \frac{\text{Shares burned}}{\text{Total shares}} \]

Which gives us:

\[ \frac{A}{T_{\text{assets}}} = \frac{S}{T_{\text{shares}}} \]

5.3 Solving for A

From the proportion:

\[ \frac{A}{T_{\text{assets}}} = \frac{S}{T_{\text{shares}}} \]

Cross-multiply:

\[ A \cdot T_{\text{shares}} = S \cdot T_{\text{assets}} \]

Therefore:

\[ A = S \cdot \frac{T_{\text{assets}}}{T_{\text{shares}}} \]

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:

\[ S_{\text{received}} = A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}} \]

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:

\[ A_{\text{withdrawn}} = S_{\text{received}} \cdot \frac{T_{\text{assets}}'}{T_{\text{shares}}'} \]

Substitute the new totals:

\[ A_{\text{withdrawn}} = S_{\text{received}} \cdot \frac{T_{\text{assets}} + A}{T_{\text{shares}} + S_{\text{received}}} \]

Substitute \(S_{\text{received}} = A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}}\):

\[ A_{\text{withdrawn}} = \left(A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}}\right) \cdot \frac{T_{\text{assets}} + A}{T_{\text{shares}} + A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}}} \]

Simplify the denominator:

\[ T_{\text{shares}} + A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}} = T_{\text{shares}} \left(1 + \frac{A}{T_{\text{assets}}}\right) = T_{\text{shares}} \cdot \frac{T_{\text{assets}} + A}{T_{\text{assets}}} \]

Substitute back:

\[ A_{\text{withdrawn}} = \left(A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}}\right) \cdot \frac{T_{\text{assets}} + A}{T_{\text{shares}} \cdot \frac{T_{\text{assets}} + A}{T_{\text{assets}}}} \]
\[ = A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}} \cdot \frac{T_{\text{assets}}}{T_{\text{shares}}} \]
\[ = A \quad \blacksquare \]

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:

\[ A = 100 \cdot \frac{1{,}100}{1{,}100} = 100 \text{ USDC} \]

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:

Withdrawal 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

  1. State updates before external calls (prevents reentrancy)
  2. Balance check before calculation (prevents underflow)
  3. Zero-asset check (prevents rounding to zero)
  4. 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:

\[ \sum_{i=1}^{n} \left( B_i \cdot \frac{T_{\text{assets}}}{T_{\text{shares}}} \right) = T_{\text{assets}} \]

Or equivalently:

\[ \sum_{i=1}^{n} B_i = T_{\text{shares}} \]

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:

\[ \sum_{i=1}^{n} B_i = T_{\text{shares}} \]

Action: User \(j\) deposits \(A\) assets, receives \(S\) shares where:

\[ S = A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}} \]

New state: - \(T_{\text{assets}}' = T_{\text{assets}} + A\) - \(T_{\text{shares}}' = T_{\text{shares}} + S\) - \(B_j' = B_j + S\)

Need to prove:

\[ \sum_{i=1}^{n} B_i' = T_{\text{shares}}' \]

Proof:

\[ \sum_{i=1}^{n} B_i' = \sum_{i \neq j} B_i + B_j' \]
\[ = \sum_{i \neq j} B_i + (B_j + S) \]
\[ = \sum_{i=1}^{n} B_i + S \]

By assumption (invariant held before):

\[ = T_{\text{shares}} + S \]
\[ = T_{\text{shares}}' \quad \blacksquare \]

Conclusion: Deposits preserve the invariant.

6.4 Proof That Invariant Holds After Withdrawal

Given: Invariant holds before withdrawal:

\[ \sum_{i=1}^{n} B_i = T_{\text{shares}} \]

Action: User \(j\) withdraws \(S\) shares, receives \(A\) assets where:

\[ A = S \cdot \frac{T_{\text{assets}}}{T_{\text{shares}}} \]

New state: - \(T_{\text{assets}}' = T_{\text{assets}} - A\) - \(T_{\text{shares}}' = T_{\text{shares}} - S\) - \(B_j' = B_j - S\)

Need to prove:

\[ \sum_{i=1}^{n} B_i' = T_{\text{shares}}' \]

Proof:

\[ \sum_{i=1}^{n} B_i' = \sum_{i \neq j} B_i + B_j' \]
\[ = \sum_{i \neq j} B_i + (B_j - S) \]
\[ = \sum_{i=1}^{n} B_i - S \]

By assumption:

\[ = T_{\text{shares}} - S \]
\[ = T_{\text{shares}}' \quad \blacksquare \]

Conclusion: Withdrawals preserve the invariant.

6.5 When Does the Invariant Break?

The invariant can only break if:

  1. State updates are inconsistent
  2. Example: Update totalAssets but not totalShares
  3. Example: Update balances[user] but not totalShares

  4. External state changes bypass the Vault

  5. Example: Someone sends tokens directly to the Vault address
  6. Example: Token implements transfer fees (balance != transfer amount)

  7. Integer overflow/underflow

  8. Example: totalShares wraps around from overflow
  9. Example: Subtraction causes underflow

  10. Reentrancy exploits

  11. Example: Attacker calls withdraw() from within a deposit() callback
  12. State becomes inconsistent mid-execution

  13. Rounding errors accumulate

  14. Example: Multiple small deposits round shares to zero
  15. 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:

\[ S = A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}} \]

The division \(\frac{T_{\text{shares}}}{T_{\text{assets}}}\) rounds down to the nearest integer.

Example:

shares = 100 * 1_000_000 / 1_000_001
# = 100 * 0.999999...
# = 99.999999...
# = 99 (rounds down)

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:

  1. Attacker deposits 1 wei

    totalAssets = 1
    totalShares = 1
    sharePrice = 1 / 1 = 1
    

  2. Attacker directly transfers 1,000,000 tokens to Vault

    totalAssets = 1,000,001  (Vault balance increased)
    totalShares = 1          (No shares minted)
    sharePrice = 1,000,001 / 1 = 1,000,001
    

  3. Victim deposits 1,000 tokens

    shares = 1,000 * 1 / 1,000,001
           = 0.000999...
           = 0 (rounds down!)
    

Result: Victim deposited 1,000 tokens and received 0 shares.

The attacker now withdraws their 1 share:

assets = 1 * 1,001,001 / 1 = 1,001,001

Attacker profits 1,000 tokens (minus the initial 1 wei deposit).

7.3 Mathematical Analysis

The attack works because:

\[ \text{shares} = \text{assets} \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}} \]

If \(\frac{\text{assets}}{T_{\text{assets}}} < \frac{1}{T_{\text{shares}}}\), then:

\[ \text{shares} < 1 \implies \text{shares} = 0 \text{ (after rounding)} \]

Condition for zero shares:

\[ \text{assets} < \frac{T_{\text{assets}}}{T_{\text{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:

\[ \text{assets} < \frac{T_{\text{assets}}}{T_{\text{shares}}} \]

With MIN_SHARES = 1000, the attacker must:

\[ \text{assets} < \frac{T_{\text{assets}}}{1000} \]

If attacker inflates \(T_{\text{assets}}\) to \(10^{18}\), victim needs to deposit:

\[ \text{assets} > \frac{10^{18}}{1000} = 10^{15} \]

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:

\[ \text{shares} = \text{assets} \cdot \frac{0 + 10^9}{0 + 10^9} = \text{assets} \]

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:

\[ \frac{A \cdot T_{\text{shares}}}{T_{\text{assets}}} \geq \text{MIN_SHARES} \]

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:

\[ \text{Lending: } \sum \text{(borrows)} \leq \sum \text{(deposits)} \]
\[ \text{Vault: } \sum \text{(claims)} = \text{totalAssets (exactly)} \]

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.sender verified (not tx.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:

  1. Attacker deposited funds
  2. Vault invested funds into strategy (reducing balanceOf)
  3. Ratio calculation undervalued shares
  4. 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:

  1. Deposit 1 wei → receive 1 share
  2. Donate 1,000,000 tokens to Vault
  3. sharePrice = 1,000,000 / 1 = 1,000,000
  4. 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:

  1. Attacker created malicious ERC-20 token
  2. Token's transfer function called back into Fuse
  3. Re-entered borrow() before state updated
  4. 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:

  1. Attacker took flash loan
  2. Manipulated oracle price via large Curve swap
  3. Called rebalance() (which anyone could call)
  4. Vault rebalanced at bad price
  5. 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:

\[ \text{Deposit: } S = A \cdot \frac{T_{\text{shares}}}{T_{\text{assets}}} \]
\[ \text{Withdraw: } A = S \cdot \frac{T_{\text{assets}}}{T_{\text{shares}}} \]

The One Core Invariant:

\[ \sum_{i=1}^{n} B_i = T_{\text{shares}} \]

The Three Failure Modes:

  1. Accounting breaks
  2. Custody breaks
  3. 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:

  1. ✓ Are state updates atomic?
  2. ✓ Is CEI pattern followed?
  3. ✓ Are shares ≥ MIN_SHARES enforced?
  4. ✓ Can the invariant break under any conditions?
  5. ✓ Is every external call checked?
  6. ✓ 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

Book Free Intro Call

GitHub | LinkedIn | Twitter


Published: 2026-01-16
Last Updated: 2026-01-16
Reading Time: ~2-2.5 hours (deep reading)


Tags: #Vyper #Ethereum #SmartContracts #DeFi #Mindset